hilos-agent 0.9.0 → 0.9.2

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.
@@ -25,13 +25,25 @@
25
25
  * @typedef {object} AgentEvent
26
26
  * A normalized, render-ready step. Exactly one of these per meaningful thing the
27
27
  * coding agent did. Small on purpose — the LiveRunCard maps `t` to a label.
28
- * @property {'phase'|'edit'|'run'|'read'|'think'|'note'|'session'|'result'} t
28
+ * @property {'phase'|'edit'|'run'|'read'|'think'|'note'|'session'|'result'|'usage'|'websearch'|'webfetch'|'subagent'} t
29
29
  * - 'session' → { sessionId } the CLI's resumable session id (init only)
30
30
  * - 'edit' → { path } wrote/edited a file
31
31
  * - 'read' → { path } read a file
32
32
  * - 'run' → { cmd } ran a shell command
33
33
  * - 'note' → { text } the agent's narration / thinking (think→note)
34
+ * - 'websearch' → { query } searched the web (0789)
35
+ * - 'webfetch' → { target } read a web page — host + path ONLY, the
36
+ * query string is stripped before it gets
37
+ * here (0789)
38
+ * - 'subagent' → { text } spawned a helper; ONE line per spawn, the
39
+ * helper's own steps never surface (0789)
34
40
  * - 'result' → { ok, summary? } the run finished (ok=false on error)
41
+ * - 'usage' → { inputTokens, outputTokens, cacheReadTokens,
42
+ * cacheCreationTokens, costUsd?, model? }
43
+ * what the turn cost, as the CLI itself
44
+ * reported it (0787). Never a step — the
45
+ * ring and the activity fold skip it; it
46
+ * rides home on the report instead.
35
47
  * - 'phase' → { name } reserved lifecycle marker (unused by v1
36
48
  * parsers; kept so 0274 can add start/end
37
49
  * phases without widening the type)
@@ -39,9 +51,17 @@
39
51
  * @property {string} [path]
40
52
  * @property {string} [cmd]
41
53
  * @property {string} [text]
54
+ * @property {string} [query]
55
+ * @property {string} [target]
42
56
  * @property {string} [name]
43
57
  * @property {boolean} [ok]
44
58
  * @property {string} [summary]
59
+ * @property {number} [inputTokens]
60
+ * @property {number} [outputTokens]
61
+ * @property {number} [cacheReadTokens]
62
+ * @property {number} [cacheCreationTokens]
63
+ * @property {number} [costUsd]
64
+ * @property {string} [model]
45
65
  */
46
66
 
47
67
  // --- Security: sanitize everything that reaches the UI ---------------------
@@ -93,6 +113,239 @@ function tryParse(line) {
93
113
  }
94
114
  }
95
115
 
116
+ // --- Usage (0787) ----------------------------------------------------------
117
+ //
118
+ // Both live-verified streams already carry what a run cost; before 0787 the
119
+ // parsers read those very lines and threw the numbers away. The rule here is
120
+ // the module's rule everywhere else: a vendor that reports nothing produces
121
+ // NOTHING (no zero-filled event, no invented model), because an honest silence
122
+ // degrades to "Tokens unavailable" on the card while a fabricated zero reads as
123
+ // a free run.
124
+
125
+ /** A finite, non-negative number, or 0. Never throws, never returns NaN. */
126
+ function posNum(value) {
127
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
128
+ }
129
+
130
+ /** Token counts are whole numbers. */
131
+ function tokenNum(value) {
132
+ return Math.floor(posNum(value));
133
+ }
134
+
135
+ /**
136
+ * Build a `usage` event, or null when the CLI reported nothing usable. `model`
137
+ * is optional — plenty of streams price a turn without naming the model, and
138
+ * the daemon fills that in from the model it resolved at spawn.
139
+ */
140
+ function usageEvent({
141
+ inputTokens,
142
+ outputTokens,
143
+ cacheReadTokens,
144
+ cacheCreationTokens,
145
+ costUsd,
146
+ model,
147
+ } = {}) {
148
+ const ev = {
149
+ t: "usage",
150
+ inputTokens: tokenNum(inputTokens),
151
+ outputTokens: tokenNum(outputTokens),
152
+ cacheReadTokens: tokenNum(cacheReadTokens),
153
+ cacheCreationTokens: tokenNum(cacheCreationTokens),
154
+ };
155
+ if (typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0) {
156
+ ev.costUsd = costUsd;
157
+ }
158
+ const id = sanitizeText(typeof model === "string" ? model.trim() : "");
159
+ if (id) ev.model = id;
160
+ const tokens =
161
+ ev.inputTokens + ev.outputTokens + ev.cacheReadTokens + ev.cacheCreationTokens;
162
+ if (!tokens && ev.costUsd == null) return null; // nothing was reported
163
+ return ev;
164
+ }
165
+
166
+ /**
167
+ * Which model a Claude `result` frame credits the work to. Live-verified: the
168
+ * frame carries a `modelUsage` map keyed by model id (a run that spawns
169
+ * subagents lists several), so we credit the id that did the most work rather
170
+ * than whichever key happens to be first.
171
+ */
172
+ function claudeResultModel(obj) {
173
+ const map = obj.modelUsage && typeof obj.modelUsage === "object" ? obj.modelUsage : null;
174
+ if (!map) return "";
175
+ let best = "";
176
+ let bestTokens = -1;
177
+ for (const [id, entry] of Object.entries(map)) {
178
+ if (!id || !entry || typeof entry !== "object") continue;
179
+ const tokens =
180
+ posNum(entry.inputTokens) +
181
+ posNum(entry.outputTokens) +
182
+ posNum(entry.cacheReadInputTokens) +
183
+ posNum(entry.cacheCreationInputTokens);
184
+ if (tokens > bestTokens) {
185
+ bestTokens = tokens;
186
+ best = id;
187
+ }
188
+ }
189
+ return best;
190
+ }
191
+
192
+ /**
193
+ * Accumulate `usage` events into one total.
194
+ *
195
+ * DELTA CONTRACT: `take()` returns everything folded SINCE THE LAST TAKE and
196
+ * resets. Reports are additive on the server (each one writes its own ledger
197
+ * row), so a run that reports twice — a proposal card and then a shipped card,
198
+ * or one revision round per loop — must send what is new each time. Sending a
199
+ * running total would bill the same tokens over and over. The model id is
200
+ * sticky: it is a label, not a quantity, so every take carries the last one seen.
201
+ */
202
+ export function createUsageFold() {
203
+ let pending = null;
204
+ let model = "";
205
+
206
+ const empty = () => ({
207
+ inputTokens: 0,
208
+ outputTokens: 0,
209
+ cacheReadTokens: 0,
210
+ cacheCreationTokens: 0,
211
+ costUsd: null,
212
+ });
213
+
214
+ return {
215
+ /** @param {AgentEvent} ev */
216
+ push(ev) {
217
+ if (!ev || typeof ev !== "object" || ev.t !== "usage") return;
218
+ if (!pending) pending = empty();
219
+ pending.inputTokens += tokenNum(ev.inputTokens);
220
+ pending.outputTokens += tokenNum(ev.outputTokens);
221
+ pending.cacheReadTokens += tokenNum(ev.cacheReadTokens);
222
+ pending.cacheCreationTokens += tokenNum(ev.cacheCreationTokens);
223
+ if (typeof ev.costUsd === "number" && Number.isFinite(ev.costUsd) && ev.costUsd >= 0) {
224
+ pending.costUsd = posNum(pending.costUsd) + ev.costUsd;
225
+ }
226
+ if (typeof ev.model === "string" && ev.model) model = ev.model;
227
+ },
228
+ /** Peek at what is pending without consuming it. */
229
+ total() {
230
+ return pending ? { ...pending, ...(model ? { model } : {}) } : null;
231
+ },
232
+ /** Consume everything folded since the last take. */
233
+ take() {
234
+ const out = pending ? { ...pending, ...(model ? { model } : {}) } : null;
235
+ pending = null;
236
+ return out;
237
+ },
238
+ };
239
+ }
240
+
241
+ // --- Web + subagent steps (0789) --------------------------------------------
242
+ //
243
+ // Before 0789 every mapper dropped WebSearch / WebFetch / Grep / Glob / Task, so
244
+ // a run could spend two minutes reading the web and hiring a helper while the
245
+ // room watched a card that said nothing. Web calls and subagent spawns now get
246
+ // ONE quiet line each.
247
+ //
248
+ // GREP AND GLOB STAY DROPPED — deliberately, not by omission. The audit listed
249
+ // them alongside the web tools, but the card is an EIGHT-STEP recency window:
250
+ // a run that greps thirty times would spend the whole ring on searches nobody
251
+ // is waiting on and push the edits and the test run off the top. A web call or
252
+ // a helper spawn is rare and each one is news; a grep is the agent clearing its
253
+ // throat.
254
+ //
255
+ // The subagent's INTERIOR stays invisible too (0537's rule): the helper's own
256
+ // steps reach the room as narration when it reports, never as steps on the
257
+ // parent's card. One line per spawn, at the spawn.
258
+
259
+ /** Longest a query / target / description may be on a card line, before the
260
+ * 300-char sanitize cap. Same budget describeRun gives a command. */
261
+ const WEB_LABEL_MAX = 60;
262
+
263
+ /** Collapse whitespace, cap to a card line, then strip token-shaped secrets. */
264
+ function webPhrase(value) {
265
+ const raw = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
266
+ if (!raw) return "";
267
+ // Redact BEFORE truncating (lockstep with lib/hosted-progress.ts): a token
268
+ // cut at the cap loses the suffix the redactor keys on, and the fragment
269
+ // would render.
270
+ const clean = sanitizeText(raw);
271
+ if (!clean) return "";
272
+ return clean.length > WEB_LABEL_MAX ? clean.slice(0, WEB_LABEL_MAX - 1) + "…" : clean;
273
+ }
274
+
275
+ /**
276
+ * A URL reduced to host + path. The QUERY STRING NEVER REACHES THE CARD — it is
277
+ * where API keys, signed-URL tokens and session ids live, and a fetched URL is
278
+ * attacker-influenceable (an agent follows links). The fragment goes for the
279
+ * same reason, and userinfo (`https://user:token@host/…`) is dropped because
280
+ * `URL#host` excludes it. A scheme-less string is retried as https, but ONLY if
281
+ * its authority carries a dot — otherwise `new URL` happily turns any bare word
282
+ * into a "host" and the card would narrate "Read not-a-url-at-all" instead of
283
+ * degrading to "Read a web page". Anything with no host (`data:`, `mailto:`,
284
+ * plain prose) → "".
285
+ * @param {unknown} value
286
+ * @returns {string}
287
+ */
288
+ export function webTarget(value) {
289
+ const raw = typeof value === "string" ? value.trim() : "";
290
+ if (!raw) return "";
291
+ let url = null;
292
+ try {
293
+ url = new URL(raw);
294
+ } catch {
295
+ if (!/^[^\s/?#@]+\.[^\s/?#@]+/.test(raw)) return "";
296
+ try {
297
+ url = new URL(`https://${raw}`);
298
+ } catch {
299
+ return ""; // not a URL — say nothing rather than echo an unparsed string
300
+ }
301
+ }
302
+ if (!url.host) return "";
303
+ const path = url.pathname && url.pathname !== "/" ? url.pathname.replace(/\/+$/, "") : "";
304
+ return webPhrase(`${url.host}${path}`);
305
+ }
306
+
307
+ /**
308
+ * Claude-family web + subagent tools → an AgentEvent, or null to fall through.
309
+ *
310
+ * Tool names captured LIVE against Claude Code 2.1.233
311
+ * (`claude -p --output-format stream-json --verbose`):
312
+ *
313
+ * {"type":"assistant","message":{"content":[{"type":"tool_use",
314
+ * "name":"WebSearch","input":{"query":"hilos.sh Pablo Stanley chat for agents"}}]}}
315
+ * {"type":"assistant","message":{"content":[{"type":"tool_use",
316
+ * "name":"WebFetch","input":{"url":"https://example.com/?token=…","prompt":"what is this page"}}]}}
317
+ * {"type":"assistant","message":{"content":[{"type":"tool_use",
318
+ * "name":"Agent","input":{"description":"probe subagent","prompt":"…",
319
+ * "subagent_type":"general-purpose","run_in_background":false}}]}}
320
+ *
321
+ * The subagent tool is named `Agent` in this build — the audit called it `Task`,
322
+ * which is what older builds and the hosted sandbox's tool list use — so BOTH
323
+ * names map, and both carry `input.description`. The `system`/`task_started`,
324
+ * `task_updated` and `task_notification` frames that follow a spawn are the
325
+ * helper's interior and stay unmapped on purpose.
326
+ *
327
+ * LOCKSTEP: `toolEvent` in lib/hosted-progress.ts is this function's TS twin.
328
+ * Change one, look at the other.
329
+ */
330
+ function claudeWebToolEvent(name, input) {
331
+ const o = input && typeof input === "object" ? input : {};
332
+ switch (name) {
333
+ case "WebSearch":
334
+ return { t: "websearch", query: webPhrase(o.query) };
335
+ case "WebFetch":
336
+ return { t: "webfetch", target: webTarget(o.url) };
337
+ case "Agent":
338
+ case "Task": {
339
+ // Only a spawn that says what it hired the helper FOR is worth a line;
340
+ // "Started a helper" alone tells the room nothing it can act on.
341
+ const text = webPhrase(o.description);
342
+ return text ? { t: "subagent", text } : null;
343
+ }
344
+ default:
345
+ return null;
346
+ }
347
+ }
348
+
96
349
  /** Map a Claude/agent tool name to a normalized event kind (or null to skip). */
97
350
  function toolKind(name) {
98
351
  switch (name) {
@@ -106,8 +359,9 @@ function toolKind(name) {
106
359
  case "Bash":
107
360
  return "run";
108
361
  default:
109
- // Grep/Glob/WebFetch/Task/etc. carry less "alive" signal; ignore in v1 so
110
- // an unknown tool can never crash the parser. 0274 may promote some.
362
+ // Grep/Glob/etc. carry less "alive" signal; ignore so an unknown tool can
363
+ // never crash the parser. The web + subagent tools are handled ahead of
364
+ // this by claudeWebToolEvent (0789).
111
365
  return null;
112
366
  }
113
367
  }
@@ -120,6 +374,8 @@ function blockToEvent(block) {
120
374
  return text ? { t: "note", text } : null;
121
375
  }
122
376
  if (block.type === "tool_use" && typeof block.name === "string") {
377
+ const web = claudeWebToolEvent(block.name, block.input);
378
+ if (web) return web;
123
379
  const kind = toolKind(block.name);
124
380
  if (!kind) return null;
125
381
  const input = block.input && typeof block.input === "object" ? block.input : {};
@@ -139,6 +395,19 @@ function blockToEvent(block) {
139
395
  * `lib/hosted-agent.ts`: system init (session_id), assistant turns (text +
140
396
  * tool_use), and the final result. Everything else (user tool_result, unknown
141
397
  * types) → [].
398
+ *
399
+ * The result frame also prices the run (0787). Captured LIVE against Claude
400
+ * Code 2.1.233 (`claude -p --output-format stream-json --verbose`):
401
+ *
402
+ * {"type":"result","subtype":"success","total_cost_usd":0.481725,
403
+ * "usage":{"input_tokens":2,"cache_creation_input_tokens":23172,
404
+ * "cache_read_input_tokens":16015,"output_tokens":45,…},
405
+ * "modelUsage":{"claude-fable-5":{"inputTokens":2,"outputTokens":45,
406
+ * "cacheReadInputTokens":16015,"cacheCreationInputTokens":23172,
407
+ * "costUSD":0.481725,…}}, …}
408
+ *
409
+ * Same field names `lib/run-usage.ts` settles hosted runs on — deliberately, so
410
+ * both lanes meter the same numbers.
142
411
  */
143
412
  function parseClaudeLine(line) {
144
413
  const obj = tryParse(line);
@@ -149,7 +418,17 @@ function parseClaudeLine(line) {
149
418
  if (obj.type === "result") {
150
419
  const ok = obj.is_error !== true && obj.subtype !== "error";
151
420
  const summary = typeof obj.result === "string" ? sanitizeText(obj.result) : undefined;
152
- return [summary ? { t: "result", ok, summary } : { t: "result", ok }];
421
+ const u = obj.usage && typeof obj.usage === "object" ? obj.usage : {};
422
+ const usage = usageEvent({
423
+ inputTokens: u.input_tokens,
424
+ outputTokens: u.output_tokens,
425
+ cacheReadTokens: u.cache_read_input_tokens,
426
+ cacheCreationTokens: u.cache_creation_input_tokens,
427
+ costUsd: obj.total_cost_usd,
428
+ model: claudeResultModel(obj),
429
+ });
430
+ const done = summary ? { t: "result", ok, summary } : { t: "result", ok };
431
+ return usage ? [usage, done] : [done];
153
432
  }
154
433
  if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
155
434
  const out = [];
@@ -163,50 +442,234 @@ function parseClaudeLine(line) {
163
442
  }
164
443
 
165
444
  /**
166
- * Parse ONE Codex `--json` item line → AgentEvent[]. Best-effort / experimental:
167
- * Codex's format is younger and less documented, so we read the kind from either
168
- * `item.type` (the `{type:"item.completed", item:{…}}` envelope) or a bare
169
- * top-level `type`, and pull fields from whichever object carries them. Unknown
170
- * kinds → [].
445
+ * A stateful line parser for Codex `exec --json` JSONL (0783).
446
+ *
447
+ * Shapes captured LIVE against codex-cli 0.144.1 (a real `codex exec --json`
448
+ * run in a scratch repo create a file, run a command, read it back):
449
+ *
450
+ * {"type":"thread.started","thread_id":"01a00968-…"}
451
+ * {"type":"turn.started"}
452
+ * {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"…"}}
453
+ * {"type":"item.started","item":{"id":"item_2","type":"file_change",
454
+ * "changes":[{"path":"/tmp/x/hello.txt","kind":"add"}],"status":"in_progress"}}
455
+ * {"type":"item.completed","item":{"id":"item_2","type":"file_change",…,"status":"completed"}}
456
+ * {"type":"item.started","item":{"id":"item_3","type":"command_execution",
457
+ * "command":"/bin/zsh -lc 'ls -la'","exit_code":null,"status":"in_progress"}}
458
+ * {"type":"item.completed","item":{"id":"item_3",…,"exit_code":0,"status":"completed"}}
459
+ * {"type":"turn.completed","usage":{…}}
460
+ *
461
+ * What that stream forced, and why this parser is stateful:
462
+ * - `thread.started.thread_id` IS the resumable session id — the same id
463
+ * `codex exec resume <id>` takes (live-verified: a resumed run answered
464
+ * from the first run's context) and the same id `thread.started` echoes
465
+ * back on the resumed run. It's the ONLY place the id appears, so it is
466
+ * the `session` event that 0282's resume gate records.
467
+ * - EVERY tool item arrives TWICE (`item.started` then `item.completed`,
468
+ * same `item.id`), so we emit AT MOST ONCE per item id — at the first
469
+ * frame that carries content, which is the `started` one. Same emit-once
470
+ * discipline as createAcpEventMapper; an item with no id (older/other
471
+ * shapes) is never deduped, so nothing that used to emit stops emitting.
472
+ * - `turn.completed` carries ONLY usage — no text. So the last
473
+ * `agent_message` is remembered and becomes the result summary, which is
474
+ * what the folder run reports (raw stdout is JSONL now, not prose).
475
+ * - Failure has two spellings, and a real run emits BOTH: a top-level
476
+ * `{"type":"error","message":…}` and a `turn.failed` carrying
477
+ * `error.message`. One result per stream — but a bare stream error is HELD,
478
+ * not published: codex retries some of them, and a verdict latched on the
479
+ * first error would throw away the successful `turn.completed` that
480
+ * follows. The turn decides: `turn.completed` clears the held error and
481
+ * wins with success, `turn.failed` publishes the failure, and a stream that
482
+ * just stops (flush, no terminal turn) publishes what it was holding.
483
+ * - An item whose `type` is `error` is NOT a failed run — live capture shows
484
+ * advisory notices there (a skills-context-budget warning). Those are
485
+ * skipped; the run's real verdict comes from turn.failed / the stream error.
486
+ *
487
+ * Unknown envelopes and unknown item kinds → [] (a Codex format change
488
+ * degrades the card, never crashes the run).
171
489
  */
172
- function parseCodexLine(line) {
173
- const obj = tryParse(line);
174
- if (!obj) return [];
175
- const item = obj.item && typeof obj.item === "object" ? obj.item : obj;
176
- const kind = item.type ?? obj.type;
177
- switch (kind) {
178
- case "command_execution": {
179
- const raw = item.command ?? item.cmd;
180
- const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
181
- return cmd ? [{ t: "run", cmd: sanitizeText(cmd) }] : [];
490
+ /**
491
+ * Codex's token accounting → a `usage` event, or null (0787).
492
+ *
493
+ * ONE rule for both codex transports, which report the same shape in two
494
+ * different envelopes `codex exec --json`'s `turn.completed.usage` and
495
+ * `codex mcp-server`'s `token_count.info.last_token_usage`:
496
+ *
497
+ * {"input_tokens":21993,"cached_input_tokens":9984,
498
+ * "output_tokens":5,"reasoning_output_tokens":0}
499
+ *
500
+ * `input_tokens` is the TOTAL prompt, with `cached_input_tokens` a subset of it
501
+ * (the OpenAI convention), so the cached half is split out to sit in the same
502
+ * uncached/cache-read shape Claude reports and hosted settlement already stores.
503
+ * `reasoning_output_tokens` is likewise a subset of `output_tokens` — adding it
504
+ * would double-count. Codex prices nothing and names no model, so the event
505
+ * carries neither; the daemon supplies the model it resolved at spawn.
506
+ *
507
+ * Exported because the MCP transport (codex-mcp-session.mjs) is not a stdout
508
+ * parser and cannot go through makeStreamParser, but must split cached tokens
509
+ * exactly the same way or the two transports would report differently.
510
+ */
511
+ export function codexUsageEvent(usage) {
512
+ if (!usage || typeof usage !== "object") return null;
513
+ const total = tokenNum(usage.input_tokens);
514
+ const cached = Math.min(tokenNum(usage.cached_input_tokens), total);
515
+ return usageEvent({
516
+ inputTokens: total - cached,
517
+ cacheReadTokens: cached,
518
+ outputTokens: usage.output_tokens,
519
+ });
520
+ }
521
+
522
+ /** A Codex turn envelope → at most one `usage` event. */
523
+ function codexUsage(obj) {
524
+ const ev = codexUsageEvent(obj.usage);
525
+ return ev ? [ev] : [];
526
+ }
527
+
528
+ function makeCodexLineParser() {
529
+ const emitted = new Set(); // item.id → already turned into an event
530
+ let lastMessage = ""; // the newest agent_message — the turn.completed summary
531
+ let resultSeen = false; // one verdict per stream (error + turn.failed pair up)
532
+ let heldError = null; // a bare stream error, waiting for the turn to rule on it
533
+
534
+ const result = (ok, summary) => {
535
+ if (resultSeen) return [];
536
+ resultSeen = true;
537
+ heldError = null;
538
+ return [summary ? { t: "result", ok, summary } : { t: "result", ok }];
539
+ };
540
+
541
+ function parseCodexLine(line) {
542
+ const obj = tryParse(line);
543
+ if (!obj) return [];
544
+
545
+ // --- Envelope-level events (thread/turn/stream), never items -------------
546
+ if (obj.type === "thread.started") {
547
+ const raw = obj.thread_id ?? obj.threadId ?? obj.session_id;
548
+ const id = typeof raw === "string" ? sanitizeText(raw) : "";
549
+ return id ? [{ t: "session", sessionId: id }] : [];
182
550
  }
183
- case "file_change": {
184
- const changes = Array.isArray(item.changes)
185
- ? item.changes
186
- : item.path
187
- ? [{ path: item.path }]
188
- : [];
189
- const out = [];
190
- for (const c of changes) {
191
- const p = c && (c.path ?? c.file_path);
192
- if (typeof p === "string" && p) out.push({ t: "edit", path: sanitizeText(p) });
193
- }
551
+ if (obj.type === "turn.started") return [];
552
+ if (obj.type === "turn.completed") {
553
+ const out = codexUsage(obj);
554
+ for (const ev of result(true, lastMessage)) out.push(ev);
194
555
  return out;
195
556
  }
196
- case "agent_message": {
197
- const raw = item.text ?? item.message;
198
- const text = typeof raw === "string" ? sanitizeText(raw.replace(/\s+/g, " ").trim()) : "";
199
- return text ? [{ t: "note", text }] : [];
200
- }
201
- case "task_complete":
202
- case "turn.completed": {
203
- const raw = item.text ?? item.message ?? obj.text;
204
- const summary = typeof raw === "string" ? sanitizeText(raw) : undefined;
205
- return [summary ? { t: "result", ok: true, summary } : { t: "result", ok: true }];
557
+ if (obj.type === "turn.failed") {
558
+ const err = obj.error && typeof obj.error === "object" ? obj.error : {};
559
+ const raw = err.message ?? obj.message;
560
+ const summary = typeof raw === "string" ? sanitizeText(raw) : "";
561
+ // A failed turn still spent tokens. Codex has not been observed pricing
562
+ // one, so this emits only if the envelope actually carries usage.
563
+ const out = codexUsage(obj);
564
+ for (const ev of result(false, summary || heldError || "")) out.push(ev);
565
+ return out;
206
566
  }
207
- default:
567
+ if (obj.type === "error" && !obj.item) {
568
+ // HELD, not published — see the header. The turn (or the end of the
569
+ // stream) decides whether this was the run's verdict or a blip it retried.
570
+ if (resultSeen) return [];
571
+ const raw = obj.message ?? (obj.error && typeof obj.error === "object" ? obj.error.message : "");
572
+ heldError = (typeof raw === "string" ? sanitizeText(raw) : "") || heldError || "";
208
573
  return [];
574
+ }
575
+
576
+ // --- Item events --------------------------------------------------------
577
+ const item = obj.item && typeof obj.item === "object" ? obj.item : obj;
578
+ const kind = item.type ?? obj.type;
579
+ const id = typeof item.id === "string" && item.id ? item.id : "";
580
+ if (id && emitted.has(id)) return []; // the started/completed twin
581
+ const keep = (events) => {
582
+ if (events.length && id) {
583
+ emitted.add(id);
584
+ if (emitted.size > 500) emitted.delete(emitted.values().next().value); // bound
585
+ }
586
+ return events;
587
+ };
588
+
589
+ switch (kind) {
590
+ case "command_execution": {
591
+ const raw = item.command ?? item.cmd;
592
+ const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
593
+ return keep(cmd ? [{ t: "run", cmd: sanitizeText(cmd) }] : []);
594
+ }
595
+ case "file_change": {
596
+ const changes = Array.isArray(item.changes)
597
+ ? item.changes
598
+ : item.path
599
+ ? [{ path: item.path }]
600
+ : [];
601
+ const out = [];
602
+ for (const c of changes) {
603
+ const p = c && (c.path ?? c.file_path);
604
+ if (typeof p === "string" && p) out.push({ t: "edit", path: sanitizeText(p) });
605
+ }
606
+ return keep(out);
607
+ }
608
+ case "web_search": {
609
+ // 0789, captured LIVE against codex-cli 0.144.1 (`codex exec --json
610
+ // -c tools.web_search=true`):
611
+ //
612
+ // item.started {"id":"exec-bdb…","type":"web_search","query":"",
613
+ // "action":{"type":"other"}}
614
+ // item.completed {"id":"exec-bdb…","type":"web_search",
615
+ // "query":"hilos.sh Pablo Stanley",
616
+ // "action":{"type":"search","query":"hilos.sh Pablo Stanley"}}
617
+ //
618
+ // The STARTED half carries an EMPTY query — codex opens the item before
619
+ // the model has committed to the words. Returning [] there leaves the id
620
+ // unmarked (keep() only remembers ids that actually emitted), so the
621
+ // completed twin is the one that becomes the step. Same shape as
622
+ // command_execution's "no command says nothing" rule; emit-once holds.
623
+ const action = item.action && typeof item.action === "object" ? item.action : {};
624
+ const query = webPhrase(item.query || action.query);
625
+ return keep(query ? [{ t: "websearch", query }] : []);
626
+ }
627
+ case "agent_message": {
628
+ const raw = item.text ?? item.message;
629
+ const text = typeof raw === "string" ? sanitizeText(raw.replace(/\s+/g, " ").trim()) : "";
630
+ if (text) lastMessage = text; // the summary turn.completed doesn't carry
631
+ return keep(text ? [{ t: "note", text }] : []);
632
+ }
633
+ case "task_complete": {
634
+ // Legacy/other Codex builds: a terminal item rather than a turn envelope.
635
+ const raw = item.text ?? item.message ?? obj.text;
636
+ const summary = typeof raw === "string" ? sanitizeText(raw) : lastMessage;
637
+ return keep(result(true, summary));
638
+ }
639
+ default:
640
+ // `error` items are advisory notices, `reasoning` carries no step —
641
+ // both skipped, and an unknown kind can never crash the parser.
642
+ return [];
643
+ }
209
644
  }
645
+
646
+ /** End of stream: publish a held error nobody ever ruled on. */
647
+ function finish() {
648
+ if (resultSeen || heldError === null) return [];
649
+ return result(false, heldError);
650
+ }
651
+
652
+ return { parseCodexLine, finish };
653
+ }
654
+
655
+ /**
656
+ * The codex stream parser: line buffering over the stateful line parser, plus
657
+ * the end-of-stream finalizer that publishes a held error (see above). A
658
+ * `flush()` is the only place the parser learns the stream is over.
659
+ */
660
+ function makeCodexParser() {
661
+ const codex = makeCodexLineParser();
662
+ const lines = makeLineBufferedParser(codex.parseCodexLine);
663
+ return {
664
+ /** @param {string} chunk @returns {AgentEvent[]} */
665
+ push: (chunk) => lines.push(chunk),
666
+ /** @returns {AgentEvent[]} */
667
+ flush: () => {
668
+ const out = lines.flush();
669
+ for (const ev of codex.finish()) out.push(ev);
670
+ return out;
671
+ },
672
+ };
210
673
  }
211
674
 
212
675
  /**
@@ -284,14 +747,29 @@ function cursorToolEvent(toolCall) {
284
747
 
285
748
  /** One opencode `tool_use` part → an AgentEvent, or null. Tool names are
286
749
  * lowercase (`read`/`write`/`edit`/`patch`/`bash`); the arguments live under
287
- * `state.input` (`filePath` for file tools, `command` for bash). Anything else
288
- * (glob/grep/webfetch/task) carries less "alive" signal and is skipped, mirroring
289
- * toolKind()'s v1 restraint — an unknown tool never crashes the parser. */
750
+ * `state.input` (`filePath` for file tools, `command` for bash). `webfetch` and
751
+ * `task` join them in 0789 captured LIVE against opencode 1.18.5:
752
+ *
753
+ * {"type":"tool_use","part":{"tool":"webfetch","state":{"input":
754
+ * {"url":"https://example.com/?token=…"}}}}
755
+ * {"type":"tool_use","part":{"tool":"task","state":{"input":
756
+ * {"description":"probe subagent","prompt":"…","subagent_type":"general"}}}}
757
+ *
758
+ * opencode ships no web SEARCH tool, so there is nothing to map for it.
759
+ * glob/grep stay skipped (see the 0789 note above claudeWebToolEvent), and an
760
+ * unknown tool still never crashes the parser. */
290
761
  function opencodeToolEvent(part) {
291
762
  if (!part || typeof part !== "object") return null;
292
763
  const tool = typeof part.tool === "string" ? part.tool.toLowerCase() : "";
293
764
  const state = part.state && typeof part.state === "object" ? part.state : {};
294
765
  const input = state.input && typeof state.input === "object" ? state.input : {};
766
+ if (tool === "webfetch") {
767
+ return { t: "webfetch", target: webTarget(input.url) };
768
+ }
769
+ if (tool === "task") {
770
+ const text = webPhrase(input.description);
771
+ return text ? { t: "subagent", text } : null;
772
+ }
295
773
  if (tool === "bash") {
296
774
  const raw = input.command;
297
775
  const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
@@ -468,7 +946,10 @@ function makeTextTailParser() {
468
946
  export function makeStreamParser(vendor) {
469
947
  const v = normalizeVendor(vendor);
470
948
  if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
471
- if (v === "codex") return makeLineBufferedParser(parseCodexLine);
949
+ // codex's parser carries per-stream state (emit-once item ids, the last
950
+ // agent message, one verdict, a held error) and needs the end of the stream,
951
+ // so it owns its own line buffering — 0783.
952
+ if (v === "codex") return makeCodexParser();
472
953
  if (v === "cursor") return makeLineBufferedParser(parseCursorLine);
473
954
  // opencode's line parser carries per-stream state (dedupe + last text), so
474
955
  // each parser instance gets its own.
@@ -501,6 +982,12 @@ export function stepLabel(event) {
501
982
  return event.path ? `Reading ${event.path}` : "Reading files";
502
983
  case "run":
503
984
  return describeRun(event.cmd);
985
+ case "websearch":
986
+ return event.query ? `Searched the web: ${event.query}` : "Searched the web";
987
+ case "webfetch":
988
+ return event.target ? `Read ${event.target}` : "Read a web page";
989
+ case "subagent":
990
+ return event.text ? `Started a helper: ${event.text}` : null;
504
991
  case "note":
505
992
  return event.text ? event.text : null;
506
993
  case "result":
@@ -634,6 +1121,23 @@ export function createActivityFold(limit = MAX_ACTIVITY) {
634
1121
  case "run":
635
1122
  add({ kind: "run", subject: event.cmd || "", status: "running", n: 1 });
636
1123
  return;
1124
+ case "websearch":
1125
+ case "webfetch":
1126
+ case "subagent": {
1127
+ // Past-tense sentences that carry themselves, like `note` — the stream
1128
+ // never tells us when a search or a helper FINISHED, so a "running" row
1129
+ // would sit there mid-tense until some later action happened to settle
1130
+ // it. One settled line, said once (0789).
1131
+ const text = stepLabel(event);
1132
+ if (!text) return;
1133
+ add({
1134
+ kind: event.t === "subagent" ? "task" : "web",
1135
+ subject: text,
1136
+ status: "done",
1137
+ n: 1,
1138
+ });
1139
+ return;
1140
+ }
637
1141
  case "note":
638
1142
  if (!event.text) return;
639
1143
  add({ kind: "note", subject: event.text, status: "done", n: 1 });
@@ -667,3 +1171,99 @@ export function createActivityFold(limit = MAX_ACTIVITY) {
667
1171
 
668
1172
  return { push, settle, list };
669
1173
  }
1174
+
1175
+ // --- ACP → AgentEvent (0759 slice 3) ---------------------------------------
1176
+
1177
+ const ACP_SEEN_LIMIT = 500;
1178
+
1179
+ function stripBackticks(value) {
1180
+ const s = String(value ?? "").trim();
1181
+ const m = /^`(.*)`$/s.exec(s);
1182
+ return m ? m[1].trim() : s;
1183
+ }
1184
+
1185
+ /**
1186
+ * Map ACP `session/update` tool events onto AgentEvents, statefully.
1187
+ *
1188
+ * The rule comes from the recorded wire logs of both live vendors: cursor's
1189
+ * `tool_call` (status pending) already carries `rawInput.command`, so its row
1190
+ * appears immediately — visible while a permission ask blocks; opencode's
1191
+ * pending frame has only the tool NAME as its title ("bash"), and the real
1192
+ * command arrives on the `in_progress` update. So: merge what each update
1193
+ * teaches per `toolCallId`, emit AT MOST once per id — at the first update
1194
+ * carrying a command or file path — and flush with the best-known title at a
1195
+ * terminal status if neither ever appeared. Message/thought/usage updates are
1196
+ * not activity; they return null.
1197
+ *
1198
+ * 0789 left this mapper alone on purpose: ACP's `kind` vocabulary already
1199
+ * includes `search` and `fetch`, and both fall through to the `note` arm below
1200
+ * carrying the vendor's own title, so web work on an ACP transport was never
1201
+ * invisible the way the stdout parsers made it. Nothing to un-drop here.
1202
+ */
1203
+ export function createAcpEventMapper() {
1204
+ /** @type {Map<string, {kind: string, title: string, cmd: string, path: string, emitted: boolean}>} */
1205
+ const seen = new Map();
1206
+ let anonymous = 0;
1207
+
1208
+ function remember(id, info) {
1209
+ if (seen.has(id)) seen.delete(id); // re-insert = most-recent, cheap LRU
1210
+ seen.set(id, info);
1211
+ if (seen.size > ACP_SEEN_LIMIT) {
1212
+ const oldest = seen.keys().next().value;
1213
+ if (oldest !== undefined) seen.delete(oldest);
1214
+ }
1215
+ }
1216
+
1217
+ function eventFor(info) {
1218
+ if (info.kind === "edit") {
1219
+ return { t: "edit", path: info.path || stripBackticks(info.title) };
1220
+ }
1221
+ if (info.kind === "read") {
1222
+ return { t: "read", path: info.path || stripBackticks(info.title) };
1223
+ }
1224
+ if (info.kind === "execute") {
1225
+ return { t: "run", cmd: info.cmd || stripBackticks(info.title) };
1226
+ }
1227
+ const text = stripBackticks(info.title);
1228
+ return text ? { t: "note", text } : null;
1229
+ }
1230
+
1231
+ /** @param {any} update @returns {AgentEvent | null} */
1232
+ function push(update) {
1233
+ if (!update || typeof update !== "object") return null;
1234
+ const kind = update.sessionUpdate;
1235
+ if (kind !== "tool_call" && kind !== "tool_call_update") return null;
1236
+ const id =
1237
+ typeof update.toolCallId === "string" && update.toolCallId
1238
+ ? update.toolCallId
1239
+ : `acp_anon_${++anonymous}`;
1240
+ const prev = seen.get(id) ?? { kind: "", title: "", cmd: "", path: "", emitted: false };
1241
+ const rawInput =
1242
+ update.rawInput && typeof update.rawInput === "object" ? update.rawInput : {};
1243
+ const location = Array.isArray(update.locations)
1244
+ ? update.locations.find((l) => l && typeof l.path === "string" && l.path)
1245
+ : null;
1246
+ const info = {
1247
+ kind: typeof update.kind === "string" && update.kind ? update.kind : prev.kind,
1248
+ title: typeof update.title === "string" && update.title ? update.title : prev.title,
1249
+ cmd: typeof rawInput.command === "string" && rawInput.command ? rawInput.command : prev.cmd,
1250
+ path: location ? location.path : prev.path,
1251
+ emitted: prev.emitted,
1252
+ };
1253
+ const terminal = ["completed", "failed", "cancelled"].includes(update.status);
1254
+ // A location on an execute call is its working DIRECTORY (observed on
1255
+ // opencode's pending frame), not a target — only a real command may
1256
+ // trigger an execute row, or the weak tool-name title would win.
1257
+ const informative =
1258
+ info.cmd || (info.kind !== "execute" && info.path) || (terminal && info.title);
1259
+ let event = null;
1260
+ if (!info.emitted && informative) {
1261
+ event = eventFor(info);
1262
+ if (event) info.emitted = true;
1263
+ }
1264
+ remember(id, info);
1265
+ return event;
1266
+ }
1267
+
1268
+ return { push };
1269
+ }