@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.
- package/README.md +32 -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/catalogs.ts +7 -0
- package/src/agent-runners/claude/adapter.ts +330 -0
- package/src/agent-runners/claude.mjs +46 -17
- package/src/agent-runners/codex/adapter.ts +135 -0
- package/src/agent-runners/codex.mjs +15 -7
- package/src/agent-runners/index.ts +24 -15
- package/src/agent-stream/agent-stream.ts +45 -430
- package/src/agent-stream/draft.ts +156 -0
- package/src/agent-stream/index.ts +3 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reading an agent session as it happens (RFC 0020, phase 1).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Since RFC 0048 § 9 a session started under an agent app speaks **our** contract (`agent-v1`, read
|
|
5
|
+
* by `../agent-contract`): the runner normalised its harness's output before it left the machine,
|
|
6
|
+
* and nothing here knows which harness it was. What remains in this file is everything around that
|
|
7
|
+
* reader — how to write to a session, how to sum what it consumed — and the **legacy dialects**:
|
|
8
|
+
* the harnesses' own shapes, normalised here, for the sessions that started on them (the CLI of a
|
|
9
|
+
* task delegated before agent apps, a runner's previous dialect) and for spunto-lite.
|
|
8
10
|
*
|
|
9
11
|
* Two rules the rest of the code depends on:
|
|
10
12
|
*
|
|
@@ -19,10 +21,35 @@
|
|
|
19
21
|
* ingestion loop lives in task-events.service.ts.
|
|
20
22
|
*/
|
|
21
23
|
|
|
24
|
+
import {
|
|
25
|
+
clip,
|
|
26
|
+
readContractLine,
|
|
27
|
+
TASK_EVENT_TYPES,
|
|
28
|
+
type NormalizedEvent,
|
|
29
|
+
type TaskEventType,
|
|
30
|
+
type RunnerInput,
|
|
31
|
+
type SourcedEvent,
|
|
32
|
+
} from "../agent-contract/vocabulary"
|
|
33
|
+
import { adaptClaudeJson, cleanTitle, TITLE_REQUEST_PREFIX } from "../agent-runners/claude/adapter"
|
|
34
|
+
import { adaptCodex } from "../agent-runners/codex/adapter"
|
|
22
35
|
import { contextWindowOf } from "./model-window"
|
|
23
|
-
import { claudeTurnLimit } from "./turn-limit"
|
|
24
36
|
|
|
25
|
-
export
|
|
37
|
+
export { cleanTitle }
|
|
38
|
+
export { TASK_EVENT_TYPES } from "../agent-contract/vocabulary"
|
|
39
|
+
export type { NormalizedEvent, RunnerInput, SourcedEvent, TaskEventType } from "../agent-contract/vocabulary"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How a session is read.
|
|
43
|
+
*
|
|
44
|
+
* - `agent-v1` — **the Spunto agent contract** (`../agent-contract`): what every agent app's runner
|
|
45
|
+
* prints since the normalisation moved into the runner (RFC 0048 § 9). The one dialect a new
|
|
46
|
+
* session speaks; read by one strict reader, with no adapter of anyone's.
|
|
47
|
+
* - `claude-runner`, `codex-runner` — what those runners printed before: their harness's own
|
|
48
|
+
* dialect, normalised here. Kept for the sessions that started on it, until they end.
|
|
49
|
+
* - `claude-stream`, `claude-json`, `jsonl`, `none` — the CLI dialects of a task delegated before
|
|
50
|
+
* agent apps (`LEGACY_CLI`), and what spunto-lite's `spunto.json` still names.
|
|
51
|
+
*/
|
|
52
|
+
export const AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream", "jsonl", "claude-runner", "codex-runner", "agent-v1"] as const
|
|
26
53
|
export type AgentProtocol = (typeof AGENT_PROTOCOLS)[number]
|
|
27
54
|
|
|
28
55
|
/**
|
|
@@ -35,21 +62,12 @@ export const PROJECT_AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream",
|
|
|
35
62
|
/**
|
|
36
63
|
* A runner of an agent app (RFC 0048, `../agent-runners`): our own process in the machine, driving a
|
|
37
64
|
* harness through its official SDK. Its stdin speaks **our** contract (`RunnerInput`), whatever the
|
|
38
|
-
* harness
|
|
65
|
+
* harness — and so does its stdout, since `agent-v1`.
|
|
39
66
|
*/
|
|
40
67
|
export function isRunner(protocol: AgentProtocol): boolean {
|
|
41
|
-
return protocol === "claude-runner" || protocol === "codex-runner"
|
|
68
|
+
return protocol === "agent-v1" || protocol === "claude-runner" || protocol === "codex-runner"
|
|
42
69
|
}
|
|
43
70
|
|
|
44
|
-
/**
|
|
45
|
-
* One line of a runner's stdin. The whole input side of the contract — three verbs, the same for
|
|
46
|
-
* every harness, so the platform never writes a harness's own shape into a runner.
|
|
47
|
-
*/
|
|
48
|
-
export type RunnerInput =
|
|
49
|
-
| { type: "user"; text: string }
|
|
50
|
-
| { type: "interrupt"; id: string }
|
|
51
|
-
| { type: "title"; id: string; description: string }
|
|
52
|
-
|
|
53
71
|
function runnerLine(input: RunnerInput): string {
|
|
54
72
|
return `${JSON.stringify(input)}\n`
|
|
55
73
|
}
|
|
@@ -59,57 +77,6 @@ function speaksClaude(protocol: AgentProtocol): boolean {
|
|
|
59
77
|
return protocol === "claude-json" || protocol === "claude-stream" || protocol === "claude-runner"
|
|
60
78
|
}
|
|
61
79
|
|
|
62
|
-
/**
|
|
63
|
-
* The normalized vocabulary. Deliberately short — it has to be translatable *from* several
|
|
64
|
-
* harnesses, so anything specific to one of them does not belong in it.
|
|
65
|
-
*/
|
|
66
|
-
export const TASK_EVENT_TYPES = [
|
|
67
|
-
"session.started", // { harness, model?, cwd?, tools? }
|
|
68
|
-
"session.title", // { title } — the name the harness gave the conversation
|
|
69
|
-
"message", // { role, text }
|
|
70
|
-
"thinking", // { text }
|
|
71
|
-
"tool.call", // { callId, name, input }
|
|
72
|
-
"tool.result", // { callId, isError, output }
|
|
73
|
-
"plan", // { steps: [{ text, status }] }
|
|
74
|
-
// { scope: "call" | "session", model?, inputTokens?, outputTokens?, cacheReadTokens?,
|
|
75
|
-
// cacheCreationTokens?, contextTokens?, costUsd?, turns?, durationMs? } — see `summarizeUsage`
|
|
76
|
-
"usage",
|
|
77
|
-
// { status, stopReason?, summary?, limit? } — `limit` ({ kind, status, message, resetsAt }) when
|
|
78
|
-
// the turn ended because the account hit a usage/rate limit or the API was overloaded, see
|
|
79
|
-
// `turn-limit.ts`
|
|
80
|
-
"session.ended",
|
|
81
|
-
// { tasks: [{ id, kind, description }] } — what the harness still runs in the background, whole
|
|
82
|
-
"session.background",
|
|
83
|
-
"raw", // anything an adapter could not place
|
|
84
|
-
] as const
|
|
85
|
-
export type TaskEventType = (typeof TASK_EVENT_TYPES)[number]
|
|
86
|
-
|
|
87
|
-
export type NormalizedEvent = {
|
|
88
|
-
type: TaskEventType
|
|
89
|
-
payload: Record<string, unknown>
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Per-field ceiling on stored text. A single `tool.result` can be a 2 MB file read; the timeline
|
|
94
|
-
* needs enough of it to be readable, not all of it. `truncated` is set so the UI can say so
|
|
95
|
-
* instead of quietly lying about what the agent saw.
|
|
96
|
-
*/
|
|
97
|
-
const MAX_TEXT = 8_000
|
|
98
|
-
|
|
99
|
-
function clip(value: unknown): { text: string; truncated: boolean } {
|
|
100
|
-
const text =
|
|
101
|
-
typeof value === "string" ? value : value === undefined || value === null ? "" : safeStringify(value)
|
|
102
|
-
return text.length > MAX_TEXT ? { text: text.slice(0, MAX_TEXT), truncated: true } : { text, truncated: false }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function safeStringify(value: unknown): string {
|
|
106
|
-
try {
|
|
107
|
-
return JSON.stringify(value) ?? ""
|
|
108
|
-
} catch {
|
|
109
|
-
return String(value)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
80
|
/**
|
|
114
81
|
* The command actually run, given the project's harness and its protocol.
|
|
115
82
|
*
|
|
@@ -180,12 +147,13 @@ export function interruptLine(protocol: AgentProtocol, requestId: string): strin
|
|
|
180
147
|
* `control_response` (an interrupt's, say) without keeping state between write and read.
|
|
181
148
|
*/
|
|
182
149
|
export function sessionTitleRequestLine(protocol: AgentProtocol, requestId: string, description: string): string | null {
|
|
183
|
-
if (protocol !== "claude-stream" && protocol !== "claude-runner") return null
|
|
150
|
+
if (protocol !== "claude-stream" && protocol !== "claude-runner" && protocol !== "agent-v1") return null
|
|
184
151
|
const text = description.trim()
|
|
185
152
|
if (!text) return null
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
153
|
+
// A runner is asked with the contract's own verb, and answers — if its harness can name a
|
|
154
|
+
// conversation at all — with a `session.title` line (`agent-v1`), or, on the old Claude runner,
|
|
155
|
+
// with the very `control_response` the CLI would have printed.
|
|
156
|
+
if (isRunner(protocol)) return runnerLine({ type: "title", id: `${TITLE_REQUEST_PREFIX}${requestId}`, description: text })
|
|
189
157
|
return `${JSON.stringify({
|
|
190
158
|
type: "control_request",
|
|
191
159
|
request_id: `${TITLE_REQUEST_PREFIX}${requestId}`,
|
|
@@ -193,9 +161,6 @@ export function sessionTitleRequestLine(protocol: AgentProtocol, requestId: stri
|
|
|
193
161
|
})}\n`
|
|
194
162
|
}
|
|
195
163
|
|
|
196
|
-
/** Marks the `control_request`s that ask for a title, and so the `control_response`s that answer. */
|
|
197
|
-
const TITLE_REQUEST_PREFIX = "spunto-title-"
|
|
198
|
-
|
|
199
164
|
/**
|
|
200
165
|
* A shell that prints the line where the harness wrote **the name it gave the conversation**, or
|
|
201
166
|
* null for a dialect that has no such thing.
|
|
@@ -249,9 +214,6 @@ export function readsTitleOutOfBand(protocol: AgentProtocol): boolean {
|
|
|
249
214
|
return speaksClaude(protocol)
|
|
250
215
|
}
|
|
251
216
|
|
|
252
|
-
/** Longest name we keep. The column allows 200; a harness that writes an essay gets cut. */
|
|
253
|
-
const MAX_TITLE = 200
|
|
254
|
-
|
|
255
217
|
/**
|
|
256
218
|
* The name, out of what the probe printed. Newest first, and the first line that is really a
|
|
257
219
|
* title record wins — see the `tail -5` above.
|
|
@@ -275,13 +237,6 @@ export function parseSessionTitle(protocol: AgentProtocol, output: string): { ti
|
|
|
275
237
|
return null
|
|
276
238
|
}
|
|
277
239
|
|
|
278
|
-
/** One line, no runs of whitespace, bounded — a title goes in a list row and in a page header. */
|
|
279
|
-
export function cleanTitle(value: unknown): string | null {
|
|
280
|
-
if (typeof value !== "string") return null
|
|
281
|
-
const title = value.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE).trim()
|
|
282
|
-
return title || null
|
|
283
|
-
}
|
|
284
|
-
|
|
285
240
|
/**
|
|
286
241
|
* Parse one line of a harness's stream into zero or more normalized events.
|
|
287
242
|
*
|
|
@@ -289,6 +244,7 @@ export function cleanTitle(value: unknown): string | null {
|
|
|
289
244
|
* or an event type we deliberately have no use for.
|
|
290
245
|
*/
|
|
291
246
|
export function parseStreamLine(protocol: AgentProtocol, line: string): NormalizedEvent[] {
|
|
247
|
+
if (protocol === "agent-v1") return readContractLine(line).map(({ source: _s, ...event }) => event)
|
|
292
248
|
const trimmed = line.trim()
|
|
293
249
|
if (!trimmed) return []
|
|
294
250
|
let parsed: unknown
|
|
@@ -321,333 +277,10 @@ function adaptPassthrough(obj: Record<string, unknown>): NormalizedEvent[] {
|
|
|
321
277
|
return [{ type: type as TaskEventType, payload }]
|
|
322
278
|
}
|
|
323
279
|
|
|
324
|
-
/**
|
|
325
|
-
* `codex-runner` — the events of `@openai/codex-sdk`'s `runStreamed()` (`ThreadEvent`), printed one
|
|
326
|
-
* per line by the Codex runner (RFC 0048). Written against the SDK's own types (0.164.0); the
|
|
327
|
-
* runner adds nothing of its own beyond a `thread.started` for a resumed thread (the SDK only
|
|
328
|
-
* announces new ones) and a `turn.failed` when a turn is interrupted. Not yet run against a real
|
|
329
|
-
* OpenAI account: the shapes below were measured on a failing turn (401) only.
|
|
330
|
-
*
|
|
331
|
-
* Codex works per **turn**, not per session: `turn.completed` is a hand-back the way Claude's
|
|
332
|
-
* `result` is, so it ends the turn here too. Its usage is the turn's sum, which is why it is filed
|
|
333
|
-
* as a `call` (summed over the task) without a `contextTokens`: no single number of it is the
|
|
334
|
-
* size of the prompt last sent.
|
|
335
|
-
*/
|
|
336
|
-
function adaptCodex(obj: Record<string, unknown>): NormalizedEvent[] {
|
|
337
|
-
const type = obj.type
|
|
338
|
-
if (type === "thread.started") {
|
|
339
|
-
return [{ type: "session.started", payload: { harness: "codex", model: null, sessionId: typeof obj.thread_id === "string" ? obj.thread_id : null } }]
|
|
340
|
-
}
|
|
341
|
-
if (type === "turn.started") return []
|
|
342
|
-
if (type === "turn.completed") {
|
|
343
|
-
const u = (obj.usage ?? {}) as Record<string, unknown>
|
|
344
|
-
return [
|
|
345
|
-
{
|
|
346
|
-
type: "usage",
|
|
347
|
-
payload: {
|
|
348
|
-
scope: "call",
|
|
349
|
-
model: null,
|
|
350
|
-
inputTokens: num(u.input_tokens) ?? 0,
|
|
351
|
-
outputTokens: (num(u.output_tokens) ?? 0) + (num(u.reasoning_output_tokens) ?? 0),
|
|
352
|
-
cacheReadTokens: num(u.cached_input_tokens) ?? 0,
|
|
353
|
-
cacheCreationTokens: num(u.cache_write_input_tokens) ?? 0,
|
|
354
|
-
},
|
|
355
|
-
},
|
|
356
|
-
{ type: "session.ended", payload: { status: "succeeded", stopReason: null, summary: "" } },
|
|
357
|
-
]
|
|
358
|
-
}
|
|
359
|
-
if (type === "turn.failed") {
|
|
360
|
-
const error = (obj.error ?? {}) as Record<string, unknown>
|
|
361
|
-
return [{ type: "session.ended", payload: { status: "failed", stopReason: null, summary: clip(error.message).text } }]
|
|
362
|
-
}
|
|
363
|
-
// Not an end: measured, Codex emits `error` for every retry ("Reconnecting... 2/5 (401 …)") and
|
|
364
|
-
// only the `turn.failed` that follows the last one ends the turn. Kept visible, not fatal.
|
|
365
|
-
if (type === "error") return [{ type: "raw", payload: { json: obj } }]
|
|
366
|
-
if (type !== "item.started" && type !== "item.updated" && type !== "item.completed") {
|
|
367
|
-
return [{ type: "raw", payload: { json: obj } }]
|
|
368
|
-
}
|
|
369
|
-
const item = (obj.item ?? {}) as Record<string, unknown>
|
|
370
|
-
const callId = typeof item.id === "string" ? item.id : null
|
|
371
|
-
const started = type === "item.started"
|
|
372
|
-
const completed = type === "item.completed"
|
|
373
|
-
switch (item.type) {
|
|
374
|
-
case "agent_message": {
|
|
375
|
-
if (!completed) return []
|
|
376
|
-
const { text, truncated } = clip(item.text)
|
|
377
|
-
return text ? [{ type: "message", payload: { role: "assistant", text, truncated } }] : []
|
|
378
|
-
}
|
|
379
|
-
case "reasoning": {
|
|
380
|
-
if (!completed) return []
|
|
381
|
-
const { text, truncated } = clip(item.text)
|
|
382
|
-
return text ? [{ type: "thinking", payload: { text, truncated } }] : []
|
|
383
|
-
}
|
|
384
|
-
case "command_execution": {
|
|
385
|
-
if (started) {
|
|
386
|
-
const { text: input, truncated } = clip({ command: item.command })
|
|
387
|
-
return [{ type: "tool.call", payload: { callId, name: "Bash", input, truncated } }]
|
|
388
|
-
}
|
|
389
|
-
if (!completed) return []
|
|
390
|
-
const { text: output, truncated } = clip(item.aggregated_output)
|
|
391
|
-
const failed = item.status === "failed" || (typeof item.exit_code === "number" && item.exit_code !== 0)
|
|
392
|
-
return [{ type: "tool.result", payload: { callId, isError: failed, output, truncated } }]
|
|
393
|
-
}
|
|
394
|
-
case "file_change": {
|
|
395
|
-
// Reported once, applied: the call and its outcome arrive together.
|
|
396
|
-
if (!completed) return []
|
|
397
|
-
const changes = Array.isArray(item.changes) ? item.changes : []
|
|
398
|
-
const { text: input, truncated } = clip({ changes })
|
|
399
|
-
const output = changes
|
|
400
|
-
.filter((c): c is Record<string, unknown> => !!c && typeof c === "object")
|
|
401
|
-
.map((c) => `${c.kind} ${c.path}`)
|
|
402
|
-
.join("\n")
|
|
403
|
-
return [
|
|
404
|
-
{ type: "tool.call", payload: { callId, name: "Edit", input, truncated } },
|
|
405
|
-
{ type: "tool.result", payload: { callId, isError: item.status === "failed", output, truncated: false } },
|
|
406
|
-
]
|
|
407
|
-
}
|
|
408
|
-
case "mcp_tool_call": {
|
|
409
|
-
const name = `${typeof item.server === "string" ? item.server : "mcp"}.${typeof item.tool === "string" ? item.tool : "tool"}`
|
|
410
|
-
if (started) {
|
|
411
|
-
const { text: input, truncated } = clip(item.arguments)
|
|
412
|
-
return [{ type: "tool.call", payload: { callId, name, input, truncated } }]
|
|
413
|
-
}
|
|
414
|
-
if (!completed) return []
|
|
415
|
-
const error = (item.error ?? null) as Record<string, unknown> | null
|
|
416
|
-
const { text: output, truncated } = clip(error ? error.message : (item.result as Record<string, unknown> | undefined)?.content ?? "")
|
|
417
|
-
return [{ type: "tool.result", payload: { callId, isError: !!error || item.status === "failed", output, truncated } }]
|
|
418
|
-
}
|
|
419
|
-
case "web_search": {
|
|
420
|
-
if (!started) return []
|
|
421
|
-
const { text: input, truncated } = clip({ query: item.query })
|
|
422
|
-
return [{ type: "tool.call", payload: { callId, name: "WebSearch", input, truncated } }]
|
|
423
|
-
}
|
|
424
|
-
case "todo_list": {
|
|
425
|
-
const steps = (Array.isArray(item.items) ? item.items : [])
|
|
426
|
-
.filter((t): t is Record<string, unknown> => !!t && typeof t === "object")
|
|
427
|
-
.map((t) => ({ text: clip(t.text).text, status: t.completed === true ? "completed" : "pending" }))
|
|
428
|
-
.filter((s) => s.text)
|
|
429
|
-
return steps.length > 0 ? [{ type: "plan", payload: { steps } }] : []
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return [{ type: "raw", payload: { json: obj } }]
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* `claude-json` — Claude Code's `--output-format stream-json --verbose`.
|
|
437
|
-
*
|
|
438
|
-
* Written against output measured in a real worker, not against a spec: the shapes below are
|
|
439
|
-
* what the CLI emitted, and `task-events.service.test.ts` replays recorded samples so a change in
|
|
440
|
-
* that output shows up as a failing test rather than as an empty timeline.
|
|
441
|
-
*/
|
|
442
|
-
function adaptClaudeJson(obj: Record<string, unknown>): NormalizedEvent[] {
|
|
443
|
-
const type = obj.type
|
|
444
|
-
|
|
445
|
-
if (type === "system" && obj.subtype === "init") {
|
|
446
|
-
const model = typeof obj.model === "string" ? obj.model : null
|
|
447
|
-
return [
|
|
448
|
-
{
|
|
449
|
-
type: "session.started",
|
|
450
|
-
payload: {
|
|
451
|
-
harness: "claude-code",
|
|
452
|
-
model,
|
|
453
|
-
// Read here and nowhere else: this is the only line that spells the model the way the
|
|
454
|
-
// *session* was opened (`claude-opus-5[1m]`), suffix included — the assistant messages
|
|
455
|
-
// that follow carry the plain API id. From the catalogue only, so this stays a pure
|
|
456
|
-
// function; the ingestion loop replaces it with the model's own answer when the task
|
|
457
|
-
// has a key to ask with (`resolveContextWindow`).
|
|
458
|
-
contextWindow: contextWindowOf(model),
|
|
459
|
-
cwd: typeof obj.cwd === "string" ? obj.cwd : null,
|
|
460
|
-
sessionId: typeof obj.session_id === "string" ? obj.session_id : null,
|
|
461
|
-
tools: Array.isArray(obj.tools) ? obj.tools.filter((t) => typeof t === "string") : [],
|
|
462
|
-
},
|
|
463
|
-
},
|
|
464
|
-
]
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// The set of background tasks (a `run_in_background` shell, a backgrounded subagent) still
|
|
468
|
-
// alive, restated whole on every change. Why it has to be read: a `result` ends the *turn*, not
|
|
469
|
-
// the work — measured, a turn that leaves a background task running emits its `result`, and
|
|
470
|
-
// when that task finishes the harness wakes itself up for another turn and another `result`,
|
|
471
|
-
// nobody having said anything. So a `result` is a hand-back only if nothing was left running
|
|
472
|
-
// behind it, and this line is the only place that says so.
|
|
473
|
-
if (type === "system" && obj.subtype === "background_tasks_changed") {
|
|
474
|
-
const tasks = (Array.isArray(obj.tasks) ? obj.tasks : [])
|
|
475
|
-
.filter((t): t is Record<string, unknown> => !!t && typeof t === "object")
|
|
476
|
-
.map((t) => ({
|
|
477
|
-
id: typeof t.task_id === "string" ? t.task_id : null,
|
|
478
|
-
kind: typeof t.task_type === "string" ? t.task_type : null,
|
|
479
|
-
description: typeof t.description === "string" ? t.description : null,
|
|
480
|
-
}))
|
|
481
|
-
return [{ type: "session.background", payload: { tasks } }]
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (type === "assistant" || type === "user") {
|
|
485
|
-
const message = obj.message as Record<string, unknown> | undefined
|
|
486
|
-
const content = Array.isArray(message?.content) ? message!.content : []
|
|
487
|
-
const role = type === "assistant" ? "assistant" : "user"
|
|
488
|
-
const events: NormalizedEvent[] = []
|
|
489
|
-
for (const rawBlock of content) {
|
|
490
|
-
if (!rawBlock || typeof rawBlock !== "object") continue
|
|
491
|
-
const block = rawBlock as Record<string, unknown>
|
|
492
|
-
switch (block.type) {
|
|
493
|
-
case "text": {
|
|
494
|
-
const { text, truncated } = clip(block.text)
|
|
495
|
-
if (text) events.push({ type: "message", payload: { role, text, truncated } })
|
|
496
|
-
break
|
|
497
|
-
}
|
|
498
|
-
case "thinking": {
|
|
499
|
-
const { text, truncated } = clip(block.thinking)
|
|
500
|
-
if (text) events.push({ type: "thinking", payload: { text, truncated } })
|
|
501
|
-
break
|
|
502
|
-
}
|
|
503
|
-
case "tool_use": {
|
|
504
|
-
const name = typeof block.name === "string" ? block.name : "tool"
|
|
505
|
-
const { text: input, truncated } = clip(block.input)
|
|
506
|
-
events.push({
|
|
507
|
-
type: "tool.call",
|
|
508
|
-
payload: { callId: typeof block.id === "string" ? block.id : null, name, input, truncated },
|
|
509
|
-
})
|
|
510
|
-
// A todo list is a plan, and a plan is the single most useful thing to show while a
|
|
511
|
-
// session runs. Emitted *alongside* the tool call rather than instead of it: the call
|
|
512
|
-
// is what happened, the plan is what it means.
|
|
513
|
-
const plan = todoPlan(block.input)
|
|
514
|
-
if (plan) events.push(plan)
|
|
515
|
-
break
|
|
516
|
-
}
|
|
517
|
-
case "tool_result": {
|
|
518
|
-
const { text: output, truncated } = clip(
|
|
519
|
-
typeof block.content === "string" ? block.content : block.content ?? "",
|
|
520
|
-
)
|
|
521
|
-
events.push({
|
|
522
|
-
type: "tool.result",
|
|
523
|
-
payload: {
|
|
524
|
-
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : null,
|
|
525
|
-
isError: block.is_error === true,
|
|
526
|
-
output,
|
|
527
|
-
truncated,
|
|
528
|
-
},
|
|
529
|
-
})
|
|
530
|
-
break
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
// The synthetic message Claude Code writes when the API refused the call carries the one
|
|
535
|
-
// structured reset time of a usage limit; the `result` that follows does not. Kept on the
|
|
536
|
-
// message so ingestion can carry it over to the turn's end (`stampLimitReset`).
|
|
537
|
-
if (type === "assistant") {
|
|
538
|
-
const limit = claudeTurnLimit(obj)
|
|
539
|
-
if (limit) for (const e of events) if (e.type === "message") e.payload.limit = limit
|
|
540
|
-
}
|
|
541
|
-
// What the call that produced this message cost, and how full the context was when it ran.
|
|
542
|
-
// Only assistant messages carry it — a `user` line is tool results being handed back.
|
|
543
|
-
if (type === "assistant") {
|
|
544
|
-
const measured = callUsage(message)
|
|
545
|
-
if (measured) events.push(measured)
|
|
546
|
-
}
|
|
547
|
-
return events
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
if (type === "result") {
|
|
551
|
-
const usage = (obj.usage ?? {}) as Record<string, unknown>
|
|
552
|
-
const events: NormalizedEvent[] = [
|
|
553
|
-
{
|
|
554
|
-
type: "usage",
|
|
555
|
-
payload: {
|
|
556
|
-
// The harness's own running total, **cumulative over the whole process** — with an
|
|
557
|
-
// interactive protocol a `result` lands at the end of every turn and each one restates
|
|
558
|
-
// the session so far. Summing them would bill the first turn once per turn that
|
|
559
|
-
// followed; the latest one is the answer. (Claude Code's own SDK reader does the same:
|
|
560
|
-
// it overwrites `total_cost_usd`/`num_turns` on each result rather than adding.)
|
|
561
|
-
scope: "session",
|
|
562
|
-
inputTokens: num(usage.input_tokens),
|
|
563
|
-
outputTokens: num(usage.output_tokens),
|
|
564
|
-
cacheReadTokens: num(usage.cache_read_input_tokens),
|
|
565
|
-
cacheCreationTokens: num(usage.cache_creation_input_tokens),
|
|
566
|
-
costUsd: num(obj.total_cost_usd),
|
|
567
|
-
turns: num(obj.num_turns),
|
|
568
|
-
durationMs: num(obj.duration_ms),
|
|
569
|
-
},
|
|
570
|
-
},
|
|
571
|
-
{
|
|
572
|
-
type: "session.ended",
|
|
573
|
-
payload: {
|
|
574
|
-
// `is_error` too: a turn the API refused (a usage limit, measured) still says
|
|
575
|
-
// `subtype: "success"` — it is the *harness* that ended cleanly, not the turn.
|
|
576
|
-
status: obj.subtype === "success" && obj.is_error !== true ? "succeeded" : "failed",
|
|
577
|
-
stopReason: typeof obj.stop_reason === "string" ? obj.stop_reason : null,
|
|
578
|
-
summary: clip(obj.result).text,
|
|
579
|
-
...limitField(obj),
|
|
580
|
-
},
|
|
581
|
-
},
|
|
582
|
-
]
|
|
583
|
-
return events
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// The answer to `sessionTitleRequestLine`. Only ours, recognised by request id: a
|
|
587
|
-
// `control_response` is also how an interrupt is acknowledged, and that one is no name.
|
|
588
|
-
if (type === "control_response") {
|
|
589
|
-
const response = (obj.response ?? {}) as Record<string, unknown>
|
|
590
|
-
const inner = (response.response ?? {}) as Record<string, unknown>
|
|
591
|
-
const requestId = typeof response.request_id === "string" ? response.request_id : ""
|
|
592
|
-
const title = response.subtype === "success" && requestId.startsWith(TITLE_REQUEST_PREFIX) ? cleanTitle(inner.title) : null
|
|
593
|
-
if (title) return [{ type: "session.title", payload: { title } }]
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Known-but-uninteresting (`rate_limit_event`, `system/thinking_tokens`) and anything the CLI
|
|
597
|
-
// grows next land here rather than being dropped: an unknown event is information about the
|
|
598
|
-
// harness, and the UI keeps it behind a toggle.
|
|
599
|
-
return [{ type: "raw", payload: { json: obj } }]
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function limitField(obj: Record<string, unknown>): { limit?: ReturnType<typeof claudeTurnLimit> } {
|
|
603
|
-
const limit = claudeTurnLimit(obj)
|
|
604
|
-
return limit ? { limit } : {}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
280
|
function num(value: unknown): number | null {
|
|
608
281
|
return typeof value === "number" && Number.isFinite(value) ? value : null
|
|
609
282
|
}
|
|
610
283
|
|
|
611
|
-
/**
|
|
612
|
-
* What **one model call** consumed, read off an assistant message.
|
|
613
|
-
*
|
|
614
|
-
* Two different questions are answered by these numbers, and only the first is obvious:
|
|
615
|
-
*
|
|
616
|
-
* - added up over the session, they are what it has spent in tokens — and unlike the `result`
|
|
617
|
-
* line, they exist *during* a turn, which is when someone is watching;
|
|
618
|
-
* - the latest one alone is the **context**: `input + cache_read + cache_creation` is the prompt
|
|
619
|
-
* the harness just sent, so it is how full the window is right now. Same formula Claude Code
|
|
620
|
-
* uses for its own context meter.
|
|
621
|
-
*
|
|
622
|
-
* Skipped when every counter is zero: an API error makes the CLI emit a synthetic assistant
|
|
623
|
-
* message with an all-zero usage, and drawing a context that just fell to zero would be a lie
|
|
624
|
-
* about the session rather than a fact about the error.
|
|
625
|
-
*/
|
|
626
|
-
function callUsage(message: Record<string, unknown> | undefined): NormalizedEvent | null {
|
|
627
|
-
const usage = message?.usage
|
|
628
|
-
if (!usage || typeof usage !== "object") return null
|
|
629
|
-
const u = usage as Record<string, unknown>
|
|
630
|
-
const inputTokens = num(u.input_tokens) ?? 0
|
|
631
|
-
const outputTokens = num(u.output_tokens) ?? 0
|
|
632
|
-
const cacheReadTokens = num(u.cache_read_input_tokens) ?? 0
|
|
633
|
-
const cacheCreationTokens = num(u.cache_creation_input_tokens) ?? 0
|
|
634
|
-
if (inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens === 0) return null
|
|
635
|
-
// `<synthetic>` is what the CLI puts there when no model ran at all.
|
|
636
|
-
const model = typeof message?.model === "string" && !message.model.startsWith("<") ? message.model : null
|
|
637
|
-
return {
|
|
638
|
-
type: "usage",
|
|
639
|
-
payload: {
|
|
640
|
-
scope: "call",
|
|
641
|
-
model,
|
|
642
|
-
inputTokens,
|
|
643
|
-
outputTokens,
|
|
644
|
-
cacheReadTokens,
|
|
645
|
-
cacheCreationTokens,
|
|
646
|
-
contextTokens: inputTokens + cacheReadTokens + cacheCreationTokens,
|
|
647
|
-
},
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
284
|
/** What a session has consumed so far — the recap the cockpit puts beside the timeline. */
|
|
652
285
|
export type SessionUsage = {
|
|
653
286
|
/** The model that ran, as the harness named it. */
|
|
@@ -723,24 +356,6 @@ export function summarizeUsage(input: UsageInput): SessionUsage | null {
|
|
|
723
356
|
}
|
|
724
357
|
}
|
|
725
358
|
|
|
726
|
-
/** Claude Code's TodoWrite input, as a `plan` — `{ todos: [{ content, status }] }`. */
|
|
727
|
-
function todoPlan(input: unknown): NormalizedEvent | null {
|
|
728
|
-
if (!input || typeof input !== "object") return null
|
|
729
|
-
const todos = (input as Record<string, unknown>).todos
|
|
730
|
-
if (!Array.isArray(todos) || todos.length === 0) return null
|
|
731
|
-
const steps = todos
|
|
732
|
-
.filter((t): t is Record<string, unknown> => !!t && typeof t === "object")
|
|
733
|
-
.map((t) => ({
|
|
734
|
-
text: clip(t.content ?? t.activeForm ?? "").text,
|
|
735
|
-
status: typeof t.status === "string" ? t.status : "pending",
|
|
736
|
-
}))
|
|
737
|
-
.filter((s) => s.text)
|
|
738
|
-
return steps.length > 0 ? { type: "plan", payload: { steps } } : null
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
/** An event and the harness line it came from. One line can yield several. */
|
|
742
|
-
export type SourcedEvent = NormalizedEvent & { source: string }
|
|
743
|
-
|
|
744
359
|
/**
|
|
745
360
|
* Parse whole lines, keeping each event attached to the line it was derived from.
|
|
746
361
|
*
|
|
@@ -755,17 +370,17 @@ export function parseLines(protocol: AgentProtocol, lines: string[], budget: num
|
|
|
755
370
|
const out: SourcedEvent[] = []
|
|
756
371
|
for (const line of lines) {
|
|
757
372
|
if (out.length >= budget) break
|
|
758
|
-
let events:
|
|
373
|
+
let events: SourcedEvent[]
|
|
759
374
|
try {
|
|
760
|
-
|
|
375
|
+
// The contract carries each event's source itself; every other dialect's source is its line.
|
|
376
|
+
events = protocol === "agent-v1" ? readContractLine(line) : parseStreamLine(protocol, line).map((e) => ({ ...e, source: line.trim() }))
|
|
761
377
|
} catch (err) {
|
|
762
378
|
console.warn(`[tasks] agent stream adapter threw: ${err}`)
|
|
763
379
|
continue
|
|
764
380
|
}
|
|
765
|
-
const source = line.trim()
|
|
766
381
|
for (const event of events) {
|
|
767
382
|
if (out.length >= budget) break
|
|
768
|
-
out.push(
|
|
383
|
+
out.push(event)
|
|
769
384
|
}
|
|
770
385
|
}
|
|
771
386
|
return out
|