Agent stream
Reading an agent session as it happens — one adapter per harness dialect, one small event vocabulary out, plus usage summaries and model context windows.
Why it exists
A delegated task's harness is launched with a prompt on stdin and nothing else agreed. That poverty is what lets the command hold Claude Code, Codex, or a shell script. Streaming the session needs strictly more than that, so the extra knowledge lives here, in one adapter per dialect, and nowhere else.
Two rules everything else depends on:
- The vocabulary is what gets stored, never the harness's own shape. A CLI's JSON output is an
output format, not an API; persisting it would date the history the first time the vendor
reshuffles a field. Adapters translate into a small set of event types, and anything unmapped
becomes
rawrather than being dropped. - A malformed line is never fatal. This parses third-party output on a read path. Garbage in a line costs that line, not the task.
This module is here for the reason that inverts the rest of the package: nothing has forked it yet. Every other module was extracted after two copies had drifted. Putting this one in the shared package now costs nothing and means the second product never writes its own.
The event vocabulary, field by field, is documented in Formats → Agent events.
import {
applyProtocol,
splitCompleteLines,
parseLines,
summarizeUsage,
type AgentProtocol,
} from "@spunto/build/agent-stream"Everything is pure except liveContextWindow / resolveContextWindow, which make an outbound
call to Anthropic's Models API with a credential you pass in. The package still reads no
environment and holds no key of its own.
Protocols
AGENT_PROTOCOLS / AgentProtocol
const AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream", "jsonl"] as const
type AgentProtocol = (typeof AGENT_PROTOCOLS)[number]| Protocol | Meaning |
|---|---|
none | Do not stream. The harness's output is not parsed. |
claude-json | Claude Code's --output-format stream-json --verbose. One prompt, then the harness exits. |
claude-stream | Same output, plus --input-format stream-json: the harness stays alive between turns, so it can be answered mid-flight and interrupted. |
jsonl | The harness already speaks this package's vocabulary, one JSON event per line. The escape hatch that means a home-made agent has nothing to learn beyond one page of documentation. |
applyProtocol(command, protocol)
function applyProtocol(command: string, protocol: AgentProtocol): stringThe command actually run, given the project's harness command and its protocol. The platform
appends the flags rather than asking the project to, because getting them wrong is silent: a
missing --verbose makes Claude Code refuse stream-json outright, and a missing
--output-format produces prose that parses into nothing.
Left untouched for none / jsonl, and when the command already contains --output-format — a
project that wants different flags keeps full control by saying so.
applyProtocol("claude -p", "claude-json")
// → "claude -p --output-format stream-json --verbose"
applyProtocol("claude -p", "claude-stream")
// → "claude -p --output-format stream-json --verbose --input-format stream-json"
applyProtocol("my-agent", "jsonl")
// → "my-agent"isInteractive(protocol)
function isInteractive(protocol: AgentProtocol): booleanDoes this protocol keep the harness alive, listening on stdin, between turns? Only claude-stream.
userTurnLine(protocol, text)
function userTurnLine(protocol: AgentProtocol, text: string): stringOne turn of the conversation, as the harness expects it on stdin. For claude-stream, a
{"type":"user","message":{…}} JSON line; for every other protocol, the text itself. Always
newline-terminated, to be written as a single write — under PIPE_BUF that is atomic, which is
what stops two messages from interleaving into one unparseable line.
stdin.write(userTurnLine("claude-stream", "Now add tests for it."))interruptLine(protocol, requestId)
function interruptLine(protocol: AgentProtocol, requestId: string): string | nullStops what the harness is doing without killing it: a control_request / interrupt line for
claude-stream, null for every other protocol. Measured behaviour: Claude Code answers with a
success control_response, ends the turn with result/error_during_execution, and stays alive — so
the next message is a normal turn, not a resume.
Parsing a stream
TASK_EVENT_TYPES / TaskEventType / NormalizedEvent
const TASK_EVENT_TYPES = [
"session.started", "session.title", "message", "thinking", "tool.call", "tool.result",
"plan", "usage", "session.ended", "session.background", "raw",
] as const
type TaskEventType = (typeof TASK_EVENT_TYPES)[number]
type NormalizedEvent = { type: TaskEventType; payload: Record<string, unknown> }The normalised vocabulary. Deliberately short — it has to be translatable from several harnesses, so anything specific to one of them does not belong in it.
| Type | Payload |
|---|---|
session.started | harness, model, contextWindow, cwd, sessionId, tools |
session.title | title — the name the harness gave the conversation |
message | role, text, truncated |
thinking | text, truncated |
tool.call | callId, name, input, truncated |
tool.result | callId, isError, output, truncated |
plan | steps: [{ text, status }] |
usage | scope: "call" | "session", token counters, and for session: costUsd, turns, durationMs |
session.ended | status: "succeeded" | "failed", stopReason, summary |
session.background | tasks: [{ id, kind, description }] — what still runs in the background, restated whole |
raw | text (non-JSON line) or json (unmapped object) |
Text fields are clipped at 8,000 characters with truncated: true, so the UI can say so
instead of quietly lying about what the agent saw. A single tool result can be a 2 MB file read.
parseStreamLine(protocol, line)
function parseStreamLine(protocol: AgentProtocol, line: string): NormalizedEvent[]Parses one line into zero or more events. Zero is a normal answer (a blank line, a JSON value
that is not an object). A non-JSON line becomes a raw event with its text — a harness that also
prints a banner to stdout is common, and that must not be invisible either.
For the Claude dialects: system/init → session.started; assistant/user content blocks →
message, thinking, tool.call, tool.result; a TodoWrite tool call additionally yields a
plan; each assistant message with non-zero usage yields a usage with scope: "call" (and
contextTokens = input + cacheRead + cacheCreation); result → a usage with scope: "session"
plus session.ended; system/background_tasks_changed → session.background; anything else →
raw. For jsonl, an object whose type is in the vocabulary passes through with the rest of the
object as payload; anything else becomes raw.
parseStreamLine("jsonl", '{"type":"message","role":"assistant","text":"Done."}')
// → [{ type: "message", payload: { role: "assistant", text: "Done." } }]A session.ended ends the turn, not necessarily the work. If a background task is still
running, the harness wakes itself up for another turn and another result when it finishes. A
result is a hand-back only if the latest session.background lists nothing.
SourcedEvent and parseLines(protocol, lines, budget)
type SourcedEvent = NormalizedEvent & { source: string }
function parseLines(protocol: AgentProtocol, lines: string[], budget: number): SourcedEvent[]Parses whole lines, keeping each event attached to the (trimmed) line it came from. That pairing is
what makes normalisation reversible: type/payload is our reading of the line, source is the
line — get the reading wrong and the line is still there to read again. budget caps how many
events one pass may produce, so a runaway session cannot blow past a per-task ceiling in one chunk.
An adapter that throws costs its line (with a console.warn), not the pass.
splitCompleteLines(chunk)
function splitCompleteLines(chunk: Uint8Array): { lines: string[]; consumed: number }Splits a chunk of bytes into whole lines and says how many bytes that consumed. The cursor only
ever advances past a complete line; a read that lands mid-line leaves the tail for the next one.
Without this, a 3 KB JSON event straddling two reads would be parsed as two invalid halves. Takes a
Uint8Array so a Node Buffer passes straight through and the package stays runnable anywhere.
let offset = 0
async function poll(taskId: string) {
const chunk = await readFrom(worker, logPath, offset) // bytes from `offset`
const { lines, consumed } = splitCompleteLines(chunk)
offset += consumed
const events = parseLines(task.protocol, lines, 500)
await saveEvents(taskId, events) // store type, payload and source
}Session titles
Claude Code names its own conversations, but does not put that name on stdout: it writes it to
the session transcript as {"type":"ai-title","aiTitle":…} lines. So the only way to get it is to
read it out of band, in the worker — hence a shell probe plus a parser. A jsonl harness says its
name in band, as a session.title event, and pays nothing.
readsTitleOutOfBand(protocol)
function readsTitleOutOfBand(protocol: AgentProtocol): booleanTrue for claude-json and claude-stream. Ask it before looking up a session id.
sessionTitleProbe(protocol, sessionId)
function sessionTitleProbe(protocol: AgentProtocol, sessionId: string): string | nullA shell to run in the worker that prints the last few ai-title lines of the session's transcript
(found by file name under ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects). null for a dialect with
no out-of-band title, or for a session id outside [A-Za-z0-9._-] (1–128 chars) — the id comes
from the harness's output and is about to be interpolated into a shell, so anything else is refused.
Finding nothing (for example, a harness run with --no-session-persistence) is a normal answer.
parseSessionTitle(protocol, output)
function parseSessionTitle(protocol: AgentProtocol, output: string): { title: string; source: string } | nullThe title out of what the probe printed: the newest line that is really an ai-title record wins
(the probe's grep is a substring match on a file the agent itself can write into).
if (readsTitleOutOfBand(protocol)) {
const probe = sessionTitleProbe(protocol, sessionId)
if (probe) {
const found = parseSessionTitle(protocol, await exec(worker, probe))
if (found) await setTitle(taskId, found.title)
}
}cleanTitle(value)
function cleanTitle(value: unknown): string | nullOne line, whitespace runs collapsed, trimmed, at most 200 characters; null for a non-string or an
empty result.
Usage
UsageInput and summarizeUsage(input)
type UsageInput = {
calls: { count: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number }
latestCall: Record<string, unknown> | null // most recent scope: "call" payload
latestSession: Record<string, unknown> | null // most recent scope: "session" payload
started: Record<string, unknown> | null // most recent session.started payload
}
function summarizeUsage(input: UsageInput): SessionUsage | nullFolds a task's usage events into the one recap a reader wants. Which number comes from where is the whole content of the function:
- tokens are summed from per-call events, because a turn in flight has not produced a harness total yet — and "0 tokens" under a session that has worked for ten minutes is never true. The harness total is the fallback for a dialect that only reports at the end;
- cost, turns and duration only ever come from the harness: pricing a call needs a rate card, and a platform that made one up would be quoting a number it cannot stand behind;
- context is the latest call's prompt size, against the window the session was opened with.
Returns null when nothing was measured at all — nothing measured is not zero measured.
SessionUsage
Prop
Type
Context windows
A session's context window has two sources of very different quality: a catalogue in this
package (free, synchronous, and wrong on the day a model ships) and the Models API
(GET /v1/models/{id} → max_input_tokens, the model's own answer). Neither guesses: a model in
neither source is worth null, and a UI then shows a token count with no percentage — a wrong bar
is worse than no bar.
The catalogue is exact ids, not a pattern match. A family regex once answered 200K for models with a native 1M window, pegging the meter at 100% a fifth of the way in. A model missing from the table reads as unknown, never as a neighbour's window.
apiModelId(model)
function apiModelId(model: string): stringThe API id inside whatever a harness wrote, so one catalogue covers every spelling: lowercases,
drops Claude Code's [1m] suffix, a Bedrock anthropic. prefix (optionally behind a region), a
Vertex @snapshot, a Bedrock -v1:0 suffix and a trailing -YYYYMMDD date.
apiModelId("claude-opus-5[1m]") // → "claude-opus-5"
apiModelId("us.anthropic.claude-sonnet-4-5-20250929-v1:0") // → "claude-sonnet-4-5"
apiModelId("claude-3-5-haiku@20241022") // → "claude-3-5-haiku"contextWindowOf(model)
function contextWindowOf(model: string | null | undefined): number | nullFrom the catalogue alone — no credential, no network. 1_000_000 for any model string carrying the
[1m] suffix; otherwise the catalogued window of its apiModelId, or null.
contextWindowOf("claude-opus-5") // → 1000000
contextWindowOf("claude-haiku-4-5") // → 200000
contextWindowOf("some-future-model") // → nullModelCredential
type ModelCredential = { kind: "api-key" | "oauth"; value: string }How a session authenticates against Anthropic. A tagged union, not a string, because the two kinds
use different headers and sending a token on the wrong one fails quietly as a 401 that looks like a
revoked key: api-key (ANTHROPIC_API_KEY) goes on x-api-key; oauth (a subscription token such
as CLAUDE_CODE_OAUTH_TOKEN) goes on Authorization: Bearer plus the oauth-2025-04-20 beta header.
liveContextWindow(model, credential)
function liveContextWindow(model: string, credential: ModelCredential): Promise<number | null>Asks the Models API (4-second timeout). Never throws: every failure mode — no network, revoked
key, unknown model, insufficient scope, 429 — resolves to null, because a session must not stop
streaming over a catalogue lookup. Memoised per process: a hit for the life of the process, a miss
retried after 10 minutes.
resolveContextWindow(model, credential)
function resolveContextWindow(
model: string | null | undefined,
credential: ModelCredential | null | undefined,
): Promise<number | null>The window to record for a session, best source first: the [1m] suffix wins outright (it is a
statement about this session); then the Models API when a credential is given; then the
catalogue — including when there is no credential at all, which is ordinary, not an error.
const started = events.find((e) => e.type === "session.started")
if (started) {
const key = secrets.ANTHROPIC_API_KEY
started.payload.contextWindow = await resolveContextWindow(
started.payload.model as string | null,
key ? { kind: "api-key", value: key } : null,
)
}resetModelWindowCache()
function resetModelWindowCache(): voidClears the process-wide memo of liveContextWindow. For tests only.
Types
The canonical shapes a worker's environment is described by, the status it reports back, and the worker status vocabulary with its transition graph.
Terminal
The half of a persistent worker terminal that is a string — the dtach scripts, the session layout, and the parsing of what they print back.