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.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* and a set of sentinel words the turn loop reads out of stdout. A second CLI is
|
|
8
8
|
* not a different binary name — it is a different answer to each of those.
|
|
9
9
|
*
|
|
10
|
-
* So each runtime declares its own answers
|
|
11
|
-
*
|
|
10
|
+
* So each runtime declares its own answers — one row per vendor, in
|
|
11
|
+
* runtimeClaude.mjs, runtimeCodex.mjs and runtimeAntigravity.mjs, assembled
|
|
12
|
+
* into RUNTIMES here — and everything else in the daemon asks this module
|
|
13
|
+
* rather than knowing them.
|
|
12
14
|
*
|
|
13
15
|
* WHAT A RUNTIME MUST BE ABLE TO DO to build a Flowviant task at all:
|
|
14
16
|
* 1. run headless from one prompt and exit,
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
* 4. emit machine-readable progress, or the thread goes silent for the length
|
|
20
22
|
* of a build.
|
|
21
23
|
* Claude Code and Codex both do all four. Antigravity does 1, 3 and 4 and is
|
|
22
|
-
* declared
|
|
24
|
+
* declared in runtimeAntigravity.mjs with the exact reason it cannot yet do 2.
|
|
23
25
|
*
|
|
24
26
|
* BUT ONLY A BUILD NEEDS ALL FOUR, and that qualifier was missing long enough to
|
|
25
27
|
* cost Antigravity every capability it has. Requirement 2 is the CONTROL PLANE —
|
|
@@ -32,482 +34,25 @@
|
|
|
32
34
|
*
|
|
33
35
|
* WHAT THIS MODULE DELIBERATELY DOES NOT DO: pick. Which runtime runs a task is
|
|
34
36
|
* decided in the app, by @mentioning it (CLAUDE.md: the @mention is the only
|
|
35
|
-
* dispatch), and arrives on the brief. Detection
|
|
36
|
-
* machine HAVE" — activity, never capacity, and never a
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* `label` is one collapsed, clamped line, because its first two consumers are a
|
|
55
|
-
* terminal console and a one-line pulse that is overwritten every two seconds,
|
|
56
|
-
* and neither can show a paragraph. The turn TRACE is a third consumer with the
|
|
57
|
-
* opposite need: it is scrollback, and clipping a sentence at 140 characters
|
|
58
|
-
* there is the product summarizing its own agent.
|
|
59
|
-
*
|
|
60
|
-
* So a prose activity may ALSO carry `full`: the text EXACTLY as the CLI
|
|
61
|
-
* emitted it, uncollapsed and unclipped. Nothing derives it and nothing but the
|
|
62
|
-
* trace reads it — `label` is untouched, so the console and the pulse are
|
|
63
|
-
* byte-identical to before. Absent means there was no fuller text than the
|
|
64
|
-
* label (a tool line, a bare thinking marker), and the trace falls back to the
|
|
65
|
-
* label — which is what every pre-0.87.0 daemon does for everything.
|
|
66
|
-
*
|
|
67
|
-
* THE MARKER BELOW IS THE label-ONLY CASE, and one definition with four
|
|
68
|
-
* readers: claude.mjs writes it, this file's codex parser falls back to it, and
|
|
69
|
-
* TWO collapses key on it — `trace.mjs` for the turn trace, `fleet.mjs` for the
|
|
70
|
-
* wiki sweep's feed. A collapse keyed on a string typed out four times would
|
|
71
|
-
* silently stop collapsing the first time somebody reworded one of them, and a
|
|
72
|
-
* collapse that stops collapsing fails no test — it just fills a feed.
|
|
73
|
-
*
|
|
74
|
-
* It exists at all because the CLIs do not hand over the thinking itself:
|
|
75
|
-
* measured 2026-09-16 across three real transcripts, 93 thinking blocks, every
|
|
76
|
-
* one of them empty (signature only), and a live probe with MAX_THINKING_TOKENS
|
|
77
|
-
* and `--include-partial-messages` returned an empty `thinking_delta` and a
|
|
78
|
-
* zero-length complete block. So the marker is the honest whole of what is
|
|
79
|
-
* known — "it is reasoning, not hung" — and the day text does arrive it rides
|
|
80
|
-
* `full` and this marker is simply not used.
|
|
81
|
-
*/
|
|
82
|
-
export const THINK_MARKER = 'thinking…';
|
|
83
|
-
|
|
84
|
-
const shortPath = (p, cwd) => {
|
|
85
|
-
const s = String(p ?? '');
|
|
86
|
-
return cwd && s.startsWith(cwd) ? s.slice(cwd.length).replace(/^\//, '') : s;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
// ── Claude Code ────────────────────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Tool-call → one line of activity. Claude's tool names, unchanged from when
|
|
93
|
-
* this lived in claude.mjs; `kind` is the daemon's own vocabulary and every
|
|
94
|
-
* runtime's parser must speak it (`read` is what the wiki file counter counts,
|
|
95
|
-
* `write` carries `path` so distinct pages can be counted).
|
|
96
|
-
*/
|
|
97
|
-
export function humanizeClaudeTool(name, input = {}, cwd = '') {
|
|
98
|
-
switch (name) {
|
|
99
|
-
case 'Read':
|
|
100
|
-
return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
|
|
101
|
-
case 'Write':
|
|
102
|
-
case 'Edit': {
|
|
103
|
-
const p = String(input.file_path ?? '');
|
|
104
|
-
const tail = p.split('/').slice(-2).join('/');
|
|
105
|
-
return { kind: 'write', path: p, label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
|
|
106
|
-
}
|
|
107
|
-
case 'Grep':
|
|
108
|
-
return {
|
|
109
|
-
kind: 'search',
|
|
110
|
-
label: `grep ${JSON.stringify(input.pattern ?? '')}${input.path ? ` in ${shortPath(input.path, cwd)}` : ''}`,
|
|
111
|
-
};
|
|
112
|
-
case 'Glob':
|
|
113
|
-
return { kind: 'glob', label: `glob ${input.pattern ?? ''}` };
|
|
114
|
-
case 'LS':
|
|
115
|
-
return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
|
|
116
|
-
case 'Bash':
|
|
117
|
-
// `command` rides beside the display label, VERBATIM (capped): the label
|
|
118
|
-
// is a 60-char readout for a console and the rail, and truncation is
|
|
119
|
-
// fine there — but the admin's command audit exists to answer "what
|
|
120
|
-
// actually ran on our box", and an ellipsis is exactly where the part
|
|
121
|
-
// that matters would hide.
|
|
122
|
-
return {
|
|
123
|
-
kind: 'bash',
|
|
124
|
-
command: String(input.command ?? '').slice(0, 2000),
|
|
125
|
-
label: `$ ${oneLine(input.command, 60)}`,
|
|
126
|
-
};
|
|
127
|
-
case 'TodoWrite': {
|
|
128
|
-
// The CLI's own todo list — the plan the transcript's PLAN card renders.
|
|
129
|
-
// The label narrates the change ("plan: 2 of 5 — cut over /api/session");
|
|
130
|
-
// the structured items ride through toolEventOf below.
|
|
131
|
-
const todos = Array.isArray(input.todos) ? input.todos : [];
|
|
132
|
-
if (todos.length === 0) return null;
|
|
133
|
-
const done = todos.filter((t) => t?.status === 'completed').length;
|
|
134
|
-
const active = todos.find((t) => t?.status === 'in_progress');
|
|
135
|
-
return {
|
|
136
|
-
kind: 'plan',
|
|
137
|
-
label: `plan: ${done} of ${todos.length}${active?.content ? ` — ${oneLine(active.content, 60)}` : ''}`,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
default:
|
|
141
|
-
return null; // other tools: silent
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Count '\n' without materializing a split — a Write's content can be a
|
|
146
|
-
* multi-MB generated file, and `split('\n')` re-allocates all of it as line
|
|
147
|
-
* strings on the synchronous stream path the narrator and wake socket share.
|
|
148
|
-
* Numbers cannot leak, so counting runs on the RAW string. */
|
|
149
|
-
export function countLines(s) {
|
|
150
|
-
const str = String(s ?? '');
|
|
151
|
-
if (!str) return 0;
|
|
152
|
-
let n = 1;
|
|
153
|
-
let i = -1;
|
|
154
|
-
while ((i = str.indexOf('\n', i + 1)) !== -1) n++;
|
|
155
|
-
return n;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** How much raw string the scrubber sees before any cap is applied. A secret
|
|
159
|
-
* can only surface in the first ~capped chars of a field; giving the scrub a
|
|
160
|
-
* window this much larger means a secret would have to be longer than the
|
|
161
|
-
* window minus the cap to straddle out of it — no real credential is. */
|
|
162
|
-
const SCRUB_WINDOW = 8_192;
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Tool-call → ONE STRUCTURED EVENT for the transcript's tool cards — the
|
|
166
|
-
* relay's durable form, where `humanizeClaudeTool` above is its one-line live
|
|
167
|
-
* form. Same source (the CLI's own tool_use input), zero inference: every
|
|
168
|
-
* field is something the CLI emitted, and a tool this doesn't know renders as
|
|
169
|
-
* nothing rather than as a guess.
|
|
170
|
-
*
|
|
171
|
-
* Wire vocabulary (compact keys — this rides a 1.5s-throttled POST):
|
|
172
|
-
* t: read|edit|write|grep|glob|bash|task|plan
|
|
173
|
-
* p: path (worktree-relative) q: pattern/description c: command
|
|
174
|
-
* a/d: line counts added/deleted (from the input's own strings)
|
|
175
|
-
* dl: a few "-/+" prefixed preview lines of an Edit
|
|
176
|
-
* items: the plan's todos, x = text, s = done|active|open
|
|
177
|
-
*
|
|
178
|
-
* SCRUB BEFORE CAP — the order is load-bearing (review, 2026-09-01). The
|
|
179
|
-
* caller passes its `envScrub`; every string is scrubbed over a bounded
|
|
180
|
-
* window FIRST and capped LAST, because the reverse order had two failures:
|
|
181
|
-
* a secret straddling the cap boundary was cut into a prefix the
|
|
182
|
-
* exact-substring scrub could no longer match (a partial credential on the
|
|
183
|
-
* wire), and a scrub REPLACEMENT that grew a string past the cap tripped the
|
|
184
|
-
* server's field limits. `scrub` defaults to identity so this stays testable
|
|
185
|
-
* without a vault.
|
|
186
|
-
*/
|
|
187
|
-
export function toolEventOf(name, input = {}, cwd = '', scrub = (s) => s) {
|
|
188
|
-
// Window → scrub → cap. The window bounds what a multi-MB input costs; the
|
|
189
|
-
// cap is applied AFTER the scrub so a replacement cannot overflow it.
|
|
190
|
-
const clean = (v, cap) => scrub(String(v ?? '').slice(0, SCRUB_WINDOW)).slice(0, cap);
|
|
191
|
-
const rel = (p) => clean(shortPath(p, cwd), 300);
|
|
192
|
-
// The first k lines of a side, from a scrubbed bounded prefix — never a
|
|
193
|
-
// full split of the raw string.
|
|
194
|
-
const firstLines = (s, k) =>
|
|
195
|
-
scrub(String(s ?? '').slice(0, SCRUB_WINDOW)).split('\n', k).slice(0, k);
|
|
196
|
-
switch (name) {
|
|
197
|
-
case 'Read':
|
|
198
|
-
return { t: 'read', p: rel(input.file_path) };
|
|
199
|
-
case 'Write':
|
|
200
|
-
return { t: 'write', p: rel(input.file_path), a: countLines(input.content) };
|
|
201
|
-
case 'Edit': {
|
|
202
|
-
const oldS = String(input.old_string ?? '');
|
|
203
|
-
const newS = String(input.new_string ?? '');
|
|
204
|
-
// A MINI-DIFF, not the diff: the first lines of each side, enough to
|
|
205
|
-
// recognise the change at a glance. The real diff lives in git.
|
|
206
|
-
const dl = [
|
|
207
|
-
...firstLines(oldS, 2).map((l) => `- ${l}`),
|
|
208
|
-
...firstLines(newS, 3).map((l) => `+ ${l}`),
|
|
209
|
-
].map((l) => l.slice(0, 160));
|
|
210
|
-
return { t: 'edit', p: rel(input.file_path), a: countLines(newS), d: countLines(oldS), dl };
|
|
211
|
-
}
|
|
212
|
-
case 'Grep':
|
|
213
|
-
return {
|
|
214
|
-
t: 'grep',
|
|
215
|
-
q: clean(input.pattern, 200),
|
|
216
|
-
...(input.path ? { p: rel(input.path) } : {}),
|
|
217
|
-
};
|
|
218
|
-
case 'Glob':
|
|
219
|
-
return { t: 'glob', q: clean(input.pattern, 200) };
|
|
220
|
-
case 'Bash':
|
|
221
|
-
return { t: 'bash', c: clean(input.command, 200) };
|
|
222
|
-
case 'Task':
|
|
223
|
-
return { t: 'task', q: clean(input.description, 200) };
|
|
224
|
-
case 'TodoWrite': {
|
|
225
|
-
const todos = Array.isArray(input.todos) ? input.todos.slice(0, 20) : [];
|
|
226
|
-
const items = todos
|
|
227
|
-
.map((td) => ({
|
|
228
|
-
x: clean(td?.content, 120),
|
|
229
|
-
s: td?.status === 'completed' ? 'done' : td?.status === 'in_progress' ? 'active' : 'open',
|
|
230
|
-
}))
|
|
231
|
-
.filter((i) => i.x);
|
|
232
|
-
return items.length ? { t: 'plan', items } : null;
|
|
233
|
-
}
|
|
234
|
-
default:
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* THE ACTIVITY KINDS `toolEventOf` ALSO ANSWERS — the dedupe rule, stated once.
|
|
241
|
-
*
|
|
242
|
-
* A Claude tool call goes down BOTH paths in claude.mjs: `humanizeClaudeTool`
|
|
243
|
-
* makes a one-line activity and `toolEventOf` makes a structured event, from
|
|
244
|
-
* the same `tool_use`. A consumer taking both — the agent turn's trace — would
|
|
245
|
-
* otherwise render every read twice, once as a sentence and once as a card.
|
|
246
|
-
*
|
|
247
|
-
* It is the KINDS the structured builder answers, not every tool kind: `LS`
|
|
248
|
-
* produces a `list` activity and no tool event, so dropping `list` would delete
|
|
249
|
-
* it from the trace entirely. And it is only safe to apply on the runtimes
|
|
250
|
-
* whose stream reaches `onToolEvent` at all — codex and agy have their own
|
|
251
|
-
* parsers, never call it, and would go silent. Both halves are pinned in
|
|
252
|
-
* trace.test.mjs, because the two functions are edited independently and the
|
|
253
|
-
* failure is invisible: a dropped prose line looks exactly like a quiet turn.
|
|
254
|
-
*/
|
|
255
|
-
export const CLAUDE_TOOL_PROSE_KINDS = new Set([
|
|
256
|
-
'read',
|
|
257
|
-
'write',
|
|
258
|
-
'search',
|
|
259
|
-
'glob',
|
|
260
|
-
'bash',
|
|
261
|
-
'plan',
|
|
262
|
-
]);
|
|
263
|
-
|
|
264
|
-
// ── Codex ──────────────────────────────────────────────────────────────────
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Codex item → activity, in the daemon's vocabulary.
|
|
268
|
-
*
|
|
269
|
-
* The event names and item types below are not guesses: they were read off the
|
|
270
|
-
* shipped 0.147 binary (`ThreadStarted`/`TurnCompleted`/`ItemCompleted`, items
|
|
271
|
-
* `agent_message` / `reasoning` / `command_execution` / `file_change` /
|
|
272
|
-
* `mcp_tool_call` / `web_search` / `todo_list`). Unknown item types return null
|
|
273
|
-
* and stay silent rather than printing a raw JSON blob into someone's console.
|
|
274
|
-
*/
|
|
275
|
-
function humanizeCodexItem(item = {}, cwd = '') {
|
|
276
|
-
switch (item.item_type ?? item.type) {
|
|
277
|
-
// `full` rides beside the label wherever the item carries more than the
|
|
278
|
-
// label can hold — see the `label`/`full` note above. Codex is the runtime
|
|
279
|
-
// that actually SENDS reasoning text today, so its `think` is the one place
|
|
280
|
-
// in this daemon where a real thought reaches the trace whole.
|
|
281
|
-
case 'agent_message': {
|
|
282
|
-
const text = String(item.text ?? item.message ?? '');
|
|
283
|
-
return { kind: 'say', label: oneLine(text), ...(text ? { full: text } : {}) };
|
|
284
|
-
}
|
|
285
|
-
case 'reasoning': {
|
|
286
|
-
const text = String(item.text ?? '');
|
|
287
|
-
return {
|
|
288
|
-
kind: 'think',
|
|
289
|
-
label: oneLine(text) || THINK_MARKER,
|
|
290
|
-
...(text.trim() ? { full: text } : {}),
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
case 'command_execution':
|
|
294
|
-
return {
|
|
295
|
-
kind: 'bash',
|
|
296
|
-
command: String(item.command ?? '').slice(0, 2000),
|
|
297
|
-
label: `$ ${oneLine(item.command, 60)}`,
|
|
298
|
-
};
|
|
299
|
-
case 'file_change': {
|
|
300
|
-
// `changes` is a list of touched paths; the daemon counts distinct files,
|
|
301
|
-
// so emit one activity per path rather than one for the batch.
|
|
302
|
-
const first = (item.changes ?? [])[0] ?? {};
|
|
303
|
-
const p = String(first.path ?? '');
|
|
304
|
-
const tail = p.split('/').slice(-2).join('/');
|
|
305
|
-
const verb = first.kind === 'add' ? '+' : first.kind === 'delete' ? '-' : '~';
|
|
306
|
-
return { kind: 'write', path: p, label: `${verb} ${tail || 'file'}` };
|
|
307
|
-
}
|
|
308
|
-
case 'mcp_tool_call':
|
|
309
|
-
return { kind: 'tool', label: `${item.server ?? 'mcp'}.${item.tool ?? ''}` };
|
|
310
|
-
case 'web_search':
|
|
311
|
-
return { kind: 'search', label: `search ${oneLine(item.query, 60)}` };
|
|
312
|
-
default:
|
|
313
|
-
return null;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Codex `--json` emits JSONL of ThreadEvents. Returns `{ activity, text,
|
|
319
|
-
* threadId }` — `text` accumulates the agent's own words, because the turn
|
|
320
|
-
* loop reads its sentinels (NOTHING / BLOCKED:<id> / DONE) out of exactly
|
|
321
|
-
* that; `threadId` surfaces once, off the lifecycle event, for callers that
|
|
322
|
-
* need to resume THIS conversation later (runTurn's onThreadId).
|
|
323
|
-
*/
|
|
324
|
-
function parseCodexLine(line, cwd) {
|
|
325
|
-
let ev;
|
|
326
|
-
try {
|
|
327
|
-
ev = JSON.parse(line);
|
|
328
|
-
} catch {
|
|
329
|
-
return null; // not every line is JSON (warnings go to stderr, but be safe)
|
|
330
|
-
}
|
|
331
|
-
switch (ev.type) {
|
|
332
|
-
// The conversation's own id, announced before any item (`ThreadStarted` on
|
|
333
|
-
// the shipped 0.147 binary, like the measured events below). Surfaced so a
|
|
334
|
-
// SESSION turn can resume this exact thread next time: `resume --last` is
|
|
335
|
-
// a machine-global guess, and on a box running two tabs — or a tab plus a
|
|
336
|
-
// dispatch — it resumes someone else's conversation. No activity and no
|
|
337
|
-
// text: nothing here is the model speaking.
|
|
338
|
-
case 'thread.started':
|
|
339
|
-
return { activity: null, text: '', threadId: String(ev.thread_id ?? '') || null };
|
|
340
|
-
case 'item.completed': {
|
|
341
|
-
const item = ev.item ?? {};
|
|
342
|
-
const activity = humanizeCodexItem(item, cwd);
|
|
343
|
-
// Only the agent's MESSAGES are sentinel-bearing text. Reasoning is not:
|
|
344
|
-
// a model that muses "I could output NOTHING here" must not end the turn.
|
|
345
|
-
const isMessage = (item.item_type ?? item.type) === 'agent_message';
|
|
346
|
-
const text = isMessage ? `${item.text ?? item.message ?? ''}\n` : '';
|
|
347
|
-
// `answer` is the SAME message, surfaced alone: `text` accumulates every
|
|
348
|
-
// message plus every error and stderr line, so a caller that needs "what
|
|
349
|
-
// the agent said LAST" (the agent lane's final JSON object) cannot recover
|
|
350
|
-
// it from `out`. Claude's stream has this for free under
|
|
351
|
-
// `answerFromResult`; codex needed it said.
|
|
352
|
-
return { activity, text, ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
|
|
353
|
-
}
|
|
354
|
-
// THE TURN'S OWN TOKEN COUNT (2026-09-24). `codex exec --json` closes a
|
|
355
|
-
// turn with `turn.completed` carrying `usage` — the codex twin of Claude's
|
|
356
|
-
// `result.usage`, and until now dropped on the floor, so a codex agent
|
|
357
|
-
// reported no spend at all. Mapped onto the daemon's four counters:
|
|
358
|
-
// OpenAI's `input_tokens` INCLUDES the cached part, Claude's excludes it,
|
|
359
|
-
// so the cached share is subtracted out rather than counted twice. Codex
|
|
360
|
-
// has no cache-creation figure; zero is what it reported, not a guess.
|
|
361
|
-
// Output already includes reasoning tokens on OpenAI's side.
|
|
362
|
-
case 'turn.completed': {
|
|
363
|
-
const u = ev.usage;
|
|
364
|
-
if (!u || typeof u !== 'object') return null;
|
|
365
|
-
const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Math.floor(Number(v)) : 0);
|
|
366
|
-
const cached = n(u.cached_input_tokens);
|
|
367
|
-
return {
|
|
368
|
-
activity: null,
|
|
369
|
-
text: '',
|
|
370
|
-
// `usageFromResult`'s shape (claude.mjs), so every consumer of
|
|
371
|
-
// `onUsage` reads one vocabulary whichever CLI counted it.
|
|
372
|
-
usage: {
|
|
373
|
-
input: Math.max(0, n(u.input_tokens) - cached),
|
|
374
|
-
output: n(u.output_tokens),
|
|
375
|
-
cacheCreate: 0,
|
|
376
|
-
cacheRead: cached,
|
|
377
|
-
},
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
case 'turn.failed': {
|
|
381
|
-
// An error is prose too, and the sentence that explains a failed turn is
|
|
382
|
-
// routinely longer than a 140-char label — a stack-shaped message loses
|
|
383
|
-
// its cause exactly where somebody is reading to find it.
|
|
384
|
-
//
|
|
385
|
-
// THE LABEL KEEPS ITS OWN `??`, not `msg || …`, and the difference is one
|
|
386
|
-
// input: an EXPLICITLY EMPTY message. `??` lets `''` through as an empty
|
|
387
|
-
// label, which every consumer swallows (`if (!label)`); `||` would print
|
|
388
|
-
// "turn failed" and fire a pulse where this daemon printed nothing. That
|
|
389
|
-
// may well be the better readout, but it is a change to the console and
|
|
390
|
-
// the pulse, and `full` shipped on the promise that neither moved — so it
|
|
391
|
-
// is argued for on its own day, not smuggled in beside a relay field.
|
|
392
|
-
const msg = String(ev.error?.message ?? '');
|
|
393
|
-
return {
|
|
394
|
-
activity: {
|
|
395
|
-
kind: 'error',
|
|
396
|
-
label: oneLine(ev.error?.message ?? 'turn failed'),
|
|
397
|
-
...(msg.trim() ? { full: msg } : {}),
|
|
398
|
-
},
|
|
399
|
-
// THE FAILURE'S OWN WORDS REACH `text` (2026-09-24), the rule the bare
|
|
400
|
-
// `error` arm below already keeps. This returned '' — so a codex turn
|
|
401
|
-
// that failed on a usage limit handed the limit matcher nothing, the
|
|
402
|
-
// agent landed in Stuck as "produced no output", and every other codex
|
|
403
|
-
// agent kept spending into the same wall.
|
|
404
|
-
text: msg.trim() ? `${msg}\n` : '',
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
// A bare `error` event — the shape an auth failure arrives in ("401
|
|
408
|
-
// Unauthorized: Missing bearer…", observed against 0.147.0 with no
|
|
409
|
-
// credentials). It used to fall through to `default` and be dropped, which
|
|
410
|
-
// meant a signed-out Codex produced an EMPTY turn: no sentinel, so the
|
|
411
|
-
// driver nudged twice and reported `stalled`, and the thread said the agent
|
|
412
|
-
// gave up rather than that the CLI is not signed in. The message goes into
|
|
413
|
-
// `text` so it reaches the operator's console AND the usage-limit
|
|
414
|
-
// classifier, which reads exactly this stream.
|
|
415
|
-
case 'error': {
|
|
416
|
-
// Same split as the arm above: `??` for the label (untouched behaviour),
|
|
417
|
-
// `msg` for the relay field only.
|
|
418
|
-
const msg = String(ev.message ?? '');
|
|
419
|
-
return {
|
|
420
|
-
activity: {
|
|
421
|
-
kind: 'error',
|
|
422
|
-
label: oneLine(ev.message ?? 'error'),
|
|
423
|
-
...(msg.trim() ? { full: msg } : {}),
|
|
424
|
-
},
|
|
425
|
-
text: `${ev.message ?? ''}\n`,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
default:
|
|
429
|
-
return null; // turn.started / item.started / item.updated
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// ── Antigravity ────────────────────────────────────────────────────────────
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* `agy --output-format stream-json` emits `{event: init|step_update|result}`.
|
|
437
|
-
* Tool names read off a live 1.1.12 session rather than guessed.
|
|
438
|
-
*/
|
|
439
|
-
function humanizeAgyTool(name, p = {}, cwd = '') {
|
|
440
|
-
const path = p.TargetFile ?? p.AbsolutePath ?? p.DirectoryPath ?? p.File ?? '';
|
|
441
|
-
const tail = String(path).split('/').slice(-2).join('/');
|
|
442
|
-
switch (name) {
|
|
443
|
-
case 'view_file':
|
|
444
|
-
case 'read_resource':
|
|
445
|
-
return { kind: 'read', label: `read ${shortPath(path, cwd)}` };
|
|
446
|
-
case 'write_to_file':
|
|
447
|
-
case 'replace_file_content':
|
|
448
|
-
case 'multi_replace_file_content':
|
|
449
|
-
return {
|
|
450
|
-
kind: 'write',
|
|
451
|
-
path: String(path),
|
|
452
|
-
label: `${name === 'write_to_file' ? '+ page' : '~ page'} ${tail || 'file'}`,
|
|
453
|
-
};
|
|
454
|
-
case 'grep_search':
|
|
455
|
-
return { kind: 'search', label: `grep ${oneLine(p.Query ?? p.SearchTerm ?? '', 60)}` };
|
|
456
|
-
case 'find_by_name':
|
|
457
|
-
return { kind: 'glob', label: `find ${oneLine(p.Pattern ?? '', 60)}` };
|
|
458
|
-
case 'list_dir':
|
|
459
|
-
return { kind: 'list', label: `ls ${shortPath(path, cwd)}` };
|
|
460
|
-
case 'run_command':
|
|
461
|
-
return {
|
|
462
|
-
kind: 'bash',
|
|
463
|
-
command: String(p.CommandLine ?? '').slice(0, 2000),
|
|
464
|
-
label: `$ ${oneLine(p.CommandLine, 60)}`,
|
|
465
|
-
};
|
|
466
|
-
case 'call_mcp_tool':
|
|
467
|
-
return { kind: 'tool', label: `mcp.${p.ToolName ?? ''}` };
|
|
468
|
-
default:
|
|
469
|
-
return null;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function parseAgyLine(line, cwd) {
|
|
474
|
-
let ev;
|
|
475
|
-
try {
|
|
476
|
-
ev = JSON.parse(line);
|
|
477
|
-
} catch {
|
|
478
|
-
return null;
|
|
479
|
-
}
|
|
480
|
-
if (ev.event === 'step_update') {
|
|
481
|
-
const su = ev.step_update ?? {};
|
|
482
|
-
const ti = su.tool_info;
|
|
483
|
-
if (!ti) return null;
|
|
484
|
-
const err = ti.error?.message;
|
|
485
|
-
// Same rule as codex's error lines: the label is the console's, `full` is
|
|
486
|
-
// the trace's, and a tool error's message is exactly the kind of sentence a
|
|
487
|
-
// 140-char clamp cuts the cause out of.
|
|
488
|
-
if (err)
|
|
489
|
-
return {
|
|
490
|
-
activity: { kind: 'error', label: oneLine(err), full: String(err) },
|
|
491
|
-
text: '',
|
|
492
|
-
};
|
|
493
|
-
// Each tool is reported twice — once ACTIVE, once DONE — so only the
|
|
494
|
-
// terminal state emits, otherwise every action appears in the thread twice.
|
|
495
|
-
if (su.state && su.state !== 'DONE') return null;
|
|
496
|
-
return { activity: humanizeAgyTool(ti.name, ti.parameters ?? {}, cwd), text: '' };
|
|
497
|
-
}
|
|
498
|
-
if (ev.event === 'result') {
|
|
499
|
-
const r = ev.result ?? {};
|
|
500
|
-
// The final answer is the ONLY sentinel-bearing text: agy has no incremental
|
|
501
|
-
// assistant-message event, so a turn's whole verdict arrives here at once.
|
|
502
|
-
return {
|
|
503
|
-
activity: r.error
|
|
504
|
-
? { kind: 'error', label: oneLine(r.error), full: String(r.error) }
|
|
505
|
-
: null,
|
|
506
|
-
text: `${r.response ?? ''}${r.error ? `\n${r.error}` : ''}\n`,
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
return null;
|
|
510
|
-
}
|
|
37
|
+
* dispatch), and arrives on the brief. Detection (runtimeDetection.mjs) answers
|
|
38
|
+
* "what does this machine HAVE" — activity, never capacity, and never a
|
|
39
|
+
* default we invented.
|
|
40
|
+
*
|
|
41
|
+
* WHAT MOVED OUT (2026-09-26, SOLID F045), each to the module named for its
|
|
42
|
+
* own reason to change: what a CLI's stream MEANT → runtimeEvents.mjs; which
|
|
43
|
+
* CLIs this box has, and which runs an unmentioned job → runtimeDetection.mjs;
|
|
44
|
+
* the skills and MCP servers a CLI reported, and the one-shot probe that
|
|
45
|
+
* learns them → runtimeCapabilities.mjs; and each vendor's own row (argv,
|
|
46
|
+
* profiles, efforts, MCP mint) → runtime{Claude,Codex,Antigravity}.mjs, since
|
|
47
|
+
* a vendor renaming a flag is no reason to open another vendor's builder. This
|
|
48
|
+
* file keeps the registry's assembly, the lookup and the per-profile
|
|
49
|
+
* drivability rule.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { SAFE } from './config.mjs';
|
|
53
|
+
import { CLAUDE_RUNTIME } from './runtimeClaude.mjs';
|
|
54
|
+
import { CODEX_RUNTIME } from './runtimeCodex.mjs';
|
|
55
|
+
import { ANTIGRAVITY_RUNTIME } from './runtimeAntigravity.mjs';
|
|
511
56
|
|
|
512
57
|
// ── The registry ───────────────────────────────────────────────────────────
|
|
513
58
|
|
|
@@ -526,524 +71,14 @@ function parseAgyLine(line, cwd) {
|
|
|
526
71
|
* read the bearer token from an ENV VAR (`bearer_token_env_var`), so its token
|
|
527
72
|
* never touches disk at all — strictly better, and the reason Codex was the
|
|
528
73
|
* first second-runtime rather than the easiest-looking one.
|
|
74
|
+
*
|
|
75
|
+
* Each row's `mcp` is that mint (`claudeMcp` in runtimeClaude.mjs, `codexMcp`
|
|
76
|
+
* in runtimeCodex.mjs); Antigravity's is null, and its row says why.
|
|
529
77
|
*/
|
|
530
|
-
function claudeMcp(token, mcpUrl) {
|
|
531
|
-
const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
|
|
532
|
-
const path = join(dir, 'mcp.json');
|
|
533
|
-
writeFileSync(
|
|
534
|
-
path,
|
|
535
|
-
JSON.stringify({
|
|
536
|
-
mcpServers: {
|
|
537
|
-
flowviant: {
|
|
538
|
-
type: 'http',
|
|
539
|
-
url: mcpUrl,
|
|
540
|
-
headers: { Authorization: `Bearer ${token}`, 'User-Agent': USER_AGENT },
|
|
541
|
-
},
|
|
542
|
-
},
|
|
543
|
-
}),
|
|
544
|
-
{ mode: 0o600 }
|
|
545
|
-
);
|
|
546
|
-
return { dir, args: ['--mcp-config', path], env: {} };
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
function codexMcp(token, mcpUrl) {
|
|
550
|
-
return {
|
|
551
|
-
dir: null, // nothing written — the token rides in the environment
|
|
552
|
-
args: [
|
|
553
|
-
'-c',
|
|
554
|
-
`mcp_servers.flowviant.url="${mcpUrl}"`,
|
|
555
|
-
'-c',
|
|
556
|
-
'mcp_servers.flowviant.bearer_token_env_var="FLOWVIANT_MCP_TOKEN"',
|
|
557
|
-
],
|
|
558
|
-
env: { FLOWVIANT_MCP_TOKEN: token },
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
|
|
562
78
|
export const RUNTIMES = {
|
|
563
|
-
claude:
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
vendor: 'Anthropic',
|
|
567
|
-
bin: 'claude',
|
|
568
|
-
install: 'npm i -g @anthropic-ai/claude-code',
|
|
569
|
-
login: 'claude',
|
|
570
|
-
/** The Agent-SDK live session (persistent, injectable mid-task) is Claude
|
|
571
|
-
* only — it is an Anthropic SDK, not a CLI contract. Everything else runs
|
|
572
|
-
* the subprocess path. */
|
|
573
|
-
live: true,
|
|
574
|
-
/**
|
|
575
|
-
* Every profile, because every profile is DEFINED in its vocabulary: the
|
|
576
|
-
* four `--allowedTools` lists in claude.mjs are what "build", "wiki",
|
|
577
|
-
* "consult" and "plan" currently mean. That is a statement about where the
|
|
578
|
-
* contract was written, not a claim that only Claude could ever satisfy it.
|
|
579
|
-
*/
|
|
580
|
-
// `design` and `research` (0.97.0) are Claude's alone: each is an
|
|
581
|
-
// `--allowedTools` list with a PATH-SCOPED write (claude.mjs), and neither
|
|
582
|
-
// codex's sandbox modes nor agy's flags were measured to express "write
|
|
583
|
-
// only this directory". A runtime that does not declare them is refused
|
|
584
|
-
// such a card before spawn, in words — never handed a build turn instead.
|
|
585
|
-
profiles: ['build', 'wiki', 'consult', 'plan', 'design', 'research'],
|
|
586
|
-
mcp: claudeMcp,
|
|
587
|
-
/**
|
|
588
|
-
* Claude takes the operating contract as a real system prompt, which is the
|
|
589
|
-
* strongest form of it available anywhere: `--append-system-prompt` sits
|
|
590
|
-
* above the conversation rather than inside it.
|
|
591
|
-
*/
|
|
592
|
-
args({ prompt, system, model, effort, resume, streamJson, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, agentTools }) {
|
|
593
|
-
const a = [];
|
|
594
|
-
// THREE ANSWERS TO ONE QUESTION — "what conversation is this?" — and they
|
|
595
|
-
// are mutually exclusive, never combined.
|
|
596
|
-
//
|
|
597
|
-
// ADOPTION: `--resume <id> --fork-session` finds the session globally
|
|
598
|
-
// (any cwd), carries its full context, and writes the FORK natively into
|
|
599
|
-
// THIS cwd's own store, leaving the original transcript untouched
|
|
600
|
-
// (measured on 2.1.234).
|
|
601
|
-
//
|
|
602
|
-
// BY ID: the conversation THIS TAB spoke under last time, learned from
|
|
603
|
-
// the CLI's own `system.init` event and pinned per session. It exists
|
|
604
|
-
// because `--continue` is CWD-KEYED, and a directory stopped being one
|
|
605
|
-
// tab the day tabs moved into their driver's project folder — two tabs
|
|
606
|
-
// sharing a directory both said `--continue` and both resumed whichever
|
|
607
|
-
// conversation spoke most recently there, so tab B inherited tab A's
|
|
608
|
-
// entire context and every turn after that ping-ponged between them.
|
|
609
|
-
// Exactly the failure codex's own note warns about for `resume --last`,
|
|
610
|
-
// arriving for Claude by a different route. An id is unambiguous wherever
|
|
611
|
-
// the tab is standing.
|
|
612
|
-
//
|
|
613
|
-
// `--continue` is the LAST resort, and only where nothing better is
|
|
614
|
-
// known.
|
|
615
|
-
if (adoptResumeId) a.push('--resume', adoptResumeId, '--fork-session');
|
|
616
|
-
else if (resumeThreadId) a.push('--resume', resumeThreadId);
|
|
617
|
-
else if (resume) a.push('--continue');
|
|
618
|
-
a.push('-p', prompt, '--append-system-prompt', system);
|
|
619
|
-
if (agentTools) {
|
|
620
|
-
// Keep personal settings, but never load the agent-editable project's
|
|
621
|
-
// settings, hooks, skills or CLAUDE.md discovery. A resumed agent must
|
|
622
|
-
// render the CURRENT base snapshot, not the first turn's prompt cache.
|
|
623
|
-
a.push('--setting-sources', 'user', '--system-prompt-snapshot', 'off');
|
|
624
|
-
if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
|
|
625
|
-
}
|
|
626
|
-
// These fenced postures have no project MCP, even if Claude discovers a
|
|
627
|
-
// .mcp.json in the agent's worktree. Build gets its reviewed base config
|
|
628
|
-
// explicitly from the caller; the plan profile keeps its own principal.
|
|
629
|
-
if (['consult', 'wiki', 'design', 'research'].includes(profile)) a.push('--strict-mcp-config');
|
|
630
|
-
a.push(...mcp, ...resultSchemaArgs);
|
|
631
|
-
a.push('--model', model || MODEL);
|
|
632
|
-
if (effort) a.push('--effort', effort);
|
|
633
|
-
if (streamJson) a.push('--output-format', 'stream-json', '--verbose');
|
|
634
|
-
// The knowledge library (0.94.0) sits in the checkout, outside a
|
|
635
|
-
// worktree's cwd: named as a readable directory so a curated profile
|
|
636
|
-
// does not refuse the path the prompt just handed it. Before `perm`,
|
|
637
|
-
// because `--allowedTools` is variadic and would swallow the flag.
|
|
638
|
-
if (knowledgeDir) a.push('--add-dir', knowledgeDir);
|
|
639
|
-
a.push(...perm);
|
|
640
|
-
return a;
|
|
641
|
-
},
|
|
642
|
-
parse: null, // claude.mjs owns its own stream parser (unchanged)
|
|
643
|
-
},
|
|
644
|
-
|
|
645
|
-
codex: {
|
|
646
|
-
id: 'codex',
|
|
647
|
-
label: 'Codex',
|
|
648
|
-
vendor: 'OpenAI',
|
|
649
|
-
bin: 'codex',
|
|
650
|
-
install: 'npm i -g @openai/codex',
|
|
651
|
-
login: 'codex login',
|
|
652
|
-
live: false,
|
|
653
|
-
/**
|
|
654
|
-
* BUILD AND CONSULT. See `args()` below for how consult is expressed — the
|
|
655
|
-
* short version is that Codex keeps the promise at the kernel rather than in
|
|
656
|
-
* a verb allowlist, and for a consult's actual threat (exfiltration driven
|
|
657
|
-
* by an injected question) that is sufficient and arguably stronger.
|
|
658
|
-
*
|
|
659
|
-
* ALL THREE, and `wiki` is the one worth pausing on: Codex expresses it
|
|
660
|
-
* MORE strictly than Claude does. A cartographer must read the repo and
|
|
661
|
-
* write only the vault; Claude's WIKI_PERM cannot path-scope Write and says
|
|
662
|
-
* so, leaning on the worktree reset as a backstop. Codex's permission
|
|
663
|
-
* profiles enforce the same rule in the kernel — measured: repo readable,
|
|
664
|
-
* vault writable, repo writes refused, network off.
|
|
665
|
-
*
|
|
666
|
-
* Enforcement was verified on Linux (bubblewrap + seccomp). macOS Seatbelt
|
|
667
|
-
* and Windows are UNTESTED; if this daemon starts running there, re-verify
|
|
668
|
-
* before trusting the consult posture on those platforms.
|
|
669
|
-
*/
|
|
670
|
-
profiles: ['build', 'consult', 'wiki', 'plan'],
|
|
671
|
-
mcp: codexMcp,
|
|
672
|
-
/**
|
|
673
|
-
* Codex has NO system-prompt flag. The contract therefore rides inside the
|
|
674
|
-
* prompt, fenced and placed first, and this is a genuine weakening worth
|
|
675
|
-
* stating plainly: a system prompt is a rule, and a prompt preamble is a
|
|
676
|
-
* strong suggestion the model may drift from over a long turn. It is the
|
|
677
|
-
* best available; AGENTS.md was the alternative and is worse, because it is
|
|
678
|
-
* a FILE IN THE WORKTREE — one `git add -A` from being committed into the
|
|
679
|
-
* user's repository, which is not a risk worth taking for a slightly
|
|
680
|
-
* stickier instruction.
|
|
681
|
-
*
|
|
682
|
-
* Agent turns additionally pass reviewed base instructions as a developer
|
|
683
|
-
* config override when the personal config has no developer instruction;
|
|
684
|
-
* the prompt always carries them too. Workbench tabs keep their normal
|
|
685
|
-
* checkout discovery.
|
|
686
|
-
*
|
|
687
|
-
* `--skip-git-repo-check` is deliberately NOT passed: a task always builds
|
|
688
|
-
* in a git worktree, and if it somehow is not one, failing loudly beats
|
|
689
|
-
* silently editing files nobody can diff.
|
|
690
|
-
*
|
|
691
|
-
* THE PROMPT IS LAST, and that is load-bearing rather than tidy. Codex takes
|
|
692
|
-
* it as a trailing POSITIONAL, so every flag — including the two `-c` MCP
|
|
693
|
-
* overrides, which the caller hands in rather than appending — has to be
|
|
694
|
-
* placed before it. Appending them after the positional is the kind of argv
|
|
695
|
-
* that parses today and stops parsing on some future clap upgrade.
|
|
696
|
-
*/
|
|
697
|
-
args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools }) {
|
|
698
|
-
// Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
|
|
699
|
-
// agy moves) — codex has no adoptable store wired yet. Reaching here
|
|
700
|
-
// with an adopt id is a wiring mistake upstream, and it fails loudly on
|
|
701
|
-
// purpose: quietly dropping the flag would answer that session's held
|
|
702
|
-
// context with a different brain.
|
|
703
|
-
if (adoptResumeId) throw new Error("codex has no adoptable terminal store — an adopt id can't reach this builder");
|
|
704
|
-
const a = ['exec'];
|
|
705
|
-
// BY ID when the caller knows WHICH conversation this is — a Workbench
|
|
706
|
-
// tab's held context, captured off thread.started and stored with its
|
|
707
|
-
// worktree. `--last` resumes the machine's most recent codex conversation,
|
|
708
|
-
// which is only safe on the dispatch path (one lane, one turn at a time,
|
|
709
|
-
// in its own worktree); for a session it is a machine-global guess that
|
|
710
|
-
// two tabs — or a tab plus a dispatch — would cross-resume.
|
|
711
|
-
if (resumeThreadId) a.push('resume', resumeThreadId);
|
|
712
|
-
else if (resume) a.push('resume', '--last');
|
|
713
|
-
a.push('--json');
|
|
714
|
-
if (agentTools) {
|
|
715
|
-
a.push('-c', 'project_doc_max_bytes=0');
|
|
716
|
-
// A person's existing developer instructions keep their authority.
|
|
717
|
-
// The base snapshot is also present in the per-turn prompt preamble.
|
|
718
|
-
if (!agentTools.personalDeveloperInstructions)
|
|
719
|
-
a.push('-c', `developer_instructions=${JSON.stringify(agentTools.instructions)}`);
|
|
720
|
-
}
|
|
721
|
-
if (model) a.push('--model', model);
|
|
722
|
-
// Effort is a config value on Codex rather than a flag.
|
|
723
|
-
if (effort) a.push('-c', `model_reasoning_effort="${effort}"`);
|
|
724
|
-
|
|
725
|
-
if (profile === 'consult') {
|
|
726
|
-
// A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — and it is a
|
|
727
|
-
// different shape from Claude's, which is the whole reason `profile` is
|
|
728
|
-
// a promise rather than a flag list.
|
|
729
|
-
//
|
|
730
|
-
// Claude gets a VERB allowlist: Read, Grep, Glob and a handful of
|
|
731
|
-
// read-only Bash forms, with nothing that reaches the network. Codex has
|
|
732
|
-
// no such thing — it has no file-read tool at all, so reading the repo
|
|
733
|
-
// IS command execution (`cat`, `rg`). Removing the shell leaves the
|
|
734
|
-
// model with exactly ["update_plan","request_user_input"], which cannot
|
|
735
|
-
// answer a question about a codebase. You get both capabilities or
|
|
736
|
-
// neither.
|
|
737
|
-
//
|
|
738
|
-
// So the promise is kept one layer down instead. `read-only` is
|
|
739
|
-
// kernel-enforced (bubblewrap + seccomp on Linux): writes fail, and a
|
|
740
|
-
// direct-IP connect fails with "Operation not permitted" — socket() is
|
|
741
|
-
// denied, not merely DNS. An injected command still RUNS and still
|
|
742
|
-
// cannot take the repository anywhere, which is the threat a consult
|
|
743
|
-
// actually has: its prompt is steered by a question any project editor
|
|
744
|
-
// can type. Arguably a stronger guarantee than the allowlist, being
|
|
745
|
-
// below the agent rather than inside it.
|
|
746
|
-
a.push('--sandbox', 'read-only');
|
|
747
|
-
|
|
748
|
-
// THE HOLE THE SANDBOX DOES NOT COVER. `web_search` ships in `codex
|
|
749
|
-
// exec`'s default tool list even without --search, and it executes
|
|
750
|
-
// SERVER-SIDE at OpenAI — no local sandbox touches it. An injected turn
|
|
751
|
-
// could pack repo contents into a query and egress them straight past
|
|
752
|
-
// everything above. Both spellings, because the two config systems
|
|
753
|
-
// disagree about which one is live.
|
|
754
|
-
a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
|
|
755
|
-
|
|
756
|
-
// Sub-agents would be a second turn whose posture nobody here chose.
|
|
757
|
-
a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false');
|
|
758
|
-
|
|
759
|
-
// HERMETIC. Without these a user's ~/.codex/config.toml, a project
|
|
760
|
-
// `.rules` execpolicy file, or their own MCP servers can widen a posture
|
|
761
|
-
// we are asserting on their behalf — silently, and on the one turn whose
|
|
762
|
-
// prompt comes from someone else's typing.
|
|
763
|
-
a.push('--ignore-user-config', '--ignore-rules');
|
|
764
|
-
} else if (profile === 'plan') {
|
|
765
|
-
// A PLANNING SESSION. Read-only on the filesystem, exactly like a
|
|
766
|
-
// consult — the writes it makes go through the control plane, not
|
|
767
|
-
// through this box — so the kernel sandbox is the same one, and for the
|
|
768
|
-
// same reason: this turn's prompt is steered by anything a project
|
|
769
|
-
// editor can type.
|
|
770
|
-
//
|
|
771
|
-
// Everything the consult branch above closes stays closed, and the
|
|
772
|
-
// reasoning is unchanged, so it is not restated: web_search egresses
|
|
773
|
-
// server-side at OpenAI where no local sandbox reaches it, sub-agents
|
|
774
|
-
// would be a turn whose posture nobody here chose, and a user's own
|
|
775
|
-
// config or MCP servers must not widen a posture we are asserting on
|
|
776
|
-
// their behalf.
|
|
777
|
-
//
|
|
778
|
-
// What differs from a consult is the ONE thing this profile exists for:
|
|
779
|
-
// an MCP config IS passed, carrying the plan principal's token. That
|
|
780
|
-
// token's whole tool set is the five plan tools — the server refuses
|
|
781
|
-
// anything else on it — so the control plane being open here does not
|
|
782
|
-
// widen what a hijacked turn could reach beyond the plan it is already
|
|
783
|
-
// sitting in.
|
|
784
|
-
a.push('--sandbox', 'read-only');
|
|
785
|
-
a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
|
|
786
|
-
a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false');
|
|
787
|
-
a.push('--ignore-user-config', '--ignore-rules');
|
|
788
|
-
} else if (profile === 'wiki' && vaultDir) {
|
|
789
|
-
// THE CARTOGRAPHER, AND THIS ONE IS STRICTER THAN CLAUDE'S.
|
|
790
|
-
//
|
|
791
|
-
// A wiki turn reads the whole repo and writes ONLY the vault. Claude
|
|
792
|
-
// cannot actually express that: WIKI_PERM hands it Write/Edit and its own
|
|
793
|
-
// comment admits "Write/Edit can't be path-scoped here; the worktree
|
|
794
|
-
// reset is the backstop" — i.e. the cartographer CAN scribble on the
|
|
795
|
-
// checkout and we clean up afterwards.
|
|
796
|
-
//
|
|
797
|
-
// Codex's permission profiles take a per-path filesystem map, so the
|
|
798
|
-
// rule is enforced by the kernel instead of apologised for. Measured:
|
|
799
|
-
// repo readable, vault writable, repo writes fail "Read-only file
|
|
800
|
-
// system", and curl returns 000 — the network is off, which is the half
|
|
801
|
-
// WIKI_PERM was really protecting (its comment: "Command execution is
|
|
802
|
-
// the line: it enables network exfil").
|
|
803
|
-
//
|
|
804
|
-
// The table is set WHOLE rather than as a dotted key, and that is not
|
|
805
|
-
// style: `-c permissions.wiki.filesystem."<path>"="write"` splits the
|
|
806
|
-
// dotted path on the dots INSIDE the path, and every real vault lives
|
|
807
|
-
// under `~/.flowviant/vaults/…`. It fails with "filesystem path must be
|
|
808
|
-
// absolute", which reads like a path problem and is a parsing one.
|
|
809
|
-
a.push('-c', 'permissions.flowviantwiki.extends=":read-only"');
|
|
810
|
-
a.push('-c', `permissions.flowviantwiki.filesystem={"${vaultDir}"="write"}`);
|
|
811
|
-
a.push('-P', 'flowviantwiki');
|
|
812
|
-
a.push('--ignore-user-config', '--ignore-rules');
|
|
813
|
-
} else {
|
|
814
|
-
// The daemon's build posture, mapped: SAFE keeps writes inside the
|
|
815
|
-
// workspace, the default lets the agent run its own tests and git
|
|
816
|
-
// commands. Neither asks a human — there is no human on this end of the
|
|
817
|
-
// pipe. Deliberately NOT hermetic: a build is work the user asked for by
|
|
818
|
-
// @mentioning this CLI, and their own config is theirs to apply.
|
|
819
|
-
a.push('--sandbox', SAFE ? 'workspace-write' : 'danger-full-access');
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
a.push(...mcp, ...resultSchemaArgs);
|
|
823
|
-
a.push(`${system}\n\n---\n\n${prompt}`);
|
|
824
|
-
return a;
|
|
825
|
-
},
|
|
826
|
-
parse: parseCodexLine,
|
|
827
|
-
/** `codex exec --output-schema <file>` constrains the FINAL message to a
|
|
828
|
-
* JSON Schema. Only needed on the mediated path; the direct path reports
|
|
829
|
-
* through MCP tool calls, which are already structured. */
|
|
830
|
-
resultSchema: (path) => ['--output-schema', path],
|
|
831
|
-
},
|
|
832
|
-
|
|
833
|
-
/**
|
|
834
|
-
* DECLARED, NOT DRIVABLE — and this is now MEASURED rather than argued.
|
|
835
|
-
*
|
|
836
|
-
* The reason went wrong twice before it went right, so the evidence is written
|
|
837
|
-
* down here in full. First it cited upstream antigravity-cli#60, which is about
|
|
838
|
-
* `.antigravitycli/mcp_config.json` — the project-DISCOVERY folder — while
|
|
839
|
-
* claiming it was about `.agents/`, the workspace-CUSTOMIZATION folder. Then,
|
|
840
|
-
* on reading the docs (antigravity.google/docs/mcp describes `.agents/`, and
|
|
841
|
-
* Google's own codelab creates it), this comment swung the other way and said
|
|
842
|
-
* the blocker looked wrong. Both were reasoning from paperwork.
|
|
843
|
-
*
|
|
844
|
-
* THE TEST, run against agy 1.1.12, signed in, in a real git repo:
|
|
845
|
-
* the SAME minimal stdio MCP server, declared two ways.
|
|
846
|
-
* • workspace `<worktree>/.agents/mcp_config.json` → the server process is
|
|
847
|
-
* NEVER SPAWNED. Not connected-and-failed: never launched. The model
|
|
848
|
-
* answers "NO_MCP".
|
|
849
|
-
* • global `~/.gemini/config/mcp_config.json` → spawns immediately and
|
|
850
|
-
* handshakes: server/discover, initialize, notifications/initialized,
|
|
851
|
-
* tools/list.
|
|
852
|
-
* stdio deliberately, to remove every confound — no bearer token to reject,
|
|
853
|
-
* no network, no TLS. The difference is the config LOCATION and nothing else.
|
|
854
|
-
*
|
|
855
|
-
* AND IT SURVIVES THE TRUST VARIABLE, which was the obvious objection: agy
|
|
856
|
-
* keeps a `trustedWorkspaces` list in settings.json, a fresh per-task worktree
|
|
857
|
-
* is not on it, and an untrusted folder demonstrably changes behaviour (the
|
|
858
|
-
* model stops treating cwd as its workspace and works out of its own scratch
|
|
859
|
-
* dir). Adding the worktree to `trustedWorkspaces` and re-running changed
|
|
860
|
-
* nothing: the server still never spawned, the model still answered NO_MCP.
|
|
861
|
-
* So the workspace config is not trust-gated, it is simply not read.
|
|
862
|
-
*
|
|
863
|
-
* So Antigravity's MCP config is machine-wide IN PRACTICE, and the original
|
|
864
|
-
* blocker's conclusion stands even though its cited reason never did: every
|
|
865
|
-
* lane on the box would share one worker token, which is exactly the blast
|
|
866
|
-
* radius the per-lane token exists to prevent. That is why this stays
|
|
867
|
-
* undrivable, and it is a property of the CLI rather than something we can
|
|
868
|
-
* work around from here.
|
|
869
|
-
*
|
|
870
|
-
* A TRAP FOR WHOEVER RE-TESTS THIS: agy exposes MCP through a single generic
|
|
871
|
-
* `call_mcp_tool` dispatcher, so per-server tools NEVER appear in the `init`
|
|
872
|
-
* event's tools array even when a server is loaded correctly. Reading that
|
|
873
|
-
* array tells you nothing. Watch the server process instead, or ask the model.
|
|
874
|
-
*
|
|
875
|
-
* FOUR MORE FACTS from the same session, each an independent obstacle:
|
|
876
|
-
* 1. AUTH IS INTERACTIVE OAUTH (bubbletea TUI; needs a real /dev/tty), with
|
|
877
|
-
* no headless credential path. A signed-out `agy -p` prints a consent URL
|
|
878
|
-
* and then SITS until `--print-timeout` (default 5m) before erroring — so
|
|
879
|
-
* a lane on a signed-out agy burns five minutes per turn looking like a
|
|
880
|
-
* hang. Preflight cannot see it: `--version` succeeds while signed out.
|
|
881
|
-
* 2. `--sandbox` IS A BOOLEAN ("terminal restrictions enabled"), not a mode
|
|
882
|
-
* selector — but a CONSULT POSTURE IS STILL EXPRESSIBLE, and this was
|
|
883
|
-
* recorded backwards here for a while. It does not come from `--sandbox`
|
|
884
|
-
* at all; it comes from headless mode's default. Any tool needing a
|
|
885
|
-
* permission that cannot be prompted for is AUTO-DENIED:
|
|
886
|
-
* "User denied permission to run command: <cmd>"
|
|
887
|
-
* "a tool required the 'command' permission that headless mode cannot
|
|
888
|
-
* prompt for, so it was auto-denied."
|
|
889
|
-
* Observed repeatedly, including for an entirely benign `pwd && ls -la`,
|
|
890
|
-
* and a local listener confirmed no egress in any run. Reads (list_dir,
|
|
891
|
-
* file reads) work throughout. So: NOT passing
|
|
892
|
-
* `--dangerously-skip-permissions` IS the consult posture, and passing it
|
|
893
|
-
* is the build posture — both per invocation, both harness-enforced
|
|
894
|
-
* rather than model-instructed. `--mode plan` is a separate, WEAKER thing:
|
|
895
|
-
* it steers behaviour and blocks workspace writes, but it is not what
|
|
896
|
-
* stops command execution.
|
|
897
|
-
* RESIDUAL RISK, and it has no fix from here: `permissions.allow` in the
|
|
898
|
-
* machine-wide settings.json is inherited, so a user who has allowed
|
|
899
|
-
* `command(...)` widens every consult on that box. Codex has
|
|
900
|
-
* `--ignore-user-config` for exactly this; agy has no equivalent.
|
|
901
|
-
* 3. No `mcp` subcommand; `/mcp` is interactive-only.
|
|
902
|
-
* 4. It attempts to INSTALL PLAYWRIGHT at startup (observed failing 404
|
|
903
|
-
* against playwright.azureedge.net). A daemon runtime that downloads and
|
|
904
|
-
* runs a browser driver is worth knowing before it goes on a machine the
|
|
905
|
-
* project leaves running.
|
|
906
|
-
*
|
|
907
|
-
* There is no npm package. `antigravity-cli` (0.0.1, "placeholder") and `agy`
|
|
908
|
-
* (0.0.0, empty) on npm are SQUATS by unrelated accounts; the real channel is
|
|
909
|
-
* the install script at antigravity.google, which is why `install` below says
|
|
910
|
-
* to see the docs rather than naming an npm command.
|
|
911
|
-
*
|
|
912
|
-
* WHAT WOULD UNBLOCK IT: a per-invocation MCP flag or env var, or workspace
|
|
913
|
-
* configs actually being honoured. Re-test with the two-location stdio probe
|
|
914
|
-
* above — it is five minutes and it answers the question outright. Pin any
|
|
915
|
-
* wiring to >= 1.1.10 (`--model`/`--effort` were ignored in headless before it;
|
|
916
|
-
* `--output-format` arrived in 1.1.8).
|
|
917
|
-
*/
|
|
918
|
-
antigravity: {
|
|
919
|
-
id: 'antigravity',
|
|
920
|
-
label: 'Antigravity',
|
|
921
|
-
vendor: 'Google',
|
|
922
|
-
bin: 'agy',
|
|
923
|
-
install: 'see antigravity.google/docs/cli',
|
|
924
|
-
login: 'agy',
|
|
925
|
-
live: false,
|
|
926
|
-
/** None, because it cannot reach the MCP server at all — see `blocked`. */
|
|
927
|
-
/**
|
|
928
|
-
* WIKI AND CONSULT, BUT NOT BUILD — and the split is the whole point of
|
|
929
|
-
* making drivability per-profile rather than one verdict.
|
|
930
|
-
*
|
|
931
|
-
* Only a BUILD needs the MCP control plane: claiming, reporting a blocker,
|
|
932
|
-
* attaching a PR, completing. A wiki turn writes markdown files that the
|
|
933
|
-
* daemon syncs afterwards, and a consult answers a question in prose; BOTH
|
|
934
|
-
* already run with no MCP at all on every runtime, Claude included. Gating
|
|
935
|
-
* them on an MCP capability they never use was a test of the wrong thing,
|
|
936
|
-
* and it is what kept Antigravity at zero for months.
|
|
937
|
-
*
|
|
938
|
-
* Build returns here when the mediated adapter lands (the daemon holds the
|
|
939
|
-
* MCP connection and the CLI just returns schema-enforced JSON via
|
|
940
|
-
* `--json-schema`), which needs no per-invocation MCP config from the vendor
|
|
941
|
-
* at all.
|
|
942
|
-
*
|
|
943
|
-
* PLAN IS ABSENT ON PURPOSE, and NOT because a planning turn is beyond it —
|
|
944
|
-
* it reads code as well as anything here. A plan session is a conversation
|
|
945
|
-
* that makes many control-plane calls as it goes (spawn a slice, re-shape
|
|
946
|
-
* it, drop it, rewrite the spec), and mediation turns a turn into ONE
|
|
947
|
-
* schema-enforced form the daemon then applies. That shape fits a build,
|
|
948
|
-
* whose outcome is a single structured result; it does not yet fit an
|
|
949
|
-
* argument. Note that `canRun` would otherwise say yes via `mediated()` and
|
|
950
|
-
* hand this runtime a job it cannot finish — declaring the profile is the
|
|
951
|
-
* only thing standing between here and that. Mediated planning is a real
|
|
952
|
-
* design (the session returns its writes as a batch), just not a built one.
|
|
953
|
-
*/
|
|
954
|
-
profiles: ['build', 'wiki', 'consult'],
|
|
955
|
-
mcp: null,
|
|
956
|
-
args({ prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
|
|
957
|
-
const a = [];
|
|
958
|
-
// WHICH CONVERSATION IS THIS — one decision, answered one way (the same
|
|
959
|
-
// rule as Claude's --resume/--continue). By id when the caller knows:
|
|
960
|
-
// `--conversation <id>` resumes globally, any cwd (measured on 1.1.12).
|
|
961
|
-
// Adoption is the SAME argv because agy has no fork — the id in the db
|
|
962
|
-
// is the identity (a renamed copy fails "trajectory not found",
|
|
963
|
-
// measured), so adopting MOVES the conversation: the tab continues the
|
|
964
|
-
// terminal session itself, appending to its one store. `--continue` is
|
|
965
|
-
// the cwd-keyed fallback for a session whose id was never learned.
|
|
966
|
-
if (adoptResumeId || resumeConversationId) a.push('--conversation', adoptResumeId || resumeConversationId);
|
|
967
|
-
else if (resume) a.push('--continue');
|
|
968
|
-
// No system-prompt flag, same weakening as Codex: the contract rides in
|
|
969
|
-
// the prompt, fenced and first.
|
|
970
|
-
a.push('-p', `${system}\n\n---\n\n${prompt}`);
|
|
971
|
-
a.push('--output-format', 'stream-json');
|
|
972
|
-
if (model) a.push('--model', model);
|
|
973
|
-
if (effort) a.push('--effort', effort);
|
|
974
|
-
a.push(...resultSchemaArgs);
|
|
975
|
-
|
|
976
|
-
if (profile === 'wiki') {
|
|
977
|
-
// THE CARTOGRAPHER, at the same bar WIKI_PERM sets for Claude — writes
|
|
978
|
-
// allowed, network shut. Both halves measured on 1.1.12:
|
|
979
|
-
// • headless cannot prompt, so anything needing approval is
|
|
980
|
-
// auto-denied — including the file writes a wiki turn exists to
|
|
981
|
-
// make. `--dangerously-skip-permissions` is what grants them.
|
|
982
|
-
// • `--sandbox` blocks egress: with it, curl returned 400 and a local
|
|
983
|
-
// listener saw nothing; without it, 200 and the request arrived.
|
|
984
|
-
// Upstream #36 warns these two cancel out (skip-permissions
|
|
985
|
-
// auto-approving the sandbox-bypass prompt). It does NOT reproduce on
|
|
986
|
-
// 1.1.12 — tested together, the write landed and the network stayed
|
|
987
|
-
// shut. Re-check on upgrade: if it ever does cancel, this posture
|
|
988
|
-
// silently becomes "wiki turn with internet", which is the one line
|
|
989
|
-
// WIKI_PERM draws ("Command execution is the line: it enables network
|
|
990
|
-
// exfil").
|
|
991
|
-
if (vaultDir) a.push('--add-dir', vaultDir);
|
|
992
|
-
a.push('--sandbox', '--dangerously-skip-permissions');
|
|
993
|
-
// A sweep reads a whole repository; the 5m default would guillotine it.
|
|
994
|
-
a.push('--print-timeout', '60m');
|
|
995
|
-
} else if (profile === 'build') {
|
|
996
|
-
// A BUILD WRITES, and headless auto-denies anything needing approval —
|
|
997
|
-
// so without this every edit comes back "User denied permission" and the
|
|
998
|
-
// turn reports failure having touched nothing. Caught only because an
|
|
999
|
-
// end-to-end test had to add the flag by hand to work.
|
|
1000
|
-
//
|
|
1001
|
-
// NOT `--sandbox` here, unlike wiki: a build has to `git push`, so it
|
|
1002
|
-
// needs the network by definition.
|
|
1003
|
-
//
|
|
1004
|
-
// AND NOTHING ELSE CONTAINS IT EITHER. This comment used to end "the
|
|
1005
|
-
// containment is the worktree, as it is for every runtime", which was
|
|
1006
|
-
// false and is the kind of false that stops people looking: `cwd` is a
|
|
1007
|
-
// starting directory, not a jail. A build turn runs with the operator's
|
|
1008
|
-
// full user permissions — it can write outside the worktree, to their
|
|
1009
|
-
// home directory, to their other checkouts. Claude gets
|
|
1010
|
-
// --dangerously-skip-permissions, codex gets --sandbox
|
|
1011
|
-
// danger-full-access, and agy gets no sandbox flag at all. The only
|
|
1012
|
-
// real boundary today is that the person driving the tab is trusted.
|
|
1013
|
-
//
|
|
1014
|
-
// FLOWVIANT_SAFE HAS NO EXPRESSION ON THIS RUNTIME. Claude narrows to an
|
|
1015
|
-
// allowlist and Codex to `workspace-write`; agy's only per-invocation
|
|
1016
|
-
// control is this boolean, and its allow/deny engine is machine-wide.
|
|
1017
|
-
// So a SAFE-mode operator gets an agy build that is not actually
|
|
1018
|
-
// narrowed — which is why `mediatedSafeGap` is surfaced rather than
|
|
1019
|
-
// quietly ignored.
|
|
1020
|
-
a.push('--dangerously-skip-permissions');
|
|
1021
|
-
a.push('--print-timeout', '60m');
|
|
1022
|
-
} else if (profile === 'consult') {
|
|
1023
|
-
// NOTHING GRANTED, deliberately. The headless default IS the consult
|
|
1024
|
-
// posture: `run_command` comes back "User denied permission… headless
|
|
1025
|
-
// mode cannot prompt for it, so it was auto-denied", observed even for a
|
|
1026
|
-
// benign `pwd && ls -la`, with no egress in any run. Reads keep working,
|
|
1027
|
-
// which is all a consult needs. Passing --dangerously-skip-permissions
|
|
1028
|
-
// here would hand a question ANY project editor can type a shell.
|
|
1029
|
-
a.push('--print-timeout', '10m');
|
|
1030
|
-
}
|
|
1031
|
-
return a;
|
|
1032
|
-
},
|
|
1033
|
-
parse: parseAgyLine,
|
|
1034
|
-
/**
|
|
1035
|
-
* `--json-schema` enforces the shape of the final result — verified against
|
|
1036
|
-
* 1.1.12, which returned exactly the object asked for. This is what makes a
|
|
1037
|
-
* BUILD possible on a runtime that cannot be handed an MCP config: the CLI
|
|
1038
|
-
* stops needing to CALL anything and just returns a filled-in form, and the
|
|
1039
|
-
* daemon makes the control-plane calls on its behalf with the lane's own
|
|
1040
|
-
* per-lane token. Strictly better than parsing markers out of prose, which a
|
|
1041
|
-
* model can wrap in a code fence, truncate or hallucinate.
|
|
1042
|
-
*/
|
|
1043
|
-
resultSchema: (path) => ['--json-schema', path],
|
|
1044
|
-
blocked:
|
|
1045
|
-
'its MCP config is machine-wide — a workspace .agents/mcp_config.json is never loaded (measured), so every lane would share one token',
|
|
1046
|
-
},
|
|
79
|
+
claude: CLAUDE_RUNTIME,
|
|
80
|
+
codex: CODEX_RUNTIME,
|
|
81
|
+
antigravity: ANTIGRAVITY_RUNTIME,
|
|
1047
82
|
};
|
|
1048
83
|
|
|
1049
84
|
// DELETED: `DISPATCHABLE`, a `mcp && args` filter last described as "runtimes
|
|
@@ -1054,7 +89,17 @@ export const RUNTIMES = {
|
|
|
1054
89
|
// constant would have silently undone this release. `canRun(rt, profile)` /
|
|
1055
90
|
// `drivableHere(rt)` below are the answers.
|
|
1056
91
|
|
|
1057
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The adapter for a runtime id, or NULL for an id this daemon does not declare.
|
|
94
|
+
*
|
|
95
|
+
* NEVER A SUBSTITUTE (2026-09-26, SOLID F048). This used to fall back to
|
|
96
|
+
* Claude, so a misspelled id — or one a newer server learned ahead of this
|
|
97
|
+
* daemon — minted Claude's MCP config or spawned Claude under another
|
|
98
|
+
* runtime's requested identity. The callers (`mcpFor`, `runTurn`) refuse a
|
|
99
|
+
* null in words, naming the id they were given. A caller that OMITS the
|
|
100
|
+
* runtime still gets Claude, from its own explicit default, never from here.
|
|
101
|
+
*/
|
|
102
|
+
export const runtimeById = (id) => (typeof id === 'string' && Object.hasOwn(RUNTIMES, id) ? RUNTIMES[id] : null);
|
|
1058
103
|
|
|
1059
104
|
/**
|
|
1060
105
|
* Can the worker this daemon is running actually DRIVE this runtime right now?
|
|
@@ -1089,7 +134,7 @@ export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
|
|
|
1089
134
|
* is the control plane, and a runtime that cannot reach it cannot participate.
|
|
1090
135
|
* A WIKI turn writes markdown the daemon syncs afterwards. A CONSULT answers a
|
|
1091
136
|
* question in prose. Neither passes an MCP config on ANY runtime today, Claude
|
|
1092
|
-
* included — check the two call sites in
|
|
137
|
+
* included — check the two call sites in wikiRunner.mjs, they hand `runTurn` no
|
|
1093
138
|
* `mcpArgs` at all.
|
|
1094
139
|
*
|
|
1095
140
|
* A PLAN is the second one, and it is the reason the consult stopped being the
|
|
@@ -1143,408 +188,3 @@ export const canRun = (rt, profile) =>
|
|
|
1143
188
|
* which is why a runtime can be undispatchable and still run wiki and consult.
|
|
1144
189
|
*/
|
|
1145
190
|
export const drivableHere = (rt) => canRun(rt, 'build');
|
|
1146
|
-
|
|
1147
|
-
/**
|
|
1148
|
-
* WHICH RUNTIME RUNS A JOB THAT NOBODY @MENTIONED.
|
|
1149
|
-
*
|
|
1150
|
-
* Building a task has an author: you @mentioned a CLI, and that is the only
|
|
1151
|
-
* dispatch in this product. The other turns have none — the wiki sweep, the
|
|
1152
|
-
* re-ground, the plan check, the quick edit and the consult are all started by
|
|
1153
|
-
* the daemon or the server, and until now every one of them took `runTurn`'s
|
|
1154
|
-
* default parameter value and ran Claude. That was not a decision; it was five
|
|
1155
|
-
* call sites omitting an argument, and it only looked correct while Claude was
|
|
1156
|
-
* the only runtime and preflight refused to start without it.
|
|
1157
|
-
*
|
|
1158
|
-
* A PROFILE is a promise about what is IMPOSSIBLE during the turn, not a flag
|
|
1159
|
-
* list — flag lists are per-vendor, promises are not, and the goal is that every
|
|
1160
|
-
* runtime behaves the same way predictably. A runtime declares the profiles it
|
|
1161
|
-
* can actually express; one that cannot express a profile does not get that job,
|
|
1162
|
-
* rather than getting it with weaker guarantees nobody wrote down.
|
|
1163
|
-
*
|
|
1164
|
-
* Claude first when it qualifies — not favouritism, and worth saying plainly:
|
|
1165
|
-
* these prompts were written and tuned against it, so it is the known-good
|
|
1166
|
-
* answer and anything else is a substitution. When it is absent, any runtime
|
|
1167
|
-
* that can express the profile runs the job, which is the whole point.
|
|
1168
|
-
*/
|
|
1169
|
-
export function pickRuntimeFor(profile, { detected } = {}) {
|
|
1170
|
-
const rows = detected ?? detectRuntimes();
|
|
1171
|
-
const ok = (id) =>
|
|
1172
|
-
canRun(RUNTIMES[id], profile) && Boolean(rows.find((d) => d.id === id)?.installed);
|
|
1173
|
-
if (ok('claude')) return 'claude';
|
|
1174
|
-
return Object.keys(RUNTIMES).find(ok) ?? null;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// ── Detection ──────────────────────────────────────────────────────────────
|
|
1178
|
-
|
|
1179
|
-
/**
|
|
1180
|
-
* Which of these is on this machine, asked at most once per DETECT_TTL_MS.
|
|
1181
|
-
*
|
|
1182
|
-
* `--version` rather than `which`: a binary on PATH that cannot execute (a
|
|
1183
|
-
* broken install, a wrong-arch download, a shell alias pointing at nothing) is
|
|
1184
|
-
* not a runtime you can dispatch to, and reporting it as one sends work into a
|
|
1185
|
-
* hole. 5s is generous for a version print and short enough that three missing
|
|
1186
|
-
* CLIs cannot stall a roster poll.
|
|
1187
|
-
*
|
|
1188
|
-
* Reported to the server on the roster poll so the app can stop saying "we have
|
|
1189
|
-
* not looked". It is a statement about THIS MACHINE and nothing else — no
|
|
1190
|
-
* account, no quota, no entitlement. Flowviant relays; it does not enforce.
|
|
1191
|
-
*/
|
|
1192
|
-
let detectedCache = null;
|
|
1193
|
-
let detectedAt = 0;
|
|
1194
|
-
// The cache EXPIRES rather than living for the process: pickRuntimeFor's whole
|
|
1195
|
-
// premise is "a CLI can be installed while the daemon runs", and both it and
|
|
1196
|
-
// the roster poll read this cache — a forever-cache made that comment a lie
|
|
1197
|
-
// (nothing after preflight ever passed refresh, so a mid-run install was
|
|
1198
|
-
// invisible until restart, to the server included). 5 minutes keeps the probes
|
|
1199
|
-
// (3 sync --version execs) off the hot path while an install still surfaces on
|
|
1200
|
-
// the next poll or job.
|
|
1201
|
-
const DETECT_TTL_MS = 5 * 60 * 1000;
|
|
1202
|
-
export function detectRuntimes({ refresh = false } = {}) {
|
|
1203
|
-
if (detectedCache && !refresh && Date.now() - detectedAt < DETECT_TTL_MS) return detectedCache;
|
|
1204
|
-
detectedAt = Date.now();
|
|
1205
|
-
detectedCache = Object.values(RUNTIMES).map((rt) => {
|
|
1206
|
-
let version = null;
|
|
1207
|
-
try {
|
|
1208
|
-
version = String(
|
|
1209
|
-
execFileSync(rt.bin, ['--version'], {
|
|
1210
|
-
timeout: 5000,
|
|
1211
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1212
|
-
})
|
|
1213
|
-
)
|
|
1214
|
-
.trim()
|
|
1215
|
-
.split('\n')[0]
|
|
1216
|
-
.slice(0, 40);
|
|
1217
|
-
} catch {
|
|
1218
|
-
version = null;
|
|
1219
|
-
}
|
|
1220
|
-
return {
|
|
1221
|
-
id: rt.id,
|
|
1222
|
-
installed: version !== null,
|
|
1223
|
-
version,
|
|
1224
|
-
// Installed and drivable are different questions, and conflating them is
|
|
1225
|
-
// how a user ends up @mentioning something that silently never starts.
|
|
1226
|
-
// THREE questions, in fact — see `drivableHere`: the CLI can be installed,
|
|
1227
|
-
// and this module can know how to spawn it, and the worker this daemon is
|
|
1228
|
-
// running can still be unable to drive it.
|
|
1229
|
-
dispatchable: version !== null && drivableHere(rt),
|
|
1230
|
-
blocked: rt.blocked ?? null,
|
|
1231
|
-
};
|
|
1232
|
-
});
|
|
1233
|
-
return detectedCache;
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
/**
|
|
1237
|
-
* WHAT THE CLI SAID IT CAN BE ASKED FOR BY NAME — the machine's skills.
|
|
1238
|
-
*
|
|
1239
|
-
* Learned, never scanned. Claude Code's `system.init` event names its own
|
|
1240
|
-
* resolved skill set on every stream-json turn, and the daemon already parses
|
|
1241
|
-
* that stream (claude.mjs), so this costs nothing and is authoritative: it has
|
|
1242
|
-
* plugins, this repo's `.claude/skills`, and whatever project settings enabled
|
|
1243
|
-
* or disabled already folded in. A `~/.claude/skills` scan of our own would be
|
|
1244
|
-
* a second implementation of the CLI's resolution rules, and would drift.
|
|
1245
|
-
*
|
|
1246
|
-
* THE PRICE OF LEARNING RATHER THAN PROBING is that a machine which has not run
|
|
1247
|
-
* a turn yet knows nothing, and says nothing. That is the honest answer: the
|
|
1248
|
-
* app renders no menu rather than an empty one, and a slash typed into a tab
|
|
1249
|
-
* still reaches the CLI either way — the menu is an autocomplete, never a gate.
|
|
1250
|
-
* We do NOT probe for it: a `claude -p` run purely to populate a dropdown would
|
|
1251
|
-
* spend the operator's quota on a UI affordance.
|
|
1252
|
-
*
|
|
1253
|
-
* PER MACHINE, not per session. Every session worktree is a checkout of the one
|
|
1254
|
-
* repo this daemon serves, so project skills are identical across tabs and
|
|
1255
|
-
* personal skills are machine-wide. Last turn wins, which is what makes a skill
|
|
1256
|
-
* added mid-run show up on the next poll.
|
|
1257
|
-
*/
|
|
1258
|
-
let skillsCache = null;
|
|
1259
|
-
|
|
1260
|
-
/** Claude Code's own names: letters, digits, dash, underscore, and the colon a
|
|
1261
|
-
* plugin skill wears (`plugin:skill`). Anything else is not a name we could
|
|
1262
|
-
* put after a `/` anyway, so it is dropped rather than relayed as garbage. */
|
|
1263
|
-
const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9_:-]{0,63}$/;
|
|
1264
|
-
|
|
1265
|
-
/** Record what a turn's init event reported. Bounded and sorted so the poll's
|
|
1266
|
-
* query param has a stable length and a stable order — an unstable order would
|
|
1267
|
-
* make the server write a "change" on every single poll. */
|
|
1268
|
-
export function recordSkills(names) {
|
|
1269
|
-
if (!Array.isArray(names)) return;
|
|
1270
|
-
const clean = [...new Set(names.map((n) => String(n).trim()).filter((n) => SKILL_NAME.test(n)))]
|
|
1271
|
-
.sort()
|
|
1272
|
-
.slice(0, 100);
|
|
1273
|
-
// An empty report is a FACT (a machine with no skills installed), so it is
|
|
1274
|
-
// recorded as []. Never conflated with null, which stays "no turn has run".
|
|
1275
|
-
skillsCache = clean;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
/** What to send on the roster poll — null until a turn has taught us. */
|
|
1279
|
-
export function knownSkills() {
|
|
1280
|
-
return skillsCache;
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
/**
|
|
1284
|
-
* WHICH MCP SERVERS AND CLAUDE.AI CONNECTORS THE CLI MOUNTED, AND HOW EACH
|
|
1285
|
-
* STANDS (2026-09-23, 0.97.0) — learned off the same `system.init` event the
|
|
1286
|
-
* skills are, so it costs nothing and cannot drift from what a turn reaches.
|
|
1287
|
-
*
|
|
1288
|
-
* MEASURED on Claude Code 2.1.281 (`claude -p hi --model haiku --output-format
|
|
1289
|
-
* stream-json --verbose`): the init event carries `mcp_servers: [{ name,
|
|
1290
|
-
* status, source }]` — statuses seen `connected`, `failed`, `needs-auth` and
|
|
1291
|
-
* `pending`; a claude.ai connector arrives named `claude.ai <Name>` with
|
|
1292
|
-
* `source: "claudeai"`, a server a turn mounts with `--mcp-config` with
|
|
1293
|
-
* `source: "dynamic"`. A connector that needs a one-time sign-in AT THIS BOX
|
|
1294
|
-
* reads `needs-auth` here long before a turn fails on it, and the app's
|
|
1295
|
-
* Machines page names it.
|
|
1296
|
-
*
|
|
1297
|
-
* `flowviant` IS EXCLUDED: it is the server THIS daemon mounts on every tab
|
|
1298
|
-
* turn, always present, and not something a person can act on. A status off
|
|
1299
|
-
* the closed list is relayed as `other` rather than dropped — the CLI said
|
|
1300
|
-
* something about the server, and silence would be the daemon deciding it did
|
|
1301
|
-
* not. Bounded (40 entries, names cut at 60) and sorted, the `recordSkills`
|
|
1302
|
-
* discipline: a stable param, so an unchanged report writes nothing.
|
|
1303
|
-
*
|
|
1304
|
-
* ONLY WHAT IS NOT CONNECTED LEAVES THE BOX (2026-09-23). The first cut
|
|
1305
|
-
* relayed every server, connected ones included — so the operator's whole set
|
|
1306
|
-
* of signed-in services (their mail, their brokerage, their calendar) rode the
|
|
1307
|
-
* poll to the server and into every teammate's read of the Machines page,
|
|
1308
|
-
* which renders only the servers that are NOT connected anyway. A connected
|
|
1309
|
-
* connector is good news and good news is not a line; it is also nobody
|
|
1310
|
-
* else's business. So `connected` is dropped HERE, before the cap, and what is
|
|
1311
|
-
* sent is exactly what the page can say something about.
|
|
1312
|
-
*
|
|
1313
|
-
* THREE STATES, the skills split: null = no turn has taught us (the param is
|
|
1314
|
-
* not sent), [] = nothing of the person's own needs anything (every server is
|
|
1315
|
-
* connected, or none is mounted), a list = the ones that do. Last turn wins,
|
|
1316
|
-
* so a connector somebody signs into leaves the list on the next turn's init,
|
|
1317
|
-
* and an all-connected turn sends `[]`, which clears a stale list server-side.
|
|
1318
|
-
*/
|
|
1319
|
-
let mcpServersCache = null;
|
|
1320
|
-
|
|
1321
|
-
export const MCP_STATUSES = new Set(['connected', 'failed', 'needs-auth', 'pending']);
|
|
1322
|
-
export const MAX_MCP_SERVERS = 40;
|
|
1323
|
-
export const MCP_NAME_MAX = 60;
|
|
1324
|
-
|
|
1325
|
-
export function recordMcpServers(list) {
|
|
1326
|
-
if (!Array.isArray(list)) return;
|
|
1327
|
-
const seen = new Map();
|
|
1328
|
-
for (const e of list) {
|
|
1329
|
-
if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
|
|
1330
|
-
const n = e.name.trim().slice(0, MCP_NAME_MAX).trim();
|
|
1331
|
-
if (!n || n === 'flowviant' || seen.has(n)) continue;
|
|
1332
|
-
if (e.status === 'connected') continue; // see the header: never relayed
|
|
1333
|
-
seen.set(n, { n, s: MCP_STATUSES.has(e.status) ? e.status : 'other' });
|
|
1334
|
-
}
|
|
1335
|
-
mcpServersCache = [...seen.values()]
|
|
1336
|
-
.sort((a, b) => (a.n < b.n ? -1 : a.n > b.n ? 1 : 0))
|
|
1337
|
-
.slice(0, MAX_MCP_SERVERS);
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
/** What to send on the roster poll as `mcp` — null until a turn has taught us. */
|
|
1341
|
-
export function knownMcpServers() {
|
|
1342
|
-
return mcpServersCache;
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
/**
|
|
1346
|
-
* LEARN WHAT `/` CAN OFFER, ON A MACHINE NO TURN HAS TAUGHT.
|
|
1347
|
-
*
|
|
1348
|
-
* WHY THIS EXISTS. `recordSkills` above is fed from the init event of a tab
|
|
1349
|
-
* turn — authoritative and free, but with a hole nobody priced: THE FIRST THING
|
|
1350
|
-
* ANYONE DOES IN A NEW TAB IS TYPE `/`, and that is by definition before that
|
|
1351
|
-
* machine has run a turn. The menu was guaranteed empty exactly where it is
|
|
1352
|
-
* first reached. Not a theoretical hole: checked against production on
|
|
1353
|
-
* 2026-08-25, `agent_tokens.skills` was NULL for EVERY machine credential that
|
|
1354
|
-
* has ever existed, because the only tab turns ever run predated the release
|
|
1355
|
-
* that reports. The feature had never worked for anyone, once.
|
|
1356
|
-
*
|
|
1357
|
-
* IT COSTS ONE SMALL REQUEST, AND THAT IS THE HONEST NUMBER. This file used to
|
|
1358
|
-
* forbid probing outright — "a `claude -p` run purely to populate a dropdown
|
|
1359
|
-
* would spend the operator's quota on an affordance" — and that rule was
|
|
1360
|
-
* written picturing a COMPLETED TURN. This is not one: Claude Code emits
|
|
1361
|
-
* `system.init`, carrying its own fully resolved skill set, within ~0.5s and
|
|
1362
|
-
* long before it finishes answering, so the child is killed the moment that
|
|
1363
|
-
* event is read. But the request HAS gone out by then — measured on 2.1.245 by
|
|
1364
|
-
* reading the transcript a killed probe left behind: 2 input tokens, 4 output,
|
|
1365
|
-
* ~6k cache-creation. Two zero-request routes were tried and both failed: an
|
|
1366
|
-
* empty prompt errors before init is emitted, and `--input-format stream-json`
|
|
1367
|
-
* emits nothing at all until a message arrives. So the cost is one cheap turn,
|
|
1368
|
-
* ONCE per daemon process, on the cheapest model, and only on a machine no turn
|
|
1369
|
-
* has taught. Do not let this grow into a per-tab or per-poll probe.
|
|
1370
|
-
*
|
|
1371
|
-
* `--model haiku` for that reason, and it is safe by this repo's own rule: a
|
|
1372
|
-
* name lives in AGENT_MODELS only once `claude --model <name>` is known to be
|
|
1373
|
-
* accepted on a real install. If it were ever refused the probe simply learns
|
|
1374
|
-
* nothing and the machine stays unmeasured — which is exactly today's state, so
|
|
1375
|
-
* the failure mode is the status quo rather than a regression.
|
|
1376
|
-
*
|
|
1377
|
-
* IT SCANS FOR THE INIT EVENT, never assuming it is line 1 — and that is not
|
|
1378
|
-
* defensive padding, it is measured. With `--model haiku` the CLI prints a
|
|
1379
|
-
* `system/status` line FIRST and init lands on line 2; with the default model
|
|
1380
|
-
* init is line 1. A first-line-only reader (the version this replaced) silently
|
|
1381
|
-
* learned nothing the moment a model flag was added.
|
|
1382
|
-
*
|
|
1383
|
-
* IT CLEANS UP AFTER ITSELF, and this is not optional. `claude -p` writes a
|
|
1384
|
-
* transcript to `~/.claude/projects/<munged-cwd>/<session-id>.jsonl` the moment
|
|
1385
|
-
* it starts, and `localSessions.mjs` reports the newest ENDED session per
|
|
1386
|
-
* directory to the Workbench as an ADOPTABLE row. Left behind, every daemon
|
|
1387
|
-
* start would put a phantom untitled session in the `+` menu offering to adopt
|
|
1388
|
-
* a conversation that never happened. The init event names its own session id,
|
|
1389
|
-
* so the file is ours by name and is removed by it.
|
|
1390
|
-
*
|
|
1391
|
-
* IT IS BEST-EFFORT IN EVERY DIRECTION. No claude, no PATH, a CLI that changed
|
|
1392
|
-
* its event shape, an unwritable home — all leave `skillsCache` exactly as it
|
|
1393
|
-
* was (null, "nobody looked"), which is the honest answer and the one the app
|
|
1394
|
-
* already renders correctly. Nothing here may throw, and nothing may block the
|
|
1395
|
-
* poll it is called from.
|
|
1396
|
-
*/
|
|
1397
|
-
|
|
1398
|
-
/** Long enough for a cold CLI start on a slow box, short enough that a hung
|
|
1399
|
-
* child is not left holding a session for the life of the daemon. */
|
|
1400
|
-
const SKILL_PROBE_TIMEOUT_MS = 30_000;
|
|
1401
|
-
|
|
1402
|
-
/** Init arrives within the first couple of events or not at all; this is the
|
|
1403
|
-
* guard against parsing a whole turn's output looking for it. */
|
|
1404
|
-
const SKILL_PROBE_MAX_LINES = 20;
|
|
1405
|
-
|
|
1406
|
-
let skillProbeStarted = false;
|
|
1407
|
-
|
|
1408
|
-
/** Where Claude Code keeps a transcript for `cwd`: `/` and `.` both become `-`. */
|
|
1409
|
-
function transcriptCandidates(cwd, sessionId) {
|
|
1410
|
-
const base = join(homedir(), '.claude', 'projects');
|
|
1411
|
-
const out = [join(base, cwd.replace(/[/.]/g, '-'), `${sessionId}.jsonl`)];
|
|
1412
|
-
// The munge is Claude Code's, not ours, so a version that changes it must not
|
|
1413
|
-
// leave the file behind: fall back to finding our own session id by name.
|
|
1414
|
-
try {
|
|
1415
|
-
for (const d of readdirSync(base)) out.push(join(base, d, `${sessionId}.jsonl`));
|
|
1416
|
-
} catch {
|
|
1417
|
-
/* no project store — nothing was written either */
|
|
1418
|
-
}
|
|
1419
|
-
return out;
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
/**
|
|
1423
|
-
* Delete the transcript a headless `-p` turn left behind.
|
|
1424
|
-
*
|
|
1425
|
-
* EXPORTED because the skills probe is no longer the only thing that runs one:
|
|
1426
|
-
* resolving a project's dev command is a background Claude turn too, and every
|
|
1427
|
-
* such turn has the same footprint. `claude -p` writes
|
|
1428
|
-
* `~/.claude/projects/<munged-cwd>/<id>.jsonl` at startup, and
|
|
1429
|
-
* `localSessions.mjs` offers the newest ENDED session per directory as
|
|
1430
|
-
* ADOPTABLE — so anything we run for our own purposes would put a phantom
|
|
1431
|
-
* untitled session in somebody's `+` menu.
|
|
1432
|
-
*/
|
|
1433
|
-
export function removeProbeTranscript(cwd, sessionId) {
|
|
1434
|
-
if (!sessionId || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) return;
|
|
1435
|
-
for (const f of transcriptCandidates(cwd, sessionId)) {
|
|
1436
|
-
try {
|
|
1437
|
-
if (existsSync(f)) {
|
|
1438
|
-
rmSync(f, { force: true });
|
|
1439
|
-
return;
|
|
1440
|
-
}
|
|
1441
|
-
} catch {
|
|
1442
|
-
/* best-effort */
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
/**
|
|
1448
|
-
* One line of the probe's stdout → the init event's payload, or null for "not
|
|
1449
|
-
* it, keep reading".
|
|
1450
|
-
*
|
|
1451
|
-
* SEPARATE FROM THE SCAN LOOP so the thing that actually broke can be tested
|
|
1452
|
-
* without spawning a CLI. The first version of this probe read line 1 and
|
|
1453
|
-
* stopped, which was measured-correct with the default model and silently
|
|
1454
|
-
* WRONG with `--model haiku`: that path prints a `system/status` line first and
|
|
1455
|
-
* puts init on line 2, so the probe learned nothing and reported nothing, which
|
|
1456
|
-
* is indistinguishable from the bug it was written to fix.
|
|
1457
|
-
*
|
|
1458
|
-
* A line that is not JSON is not a failure — a CLI warning on stdout is a line
|
|
1459
|
-
* to skip, not a reason to abandon the probe.
|
|
1460
|
-
*/
|
|
1461
|
-
export function parseInitLine(line) {
|
|
1462
|
-
let ev;
|
|
1463
|
-
try {
|
|
1464
|
-
ev = JSON.parse(line);
|
|
1465
|
-
} catch {
|
|
1466
|
-
return null;
|
|
1467
|
-
}
|
|
1468
|
-
if (!ev || ev.type !== 'system' || ev.subtype !== 'init') return null;
|
|
1469
|
-
return {
|
|
1470
|
-
// An init event WITHOUT skills is still the init event: stop reading, but
|
|
1471
|
-
// record nothing. Conflating the two would keep the probe scanning a whole
|
|
1472
|
-
// turn's output on a CLI that does not report them.
|
|
1473
|
-
skills: Array.isArray(ev.skills) ? ev.skills : null,
|
|
1474
|
-
// The CLI's mounted MCP servers (0.97.0) — `recordMcpServers` normalises.
|
|
1475
|
-
mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : null,
|
|
1476
|
-
sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
|
|
1477
|
-
};
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
/**
|
|
1481
|
-
* Kick off the one-shot probe. Returns immediately; the result lands in
|
|
1482
|
-
* `skillsCache` and rides the NEXT poll, so nothing waits on it.
|
|
1483
|
-
*
|
|
1484
|
-
* A no-op once a turn has taught us (`skillsCache !== null`) — a turn's init
|
|
1485
|
-
* event and this one say the same thing, and the turn is free.
|
|
1486
|
-
*/
|
|
1487
|
-
export function probeSkillsOnce(cwd) {
|
|
1488
|
-
if (skillProbeStarted || skillsCache !== null) return;
|
|
1489
|
-
skillProbeStarted = true;
|
|
1490
|
-
let child;
|
|
1491
|
-
try {
|
|
1492
|
-
// Claude Code specifically, not the `wiki` profile's runtime pick: `skills`
|
|
1493
|
-
// is Claude Code's own field and the `/` tray only renders for claude tabs.
|
|
1494
|
-
child = spawn(
|
|
1495
|
-
RUNTIMES.claude.bin,
|
|
1496
|
-
['-p', 'x', '--model', 'haiku', '--output-format', 'stream-json', '--verbose'],
|
|
1497
|
-
{ cwd, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
1498
|
-
);
|
|
1499
|
-
} catch {
|
|
1500
|
-
return; // no claude on PATH — the machine simply stays unmeasured
|
|
1501
|
-
}
|
|
1502
|
-
let buf = '';
|
|
1503
|
-
let lines = 0;
|
|
1504
|
-
let settled = false;
|
|
1505
|
-
const finish = (sessionId) => {
|
|
1506
|
-
if (settled) return;
|
|
1507
|
-
settled = true;
|
|
1508
|
-
clearTimeout(timer);
|
|
1509
|
-
try {
|
|
1510
|
-
child.kill('SIGKILL');
|
|
1511
|
-
} catch {
|
|
1512
|
-
/* already gone */
|
|
1513
|
-
}
|
|
1514
|
-
// AFTER the kill, and on a delay: the transcript is the CHILD's file, so
|
|
1515
|
-
// deleting it while the child still lives races a recreate.
|
|
1516
|
-
if (sessionId) setTimeout(() => removeProbeTranscript(cwd, sessionId), 750).unref?.();
|
|
1517
|
-
};
|
|
1518
|
-
const timer = setTimeout(() => finish(null), SKILL_PROBE_TIMEOUT_MS);
|
|
1519
|
-
timer.unref?.();
|
|
1520
|
-
child.on('error', () => finish(null));
|
|
1521
|
-
child.on('exit', () => finish(null));
|
|
1522
|
-
// UTF-8 mode on the STREAM, not a per-chunk `.toString()` — a multi-byte
|
|
1523
|
-
// character straddling a chunk boundary decodes to mojibake (or a stray
|
|
1524
|
-
// replacement byte) when each chunk is decoded on its own; the stream's
|
|
1525
|
-
// own decoder holds the partial sequence over to the next chunk instead.
|
|
1526
|
-
child.stdout.setEncoding('utf8');
|
|
1527
|
-
child.stdout.on('data', (d) => {
|
|
1528
|
-
if (settled) return;
|
|
1529
|
-
buf += d;
|
|
1530
|
-
let nl;
|
|
1531
|
-
while (!settled && (nl = buf.indexOf('\n')) >= 0) {
|
|
1532
|
-
const line = buf.slice(0, nl);
|
|
1533
|
-
buf = buf.slice(nl + 1);
|
|
1534
|
-
if (!line.trim()) continue;
|
|
1535
|
-
if (++lines > SKILL_PROBE_MAX_LINES) {
|
|
1536
|
-
finish(null);
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
1539
|
-
const init = parseInitLine(line);
|
|
1540
|
-
if (!init) continue;
|
|
1541
|
-
if (init.skills) recordSkills(init.skills);
|
|
1542
|
-
if (init.mcpServers) recordMcpServers(init.mcpServers);
|
|
1543
|
-
finish(init.sessionId);
|
|
1544
|
-
return;
|
|
1545
|
-
}
|
|
1546
|
-
// A single line this long is not an init event; stop buffering a whole
|
|
1547
|
-
// turn's output into memory waiting for one.
|
|
1548
|
-
if (!settled && buf.length > 1_000_000) finish(null);
|
|
1549
|
-
});
|
|
1550
|
-
}
|