flowviant 0.102.2 → 0.104.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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,482 @@
1
+ /**
2
+ * WHAT EACH CLI'S STREAM SAID, IN THE DAEMON'S VOCABULARY — the event parsers.
3
+ *
4
+ * Split out of runtimes.mjs (2026-09-26, SOLID F045). The registry answers "how
5
+ * do you drive this CLI"; this file answers "what did its output mean", and the
6
+ * two change for different reasons: a vendor renaming an event or a tool is an
7
+ * edit here, a vendor renaming a flag is an edit to the registry. Claude's tool
8
+ * calls (the one-line activity and the structured tool event), Codex's JSONL
9
+ * ThreadEvents and Antigravity's stream-json all live here, beside the
10
+ * `label`/`full` convention and the thinking marker every one of them speaks.
11
+ *
12
+ * Pure: no state, no I/O. The registry hands `parseCodexLine` and
13
+ * `parseAgyLine` to its adapters as their `parse`; claudeStream.mjs owns Claude's
14
+ * own stream loop and calls `humanizeClaudeTool`/`toolEventOf` from it.
15
+ */
16
+
17
+ /** Truncate for a one-line activity label. */
18
+ const oneLine = (s, n = 140) =>
19
+ String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
20
+
21
+ /**
22
+ * `label` IS THE READOUT; `full` IS THE RELAY (2026-09-16) — the convention
23
+ * every prose activity in this daemon follows, stated once here because three
24
+ * parsers produce one and three consumers read them.
25
+ *
26
+ * `label` is one collapsed, clamped line, because its first two consumers are a
27
+ * terminal console and a one-line pulse that is overwritten every two seconds,
28
+ * and neither can show a paragraph. The turn TRACE is a third consumer with the
29
+ * opposite need: it is scrollback, and clipping a sentence at 140 characters
30
+ * there is the product summarizing its own agent.
31
+ *
32
+ * So a prose activity may ALSO carry `full`: the text EXACTLY as the CLI
33
+ * emitted it, uncollapsed and unclipped. Nothing derives it and nothing but the
34
+ * trace reads it — `label` is untouched, so the console and the pulse are
35
+ * byte-identical to before. Absent means there was no fuller text than the
36
+ * label (a tool line, a bare thinking marker), and the trace falls back to the
37
+ * label — which is what every pre-0.87.0 daemon does for everything.
38
+ *
39
+ * THE MARKER BELOW IS THE label-ONLY CASE, and one definition with four
40
+ * readers: claudeStream.mjs writes it, this file's codex parser falls back to it, and
41
+ * TWO collapses key on it — `trace.mjs` for the turn trace, `wikiRunner.mjs`
42
+ * for the wiki sweep's feed. A collapse keyed on a string typed out four times would
43
+ * silently stop collapsing the first time somebody reworded one of them, and a
44
+ * collapse that stops collapsing fails no test — it just fills a feed.
45
+ *
46
+ * It exists at all because the CLIs do not hand over the thinking itself:
47
+ * measured 2026-09-16 across three real transcripts, 93 thinking blocks, every
48
+ * one of them empty (signature only), and a live probe with MAX_THINKING_TOKENS
49
+ * and `--include-partial-messages` returned an empty `thinking_delta` and a
50
+ * zero-length complete block. So the marker is the honest whole of what is
51
+ * known — "it is reasoning, not hung" — and the day text does arrive it rides
52
+ * `full` and this marker is simply not used.
53
+ */
54
+ export const THINK_MARKER = 'thinking…';
55
+
56
+ const shortPath = (p, cwd) => {
57
+ const s = String(p ?? '');
58
+ return cwd && s.startsWith(cwd) ? s.slice(cwd.length).replace(/^\//, '') : s;
59
+ };
60
+
61
+ // ── Claude Code ────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Tool-call → one line of activity. Claude's tool names, unchanged from when
65
+ * this lived in claude.mjs; `kind` is the daemon's own vocabulary and every
66
+ * runtime's parser must speak it (`read` is what the wiki file counter counts,
67
+ * `write` carries `path` so distinct pages can be counted).
68
+ */
69
+ export function humanizeClaudeTool(name, input = {}, cwd = '') {
70
+ switch (name) {
71
+ case 'Read':
72
+ return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
73
+ case 'Write':
74
+ case 'Edit': {
75
+ const p = String(input.file_path ?? '');
76
+ const tail = p.split('/').slice(-2).join('/');
77
+ return { kind: 'write', path: p, label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
78
+ }
79
+ case 'Grep':
80
+ return {
81
+ kind: 'search',
82
+ label: `grep ${JSON.stringify(input.pattern ?? '')}${input.path ? ` in ${shortPath(input.path, cwd)}` : ''}`,
83
+ };
84
+ case 'Glob':
85
+ return { kind: 'glob', label: `glob ${input.pattern ?? ''}` };
86
+ case 'LS':
87
+ return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
88
+ case 'Bash':
89
+ // `command` rides beside the display label, VERBATIM (capped): the label
90
+ // is a 60-char readout for a console and the rail, and truncation is
91
+ // fine there — but the admin's command audit exists to answer "what
92
+ // actually ran on our box", and an ellipsis is exactly where the part
93
+ // that matters would hide.
94
+ return {
95
+ kind: 'bash',
96
+ command: String(input.command ?? '').slice(0, 2000),
97
+ label: `$ ${oneLine(input.command, 60)}`,
98
+ };
99
+ case 'TodoWrite': {
100
+ // The CLI's own todo list — the plan the transcript's PLAN card renders.
101
+ // The label narrates the change ("plan: 2 of 5 — cut over /api/session");
102
+ // the structured items ride through toolEventOf below.
103
+ const todos = Array.isArray(input.todos) ? input.todos : [];
104
+ if (todos.length === 0) return null;
105
+ const done = todos.filter((t) => t?.status === 'completed').length;
106
+ const active = todos.find((t) => t?.status === 'in_progress');
107
+ return {
108
+ kind: 'plan',
109
+ label: `plan: ${done} of ${todos.length}${active?.content ? ` — ${oneLine(active.content, 60)}` : ''}`,
110
+ };
111
+ }
112
+ default:
113
+ return null; // other tools: silent
114
+ }
115
+ }
116
+
117
+ /** Count '\n' without materializing a split — a Write's content can be a
118
+ * multi-MB generated file, and `split('\n')` re-allocates all of it as line
119
+ * strings on the synchronous stream path the narrator and wake socket share.
120
+ * Numbers cannot leak, so counting runs on the RAW string. */
121
+ export function countLines(s) {
122
+ const str = String(s ?? '');
123
+ if (!str) return 0;
124
+ let n = 1;
125
+ let i = -1;
126
+ while ((i = str.indexOf('\n', i + 1)) !== -1) n++;
127
+ return n;
128
+ }
129
+
130
+ /** How much raw string the scrubber sees before any cap is applied. A secret
131
+ * can only surface in the first ~capped chars of a field; giving the scrub a
132
+ * window this much larger means a secret would have to be longer than the
133
+ * window minus the cap to straddle out of it — no real credential is. */
134
+ const SCRUB_WINDOW = 8_192;
135
+
136
+ /**
137
+ * Tool-call → ONE STRUCTURED EVENT for the transcript's tool cards — the
138
+ * relay's durable form, where `humanizeClaudeTool` above is its one-line live
139
+ * form. Same source (the CLI's own tool_use input), zero inference: every
140
+ * field is something the CLI emitted, and a tool this doesn't know renders as
141
+ * nothing rather than as a guess.
142
+ *
143
+ * Wire vocabulary (compact keys — this rides a 1.5s-throttled POST):
144
+ * t: read|edit|write|grep|glob|bash|task|plan
145
+ * p: path (worktree-relative) q: pattern/description c: command
146
+ * a/d: line counts added/deleted (from the input's own strings)
147
+ * dl: a few "-/+" prefixed preview lines of an Edit
148
+ * items: the plan's todos, x = text, s = done|active|open
149
+ *
150
+ * SCRUB BEFORE CAP — the order is load-bearing (review, 2026-09-01). The
151
+ * caller passes its `envScrub`; every string is scrubbed over a bounded
152
+ * window FIRST and capped LAST, because the reverse order had two failures:
153
+ * a secret straddling the cap boundary was cut into a prefix the
154
+ * exact-substring scrub could no longer match (a partial credential on the
155
+ * wire), and a scrub REPLACEMENT that grew a string past the cap tripped the
156
+ * server's field limits. `scrub` defaults to identity so this stays testable
157
+ * without a vault.
158
+ */
159
+ export function toolEventOf(name, input = {}, cwd = '', scrub = (s) => s) {
160
+ // Window → scrub → cap. The window bounds what a multi-MB input costs; the
161
+ // cap is applied AFTER the scrub so a replacement cannot overflow it.
162
+ const clean = (v, cap) => scrub(String(v ?? '').slice(0, SCRUB_WINDOW)).slice(0, cap);
163
+ const rel = (p) => clean(shortPath(p, cwd), 300);
164
+ // The first k lines of a side, from a scrubbed bounded prefix — never a
165
+ // full split of the raw string.
166
+ const firstLines = (s, k) =>
167
+ scrub(String(s ?? '').slice(0, SCRUB_WINDOW)).split('\n', k).slice(0, k);
168
+ switch (name) {
169
+ case 'Read':
170
+ return { t: 'read', p: rel(input.file_path) };
171
+ case 'Write':
172
+ return { t: 'write', p: rel(input.file_path), a: countLines(input.content) };
173
+ case 'Edit': {
174
+ const oldS = String(input.old_string ?? '');
175
+ const newS = String(input.new_string ?? '');
176
+ // A MINI-DIFF, not the diff: the first lines of each side, enough to
177
+ // recognise the change at a glance. The real diff lives in git.
178
+ const dl = [
179
+ ...firstLines(oldS, 2).map((l) => `- ${l}`),
180
+ ...firstLines(newS, 3).map((l) => `+ ${l}`),
181
+ ].map((l) => l.slice(0, 160));
182
+ return { t: 'edit', p: rel(input.file_path), a: countLines(newS), d: countLines(oldS), dl };
183
+ }
184
+ case 'Grep':
185
+ return {
186
+ t: 'grep',
187
+ q: clean(input.pattern, 200),
188
+ ...(input.path ? { p: rel(input.path) } : {}),
189
+ };
190
+ case 'Glob':
191
+ return { t: 'glob', q: clean(input.pattern, 200) };
192
+ case 'Bash':
193
+ return { t: 'bash', c: clean(input.command, 200) };
194
+ case 'Task':
195
+ return { t: 'task', q: clean(input.description, 200) };
196
+ case 'TodoWrite': {
197
+ const todos = Array.isArray(input.todos) ? input.todos.slice(0, 20) : [];
198
+ const items = todos
199
+ .map((td) => ({
200
+ x: clean(td?.content, 120),
201
+ s: td?.status === 'completed' ? 'done' : td?.status === 'in_progress' ? 'active' : 'open',
202
+ }))
203
+ .filter((i) => i.x);
204
+ return items.length ? { t: 'plan', items } : null;
205
+ }
206
+ default:
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * THE ACTIVITY KINDS `toolEventOf` ALSO ANSWERS — the dedupe rule, stated once.
213
+ *
214
+ * A Claude tool call goes down BOTH paths in claudeStream.mjs: `humanizeClaudeTool`
215
+ * makes a one-line activity and `toolEventOf` makes a structured event, from
216
+ * the same `tool_use`. A consumer taking both — the agent turn's trace — would
217
+ * otherwise render every read twice, once as a sentence and once as a card.
218
+ *
219
+ * It is the KINDS the structured builder answers, not every tool kind: `LS`
220
+ * produces a `list` activity and no tool event, so dropping `list` would delete
221
+ * it from the trace entirely. And it is only safe to apply on the runtimes
222
+ * whose stream reaches `onToolEvent` at all — codex and agy have their own
223
+ * parsers, never call it, and would go silent. Both halves are pinned in
224
+ * trace.test.mjs, because the two functions are edited independently and the
225
+ * failure is invisible: a dropped prose line looks exactly like a quiet turn.
226
+ */
227
+ export const CLAUDE_TOOL_PROSE_KINDS = new Set([
228
+ 'read',
229
+ 'write',
230
+ 'search',
231
+ 'glob',
232
+ 'bash',
233
+ 'plan',
234
+ ]);
235
+
236
+ // ── Codex ──────────────────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Codex item → activity, in the daemon's vocabulary.
240
+ *
241
+ * The event names and item types below are not guesses: they were read off the
242
+ * shipped 0.147 binary (`ThreadStarted`/`TurnCompleted`/`ItemCompleted`, items
243
+ * `agent_message` / `reasoning` / `command_execution` / `file_change` /
244
+ * `mcp_tool_call` / `web_search` / `todo_list`). Unknown item types return null
245
+ * and stay silent rather than printing a raw JSON blob into someone's console.
246
+ */
247
+ function humanizeCodexItem(item = {}, cwd = '') {
248
+ switch (item.item_type ?? item.type) {
249
+ // `full` rides beside the label wherever the item carries more than the
250
+ // label can hold — see the `label`/`full` note above. Codex is the runtime
251
+ // that actually SENDS reasoning text today, so its `think` is the one place
252
+ // in this daemon where a real thought reaches the trace whole.
253
+ case 'agent_message': {
254
+ const text = String(item.text ?? item.message ?? '');
255
+ return { kind: 'say', label: oneLine(text), ...(text ? { full: text } : {}) };
256
+ }
257
+ case 'reasoning': {
258
+ const text = String(item.text ?? '');
259
+ return {
260
+ kind: 'think',
261
+ label: oneLine(text) || THINK_MARKER,
262
+ ...(text.trim() ? { full: text } : {}),
263
+ };
264
+ }
265
+ case 'command_execution':
266
+ return {
267
+ kind: 'bash',
268
+ command: String(item.command ?? '').slice(0, 2000),
269
+ label: `$ ${oneLine(item.command, 60)}`,
270
+ };
271
+ case 'file_change': {
272
+ // `changes` is a list of touched paths; the daemon counts distinct files,
273
+ // so emit one activity per path rather than one for the batch.
274
+ const first = (item.changes ?? [])[0] ?? {};
275
+ const p = String(first.path ?? '');
276
+ const tail = p.split('/').slice(-2).join('/');
277
+ const verb = first.kind === 'add' ? '+' : first.kind === 'delete' ? '-' : '~';
278
+ return { kind: 'write', path: p, label: `${verb} ${tail || 'file'}` };
279
+ }
280
+ case 'mcp_tool_call':
281
+ return { kind: 'tool', label: `${item.server ?? 'mcp'}.${item.tool ?? ''}` };
282
+ case 'web_search':
283
+ return { kind: 'search', label: `search ${oneLine(item.query, 60)}` };
284
+ default:
285
+ return null;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Codex `--json` emits JSONL of ThreadEvents. Returns `{ activity, text,
291
+ * threadId }` — `text` accumulates the agent's own words, because the turn
292
+ * loop reads its sentinels (NOTHING / BLOCKED:<id> / DONE) out of exactly
293
+ * that; `threadId` surfaces once, off the lifecycle event, for callers that
294
+ * need to resume THIS conversation later (runTurn's onThreadId).
295
+ */
296
+ export function parseCodexLine(line, cwd) {
297
+ let ev;
298
+ try {
299
+ ev = JSON.parse(line);
300
+ } catch {
301
+ return null; // not every line is JSON (warnings go to stderr, but be safe)
302
+ }
303
+ switch (ev.type) {
304
+ // The conversation's own id, announced before any item (`ThreadStarted` on
305
+ // the shipped 0.147 binary, like the measured events below). Surfaced so a
306
+ // SESSION turn can resume this exact thread next time: `resume --last` is
307
+ // a machine-global guess, and on a box running two tabs — or a tab plus a
308
+ // dispatch — it resumes someone else's conversation. No activity and no
309
+ // text: nothing here is the model speaking.
310
+ case 'thread.started':
311
+ return { activity: null, text: '', threadId: String(ev.thread_id ?? '') || null };
312
+ case 'item.completed': {
313
+ const item = ev.item ?? {};
314
+ const activity = humanizeCodexItem(item, cwd);
315
+ // Only the agent's MESSAGES are sentinel-bearing text. Reasoning is not:
316
+ // a model that muses "I could output NOTHING here" must not end the turn.
317
+ const isMessage = (item.item_type ?? item.type) === 'agent_message';
318
+ const text = isMessage ? `${item.text ?? item.message ?? ''}\n` : '';
319
+ // `answer` is the SAME message, surfaced alone: `text` accumulates every
320
+ // message plus every error and stderr line, so a caller that needs "what
321
+ // the agent said LAST" (the agent lane's final JSON object) cannot recover
322
+ // it from `out`. Claude's stream has this for free under
323
+ // `answerFromResult`; codex needed it said.
324
+ return { activity, text, ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
325
+ }
326
+ // THE TURN'S OWN TOKEN COUNT (2026-09-24). `codex exec --json` closes a
327
+ // turn with `turn.completed` carrying `usage` — the codex twin of Claude's
328
+ // `result.usage`, and until now dropped on the floor, so a codex agent
329
+ // reported no spend at all. Mapped onto the daemon's four counters:
330
+ // OpenAI's `input_tokens` INCLUDES the cached part, Claude's excludes it,
331
+ // so the cached share is subtracted out rather than counted twice. Codex
332
+ // has no cache-creation figure; zero is what it reported, not a guess.
333
+ // Output already includes reasoning tokens on OpenAI's side.
334
+ case 'turn.completed': {
335
+ const u = ev.usage;
336
+ if (!u || typeof u !== 'object') return null;
337
+ const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Math.floor(Number(v)) : 0);
338
+ const cached = n(u.cached_input_tokens);
339
+ return {
340
+ activity: null,
341
+ text: '',
342
+ // `usageFromResult`'s shape (claudeStream.mjs), so every consumer of
343
+ // `onUsage` reads one vocabulary whichever CLI counted it.
344
+ usage: {
345
+ input: Math.max(0, n(u.input_tokens) - cached),
346
+ output: n(u.output_tokens),
347
+ cacheCreate: 0,
348
+ cacheRead: cached,
349
+ },
350
+ };
351
+ }
352
+ case 'turn.failed': {
353
+ // An error is prose too, and the sentence that explains a failed turn is
354
+ // routinely longer than a 140-char label — a stack-shaped message loses
355
+ // its cause exactly where somebody is reading to find it.
356
+ //
357
+ // THE LABEL KEEPS ITS OWN `??`, not `msg || …`, and the difference is one
358
+ // input: an EXPLICITLY EMPTY message. `??` lets `''` through as an empty
359
+ // label, which every consumer swallows (`if (!label)`); `||` would print
360
+ // "turn failed" and fire a pulse where this daemon printed nothing. That
361
+ // may well be the better readout, but it is a change to the console and
362
+ // the pulse, and `full` shipped on the promise that neither moved — so it
363
+ // is argued for on its own day, not smuggled in beside a relay field.
364
+ const msg = String(ev.error?.message ?? '');
365
+ return {
366
+ activity: {
367
+ kind: 'error',
368
+ label: oneLine(ev.error?.message ?? 'turn failed'),
369
+ ...(msg.trim() ? { full: msg } : {}),
370
+ },
371
+ // THE FAILURE'S OWN WORDS REACH `text` (2026-09-24), the rule the bare
372
+ // `error` arm below already keeps. This returned '' — so a codex turn
373
+ // that failed on a usage limit handed the limit matcher nothing, the
374
+ // agent landed in Stuck as "produced no output", and every other codex
375
+ // agent kept spending into the same wall.
376
+ text: msg.trim() ? `${msg}\n` : '',
377
+ };
378
+ }
379
+ // A bare `error` event — the shape an auth failure arrives in ("401
380
+ // Unauthorized: Missing bearer…", observed against 0.147.0 with no
381
+ // credentials). It used to fall through to `default` and be dropped, which
382
+ // meant a signed-out Codex produced an EMPTY turn: no sentinel, so the
383
+ // driver nudged twice and reported `stalled`, and the thread said the agent
384
+ // gave up rather than that the CLI is not signed in. The message goes into
385
+ // `text` so it reaches the operator's console AND the usage-limit
386
+ // classifier, which reads exactly this stream.
387
+ case 'error': {
388
+ // Same split as the arm above: `??` for the label (untouched behaviour),
389
+ // `msg` for the relay field only.
390
+ const msg = String(ev.message ?? '');
391
+ return {
392
+ activity: {
393
+ kind: 'error',
394
+ label: oneLine(ev.message ?? 'error'),
395
+ ...(msg.trim() ? { full: msg } : {}),
396
+ },
397
+ text: `${ev.message ?? ''}\n`,
398
+ };
399
+ }
400
+ default:
401
+ return null; // turn.started / item.started / item.updated
402
+ }
403
+ }
404
+
405
+ // ── Antigravity ────────────────────────────────────────────────────────────
406
+
407
+ /**
408
+ * `agy --output-format stream-json` emits `{event: init|step_update|result}`.
409
+ * Tool names read off a live 1.1.12 session rather than guessed.
410
+ */
411
+ function humanizeAgyTool(name, p = {}, cwd = '') {
412
+ const path = p.TargetFile ?? p.AbsolutePath ?? p.DirectoryPath ?? p.File ?? '';
413
+ const tail = String(path).split('/').slice(-2).join('/');
414
+ switch (name) {
415
+ case 'view_file':
416
+ case 'read_resource':
417
+ return { kind: 'read', label: `read ${shortPath(path, cwd)}` };
418
+ case 'write_to_file':
419
+ case 'replace_file_content':
420
+ case 'multi_replace_file_content':
421
+ return {
422
+ kind: 'write',
423
+ path: String(path),
424
+ label: `${name === 'write_to_file' ? '+ page' : '~ page'} ${tail || 'file'}`,
425
+ };
426
+ case 'grep_search':
427
+ return { kind: 'search', label: `grep ${oneLine(p.Query ?? p.SearchTerm ?? '', 60)}` };
428
+ case 'find_by_name':
429
+ return { kind: 'glob', label: `find ${oneLine(p.Pattern ?? '', 60)}` };
430
+ case 'list_dir':
431
+ return { kind: 'list', label: `ls ${shortPath(path, cwd)}` };
432
+ case 'run_command':
433
+ return {
434
+ kind: 'bash',
435
+ command: String(p.CommandLine ?? '').slice(0, 2000),
436
+ label: `$ ${oneLine(p.CommandLine, 60)}`,
437
+ };
438
+ case 'call_mcp_tool':
439
+ return { kind: 'tool', label: `mcp.${p.ToolName ?? ''}` };
440
+ default:
441
+ return null;
442
+ }
443
+ }
444
+
445
+ export function parseAgyLine(line, cwd) {
446
+ let ev;
447
+ try {
448
+ ev = JSON.parse(line);
449
+ } catch {
450
+ return null;
451
+ }
452
+ if (ev.event === 'step_update') {
453
+ const su = ev.step_update ?? {};
454
+ const ti = su.tool_info;
455
+ if (!ti) return null;
456
+ const err = ti.error?.message;
457
+ // Same rule as codex's error lines: the label is the console's, `full` is
458
+ // the trace's, and a tool error's message is exactly the kind of sentence a
459
+ // 140-char clamp cuts the cause out of.
460
+ if (err)
461
+ return {
462
+ activity: { kind: 'error', label: oneLine(err), full: String(err) },
463
+ text: '',
464
+ };
465
+ // Each tool is reported twice — once ACTIVE, once DONE — so only the
466
+ // terminal state emits, otherwise every action appears in the thread twice.
467
+ if (su.state && su.state !== 'DONE') return null;
468
+ return { activity: humanizeAgyTool(ti.name, ti.parameters ?? {}, cwd), text: '' };
469
+ }
470
+ if (ev.event === 'result') {
471
+ const r = ev.result ?? {};
472
+ // The final answer is the ONLY sentinel-bearing text: agy has no incremental
473
+ // assistant-message event, so a turn's whole verdict arrives here at once.
474
+ return {
475
+ activity: r.error
476
+ ? { kind: 'error', label: oneLine(r.error), full: String(r.error) }
477
+ : null,
478
+ text: `${r.response ?? ''}${r.error ? `\n${r.error}` : ''}\n`,
479
+ };
480
+ }
481
+ return null;
482
+ }
@@ -4,6 +4,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { readBaseTools } from './projectTools.mjs';
7
+ import { mcpRefusal } from './projectToolRuntimes.mjs';
7
8
 
8
9
  const TTL = 5 * 60_000;
9
10
  let cachedAt = 0;
@@ -68,7 +69,9 @@ export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
68
69
  const snapshot = repoCached.snapshot;
69
70
  if (out.rts.codex) out.rts.codex = [...new Set([...out.rts.codex, ...snapshot.skills.map((s) => s.path.split('/')[2])])].slice(0, 80);
70
71
  if (out.rtp.codex) out.rtp.codex = [...new Map([...out.rtp.codex, ...snapshot.tools
71
- .filter((t) => t.state !== 'ready' || t.config.type === 'sse')
72
+ // A repo server Codex refuses (projectToolRuntimes.mjs owns which) is
73
+ // one this machine cannot connect for Codex, like a broken one.
74
+ .filter((t) => t.state !== 'ready' || mcpRefusal('codex', t.config) != null)
72
75
  .map((t) => ({ n: t.name, s: 'failed' }))].map((server) => [server.n, server])).values()].slice(0, 80);
73
76
  } catch { /* keep local measurements */ }
74
77
  return out;