@spunto/build 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The Spunto agent contract (RFC 0048 § 4.2, § 9): what an agent app's runner prints, and the one
3
+ * reader the platform has for it.
4
+ *
5
+ * A runner speaks **our** vocabulary on stdout, one JSON object per line, whatever harness it
6
+ * drives — the normalisation from a harness's own dialect happens in the runner, in the machine,
7
+ * before the line ever leaves it. So the platform has a single reader, strict, and no adapter of
8
+ * anyone's: adding an agent is writing a runner, never changing the platform.
9
+ *
10
+ * Everything here is derived from one table (`FIELDS`): the validator the platform reads with, and
11
+ * the JSON Schema an app author writes against (`agentContractSchema`, published as
12
+ * `agent-contract.v1.schema.json`). They cannot drift, because there is only one of them.
13
+ *
14
+ * Two rules the platform depends on:
15
+ *
16
+ * 1. **Closed schemas.** A known type with a field we do not know, or a field of the wrong shape,
17
+ * is not guessed at: the line is kept as `raw`, with the reason, and nothing silently lands in
18
+ * the timeline half-read. An unknown type is `raw` too. Never an error, never dropped.
19
+ * 2. **`source` travels with the event.** A runner puts the harness's own message next to the
20
+ * event it read it into (`"source": {…}`), so the projection stays replayable (RFC 0020) — the
21
+ * platform stores *that* as the event's source, not our reading of it.
22
+ *
23
+ * Pure: no I/O, no clock.
24
+ */
25
+
26
+ import { TURN_LIMIT_KINDS } from "../agent-stream/turn-limit"
27
+
28
+ /** Bumped only for a change an existing runner would not survive. Additive changes keep it. */
29
+ export const AGENT_CONTRACT_VERSION = 1
30
+
31
+ /**
32
+ * The stored vocabulary. Deliberately short — it has to be translatable *from* several harnesses,
33
+ * so anything specific to one of them does not belong in it.
34
+ */
35
+ export const TASK_EVENT_TYPES = [
36
+ "session.started",
37
+ "session.title",
38
+ "message",
39
+ "thinking",
40
+ "tool.call",
41
+ "tool.result",
42
+ "plan",
43
+ "usage",
44
+ "session.ended",
45
+ "session.background",
46
+ "raw",
47
+ ] as const
48
+ export type TaskEventType = (typeof TASK_EVENT_TYPES)[number]
49
+
50
+ /**
51
+ * Lines a runner may print that are **not events**: never stored, read on the way past. Only one —
52
+ * the text of a message still being written (`message.delta`), which folds into the transient draft
53
+ * the cockpit shows until the finished `message` lands (`../agent-stream/draft.ts`).
54
+ */
55
+ export const TRANSIENT_TYPES = ["message.delta"] as const
56
+ export type TransientType = (typeof TRANSIENT_TYPES)[number]
57
+
58
+ export type NormalizedEvent = {
59
+ type: TaskEventType
60
+ payload: Record<string, unknown>
61
+ }
62
+
63
+ /** An event and the harness line it came from. One line can yield several. */
64
+ export type SourcedEvent = NormalizedEvent & { source: string }
65
+
66
+ /**
67
+ * One line of a runner's stdin. The whole input side of the contract — three verbs, the same for
68
+ * every harness, so the platform never writes a harness's own shape into a runner. Closing stdin
69
+ * ends the session once its turn is over.
70
+ */
71
+ export type RunnerInput =
72
+ | { type: "user"; text: string }
73
+ | { type: "interrupt"; id: string }
74
+ | { type: "title"; id: string; description: string }
75
+
76
+ // ── The table ─────────────────────────────────────────────────────────────────
77
+
78
+ type Field = { check: (v: unknown) => boolean; schema: Record<string, unknown>; required?: boolean }
79
+
80
+ const str = (): Field => ({ check: (v) => typeof v === "string", schema: { type: "string" } })
81
+ const num = (): Field => ({ check: (v) => typeof v === "number" && Number.isFinite(v), schema: { type: "number" } })
82
+ const bool = (): Field => ({ check: (v) => typeof v === "boolean", schema: { type: "boolean" } })
83
+ const any = (): Field => ({ check: () => true, schema: {} })
84
+ const oneOf = (values: readonly string[]): Field => ({
85
+ check: (v) => typeof v === "string" && values.includes(v),
86
+ schema: { type: "string", enum: [...values] },
87
+ })
88
+ const nullable = (f: Field): Field => ({
89
+ check: (v) => v === null || f.check(v),
90
+ schema: { anyOf: [f.schema, { type: "null" }] },
91
+ })
92
+ const array = (item: Field): Field => ({
93
+ check: (v) => Array.isArray(v) && v.every(item.check),
94
+ schema: { type: "array", items: item.schema },
95
+ })
96
+ const req = (f: Field): Field => ({ ...f, required: true })
97
+ const object = (fields: Record<string, Field>): Field => ({
98
+ check: (v) => !!v && typeof v === "object" && !Array.isArray(v) && fieldsProblem(v as Record<string, unknown>, fields) === null,
99
+ schema: objectSchema(fields),
100
+ })
101
+
102
+ function objectSchema(fields: Record<string, Field>, extra: Record<string, unknown> = {}): Record<string, unknown> {
103
+ const required = Object.entries(fields).filter(([, f]) => f.required).map(([k]) => k)
104
+ return {
105
+ type: "object",
106
+ properties: { ...extra, ...Object.fromEntries(Object.entries(fields).map(([k, f]) => [k, f.schema])) },
107
+ ...(required.length || extra.type ? { required: [...(extra.type ? ["type"] : []), ...required] } : {}),
108
+ additionalProperties: false,
109
+ }
110
+ }
111
+
112
+ /** Why `obj` does not fit `fields`, or null. Closed: an unknown key is a problem too. */
113
+ function fieldsProblem(obj: Record<string, unknown>, fields: Record<string, Field>): string | null {
114
+ for (const [key, field] of Object.entries(fields)) {
115
+ if (!(key in obj) || obj[key] === undefined) {
116
+ if (field.required) return `missing "${key}"`
117
+ continue
118
+ }
119
+ if (!field.check(obj[key])) return `"${key}" is not ${describe(field.schema)}`
120
+ }
121
+ for (const key of Object.keys(obj)) if (!(key in fields)) return `unknown field "${key}"`
122
+ return null
123
+ }
124
+
125
+ function describe(schema: Record<string, unknown>): string {
126
+ if (Array.isArray(schema.enum)) return `one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`
127
+ if (Array.isArray(schema.anyOf)) return schema.anyOf.map((s) => describe(s as Record<string, unknown>)).join(" or ")
128
+ if (schema.type === "array") return "an array of the expected shape"
129
+ if (schema.type === "object") return "an object of the expected shape"
130
+ return typeof schema.type === "string" ? `a ${schema.type}` : "valid"
131
+ }
132
+
133
+ /** `limit` — the turn ended because the account ran out, not the work (`../agent-stream/turn-limit.ts`). */
134
+ const LIMIT = object({
135
+ kind: req(oneOf(TURN_LIMIT_KINDS)),
136
+ status: nullable(num()),
137
+ message: req(str()),
138
+ resetsAt: nullable(str()),
139
+ })
140
+
141
+ const COUNT = nullable(num())
142
+
143
+ /**
144
+ * Every line type, and its fields. `type` and `source` are on every line and not repeated here.
145
+ * A field absent from a line is "unknown", which is not the same as `null` only for the reader of
146
+ * the stored payload — both are allowed wherever the field is optional.
147
+ */
148
+ const FIELDS: Record<TaskEventType | TransientType, Record<string, Field>> = {
149
+ // The session opened (or was resumed). `sessionId` is what `--resume` takes back; `contextWindow`
150
+ // the size of the model's window, when the runner could tell — the platform no longer guesses it.
151
+ "session.started": {
152
+ harness: req(str()),
153
+ model: nullable(str()),
154
+ sessionId: nullable(str()),
155
+ cwd: nullable(str()),
156
+ tools: array(str()),
157
+ contextWindow: COUNT,
158
+ },
159
+ // The name the harness gave the conversation — in answer to the `title` verb, or on its own.
160
+ "session.title": { title: req(str()) },
161
+ message: { role: req(oneOf(["assistant", "user"])), text: req(str()), truncated: bool(), limit: nullable(LIMIT) },
162
+ thinking: { text: req(str()), truncated: bool() },
163
+ // `input` is text: the call's arguments as the harness wrote them, JSON or not.
164
+ "tool.call": { callId: nullable(str()), name: req(str()), input: req(str()), truncated: bool() },
165
+ "tool.result": { callId: nullable(str()), isError: req(bool()), output: req(str()), truncated: bool() },
166
+ plan: { steps: req(array(object({ text: req(str()), status: req(str()) }))) },
167
+ // `call`: one model call, summed over the task; its `contextTokens` is how full the window is
168
+ // now. `session`: the harness's own running total, restated — the latest one wins.
169
+ usage: {
170
+ scope: req(oneOf(["call", "session"])),
171
+ model: nullable(str()),
172
+ inputTokens: COUNT,
173
+ outputTokens: COUNT,
174
+ cacheReadTokens: COUNT,
175
+ cacheCreationTokens: COUNT,
176
+ contextTokens: COUNT,
177
+ contextWindow: COUNT,
178
+ costUsd: COUNT,
179
+ turns: COUNT,
180
+ durationMs: COUNT,
181
+ },
182
+ // The turn is over and the harness is listening again (or exiting). `limit` when the account,
183
+ // not the work, ended it — the platform then waits for `resetsAt` instead of calling it review.
184
+ "session.ended": {
185
+ status: req(oneOf(["succeeded", "failed"])),
186
+ stopReason: nullable(str()),
187
+ summary: str(),
188
+ limit: nullable(LIMIT),
189
+ },
190
+ // What the harness still runs in the background, restated whole — a turn that leaves something
191
+ // running has not handed back.
192
+ "session.background": {
193
+ tasks: req(array(object({ id: nullable(str()), kind: nullable(str()), description: nullable(str()) }))),
194
+ },
195
+ raw: { json: any(), text: str() },
196
+ // Not stored. `start` opens a fresh draft (one draft is one text block), else `text` is appended.
197
+ "message.delta": { text: req(str()), start: bool() },
198
+ }
199
+
200
+ /** The JSON Schema of one output line, every type. What an app author validates against. */
201
+ export function agentContractSchema(): Record<string, unknown> {
202
+ const line = (type: string, fields: Record<string, Field>) =>
203
+ objectSchema(fields, {
204
+ type: { const: type },
205
+ source: { description: "The harness's own message this event was read from, verbatim — kept so the projection can be replayed." },
206
+ })
207
+ const input: Record<string, Field>[] = [
208
+ { type: req(oneOf(["user"])), text: req(str()) },
209
+ { type: req(oneOf(["interrupt"])), id: req(str()) },
210
+ { type: req(oneOf(["title"])), id: req(str()), description: req(str()) },
211
+ ]
212
+ return {
213
+ $schema: "https://json-schema.org/draft/2020-12/schema",
214
+ $id: `https://spunto.net/schemas/agent-contract.v${AGENT_CONTRACT_VERSION}.json`,
215
+ title: `Spunto agent contract, v${AGENT_CONTRACT_VERSION}`,
216
+ description:
217
+ "What an agent app's runner prints on stdout (one `output` object per line) and reads on stdin (one `input` object per line). See docs/agent-contract.md.",
218
+ $defs: {
219
+ output: { oneOf: Object.entries(FIELDS).map(([type, fields]) => line(type, fields)) },
220
+ input: { oneOf: input.map((fields) => objectSchema(fields)) },
221
+ },
222
+ $ref: "#/$defs/output",
223
+ }
224
+ }
225
+
226
+ // ── The reader ────────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Per-field ceiling on stored text. A single `tool.result` can be a 2 MB file read; the timeline
230
+ * needs enough of it to be readable, not all of it. `truncated` is set so the UI can say so.
231
+ */
232
+ export const MAX_TEXT = 8_000
233
+
234
+ export function clip(value: unknown): { text: string; truncated: boolean } {
235
+ const text =
236
+ typeof value === "string" ? value : value === undefined || value === null ? "" : safeStringify(value)
237
+ return text.length > MAX_TEXT ? { text: text.slice(0, MAX_TEXT), truncated: true } : { text, truncated: false }
238
+ }
239
+
240
+ export function safeStringify(value: unknown): string {
241
+ try {
242
+ return JSON.stringify(value) ?? ""
243
+ } catch {
244
+ return String(value)
245
+ }
246
+ }
247
+
248
+ /** Fields a runner may write at any length; the reader clips them as the adapters always did. */
249
+ const CLIPPED: Partial<Record<TaskEventType, string>> = {
250
+ message: "text",
251
+ thinking: "text",
252
+ "tool.call": "input",
253
+ "tool.result": "output",
254
+ }
255
+
256
+ /** Why this line is not a valid output line of the contract, or null. */
257
+ export function contractLineProblem(obj: Record<string, unknown>): string | null {
258
+ const type = obj.type
259
+ if (typeof type !== "string") return `missing "type"`
260
+ const fields = (FIELDS as Record<string, Record<string, Field> | undefined>)[type]
261
+ if (!fields) return `unknown type "${type}"`
262
+ const { type: _t, source: _s, ...payload } = obj
263
+ return fieldsProblem(payload, fields)
264
+ }
265
+
266
+ /** Is this a line that is read on the way past and never stored (`message.delta`)? */
267
+ export function isTransient(obj: Record<string, unknown>): boolean {
268
+ return (TRANSIENT_TYPES as readonly unknown[]).includes(obj.type)
269
+ }
270
+
271
+ /**
272
+ * Read one parsed line of the contract into the event it states, with its source.
273
+ *
274
+ * `line` is what was read, kept as the source when the runner gave none — a home-made agent that
275
+ * has no harness message of its own to point at is still auditable. Transient lines read as
276
+ * nothing here; see `foldDraft`.
277
+ */
278
+ export function readContractObject(obj: Record<string, unknown>, line: string): SourcedEvent[] {
279
+ if (isTransient(obj)) return []
280
+ const source = obj.source === undefined ? line : typeof obj.source === "string" ? obj.source : safeStringify(obj.source)
281
+ const problem = contractLineProblem(obj)
282
+ if (problem) return [{ type: "raw", payload: { json: withoutSource(obj), invalid: problem }, source }]
283
+ const { type, source: _s, ...payload } = obj
284
+ const field = CLIPPED[type as TaskEventType]
285
+ if (field) {
286
+ const { text, truncated } = clip(payload[field])
287
+ payload[field] = text
288
+ if (truncated) payload.truncated = true
289
+ }
290
+ if (type === "session.ended" && typeof payload.summary === "string") payload.summary = clip(payload.summary).text
291
+ return [{ type: type as TaskEventType, payload, source }]
292
+ }
293
+
294
+ function withoutSource(obj: Record<string, unknown>): Record<string, unknown> {
295
+ const { source: _s, ...rest } = obj
296
+ return rest
297
+ }
298
+
299
+ /** Read one line of a runner's stdout. A line that is not a JSON object is kept as `raw` text. */
300
+ export function readContractLine(line: string): SourcedEvent[] {
301
+ const trimmed = line.trim()
302
+ if (!trimmed) return []
303
+ let parsed: unknown
304
+ try {
305
+ parsed = JSON.parse(trimmed)
306
+ } catch {
307
+ return [{ type: "raw", payload: { text: clip(trimmed).text }, source: trimmed }]
308
+ }
309
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
310
+ return [{ type: "raw", payload: { json: parsed }, source: trimmed }]
311
+ }
312
+ return readContractObject(parsed as Record<string, unknown>, trimmed)
313
+ }
@@ -47,6 +47,13 @@ export type AgentCatalog = {
47
47
  /** The variable a task's chosen parameters reach the runner in, as one JSON object. */
48
48
  export const OPTIONS_ENV = "SPUNTO_AGENT_OPTIONS"
49
49
 
50
+ /**
51
+ * `"1"` when the session should stream the tokens of a message as they are written (partial
52
+ * messages, folded into the draft by `foldDraft`). Set by the platform behind the org flag
53
+ * `token-streaming`; absent, a runner prints finished messages only — the system before it.
54
+ */
55
+ export const STREAM_ENV = "SPUNTO_AGENT_STREAM"
56
+
50
57
  /**
51
58
  * The variable the org's MCP servers reach the runner in (RFC 0049): a JSON array of
52
59
  * `{ name, path }`, `path` being the platform's gateway for that server, resolved by the runner
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Claude's dialect, read into our vocabulary — the SDK's messages, which are exactly the CLI's
3
+ * `--output-format stream-json --verbose` shapes.
4
+ *
5
+ * Lives with the Claude runner (RFC 0048 § 9): the runner bundles it and prints the contract
6
+ * (`claudeContractLines`), so the platform reads no Claude line of a session started since. The
7
+ * platform's legacy reader (`../../agent-stream`) still imports `adaptClaudeJson` for the
8
+ * conversations that began on the CLI or on the runner's old dialect, until they end.
9
+ *
10
+ * Written against output measured in a real worker, not against a spec: the shapes below are what
11
+ * the CLI emitted, and the tests replay recorded samples so a change in that output shows up as a
12
+ * failing test rather than as an empty timeline.
13
+ */
14
+
15
+ import { clip, type NormalizedEvent } from "../../agent-contract/vocabulary"
16
+ import { contextWindowOf } from "../../agent-stream/model-window"
17
+ import { claudeTurnLimit, resetFromText, type TurnLimit } from "../../agent-stream/turn-limit"
18
+
19
+ /** Marks the `control_request`s that ask the CLI for a title, and so the `control_response`s that answer. */
20
+ export const TITLE_REQUEST_PREFIX = "spunto-title-"
21
+
22
+ /** One line, no runs of whitespace, bounded — a title goes in a list row and in a page header. */
23
+ export function cleanTitle(value: unknown): string | null {
24
+ if (typeof value !== "string") return null
25
+ const title = value.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE).trim()
26
+ return title || null
27
+ }
28
+
29
+ /** Longest name we keep. The column allows 200; a harness that writes an essay gets cut. */
30
+ const MAX_TITLE = 200
31
+
32
+ export function adaptClaudeJson(obj: Record<string, unknown>): NormalizedEvent[] {
33
+ const type = obj.type
34
+
35
+ if (type === "system" && obj.subtype === "init") {
36
+ const model = typeof obj.model === "string" ? obj.model : null
37
+ return [
38
+ {
39
+ type: "session.started",
40
+ payload: {
41
+ harness: "claude-code",
42
+ model,
43
+ // Read here and nowhere else: this is the only line that spells the model the way the
44
+ // *session* was opened (`claude-opus-5[1m]`), suffix included — the assistant messages
45
+ // that follow carry the plain API id. From the catalogue only, so this stays a pure
46
+ // function; the runner replaces it with the model's own answer when it has a key to ask
47
+ // with (`resolveContextWindow`).
48
+ contextWindow: contextWindowOf(model),
49
+ cwd: typeof obj.cwd === "string" ? obj.cwd : null,
50
+ sessionId: typeof obj.session_id === "string" ? obj.session_id : null,
51
+ tools: Array.isArray(obj.tools) ? obj.tools.filter((t) => typeof t === "string") : [],
52
+ },
53
+ },
54
+ ]
55
+ }
56
+
57
+ // The set of background tasks (a `run_in_background` shell, a backgrounded subagent) still
58
+ // alive, restated whole on every change. Why it has to be read: a `result` ends the *turn*, not
59
+ // the work — measured, a turn that leaves a background task running emits its `result`, and
60
+ // when that task finishes the harness wakes itself up for another turn and another `result`,
61
+ // nobody having said anything. So a `result` is a hand-back only if nothing was left running
62
+ // behind it, and this line is the only place that says so.
63
+ if (type === "system" && obj.subtype === "background_tasks_changed") {
64
+ const tasks = (Array.isArray(obj.tasks) ? obj.tasks : [])
65
+ .filter((t): t is Record<string, unknown> => !!t && typeof t === "object")
66
+ .map((t) => ({
67
+ id: typeof t.task_id === "string" ? t.task_id : null,
68
+ kind: typeof t.task_type === "string" ? t.task_type : null,
69
+ description: typeof t.description === "string" ? t.description : null,
70
+ }))
71
+ return [{ type: "session.background", payload: { tasks } }]
72
+ }
73
+
74
+ if (type === "assistant" || type === "user") {
75
+ const message = obj.message as Record<string, unknown> | undefined
76
+ const content = Array.isArray(message?.content) ? message!.content : []
77
+ const role = type === "assistant" ? "assistant" : "user"
78
+ const events: NormalizedEvent[] = []
79
+ for (const rawBlock of content) {
80
+ if (!rawBlock || typeof rawBlock !== "object") continue
81
+ const block = rawBlock as Record<string, unknown>
82
+ switch (block.type) {
83
+ case "text": {
84
+ const { text, truncated } = clip(block.text)
85
+ if (text) events.push({ type: "message", payload: { role, text, truncated } })
86
+ break
87
+ }
88
+ case "thinking": {
89
+ const { text, truncated } = clip(block.thinking)
90
+ if (text) events.push({ type: "thinking", payload: { text, truncated } })
91
+ break
92
+ }
93
+ case "tool_use": {
94
+ const name = typeof block.name === "string" ? block.name : "tool"
95
+ const { text: input, truncated } = clip(block.input)
96
+ events.push({
97
+ type: "tool.call",
98
+ payload: { callId: typeof block.id === "string" ? block.id : null, name, input, truncated },
99
+ })
100
+ // A todo list is a plan, and a plan is the single most useful thing to show while a
101
+ // session runs. Emitted *alongside* the tool call rather than instead of it: the call
102
+ // is what happened, the plan is what it means.
103
+ const plan = todoPlan(block.input)
104
+ if (plan) events.push(plan)
105
+ break
106
+ }
107
+ case "tool_result": {
108
+ const { text: output, truncated } = clip(
109
+ typeof block.content === "string" ? block.content : block.content ?? "",
110
+ )
111
+ events.push({
112
+ type: "tool.result",
113
+ payload: {
114
+ callId: typeof block.tool_use_id === "string" ? block.tool_use_id : null,
115
+ isError: block.is_error === true,
116
+ output,
117
+ truncated,
118
+ },
119
+ })
120
+ break
121
+ }
122
+ }
123
+ }
124
+ // The synthetic message Claude Code writes when the API refused the call carries the one
125
+ // structured reset time of a usage limit; the `result` that follows does not. Kept on the
126
+ // message so ingestion can carry it over to the turn's end (`stampLimitReset`).
127
+ if (type === "assistant") {
128
+ const limit = claudeTurnLimit(obj)
129
+ if (limit) for (const e of events) if (e.type === "message") e.payload.limit = limit
130
+ }
131
+ // What the call that produced this message cost, and how full the context was when it ran.
132
+ // Only assistant messages carry it — a `user` line is tool results being handed back.
133
+ if (type === "assistant") {
134
+ const measured = callUsage(message)
135
+ if (measured) events.push(measured)
136
+ }
137
+ return events
138
+ }
139
+
140
+ if (type === "result") {
141
+ const usage = (obj.usage ?? {}) as Record<string, unknown>
142
+ const limit = claudeTurnLimit(obj)
143
+ return [
144
+ {
145
+ type: "usage",
146
+ payload: {
147
+ // The harness's own running total, **cumulative over the whole process** — with an
148
+ // interactive session a `result` lands at the end of every turn and each one restates
149
+ // the session so far. Summing them would bill the first turn once per turn that
150
+ // followed; the latest one is the answer. (Claude Code's own SDK reader does the same:
151
+ // it overwrites `total_cost_usd`/`num_turns` on each result rather than adding.)
152
+ scope: "session",
153
+ inputTokens: num(usage.input_tokens),
154
+ outputTokens: num(usage.output_tokens),
155
+ cacheReadTokens: num(usage.cache_read_input_tokens),
156
+ cacheCreationTokens: num(usage.cache_creation_input_tokens),
157
+ costUsd: num(obj.total_cost_usd),
158
+ turns: num(obj.num_turns),
159
+ durationMs: num(obj.duration_ms),
160
+ },
161
+ },
162
+ {
163
+ type: "session.ended",
164
+ payload: {
165
+ // `is_error` too: a turn the API refused (a usage limit, measured) still says
166
+ // `subtype: "success"` — it is the *harness* that ended cleanly, not the turn.
167
+ status: obj.subtype === "success" && obj.is_error !== true ? "succeeded" : "failed",
168
+ stopReason: typeof obj.stop_reason === "string" ? obj.stop_reason : null,
169
+ summary: clip(obj.result).text,
170
+ ...(limit ? { limit } : {}),
171
+ },
172
+ },
173
+ ]
174
+ }
175
+
176
+ // The CLI's answer to a `generate_session_title` request. Only ours, recognised by request id:
177
+ // a `control_response` is also how an interrupt is acknowledged, and that one is no name.
178
+ if (type === "control_response") {
179
+ const response = (obj.response ?? {}) as Record<string, unknown>
180
+ const inner = (response.response ?? {}) as Record<string, unknown>
181
+ const requestId = typeof response.request_id === "string" ? response.request_id : ""
182
+ const title = response.subtype === "success" && requestId.startsWith(TITLE_REQUEST_PREFIX) ? cleanTitle(inner.title) : null
183
+ if (title) return [{ type: "session.title", payload: { title } }]
184
+ }
185
+
186
+ // Tokens of a message still being written (`includePartialMessages`). Not events: the finished
187
+ // `assistant` line repeats them whole, and one row per token would bury the session. They fold
188
+ // into the transient draft instead (`claudeDelta` below, `draft.ts` for the old dialects).
189
+ if (type === "stream_event") return []
190
+
191
+ // Known-but-uninteresting (`rate_limit_event`, `system/thinking_tokens`) and anything the CLI
192
+ // grows next land here rather than being dropped: an unknown event is information about the
193
+ // harness, and the UI keeps it behind a toggle.
194
+ return [{ type: "raw", payload: { json: obj } }]
195
+ }
196
+
197
+ function num(value: unknown): number | null {
198
+ return typeof value === "number" && Number.isFinite(value) ? value : null
199
+ }
200
+
201
+ /**
202
+ * What **one model call** consumed, read off an assistant message.
203
+ *
204
+ * Two different questions are answered by these numbers, and only the first is obvious:
205
+ *
206
+ * - added up over the session, they are what it has spent in tokens — and unlike the `result`
207
+ * line, they exist *during* a turn, which is when someone is watching;
208
+ * - the latest one alone is the **context**: `input + cache_read + cache_creation` is the prompt
209
+ * the harness just sent, so it is how full the window is right now. Same formula Claude Code
210
+ * uses for its own context meter.
211
+ *
212
+ * Skipped when every counter is zero: an API error makes the CLI emit a synthetic assistant
213
+ * message with an all-zero usage, and drawing a context that just fell to zero would be a lie
214
+ * about the session rather than a fact about the error.
215
+ */
216
+ function callUsage(message: Record<string, unknown> | undefined): NormalizedEvent | null {
217
+ const usage = message?.usage
218
+ if (!usage || typeof usage !== "object") return null
219
+ const u = usage as Record<string, unknown>
220
+ const inputTokens = num(u.input_tokens) ?? 0
221
+ const outputTokens = num(u.output_tokens) ?? 0
222
+ const cacheReadTokens = num(u.cache_read_input_tokens) ?? 0
223
+ const cacheCreationTokens = num(u.cache_creation_input_tokens) ?? 0
224
+ if (inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens === 0) return null
225
+ // `<synthetic>` is what the CLI puts there when no model ran at all.
226
+ const model = typeof message?.model === "string" && !message.model.startsWith("<") ? message.model : null
227
+ return {
228
+ type: "usage",
229
+ payload: {
230
+ scope: "call",
231
+ model,
232
+ inputTokens,
233
+ outputTokens,
234
+ cacheReadTokens,
235
+ cacheCreationTokens,
236
+ contextTokens: inputTokens + cacheReadTokens + cacheCreationTokens,
237
+ },
238
+ }
239
+ }
240
+
241
+ /** Claude Code's TodoWrite input, as a `plan` — `{ todos: [{ content, status }] }`. */
242
+ function todoPlan(input: unknown): NormalizedEvent | null {
243
+ if (!input || typeof input !== "object") return null
244
+ const todos = (input as Record<string, unknown>).todos
245
+ if (!Array.isArray(todos) || todos.length === 0) return null
246
+ const steps = todos
247
+ .filter((t): t is Record<string, unknown> => !!t && typeof t === "object")
248
+ .map((t) => ({
249
+ text: clip(t.content ?? t.activeForm ?? "").text,
250
+ status: typeof t.status === "string" ? t.status : "pending",
251
+ }))
252
+ .filter((s) => s.text)
253
+ return steps.length > 0 ? { type: "plan", payload: { steps } } : null
254
+ }
255
+
256
+ // ── The contract, as the runner prints it ────────────────────────────────────
257
+
258
+ /**
259
+ * A partial message (`stream_event`, `includePartialMessages`) as the contract's transient
260
+ * `message.delta`: a text block starting opens a fresh draft, a `text_delta` grows it. A subagent's
261
+ * partials (`parent_tool_use_id` set) are skipped — what it writes is not what the session says to
262
+ * the reader, and interleaving it would garble the draft of the main thread.
263
+ */
264
+ export function claudeDelta(obj: Record<string, unknown>): Record<string, unknown>[] {
265
+ if (obj.type !== "stream_event" || obj.parent_tool_use_id) return []
266
+ const event = (obj.event ?? {}) as Record<string, unknown>
267
+ if (event.type === "content_block_start") {
268
+ const block = (event.content_block ?? {}) as Record<string, unknown>
269
+ if (block.type === "text") return [{ type: "message.delta", start: true, text: typeof block.text === "string" ? block.text : "" }]
270
+ } else if (event.type === "content_block_delta") {
271
+ const delta = (event.delta ?? {}) as Record<string, unknown>
272
+ if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text) return [{ type: "message.delta", text: delta.text }]
273
+ }
274
+ return []
275
+ }
276
+
277
+ /** How deep `withoutFileBytes` looks. A content block sits two or three levels down; this is a loop guard. */
278
+ const MAX_DEPTH = 8
279
+
280
+ /**
281
+ * The message with every base64 file block reduced to what it is (`{type, media_type}`).
282
+ *
283
+ * What the *events* are read from, not what is printed as `source`: a screenshot a tool hands back
284
+ * is megabytes of base64 on one line, and read as text it would fill a `tool.result` with 8 KB of
285
+ * noise. The bytes stay whole in `source`, where the platform takes them out and stores them as a
286
+ * file of the task (RFC 0022) — so the event only needs to say that a file was there.
287
+ */
288
+ export function withoutFileBytes(value: unknown, depth = 0): unknown {
289
+ if (depth > MAX_DEPTH || !value || typeof value !== "object") return value
290
+ if (Array.isArray(value)) return value.map((v) => withoutFileBytes(v, depth + 1))
291
+ const obj = value as Record<string, unknown>
292
+ const src = obj.source as Record<string, unknown> | undefined
293
+ if ((obj.type === "image" || obj.type === "document") && src && typeof src === "object" && src.type === "base64") {
294
+ const { source: _s, ...rest } = obj
295
+ return { ...rest, media_type: src.media_type }
296
+ }
297
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, withoutFileBytes(v, depth + 1)]))
298
+ }
299
+
300
+ /**
301
+ * The Claude runner's reading of a session: SDK messages in, lines of the contract out — each event
302
+ * a message reads into, with the message itself as `source`. Every line of one message carries the
303
+ * same `source`, so the platform can tell they belong together (and stores a file they carry once).
304
+ *
305
+ * Stateful for one reason: **when a limit lifts**. Claude Code puts `resetsAt` on the synthetic
306
+ * assistant message it writes in place of the refused answer, one line *before* the `result` that
307
+ * ends the turn — which only says it in prose (« resets 9pm (UTC) »), measured on 2026-10-17. The
308
+ * contract says it where the platform waits for it, on `session.ended`: carried over from that
309
+ * message, else read from the prose against the clock (`now`, a parameter for the tests).
310
+ */
311
+ export function claudeTranslator(now: () => Date = () => new Date()): (message: Record<string, unknown>) => Record<string, unknown>[] {
312
+ let carried: string | null = null
313
+ return (message) => {
314
+ if (message.type === "stream_event") return claudeDelta(message)
315
+ // The runner answers the `title` verb itself; a control response is never a title here.
316
+ if (message.type === "control_response") return []
317
+ const events = adaptClaudeJson(withoutFileBytes(message) as Record<string, unknown>)
318
+ for (const e of events) {
319
+ const limit = e.payload.limit as TurnLimit | undefined
320
+ if (e.type === "message" && limit?.resetsAt) carried = limit.resetsAt
321
+ if (e.type !== "session.ended") continue
322
+ if (limit && !limit.resetsAt) {
323
+ const resetsAt = carried ?? resetFromText(limit.message, now())
324
+ if (resetsAt) e.payload.limit = { ...limit, resetsAt }
325
+ }
326
+ carried = null
327
+ }
328
+ return events.map((e) => ({ type: e.type, ...e.payload, source: message }))
329
+ }
330
+ }