flowviant 0.36.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.
- package/bin/lib/claude.mjs +100 -67
- package/bin/lib/env.mjs +75 -10
- package/bin/lib/fleet.mjs +159 -10
- package/bin/lib/git.mjs +119 -2
- package/bin/lib/live.mjs +33 -4
- package/bin/lib/runtimes.mjs +363 -0
- package/package.json +2 -2
package/bin/lib/claude.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Driving
|
|
3
|
-
* posture, and one
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,46 +516,32 @@ export function mcpConfigFor(token, mcpUrl) {
|
|
|
508
516
|
return { dir, path: p };
|
|
509
517
|
}
|
|
510
518
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
return { kind: 'write', label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
|
|
535
|
-
}
|
|
536
|
-
case 'Grep':
|
|
537
|
-
return {
|
|
538
|
-
kind: 'search',
|
|
539
|
-
label: `grep ${JSON.stringify(input.pattern ?? '')}${input.path ? ` in ${shortPath(input.path, cwd)}` : ''}`,
|
|
540
|
-
};
|
|
541
|
-
case 'Glob':
|
|
542
|
-
return { kind: 'glob', label: `glob ${input.pattern ?? ''}` };
|
|
543
|
-
case 'LS':
|
|
544
|
-
return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
|
|
545
|
-
case 'Bash':
|
|
546
|
-
return { kind: 'bash', label: `$ ${String(input.command ?? '').replace(/\s+/g, ' ').slice(0, 60)}` };
|
|
547
|
-
default:
|
|
548
|
-
return null; // other tools: silent
|
|
549
|
-
}
|
|
550
|
-
}
|
|
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;
|
|
551
545
|
|
|
552
546
|
// Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
|
|
553
547
|
const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
@@ -600,24 +594,40 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
|
|
|
600
594
|
// returned string for sentinel detection, and each activity is handed to
|
|
601
595
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
602
596
|
// off and keep the raw text passthrough + line sentinels.
|
|
603
|
-
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 }) {
|
|
604
598
|
return new Promise((resolve) => {
|
|
605
|
-
const
|
|
606
|
-
if (
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
//
|
|
619
616
|
// readOnly wins over wikiPerm: a consult must never inherit write tools.
|
|
620
|
-
args.
|
|
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
|
+
});
|
|
621
631
|
// Whatever this machine is signed in with, we use. We do NOT pick.
|
|
622
632
|
//
|
|
623
633
|
// This used to delete ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN to force
|
|
@@ -629,26 +639,49 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
|
|
|
629
639
|
// deliberately configured.
|
|
630
640
|
//
|
|
631
641
|
// Which credential is correct, and whether an account may be shared, is
|
|
632
|
-
// between the operator and
|
|
633
|
-
// not enforce it; it runs
|
|
634
|
-
const child = spawn(
|
|
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
|
+
});
|
|
635
652
|
onSpawn?.(child);
|
|
636
653
|
let out = '';
|
|
637
654
|
const pfx = label ? `${label} ` : '';
|
|
638
655
|
const emit = (s) => process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
|
|
639
656
|
|
|
640
|
-
|
|
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) {
|
|
641
663
|
let buf = '';
|
|
642
664
|
const appendText = (t) => {
|
|
643
665
|
out += t;
|
|
644
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
|
+
};
|
|
645
678
|
child.stdout.on('data', (d) => {
|
|
646
679
|
buf += d.toString();
|
|
647
680
|
let nl;
|
|
648
681
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
649
682
|
const line = buf.slice(0, nl);
|
|
650
683
|
buf = buf.slice(nl + 1);
|
|
651
|
-
if (line.trim())
|
|
684
|
+
if (line.trim()) onLine(line);
|
|
652
685
|
}
|
|
653
686
|
});
|
|
654
687
|
// stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
|
|
@@ -659,14 +692,14 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
|
|
|
659
692
|
});
|
|
660
693
|
child.on('error', (e) => {
|
|
661
694
|
if (e.code === 'ENOENT') {
|
|
662
|
-
console.error(
|
|
695
|
+
console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
|
|
663
696
|
process.exit(1);
|
|
664
697
|
}
|
|
665
698
|
console.error(e);
|
|
666
699
|
resolve(out);
|
|
667
700
|
});
|
|
668
701
|
child.on('close', () => {
|
|
669
|
-
if (buf.trim())
|
|
702
|
+
if (buf.trim()) onLine(buf);
|
|
670
703
|
resolve(out);
|
|
671
704
|
});
|
|
672
705
|
return;
|
|
@@ -681,7 +714,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
|
|
|
681
714
|
child.stderr.on('data', onChunk);
|
|
682
715
|
child.on('error', (e) => {
|
|
683
716
|
if (e.code === 'ENOENT') {
|
|
684
|
-
console.error(
|
|
717
|
+
console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
|
|
685
718
|
process.exit(1);
|
|
686
719
|
}
|
|
687
720
|
console.error(e);
|
package/bin/lib/env.mjs
CHANGED
|
@@ -213,19 +213,39 @@ export async function loadCachedEnv(projectId) {
|
|
|
213
213
|
|
|
214
214
|
// ── Materialization ────────────────────────────────────────────────────────
|
|
215
215
|
|
|
216
|
-
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
216
|
+
/**
|
|
217
|
+
* Add the materialized paths to the exclude file git ACTUALLY READS.
|
|
218
|
+
*
|
|
219
|
+
* This used to resolve the worktree's own gitdir (`.git/worktrees/<name>`) and
|
|
220
|
+
* write `info/exclude` there, on the belief that it "applies to that worktree
|
|
221
|
+
* only and never touches the user's repo". Git does not read that file: it
|
|
222
|
+
* resolves `info/exclude` against $GIT_COMMON_DIR — the main `.git` — so in
|
|
223
|
+
* every linked worktree the daemon creates, the exclusion did nothing at all.
|
|
224
|
+
* The plaintext secret files stayed visible to `git add -A`, which is what
|
|
225
|
+
* `checkpointWip` runs before force-pushing a WIP commit to the remote.
|
|
226
|
+
*
|
|
227
|
+
* `--git-common-dir` is asked of git rather than derived, because that is the
|
|
228
|
+
* one answer that cannot drift from what git itself will consult. The file is
|
|
229
|
+
* local to the clone and never committed.
|
|
230
|
+
*
|
|
231
|
+
* This is a CONVENIENCE, not the guarantee. The guarantee is the check-ignore
|
|
232
|
+
* verification in materializeInto, which refuses to write a secret that git can
|
|
233
|
+
* still see.
|
|
234
|
+
*/
|
|
219
235
|
function excludeInWorktree(wt, relPaths) {
|
|
220
236
|
try {
|
|
221
|
-
|
|
222
|
-
let gitdir = dotGit;
|
|
237
|
+
let gitdir;
|
|
223
238
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
239
|
+
gitdir = resolve(
|
|
240
|
+
wt,
|
|
241
|
+
execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
242
|
+
cwd: wt,
|
|
243
|
+
encoding: 'utf8',
|
|
244
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
245
|
+
}).trim()
|
|
246
|
+
);
|
|
227
247
|
} catch {
|
|
228
|
-
|
|
248
|
+
return; // not a repo — materializeInto's check-ignore gate will refuse anyway
|
|
229
249
|
}
|
|
230
250
|
const excludePath = join(gitdir, 'info', 'exclude');
|
|
231
251
|
mkdirSync(dirname(excludePath), { recursive: true });
|
|
@@ -267,6 +287,27 @@ function isTrackedInGit(wt, relPath) {
|
|
|
267
287
|
}
|
|
268
288
|
}
|
|
269
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Will git hide this path? Asked of git, never inferred.
|
|
292
|
+
*
|
|
293
|
+
* This is the gate that makes writing a secret safe, and it is asked AFTER the
|
|
294
|
+
* exclude file is updated so it reflects the state the agent will actually run
|
|
295
|
+
* under. It fails CLOSED: any error — not a repo, git missing, a weird
|
|
296
|
+
* pathspec — reads as "not ignored", so the secret is not written. A wrong
|
|
297
|
+
* "yes" here puts plaintext on a remote branch; a wrong "no" costs a warning.
|
|
298
|
+
*/
|
|
299
|
+
function isIgnoredInGit(wt, relPath) {
|
|
300
|
+
try {
|
|
301
|
+
execFileSync('git', ['check-ignore', '-q', '--', relPath], {
|
|
302
|
+
cwd: wt,
|
|
303
|
+
stdio: 'ignore',
|
|
304
|
+
});
|
|
305
|
+
return true; // exit 0 = ignored
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
270
311
|
// Per-worktree: the target files we last materialized THIS SESSION.
|
|
271
312
|
const lastFilesByWorktree = new Map();
|
|
272
313
|
// Project-global union of every target file we've ever materialized — PERSISTED
|
|
@@ -334,12 +375,25 @@ export function materializeInto(wt) {
|
|
|
334
375
|
byFile.set(v.targetFile, list);
|
|
335
376
|
}
|
|
336
377
|
|
|
378
|
+
// Exclude BEFORE writing, not after. The old order wrote plaintext first and
|
|
379
|
+
// tried to hide it afterwards, so every failure mode — and the exclude file
|
|
380
|
+
// being the wrong one, which it was — left a readable secret in a tree that
|
|
381
|
+
// `checkpointWip` force-pushes.
|
|
382
|
+
excludeInWorktree(wt, [...byFile.keys()]);
|
|
383
|
+
|
|
337
384
|
const written = [];
|
|
338
385
|
for (const [file, list] of byFile) {
|
|
339
386
|
if (isTrackedInGit(wt, file)) {
|
|
340
387
|
warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
|
|
341
388
|
continue;
|
|
342
389
|
}
|
|
390
|
+
// The load-bearing check. A materialized secret sits in a worktree whose
|
|
391
|
+
// whole tree gets `git add -A`'d and force-pushed by the WIP checkpoint, so
|
|
392
|
+
// "git cannot see this file" is a precondition for writing it, not a nicety.
|
|
393
|
+
if (!isIgnoredInGit(wt, file)) {
|
|
394
|
+
warn(`env: "${file}" is not gitignored — refusing to write secrets there. Add it to .gitignore. Its keys are NOT materialized.`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
343
397
|
try {
|
|
344
398
|
const abs = join(wt, file);
|
|
345
399
|
mkdirSync(dirname(abs), { recursive: true });
|
|
@@ -369,7 +423,18 @@ export function materializeInto(wt) {
|
|
|
369
423
|
}
|
|
370
424
|
for (const f of written) knownTargetFiles.add(f);
|
|
371
425
|
lastFilesByWorktree.set(wt, written);
|
|
372
|
-
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The secret files this daemon has materialized into `wt`.
|
|
430
|
+
*
|
|
431
|
+
* Exists so anything that stages the whole tree can subtract them by pathspec.
|
|
432
|
+
* Belt to the check-ignore braces: `git add -A` obeys .gitignore, so a properly
|
|
433
|
+
* ignored file is already safe — but "already safe" was the assumption that put
|
|
434
|
+
* plaintext on a remote branch, and a second, independent mechanism is cheap.
|
|
435
|
+
*/
|
|
436
|
+
export function materializedFiles(wt) {
|
|
437
|
+
return [...(lastFilesByWorktree.get(wt) ?? [])];
|
|
373
438
|
}
|
|
374
439
|
|
|
375
440
|
// ── Uplink scrubbing ───────────────────────────────────────────────────────
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -38,12 +38,13 @@ import {
|
|
|
38
38
|
isValidPrUrl,
|
|
39
39
|
isValidBranch,
|
|
40
40
|
isSafePathSegment,
|
|
41
|
+
worktreeDiffstat,
|
|
41
42
|
} from './git.mjs';
|
|
42
43
|
import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
|
|
43
44
|
import { revertPatch, withPatchLock } from './patch.mjs';
|
|
44
45
|
import {
|
|
45
46
|
sleep,
|
|
46
|
-
|
|
47
|
+
mcpFor,
|
|
47
48
|
runTurn,
|
|
48
49
|
sawSentinel,
|
|
49
50
|
blockedId,
|
|
@@ -75,6 +76,7 @@ import {
|
|
|
75
76
|
} from './env.mjs';
|
|
76
77
|
import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
|
|
77
78
|
import { machineSnapshot } from './resources.mjs';
|
|
79
|
+
import { detectRuntimes } from './runtimes.mjs';
|
|
78
80
|
|
|
79
81
|
async function fetchRoster(haveIds) {
|
|
80
82
|
const url = new URL(FLEET_URL);
|
|
@@ -84,6 +86,26 @@ async function fetchRoster(haveIds) {
|
|
|
84
86
|
// machine knows its cores, its RAM and whose Claude quota is being spent.
|
|
85
87
|
// Older servers ignore the param, so sending it is always safe.
|
|
86
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
|
+
}
|
|
87
109
|
// Env-sync identity + materialized version (the Settings "env vN" chip).
|
|
88
110
|
try {
|
|
89
111
|
for (const [k, v] of Object.entries(await envQueryParams())) {
|
|
@@ -122,11 +144,90 @@ async function fetchRoster(haveIds) {
|
|
|
122
144
|
return data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
|
|
123
145
|
}
|
|
124
146
|
|
|
147
|
+
const RUN_DIFFSTAT_URL = FLEET_URL.replace(/\/agents\/?$/, '/run-diffstat');
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Post what a run has changed, every 20s, until the returned stop() is called.
|
|
151
|
+
*
|
|
152
|
+
* Daemon-side rather than an MCP tool the agent calls: the agent forgets, each
|
|
153
|
+
* call costs tokens, and anything the agent reports about itself is downstream
|
|
154
|
+
* of whatever it is currently reading. The daemon owns the worktree, so it can
|
|
155
|
+
* just look.
|
|
156
|
+
*
|
|
157
|
+
* Posts when the numbers MOVED, and otherwise once every couple of minutes to
|
|
158
|
+
* say the worktree is still being watched. Both halves are needed. Writing the
|
|
159
|
+
* same row every 20s would make a wedged turn look busy; never re-writing it
|
|
160
|
+
* makes a HEALTHY run look dead, because the reader treats a sample it has not
|
|
161
|
+
* seen refreshed in three minutes as a daemon that stopped — and an agent that
|
|
162
|
+
* finishes editing and then spends fifteen minutes running the test suite
|
|
163
|
+
* produces exactly the same silence as one that died. REFRESH_MS sits well
|
|
164
|
+
* inside that window so an idle-but-live worktree keeps its panel.
|
|
165
|
+
*/
|
|
166
|
+
const DIFFSTAT_REFRESH_MS = 120_000;
|
|
167
|
+
|
|
168
|
+
function sampleDiffstat(cwd, baseRef, intentId, agentId) {
|
|
169
|
+
let last = '';
|
|
170
|
+
let lastSentAt = 0;
|
|
171
|
+
let alive = true;
|
|
172
|
+
const post = async () => {
|
|
173
|
+
if (!alive) return;
|
|
174
|
+
let stat = null;
|
|
175
|
+
try {
|
|
176
|
+
stat = worktreeDiffstat(cwd, baseRef);
|
|
177
|
+
} catch {
|
|
178
|
+
return; // a worktree mid-reset is not an error worth reporting
|
|
179
|
+
}
|
|
180
|
+
if (!stat) return;
|
|
181
|
+
const key = JSON.stringify(stat);
|
|
182
|
+
if (key === last && Date.now() - lastSentAt < DIFFSTAT_REFRESH_MS) return;
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch(RUN_DIFFSTAT_URL, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: {
|
|
187
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
188
|
+
'User-Agent': USER_AGENT,
|
|
189
|
+
'Content-Type': 'application/json',
|
|
190
|
+
},
|
|
191
|
+
signal: AbortSignal.timeout(15_000),
|
|
192
|
+
// The lane, not just the task: the server matches the run on both, so a
|
|
193
|
+
// sample can only ever overwrite the diffstat of THIS lane's own run.
|
|
194
|
+
body: JSON.stringify({ intentId, agentId, diffstat: stat }),
|
|
195
|
+
});
|
|
196
|
+
// Only a sample the server ACCEPTED counts as sent. Marking it delivered
|
|
197
|
+
// before the round-trip meant a dropped request suppressed every retry
|
|
198
|
+
// for as long as the numbers held still — which is precisely when the
|
|
199
|
+
// reader is about to expire the panel.
|
|
200
|
+
if (res.ok) {
|
|
201
|
+
last = key;
|
|
202
|
+
lastSentAt = Date.now();
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
/* best-effort: `last` is untouched, so the next tick tries again */
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const t = setInterval(() => void post(), 20_000);
|
|
209
|
+
// Kick once after a beat so a fast task still reports something before it ends.
|
|
210
|
+
const first = setTimeout(() => void post(), 5_000);
|
|
211
|
+
return () => {
|
|
212
|
+
alive = false;
|
|
213
|
+
clearInterval(t);
|
|
214
|
+
clearTimeout(first);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
125
218
|
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
126
219
|
// base between tasks (fresh conversation), resume in place while on a blocker.
|
|
127
220
|
async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getNext, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
|
|
128
221
|
let resuming = false;
|
|
129
222
|
let needsReset = true; // reset to base before a FRESH task, not on idle polls
|
|
223
|
+
// The task this lane is currently holding. `next` only arrives on a FRESH
|
|
224
|
+
// turn, but a run that comes back from a blocker is still building the same
|
|
225
|
+
// intent — without remembering it here, the entire post-blocker half of a run
|
|
226
|
+
// reports no diffstat and the tray blanks mid-build.
|
|
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';
|
|
130
231
|
let phase = ''; // '', 'idle', 'blocked' — log each transition once, not per poll
|
|
131
232
|
const enter = (p, fn, msg) => {
|
|
132
233
|
if (phase !== p) {
|
|
@@ -153,30 +254,55 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
|
|
|
153
254
|
materializeInto(cwd); // reset wiped the env files (git clean -fd) — rewrite
|
|
154
255
|
needsReset = false;
|
|
155
256
|
}
|
|
156
|
-
const { dir, path: mcpConfig } = mcpConfigFor(token, getMcpUrl());
|
|
157
257
|
// The task the server says is next for this lane, read ONCE per turn: the
|
|
158
|
-
// model and effort below become process flags, so they must
|
|
159
|
-
// same task the kickoff tells
|
|
160
|
-
// 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.
|
|
161
261
|
const next = resuming ? null : getNext?.(agentId) || null;
|
|
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());
|
|
162
272
|
let out = '';
|
|
273
|
+
// Report what this run is changing, while it is changing it. The commits
|
|
274
|
+
// endpoint can only describe work that has already reached the provider, so
|
|
275
|
+
// without this the app has nothing to say about a task for the whole time it
|
|
276
|
+
// is being built. Only when we know WHICH task this turn is for — the same
|
|
277
|
+
// hint that carries its model and effort — because a diffstat attributed to
|
|
278
|
+
// the wrong run is worse than none. On a resume that is the intent this
|
|
279
|
+
// lane already holds; the worktree it is about to keep editing is the same
|
|
280
|
+
// one, so the samples describe the same run.
|
|
281
|
+
const stopDiffstat = heldIntentId
|
|
282
|
+
? sampleDiffstat(cwd, baseRef, heldIntentId, agentId)
|
|
283
|
+
: null;
|
|
163
284
|
try {
|
|
164
285
|
out = await runTurn({
|
|
165
286
|
prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF(next?.intentId),
|
|
166
287
|
resume: resuming,
|
|
167
288
|
system: SYSTEM_SINGLE,
|
|
168
289
|
cwd,
|
|
169
|
-
|
|
290
|
+
runtime: heldRuntime,
|
|
291
|
+
mcpArgs,
|
|
292
|
+
mcpEnv,
|
|
170
293
|
label,
|
|
171
294
|
// Per-task overrides — null/absent means this machine's own defaults
|
|
172
|
-
// (FLOWVIANT_MODEL, and
|
|
295
|
+
// (FLOWVIANT_MODEL, and the CLI's own effort). A resume keeps the
|
|
173
296
|
// session it already has, so there is nothing to re-pick there.
|
|
174
297
|
model: next?.model || undefined,
|
|
175
298
|
effort: next?.effort || undefined,
|
|
176
299
|
onSpawn: (ch) => onChild?.(ch),
|
|
177
300
|
});
|
|
178
301
|
} finally {
|
|
179
|
-
|
|
302
|
+
stopDiffstat?.();
|
|
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 });
|
|
180
306
|
onChild?.(null);
|
|
181
307
|
}
|
|
182
308
|
if (!isAlive()) break;
|
|
@@ -189,6 +315,7 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
|
|
|
189
315
|
if (sawSentinel(out, 'NOTHING')) {
|
|
190
316
|
enter('idle', info, 'idle — no work assigned');
|
|
191
317
|
resuming = false;
|
|
318
|
+
heldIntentId = null; // let go of the task, and of its diffstat
|
|
192
319
|
await sleep(IDLE_SECONDS);
|
|
193
320
|
continue;
|
|
194
321
|
}
|
|
@@ -197,6 +324,7 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
|
|
|
197
324
|
phase = '';
|
|
198
325
|
resuming = false;
|
|
199
326
|
needsReset = true;
|
|
327
|
+
heldIntentId = null;
|
|
200
328
|
continue;
|
|
201
329
|
}
|
|
202
330
|
// No sentinel — the turn didn't complete the protocol. Almost always the
|
|
@@ -209,7 +337,10 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
|
|
|
209
337
|
// failure, not completion — retry in place and KEEP the worktree. Resetting
|
|
210
338
|
// here would wipe the blocked task's uncommitted changes. Only a fresh-task
|
|
211
339
|
// turn (not resuming) warrants a clean slate next time.
|
|
212
|
-
if (!resuming)
|
|
340
|
+
if (!resuming) {
|
|
341
|
+
needsReset = true;
|
|
342
|
+
heldIntentId = null; // fresh slate next turn — nothing held to sample
|
|
343
|
+
}
|
|
213
344
|
await sleep(IDLE_SECONDS);
|
|
214
345
|
}
|
|
215
346
|
info(`${label} stopped`);
|
|
@@ -1078,6 +1209,14 @@ export async function runFleetDaemon() {
|
|
|
1078
1209
|
const startedAt = Date.now();
|
|
1079
1210
|
let filesRead = 0;
|
|
1080
1211
|
let phase = 'reading';
|
|
1212
|
+
// Distinct vault pages this turn has written. Counted HERE, from the
|
|
1213
|
+
// stream, because it is the only place that knows mid-turn: the daemon
|
|
1214
|
+
// syncs the vault to the server once, AFTER the turn returns, so a
|
|
1215
|
+
// server-side count of "rows touched since the turn began" is zero for
|
|
1216
|
+
// the entire writing phase — which is exactly how long the bar needs it.
|
|
1217
|
+
// A Set, not a counter: pages get written once and then edited, and
|
|
1218
|
+
// three tool calls on one page are one page.
|
|
1219
|
+
const pagesSeen = new Set();
|
|
1081
1220
|
const feed = [];
|
|
1082
1221
|
const frame = (extra) => ({
|
|
1083
1222
|
mode,
|
|
@@ -1085,12 +1224,16 @@ export async function runFleetDaemon() {
|
|
|
1085
1224
|
activity: feed[feed.length - 1] ?? '',
|
|
1086
1225
|
recent: feed.slice(-24),
|
|
1087
1226
|
filesRead,
|
|
1227
|
+
pagesWritten: pagesSeen.size,
|
|
1088
1228
|
elapsedSec: Math.round((Date.now() - startedAt) / 1000),
|
|
1089
1229
|
...extra,
|
|
1090
1230
|
});
|
|
1091
1231
|
const onActivity = (a) => {
|
|
1092
1232
|
if (a.kind === 'read') filesRead++;
|
|
1093
|
-
if (a.kind === 'write')
|
|
1233
|
+
if (a.kind === 'write') {
|
|
1234
|
+
phase = 'writing';
|
|
1235
|
+
pagesSeen.add(a.path || a.label);
|
|
1236
|
+
}
|
|
1094
1237
|
// Collapse runs of bare "thinking…" so the feed doesn't fill with it.
|
|
1095
1238
|
if (!(a.label === 'thinking…' && feed[feed.length - 1] === 'thinking…')) {
|
|
1096
1239
|
feed.push(a.label);
|
|
@@ -1430,6 +1573,12 @@ export async function runFleetDaemon() {
|
|
|
1430
1573
|
getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
|
|
1431
1574
|
getNext: (id) => nextByAgent.get(id) ?? null,
|
|
1432
1575
|
getMcpUrl: () => mcpUrl,
|
|
1576
|
+
// Injected rather than imported: fleet.mjs imports live.mjs, so live
|
|
1577
|
+
// cannot import back. The live worker is the DEFAULT one, and until
|
|
1578
|
+
// this was passed down the whole run-diffstat pipeline was reachable
|
|
1579
|
+
// only under FLOWVIANT_POLL=1 — the app's live-changes panel had no
|
|
1580
|
+
// data source at all for the path everybody actually runs.
|
|
1581
|
+
sampleDiffstat,
|
|
1433
1582
|
isAlive: () => state.alive,
|
|
1434
1583
|
onChild: (ch) => {
|
|
1435
1584
|
state.child = ch;
|
package/bin/lib/git.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Git worktree helpers (fleet & static-fleet modes). */
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import { resolve, join } from 'node:path';
|
|
6
6
|
import { rmSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
+
import { materializedFiles } from './env.mjs';
|
|
8
9
|
|
|
9
10
|
export function git(args, cwd) {
|
|
10
11
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
@@ -185,6 +186,14 @@ function gitWithEnv(args, cwd, extraEnv) {
|
|
|
185
186
|
* world untouched — it cannot tell this happened.
|
|
186
187
|
*
|
|
187
188
|
* Returns the commit sha, or null if there was nothing dirty / no remote.
|
|
189
|
+
*
|
|
190
|
+
* This PUSHES, so what it stages is a security boundary, not a detail: the
|
|
191
|
+
* daemon materializes plaintext env-vault secrets into this same worktree.
|
|
192
|
+
* They are gitignored (materializeInto refuses to write them otherwise), and
|
|
193
|
+
* `git add -A` honours .gitignore — but the whole point of the bug this guards
|
|
194
|
+
* against was an exclusion mechanism that silently did nothing, so the paths
|
|
195
|
+
* are ALSO subtracted by pathspec here. Two independent mechanisms, because one
|
|
196
|
+
* of them failing quietly is exactly what put secrets on a remote branch.
|
|
188
197
|
*/
|
|
189
198
|
export function checkpointWip(wt, intentId, baseRef) {
|
|
190
199
|
if (!isSafePathSegment(intentId)) return null;
|
|
@@ -201,7 +210,11 @@ export function checkpointWip(wt, intentId, baseRef) {
|
|
|
201
210
|
try {
|
|
202
211
|
const head = git(['rev-parse', 'HEAD'], wt);
|
|
203
212
|
gitWithEnv(['read-tree', head], wt, env);
|
|
204
|
-
gitWithEnv(
|
|
213
|
+
gitWithEnv(
|
|
214
|
+
['add', '-A', '--', '.', ...materializedFiles(wt).map((p) => `:(exclude)${p}`)],
|
|
215
|
+
wt,
|
|
216
|
+
env
|
|
217
|
+
);
|
|
205
218
|
const tree = gitWithEnv(['write-tree'], wt, env);
|
|
206
219
|
// Nothing changed since HEAD — no snapshot worth pushing.
|
|
207
220
|
if (tree === git(['rev-parse', `${head}^{tree}`], wt)) return null;
|
|
@@ -274,3 +287,107 @@ export function resetWorktree(wt, baseRef) {
|
|
|
274
287
|
console.error(` (worktree reset to ${baseRef} failed: ${e.message})`);
|
|
275
288
|
}
|
|
276
289
|
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* What has changed in this worktree since `baseRef` — committed or not.
|
|
293
|
+
*
|
|
294
|
+
* The definition matters. `git diff --numstat <base>` (no `..HEAD`) compares the
|
|
295
|
+
* base against the WORKING TREE, so it covers commits the agent has made, staged
|
|
296
|
+
* work, and edits it has not committed yet. Anything narrower would go blank at
|
|
297
|
+
* the exact moments you look: right after a commit, or before the first one.
|
|
298
|
+
*
|
|
299
|
+
* Untracked files are added separately — they are invisible to `git diff` and
|
|
300
|
+
* are usually the most interesting thing an agent has done (a new module, a new
|
|
301
|
+
* test). Their line counts are read here rather than inferred; a file too large
|
|
302
|
+
* to be source is reported as a path with no counts instead of being read into
|
|
303
|
+
* memory.
|
|
304
|
+
*
|
|
305
|
+
* PATHS AND COUNTS ONLY. Nothing in here returns file content.
|
|
306
|
+
*
|
|
307
|
+
* Both git calls are `-z`, for the reason gitRaw exists: git's line-based output
|
|
308
|
+
* QUOTES any path that is not plain ASCII, so an accented filename arrives as
|
|
309
|
+
* "n\303\251w.txt" — a string that is not the path, cannot be stat'd, and reads
|
|
310
|
+
* as garbage in the tray. `-z` emits paths verbatim.
|
|
311
|
+
*/
|
|
312
|
+
export function worktreeDiffstat(cwd, baseRef, { maxFiles = 200 } = {}) {
|
|
313
|
+
const files = [];
|
|
314
|
+
let additions = 0;
|
|
315
|
+
let deletions = 0;
|
|
316
|
+
|
|
317
|
+
const add = (path, added, removed) => {
|
|
318
|
+
additions += added;
|
|
319
|
+
deletions += removed;
|
|
320
|
+
files.push({ path, added, removed });
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
// `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
|
|
325
|
+
// but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
|
|
326
|
+
// then the new one. An empty path is therefore the rename marker, and the
|
|
327
|
+
// next two fields belong to it — read line-wise instead, a rename would
|
|
328
|
+
// report a file literally named "old => new".
|
|
329
|
+
const fields = splitNul(gitRaw(['diff', '--numstat', '-z', baseRef, '--'], cwd));
|
|
330
|
+
for (let i = 0; i < fields.length; i++) {
|
|
331
|
+
const [a, d, ...rest] = fields[i].split('\t');
|
|
332
|
+
let path = rest.join('\t');
|
|
333
|
+
if (!path) {
|
|
334
|
+
path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
|
|
335
|
+
i += 2;
|
|
336
|
+
if (!path) continue;
|
|
337
|
+
}
|
|
338
|
+
// Binary files report '-' for both counts; they changed, but not by lines.
|
|
339
|
+
add(path, a === '-' ? 0 : Number(a) || 0, d === '-' ? 0 : Number(d) || 0);
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
// No base ref yet, or not a repo — nothing to report rather than a crash.
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const untracked = splitNul(
|
|
348
|
+
gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], cwd)
|
|
349
|
+
);
|
|
350
|
+
for (const path of untracked) {
|
|
351
|
+
let added = 0;
|
|
352
|
+
// Past the cap this path will not be shown, so do not pay to read it.
|
|
353
|
+
// This is the one place the totals can undercount, and reaching it takes
|
|
354
|
+
// an untracked tree bigger than the list itself — a generated directory
|
|
355
|
+
// .gitignore missed. Statting and reading all of it on a 20s interval
|
|
356
|
+
// would block the roster poll and every other lane on this daemon.
|
|
357
|
+
if (files.length < maxFiles) {
|
|
358
|
+
try {
|
|
359
|
+
const st = statSync(join(cwd, path));
|
|
360
|
+
// Regular files ONLY. `git ls-files --others` will happily name a
|
|
361
|
+
// symlink or a fifo, and readFileSync on a fifo or a character device
|
|
362
|
+
// BLOCKS — on a 20s interval, on the daemon's single thread, that is
|
|
363
|
+
// the whole process wedged waiting for a device that may never write.
|
|
364
|
+
// 2 MB: past that it is a build artifact or a binary, and reading it
|
|
365
|
+
// to count newlines would be the most expensive thing this daemon does.
|
|
366
|
+
if (st.isFile() && st.size <= 2_000_000) {
|
|
367
|
+
const text = readFileSync(join(cwd, path), 'utf8');
|
|
368
|
+
// A NUL byte means binary. Counting "lines" in a PNG produces a
|
|
369
|
+
// number that is not wrong so much as meaningless, and it was being
|
|
370
|
+
// summed into the total shown beside git's real counts.
|
|
371
|
+
if (text.includes('\0')) throw new Error('binary');
|
|
372
|
+
// Lines, not segments. A file ending in a newline — i.e. essentially
|
|
373
|
+
// every source file an agent writes — splits into one more piece
|
|
374
|
+
// than it has lines, and that +1 was landing in the totals shown
|
|
375
|
+
// beside git's own counts.
|
|
376
|
+
added = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
/* vanished between listing and reading — report the path, no counts */
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
add(path, added, 0);
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
/* untracked listing failed — the tracked half still stands */
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (files.length === 0) return null;
|
|
389
|
+
// Totals stay whole while the LIST is capped: a truncated list must never
|
|
390
|
+
// quietly shrink the number printed beside it.
|
|
391
|
+
const truncated = Math.max(0, files.length - maxFiles);
|
|
392
|
+
return { files: files.slice(0, maxFiles), additions, deletions, truncated };
|
|
393
|
+
}
|
package/bin/lib/live.mjs
CHANGED
|
@@ -565,8 +565,10 @@ export async function runLiveTask({
|
|
|
565
565
|
resumeIntentId,
|
|
566
566
|
onChild,
|
|
567
567
|
onIntent,
|
|
568
|
+
sampleDiffstat,
|
|
569
|
+
agentId,
|
|
568
570
|
}) {
|
|
569
|
-
const claim = await mcpCall(mcpUrl, token, '
|
|
571
|
+
const claim = await mcpCall(mcpUrl, token, 'claim_next_task', {}).catch(() => null);
|
|
570
572
|
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
571
573
|
const { runId, intentId } = claim;
|
|
572
574
|
const brief = claim.brief ?? {};
|
|
@@ -753,15 +755,31 @@ export async function runLiveTask({
|
|
|
753
755
|
}, CHECKPOINT_MS);
|
|
754
756
|
checkpointTimer.unref?.();
|
|
755
757
|
|
|
758
|
+
// Report what this run is changing WHILE it changes it. Started here, beside
|
|
759
|
+
// the checkpoint timer, because both want the same two facts — a worktree and
|
|
760
|
+
// the task it belongs to — and both must be torn down on every exit from this
|
|
761
|
+
// function. Unlike poll mode there is nothing to predict: the claim above
|
|
762
|
+
// already told us the real intent.
|
|
763
|
+
const stopDiffstat = sampleDiffstat?.(cwd, baseRef, intentId, agentId) ?? null;
|
|
764
|
+
|
|
756
765
|
const input = makeInput(seedPrompt(runId, brief, transcript, resumedInPlace));
|
|
757
766
|
const session = query({
|
|
758
767
|
prompt: input.stream(),
|
|
759
768
|
options: {
|
|
760
769
|
cwd,
|
|
761
770
|
env,
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
|
|
771
|
+
// Per-task first, this machine's default second. The task's own choice
|
|
772
|
+
// comes off the BRIEF rather than the roster hint, because the claim has
|
|
773
|
+
// already happened here — this is the task we actually got, not the one
|
|
774
|
+
// the server guessed we would get.
|
|
775
|
+
//
|
|
776
|
+
// Still pinned either way: never inherit the user's global default, which
|
|
777
|
+
// may be a 1M/long-context tier their subscription cannot bill autonomous
|
|
778
|
+
// work on.
|
|
779
|
+
model: brief.agentModel || MODEL,
|
|
780
|
+
// Omitted entirely when unset — Claude Code's own default is the right
|
|
781
|
+
// answer, and passing undefined effort is not the same as not passing it.
|
|
782
|
+
...(brief.agentEffort ? { effort: brief.agentEffort } : {}),
|
|
765
783
|
permissionMode: SAFE ? 'default' : 'bypassPermissions',
|
|
766
784
|
...(SAFE ? { allowedTools: SAFE_TOOLS } : {}),
|
|
767
785
|
systemPrompt: { type: 'preset', preset: 'claude_code', append: SYSTEM_LIVE },
|
|
@@ -972,6 +990,10 @@ export async function runLiveTask({
|
|
|
972
990
|
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
973
991
|
} finally {
|
|
974
992
|
clearInterval(checkpointTimer);
|
|
993
|
+
// Same finally as the checkpoint: every path out of this task — done,
|
|
994
|
+
// parked, rate-limited, thrown — must stop reporting a worktree that is
|
|
995
|
+
// about to stop being this run's.
|
|
996
|
+
stopDiffstat?.();
|
|
975
997
|
// Last word on this task's state. If the work landed (branch pushed, PR
|
|
976
998
|
// open, patch applied) the checkpoint has served its purpose and the ref is
|
|
977
999
|
// deleted — otherwise it accumulates one hidden ref per task, forever, on
|
|
@@ -1046,6 +1068,11 @@ export async function runLiveWorker({
|
|
|
1046
1068
|
onChild,
|
|
1047
1069
|
onIntent,
|
|
1048
1070
|
onPreview,
|
|
1071
|
+
/** Start posting this run's worktree diffstat; returns stop(). Injected from
|
|
1072
|
+
* fleet.mjs (which imports this module, so the dependency cannot go the
|
|
1073
|
+
* other way). Optional so a caller without it degrades to no panel rather
|
|
1074
|
+
* than crashing. */
|
|
1075
|
+
sampleDiffstat,
|
|
1049
1076
|
}) {
|
|
1050
1077
|
// The intent this worker is holding across iterations. When a task parks on a
|
|
1051
1078
|
// blocker its worktree keeps uncommitted work; on the resume claim we must NOT
|
|
@@ -1198,6 +1225,8 @@ export async function runLiveWorker({
|
|
|
1198
1225
|
isAlive,
|
|
1199
1226
|
resumeIntentId: lastIntentId,
|
|
1200
1227
|
onChild,
|
|
1228
|
+
sampleDiffstat,
|
|
1229
|
+
agentId,
|
|
1201
1230
|
});
|
|
1202
1231
|
} catch (e) {
|
|
1203
1232
|
enter('error', warn, `${c.yellow('error')} ${c.dim(`— ${e?.message ?? e}`)}`);
|
|
@@ -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.
|
|
4
|
-
"description": "Run your own
|
|
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"
|