flowviant 0.35.0 → 0.37.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.
@@ -89,8 +89,18 @@ export const RESUME =
89
89
  'Resume. First call get_blocker_resolution for any blocker you reported; if resolved, ' +
90
90
  'apply the human’s answer and continue. Otherwise keep picking up and completing ' +
91
91
  'the tasks you were @mentioned on, per your instructions.';
92
- export const SINGLE_KICKOFF =
93
- 'Pick up and complete exactly ONE Flowviant task per your instructions, then stop.';
92
+ // `intentId` is the task the SERVER says this lane is next in line for. Naming
93
+ // it matters beyond saving a lookup: the daemon has already spawned this Claude
94
+ // with that task's --model and --effort, and those cannot change once the
95
+ // process exists. Left to pick freely, a lane could claim a sibling task and
96
+ // run it under settings its owner chose for something else. Omitted (older
97
+ // server, or nothing waiting) it falls back to the original free pick.
98
+ export const SINGLE_KICKOFF = (intentId) =>
99
+ intentId
100
+ ? `Pick up Flowviant task ${intentId} — call claim_next_intent with intentId "${intentId}" — ` +
101
+ 'complete exactly that ONE task per your instructions, then stop. If that ' +
102
+ 'claim comes back unavailable, claim whatever is next for you instead.'
103
+ : 'Pick up and complete exactly ONE Flowviant task per your instructions, then stop.';
94
104
  export const SINGLE_RESUME =
95
105
  'Resume your current task. Call get_blocker_resolution for the blocker you reported; ' +
96
106
  'if resolved, apply the human’s answer and finish this one intent, then stop.';
@@ -521,7 +531,10 @@ export function humanizeToolUse(name, input = {}, cwd = '') {
521
531
  case 'Edit': {
522
532
  const p = String(input.file_path ?? '');
523
533
  const tail = p.split('/').slice(-2).join('/');
524
- return { kind: 'write', label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
534
+ // `path` is what lets a caller count DISTINCT pages: a page written once
535
+ // and then edited twice is one page, and the label alone cannot say that
536
+ // (it changes between '+ page' and '~ page' for the same file).
537
+ return { kind: 'write', path: p, label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
525
538
  }
526
539
  case 'Grep':
527
540
  return {
@@ -590,7 +603,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
590
603
  // returned string for sentinel detection, and each activity is handed to
591
604
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
592
605
  // off and keep the raw text passthrough + line sentinels.
593
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity, wikiPerm, readOnly }) {
606
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity, wikiPerm, readOnly, model, effort }) {
594
607
  return new Promise((resolve) => {
595
608
  const args = [];
596
609
  if (resume) args.push('--continue');
@@ -599,7 +612,12 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
599
612
  if (mcpConfig) args.push('--mcp-config', mcpConfig);
600
613
  // Pin the model — never inherit the user's global default (which may be a
601
614
  // 1M/long-context tier their subscription can't bill autonomous work on).
602
- args.push('--model', MODEL);
615
+ // A per-task override (chosen in the app, validated server-side against a
616
+ // fixed list before it ever reaches this argv) wins over the machine pin;
617
+ // absent, the pin stands. Effort has no machine-level pin at all: unset
618
+ // means Claude Code's own default, which is the honest resting state.
619
+ args.push('--model', model || MODEL);
620
+ if (effort) args.push('--effort', effort);
603
621
  if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
604
622
  // readOnly wins over wikiPerm: a consult must never inherit write tools.
605
623
  args.push(...(readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM));
package/bin/lib/env.mjs CHANGED
@@ -213,19 +213,39 @@ export async function loadCachedEnv(projectId) {
213
213
 
214
214
  // ── Materialization ────────────────────────────────────────────────────────
215
215
 
216
- /** Per-worktree git exclude — untracked AND unstageable. A git worktree's
217
- * `.git` is a FILE pointing at its private gitdir; info/exclude there applies
218
- * to that worktree only and never touches the user's repo. */
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
- const dotGit = join(wt, '.git');
222
- let gitdir = dotGit;
237
+ let gitdir;
223
238
  try {
224
- const content = readFileSync(dotGit, 'utf8');
225
- const m = content.match(/^gitdir:\s*(.+)\s*$/m);
226
- if (m) gitdir = resolve(wt, m[1].trim());
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
- /* .git is a directory (main checkout) use it directly */
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
- if (written.length) excludeInWorktree(wt, written);
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,6 +38,7 @@ 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';
@@ -122,11 +123,87 @@ async function fetchRoster(haveIds) {
122
123
  return data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
123
124
  }
124
125
 
126
+ const RUN_DIFFSTAT_URL = FLEET_URL.replace(/\/agents\/?$/, '/run-diffstat');
127
+
128
+ /**
129
+ * Post what a run has changed, every 20s, until the returned stop() is called.
130
+ *
131
+ * Daemon-side rather than an MCP tool the agent calls: the agent forgets, each
132
+ * call costs tokens, and anything the agent reports about itself is downstream
133
+ * of whatever it is currently reading. The daemon owns the worktree, so it can
134
+ * just look.
135
+ *
136
+ * Posts when the numbers MOVED, and otherwise once every couple of minutes to
137
+ * say the worktree is still being watched. Both halves are needed. Writing the
138
+ * same row every 20s would make a wedged turn look busy; never re-writing it
139
+ * makes a HEALTHY run look dead, because the reader treats a sample it has not
140
+ * seen refreshed in three minutes as a daemon that stopped — and an agent that
141
+ * finishes editing and then spends fifteen minutes running the test suite
142
+ * produces exactly the same silence as one that died. REFRESH_MS sits well
143
+ * inside that window so an idle-but-live worktree keeps its panel.
144
+ */
145
+ const DIFFSTAT_REFRESH_MS = 120_000;
146
+
147
+ function sampleDiffstat(cwd, baseRef, intentId, agentId) {
148
+ let last = '';
149
+ let lastSentAt = 0;
150
+ let alive = true;
151
+ const post = async () => {
152
+ if (!alive) return;
153
+ let stat = null;
154
+ try {
155
+ stat = worktreeDiffstat(cwd, baseRef);
156
+ } catch {
157
+ return; // a worktree mid-reset is not an error worth reporting
158
+ }
159
+ if (!stat) return;
160
+ const key = JSON.stringify(stat);
161
+ if (key === last && Date.now() - lastSentAt < DIFFSTAT_REFRESH_MS) return;
162
+ try {
163
+ const res = await fetch(RUN_DIFFSTAT_URL, {
164
+ method: 'POST',
165
+ headers: {
166
+ Authorization: `Bearer ${FLEET_TOKEN}`,
167
+ 'User-Agent': USER_AGENT,
168
+ 'Content-Type': 'application/json',
169
+ },
170
+ signal: AbortSignal.timeout(15_000),
171
+ // The lane, not just the task: the server matches the run on both, so a
172
+ // sample can only ever overwrite the diffstat of THIS lane's own run.
173
+ body: JSON.stringify({ intentId, agentId, diffstat: stat }),
174
+ });
175
+ // Only a sample the server ACCEPTED counts as sent. Marking it delivered
176
+ // before the round-trip meant a dropped request suppressed every retry
177
+ // for as long as the numbers held still — which is precisely when the
178
+ // reader is about to expire the panel.
179
+ if (res.ok) {
180
+ last = key;
181
+ lastSentAt = Date.now();
182
+ }
183
+ } catch {
184
+ /* best-effort: `last` is untouched, so the next tick tries again */
185
+ }
186
+ };
187
+ const t = setInterval(() => void post(), 20_000);
188
+ // Kick once after a beat so a fast task still reports something before it ends.
189
+ const first = setTimeout(() => void post(), 5_000);
190
+ return () => {
191
+ alive = false;
192
+ clearInterval(t);
193
+ clearTimeout(first);
194
+ };
195
+ }
196
+
125
197
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
126
198
  // base between tasks (fresh conversation), resume in place while on a blocker.
127
- async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
199
+ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getNext, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
128
200
  let resuming = false;
129
201
  let needsReset = true; // reset to base before a FRESH task, not on idle polls
202
+ // The task this lane is currently holding. `next` only arrives on a FRESH
203
+ // turn, but a run that comes back from a blocker is still building the same
204
+ // intent — without remembering it here, the entire post-blocker half of a run
205
+ // reports no diffstat and the tray blanks mid-build.
206
+ let heldIntentId = null;
130
207
  let phase = ''; // '', 'idle', 'blocked' — log each transition once, not per poll
131
208
  const enter = (p, fn, msg) => {
132
209
  if (phase !== p) {
@@ -154,18 +231,41 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
154
231
  needsReset = false;
155
232
  }
156
233
  const { dir, path: mcpConfig } = mcpConfigFor(token, getMcpUrl());
234
+ // The task the server says is next for this lane, read ONCE per turn: the
235
+ // model and effort below become process flags, so they must describe the
236
+ // same task the kickoff tells Claude to claim. Re-reading the map mid-turn
237
+ // could pair one task's flags with another's work.
238
+ const next = resuming ? null : getNext?.(agentId) || null;
239
+ if (next?.intentId) heldIntentId = next.intentId;
157
240
  let out = '';
241
+ // Report what this run is changing, while it is changing it. The commits
242
+ // endpoint can only describe work that has already reached the provider, so
243
+ // without this the app has nothing to say about a task for the whole time it
244
+ // is being built. Only when we know WHICH task this turn is for — the same
245
+ // hint that carries its model and effort — because a diffstat attributed to
246
+ // the wrong run is worse than none. On a resume that is the intent this
247
+ // lane already holds; the worktree it is about to keep editing is the same
248
+ // one, so the samples describe the same run.
249
+ const stopDiffstat = heldIntentId
250
+ ? sampleDiffstat(cwd, baseRef, heldIntentId, agentId)
251
+ : null;
158
252
  try {
159
253
  out = await runTurn({
160
- prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF,
254
+ prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF(next?.intentId),
161
255
  resume: resuming,
162
256
  system: SYSTEM_SINGLE,
163
257
  cwd,
164
258
  mcpConfig,
165
259
  label,
260
+ // Per-task overrides — null/absent means this machine's own defaults
261
+ // (FLOWVIANT_MODEL, and Claude Code's own effort). A resume keeps the
262
+ // session it already has, so there is nothing to re-pick there.
263
+ model: next?.model || undefined,
264
+ effort: next?.effort || undefined,
166
265
  onSpawn: (ch) => onChild?.(ch),
167
266
  });
168
267
  } finally {
268
+ stopDiffstat?.();
169
269
  rmSync(dir, { recursive: true, force: true });
170
270
  onChild?.(null);
171
271
  }
@@ -179,6 +279,7 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
179
279
  if (sawSentinel(out, 'NOTHING')) {
180
280
  enter('idle', info, 'idle — no work assigned');
181
281
  resuming = false;
282
+ heldIntentId = null; // let go of the task, and of its diffstat
182
283
  await sleep(IDLE_SECONDS);
183
284
  continue;
184
285
  }
@@ -187,6 +288,7 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
187
288
  phase = '';
188
289
  resuming = false;
189
290
  needsReset = true;
291
+ heldIntentId = null;
190
292
  continue;
191
293
  }
192
294
  // No sentinel — the turn didn't complete the protocol. Almost always the
@@ -199,7 +301,10 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
199
301
  // failure, not completion — retry in place and KEEP the worktree. Resetting
200
302
  // here would wipe the blocked task's uncommitted changes. Only a fresh-task
201
303
  // turn (not resuming) warrants a clean slate next time.
202
- if (!resuming) needsReset = true;
304
+ if (!resuming) {
305
+ needsReset = true;
306
+ heldIntentId = null; // fresh slate next turn — nothing held to sample
307
+ }
203
308
  await sleep(IDLE_SECONDS);
204
309
  }
205
310
  info(`${label} stopped`);
@@ -298,6 +403,13 @@ export async function runFleetDaemon() {
298
403
  const tokenByAgent = new Map(); // agentId -> latest worker token
299
404
  const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
300
405
  const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
406
+ // agentId -> the intent the server would hand this lane next: { intentId,
407
+ // title, model, effort }. `--model`/`--effort` are fixed when Claude starts,
408
+ // and by then nothing has been claimed — so the server names the task first
409
+ // and the turn pins its claim to that id. Absent on older servers, in which
410
+ // case the lane behaves exactly as it did before: generic kickoff, machine
411
+ // defaults.
412
+ const nextByAgent = new Map();
301
413
  let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
302
414
  let mcpUrl = MCP_URL;
303
415
  const workers = new Map(); // agentId -> { state, promise, wt, label }
@@ -1061,6 +1173,14 @@ export async function runFleetDaemon() {
1061
1173
  const startedAt = Date.now();
1062
1174
  let filesRead = 0;
1063
1175
  let phase = 'reading';
1176
+ // Distinct vault pages this turn has written. Counted HERE, from the
1177
+ // stream, because it is the only place that knows mid-turn: the daemon
1178
+ // syncs the vault to the server once, AFTER the turn returns, so a
1179
+ // server-side count of "rows touched since the turn began" is zero for
1180
+ // the entire writing phase — which is exactly how long the bar needs it.
1181
+ // A Set, not a counter: pages get written once and then edited, and
1182
+ // three tool calls on one page are one page.
1183
+ const pagesSeen = new Set();
1064
1184
  const feed = [];
1065
1185
  const frame = (extra) => ({
1066
1186
  mode,
@@ -1068,12 +1188,16 @@ export async function runFleetDaemon() {
1068
1188
  activity: feed[feed.length - 1] ?? '',
1069
1189
  recent: feed.slice(-24),
1070
1190
  filesRead,
1191
+ pagesWritten: pagesSeen.size,
1071
1192
  elapsedSec: Math.round((Date.now() - startedAt) / 1000),
1072
1193
  ...extra,
1073
1194
  });
1074
1195
  const onActivity = (a) => {
1075
1196
  if (a.kind === 'read') filesRead++;
1076
- if (a.kind === 'write') phase = 'writing';
1197
+ if (a.kind === 'write') {
1198
+ phase = 'writing';
1199
+ pagesSeen.add(a.path || a.label);
1200
+ }
1077
1201
  // Collapse runs of bare "thinking…" so the feed doesn't fill with it.
1078
1202
  if (!(a.label === 'thinking…' && feed[feed.length - 1] === 'thinking…')) {
1079
1203
  feed.push(a.label);
@@ -1359,6 +1483,8 @@ export async function runFleetDaemon() {
1359
1483
  mintedAt.set(a.agentId, Date.now());
1360
1484
  }
1361
1485
  hasWorkByAgent.set(a.agentId, !!a.hasWork);
1486
+ if (a.next && typeof a.next.intentId === 'string') nextByAgent.set(a.agentId, a.next);
1487
+ else nextByAgent.delete(a.agentId);
1362
1488
  if (!workers.has(a.agentId)) {
1363
1489
  // Local ceiling, enforced and not merely requested. The roster can carry
1364
1490
  // more lanes than this machine asked for — someone added capacity by
@@ -1409,7 +1535,14 @@ export async function runFleetDaemon() {
1409
1535
 
1410
1536
  getToken: (id) => tokenByAgent.get(id),
1411
1537
  getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
1538
+ getNext: (id) => nextByAgent.get(id) ?? null,
1412
1539
  getMcpUrl: () => mcpUrl,
1540
+ // Injected rather than imported: fleet.mjs imports live.mjs, so live
1541
+ // cannot import back. The live worker is the DEFAULT one, and until
1542
+ // this was passed down the whole run-diffstat pipeline was reachable
1543
+ // only under FLOWVIANT_POLL=1 — the app's live-changes panel had no
1544
+ // data source at all for the path everybody actually runs.
1545
+ sampleDiffstat,
1413
1546
  isAlive: () => state.alive,
1414
1547
  onChild: (ch) => {
1415
1548
  state.child = ch;
@@ -1524,6 +1657,7 @@ export async function runFleetDaemon() {
1524
1657
  workers.delete(id);
1525
1658
  tokenByAgent.delete(id);
1526
1659
  hasWorkByAgent.delete(id);
1660
+ nextByAgent.delete(id);
1527
1661
  mintedAt.delete(id); // was leaked on removal (finding 14)
1528
1662
  }
1529
1663
  }
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(['add', '-A'], wt, env);
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,6 +565,8 @@ export async function runLiveTask({
565
565
  resumeIntentId,
566
566
  onChild,
567
567
  onIntent,
568
+ sampleDiffstat,
569
+ agentId,
568
570
  }) {
569
571
  const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
570
572
  if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
@@ -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
- // Pin the model never inherit the user's global default (which may be a
763
- // 1M/long-context tier their subscription can't bill autonomous work on).
764
- model: MODEL,
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}`)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.35.0",
3
+ "version": "0.37.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": {