flowviant 0.48.3 → 0.48.5

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/fleet.mjs CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  isSafePathSegment,
47
47
  worktreeDiffstat,
48
48
  } from './git.mjs';
49
- import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
49
+ import { c, info, note, ok, warn, fail } from './ui.mjs';
50
50
  import { revertPatch, withPatchLock } from './patch.mjs';
51
51
  import {
52
52
  sleep,
@@ -54,21 +54,11 @@ import {
54
54
  runTurn,
55
55
  sawSentinel,
56
56
  blockedId,
57
- SYSTEM_SINGLE,
58
- SINGLE_KICKOFF,
59
- SINGLE_RESUME,
60
57
  SYSTEM_WIKI,
61
58
  WIKI_KICKOFF,
62
59
  SYSTEM_REGROUND,
63
- SYSTEM_PLAN_CHECK,
64
- PLAN_CHECK_KICKOFF,
65
60
  REGROUND_KICKOFF,
66
- SYSTEM_PLAN,
67
- PLAN_TURN_KICKOFF,
68
- SYSTEM_QUICK_EDIT,
69
- QUICK_EDIT_KICKOFF,
70
61
  } from './claude.mjs';
71
- import { runLiveWorker, readTaskMarker } from './live.mjs';
72
62
  import { reapOrphanPreviews } from './preview.mjs';
73
63
  import { preflight } from './preflight.mjs';
74
64
  import { connectStream } from './stream.mjs';
@@ -186,55 +176,6 @@ const RUN_DIFFSTAT_URL = FLEET_URL.replace(/\/agents\/?$/, '/run-diffstat');
186
176
  */
187
177
  const DIFFSTAT_REFRESH_MS = 120_000;
188
178
 
189
- function sampleDiffstat(cwd, baseRef, intentId, agentId) {
190
- let last = '';
191
- let lastSentAt = 0;
192
- let alive = true;
193
- const post = async () => {
194
- if (!alive) return;
195
- let stat = null;
196
- try {
197
- stat = worktreeDiffstat(cwd, baseRef);
198
- } catch {
199
- return; // a worktree mid-reset is not an error worth reporting
200
- }
201
- if (!stat) return;
202
- const key = JSON.stringify(stat);
203
- if (key === last && Date.now() - lastSentAt < DIFFSTAT_REFRESH_MS) return;
204
- try {
205
- const res = await fetch(RUN_DIFFSTAT_URL, {
206
- method: 'POST',
207
- headers: {
208
- Authorization: `Bearer ${FLEET_TOKEN}`,
209
- 'User-Agent': USER_AGENT,
210
- 'Content-Type': 'application/json',
211
- },
212
- signal: AbortSignal.timeout(15_000),
213
- // The lane, not just the task: the server matches the run on both, so a
214
- // sample can only ever overwrite the diffstat of THIS lane's own run.
215
- body: JSON.stringify({ taskId: intentId, agentId, diffstat: stat }),
216
- });
217
- // Only a sample the server ACCEPTED counts as sent. Marking it delivered
218
- // before the round-trip meant a dropped request suppressed every retry
219
- // for as long as the numbers held still — which is precisely when the
220
- // reader is about to expire the panel.
221
- if (res.ok) {
222
- last = key;
223
- lastSentAt = Date.now();
224
- }
225
- } catch {
226
- /* best-effort: `last` is untouched, so the next tick tries again */
227
- }
228
- };
229
- const t = setInterval(() => void post(), 20_000);
230
- // Kick once after a beat so a fast task still reports something before it ends.
231
- const first = setTimeout(() => void post(), 5_000);
232
- return () => {
233
- alive = false;
234
- clearInterval(t);
235
- clearTimeout(first);
236
- };
237
- }
238
179
 
239
180
  /**
240
181
  * Terminal-session presence: tell the server which Claude sessions exist in
@@ -301,134 +242,6 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
301
242
 
302
243
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
303
244
  // base between tasks (fresh conversation), resume in place while on a blocker.
304
- async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getNext, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
305
- let resuming = false;
306
- let needsReset = true; // reset to base before a FRESH task, not on idle polls
307
- // The task this lane is currently holding. `next` only arrives on a FRESH
308
- // turn, but a run that comes back from a blocker is still building the same
309
- // intent — without remembering it here, the entire post-blocker half of a run
310
- // reports no diffstat and the tray blanks mid-build.
311
- let heldIntentId = null;
312
- // The CLI the task in flight is being built by — held across a resume for
313
- // the reason documented at the assignment below.
314
- let heldRuntime = 'claude';
315
- let phase = ''; // '', 'idle', 'blocked' — log each transition once, not per poll
316
- const enter = (p, fn, msg) => {
317
- if (phase !== p) {
318
- phase = p;
319
- fn(`${label} ${msg}`);
320
- }
321
- };
322
- while (isAlive()) {
323
- const token = getToken(agentId);
324
- if (!token) {
325
- await sleep(IDLE_SECONDS);
326
- continue;
327
- }
328
- // Idle = no claimable work (the server tells us via the roster poll). Don't
329
- // spawn Claude just to find nothing — that's a wasted API call. A blocked
330
- // task (resuming) still polls, so its resolution gets picked up.
331
- if (!resuming && !getHasWork(agentId)) {
332
- enter('idle', info, 'idle — no work assigned');
333
- await sleep(IDLE_SECONDS);
334
- continue;
335
- }
336
- if (!resuming && needsReset) {
337
- resetWorktree(cwd, baseRef); // clean slate for a new task
338
- materializeInto(cwd); // reset wiped the env files (git clean -fd) — rewrite
339
- needsReset = false;
340
- }
341
- // The task the server says is next for this lane, read ONCE per turn: the
342
- // runtime, model and effort below become process flags, so they must
343
- // describe the same task the kickoff tells the agent to claim. Re-reading
344
- // the map mid-turn could pair one task's flags with another's work.
345
- const next = resuming ? null : getNext?.(agentId) || null;
346
- if (next?.intentId) heldIntentId = next.intentId;
347
- // WHICH CLI builds this one. Chosen in the app by @mentioning it and carried
348
- // on the roster hint; absent (older server, or a task captured before there
349
- // was a choice) it is Claude, which is what every task ran on until now.
350
- //
351
- // A resume must keep the runtime it started on — the session, the worktree
352
- // and the branch all belong to that CLI, and handing its half-finished work
353
- // to a different one mid-task is not a fallback, it is a second author.
354
- if (!resuming) heldRuntime = next?.runtime || 'claude';
355
- const { dir, args: mcpArgs, env: mcpEnv } = mcpFor(heldRuntime, token, getMcpUrl());
356
- let out = '';
357
- // Report what this run is changing, while it is changing it. The commits
358
- // endpoint can only describe work that has already reached the provider, so
359
- // without this the app has nothing to say about a task for the whole time it
360
- // is being built. Only when we know WHICH task this turn is for — the same
361
- // hint that carries its model and effort — because a diffstat attributed to
362
- // the wrong run is worse than none. On a resume that is the intent this
363
- // lane already holds; the worktree it is about to keep editing is the same
364
- // one, so the samples describe the same run.
365
- const stopDiffstat = heldIntentId
366
- ? sampleDiffstat(cwd, baseRef, heldIntentId, agentId)
367
- : null;
368
- try {
369
- out = await runTurn({
370
- prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF(next?.intentId),
371
- resume: resuming,
372
- system: SYSTEM_SINGLE,
373
- cwd,
374
- runtime: heldRuntime,
375
- mcpArgs,
376
- mcpEnv,
377
- label,
378
- // Per-task overrides — null/absent means this machine's own defaults
379
- // (FLOWVIANT_MODEL, and the CLI's own effort). A resume keeps the
380
- // session it already has, so there is nothing to re-pick there.
381
- model: next?.model || undefined,
382
- effort: next?.effort || undefined,
383
- onSpawn: (ch) => onChild?.(ch),
384
- });
385
- } finally {
386
- stopDiffstat?.();
387
- // `dir` is null for a runtime that needed no file on disk (Codex reads its
388
- // token from the environment) — rmSync would throw on undefined.
389
- if (dir) rmSync(dir, { recursive: true, force: true });
390
- onChild?.(null);
391
- }
392
- if (!isAlive()) break;
393
- if (blockedId(out)) {
394
- enter('blocked', warn, `${c.yellow('paused')}${c.dim(' — waiting on your review/answer in Flowviant')}`);
395
- resuming = true;
396
- await sleep(POLL_SECONDS);
397
- continue;
398
- }
399
- if (sawSentinel(out, 'NOTHING')) {
400
- enter('idle', info, 'idle — no work assigned');
401
- resuming = false;
402
- heldIntentId = null; // let go of the task, and of its diffstat
403
- await sleep(IDLE_SECONDS);
404
- continue;
405
- }
406
- if (sawSentinel(out, 'DONE')) {
407
- ok(`${label} ${c.dim('finished a task — PR opened for your review')}`);
408
- phase = '';
409
- resuming = false;
410
- needsReset = true;
411
- heldIntentId = null;
412
- continue;
413
- }
414
- // No sentinel — the turn didn't complete the protocol. Almost always the
415
- // flowviant MCP failed to surface its tools (usually a stale worker token).
416
- // Drop the cached token so the next poll re-mints a fresh one, then retry —
417
- // don't fake a blocker or a completion.
418
- enter('reconnect', warn, `${c.yellow('no result')}${c.dim(' — refreshing token, retrying')}`);
419
- onTokenSuspect?.(agentId);
420
- // A no-sentinel turn while RESUMING a blocked task is a transient MCP/token
421
- // failure, not completion — retry in place and KEEP the worktree. Resetting
422
- // here would wipe the blocked task's uncommitted changes. Only a fresh-task
423
- // turn (not resuming) warrants a clean slate next time.
424
- if (!resuming) {
425
- needsReset = true;
426
- heldIntentId = null; // fresh slate next turn — nothing held to sample
427
- }
428
- await sleep(IDLE_SECONDS);
429
- }
430
- info(`${label} stopped`);
431
- }
432
245
 
433
246
  export async function runFleetDaemon() {
434
247
  console.log('');
@@ -466,19 +279,6 @@ export async function runFleetDaemon() {
466
279
  // can hand any lane any task, and two tasks can never be in each other's
467
280
  // files even when one is mid-edit.
468
281
  const taskWorktreePath = (intentId) => join(baseDir, `task-${intentId}`);
469
- const worktreeFor = (intentId) => {
470
- const r = ensureWorktree(repoRoot, taskWorktreePath(intentId), baseRef);
471
- // Only on creation: a resumed tree already has its env, and rewriting it
472
- // mid-task would clobber anything the agent changed.
473
- if (r.fresh) {
474
- try {
475
- materializeInto(r.path);
476
- } catch {
477
- /* best-effort — the task still builds, secrets-backed paths may 500 */
478
- }
479
- }
480
- return r;
481
- };
482
282
  try {
483
283
  const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
484
284
  if (kb > 1024)
@@ -688,60 +488,6 @@ export async function runFleetDaemon() {
688
488
  }
689
489
  };
690
490
 
691
- // Plan checks: the ground-truth pass. Generation drafted these against a
692
- // module manifest and wiki summaries — proxies for the repo. This runs where
693
- // the checkout is, opens the real files, and reports corrections back into the
694
- // thread. Read-only by construction; it never edits.
695
- /**
696
- * Pull the plan-check JSON off the tail of a Claude turn.
697
- *
698
- * The model is told to end with a bare JSON object, but a turn can trail
699
- * prose, a fence, or a stray newline. Scan backwards for the last balanced
700
- * object and validate it hard: anything shaped wrong is dropped rather than
701
- * written into someone's plan. Returns null when nothing usable was found.
702
- */
703
- const parsePlanChecks = (out, intents) => {
704
- const text = String(out ?? '');
705
- const known = new Set(intents.map((i) => i.id));
706
- const end = text.lastIndexOf('}');
707
- if (end === -1) return null;
708
- // NOTE: `lastIndexOf(x, -1)` returns 0, NOT -1 — the position argument is
709
- // clamped, so the obvious `start = lastIndexOf('{', start - 1)` loop spins
710
- // forever once it reaches index 0 and the parse fails. That hangs the
711
- // daemon's event loop, not just this job. Walk with an explicit stop, and
712
- // cap the attempts so a pathological turn can't burn the poll cycle either.
713
- let start = text.lastIndexOf('{', end);
714
- for (let attempts = 0; start !== -1 && attempts < 200; attempts++) {
715
- let parsed = null;
716
- try {
717
- parsed = JSON.parse(text.slice(start, end + 1));
718
- } catch {
719
- /* not a complete object at this offset — step back and retry */
720
- }
721
- if (!parsed || !Array.isArray(parsed.checks)) {
722
- if (start === 0) break;
723
- start = text.lastIndexOf('{', start - 1);
724
- continue;
725
- }
726
- return parsed.checks
727
- .filter((ch) => ch && typeof ch.id === 'string' && known.has(ch.id))
728
- .map((ch) => ({
729
- id: ch.id,
730
- alreadyBuilt: ch.alreadyBuilt === true,
731
- evidence: typeof ch.evidence === 'string' ? ch.evidence.slice(0, 300) : '',
732
- anchors: Array.isArray(ch.anchors)
733
- ? ch.anchors.filter((a) => typeof a === 'string' && a.length < 200).slice(0, 6)
734
- : [],
735
- points:
736
- typeof ch.points === 'number' && Number.isFinite(ch.points)
737
- ? Math.max(0, Math.min(13, Math.round(ch.points)))
738
- : null,
739
- note: typeof ch.note === 'string' ? ch.note.slice(0, 400) : '',
740
- }))
741
- .slice(0, 30);
742
- }
743
- return null;
744
- };
745
491
 
746
492
  /**
747
493
  * Everything that reads or rewrites the shared `wikiWt` worktree takes this:
@@ -789,419 +535,11 @@ export async function runFleetDaemon() {
789
535
  }
790
536
  };
791
537
 
792
- const PLAN_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-check-done');
793
538
  // Machine telemetry — what the box is doing with itself, for the admin view.
794
539
  const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
795
- const checkingPlans = new Set();
796
- const processPlanCheckJobs = (jobs) => {
797
- for (const job of jobs ?? []) {
798
- // New name first; the roster mirrors `intents` off `tasks` for exactly
799
- // this fallback window.
800
- const planTasks = Array.isArray(job?.tasks) ? job.tasks : job?.intents;
801
- if (!job || typeof job.id !== 'string' || !Array.isArray(planTasks)) continue;
802
- if (checkingPlans.has(job.id)) continue;
803
- if (planTasks.length === 0) continue;
804
- checkingPlans.add(job.id);
805
- (async () => {
806
- try {
807
- // WHICH CLI answers a turn nobody @mentioned. Resolved per job rather
808
- // than once at startup: a CLI can be installed while the daemon runs.
809
- const planRt = pickRuntimeFor('consult');
810
- if (!planRt) {
811
- warn(`plan check for "${job.title}" skipped — no installed CLI can run a read-only turn`);
812
- checkingPlans.delete(job.id);
813
- return;
814
- }
815
- note(`${c.cyan('plan')} ${c.dim(`— checking "${job.title}" against your code…`)}`);
816
- const out = await withWikiLock(async () => {
817
- ensureWikiWorktree();
818
- return runTurn({
819
- prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents: planTasks }),
820
- resume: false,
821
- system: SYSTEM_PLAN_CHECK,
822
- cwd: wikiWt,
823
- // Reads the repo and reports JSON — it authors nothing either.
824
- readOnly: true,
825
- runtime: planRt,
826
- label: c.cyan('[plan]'),
827
- });
828
- });
829
- const checks = parsePlanChecks(out, planTasks);
830
- if (checks === null) {
831
- warn(`plan check for "${job.title}": no usable JSON — leaving the plan as drafted`);
832
- }
833
- await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
834
- taskId: job.id,
835
- checks: checks ?? [],
836
- });
837
- if (checks?.length) {
838
- ok(`${c.cyan('plan')} ${c.dim(`— ${checks.length} correction${checks.length === 1 ? '' : 's'} for "${job.title}"`)}`);
839
- } else {
840
- ok(`${c.cyan('plan')} ${c.dim(`— "${job.title}" checks out against your code`)}`);
841
- }
842
- } catch (e) {
843
- warn(`plan check failed for "${job.title}": ${e?.message ?? e}`);
844
- // Clear the flag anyway — a stuck job would re-run every poll forever.
845
- await reportMergeOutcome(PLAN_CHECK_DONE_URL, { taskId: job.id, checks: [] });
846
- } finally {
847
- checkingPlans.delete(job.id);
848
- }
849
- })();
850
- }
851
- };
852
540
 
853
- // ── Planning sessions ────────────────────────────────────────────────────
854
- //
855
- // A turn in a plan thread, answered inside a HELD session. This was the
856
- // consult, which answered one question in prose and kept nothing: it existed
857
- // because the planner was a different, weaker brain and this turn's only job
858
- // was to correct it from the real code. That planner is gone, so the session
859
- // reads the repo AND writes the plan, over many turns, in one context.
860
- //
861
- // Two things changed shape as a result.
862
- //
863
- // ONE WORKTREE PER PLAN, not the shared `wikiWt`. Every CLI here resumes with
864
- // "continue the last session in this directory" (`--continue`, `resume
865
- // --last`) rather than by session id, so the WORKING DIRECTORY *is* the
866
- // session handle. A shared directory would have made two plans on one machine
867
- // take turns wearing each other's context — and the wiki queue hard-resets
868
- // that directory between tasks, which would pull the files out from under a
869
- // session mid-argument. A private detached checkout per plan also means plan
870
- // turns no longer queue behind the wiki lock.
871
- //
872
- // IT CARRIES MCP. A consult passed none — nothing to write. A session spawns
873
- // slices, re-shapes them, drops them and maintains the spec, all of which are
874
- // control-plane calls. The token is the fleet's PLAN principal, whose entire
875
- // tool set is those five: it cannot claim, cannot open a worktree, cannot
876
- // commit. That absence is the product rule, not a hardening measure — it is
877
- // what makes "add a dark mode toggle" typed at a plan add a slice instead of
878
- // building one, with nothing reading the sentence to decide.
879
- const CONSULT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/consult-done');
880
- const PLAN_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-token');
881
- const answering = new Set();
882
- const consultAttempts = new Map(); // turn id -> tries
883
- /** Give up after this many turns on one message. A /consult-done that never
884
- * reaches the server (offline, 500) would otherwise re-run the whole Claude
885
- * turn every poll, forever, on the owner's quota. */
886
- const MAX_CONSULT_TRIES = 3;
887
- /** ONE planning turn at a time on this machine. Sessions are per-plan so they
888
- * no longer collide on a directory, but the roster can hand back a batch, and
889
- * un-awaited spawns would put N concurrent CLI processes on someone's laptop
890
- * for what is, on the human's side, a chat. */
891
- let consultChain = Promise.resolve();
892
541
 
893
- /**
894
- * The plan credential, cached until it stops working.
895
- *
896
- * Minted lazily rather than at startup: most daemons never host a planning
897
- * session, and a token nobody uses is a credential sitting on disk for no
898
- * reason. Rotated by the server on every mint, so a re-mint after a 401 is the
899
- * recovery path.
900
- */
901
- let planToken = null;
902
- const mintPlanToken = async (force = false) => {
903
- if (planToken && !force) return planToken;
904
- try {
905
- const res = await fetch(PLAN_TOKEN_URL, {
906
- method: 'POST',
907
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
908
- });
909
- if (!res.ok) return null;
910
- const data = await res.json().catch(() => null);
911
- planToken = data?.data?.token ?? null;
912
- return planToken;
913
- } catch {
914
- return null;
915
- }
916
- };
917
542
 
918
- /**
919
- * This plan's session directory — its context, expressed as a place.
920
- *
921
- * A detached checkout at base, like a consult's, but PRIVATE and PERSISTENT:
922
- * private so `--continue` resumes this argument rather than whichever ran last
923
- * on the box, persistent so it survives the daemon restarting or updating
924
- * under it. Re-pointed at the current base each turn, because "reads your
925
- * code" has to mean the code as it is now — a plan that runs for days would
926
- * otherwise keep answering from the commit it was opened at.
927
- *
928
- * Returns null when the id is not a safe path segment: it comes off the wire.
929
- */
930
- const planWtFor = (planId) => {
931
- if (!isSafePathSegment(planId)) return null;
932
- const wt = join(baseDir, 'plans', planId);
933
- const fresh = !existsSync(wt);
934
- if (fresh) {
935
- try {
936
- git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
937
- } catch {
938
- git(['worktree', 'prune'], repoRoot);
939
- try {
940
- git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
941
- } catch {
942
- return null;
943
- }
944
- }
945
- } else {
946
- try {
947
- git(['fetch', 'origin', '--quiet'], repoRoot);
948
- git(['checkout', '--detach', baseRef], wt);
949
- git(['reset', '--hard', baseRef], wt);
950
- git(['clean', '-fd'], wt);
951
- } catch {
952
- /* offline, or a turn left it dirty — read what we have */
953
- }
954
- }
955
- return { wt, fresh };
956
- };
957
-
958
- /**
959
- * Retire the least-recently-touched session directories.
960
- *
961
- * The bound belongs HERE, in the machine, and never in the interface: ten
962
- * plans open across a team is ten checkouts on one box, which is a resource
963
- * question. Announcing a session limit in the app would be advertising
964
- * capacity, which this product does not do. A retired session simply rebuilds
965
- * from the spec next time it is asked for — the fallback the server already
966
- * expects, and which the thread says out loud when it happens.
967
- */
968
- const MAX_PLAN_SESSIONS = 8;
969
- const planTouched = new Map(); // planId -> ms
970
- const retireIdlePlanSessions = () => {
971
- const dir = join(baseDir, 'plans');
972
- if (!existsSync(dir)) return;
973
- let ids;
974
- try {
975
- ids = readdirSync(dir);
976
- } catch {
977
- return;
978
- }
979
- if (ids.length <= MAX_PLAN_SESSIONS) return;
980
- const oldestFirst = ids.sort(
981
- (a, b) => (planTouched.get(a) ?? 0) - (planTouched.get(b) ?? 0)
982
- );
983
- for (const id of oldestFirst.slice(0, ids.length - MAX_PLAN_SESSIONS)) {
984
- try {
985
- git(['worktree', 'remove', '--force', join(dir, id)], repoRoot);
986
- } catch {
987
- try {
988
- rmSync(join(dir, id), { recursive: true, force: true });
989
- } catch {
990
- /* it is a directory we will overwrite next time; not worth failing a turn */
991
- }
992
- }
993
- planTouched.delete(id);
994
- }
995
- try {
996
- git(['worktree', 'prune'], repoRoot);
997
- } catch {
998
- /* best effort */
999
- }
1000
- };
1001
-
1002
- // Quick edits — a SECOND Claude alongside a task this machine is already
1003
- // building. Unlike every other roster job it does not get a worktree of its
1004
- // own: the whole point is to work in the one the running task opened, on that
1005
- // branch, so the change rides along with the delivery instead of becoming a
1006
- // second thing to merge.
1007
- const JOIN_TAKE_URL = FLEET_URL.replace(/\/agents\/?$/, '/join-take');
1008
- const JOIN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/join-done');
1009
- const joining = new Set();
1010
- /** ONE quick edit at a time, ACROSS worktrees. Two of them in the same tree
1011
- * would fight over the index; two in different trees would still be two extra
1012
- * Claudes on the owner's account on top of the tasks already running. */
1013
- let joinChain = Promise.resolve();
1014
-
1015
- /** The worktree currently building this intent, or null if this machine isn't.
1016
- * Now a direct lookup rather than a scan: a task's checkout is named after
1017
- * the task, so there is exactly one place it could be. The marker is still
1018
- * consulted, but for LIFECYCLE rather than identity — a directory that
1019
- * outlived its run (finished, cleared its marker, kept for the review
1020
- * preview) exists but is not building anything, and must not take an edit. */
1021
- const worktreeBuilding = (intentId) => {
1022
- const wt = taskWorktreePath(intentId);
1023
- try {
1024
- if (existsSync(wt) && readTaskMarker(wt) === intentId) return { wt };
1025
- } catch {
1026
- /* a worktree that vanished isn't building anything */
1027
- }
1028
- return null;
1029
- };
1030
-
1031
- const processJoinJobs = (jobs) => {
1032
- for (const job of jobs ?? []) {
1033
- if (!job || typeof job.id !== 'string' || !job.instruction) continue;
1034
- if (joining.has(job.id)) continue;
1035
- joining.add(job.id);
1036
- joinChain = joinChain.then(async () => {
1037
- let settled = false;
1038
- try {
1039
- const target = worktreeBuilding(job.taskId ?? job.intentId);
1040
- if (!target) {
1041
- // The run ended (or moved) between the human pressing ⚡ and this
1042
- // poll. Settle rather than retry: there is no worktree to join, and
1043
- // an unsettled row holds the reset interlock open forever.
1044
- await reportMergeOutcome(JOIN_DONE_URL, {
1045
- joinId: job.id,
1046
- ok: false,
1047
- result: 'that task is no longer building on this machine',
1048
- });
1049
- settled = true;
1050
- return;
1051
- }
1052
- // Compare-and-set BEFORE spending a Claude turn: two lanes can wake on
1053
- // the same push, and running one instruction twice into one worktree
1054
- // is exactly the double-edit this is meant to avoid.
1055
- const quickRt = pickRuntimeFor('build');
1056
- if (!quickRt) return; // nothing here can edit code; leave the join unclaimed
1057
- const claim = await postForData(JOIN_TAKE_URL, { joinId: job.id });
1058
- if (!claim?.taken) return;
1059
- note(
1060
- `${c.cyan('quick')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.taskTitle || job.intentTitle || 'a task'}"`)}`
1061
- );
1062
- const out = await runTurn({
1063
- prompt: QUICK_EDIT_KICKOFF({
1064
- intentTitle: job.taskTitle ?? job.intentTitle,
1065
- instruction: job.instruction,
1066
- askedByName: job.askedByName,
1067
- }),
1068
- // Never resume: this is its own tiny turn, not a continuation of the
1069
- // task's session. Resuming would hand it the other agent's context
1070
- // and, with it, the other agent's job.
1071
- resume: false,
1072
- system: SYSTEM_QUICK_EDIT,
1073
- cwd: target.wt,
1074
- runtime: quickRt,
1075
- // No MCP: a join records no run, claims nothing, completes nothing.
1076
- // Its only report is the one this daemon posts below.
1077
- label: c.cyan('[quick]'),
1078
- });
1079
- const summary = (out || '').trim();
1080
- await reportMergeOutcome(JOIN_DONE_URL, {
1081
- joinId: job.id,
1082
- ok: summary.length > 0,
1083
- // Scrub: a summary can quote config or env-adjacent code.
1084
- result: envScrub(summary).slice(0, 4000) || 'no change reported',
1085
- });
1086
- settled = true;
1087
- ok(`${c.cyan('quick')} ${c.dim('— landed on the task branch')}`);
1088
- } catch (e) {
1089
- warn(`quick edit failed: ${e?.message ?? e}`);
1090
- if (!settled) {
1091
- await reportMergeOutcome(JOIN_DONE_URL, {
1092
- joinId: job.id,
1093
- ok: false,
1094
- result: e?.message ?? 'the change could not be applied',
1095
- }).catch(() => {});
1096
- }
1097
- } finally {
1098
- joining.delete(job.id);
1099
- }
1100
- });
1101
- }
1102
- };
1103
-
1104
- const processConsultJobs = (jobs) => {
1105
- for (const job of jobs ?? []) {
1106
- if (!job || typeof job.id !== 'string' || !job.question) continue;
1107
- if (answering.has(job.id)) continue;
1108
- const tries = (consultAttempts.get(job.id) ?? 0) + 1;
1109
- if (tries > MAX_CONSULT_TRIES) continue;
1110
- consultAttempts.set(job.id, tries);
1111
- answering.add(job.id);
1112
- consultChain = consultChain.then(async () => {
1113
- try {
1114
- note(`${c.cyan('plan')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.planTitle || 'a plan'}"`)}`);
1115
- // The profile is the enforcement, not the prompt: this turn is steered
1116
- // by anything a project editor can type, and it holds write tools. A
1117
- // runtime that cannot express `plan` does not get the job rather than
1118
- // getting it with guarantees nobody wrote down — which today excludes
1119
- // Antigravity, whose mediated shape fits a build and not an argument.
1120
- const planRt = pickRuntimeFor('plan');
1121
- if (!planRt) {
1122
- warn('a planning turn is waiting, but no installed CLI can run a planning session');
1123
- return;
1124
- }
1125
- const token = await mintPlanToken();
1126
- if (!token) {
1127
- warn('a planning turn is waiting, but the plan credential could not be minted');
1128
- return;
1129
- }
1130
- const dir = planWtFor(job.taskId);
1131
- if (!dir) {
1132
- warn(`a planning turn is waiting, but its session directory could not be opened`);
1133
- return;
1134
- }
1135
- planTouched.set(job.taskId, Date.now());
1136
- // Resume only when this plan already HAS a session here. A fresh
1137
- // directory means either the first turn or a session we retired, and
1138
- // both want the same thing: start over from the spec, which the
1139
- // kickoff carries. `--continue` against an empty directory is not an
1140
- // error on every CLI, so asking `fresh` is what keeps it honest.
1141
- const resume = !dir.fresh && Boolean(job.sessionRef);
1142
- const mcp = mcpFor(planRt, token, mcpUrl);
1143
- let out;
1144
- try {
1145
- out = await runTurn({
1146
- prompt: PLAN_TURN_KICKOFF({
1147
- planId: job.taskId,
1148
- planTitle: job.planTitle,
1149
- question: job.question,
1150
- askedByName: job.askedByName,
1151
- // Sent only when we are NOT resuming: a live session already has
1152
- // the argument in its context, and re-stating the spec every
1153
- // turn would spend tokens telling it what it just wrote. On a
1154
- // rebuild it is the whole inheritance.
1155
- spec: resume ? null : job.spec,
1156
- }),
1157
- resume,
1158
- system: SYSTEM_PLAN,
1159
- cwd: dir.wt,
1160
- // Read the repo, write the PLAN. No Edit/Write/commit anywhere in
1161
- // the toolset — the prompt says so too, but the prompt is what an
1162
- // injected message competes with.
1163
- planPerm: true,
1164
- mcpArgs: mcp.args,
1165
- mcpEnv: mcp.env,
1166
- runtime: planRt,
1167
- label: c.cyan('[plan]'),
1168
- });
1169
- } finally {
1170
- if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
1171
- }
1172
- const answer = (out || '').trim();
1173
- const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
1174
- consultId: job.id,
1175
- ok: answer.length > 0,
1176
- // Scrub: a reply can quote config or env-adjacent code.
1177
- answer: envScrub(answer).slice(0, 8000),
1178
- // The handle the server stores, reported on EVERY turn: a session we
1179
- // had to rebuild comes back under a new directory state, and a
1180
- // stored handle that does not follow it leaves later turns trying to
1181
- // resume something that is gone.
1182
- sessionRef: dir.wt,
1183
- });
1184
- if (posted) consultAttempts.delete(job.id);
1185
- ok(`${c.cyan('plan')} ${c.dim('— replied in the plan thread')}`);
1186
- retireIdlePlanSessions();
1187
- } catch (e) {
1188
- // Settle it. A turn that cannot be answered must not re-burn quota
1189
- // every poll, and silence would leave the human waiting on a machine
1190
- // that already gave up.
1191
- await reportMergeOutcome(CONSULT_DONE_URL, {
1192
- consultId: job.id,
1193
- ok: false,
1194
- // Scrub, like the success path: an exception routinely quotes
1195
- // command output, and command output can quote a synced secret.
1196
- answer: envScrub(String(e?.message ?? 'the planning turn failed')).slice(0, 2000),
1197
- });
1198
- warn(`planning turn failed: ${e?.message ?? e}`);
1199
- } finally {
1200
- answering.delete(job.id);
1201
- }
1202
- });
1203
- }
1204
- };
1205
543
 
1206
544
  // ── Work sessions — the Workbench tabs ─────────────────────────────────────
1207
545
  //
@@ -1723,7 +1061,6 @@ export async function runFleetDaemon() {
1723
1061
  let rosterSig = null; // last roster membership, to log changes only
1724
1062
  let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
1725
1063
  let cappedWarned = false; // say once, not every reconcile, why extra lanes idle
1726
- let joinCount = 0; // for stable per-agent label colours
1727
1064
 
1728
1065
  // ── Push channel: a server wake short-circuits the reconcile sleep so a job is
1729
1066
  // picked up in ~a round trip instead of on the next poll. The socket only
@@ -1828,8 +1165,6 @@ export async function runFleetDaemon() {
1828
1165
  void flushWorkReports();
1829
1166
  processMergeJobs(roster.mergeJobs);
1830
1167
  processPatchRevertJobs(roster.patchRevertJobs);
1831
- processPlanCheckJobs(roster.planCheckJobs);
1832
- processConsultJobs(roster.consultJobs);
1833
1168
  processWorkTurns(roster.workTurnJobs);
1834
1169
  // The roster's live-session list rides along: an ENDED session's ship
1835
1170
  // must not be refused by checks whose remedies need a live tab.
@@ -1847,7 +1182,6 @@ export async function runFleetDaemon() {
1847
1182
  // the daemon's own worktrees are carved out (a session the daemon spawned
1848
1183
  // is already a tab, not something to offer adopting).
1849
1184
  void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
1850
- processJoinJobs(roster.joinJobs);
1851
1185
  processCleanupJobs(roster.cleanupJobs);
1852
1186
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));
1853
1187
 
@@ -1856,8 +1190,12 @@ export async function runFleetDaemon() {
1856
1190
  if (sig !== rosterSig) {
1857
1191
  rosterSig = sig;
1858
1192
  if (rosterIds.size === 0) {
1859
- warn('No agents on your roster yet.');
1860
- info('Add agents in Flowviant Cockpit Fleet; they spin up here automatically.');
1193
+ // `agents` is permanently [] — the lanes it counted died with dispatch
1194
+ // and the array survives only as wire compat. So this branch is the one
1195
+ // that always runs, and it used to point at the Cockpit, a surface
1196
+ // deleted 2026-08-04 that now redirects to the Board. Say what is
1197
+ // actually true instead: the machine is up, and work starts in a tab.
1198
+ info('Machine online. Open a tab in Flowviant → Workbench to start working.');
1861
1199
  } else {
1862
1200
  note(`Roster: ${c.bold(String(rosterIds.size))} agent${rosterIds.size === 1 ? '' : 's'}.`);
1863
1201
  }
@@ -1868,104 +1206,6 @@ export async function runFleetDaemon() {
1868
1206
  info('idle — waiting for agents…');
1869
1207
  }
1870
1208
 
1871
- for (const a of roster.agents) {
1872
- if (a.token) {
1873
- tokenByAgent.set(a.agentId, a.token);
1874
- mintedAt.set(a.agentId, Date.now());
1875
- }
1876
- hasWorkByAgent.set(a.agentId, !!a.hasWork);
1877
- // The hint's task id, new name first. Normalized ONTO `intentId` here so
1878
- // every downstream read (poll worker, kickoff, diffstat attribution)
1879
- // keeps its one spelling — intent is still the daemon's internal word,
1880
- // taskId is the wire's.
1881
- const nextId = a.next && (a.next.taskId ?? a.next.intentId);
1882
- if (a.next && typeof nextId === 'string')
1883
- nextByAgent.set(a.agentId, { ...a.next, intentId: nextId });
1884
- else nextByAgent.delete(a.agentId);
1885
- if (!workers.has(a.agentId)) {
1886
- // Local ceiling, enforced and not merely requested. The roster can carry
1887
- // more lanes than this machine asked for — someone added capacity by
1888
- // hand, or a second machine shares the fleet — and each extra worker is
1889
- // another Claude session, another worktree and another dev server on
1890
- // somebody's laptop. Skipping the spawn does NOT strand the work: an
1891
- // @mention addresses the FLEET, so any running lane can claim it; the
1892
- // tasks queue behind the ones we did start.
1893
- if (workers.size >= MAX_CONCURRENT) {
1894
- if (!cappedWarned) {
1895
- cappedWarned = true;
1896
- info(
1897
- `running ${MAX_CONCURRENT} task${MAX_CONCURRENT === 1 ? '' : 's'} at a time on this machine — ` +
1898
- `more will queue (FLOWVIANT_MAX_CONCURRENT to change)`
1899
- );
1900
- }
1901
- continue;
1902
- }
1903
- // LIVE lanes get NO checkout of their own — they ask for one per task,
1904
- // once they know which task. Poll mode is the legacy escape hatch and
1905
- // keeps its per-lane tree; it predates per-task sandboxes and isn't
1906
- // worth restructuring for a path nobody runs by default.
1907
- let wt = null;
1908
- if (!LIVE) {
1909
- try {
1910
- ensureWorktree(repoRoot, (wt = join(baseDir, `agent-${a.agentId}`)), baseRef);
1911
- } catch (e) {
1912
- fail(`could not create worktree for "${a.name}": ${e.message}`);
1913
- continue;
1914
- }
1915
- try {
1916
- materializeInto(wt); // synced env into the fresh worktree
1917
- } catch {
1918
- /* best-effort */
1919
- }
1920
- }
1921
- const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
1922
- const label = colorFn(`[${a.name}]`);
1923
- const state = { alive: true, child: null };
1924
- ok(`${label} ${c.dim(LIVE ? 'online — live session' : 'online — worktree ready')}`);
1925
- const workerFn = LIVE ? runLiveWorker : runFleetWorker;
1926
- const promise = workerFn({
1927
- agentId: a.agentId,
1928
- label,
1929
- ...(LIVE ? { worktreeFor } : { cwd: wt }),
1930
- baseRef,
1931
- repoRoot, // for copying the repo's local env into the preview worktree
1932
-
1933
- getToken: (id) => tokenByAgent.get(id),
1934
- getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
1935
- getNext: (id) => nextByAgent.get(id) ?? null,
1936
- getMcpUrl: () => mcpUrl,
1937
- // Injected rather than imported: fleet.mjs imports live.mjs, so live
1938
- // cannot import back. The live worker is the DEFAULT one, and until
1939
- // this was passed down the whole run-diffstat pipeline was reachable
1940
- // only under FLOWVIANT_POLL=1 — the app's live-changes panel had no
1941
- // data source at all for the path everybody actually runs.
1942
- sampleDiffstat,
1943
- isAlive: () => state.alive,
1944
- onChild: (ch) => {
1945
- state.child = ch;
1946
- },
1947
- // Which task this lane is holding, so per-process memory can be
1948
- // attributed to a task rather than to an anonymous pid. "The box is
1949
- // full" is not actionable; "this task is holding 9GB" is.
1950
- onIntent: (id) => {
1951
- state.intentId = id;
1952
- },
1953
- // Hold the preview's stop fn so teardown/removal can kill the detached
1954
- // dev-server + tunnel (they survive our exit otherwise).
1955
- onPreview: (stop) => {
1956
- state.stopPreview = stop;
1957
- },
1958
- // A turn that couldn't reach the MCP server: forget the cached token so
1959
- // the next reconcile poll re-mints a fresh one (self-heals a token that
1960
- // was rotated/expired out from under a running session).
1961
- onTokenSuspect: (id) => {
1962
- tokenByAgent.delete(id);
1963
- mintedAt.delete(id);
1964
- },
1965
- });
1966
- workers.set(a.agentId, { state, promise, wt, label });
1967
- }
1968
- }
1969
1209
 
1970
1210
  // Living-wiki work (runs under its own minted wiki token — no agent
1971
1211
  // needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged