flowviant 0.34.2 → 0.35.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.
@@ -603,13 +603,20 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
603
603
  if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
604
604
  // readOnly wins over wikiPerm: a consult must never inherit write tools.
605
605
  args.push(...(readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM));
606
- // Force the user's Claude Code subscription never the API. A key exported in
607
- // the shell would otherwise silently bill every poll-mode turn as raw API
608
- // usage (same invariant live mode enforces on its SDK session env).
609
- const env = { ...process.env };
610
- delete env.ANTHROPIC_API_KEY;
611
- delete env.ANTHROPIC_AUTH_TOKEN;
612
- const child = spawn('claude', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
606
+ // Whatever this machine is signed in with, we use. We do NOT pick.
607
+ //
608
+ // This used to delete ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN to force
609
+ // the subscription path, which was right when the daemon ran on a
610
+ // developer's laptop: a key left in their shell would silently bill every
611
+ // turn as raw API usage instead of the plan they were already paying for.
612
+ // On a machine the project leaves running, an inherited org key is the
613
+ // POINT — deleting it is the daemon overriding the credential its operator
614
+ // deliberately configured.
615
+ //
616
+ // Which credential is correct, and whether an account may be shared, is
617
+ // between the operator and Anthropic. Flowviant does not detect it and does
618
+ // not enforce it; it runs Claude the ordinary way and relays what happens.
619
+ const child = spawn('claude', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
613
620
  onSpawn?.(child);
614
621
  let out = '';
615
622
  const pfx = label ? `${label} ` : '';
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { homedir, cpus } from 'node:os';
6
+ import { homedir, cpus, totalmem } from 'node:os';
7
7
 
8
8
  // Read the daemon's version from its OWN package.json (always shipped in the npm
9
9
  // tarball) — never hardcode it. The hardcoded constant drifted: it sat at
@@ -59,30 +59,74 @@ export const STREAM_URL =
59
59
  FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
60
60
  export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
61
61
 
62
+ /**
63
+ * What this machine can actually be given, read from the machine.
64
+ *
65
+ * `os.totalmem()` and `cpus().length` report the HOST inside a container: a
66
+ * 4GB container on a 256GB box reads 256GB and cheerfully oversubscribes until
67
+ * the OOM killer picks a victim — which, because it picks by resident size, is
68
+ * frequently not the task that caused it. cgroup v2 publishes the real limits,
69
+ * so read those first and treat the os module as the fallback it is.
70
+ */
71
+ function machineLimits() {
72
+ const readCgroup = (f) => {
73
+ try {
74
+ return readFileSync(`/sys/fs/cgroup/${f}`, 'utf8').trim();
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+ let memBytes = totalmem();
80
+ const memMax = readCgroup('memory.max');
81
+ if (memMax && memMax !== 'max') {
82
+ const n = Number(memMax);
83
+ if (Number.isFinite(n) && n > 0) memBytes = Math.min(memBytes, n);
84
+ }
85
+ let cores = cpus().length || 2;
86
+ // "<quota> <period>" in microseconds, or "max <period>" for unlimited.
87
+ const cpuMax = readCgroup('cpu.max');
88
+ if (cpuMax && !cpuMax.startsWith('max')) {
89
+ const [q, p] = cpuMax.split(/\s+/).map(Number);
90
+ if (Number.isFinite(q) && Number.isFinite(p) && p > 0) {
91
+ cores = Math.max(1, Math.min(cores, Math.floor(q / p)));
92
+ }
93
+ }
94
+ return { memBytes, cores };
95
+ }
96
+
97
+ export const MACHINE = machineLimits();
98
+
62
99
  /**
63
100
  * How many tasks THIS MACHINE will build at once.
64
101
  *
65
102
  * The limit belongs here, not on the server: a task in flight is a Claude Code
66
103
  * session plus its own git worktree plus whatever the project's dev server and
67
104
  * tests want, and this process is the only party that can see the cores, the
68
- * RAM and the fan. The server used to decide it, indirectly, by how many lanes
69
- * a user had pre-sized with a dial — which asked them to answer a question
70
- * about their laptop in a web app, before they knew what they were going to
71
- * dispatch.
105
+ * RAM and the fan.
72
106
  *
73
107
  * Sent to the server on every roster poll so it can grow lanes to meet waiting
74
108
  * work UNDER this ceiling, and enforced locally besides — the roster can carry
75
- * more lanes than this (someone added capacity by hand, or a second machine
76
- * shares the fleet), and a ceiling that only exists as a request is not one.
109
+ * more lanes than this, and a ceiling that only exists as a request is not one.
110
+ *
111
+ * MEMORY is the bound, not cores. Cores oversubscribe gracefully (everything
112
+ * gets slower); memory does not (something dies, and not necessarily the
113
+ * offender). A task is a Claude session plus a dev server plus whatever the
114
+ * test runner spawns — call it 2GB, keep 2GB back for the operating system,
115
+ * and let cores cap it only when they are the scarcer thing.
77
116
  *
78
- * Half the cores, floor 1, cap 4. Half because a build agent is not the only
79
- * thing running — the user is working on this machine too and 4 because past
80
- * that the shared Claude account, not the CPU, is what runs out.
117
+ * This used to be `min(4, cores/2)` half the cores because "the user is
118
+ * working on this machine too", and a hard 4 because a personal Claude plan
119
+ * ran out before the CPU did. Both were laptop assumptions. The machine is now
120
+ * a box the project leaves running, so it reserves one core rather than half of
121
+ * them, and the ceiling is what the hardware can hold rather than a guess about
122
+ * somebody's plan.
81
123
  */
82
124
  export const MAX_CONCURRENT = (() => {
83
125
  const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
84
126
  if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
85
- return Math.max(1, Math.min(4, Math.floor((cpus().length || 2) / 2)));
127
+ const byMem = Math.floor((MACHINE.memBytes / 2 ** 30 - 2) / 2);
128
+ const byCpu = MACHINE.cores - 1;
129
+ return Math.max(1, Math.min(32, byMem, byCpu));
86
130
  })();
87
131
  export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
88
132
  // Live mode: after this long idle-parked on a blocker, tear the session down to
package/bin/lib/fleet.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * MCP token, and only spawns Claude when the server says an agent has work.
6
6
  */
7
7
 
8
- import { mkdirSync, existsSync, rmSync } from 'node:fs';
8
+ import { mkdirSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
9
9
  import { execFileSync } from 'node:child_process';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { homedir } from 'node:os';
@@ -30,6 +30,7 @@ import { handleVersionSignal } from './update.mjs';
30
30
  import {
31
31
  git,
32
32
  resetWorktree,
33
+ ensureWorktree,
33
34
  repoRootOrDie,
34
35
  detectBaseRef,
35
36
  originSlug,
@@ -73,6 +74,7 @@ import {
73
74
  scrub as envScrub,
74
75
  } from './env.mjs';
75
76
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
77
+ import { machineSnapshot } from './resources.mjs';
76
78
 
77
79
  async function fetchRoster(haveIds) {
78
80
  const url = new URL(FLEET_URL);
@@ -227,11 +229,31 @@ export async function runFleetDaemon() {
227
229
  reapOrphanPreviews((m) => info(m));
228
230
 
229
231
  // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
230
- // so Ctrl+C mid-task never loses local work. Keyed per repo path; each
231
- // agent's worktree carries a task marker so a resumed claim keeps its files.
232
+ // so Ctrl+C mid-task never loses local work. Keyed per repo path.
232
233
  const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
233
234
  const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
234
235
  mkdirSync(baseDir, { recursive: true });
236
+
237
+ // ONE CHECKOUT PER TASK, named after the task. Worktrees used to be
238
+ // `agent-<agentId>` — a long-lived tree per lane, reset to base between
239
+ // tasks — and that was the last thing a lane owned. Now a lane is a
240
+ // credential and nothing more, which is what makes it disposable: the server
241
+ // can hand any lane any task, and two tasks can never be in each other's
242
+ // files even when one is mid-edit.
243
+ const taskWorktreePath = (intentId) => join(baseDir, `task-${intentId}`);
244
+ const worktreeFor = (intentId) => {
245
+ const r = ensureWorktree(repoRoot, taskWorktreePath(intentId), baseRef);
246
+ // Only on creation: a resumed tree already has its env, and rewriting it
247
+ // mid-task would clobber anything the agent changed.
248
+ if (r.fresh) {
249
+ try {
250
+ materializeInto(r.path);
251
+ } catch {
252
+ /* best-effort — the task still builds, secrets-backed paths may 500 */
253
+ }
254
+ }
255
+ return r;
256
+ };
235
257
  try {
236
258
  const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
237
259
  if (kb > 1024)
@@ -241,6 +263,38 @@ export async function runFleetDaemon() {
241
263
  } catch {
242
264
  /* du unavailable (Windows) — skip the disk line */
243
265
  }
266
+
267
+ // Reap long-dead task checkouts. Per-lane trees were self-limiting — N lanes,
268
+ // N directories, reused forever. Per-task trees are not: every task ever
269
+ // built leaves one behind, so without this the disk grows without bound and
270
+ // `flowviant clean` becomes a chore rather than a convenience.
271
+ //
272
+ // Age, not state, is the test. The daemon has no list of which intents are
273
+ // still open, and asking the server for one would put a delete behind a
274
+ // network call that can fail — so anything untouched for a fortnight goes,
275
+ // which is far beyond how long a task stays reviewable and far beyond any
276
+ // pause a human takes mid-build. Runs at startup only: mid-run this would
277
+ // race a worker that is quietly parked on a blocker.
278
+ try {
279
+ const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
280
+ let reaped = 0;
281
+ for (const name of readdirSync(baseDir)) {
282
+ if (!name.startsWith('task-')) continue;
283
+ const p = join(baseDir, name);
284
+ try {
285
+ if (statSync(p).mtimeMs > cutoff) continue;
286
+ // Through git, so the worktree REGISTRATION goes too — an rm -rf leaves
287
+ // a stale entry that blocks re-adding the same path later.
288
+ git(['worktree', 'remove', '--force', p], repoRoot);
289
+ reaped++;
290
+ } catch {
291
+ /* held, gone, or not ours — leave it for `flowviant clean` */
292
+ }
293
+ }
294
+ if (reaped) info(`disk · reclaimed ${reaped} task worktree${reaped === 1 ? '' : 's'} idle > 14d`);
295
+ } catch {
296
+ /* the worktree home may not exist yet on a first run */
297
+ }
244
298
  const tokenByAgent = new Map(); // agentId -> latest worker token
245
299
  const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
246
300
  const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
@@ -486,6 +540,8 @@ export async function runFleetDaemon() {
486
540
  };
487
541
 
488
542
  const PLAN_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-check-done');
543
+ // Machine telemetry — what the box is doing with itself, for the admin view.
544
+ const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
489
545
  const checkingPlans = new Set();
490
546
  const processPlanCheckJobs = (jobs) => {
491
547
  for (const job of jobs ?? []) {
@@ -561,16 +617,17 @@ export async function runFleetDaemon() {
561
617
  let joinChain = Promise.resolve();
562
618
 
563
619
  /** The worktree currently building this intent, or null if this machine isn't.
564
- * The task marker lives in the worktree's git dir and is what a resume already
565
- * uses to recognise its own half-built tree, so it is the honest answer to
566
- * "where is this task actually being built". */
620
+ * Now a direct lookup rather than a scan: a task's checkout is named after
621
+ * the task, so there is exactly one place it could be. The marker is still
622
+ * consulted, but for LIFECYCLE rather than identity — a directory that
623
+ * outlived its run (finished, cleared its marker, kept for the review
624
+ * preview) exists but is not building anything, and must not take an edit. */
567
625
  const worktreeBuilding = (intentId) => {
568
- for (const w of workers.values()) {
569
- try {
570
- if (w.wt && readTaskMarker(w.wt) === intentId) return w;
571
- } catch {
572
- /* a worktree that vanished isn't building anything */
573
- }
626
+ const wt = taskWorktreePath(intentId);
627
+ try {
628
+ if (existsSync(wt) && readTaskMarker(wt) === intentId) return { wt };
629
+ } catch {
630
+ /* a worktree that vanished isn't building anything */
574
631
  }
575
632
  return null;
576
633
  };
@@ -1320,36 +1377,33 @@ export async function runFleetDaemon() {
1320
1377
  }
1321
1378
  continue;
1322
1379
  }
1323
- const wt = join(baseDir, `agent-${a.agentId}`);
1324
- try {
1325
- if (!existsSync(wt)) {
1326
- try {
1327
- git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
1328
- } catch {
1329
- // A stale registration (e.g. after `flowviant clean` rm'd the
1330
- // dir) blocks re-adding the same path — prune and retry once.
1331
- git(['worktree', 'prune'], repoRoot);
1332
- git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
1333
- }
1380
+ // LIVE lanes get NO checkout of their own — they ask for one per task,
1381
+ // once they know which task. Poll mode is the legacy escape hatch and
1382
+ // keeps its per-lane tree; it predates per-task sandboxes and isn't
1383
+ // worth restructuring for a path nobody runs by default.
1384
+ let wt = null;
1385
+ if (!LIVE) {
1386
+ try {
1387
+ ensureWorktree(repoRoot, (wt = join(baseDir, `agent-${a.agentId}`)), baseRef);
1388
+ } catch (e) {
1389
+ fail(`could not create worktree for "${a.name}": ${e.message}`);
1390
+ continue;
1391
+ }
1392
+ try {
1393
+ materializeInto(wt); // synced env into the fresh worktree
1394
+ } catch {
1395
+ /* best-effort */
1334
1396
  }
1335
- } catch (e) {
1336
- fail(`could not create worktree for "${a.name}": ${e.message}`);
1337
- continue;
1338
- }
1339
- try {
1340
- materializeInto(wt); // synced env into the fresh worktree
1341
- } catch {
1342
- /* best-effort */
1343
1397
  }
1344
1398
  const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
1345
1399
  const label = colorFn(`[${a.name}]`);
1346
1400
  const state = { alive: true, child: null };
1347
- ok(`${label} ${c.dim(`online — worktree ready${LIVE ? ' · live session' : ''}`)}`);
1401
+ ok(`${label} ${c.dim(LIVE ? 'online live session' : 'online — worktree ready')}`);
1348
1402
  const workerFn = LIVE ? runLiveWorker : runFleetWorker;
1349
1403
  const promise = workerFn({
1350
1404
  agentId: a.agentId,
1351
1405
  label,
1352
- cwd: wt,
1406
+ ...(LIVE ? { worktreeFor } : { cwd: wt }),
1353
1407
  baseRef,
1354
1408
  repoRoot, // for copying the repo's local env into the preview worktree
1355
1409
 
@@ -1360,6 +1414,12 @@ export async function runFleetDaemon() {
1360
1414
  onChild: (ch) => {
1361
1415
  state.child = ch;
1362
1416
  },
1417
+ // Which task this lane is holding, so per-process memory can be
1418
+ // attributed to a task rather than to an anonymous pid. "The box is
1419
+ // full" is not actionable; "this task is holding 9GB" is.
1420
+ onIntent: (id) => {
1421
+ state.intentId = id;
1422
+ },
1363
1423
  // Hold the preview's stop fn so teardown/removal can kill the detached
1364
1424
  // dev-server + tunnel (they survive our exit otherwise).
1365
1425
  onPreview: (stop) => {
@@ -1404,6 +1464,27 @@ export async function runFleetDaemon() {
1404
1464
  }
1405
1465
  });
1406
1466
 
1467
+ // Tell the app what this machine is doing with itself. Every reconcile,
1468
+ // best-effort, and never awaited — telemetry that can delay a dispatch is
1469
+ // worse than no telemetry.
1470
+ //
1471
+ // The one thing Flowviant could never answer about a task was "why is it
1472
+ // slow", because the box was somebody's laptop and only they could look at
1473
+ // it. Centralising is supposed to make one machine easier to manage than N
1474
+ // laptops; that is only true if the machine is visible. Per-task RSS is the
1475
+ // load-bearing part — "the box is full" is not actionable, "this task is
1476
+ // holding 9GB" is.
1477
+ void reportMergeOutcome(
1478
+ MACHINE_URL,
1479
+ machineSnapshot({
1480
+ worktreeDir: baseDir,
1481
+ tasks: [...workers].map(([, w]) => ({
1482
+ intentId: w.state.intentId ?? null,
1483
+ pid: w.state.child?.pid,
1484
+ })),
1485
+ })
1486
+ );
1487
+
1407
1488
  // Deploy: a deploy-authorized daemon reports its .flowviant/deploy.json and
1408
1489
  // runs queued deploy jobs (the server only sends deployJobs to authorized
1409
1490
  // machines). Config report is cheap + dedup'd; jobs are single-flight.
@@ -1415,7 +1496,7 @@ export async function runFleetDaemon() {
1415
1496
  // Stop workers whose agent left the roster (removed in the app).
1416
1497
  for (const [id, w] of [...workers]) {
1417
1498
  if (!rosterIds.has(id)) {
1418
- warn(`${w.label} removed — stopping it now, freeing its worktree.`);
1499
+ warn(`${w.label} removed — stopping it now.`);
1419
1500
  w.state.alive = false;
1420
1501
  // Immediate teardown (Q6=B): kill the in-flight Claude process now; its
1421
1502
  // task was already requeued server-side on removal.
@@ -1429,10 +1510,16 @@ export async function runFleetDaemon() {
1429
1510
  } catch {
1430
1511
  /* best-effort */
1431
1512
  }
1432
- try {
1433
- git(['worktree', 'remove', '--force', w.wt], repoRoot);
1434
- } catch {
1435
- /* best-effort */
1513
+ // Only poll mode's per-lane tree dies with the lane. A live lane owns no
1514
+ // checkout: the task it was building has its own, which must SURVIVE —
1515
+ // removing a lane requeues its task, and the next lane to pick that task
1516
+ // up resumes in that same directory rather than starting over.
1517
+ if (w.wt) {
1518
+ try {
1519
+ git(['worktree', 'remove', '--force', w.wt], repoRoot);
1520
+ } catch {
1521
+ /* best-effort */
1522
+ }
1436
1523
  }
1437
1524
  workers.delete(id);
1438
1525
  tokenByAgent.delete(id);
package/bin/lib/git.mjs CHANGED
@@ -1,6 +1,10 @@
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';
5
+ import { resolve, join } from 'node:path';
6
+ import { rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
4
8
 
5
9
  export function git(args, cwd) {
6
10
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -113,6 +117,149 @@ export function baseBranchName(baseRef) {
113
117
  return String(baseRef || '').replace(/^origin\//, '') || 'main';
114
118
  }
115
119
 
120
+ /**
121
+ * Get a detached worktree at `wt`, creating it at `ref` if it isn't there.
122
+ *
123
+ * Returns `{ path, fresh }` — `fresh` is the whole point. A worktree that
124
+ * ALREADY existed is one somebody was mid-way through, and the caller must not
125
+ * reset it; a freshly created one is at base by construction and has nothing to
126
+ * preserve. That single bit replaces the in-memory `resuming` flag and the
127
+ * on-disk task marker for the common case, because once a worktree is named
128
+ * after its task, "does this directory exist" IS "am I resuming".
129
+ *
130
+ * The prune-and-retry is not paranoia: `git worktree add` refuses a path that
131
+ * is still REGISTERED even when the directory is gone (`flowviant clean` rm's
132
+ * the dirs, `git worktree list` keeps the stale entries), and that failure is
133
+ * permanent until pruned.
134
+ */
135
+ export function ensureWorktree(repoRoot, wt, ref) {
136
+ // Resolve first. `existsSync` answers relative to THIS process's cwd while
137
+ // `git worktree add` answers relative to repoRoot, so a relative path makes
138
+ // the two disagree: the check says "not there", the add says "already
139
+ // exists", and the prune-and-retry can't fix a path that was never the one
140
+ // we looked at. Callers pass absolute paths today; this makes that not matter.
141
+ wt = resolve(wt);
142
+ if (existsSync(wt)) return { path: wt, fresh: false };
143
+ try {
144
+ git(['worktree', 'add', '--detach', wt, ref], repoRoot);
145
+ } catch {
146
+ git(['worktree', 'prune'], repoRoot);
147
+ git(['worktree', 'add', '--detach', wt, ref], repoRoot);
148
+ }
149
+ return { path: wt, fresh: true };
150
+ }
151
+
152
+ // ── WIP checkpoints: the sandbox's state, on the remote ────────────────────
153
+ //
154
+ // A task's uncommitted work used to exist in exactly one place — a directory on
155
+ // whichever machine claimed it. That made the checkout precious: losing the box
156
+ // lost the work, so a task was pinned to a host, the host had to be named in the
157
+ // UI, and a container could never be thrown away. Pushing the work somewhere
158
+ // durable inverts all of that. The sandbox becomes a cache.
159
+ //
160
+ // These snapshots go to `refs/flowviant-wip/<intentId>`, NOT to a branch: they
161
+ // are machine state, not history, and they must never appear in a PR, a branch
162
+ // listing, or anyone's `git log`. Force-pushed, because only the latest matters.
163
+
164
+ const wipRef = (intentId) => `refs/flowviant-wip/${intentId}`;
165
+
166
+ /** git, with extra environment — for GIT_INDEX_FILE and a committer identity we
167
+ * can't assume the machine has configured. */
168
+ function gitWithEnv(args, cwd, extraEnv) {
169
+ return execFileSync('git', args, {
170
+ cwd,
171
+ encoding: 'utf8',
172
+ stdio: ['ignore', 'pipe', 'pipe'],
173
+ env: { ...process.env, ...extraEnv },
174
+ }).trim();
175
+ }
176
+
177
+ /**
178
+ * Snapshot everything in the worktree — staged, unstaged and untracked — and
179
+ * push it, WITHOUT touching the agent's HEAD, index or files.
180
+ *
181
+ * That constraint is why this doesn't just commit. The agent is a live process
182
+ * with its own git intentions; committing under it would rewrite state it is
183
+ * mid-way through reasoning about, and `git stash` would rip the files out from
184
+ * under an editor. Building the tree in a throwaway index leaves the agent's
185
+ * world untouched — it cannot tell this happened.
186
+ *
187
+ * Returns the commit sha, or null if there was nothing dirty / no remote.
188
+ */
189
+ export function checkpointWip(wt, intentId, baseRef) {
190
+ if (!isSafePathSegment(intentId)) return null;
191
+ const idx = join(tmpdir(), `flowviant-idx-${intentId}-${process.pid}`);
192
+ const env = {
193
+ GIT_INDEX_FILE: idx,
194
+ // A snapshot must never fail because the machine has no user.name — this is
195
+ // ours, not the user's, and it never lands in history anyone reads.
196
+ GIT_AUTHOR_NAME: 'Flowviant',
197
+ GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
198
+ GIT_COMMITTER_NAME: 'Flowviant',
199
+ GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
200
+ };
201
+ try {
202
+ const head = git(['rev-parse', 'HEAD'], wt);
203
+ gitWithEnv(['read-tree', head], wt, env);
204
+ gitWithEnv(['add', '-A'], wt, env);
205
+ const tree = gitWithEnv(['write-tree'], wt, env);
206
+ // Nothing changed since HEAD — no snapshot worth pushing.
207
+ if (tree === git(['rev-parse', `${head}^{tree}`], wt)) return null;
208
+ const commit = gitWithEnv(
209
+ ['commit-tree', tree, '-p', head, '-m', `flowviant wip ${intentId}`],
210
+ wt,
211
+ env
212
+ );
213
+ git(['push', '--force', 'origin', `${commit}:${wipRef(intentId)}`], wt);
214
+ return commit;
215
+ } catch {
216
+ // Offline, no push rights, a repo with no origin — a checkpoint is an
217
+ // optimisation, never a reason to fail a task.
218
+ return null;
219
+ } finally {
220
+ try {
221
+ rmSync(idx, { force: true });
222
+ } catch {
223
+ /* best-effort */
224
+ }
225
+ void baseRef;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Rebuild a worktree from its last pushed checkpoint. Returns true if one was
231
+ * found and applied.
232
+ *
233
+ * The reset is MIXED on purpose: it leaves the snapshot's content in the files
234
+ * with HEAD back at the parent, which is what the agent had before — dirty
235
+ * working tree, nothing staged it didn't stage itself. A soft reset would hand
236
+ * it a fully-staged index it never created.
237
+ */
238
+ export function restoreWip(wt, intentId) {
239
+ if (!isSafePathSegment(intentId)) return false;
240
+ const ref = wipRef(intentId);
241
+ try {
242
+ git(['fetch', 'origin', `+${ref}:${ref}`], wt);
243
+ const commit = git(['rev-parse', ref], wt);
244
+ const parent = git(['rev-parse', `${commit}^`], wt);
245
+ git(['checkout', '--detach', commit], wt);
246
+ git(['reset', parent], wt);
247
+ return true;
248
+ } catch {
249
+ return false; // no checkpoint for this task, or it's unreachable
250
+ }
251
+ }
252
+
253
+ /** Drop a task's checkpoint once its work has landed somewhere real. */
254
+ export function clearWip(wt, intentId) {
255
+ if (!isSafePathSegment(intentId)) return;
256
+ try {
257
+ git(['push', 'origin', '--delete', wipRef(intentId)], wt);
258
+ } catch {
259
+ /* already gone, or no remote */
260
+ }
261
+ }
262
+
116
263
  export function resetWorktree(wt, baseRef) {
117
264
  try {
118
265
  git(['fetch', 'origin', '--quiet'], wt);
package/bin/lib/live.mjs CHANGED
@@ -7,9 +7,11 @@
7
7
  * the human's answer and injects it to resume in place). Same session = the
8
8
  * iterating loop, hosted through Flowviant.
9
9
  *
10
- * Auth invariant: the SDK runs the user's own Claude Code (subscription), never
11
- * the API — we strip ANTHROPIC_API_KEY from the session env so a key in the
12
- * user's shell can't silently divert to API billing.
10
+ * Auth: whatever this machine's Claude Code is signed in with. The daemon used
11
+ * to strip ANTHROPIC_API_KEY so a stray key couldn't divert a laptop's turns to
12
+ * API billing; on a machine the project leaves running an org key is the
13
+ * intended credential, and which one is legitimate is between the operator and
14
+ * Anthropic — not something Flowviant detects or enforces.
13
15
  *
14
16
  * NOTE: the SDK mechanics here (streaming-input continuity, tool_use visibility,
15
17
  * one result per turn) are validated by spikes; the end-to-end task loop needs a
@@ -33,7 +35,14 @@ import {
33
35
  } from './config.mjs';
34
36
  import { c, info, ok, warn } from './ui.mjs';
35
37
  import { sleep } from './claude.mjs';
36
- import { git, resetWorktree, isValidBranch } from './git.mjs';
38
+ import {
39
+ git,
40
+ resetWorktree,
41
+ isValidBranch,
42
+ checkpointWip,
43
+ restoreWip,
44
+ clearWip,
45
+ } from './git.mjs';
37
46
  import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
38
47
  import { loadPreviewConfig, startPreview } from './preview.mjs';
39
48
  import { materializeInto, scrub as envScrub } from './env.mjs';
@@ -48,6 +57,11 @@ const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
48
57
  // missed heartbeats.
49
58
  const PREVIEW_TTL_MINUTES = 6;
50
59
  const PREVIEW_HEARTBEAT_MS = 90_000;
60
+ // How often a running task snapshots its uncommitted work to the remote. Two
61
+ // minutes bounds what an unannounced death can cost while staying invisible:
62
+ // an unchanged tree writes no commit and pushes nothing, so an agent that is
63
+ // thinking rather than editing costs one cheap tree comparison.
64
+ const CHECKPOINT_MS = 120_000;
51
65
  async function registerLiveTarget(intentId, kind, url) {
52
66
  try {
53
67
  await fetch(LIVE_TARGET_URL, {
@@ -544,12 +558,13 @@ async function landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchB
544
558
  export async function runLiveTask({
545
559
  mcpUrl,
546
560
  token,
547
- cwd,
561
+ worktreeFor,
548
562
  baseRef,
549
563
  repoRoot,
550
564
  isAlive,
551
565
  resumeIntentId,
552
566
  onChild,
567
+ onIntent,
553
568
  }) {
554
569
  const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
555
570
  if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
@@ -557,12 +572,29 @@ export async function runLiveTask({
557
572
  const brief = claim.brief ?? {};
558
573
  const title = brief.title ?? 'a task';
559
574
 
575
+ // THE SANDBOX BELONGS TO THE TASK, not to the lane that happened to pick it
576
+ // up. Worktrees used to be `agent-<agentId>` — one long-lived checkout per
577
+ // lane, wiped back to base between tasks — which is why a lane had to own
578
+ // anything at all, and why every claim had to work out whether the directory
579
+ // it was standing in held its own half-built work or somebody else's finished
580
+ // work. Keyed by intent, that question answers itself: the directory either
581
+ // exists (yours, mid-flight) or it doesn't (nothing to lose).
582
+ //
583
+ // Note this is the first point at which a worktree can be chosen — the claim
584
+ // is what tells us which task we're building, and the daemon has no business
585
+ // creating a checkout for work it hasn't been given.
586
+ // Name the task this lane is holding, so its memory can be attributed to a
587
+ // task rather than to an anonymous pid.
588
+ onIntent?.(intentId);
589
+ const { path: cwd, fresh: freshTree } = worktreeFor(intentId);
590
+
560
591
  // Re-claiming the SAME intent this worker was just working — either this
561
- // daemon's own memory (parked on a blocker, now resuming) or the persistent
562
- // task marker (the daemon restarted mid-task). Its worktree holds hours of
563
- // uncommitted work. Do NOT reset.
592
+ // daemon's own memory (parked on a blocker, now resuming) or a worktree that
593
+ // was already on disk (the daemon restarted mid-task). Either way it holds
594
+ // hours of uncommitted work. Do NOT reset. `!fresh` subsumes what the task
595
+ // marker used to tell us, since the directory is now named after the task.
564
596
  const resuming = !!resumeIntentId && intentId === resumeIntentId;
565
- const resumedInPlace = !resuming && readTaskMarker(cwd) === intentId;
597
+ const resumedInPlace = !resuming && !freshTree;
566
598
 
567
599
  // CONSENT. A patch writes commits into the working checkout of whoever runs
568
600
  // this daemon — chosen by a model, and triggerable by any teammate who
@@ -656,6 +688,20 @@ export async function runLiveTask({
656
688
  }
657
689
  if (!stacked) resetWorktree(cwd, baseRef);
658
690
  }
691
+ // A fresh checkout is not necessarily a fresh TASK. This machine may never
692
+ // have seen this intent while another one built on it for an hour before
693
+ // dying, being released, or simply being a different container — and that
694
+ // work is on the remote. Restoring here, AFTER the resets above, is what
695
+ // makes a sandbox a cache rather than the only copy: any machine can pick up
696
+ // any task exactly where it was left.
697
+ if (freshTree && restoreWip(cwd, intentId)) {
698
+ info(`${c.dim('restored work in progress from the last checkpoint')}`);
699
+ await mcpCall(mcpUrl, token, 'stream_turn', {
700
+ runId,
701
+ turnId: `restore:${runId}`,
702
+ text: 'Picked this up on another machine — restored the work in progress from its last checkpoint.',
703
+ }).catch(() => {});
704
+ }
659
705
  materializeInto(cwd); // resets wipe the synced env files — rewrite them
660
706
  writeTaskMarker(cwd, intentId);
661
707
 
@@ -669,14 +715,11 @@ export async function runLiveTask({
669
715
  }).catch(() => {});
670
716
  }
671
717
 
718
+ // The machine's own credentials, inherited as configured. See claude.mjs for
719
+ // why this no longer strips ANTHROPIC_API_KEY / AUTH_TOKEN / BASE_URL: on a
720
+ // machine the project leaves running, an org key is the intended credential
721
+ // and deleting it overrides the operator. Enforcement is Anthropic's.
672
722
  const env = { ...process.env };
673
- // Force the user's Claude Code subscription — strip EVERY var that could
674
- // divert to API billing or a proxy (poll mode strips these too; live mode
675
- // was only clearing API_KEY, so an exported AUTH_TOKEN/BASE_URL silently
676
- // billed the API on the default path).
677
- delete env.ANTHROPIC_API_KEY;
678
- delete env.ANTHROPIC_AUTH_TOKEN;
679
- delete env.ANTHROPIC_BASE_URL;
680
723
 
681
724
  // The conversation arrives WITH the brief (0.30.0) — the claim already read
682
725
  // it, so asking again over poll_channel was a round-trip that told us nothing
@@ -695,6 +738,21 @@ export async function runLiveTask({
695
738
  let afterId =
696
739
  brief.lastMessageId ?? (priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null);
697
740
 
741
+ // Checkpoint on a timer for the whole session. Not on turn boundaries: the
742
+ // expensive-to-lose states are the ones that arrive without a boundary — the
743
+ // box is killed, the container is reclaimed, the process is OOMed — and a
744
+ // long tool-running turn is exactly when the most uncommitted work exists.
745
+ // Cheap when idle: an unchanged tree writes no commit and pushes nothing.
746
+ let landed = false; // set when the work reaches a branch/PR/patch — see finally
747
+ const checkpointTimer = setInterval(() => {
748
+ try {
749
+ checkpointWip(cwd, intentId);
750
+ } catch {
751
+ /* never let a snapshot disturb a running task */
752
+ }
753
+ }, CHECKPOINT_MS);
754
+ checkpointTimer.unref?.();
755
+
698
756
  const input = makeInput(seedPrompt(runId, brief, transcript, resumedInPlace));
699
757
  const session = query({
700
758
  prompt: input.stream(),
@@ -814,6 +872,7 @@ export async function runLiveTask({
814
872
  if (isPatch) {
815
873
  await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
816
874
  }
875
+ landed = true;
817
876
  return { outcome: 'done', title, intentId };
818
877
  }
819
878
 
@@ -838,12 +897,27 @@ export async function runLiveTask({
838
897
  }).catch(() => null);
839
898
  // Torn down out from under us (restart / reassign in Flowviant): the
840
899
  // server killed this run — abandon the session, don't keep building.
900
+ // RELEASED: stop, and touch nothing. The human freed the machine, not
901
+ // the work — the branch, the PR and the worktree all stay exactly as
902
+ // they are, and re-@mentioning resumes here rather than from base. The
903
+ // finally block takes a last checkpoint on the way out, so even the
904
+ // uncommitted edits survive to whichever machine picks it up next.
905
+ if (poll && poll.ok === false && poll.released) {
906
+ return { outcome: 'released', title, intentId };
907
+ }
841
908
  if (poll && poll.ok === false && poll.reason === 'run_not_active') {
842
- // Discarded (restart/reassign): clear the marker AND reset the
843
- // worktree so the next fresh claim starts from clean base, never
844
- // resuming the abandoned attempt.
909
+ // Discarded (restart/reassign). REMOVE the checkout rather than reset
910
+ // it: the directory is named after the intent, so a restart of this
911
+ // same task would otherwise find it, read "already exists" as "I am
912
+ // resuming", and pick the abandoned attempt back up — the precise
913
+ // failure the old marker-clearing existed to prevent. Deleting it
914
+ // makes the next claim genuinely fresh.
845
915
  clearTaskMarker(cwd);
846
- resetWorktree(cwd, baseRef);
916
+ try {
917
+ git(['worktree', 'remove', '--force', cwd], repoRoot);
918
+ } catch {
919
+ resetWorktree(cwd, baseRef); // couldn't remove it — at least empty it
920
+ }
847
921
  return { outcome: 'torn_down', title, intentId };
848
922
  }
849
923
  const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
@@ -884,6 +958,7 @@ export async function runLiveTask({
884
958
  return { outcome: 'stalled', title, intentId };
885
959
  }
886
960
  }
961
+ landed = completed;
887
962
  return { outcome: completed ? 'done' : 'stalled', title, intentId };
888
963
  } catch (e) {
889
964
  const rl = classifyRateLimit(e);
@@ -896,7 +971,21 @@ export async function runLiveTask({
896
971
  }
897
972
  return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
898
973
  } finally {
974
+ clearInterval(checkpointTimer);
975
+ // Last word on this task's state. If the work landed (branch pushed, PR
976
+ // open, patch applied) the checkpoint has served its purpose and the ref is
977
+ // deleted — otherwise it accumulates one hidden ref per task, forever, on
978
+ // everyone's remote. If it did NOT land, this is the most important
979
+ // checkpoint of the run: it is the one taken as the task parks, is released,
980
+ // hits a usage limit, or dies.
981
+ try {
982
+ if (landed) clearWip(cwd, intentId);
983
+ else checkpointWip(cwd, intentId);
984
+ } catch {
985
+ /* teardown must not throw */
986
+ }
899
987
  onChild?.(null); // no longer busy — token may rotate between tasks
988
+ onIntent?.(null);
900
989
  input.close();
901
990
  try {
902
991
  await session.interrupt?.();
@@ -943,7 +1032,10 @@ function copyLocalEnvFiles(repoRoot, worktree, log) {
943
1032
  export async function runLiveWorker({
944
1033
  agentId,
945
1034
  label,
946
- cwd,
1035
+ // `(intentId) => { path, fresh }`. A lane no longer HAS a working directory —
1036
+ // it is a credential and nothing else. Every checkout belongs to a task, so
1037
+ // the worker asks for one only once it knows which task it is holding.
1038
+ worktreeFor,
947
1039
  baseRef,
948
1040
  repoRoot,
949
1041
  getToken,
@@ -952,6 +1044,7 @@ export async function runLiveWorker({
952
1044
  isAlive,
953
1045
  onTokenSuspect,
954
1046
  onChild,
1047
+ onIntent,
955
1048
  onPreview,
956
1049
  }) {
957
1050
  // The intent this worker is holding across iterations. When a task parks on a
@@ -1000,6 +1093,12 @@ export async function runLiveWorker({
1000
1093
  };
1001
1094
  const startReviewPreview = async (intentId) => {
1002
1095
  stopPreview();
1096
+ if (!intentId) return;
1097
+ // The finished task's own worktree — already on disk, so this is a lookup,
1098
+ // not a creation. A review preview serves the branch that was just built,
1099
+ // which now has a durable home instead of living in whichever lane's
1100
+ // checkout happened to run it (and being wiped by that lane's next task).
1101
+ const { path: cwd } = worktreeFor(intentId);
1003
1102
  const cfg = loadPreviewConfig(cwd);
1004
1103
  const kind = cfg?.ui ? 'ui' : cfg?.api ? 'api' : null;
1005
1104
  const entry = kind ? cfg[kind] : null;
@@ -1090,9 +1189,10 @@ export async function runLiveWorker({
1090
1189
  let res;
1091
1190
  try {
1092
1191
  res = await runLiveTask({
1192
+ onIntent,
1093
1193
  mcpUrl: getMcpUrl() ?? MCP_URL,
1094
1194
  token,
1095
- cwd,
1195
+ worktreeFor,
1096
1196
  baseRef,
1097
1197
  repoRoot,
1098
1198
  isAlive,
@@ -1133,6 +1233,18 @@ export async function runLiveWorker({
1133
1233
  phase = '';
1134
1234
  continue;
1135
1235
  }
1236
+ if (res.outcome === 'released') {
1237
+ // Released: the human wanted the machine back, not the work undone. The
1238
+ // session is already gone (the finally checkpointed on the way out) and
1239
+ // the worktree stays untouched, so a later @mention resumes here rather
1240
+ // than from base. Clear lastIntentId so this worker doesn't treat a
1241
+ // future claim of the same task as its own in-memory resume — the
1242
+ // on-disk checkout is the resume signal now, and it may well be a
1243
+ // different machine that picks this up.
1244
+ info(`${label} ${c.dim(`"${res.title}" was released — stopped; its work is kept`)}`);
1245
+ phase = '';
1246
+ continue;
1247
+ }
1136
1248
  if (res.outcome === 'parked') {
1137
1249
  // Idle-parked too long on a blocker: we freed the Claude process. The intent
1138
1250
  // stays claimed; a later poll re-claims + resumes (with transcript) once the
@@ -0,0 +1,127 @@
1
+ /**
2
+ * What this machine is actually doing with itself.
3
+ *
4
+ * Flowviant can tell you a task is building and nothing about why it is slow.
5
+ * That gap is affordable when the machine is your own laptop — you can look at
6
+ * it — and not when the machine is a box in a rack that four people share. The
7
+ * whole argument for one central machine is that one machine is easier to
8
+ * manage than N laptops, and that is only true if you can SEE the one machine.
9
+ *
10
+ * This is deliberately telemetry, never a budget. It reports pressure that
11
+ * exists right now, which is the same category as "what is building" — a fact
12
+ * about the world you would have known by sitting at the keyboard. It must
13
+ * never become a headroom number in front of the person dispatching: "you may
14
+ * run 2 more tasks" is the capacity dial wearing a lab coat, and that is dead.
15
+ * The only surface for this is project settings, whose audience is whoever
16
+ * administers the box.
17
+ */
18
+
19
+ import { readFileSync, readdirSync, statfsSync } from 'node:fs';
20
+ import { freemem, loadavg } from 'node:os';
21
+ import { MACHINE } from './config.mjs';
22
+
23
+ const readFile = (p) => {
24
+ try {
25
+ return readFileSync(p, 'utf8').trim();
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Memory in use, from the cgroup when there is one.
33
+ *
34
+ * `freemem()` reports the HOST inside a container — the same trap that made
35
+ * `cpus().length` lie about core count — so a container at 95% of its own
36
+ * limit looks idle if you ask the os module. Prefer memory.current against
37
+ * memory.max, and fall back only when there is no cgroup to read.
38
+ */
39
+ function memoryUsed() {
40
+ const cur = readFile('/sys/fs/cgroup/memory.current');
41
+ if (cur !== null) {
42
+ const n = Number(cur);
43
+ if (Number.isFinite(n) && n > 0) return n;
44
+ }
45
+ return Math.max(0, MACHINE.memBytes - freemem());
46
+ }
47
+
48
+ /**
49
+ * Resident memory of a process AND everything it spawned.
50
+ *
51
+ * A task is never one process: it is Claude, plus a dev server, plus whatever
52
+ * the test runner forked. Charging a task only its own RSS would report a few
53
+ * hundred megabytes for something holding twelve gigabytes, which is worse than
54
+ * reporting nothing — it would exonerate the exact task you are hunting.
55
+ *
56
+ * Linux only, and that is stated rather than hidden: /proc is how you read this
57
+ * honestly, and a wrong number here sends someone to kill the wrong task.
58
+ */
59
+ export function processTreeRssBytes(pid) {
60
+ if (!pid) return null;
61
+ const rssOf = (p) => {
62
+ const roll = readFile(`/proc/${p}/smaps_rollup`);
63
+ const src = roll ?? readFile(`/proc/${p}/status`);
64
+ if (!src) return 0;
65
+ const m = src.match(/^(?:Rss|VmRSS):\s+(\d+)\s+kB/m);
66
+ return m ? Number(m[1]) * 1024 : 0;
67
+ };
68
+ const childrenOf = (p) => {
69
+ const t = readFile(`/proc/${p}/task`);
70
+ if (t === null && !readFile(`/proc/${p}/stat`)) return [];
71
+ const out = [];
72
+ try {
73
+ for (const tid of readdirSync(`/proc/${p}/task`)) {
74
+ const kids = readFile(`/proc/${p}/task/${tid}/children`);
75
+ if (kids) out.push(...kids.split(/\s+/).filter(Boolean).map(Number));
76
+ }
77
+ } catch {
78
+ /* no children file (not Linux, or the process just exited) */
79
+ }
80
+ return out;
81
+ };
82
+ let total = 0;
83
+ const seen = new Set();
84
+ const stack = [Number(pid)];
85
+ while (stack.length) {
86
+ const p = stack.pop();
87
+ if (!Number.isFinite(p) || seen.has(p)) continue;
88
+ seen.add(p);
89
+ total += rssOf(p);
90
+ stack.push(...childrenOf(p));
91
+ }
92
+ return total || null;
93
+ }
94
+
95
+ /** Free bytes on the volume holding the worktrees. */
96
+ export function diskFreeBytes(path) {
97
+ try {
98
+ // statfsSync landed in Node 18.15; the daemon's floor is well past that.
99
+ const s = statfsSync(path);
100
+ return { free: s.bavail * s.bsize, total: s.blocks * s.bsize };
101
+ } catch {
102
+ return null; // no statfs on this platform, or the path is gone
103
+ }
104
+ }
105
+
106
+ /**
107
+ * A snapshot for the roster poll. Small, flat, and all of it observed — nothing
108
+ * here is a prediction or an allowance.
109
+ */
110
+ export function machineSnapshot({ worktreeDir, tasks = [] } = {}) {
111
+ const used = memoryUsed();
112
+ const disk = worktreeDir ? diskFreeBytes(worktreeDir) : null;
113
+ return {
114
+ memTotal: MACHINE.memBytes,
115
+ memUsed: used,
116
+ cores: MACHINE.cores,
117
+ // Unix only; Windows reports zeroes, which we send as null rather than as a
118
+ // very calm-looking 0.00.
119
+ load1: loadavg()[0] || null,
120
+ diskFree: disk?.free ?? null,
121
+ diskTotal: disk?.total ?? null,
122
+ // Per-task, so "the box is full" can be traced to the task that filled it.
123
+ tasks: tasks
124
+ .map((t) => ({ intentId: t.intentId, rss: processTreeRssBytes(t.pid) }))
125
+ .filter((t) => t.intentId && t.rss),
126
+ };
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.34.2",
3
+ "version": "0.35.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {