@spunto/build 0.22.0 → 0.24.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.
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Reading an agent session as it happens (RFC 0020, phase 1).
3
3
  *
4
- * A task's harness is launched with the prompt on stdin and nothing else agreed — that poverty is
5
- * what makes `taskAgentCommand` able to hold Claude Code, Codex or a shell script. Streaming needs
6
- * strictly more than that, so it is **opt-in per project** (`projects.taskAgentProtocol`) and the
7
- * extra knowledge lives here, in one adapter per dialect, and nowhere else.
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 const AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream", "jsonl", "claude-runner", "codex-runner"] as const
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; its stdout is the harness's own dialect, normalised here like any other.
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
- // The Claude runner asks the SDK (`generateSessionTitle`), and answers with the very
187
- // `control_response` the CLI would have printed — so the reading side below is one path.
188
- if (protocol === "claude-runner") return runnerLine({ type: "title", id: `${TITLE_REQUEST_PREFIX}${requestId}`, description: text })
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,338 +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
- // Tokens of a message still being written (`includePartialMessages`). Not events: the finished
597
- // `assistant` line repeats them whole, and one row per token would bury the session. They fold
598
- // into the transient draft instead (`draft.ts`).
599
- if (type === "stream_event") return []
600
-
601
- // Known-but-uninteresting (`rate_limit_event`, `system/thinking_tokens`) and anything the CLI
602
- // grows next land here rather than being dropped: an unknown event is information about the
603
- // harness, and the UI keeps it behind a toggle.
604
- return [{ type: "raw", payload: { json: obj } }]
605
- }
606
-
607
- function limitField(obj: Record<string, unknown>): { limit?: ReturnType<typeof claudeTurnLimit> } {
608
- const limit = claudeTurnLimit(obj)
609
- return limit ? { limit } : {}
610
- }
611
-
612
280
  function num(value: unknown): number | null {
613
281
  return typeof value === "number" && Number.isFinite(value) ? value : null
614
282
  }
615
283
 
616
- /**
617
- * What **one model call** consumed, read off an assistant message.
618
- *
619
- * Two different questions are answered by these numbers, and only the first is obvious:
620
- *
621
- * - added up over the session, they are what it has spent in tokens — and unlike the `result`
622
- * line, they exist *during* a turn, which is when someone is watching;
623
- * - the latest one alone is the **context**: `input + cache_read + cache_creation` is the prompt
624
- * the harness just sent, so it is how full the window is right now. Same formula Claude Code
625
- * uses for its own context meter.
626
- *
627
- * Skipped when every counter is zero: an API error makes the CLI emit a synthetic assistant
628
- * message with an all-zero usage, and drawing a context that just fell to zero would be a lie
629
- * about the session rather than a fact about the error.
630
- */
631
- function callUsage(message: Record<string, unknown> | undefined): NormalizedEvent | null {
632
- const usage = message?.usage
633
- if (!usage || typeof usage !== "object") return null
634
- const u = usage as Record<string, unknown>
635
- const inputTokens = num(u.input_tokens) ?? 0
636
- const outputTokens = num(u.output_tokens) ?? 0
637
- const cacheReadTokens = num(u.cache_read_input_tokens) ?? 0
638
- const cacheCreationTokens = num(u.cache_creation_input_tokens) ?? 0
639
- if (inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens === 0) return null
640
- // `<synthetic>` is what the CLI puts there when no model ran at all.
641
- const model = typeof message?.model === "string" && !message.model.startsWith("<") ? message.model : null
642
- return {
643
- type: "usage",
644
- payload: {
645
- scope: "call",
646
- model,
647
- inputTokens,
648
- outputTokens,
649
- cacheReadTokens,
650
- cacheCreationTokens,
651
- contextTokens: inputTokens + cacheReadTokens + cacheCreationTokens,
652
- },
653
- }
654
- }
655
-
656
284
  /** What a session has consumed so far — the recap the cockpit puts beside the timeline. */
657
285
  export type SessionUsage = {
658
286
  /** The model that ran, as the harness named it. */
@@ -728,24 +356,6 @@ export function summarizeUsage(input: UsageInput): SessionUsage | null {
728
356
  }
729
357
  }
730
358
 
731
- /** Claude Code's TodoWrite input, as a `plan` — `{ todos: [{ content, status }] }`. */
732
- function todoPlan(input: unknown): NormalizedEvent | null {
733
- if (!input || typeof input !== "object") return null
734
- const todos = (input as Record<string, unknown>).todos
735
- if (!Array.isArray(todos) || todos.length === 0) return null
736
- const steps = todos
737
- .filter((t): t is Record<string, unknown> => !!t && typeof t === "object")
738
- .map((t) => ({
739
- text: clip(t.content ?? t.activeForm ?? "").text,
740
- status: typeof t.status === "string" ? t.status : "pending",
741
- }))
742
- .filter((s) => s.text)
743
- return steps.length > 0 ? { type: "plan", payload: { steps } } : null
744
- }
745
-
746
- /** An event and the harness line it came from. One line can yield several. */
747
- export type SourcedEvent = NormalizedEvent & { source: string }
748
-
749
359
  /**
750
360
  * Parse whole lines, keeping each event attached to the line it was derived from.
751
361
  *
@@ -760,17 +370,17 @@ export function parseLines(protocol: AgentProtocol, lines: string[], budget: num
760
370
  const out: SourcedEvent[] = []
761
371
  for (const line of lines) {
762
372
  if (out.length >= budget) break
763
- let events: NormalizedEvent[]
373
+ let events: SourcedEvent[]
764
374
  try {
765
- events = parseStreamLine(protocol, line)
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() }))
766
377
  } catch (err) {
767
378
  console.warn(`[tasks] agent stream adapter threw: ${err}`)
768
379
  continue
769
380
  }
770
- const source = line.trim()
771
381
  for (const event of events) {
772
382
  if (out.length >= budget) break
773
- out.push({ ...event, source })
383
+ out.push(event)
774
384
  }
775
385
  }
776
386
  return out
@@ -29,9 +29,47 @@ function append(draft: AgentDraft | null, text: string): AgentDraft {
29
29
  return next.length > MAX_DRAFT ? { text: next.slice(0, MAX_DRAFT), truncated: true } : { text: next, truncated: false }
30
30
  }
31
31
 
32
- /** Claude's dialects print token deltas; Codex's prints the message so far on `item.updated`. */
32
+ /** Claude's dialects print token deltas; Codex's prints the message so far on `item.updated`; the contract, `message.delta`. */
33
33
  function streamsPartials(protocol: AgentProtocol): boolean {
34
- return protocol === "claude-runner" || protocol === "claude-stream" || protocol === "claude-json" || protocol === "codex-runner"
34
+ return (
35
+ protocol === "agent-v1" ||
36
+ protocol === "claude-runner" ||
37
+ protocol === "claude-stream" ||
38
+ protocol === "claude-json" ||
39
+ protocol === "codex-runner"
40
+ )
41
+ }
42
+
43
+ /** What closes a draft in the contract: the finished text, or anything that comes after it in the turn. */
44
+ const CLOSES_DRAFT = new Set(["message", "thinking", "tool.call", "tool.result", "session.ended"])
45
+
46
+ /**
47
+ * The contract (`agent-v1`): `message.delta` with `start` opens a fresh draft, without it grows the
48
+ * one in flight. The runner already chose what is a draft (a subagent's words are not), so this
49
+ * only folds — whichever harness it was.
50
+ */
51
+ function foldContract(lines: string[], draft: AgentDraft | null): AgentDraft | null {
52
+ let current = draft
53
+ for (const line of lines) {
54
+ const trimmed = line.trim()
55
+ if (!trimmed.startsWith("{")) continue
56
+ // Cheap pre-filter, before parsing a line that may carry megabytes.
57
+ if (!trimmed.includes('"message') && !trimmed.includes('"thinking"') && !trimmed.includes('"tool.') && !trimmed.includes('"session.ended"')) continue
58
+ let obj: Record<string, unknown>
59
+ try {
60
+ const parsed = JSON.parse(trimmed)
61
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue
62
+ obj = parsed as Record<string, unknown>
63
+ } catch {
64
+ continue
65
+ }
66
+ if (obj.type === "message.delta" && typeof obj.text === "string") {
67
+ current = append(obj.start === true ? null : current, obj.text)
68
+ } else if (typeof obj.type === "string" && CLOSES_DRAFT.has(obj.type)) {
69
+ current = null
70
+ }
71
+ }
72
+ return current
35
73
  }
36
74
 
37
75
  /**
@@ -82,6 +120,7 @@ function foldCodex(lines: string[], draft: AgentDraft | null): AgentDraft | null
82
120
  */
83
121
  export function foldDraft(protocol: AgentProtocol, lines: string[], draft: AgentDraft | null): AgentDraft | null {
84
122
  if (!streamsPartials(protocol)) return null
123
+ if (protocol === "agent-v1") return foldContract(lines, draft)
85
124
  if (protocol === "codex-runner") return foldCodex(lines, draft)
86
125
  let current = draft
87
126
  for (const line of lines) {