flowviant 0.38.0 → 0.40.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.
@@ -21,6 +21,15 @@
21
21
  * Claude Code and Codex both do all four. Antigravity does 1, 3 and 4 and is
22
22
  * declared below with the exact reason it cannot yet do 2.
23
23
  *
24
+ * BUT ONLY A BUILD NEEDS ALL FOUR, and that qualifier was missing long enough to
25
+ * cost Antigravity every capability it has. Requirement 2 is the CONTROL PLANE —
26
+ * claiming, blockers, PRs, completing — and only a build uses it. A wiki turn
27
+ * writes markdown the daemon syncs afterwards; a consult answers a question in
28
+ * prose. Both already run with no MCP on every runtime, Claude included. So
29
+ * drivability is per PROFILE (`canRun`), not one verdict per runtime, and a CLI
30
+ * that cannot be handed a per-invocation MCP config is still a perfectly good
31
+ * cartographer.
32
+ *
24
33
  * WHAT THIS MODULE DELIBERATELY DOES NOT DO: pick. Which runtime runs a task is
25
34
  * decided in the app, by @mentioning it (CLAUDE.md: the @mention is the only
26
35
  * dispatch), and arrives on the brief. Detection here answers "what does this
@@ -142,11 +151,90 @@ function parseCodexLine(line, cwd) {
142
151
  activity: { kind: 'error', label: oneLine(ev.error?.message ?? 'turn failed') },
143
152
  text: '',
144
153
  };
154
+ // A bare `error` event — the shape an auth failure arrives in ("401
155
+ // Unauthorized: Missing bearer…", observed against 0.147.0 with no
156
+ // credentials). It used to fall through to `default` and be dropped, which
157
+ // meant a signed-out Codex produced an EMPTY turn: no sentinel, so the
158
+ // driver nudged twice and reported `stalled`, and the thread said the agent
159
+ // gave up rather than that the CLI is not signed in. The message goes into
160
+ // `text` so it reaches the operator's console AND the usage-limit
161
+ // classifier, which reads exactly this stream.
162
+ case 'error':
163
+ return {
164
+ activity: { kind: 'error', label: oneLine(ev.message ?? 'error') },
165
+ text: `${ev.message ?? ''}\n`,
166
+ };
145
167
  default:
146
168
  return null; // thread.started / turn.started / item.started / item.updated
147
169
  }
148
170
  }
149
171
 
172
+ // ── Antigravity ────────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * `agy --output-format stream-json` emits `{event: init|step_update|result}`.
176
+ * Tool names read off a live 1.1.12 session rather than guessed.
177
+ */
178
+ function humanizeAgyTool(name, p = {}, cwd = '') {
179
+ const path = p.TargetFile ?? p.AbsolutePath ?? p.DirectoryPath ?? p.File ?? '';
180
+ const tail = String(path).split('/').slice(-2).join('/');
181
+ switch (name) {
182
+ case 'view_file':
183
+ case 'read_resource':
184
+ return { kind: 'read', label: `read ${shortPath(path, cwd)}` };
185
+ case 'write_to_file':
186
+ case 'replace_file_content':
187
+ case 'multi_replace_file_content':
188
+ return {
189
+ kind: 'write',
190
+ path: String(path),
191
+ label: `${name === 'write_to_file' ? '+ page' : '~ page'} ${tail || 'file'}`,
192
+ };
193
+ case 'grep_search':
194
+ return { kind: 'search', label: `grep ${oneLine(p.Query ?? p.SearchTerm ?? '', 60)}` };
195
+ case 'find_by_name':
196
+ return { kind: 'glob', label: `find ${oneLine(p.Pattern ?? '', 60)}` };
197
+ case 'list_dir':
198
+ return { kind: 'list', label: `ls ${shortPath(path, cwd)}` };
199
+ case 'run_command':
200
+ return { kind: 'bash', label: `$ ${oneLine(p.CommandLine, 60)}` };
201
+ case 'call_mcp_tool':
202
+ return { kind: 'tool', label: `mcp.${p.ToolName ?? ''}` };
203
+ default:
204
+ return null;
205
+ }
206
+ }
207
+
208
+ function parseAgyLine(line, cwd) {
209
+ let ev;
210
+ try {
211
+ ev = JSON.parse(line);
212
+ } catch {
213
+ return null;
214
+ }
215
+ if (ev.event === 'step_update') {
216
+ const su = ev.step_update ?? {};
217
+ const ti = su.tool_info;
218
+ if (!ti) return null;
219
+ const err = ti.error?.message;
220
+ if (err) return { activity: { kind: 'error', label: oneLine(err) }, text: '' };
221
+ // Each tool is reported twice — once ACTIVE, once DONE — so only the
222
+ // terminal state emits, otherwise every action appears in the thread twice.
223
+ if (su.state && su.state !== 'DONE') return null;
224
+ return { activity: humanizeAgyTool(ti.name, ti.parameters ?? {}, cwd), text: '' };
225
+ }
226
+ if (ev.event === 'result') {
227
+ const r = ev.result ?? {};
228
+ // The final answer is the ONLY sentinel-bearing text: agy has no incremental
229
+ // assistant-message event, so a turn's whole verdict arrives here at once.
230
+ return {
231
+ activity: r.error ? { kind: 'error', label: oneLine(r.error) } : null,
232
+ text: `${r.response ?? ''}${r.error ? `\n${r.error}` : ''}\n`,
233
+ };
234
+ }
235
+ return null;
236
+ }
237
+
150
238
  // ── The registry ───────────────────────────────────────────────────────────
151
239
 
152
240
  /**
@@ -209,17 +297,24 @@ export const RUNTIMES = {
209
297
  * only — it is an Anthropic SDK, not a CLI contract. Everything else runs
210
298
  * the subprocess path. */
211
299
  live: true,
300
+ /**
301
+ * Every profile, because every profile is DEFINED in its vocabulary: the
302
+ * three `--allowedTools` lists in claude.mjs are what "build", "wiki" and
303
+ * "consult" currently mean. That is a statement about where the contract was
304
+ * written, not a claim that only Claude could ever satisfy it.
305
+ */
306
+ profiles: ['build', 'wiki', 'consult'],
212
307
  mcp: claudeMcp,
213
308
  /**
214
309
  * Claude takes the operating contract as a real system prompt, which is the
215
310
  * strongest form of it available anywhere: `--append-system-prompt` sits
216
311
  * above the conversation rather than inside it.
217
312
  */
218
- args({ prompt, system, model, effort, resume, streamJson, perm, mcp = [] }) {
313
+ args({ prompt, system, model, effort, resume, streamJson, perm, mcp = [], resultSchemaArgs = [] }) {
219
314
  const a = [];
220
315
  if (resume) a.push('--continue');
221
316
  a.push('-p', prompt, '--append-system-prompt', system);
222
- a.push(...mcp);
317
+ a.push(...mcp, ...resultSchemaArgs);
223
318
  a.push('--model', model || MODEL);
224
319
  if (effort) a.push('--effort', effort);
225
320
  if (streamJson) a.push('--output-format', 'stream-json', '--verbose');
@@ -237,6 +332,24 @@ export const RUNTIMES = {
237
332
  install: 'npm i -g @openai/codex',
238
333
  login: 'codex login',
239
334
  live: false,
335
+ /**
336
+ * BUILD AND CONSULT. See `args()` below for how consult is expressed — the
337
+ * short version is that Codex keeps the promise at the kernel rather than in
338
+ * a verb allowlist, and for a consult's actual threat (exfiltration driven
339
+ * by an injected question) that is sufficient and arguably stronger.
340
+ *
341
+ * ALL THREE, and `wiki` is the one worth pausing on: Codex expresses it
342
+ * MORE strictly than Claude does. A cartographer must read the repo and
343
+ * write only the vault; Claude's WIKI_PERM cannot path-scope Write and says
344
+ * so, leaning on the worktree reset as a backstop. Codex's permission
345
+ * profiles enforce the same rule in the kernel — measured: repo readable,
346
+ * vault writable, repo writes refused, network off.
347
+ *
348
+ * Enforcement was verified on Linux (bubblewrap + seccomp). macOS Seatbelt
349
+ * and Windows are UNTESTED; if this daemon starts running there, re-verify
350
+ * before trusting the consult posture on those platforms.
351
+ */
352
+ profiles: ['build', 'consult', 'wiki'],
240
353
  mcp: codexMcp,
241
354
  /**
242
355
  * Codex has NO system-prompt flag. The contract therefore rides inside the
@@ -258,43 +371,182 @@ export const RUNTIMES = {
258
371
  * placed before it. Appending them after the positional is the kind of argv
259
372
  * that parses today and stops parsing on some future clap upgrade.
260
373
  */
261
- args({ prompt, system, model, effort, resume, perm: _perm, mcp = [] }) {
374
+ args({ prompt, system, model, effort, resume, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [] }) {
262
375
  const a = ['exec'];
263
376
  if (resume) a.push('resume', '--last');
264
377
  a.push('--json');
265
378
  if (model) a.push('--model', model);
266
379
  // Effort is a config value on Codex rather than a flag.
267
380
  if (effort) a.push('-c', `model_reasoning_effort="${effort}"`);
268
- // The daemon's posture, mapped: SAFE keeps writes inside the workspace,
269
- // the default lets the agent run its own tests and git commands. Neither
270
- // asks a human there is no human on this end of the pipe.
271
- a.push('--sandbox', SAFE ? 'workspace-write' : 'danger-full-access');
272
- a.push(...mcp);
381
+
382
+ if (profile === 'consult') {
383
+ // A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT and it is a
384
+ // different shape from Claude's, which is the whole reason `profile` is
385
+ // a promise rather than a flag list.
386
+ //
387
+ // Claude gets a VERB allowlist: Read, Grep, Glob and a handful of
388
+ // read-only Bash forms, with nothing that reaches the network. Codex has
389
+ // no such thing — it has no file-read tool at all, so reading the repo
390
+ // IS command execution (`cat`, `rg`). Removing the shell leaves the
391
+ // model with exactly ["update_plan","request_user_input"], which cannot
392
+ // answer a question about a codebase. You get both capabilities or
393
+ // neither.
394
+ //
395
+ // So the promise is kept one layer down instead. `read-only` is
396
+ // kernel-enforced (bubblewrap + seccomp on Linux): writes fail, and a
397
+ // direct-IP connect fails with "Operation not permitted" — socket() is
398
+ // denied, not merely DNS. An injected command still RUNS and still
399
+ // cannot take the repository anywhere, which is the threat a consult
400
+ // actually has: its prompt is steered by a question any project editor
401
+ // can type. Arguably a stronger guarantee than the allowlist, being
402
+ // below the agent rather than inside it.
403
+ a.push('--sandbox', 'read-only');
404
+
405
+ // THE HOLE THE SANDBOX DOES NOT COVER. `web_search` ships in `codex
406
+ // exec`'s default tool list even without --search, and it executes
407
+ // SERVER-SIDE at OpenAI — no local sandbox touches it. An injected turn
408
+ // could pack repo contents into a query and egress them straight past
409
+ // everything above. Both spellings, because the two config systems
410
+ // disagree about which one is live.
411
+ a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
412
+
413
+ // Sub-agents would be a second turn whose posture nobody here chose.
414
+ a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false');
415
+
416
+ // HERMETIC. Without these a user's ~/.codex/config.toml, a project
417
+ // `.rules` execpolicy file, or their own MCP servers can widen a posture
418
+ // we are asserting on their behalf — silently, and on the one turn whose
419
+ // prompt comes from someone else's typing.
420
+ a.push('--ignore-user-config', '--ignore-rules');
421
+ } else if (profile === 'wiki' && vaultDir) {
422
+ // THE CARTOGRAPHER, AND THIS ONE IS STRICTER THAN CLAUDE'S.
423
+ //
424
+ // A wiki turn reads the whole repo and writes ONLY the vault. Claude
425
+ // cannot actually express that: WIKI_PERM hands it Write/Edit and its own
426
+ // comment admits "Write/Edit can't be path-scoped here; the worktree
427
+ // reset is the backstop" — i.e. the cartographer CAN scribble on the
428
+ // checkout and we clean up afterwards.
429
+ //
430
+ // Codex's permission profiles take a per-path filesystem map, so the
431
+ // rule is enforced by the kernel instead of apologised for. Measured:
432
+ // repo readable, vault writable, repo writes fail "Read-only file
433
+ // system", and curl returns 000 — the network is off, which is the half
434
+ // WIKI_PERM was really protecting (its comment: "Command execution is
435
+ // the line: it enables network exfil").
436
+ //
437
+ // The table is set WHOLE rather than as a dotted key, and that is not
438
+ // style: `-c permissions.wiki.filesystem."<path>"="write"` splits the
439
+ // dotted path on the dots INSIDE the path, and every real vault lives
440
+ // under `~/.flowviant/vaults/…`. It fails with "filesystem path must be
441
+ // absolute", which reads like a path problem and is a parsing one.
442
+ a.push('-c', 'permissions.flowviantwiki.extends=":read-only"');
443
+ a.push('-c', `permissions.flowviantwiki.filesystem={"${vaultDir}"="write"}`);
444
+ a.push('-P', 'flowviantwiki');
445
+ a.push('--ignore-user-config', '--ignore-rules');
446
+ } else {
447
+ // The daemon's build posture, mapped: SAFE keeps writes inside the
448
+ // workspace, the default lets the agent run its own tests and git
449
+ // commands. Neither asks a human — there is no human on this end of the
450
+ // pipe. Deliberately NOT hermetic: a build is work the user asked for by
451
+ // @mentioning this CLI, and their own config is theirs to apply.
452
+ a.push('--sandbox', SAFE ? 'workspace-write' : 'danger-full-access');
453
+ }
454
+
455
+ a.push(...mcp, ...resultSchemaArgs);
273
456
  a.push(`${system}\n\n---\n\n${prompt}`);
274
457
  return a;
275
458
  },
276
459
  parse: parseCodexLine,
460
+ /** `codex exec --output-schema <file>` constrains the FINAL message to a
461
+ * JSON Schema. Only needed on the mediated path; the direct path reports
462
+ * through MCP tool calls, which are already structured. */
463
+ resultSchema: (path) => ['--output-schema', path],
277
464
  },
278
465
 
279
466
  /**
280
- * DECLARED, NOT DRIVABLE — and the reason is specific, not a shrug.
467
+ * DECLARED, NOT DRIVABLE — and this is now MEASURED rather than argued.
468
+ *
469
+ * The reason went wrong twice before it went right, so the evidence is written
470
+ * down here in full. First it cited upstream antigravity-cli#60, which is about
471
+ * `.antigravitycli/mcp_config.json` — the project-DISCOVERY folder — while
472
+ * claiming it was about `.agents/`, the workspace-CUSTOMIZATION folder. Then,
473
+ * on reading the docs (antigravity.google/docs/mcp describes `.agents/`, and
474
+ * Google's own codelab creates it), this comment swung the other way and said
475
+ * the blocker looked wrong. Both were reasoning from paperwork.
476
+ *
477
+ * THE TEST, run against agy 1.1.12, signed in, in a real git repo:
478
+ * the SAME minimal stdio MCP server, declared two ways.
479
+ * • workspace `<worktree>/.agents/mcp_config.json` → the server process is
480
+ * NEVER SPAWNED. Not connected-and-failed: never launched. The model
481
+ * answers "NO_MCP".
482
+ * • global `~/.gemini/config/mcp_config.json` → spawns immediately and
483
+ * handshakes: server/discover, initialize, notifications/initialized,
484
+ * tools/list.
485
+ * stdio deliberately, to remove every confound — no bearer token to reject,
486
+ * no network, no TLS. The difference is the config LOCATION and nothing else.
487
+ *
488
+ * AND IT SURVIVES THE TRUST VARIABLE, which was the obvious objection: agy
489
+ * keeps a `trustedWorkspaces` list in settings.json, a fresh per-task worktree
490
+ * is not on it, and an untrusted folder demonstrably changes behaviour (the
491
+ * model stops treating cwd as its workspace and works out of its own scratch
492
+ * dir). Adding the worktree to `trustedWorkspaces` and re-running changed
493
+ * nothing: the server still never spawned, the model still answered NO_MCP.
494
+ * So the workspace config is not trust-gated, it is simply not read.
495
+ *
496
+ * So Antigravity's MCP config is machine-wide IN PRACTICE, and the original
497
+ * blocker's conclusion stands even though its cited reason never did: every
498
+ * lane on the box would share one worker token, which is exactly the blast
499
+ * radius the per-lane token exists to prevent. That is why this stays
500
+ * undrivable, and it is a property of the CLI rather than something we can
501
+ * work around from here.
502
+ *
503
+ * A TRAP FOR WHOEVER RE-TESTS THIS: agy exposes MCP through a single generic
504
+ * `call_mcp_tool` dispatcher, so per-server tools NEVER appear in the `init`
505
+ * event's tools array even when a server is loaded correctly. Reading that
506
+ * array tells you nothing. Watch the server process instead, or ask the model.
507
+ *
508
+ * FOUR MORE FACTS from the same session, each an independent obstacle:
509
+ * 1. AUTH IS INTERACTIVE OAUTH (bubbletea TUI; needs a real /dev/tty), with
510
+ * no headless credential path. A signed-out `agy -p` prints a consent URL
511
+ * and then SITS until `--print-timeout` (default 5m) before erroring — so
512
+ * a lane on a signed-out agy burns five minutes per turn looking like a
513
+ * hang. Preflight cannot see it: `--version` succeeds while signed out.
514
+ * 2. `--sandbox` IS A BOOLEAN ("terminal restrictions enabled"), not a mode
515
+ * selector — but a CONSULT POSTURE IS STILL EXPRESSIBLE, and this was
516
+ * recorded backwards here for a while. It does not come from `--sandbox`
517
+ * at all; it comes from headless mode's default. Any tool needing a
518
+ * permission that cannot be prompted for is AUTO-DENIED:
519
+ * "User denied permission to run command: <cmd>"
520
+ * "a tool required the 'command' permission that headless mode cannot
521
+ * prompt for, so it was auto-denied."
522
+ * Observed repeatedly, including for an entirely benign `pwd && ls -la`,
523
+ * and a local listener confirmed no egress in any run. Reads (list_dir,
524
+ * file reads) work throughout. So: NOT passing
525
+ * `--dangerously-skip-permissions` IS the consult posture, and passing it
526
+ * is the build posture — both per invocation, both harness-enforced
527
+ * rather than model-instructed. `--mode plan` is a separate, WEAKER thing:
528
+ * it steers behaviour and blocks workspace writes, but it is not what
529
+ * stops command execution.
530
+ * RESIDUAL RISK, and it has no fix from here: `permissions.allow` in the
531
+ * machine-wide settings.json is inherited, so a user who has allowed
532
+ * `command(...)` widens every consult on that box. Codex has
533
+ * `--ignore-user-config` for exactly this; agy has no equivalent.
534
+ * 3. No `mcp` subcommand; `/mcp` is interactive-only.
535
+ * 4. It attempts to INSTALL PLAYWRIGHT at startup (observed failing 404
536
+ * against playwright.azureedge.net). A daemon runtime that downloads and
537
+ * runs a browser driver is worth knowing before it goes on a machine the
538
+ * project leaves running.
281
539
  *
282
- * `agy` has everything else this needs: `-p` for headless, `--output-format
283
- * stream-json`, `--model`, `--effort`, `--continue`, and
284
- * `--dangerously-skip-permissions`. What it has no per-invocation form of is
285
- * the MCP server: config lives at `~/.gemini/config/mcp_config.json`, the
286
- * workspace-local `.agents/mcp_config.json` is read-but-ignored (upstream
287
- * antigravity-cli#60), and the HOME-level file cannot be made per-lane —
288
- * pointing HOME elsewhere would take the cached credentials the headless mode
289
- * signs in with along with it.
540
+ * There is no npm package. `antigravity-cli` (0.0.1, "placeholder") and `agy`
541
+ * (0.0.0, empty) on npm are SQUATS by unrelated accounts; the real channel is
542
+ * the install script at antigravity.google, which is why `install` below says
543
+ * to see the docs rather than naming an npm command.
290
544
  *
291
- * So running Antigravity today means one shared MCP token across every lane on
292
- * the machine, which is exactly the blast radius the per-lane token exists to
293
- * prevent. It is listed so `flowviant doctor` can say "installed, and here is
294
- * what is missing" rather than pretending we never looked the same posture
295
- * the app's @ tray takes. When either the workspace config is fixed upstream
296
- * or a flag appears, this becomes an `mcp` function and a `parse`, and nothing
297
- * else in the daemon changes.
545
+ * WHAT WOULD UNBLOCK IT: a per-invocation MCP flag or env var, or workspace
546
+ * configs actually being honoured. Re-test with the two-location stdio probe
547
+ * above it is five minutes and it answers the question outright. Pin any
548
+ * wiring to >= 1.1.10 (`--model`/`--effort` were ignored in headless before it;
549
+ * `--output-format` arrived in 1.1.8).
298
550
  */
299
551
  antigravity: {
300
552
  id: 'antigravity',
@@ -304,10 +556,97 @@ export const RUNTIMES = {
304
556
  install: 'see antigravity.google/docs/cli',
305
557
  login: 'agy',
306
558
  live: false,
559
+ /** None, because it cannot reach the MCP server at all — see `blocked`. */
560
+ /**
561
+ * WIKI AND CONSULT, BUT NOT BUILD — and the split is the whole point of
562
+ * making drivability per-profile rather than one verdict.
563
+ *
564
+ * Only a BUILD needs the MCP control plane: claiming, reporting a blocker,
565
+ * attaching a PR, completing. A wiki turn writes markdown files that the
566
+ * daemon syncs afterwards, and a consult answers a question in prose; BOTH
567
+ * already run with no MCP at all on every runtime, Claude included. Gating
568
+ * them on an MCP capability they never use was a test of the wrong thing,
569
+ * and it is what kept Antigravity at zero for months.
570
+ *
571
+ * Build returns here when the mediated adapter lands (the daemon holds the
572
+ * MCP connection and the CLI just returns schema-enforced JSON via
573
+ * `--json-schema`), which needs no per-invocation MCP config from the vendor
574
+ * at all.
575
+ */
576
+ profiles: ['build', 'wiki', 'consult'],
307
577
  mcp: null,
308
- args: null,
309
- parse: null,
310
- blocked: 'no per-invocation MCP config — its server list is machine-wide, so every lane would share one token',
578
+ args({ prompt, system, model, effort, resume, profile = 'build', vaultDir, resultSchemaArgs = [] }) {
579
+ const a = [];
580
+ if (resume) a.push('--continue');
581
+ // No system-prompt flag, same weakening as Codex: the contract rides in
582
+ // the prompt, fenced and first.
583
+ a.push('-p', `${system}\n\n---\n\n${prompt}`);
584
+ a.push('--output-format', 'stream-json');
585
+ if (model) a.push('--model', model);
586
+ if (effort) a.push('--effort', effort);
587
+ a.push(...resultSchemaArgs);
588
+
589
+ if (profile === 'wiki') {
590
+ // THE CARTOGRAPHER, at the same bar WIKI_PERM sets for Claude — writes
591
+ // allowed, network shut. Both halves measured on 1.1.12:
592
+ // • headless cannot prompt, so anything needing approval is
593
+ // auto-denied — including the file writes a wiki turn exists to
594
+ // make. `--dangerously-skip-permissions` is what grants them.
595
+ // • `--sandbox` blocks egress: with it, curl returned 400 and a local
596
+ // listener saw nothing; without it, 200 and the request arrived.
597
+ // Upstream #36 warns these two cancel out (skip-permissions
598
+ // auto-approving the sandbox-bypass prompt). It does NOT reproduce on
599
+ // 1.1.12 — tested together, the write landed and the network stayed
600
+ // shut. Re-check on upgrade: if it ever does cancel, this posture
601
+ // silently becomes "wiki turn with internet", which is the one line
602
+ // WIKI_PERM draws ("Command execution is the line: it enables network
603
+ // exfil").
604
+ if (vaultDir) a.push('--add-dir', vaultDir);
605
+ a.push('--sandbox', '--dangerously-skip-permissions');
606
+ // A sweep reads a whole repository; the 5m default would guillotine it.
607
+ a.push('--print-timeout', '60m');
608
+ } else if (profile === 'build') {
609
+ // A BUILD WRITES, and headless auto-denies anything needing approval —
610
+ // so without this every edit comes back "User denied permission" and the
611
+ // turn reports failure having touched nothing. Caught only because an
612
+ // end-to-end test had to add the flag by hand to work.
613
+ //
614
+ // NOT `--sandbox` here, unlike wiki: a build has to `git push` and run
615
+ // `gh pr create`, so it needs the network by definition. The containment
616
+ // is the worktree, as it is for every runtime.
617
+ //
618
+ // FLOWVIANT_SAFE HAS NO EXPRESSION ON THIS RUNTIME. Claude narrows to an
619
+ // allowlist and Codex to `workspace-write`; agy's only per-invocation
620
+ // control is this boolean, and its allow/deny engine is machine-wide.
621
+ // So a SAFE-mode operator gets an agy build that is not actually
622
+ // narrowed — which is why `mediatedSafeGap` is surfaced rather than
623
+ // quietly ignored.
624
+ a.push('--dangerously-skip-permissions');
625
+ a.push('--print-timeout', '60m');
626
+ } else if (profile === 'consult') {
627
+ // NOTHING GRANTED, deliberately. The headless default IS the consult
628
+ // posture: `run_command` comes back "User denied permission… headless
629
+ // mode cannot prompt for it, so it was auto-denied", observed even for a
630
+ // benign `pwd && ls -la`, with no egress in any run. Reads keep working,
631
+ // which is all a consult needs. Passing --dangerously-skip-permissions
632
+ // here would hand a question ANY project editor can type a shell.
633
+ a.push('--print-timeout', '10m');
634
+ }
635
+ return a;
636
+ },
637
+ parse: parseAgyLine,
638
+ /**
639
+ * `--json-schema` enforces the shape of the final result — verified against
640
+ * 1.1.12, which returned exactly the object asked for. This is what makes a
641
+ * BUILD possible on a runtime that cannot be handed an MCP config: the CLI
642
+ * stops needing to CALL anything and just returns a filled-in form, and the
643
+ * daemon makes the control-plane calls on its behalf with the lane's own
644
+ * per-lane token. Strictly better than parsing markers out of prose, which a
645
+ * model can wrap in a code fence, truncate or hallucinate.
646
+ */
647
+ resultSchema: (path) => ['--json-schema', path],
648
+ blocked:
649
+ 'its MCP config is machine-wide — a workspace .agents/mcp_config.json is never loaded (measured), so every lane would share one token',
311
650
  },
312
651
  };
313
652
 
@@ -316,6 +655,109 @@ export const DISPATCHABLE = Object.values(RUNTIMES).filter((r) => r.mcp && r.arg
316
655
 
317
656
  export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
318
657
 
658
+ /**
659
+ * Can the worker this daemon is running actually DRIVE this runtime right now?
660
+ *
661
+ * `dispatchable` on a detection row answers "is the CLI installed"; this answers
662
+ * "and can this process build with it", which is a different question and was
663
+ * briefly a narrower one.
664
+ *
665
+ * THE HISTORY MATTERS, because the answer moved twice. Live mode became the
666
+ * default and does not spawn a CLI at all — it drives the Anthropic Agent SDK
667
+ * in-process — so for one release this returned false for every non-live runtime
668
+ * under LIVE. That was honest rather than correct: a machine with Codex reported
669
+ * it could not drive Codex, which was true of the worker as it then existed, and
670
+ * `@codex` tasks visibly waited instead of being silently built by Claude.
671
+ *
672
+ * 0.40.0 made it wrong by making it unnecessary. `driveSubprocess` (live.mjs)
673
+ * gives live mode a subprocess path for non-live runtimes, sharing the same
674
+ * worktree prep, patch landing, checkpointing and teardown as the session path.
675
+ * So the restriction is gone and this is back to "installed, and this module
676
+ * knows how to spawn it" — which is what the registry's `live` flag always
677
+ * described: not which runtimes can run, but which get a session instead of a
678
+ * subprocess.
679
+ *
680
+ * LIVE is no longer read here. That is deliberate and load-bearing: this
681
+ * predicate feeds both the roster report AND the claim, and if the two ever
682
+ * disagree the daemon either claims work it cannot build or refuses work it can.
683
+ */
684
+ /**
685
+ * WHICH PROFILES NEED THE MCP CONTROL PLANE. Only one does.
686
+ *
687
+ * A BUILD has to claim work, report a blocker, attach a PR and complete — that
688
+ * is the control plane, and a runtime that cannot reach it cannot participate.
689
+ * A WIKI turn writes markdown the daemon syncs afterwards. A CONSULT answers a
690
+ * question in prose. Neither passes an MCP config on ANY runtime today, Claude
691
+ * included — check the two call sites in fleet.mjs, they hand `runTurn` no
692
+ * `mcpArgs` at all.
693
+ *
694
+ * Conflating them cost Antigravity every capability it has: `mcp && args` was
695
+ * the single drivability test, so a machine-wide MCP config disqualified it from
696
+ * two jobs that never open an MCP connection.
697
+ */
698
+ const PROFILE_NEEDS_MCP = { build: true, wiki: false, consult: false };
699
+
700
+ /**
701
+ * A build needs the control plane, but NOT necessarily an MCP config of its own.
702
+ * A runtime that can return schema-enforced output is driven MEDIATED: the
703
+ * daemon holds the MCP connection with the lane's own token and makes the calls,
704
+ * and the CLI just returns a filled-in form. So "can build" is "can reach the
705
+ * control plane, by either route".
706
+ */
707
+ export const mediated = (rt) => Boolean(rt && !rt.mcp && rt.resultSchema);
708
+
709
+ /**
710
+ * A mediated runtime whose only permission control is all-or-nothing, so
711
+ * FLOWVIANT_SAFE cannot narrow its BUILD. Surfaced rather than swallowed: an
712
+ * operator who set SAFE asked for something we cannot give them here, and
713
+ * silently running unnarrowed would be the daemon deciding that on their behalf.
714
+ */
715
+ export const mediatedSafeGap = (rt) => SAFE && mediated(rt);
716
+
717
+ /** Can this runtime do this job on this machine? */
718
+ export const canRun = (rt, profile) =>
719
+ Boolean(rt?.args) &&
720
+ (rt.profiles ?? []).includes(profile) &&
721
+ (!PROFILE_NEEDS_MCP[profile] || Boolean(rt.mcp) || mediated(rt));
722
+
723
+ /**
724
+ * Reported on the roster poll and sent on every claim, so it answers the
725
+ * DISPATCH question specifically: can an @mention of this runtime result in a
726
+ * task being built? That is `build`, and build is the profile that needs MCP —
727
+ * which is why a runtime can be undispatchable and still run wiki and consult.
728
+ */
729
+ export const drivableHere = (rt) => canRun(rt, 'build');
730
+
731
+ /**
732
+ * WHICH RUNTIME RUNS A JOB THAT NOBODY @MENTIONED.
733
+ *
734
+ * Building a task has an author: you @mentioned a CLI, and that is the only
735
+ * dispatch in this product. The other turns have none — the wiki sweep, the
736
+ * re-ground, the plan check, the quick edit and the consult are all started by
737
+ * the daemon or the server, and until now every one of them took `runTurn`'s
738
+ * default parameter value and ran Claude. That was not a decision; it was five
739
+ * call sites omitting an argument, and it only looked correct while Claude was
740
+ * the only runtime and preflight refused to start without it.
741
+ *
742
+ * A PROFILE is a promise about what is IMPOSSIBLE during the turn, not a flag
743
+ * list — flag lists are per-vendor, promises are not, and the goal is that every
744
+ * runtime behaves the same way predictably. A runtime declares the profiles it
745
+ * can actually express; one that cannot express a profile does not get that job,
746
+ * rather than getting it with weaker guarantees nobody wrote down.
747
+ *
748
+ * Claude first when it qualifies — not favouritism, and worth saying plainly:
749
+ * these prompts were written and tuned against it, so it is the known-good
750
+ * answer and anything else is a substitution. When it is absent, any runtime
751
+ * that can express the profile runs the job, which is the whole point.
752
+ */
753
+ export function pickRuntimeFor(profile, { detected } = {}) {
754
+ const rows = detected ?? detectRuntimes();
755
+ const ok = (id) =>
756
+ canRun(RUNTIMES[id], profile) && Boolean(rows.find((d) => d.id === id)?.installed);
757
+ if (ok('claude')) return 'claude';
758
+ return Object.keys(RUNTIMES).find(ok) ?? null;
759
+ }
760
+
319
761
  // ── Detection ──────────────────────────────────────────────────────────────
320
762
 
321
763
  /**
@@ -355,7 +797,10 @@ export function detectRuntimes({ refresh = false } = {}) {
355
797
  version,
356
798
  // Installed and drivable are different questions, and conflating them is
357
799
  // how a user ends up @mentioning something that silently never starts.
358
- dispatchable: version !== null && Boolean(rt.mcp && rt.args),
800
+ // THREE questions, in fact see `drivableHere`: the CLI can be installed,
801
+ // and this module can know how to spawn it, and the worker this daemon is
802
+ // running can still be unable to drive it.
803
+ dispatchable: version !== null && drivableHere(rt),
359
804
  blocked: rt.blocked ?? null,
360
805
  };
361
806
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.38.0",
4
- "description": "Run your own coding CLIs as headless build agents for Flowviant \u2014 Claude Code or Codex, on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
3
+ "version": "0.40.0",
4
+ "description": "Run your own coding CLIs as headless build agents for Flowviant Claude Code or Codex, on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowviant": "bin/cli.mjs"