flowviant 0.37.0 → 0.38.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.
@@ -1,7 +1,14 @@
1
1
  /**
2
- * Driving Claude Code: the operating-contract system prompts, the permission
3
- * posture, and one `claude -p` turn. The hard rule baked into both prompts:
4
- * there is no interactive user — the only channel to a human is the blocker loop.
2
+ * Driving a coding CLI: the operating-contract system prompts, the permission
3
+ * posture, and one headless turn. The hard rule baked into both prompts: there
4
+ * is no interactive user — the only channel to a human is the blocker loop.
5
+ *
6
+ * `runTurn` used to BE `claude -p`, argv and all. The argv, the binary, the way
7
+ * the MCP server is handed over and the shape of the event stream now come from
8
+ * the runtime registry (runtimes.mjs), because those four things are exactly
9
+ * what differs between one CLI and the next. What stays here is everything that
10
+ * is about FLOWVIANT rather than about a vendor: the contract prompts, the
11
+ * permission sets, the sentinel protocol, and the turn plumbing.
5
12
  */
6
13
 
7
14
  import { spawn } from 'node:child_process';
@@ -9,6 +16,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
9
16
  import { tmpdir } from 'node:os';
10
17
  import { join } from 'node:path';
11
18
  import { SAFE, MODEL } from './config.mjs';
19
+ import { runtimeById, humanizeClaudeTool } from './runtimes.mjs';
12
20
 
13
21
  // Multi-task loop (TOKEN / TOKENS modes): drain the whole queue in one session.
14
22
  export const SYSTEM_MULTI = `You are a Flowviant build agent running FULLY AUTONOMOUSLY via the "flowviant" MCP
@@ -16,13 +24,13 @@ server. There is NO interactive user and NO terminal to ask in. The ONLY way to
16
24
  reach a human is the blocker loop. Never ask the user directly; never wait on stdin.
17
25
 
18
26
  Operate this loop:
19
- 1. Call claim_next_intent to PICK UP the next task someone @mentioned you on. If it
27
+ 1. Call claim_next_task to PICK UP the next task someone @mentioned you on. If it
20
28
  returns claimed:false, output exactly ALL_CLEAR on its own line and stop.
21
29
  2. Read the brief, and read its "thread" FIRST — that is the task conversation, and the
22
30
  newest human message is usually the specific reason you were brought in. If the brief
23
31
  has an existing "branch" (a REVISION), \`git checkout <branch>\` to resume your prior
24
32
  work and address what the thread asks for. Use get_module_files / search_wiki /
25
- list_related_intents for context. Call report_progress as you go.
33
+ list_related_tasks for context. Call report_progress as you go.
26
34
  3. If you hit ANYTHING only a human can decide, call report_blocker with a clear
27
35
  question (and options when you can), then call get_blocker_resolution. If it is
28
36
  not yet resolved, output exactly BLOCKED:<blockerId> on its own line and STOP.
@@ -48,14 +56,14 @@ server. There is NO interactive user and NO terminal to ask in. The ONLY way to
48
56
  reach a human is the blocker loop. Never ask the user directly; never wait on stdin.
49
57
 
50
58
  Do EXACTLY ONE task this turn:
51
- 1. Call claim_next_intent to PICK UP the task someone @mentioned you on. If it returns
59
+ 1. Call claim_next_task to PICK UP the task someone @mentioned you on. If it returns
52
60
  claimed:false, output exactly NOTHING on its own line and stop. Do NOT retry.
53
61
  2. Read the brief, and read its "thread" FIRST — that is the task conversation, and the
54
62
  newest human message is usually the specific reason you were brought in. If the brief
55
63
  has an existing "branch" (a REVISION), first \`git fetch && git checkout <branch>\` to
56
64
  resume YOUR prior work and address what the thread asks for. Otherwise work from the
57
65
  clean base checkout. Use get_module_files / search_wiki /
58
- list_related_intents for context. report_progress as you go.
66
+ list_related_tasks for context. report_progress as you go.
59
67
  3. If you hit ANYTHING only a human can decide, call report_blocker (with options when
60
68
  you can), then get_blocker_resolution. If unresolved, output exactly
61
69
  BLOCKED:<blockerId> on its own line and STOP. Do NOT guess past a real decision.
@@ -97,7 +105,7 @@ export const RESUME =
97
105
  // server, or nothing waiting) it falls back to the original free pick.
98
106
  export const SINGLE_KICKOFF = (intentId) =>
99
107
  intentId
100
- ? `Pick up Flowviant task ${intentId} — call claim_next_intent with intentId "${intentId}" — ` +
108
+ ? `Pick up Flowviant task ${intentId} — call claim_next_task with taskId "${intentId}" — ` +
101
109
  'complete exactly that ONE task per your instructions, then stop. If that ' +
102
110
  'claim comes back unavailable, claim whatever is next for you instead.'
103
111
  : 'Pick up and complete exactly ONE Flowviant task per your instructions, then stop.';
@@ -508,49 +516,32 @@ export function mcpConfigFor(token, mcpUrl) {
508
516
  return { dir, path: p };
509
517
  }
510
518
 
511
- // Shorten an absolute tool path to a repo-relative one for legible output.
512
- const shortPath = (p, cwd) => {
513
- if (typeof p !== 'string') return '';
514
- let s = p;
515
- if (cwd && s.startsWith(cwd)) s = s.slice(cwd.length).replace(/^\/+/, '');
516
- return s;
517
- };
519
+ /**
520
+ * Hand a runtime the flowviant MCP server, however that runtime wants it.
521
+ *
522
+ * Returns `{ dir, args, env }`: `dir` is a temp directory to delete after the
523
+ * turn (null when the runtime needed no file at all), `args` splice into argv,
524
+ * `env` merges into the child's environment. The shape is identical for every
525
+ * runtime precisely because the mechanism is not — Claude wants a JSON file
526
+ * path, Codex wants two `-c` overrides and reads the token out of the
527
+ * environment. Callers should not have to know which.
528
+ */
529
+ export function mcpFor(runtimeId, token, mcpUrl) {
530
+ const rt = runtimeById(runtimeId);
531
+ if (!rt.mcp) throw new Error(`runtime '${rt.id}' cannot take an MCP server: ${rt.blocked}`);
532
+ return rt.mcp(token, mcpUrl);
533
+ }
518
534
 
519
535
  // Turn one Claude tool_use into a compact activity {kind, label}, or null for
520
536
  // tools not worth surfacing. `kind:'read'` is what the file counter counts; a
521
537
  // Write/Edit of a vault page is the "writing" signal. Used by wiki turns to
522
538
  // stream exactly which files Claude is touching (daemon console + app cover).
523
- export function humanizeToolUse(name, input = {}, cwd = '') {
524
- switch (name) {
525
- case 'Read':
526
- return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
527
- // Vault authoring: Write/Edit of a markdown page is the "writing" signal
528
- // (the wiki turn's only legal writes are vault files). Label with the last
529
- // two path segments — the vault lives outside cwd, so shortPath can't trim.
530
- case 'Write':
531
- case 'Edit': {
532
- const p = String(input.file_path ?? '');
533
- const tail = p.split('/').slice(-2).join('/');
534
- // `path` is what lets a caller count DISTINCT pages: a page written once
535
- // and then edited twice is one page, and the label alone cannot say that
536
- // (it changes between '+ page' and '~ page' for the same file).
537
- return { kind: 'write', path: p, label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
538
- }
539
- case 'Grep':
540
- return {
541
- kind: 'search',
542
- label: `grep ${JSON.stringify(input.pattern ?? '')}${input.path ? ` in ${shortPath(input.path, cwd)}` : ''}`,
543
- };
544
- case 'Glob':
545
- return { kind: 'glob', label: `glob ${input.pattern ?? ''}` };
546
- case 'LS':
547
- return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
548
- case 'Bash':
549
- return { kind: 'bash', label: `$ ${String(input.command ?? '').replace(/\s+/g, ' ').slice(0, 60)}` };
550
- default:
551
- return null; // other tools: silent
552
- }
553
- }
539
+ //
540
+ // The body moved to runtimes.mjs, beside Codex's equivalent, because they are
541
+ // the same job for two vendors and keeping them apart is how the two activity
542
+ // vocabularies drift. Re-exported under its original name: a dozen call sites
543
+ // know it, and none of them care where it lives.
544
+ export const humanizeToolUse = humanizeClaudeTool;
554
545
 
555
546
  // Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
556
547
  const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
@@ -603,24 +594,40 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
603
594
  // returned string for sentinel detection, and each activity is handed to
604
595
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
605
596
  // off and keep the raw text passthrough + line sentinels.
606
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity, wikiPerm, readOnly, model, effort }) {
597
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, onActivity, wikiPerm, readOnly, model, effort }) {
607
598
  return new Promise((resolve) => {
608
- const args = [];
609
- if (resume) args.push('--continue');
610
- args.push('-p', prompt, '--append-system-prompt', system);
611
- // Wiki-vault turns are pure file workno MCP server at all.
612
- if (mcpConfig) args.push('--mcp-config', mcpConfig);
613
- // Pin the model never inherit the user's global default (which may be a
614
- // 1M/long-context tier their subscription can't bill autonomous work on).
615
- // A per-task override (chosen in the app, validated server-side against a
616
- // fixed list before it ever reaches this argv) wins over the machine pin;
617
- // absent, the pin stands. Effort has no machine-level pin at all: unset
618
- // means Claude Code's own default, which is the honest resting state.
619
- args.push('--model', model || MODEL);
620
- if (effort) args.push('--effort', effort);
621
- if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
599
+ const rt = runtimeById(runtime);
600
+ if (!rt.args) {
601
+ // Reached only if a brief names a runtime this daemon declares but cannot
602
+ // drive. Fail as a turn with no sentinel the loop already treats that as
603
+ // "the protocol did not complete" and retries, rather than inventing a
604
+ // completion for work that never started.
605
+ console.error(`\nerror: cannot run '${rt.label}' ${rt.blocked}`);
606
+ resolve('');
607
+ return;
608
+ }
609
+ // Pin the model — never inherit the user's global default (which for Claude
610
+ // may be a 1M/long-context tier their subscription can't bill autonomous
611
+ // work on). A per-task override (chosen in the app, validated server-side
612
+ // against a fixed list before it ever reaches this argv) wins over the
613
+ // machine pin; absent, the pin stands. Effort has no machine-level pin at
614
+ // all: unset means the CLI's own default, the honest resting state.
615
+ //
622
616
  // readOnly wins over wikiPerm: a consult must never inherit write tools.
623
- args.push(...(readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM));
617
+ const args = rt.args({
618
+ prompt,
619
+ system,
620
+ model,
621
+ effort,
622
+ resume,
623
+ streamJson,
624
+ perm: readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM,
625
+ // Handed to the adapter rather than appended here, because WHERE these go
626
+ // is a property of the CLI: Codex reads its prompt as a trailing
627
+ // positional, so a flag after it is a flag in the wrong place.
628
+ // Wiki-vault turns are pure file work and pass neither — no MCP at all.
629
+ mcp: mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
630
+ });
624
631
  // Whatever this machine is signed in with, we use. We do NOT pick.
625
632
  //
626
633
  // This used to delete ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN to force
@@ -632,26 +639,49 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
632
639
  // deliberately configured.
633
640
  //
634
641
  // Which credential is correct, and whether an account may be shared, is
635
- // between the operator and Anthropic. Flowviant does not detect it and does
636
- // not enforce it; it runs Claude the ordinary way and relays what happens.
637
- const child = spawn('claude', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
642
+ // between the operator and the vendor. Flowviant does not detect it and does
643
+ // not enforce it; it runs the CLI the ordinary way and relays what happens.
644
+ const child = spawn(rt.bin, args, {
645
+ cwd,
646
+ stdio: ['ignore', 'pipe', 'pipe'],
647
+ // Only ADDS to the environment (the worker token, for runtimes that read
648
+ // it from there). Never replaces it: the CLI's own credentials live in
649
+ // this environment, and handing it a curated one signs it out.
650
+ ...(mcpEnv ? { env: { ...process.env, ...mcpEnv } } : {}),
651
+ });
638
652
  onSpawn?.(child);
639
653
  let out = '';
640
654
  const pfx = label ? `${label} ` : '';
641
655
  const emit = (s) => process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
642
656
 
643
- if (streamJson) {
657
+ // A runtime with its own parser is ALWAYS line-parsed — for Codex the JSONL
658
+ // stream is the only output there is, so treating it as raw text would print
659
+ // event objects at the operator and, worse, hand the sentinel matcher a
660
+ // string containing every word the model reasoned about.
661
+ const lineParsed = streamJson || Boolean(rt.parse);
662
+ if (lineParsed) {
644
663
  let buf = '';
645
664
  const appendText = (t) => {
646
665
  out += t;
647
666
  };
667
+ /** One line of the child's stdout, in whichever dialect it speaks. */
668
+ const onLine = (line) => {
669
+ if (!rt.parse) return handleStreamLine(line, { cwd, emit, onActivity, appendText });
670
+ const ev = rt.parse(line, cwd);
671
+ if (!ev) return;
672
+ if (ev.text) appendText(ev.text);
673
+ if (ev.activity) {
674
+ emit(`${ev.activity.label}\n`);
675
+ onActivity?.(ev.activity);
676
+ }
677
+ };
648
678
  child.stdout.on('data', (d) => {
649
679
  buf += d.toString();
650
680
  let nl;
651
681
  while ((nl = buf.indexOf('\n')) >= 0) {
652
682
  const line = buf.slice(0, nl);
653
683
  buf = buf.slice(nl + 1);
654
- if (line.trim()) handleStreamLine(line, { cwd, emit, onActivity, appendText });
684
+ if (line.trim()) onLine(line);
655
685
  }
656
686
  });
657
687
  // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
@@ -662,14 +692,14 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
662
692
  });
663
693
  child.on('error', (e) => {
664
694
  if (e.code === 'ENOENT') {
665
- console.error("\nerror: 'claude' CLI not found on PATH. Install Claude Code first.");
695
+ console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
666
696
  process.exit(1);
667
697
  }
668
698
  console.error(e);
669
699
  resolve(out);
670
700
  });
671
701
  child.on('close', () => {
672
- if (buf.trim()) handleStreamLine(buf, { cwd, emit, onActivity, appendText });
702
+ if (buf.trim()) onLine(buf);
673
703
  resolve(out);
674
704
  });
675
705
  return;
@@ -684,7 +714,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
684
714
  child.stderr.on('data', onChunk);
685
715
  child.on('error', (e) => {
686
716
  if (e.code === 'ENOENT') {
687
- console.error("\nerror: 'claude' CLI not found on PATH. Install Claude Code first.");
717
+ console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
688
718
  process.exit(1);
689
719
  }
690
720
  console.error(e);
package/bin/lib/fleet.mjs CHANGED
@@ -44,7 +44,7 @@ import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
44
44
  import { revertPatch, withPatchLock } from './patch.mjs';
45
45
  import {
46
46
  sleep,
47
- mcpConfigFor,
47
+ mcpFor,
48
48
  runTurn,
49
49
  sawSentinel,
50
50
  blockedId,
@@ -76,6 +76,7 @@ import {
76
76
  } from './env.mjs';
77
77
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
78
78
  import { machineSnapshot } from './resources.mjs';
79
+ import { detectRuntimes } from './runtimes.mjs';
79
80
 
80
81
  async function fetchRoster(haveIds) {
81
82
  const url = new URL(FLEET_URL);
@@ -85,6 +86,26 @@ async function fetchRoster(haveIds) {
85
86
  // machine knows its cores, its RAM and whose Claude quota is being spent.
86
87
  // Older servers ignore the param, so sending it is always safe.
87
88
  url.searchParams.set('capacity', String(MAX_CONCURRENT));
89
+ // WHICH CLIs this machine actually has, so the app can stop guessing.
90
+ //
91
+ // Until now every surface that listed Gemini or Codex said "not wired up yet"
92
+ // and meant it literally: nothing had ever looked. That was the honest answer
93
+ // while it was true, and it stops being honest the moment a second runtime can
94
+ // run — an app that cannot tell "Codex is not installed" from "we never
95
+ // checked" will confidently tell you the wrong one.
96
+ //
97
+ // A statement about this MACHINE and nothing else: no account, no quota, no
98
+ // entitlement. Detection is cached after the first poll (one version probe per
99
+ // CLI), so this costs a query param thereafter. Older servers ignore an
100
+ // unknown param, so sending it is always safe.
101
+ try {
102
+ const drivable = detectRuntimes()
103
+ .filter((r) => r.dispatchable)
104
+ .map((r) => r.id);
105
+ if (drivable.length) url.searchParams.set('runtimes', drivable.join(','));
106
+ } catch {
107
+ /* detection is best-effort — a probe must never fail the poll */
108
+ }
88
109
  // Env-sync identity + materialized version (the Settings "env vN" chip).
89
110
  try {
90
111
  for (const [k, v] of Object.entries(await envQueryParams())) {
@@ -204,6 +225,9 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
204
225
  // intent — without remembering it here, the entire post-blocker half of a run
205
226
  // reports no diffstat and the tray blanks mid-build.
206
227
  let heldIntentId = null;
228
+ // The CLI the task in flight is being built by — held across a resume for
229
+ // the reason documented at the assignment below.
230
+ let heldRuntime = 'claude';
207
231
  let phase = ''; // '', 'idle', 'blocked' — log each transition once, not per poll
208
232
  const enter = (p, fn, msg) => {
209
233
  if (phase !== p) {
@@ -230,13 +254,21 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
230
254
  materializeInto(cwd); // reset wiped the env files (git clean -fd) — rewrite
231
255
  needsReset = false;
232
256
  }
233
- const { dir, path: mcpConfig } = mcpConfigFor(token, getMcpUrl());
234
257
  // The task the server says is next for this lane, read ONCE per turn: the
235
- // model and effort below become process flags, so they must describe the
236
- // same task the kickoff tells Claude to claim. Re-reading the map mid-turn
237
- // could pair one task's flags with another's work.
258
+ // runtime, model and effort below become process flags, so they must
259
+ // describe the same task the kickoff tells the agent to claim. Re-reading
260
+ // the map mid-turn could pair one task's flags with another's work.
238
261
  const next = resuming ? null : getNext?.(agentId) || null;
239
262
  if (next?.intentId) heldIntentId = next.intentId;
263
+ // WHICH CLI builds this one. Chosen in the app by @mentioning it and carried
264
+ // on the roster hint; absent (older server, or a task captured before there
265
+ // was a choice) it is Claude, which is what every task ran on until now.
266
+ //
267
+ // A resume must keep the runtime it started on — the session, the worktree
268
+ // and the branch all belong to that CLI, and handing its half-finished work
269
+ // to a different one mid-task is not a fallback, it is a second author.
270
+ if (!resuming) heldRuntime = next?.runtime || 'claude';
271
+ const { dir, args: mcpArgs, env: mcpEnv } = mcpFor(heldRuntime, token, getMcpUrl());
240
272
  let out = '';
241
273
  // Report what this run is changing, while it is changing it. The commits
242
274
  // endpoint can only describe work that has already reached the provider, so
@@ -255,10 +287,12 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
255
287
  resume: resuming,
256
288
  system: SYSTEM_SINGLE,
257
289
  cwd,
258
- mcpConfig,
290
+ runtime: heldRuntime,
291
+ mcpArgs,
292
+ mcpEnv,
259
293
  label,
260
294
  // Per-task overrides — null/absent means this machine's own defaults
261
- // (FLOWVIANT_MODEL, and Claude Code's own effort). A resume keeps the
295
+ // (FLOWVIANT_MODEL, and the CLI's own effort). A resume keeps the
262
296
  // session it already has, so there is nothing to re-pick there.
263
297
  model: next?.model || undefined,
264
298
  effort: next?.effort || undefined,
@@ -266,7 +300,9 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
266
300
  });
267
301
  } finally {
268
302
  stopDiffstat?.();
269
- rmSync(dir, { recursive: true, force: true });
303
+ // `dir` is null for a runtime that needed no file on disk (Codex reads its
304
+ // token from the environment) — rmSync would throw on undefined.
305
+ if (dir) rmSync(dir, { recursive: true, force: true });
270
306
  onChild?.(null);
271
307
  }
272
308
  if (!isAlive()) break;
package/bin/lib/live.mjs CHANGED
@@ -568,7 +568,7 @@ export async function runLiveTask({
568
568
  sampleDiffstat,
569
569
  agentId,
570
570
  }) {
571
- const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
571
+ const claim = await mcpCall(mcpUrl, token, 'claim_next_task', {}).catch(() => null);
572
572
  if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
573
573
  const { runId, intentId } = claim;
574
574
  const brief = claim.brief ?? {};
@@ -0,0 +1,363 @@
1
+ /**
2
+ * WHICH CLI builds a task, and how you drive it.
3
+ *
4
+ * The daemon spoke exactly one runtime for its whole life, and that assumption
5
+ * is spread thinner than it looks: `claude -p` argv in one place, but also a
6
+ * `--mcp-config` file, an `--append-system-prompt`, a `stream-json` event schema
7
+ * and a set of sentinel words the turn loop reads out of stdout. A second CLI is
8
+ * not a different binary name — it is a different answer to each of those.
9
+ *
10
+ * So each runtime declares its own answers here, and everything else in the
11
+ * daemon asks this module rather than knowing them.
12
+ *
13
+ * WHAT A RUNTIME MUST BE ABLE TO DO to build a Flowviant task at all:
14
+ * 1. run headless from one prompt and exit,
15
+ * 2. talk to the flowviant MCP server — this is the whole control plane, and a
16
+ * runtime that cannot reach it cannot claim work, report a blocker, attach
17
+ * a PR or complete, which is to say it cannot participate,
18
+ * 3. act without asking permission per tool call (there is no terminal here),
19
+ * 4. emit machine-readable progress, or the thread goes silent for the length
20
+ * of a build.
21
+ * Claude Code and Codex both do all four. Antigravity does 1, 3 and 4 and is
22
+ * declared below with the exact reason it cannot yet do 2.
23
+ *
24
+ * WHAT THIS MODULE DELIBERATELY DOES NOT DO: pick. Which runtime runs a task is
25
+ * decided in the app, by @mentioning it (CLAUDE.md: the @mention is the only
26
+ * dispatch), and arrives on the brief. Detection here answers "what does this
27
+ * machine HAVE" — activity, never capacity, and never a default we invented.
28
+ */
29
+
30
+ import { execFileSync } from 'node:child_process';
31
+ import { mkdtempSync, writeFileSync } from 'node:fs';
32
+ import { tmpdir } from 'node:os';
33
+ import { join } from 'node:path';
34
+ import { SAFE, MODEL, USER_AGENT } from './config.mjs';
35
+
36
+ /** Truncate for a one-line activity label. */
37
+ const oneLine = (s, n = 140) =>
38
+ String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
39
+
40
+ const shortPath = (p, cwd) => {
41
+ const s = String(p ?? '');
42
+ return cwd && s.startsWith(cwd) ? s.slice(cwd.length).replace(/^\//, '') : s;
43
+ };
44
+
45
+ // ── Claude Code ────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Tool-call → one line of activity. Claude's tool names, unchanged from when
49
+ * this lived in claude.mjs; `kind` is the daemon's own vocabulary and every
50
+ * runtime's parser must speak it (`read` is what the wiki file counter counts,
51
+ * `write` carries `path` so distinct pages can be counted).
52
+ */
53
+ export function humanizeClaudeTool(name, input = {}, cwd = '') {
54
+ switch (name) {
55
+ case 'Read':
56
+ return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
57
+ case 'Write':
58
+ case 'Edit': {
59
+ const p = String(input.file_path ?? '');
60
+ const tail = p.split('/').slice(-2).join('/');
61
+ return { kind: 'write', path: p, label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
62
+ }
63
+ case 'Grep':
64
+ return {
65
+ kind: 'search',
66
+ label: `grep ${JSON.stringify(input.pattern ?? '')}${input.path ? ` in ${shortPath(input.path, cwd)}` : ''}`,
67
+ };
68
+ case 'Glob':
69
+ return { kind: 'glob', label: `glob ${input.pattern ?? ''}` };
70
+ case 'LS':
71
+ return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
72
+ case 'Bash':
73
+ return { kind: 'bash', label: `$ ${oneLine(input.command, 60)}` };
74
+ default:
75
+ return null; // other tools: silent
76
+ }
77
+ }
78
+
79
+ // ── Codex ──────────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Codex item → activity, in the daemon's vocabulary.
83
+ *
84
+ * The event names and item types below are not guesses: they were read off the
85
+ * shipped 0.147 binary (`ThreadStarted`/`TurnCompleted`/`ItemCompleted`, items
86
+ * `agent_message` / `reasoning` / `command_execution` / `file_change` /
87
+ * `mcp_tool_call` / `web_search` / `todo_list`). Unknown item types return null
88
+ * and stay silent rather than printing a raw JSON blob into someone's console.
89
+ */
90
+ function humanizeCodexItem(item = {}, cwd = '') {
91
+ switch (item.item_type ?? item.type) {
92
+ case 'agent_message':
93
+ return { kind: 'say', label: oneLine(item.text ?? item.message) };
94
+ case 'reasoning':
95
+ return { kind: 'think', label: oneLine(item.text) || 'thinking…' };
96
+ case 'command_execution':
97
+ return { kind: 'bash', label: `$ ${oneLine(item.command, 60)}` };
98
+ case 'file_change': {
99
+ // `changes` is a list of touched paths; the daemon counts distinct files,
100
+ // so emit one activity per path rather than one for the batch.
101
+ const first = (item.changes ?? [])[0] ?? {};
102
+ const p = String(first.path ?? '');
103
+ const tail = p.split('/').slice(-2).join('/');
104
+ const verb = first.kind === 'add' ? '+' : first.kind === 'delete' ? '-' : '~';
105
+ return { kind: 'write', path: p, label: `${verb} ${tail || 'file'}` };
106
+ }
107
+ case 'mcp_tool_call':
108
+ return { kind: 'tool', label: `${item.server ?? 'mcp'}.${item.tool ?? ''}` };
109
+ case 'web_search':
110
+ return { kind: 'search', label: `search ${oneLine(item.query, 60)}` };
111
+ default:
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Codex `--json` emits JSONL of ThreadEvents. Returns `{ activity, text }` —
118
+ * `text` accumulates the agent's own words, because the turn loop reads its
119
+ * sentinels (NOTHING / BLOCKED:<id> / DONE) out of exactly that.
120
+ */
121
+ function parseCodexLine(line, cwd) {
122
+ let ev;
123
+ try {
124
+ ev = JSON.parse(line);
125
+ } catch {
126
+ return null; // not every line is JSON (warnings go to stderr, but be safe)
127
+ }
128
+ switch (ev.type) {
129
+ case 'item.completed': {
130
+ const item = ev.item ?? {};
131
+ const activity = humanizeCodexItem(item, cwd);
132
+ // Only the agent's MESSAGES are sentinel-bearing text. Reasoning is not:
133
+ // a model that muses "I could output NOTHING here" must not end the turn.
134
+ const text =
135
+ (item.item_type ?? item.type) === 'agent_message'
136
+ ? `${item.text ?? item.message ?? ''}\n`
137
+ : '';
138
+ return { activity, text };
139
+ }
140
+ case 'turn.failed':
141
+ return {
142
+ activity: { kind: 'error', label: oneLine(ev.error?.message ?? 'turn failed') },
143
+ text: '',
144
+ };
145
+ default:
146
+ return null; // thread.started / turn.started / item.started / item.updated
147
+ }
148
+ }
149
+
150
+ // ── The registry ───────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * How each runtime is told about the flowviant MCP server.
154
+ *
155
+ * This is the part that differs most, and it is worth naming why it matters:
156
+ * the token handed over here is a WORKER token scoped to one lane, minted fresh
157
+ * and dropped at the end of the turn. Anything that forces a machine-wide config
158
+ * file forces one shared token for every lane instead, which is a real downgrade
159
+ * in blast radius — so a runtime that cannot take per-invocation config does not
160
+ * get to run, rather than getting to run less safely.
161
+ *
162
+ * Claude takes a config file path (`--mcp-config`), so the token lands in a
163
+ * 0600 temp file the caller deletes. Codex takes dotted `-c` overrides and can
164
+ * read the bearer token from an ENV VAR (`bearer_token_env_var`), so its token
165
+ * never touches disk at all — strictly better, and the reason Codex was the
166
+ * first second-runtime rather than the easiest-looking one.
167
+ */
168
+ function claudeMcp(token, mcpUrl) {
169
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
170
+ const path = join(dir, 'mcp.json');
171
+ writeFileSync(
172
+ path,
173
+ JSON.stringify({
174
+ mcpServers: {
175
+ flowviant: {
176
+ type: 'http',
177
+ url: mcpUrl,
178
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': USER_AGENT },
179
+ },
180
+ },
181
+ }),
182
+ { mode: 0o600 }
183
+ );
184
+ return { dir, args: ['--mcp-config', path], env: {} };
185
+ }
186
+
187
+ function codexMcp(token, mcpUrl) {
188
+ return {
189
+ dir: null, // nothing written — the token rides in the environment
190
+ args: [
191
+ '-c',
192
+ `mcp_servers.flowviant.url="${mcpUrl}"`,
193
+ '-c',
194
+ 'mcp_servers.flowviant.bearer_token_env_var="FLOWVIANT_MCP_TOKEN"',
195
+ ],
196
+ env: { FLOWVIANT_MCP_TOKEN: token },
197
+ };
198
+ }
199
+
200
+ export const RUNTIMES = {
201
+ claude: {
202
+ id: 'claude',
203
+ label: 'Claude Code',
204
+ vendor: 'Anthropic',
205
+ bin: 'claude',
206
+ install: 'npm i -g @anthropic-ai/claude-code',
207
+ login: 'claude',
208
+ /** The Agent-SDK live session (persistent, injectable mid-task) is Claude
209
+ * only — it is an Anthropic SDK, not a CLI contract. Everything else runs
210
+ * the subprocess path. */
211
+ live: true,
212
+ mcp: claudeMcp,
213
+ /**
214
+ * Claude takes the operating contract as a real system prompt, which is the
215
+ * strongest form of it available anywhere: `--append-system-prompt` sits
216
+ * above the conversation rather than inside it.
217
+ */
218
+ args({ prompt, system, model, effort, resume, streamJson, perm, mcp = [] }) {
219
+ const a = [];
220
+ if (resume) a.push('--continue');
221
+ a.push('-p', prompt, '--append-system-prompt', system);
222
+ a.push(...mcp);
223
+ a.push('--model', model || MODEL);
224
+ if (effort) a.push('--effort', effort);
225
+ if (streamJson) a.push('--output-format', 'stream-json', '--verbose');
226
+ a.push(...perm);
227
+ return a;
228
+ },
229
+ parse: null, // claude.mjs owns its own stream parser (unchanged)
230
+ },
231
+
232
+ codex: {
233
+ id: 'codex',
234
+ label: 'Codex',
235
+ vendor: 'OpenAI',
236
+ bin: 'codex',
237
+ install: 'npm i -g @openai/codex',
238
+ login: 'codex login',
239
+ live: false,
240
+ mcp: codexMcp,
241
+ /**
242
+ * Codex has NO system-prompt flag. The contract therefore rides inside the
243
+ * prompt, fenced and placed first, and this is a genuine weakening worth
244
+ * stating plainly: a system prompt is a rule, and a prompt preamble is a
245
+ * strong suggestion the model may drift from over a long turn. It is the
246
+ * best available; AGENTS.md was the alternative and is worse, because it is
247
+ * a FILE IN THE WORKTREE — one `git add -A` from being committed into the
248
+ * user's repository, which is not a risk worth taking for a slightly
249
+ * stickier instruction.
250
+ *
251
+ * `--skip-git-repo-check` is deliberately NOT passed: a task always builds
252
+ * in a git worktree, and if it somehow is not one, failing loudly beats
253
+ * silently editing files nobody can diff.
254
+ *
255
+ * THE PROMPT IS LAST, and that is load-bearing rather than tidy. Codex takes
256
+ * it as a trailing POSITIONAL, so every flag — including the two `-c` MCP
257
+ * overrides, which the caller hands in rather than appending — has to be
258
+ * placed before it. Appending them after the positional is the kind of argv
259
+ * that parses today and stops parsing on some future clap upgrade.
260
+ */
261
+ args({ prompt, system, model, effort, resume, perm: _perm, mcp = [] }) {
262
+ const a = ['exec'];
263
+ if (resume) a.push('resume', '--last');
264
+ a.push('--json');
265
+ if (model) a.push('--model', model);
266
+ // Effort is a config value on Codex rather than a flag.
267
+ 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);
273
+ a.push(`${system}\n\n---\n\n${prompt}`);
274
+ return a;
275
+ },
276
+ parse: parseCodexLine,
277
+ },
278
+
279
+ /**
280
+ * DECLARED, NOT DRIVABLE — and the reason is specific, not a shrug.
281
+ *
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.
290
+ *
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.
298
+ */
299
+ antigravity: {
300
+ id: 'antigravity',
301
+ label: 'Antigravity',
302
+ vendor: 'Google',
303
+ bin: 'agy',
304
+ install: 'see antigravity.google/docs/cli',
305
+ login: 'agy',
306
+ live: false,
307
+ 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',
311
+ },
312
+ };
313
+
314
+ /** Runtimes this daemon can actually put a task on. */
315
+ export const DISPATCHABLE = Object.values(RUNTIMES).filter((r) => r.mcp && r.args);
316
+
317
+ export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
318
+
319
+ // ── Detection ──────────────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * Which of these is on this machine, asked once.
323
+ *
324
+ * `--version` rather than `which`: a binary on PATH that cannot execute (a
325
+ * broken install, a wrong-arch download, a shell alias pointing at nothing) is
326
+ * not a runtime you can dispatch to, and reporting it as one sends work into a
327
+ * hole. 5s is generous for a version print and short enough that three missing
328
+ * CLIs cannot stall a roster poll.
329
+ *
330
+ * Reported to the server on the roster poll so the app can stop saying "we have
331
+ * not looked". It is a statement about THIS MACHINE and nothing else — no
332
+ * account, no quota, no entitlement. Flowviant relays; it does not enforce.
333
+ */
334
+ let detectedCache = null;
335
+ export function detectRuntimes({ refresh = false } = {}) {
336
+ if (detectedCache && !refresh) return detectedCache;
337
+ detectedCache = Object.values(RUNTIMES).map((rt) => {
338
+ let version = null;
339
+ try {
340
+ version = String(
341
+ execFileSync(rt.bin, ['--version'], {
342
+ timeout: 5000,
343
+ stdio: ['ignore', 'pipe', 'ignore'],
344
+ })
345
+ )
346
+ .trim()
347
+ .split('\n')[0]
348
+ .slice(0, 40);
349
+ } catch {
350
+ version = null;
351
+ }
352
+ return {
353
+ id: rt.id,
354
+ installed: version !== null,
355
+ version,
356
+ // Installed and drivable are different questions, and conflating them is
357
+ // how a user ends up @mentioning something that silently never starts.
358
+ dispatchable: version !== null && Boolean(rt.mcp && rt.args),
359
+ blocked: rt.blocked ?? null,
360
+ };
361
+ });
362
+ return detectedCache;
363
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.37.0",
4
- "description": "Run your own Claude Code as headless build agents for Flowviant on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
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.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowviant": "bin/cli.mjs"