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.
- package/README.md +80 -16
- package/bin/hilos-agent.mjs +16 -6
- package/package.json +1 -1
- package/src/acp-session.mjs +69 -54
- package/src/agent-events.mjs +645 -45
- package/src/argv.mjs +61 -0
- package/src/attachments.mjs +310 -0
- package/src/claude-permissions.mjs +445 -0
- package/src/cli.mjs +56 -0
- package/src/codex-mcp-session.mjs +619 -0
- package/src/config.mjs +83 -7
- package/src/handler.mjs +914 -77
- package/src/hook.mjs +793 -108
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +3 -2
- package/src/model-resolve.mjs +180 -11
- package/src/permission-gate.mjs +269 -0
- package/src/progress-emitter.mjs +100 -5
- package/src/queue.mjs +21 -5
- package/src/redact.mjs +11 -1
- package/src/reply-bridge.mjs +847 -0
- package/src/resume.mjs +48 -11
- package/src/run.mjs +130 -4
- package/src/transcript.mjs +153 -0
package/src/agent-events.mjs
CHANGED
|
@@ -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/
|
|
110
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
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
|
-
|
|
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).
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|