@spunto/build 0.22.0 → 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.
- package/README.md +26 -0
- package/package.json +9 -2
- package/src/agent-contract/agent-contract.v1.schema.json +672 -0
- package/src/agent-contract/conformance.ts +315 -0
- package/src/agent-contract/examples/fake-agent.mjs +46 -0
- package/src/agent-contract/index.ts +20 -0
- package/src/agent-contract/vocabulary.ts +313 -0
- package/src/agent-runners/claude/adapter.ts +330 -0
- package/src/agent-runners/claude.mjs +40 -17
- package/src/agent-runners/codex/adapter.ts +135 -0
- package/src/agent-runners/codex.mjs +15 -7
- package/src/agent-runners/index.ts +23 -14
- package/src/agent-stream/agent-stream.ts +45 -435
- package/src/agent-stream/draft.ts +41 -2
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|