flowviant 0.125.22 → 0.125.24

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.
@@ -31,6 +31,8 @@ import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryD
31
31
  import { MODEL, PROJECT_ID } from './config.mjs';
32
32
  import { setRuntimeLimit } from './daemonState.mjs';
33
33
  import { gitOrNull } from './git.mjs';
34
+ import { assertWorktree } from './worktreeHealth.mjs';
35
+ import { placeTurnLockedByLivePid, recordTurnLock } from './turnLock.mjs';
34
36
  import { turnCommits } from './turnReceipts.mjs';
35
37
  import { addUsage } from './turnUsage.mjs';
36
38
  import { stashCard, stashGaps } from './agentCards.mjs';
@@ -67,6 +69,7 @@ import { codexSubagentsSafe } from './runtimeCodex.mjs';
67
69
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
68
70
  import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
69
71
  import { createUiReview } from './uiReview.mjs';
72
+ import { createVideoRender } from './videoRender.mjs';
70
73
  import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
71
74
 
72
75
  export function createAgentTurnExecution({
@@ -98,6 +101,7 @@ export function createAgentTurnExecution({
98
101
  runTurnImpl = runTurn,
99
102
  exportModel,
100
103
  makeUiReview = createUiReview,
104
+ makeVideoRender = createVideoRender,
101
105
  }) {
102
106
  const reportDelivery = createDeliveryReporter();
103
107
  /** How long the final flush may hold the settle. Bounded because the settle
@@ -146,6 +150,10 @@ export function createAgentTurnExecution({
146
150
  return;
147
151
  }
148
152
  const wt = dir.wt;
153
+ const worktreeRepo = repositoryRun?.sources.find(r => r.wt === wt)?.repoRoot ?? repoRoot;
154
+ assertWorktree(wt, worktreeRepo);
155
+ const turnLock = sessionMetaPath(wt, 'flowviant-turn.lock', place);
156
+ if (placeTurnLockedByLivePid(sessionMetaPath(wt, 'flowviant-turn.lock'))) return;
149
157
  /**
150
158
  * NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
151
159
  * of these exit non-zero in states a real worktree reaches: `symbolic-ref`
@@ -404,9 +412,18 @@ export function createAgentTurnExecution({
404
412
  let uiReview = null;
405
413
  let uiReviewNote = '';
406
414
  let uiReviewEvidence = '';
415
+ // A video card's render tool (videoRender.mjs), and the words a turn
416
+ // gets when it could not be held, so it stops rather than guesses.
417
+ let videoRender = null;
418
+ let videoNote = '';
407
419
  const runCli = (args) => {
408
420
  if (run.interrupted) return Promise.resolve('');
421
+ // Preparation awaits external work; remeasure before each fresh or
422
+ // resumed spawn, before replacing Codex's own repository discovery.
423
+ assertWorktree(wt, worktreeRepo);
424
+ args.verifiedWorktree = true;
409
425
  args = withUiReview(args, uiReviewNote, uiReview?.env);
426
+ args = withUiReview(args, videoNote);
410
427
  return runTurnImpl(args);
411
428
  };
412
429
  /**
@@ -468,6 +485,10 @@ export function createAgentTurnExecution({
468
485
  uiReviewEvidence = uiReviewNote;
469
486
  }
470
487
  }
488
+ if (taskKind === 'video') {
489
+ try { videoRender = await makeVideoRender({ wt, signal: setupController.signal }); }
490
+ catch (error) { videoNote = `The card_video render tool could not be started on this machine: ${envScrub(error.message)}. Without it no GIF, MP4 or WebM can be rendered: say so in your answer and stop.`; }
491
+ }
471
492
  if (orchestrating) {
472
493
  orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
473
494
  assignmentContext: () => agentChildScope(job),
@@ -478,7 +499,7 @@ export function createAgentTurnExecution({
478
499
  notify: message => liveInput ? run.live?.inject(message) === true : false,
479
500
  taken: id => run.live?.onTaken(id) });
480
501
  }
481
- const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
502
+ const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, videoConfig: videoRender?.config ?? null, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
482
503
  projectTools = tools.projectTools;
483
504
  localCleanup?.begin(turnId);
484
505
  const { toolsNote } = tools;
@@ -656,6 +677,10 @@ export function createAgentTurnExecution({
656
677
  // (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
657
678
  // startup probe and its Terminal tabs.
658
679
  onSpawn: (ch) => {
680
+ let releaseTurnLock;
681
+ try { releaseTurnLock = recordTurnLock(turnLock, ch.pid); }
682
+ catch (error) { killGroup(ch); throw error; }
683
+ ch.once('close', releaseTurnLock);
659
684
  if (orchestrator && rt === 'codex') run.live?.connect((id, body) => orchestrator.input(id, body));
660
685
  if (rt !== 'claude') reportDelivery('agent', job, 'taken');
661
686
  brief?.running(true);
@@ -733,6 +758,7 @@ export function createAgentTurnExecution({
733
758
  // The builder has finished its fix/recheck loop. Close only resources
734
759
  // owned by its review tool, then bind evidence to the final source
735
760
  // before the existing artifact upload and delivery settlement.
761
+ try { await videoRender?.close(); } catch { /* a closed listener */ }
736
762
  try { await uiReview?.close(); }
737
763
  finally {
738
764
  try { uiReviewEvidence = uiReview?.finalize() ?? uiReviewEvidence; }
@@ -13,9 +13,10 @@
13
13
  * so a lane that learns a place cannot store one unvalidated.
14
14
  */
15
15
  import { readRepositoryPlace } from './repositoryPlaces.mjs';
16
- import { existsSync, statSync } from 'node:fs';
16
+ import { existsSync, readdirSync, statSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
19
+ import { assertWorktree } from './worktreeHealth.mjs';
19
20
 
20
21
  /** The place id meaning "the checkout", not a worktree. Must match the
21
22
  * server's REPO_PLACE — it is a wire value, not a local convention. The
@@ -213,6 +214,7 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
213
214
  * same way adoption resolves a cwd, and for the same reason.
214
215
  */
215
216
  const placeWtFor = (placeId, baseAt) => {
217
+ placeWtFor.lastError = null;
216
218
  if (placeId === REPO_PLACE) {
217
219
  // The checkout. `fresh: false` on purpose — nothing was opened, so no
218
220
  // caller may treat this as a newly-cut branch.
@@ -220,7 +222,14 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
220
222
  }
221
223
  const wt = worktreePath(baseDir, placeId);
222
224
  if (!wt) return null;
223
- const fresh = !existsSync(wt);
225
+ let fresh;
226
+ try {
227
+ fresh = !existsSync(wt) || readdirSync(wt).length === 0;
228
+ if (!fresh) assertWorktree(wt, repoRoot);
229
+ } catch (error) {
230
+ placeWtFor.lastError = error.message;
231
+ return null;
232
+ }
224
233
  if (fresh) {
225
234
  const branch = sessionBranch(placeId);
226
235
  // `baseAt` is the adoption override: a tab born from a terminal session
@@ -276,6 +285,8 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
276
285
  // always did (runTurn.mjs spawns with `{...process.env}`), so anything
277
286
  // exported in the shell the daemon was started from is present here.
278
287
  }
288
+ try { assertWorktree(wt, repoRoot); }
289
+ catch (error) { placeWtFor.lastError = error.message; return null; }
279
290
  return { wt, fresh };
280
291
  };
281
292
 
@@ -12,6 +12,7 @@ import { existsSync, readdirSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { git, isSafePathSegment } from './git.mjs';
14
14
  import { PERSON_PLACE_PREFIX } from './workPlaces.mjs';
15
+ import { placeTurnLockedByLivePid } from './turnLock.mjs';
15
16
 
16
17
  export function createWorkRetire({
17
18
  repositoryPlaces,
@@ -24,6 +25,7 @@ export function createWorkRetire({
24
25
  workTokens,
25
26
  agentChildren,
26
27
  sweepMergedSessionBranch,
28
+ sessionMetaPath,
27
29
  }) {
28
30
  /**
29
31
  * Retire the worktrees of sessions the server says are CLOSED.
@@ -111,6 +113,8 @@ export function createWorkRetire({
111
113
  if (placeLocks.has(placeOf(id)) || shipping.has(id)) continue; // still draining here
112
114
  const wt = join(dir, id);
113
115
  try {
116
+ const places = repositoryPlaces?.stateFor(id) ? repositoryPlaces.entries(id).filter(r => r.writable).map(r => r.wt) : [wt];
117
+ if (places.some(path => placeTurnLockedByLivePid(sessionMetaPath?.(path, 'flowviant-turn.lock')))) continue;
114
118
  // Uncommitted work is the human's — a resource sweep does not outrank
115
119
  // it, closed tab or not. (The non-force remove would refuse anyway;
116
120
  // the explicit check keeps the intent legible.)
@@ -179,6 +183,7 @@ export function createWorkRetire({
179
183
  if (placeLocks.has(place)) continue;
180
184
  const wt = join(baseDir, 'sessions', place);
181
185
  try {
186
+ if (placeTurnLockedByLivePid(sessionMetaPath?.(wt, 'flowviant-turn.lock'))) continue;
182
187
  if (git(['status', '--porcelain'], wt).trim() !== '') continue;
183
188
  git(['merge', '--ff-only', baseRef()], wt);
184
189
  } catch {
@@ -13,6 +13,17 @@
13
13
  * uncommitted work in the same total. Untracked files count too: git calls them
14
14
  * nothing until they are added, and a human calls them new work.
15
15
  *
16
+ * …AND THAT WHOLE SPLIT IN TWO (2026-10-25). The agent page's Changes counts
17
+ * the branch's COMMITS, so beside the whole it read as a disagreement: "Native
18
+ * 3D viewer" said +78 −12 in Changes and +114 −15 on the branch row (a running
19
+ * card's commit on top), and "New task submission" said "Nothing committed
20
+ * yet" beside +613 −294 of work nobody had committed. `committed` is
21
+ * `<merge-base>..HEAD` — the figure Changes agrees with — and `uncommitted` is
22
+ * HEAD against the working tree, untracked files again counted as additions,
23
+ * with its own rows so the page can list it apart. The halves need not sum to
24
+ * the whole: a line a commit changed and the working tree changed back counts
25
+ * in both and in neither, which is git's arithmetic, relayed.
26
+ *
16
27
  * It also carries THIS BRANCH'S OWN COMMITS and the cards they name — see
17
28
  * `branchCommits` below. That rides here rather than on a route of its own
18
29
  * because this sweep is already standing in the right directory on the right
@@ -252,16 +263,104 @@ function countLines(buf) {
252
263
  return s.endsWith('\n') ? n - 1 : n;
253
264
  }
254
265
 
266
+ /** One diffstat row. The path is clamped to the server's zod cap (file path ≤
267
+ * 300 — see the caps at worktreeDiff's return): one over-cap string would 400
268
+ * the whole report batch, and a readout must degrade to a shorter label, never
269
+ * to silence. Null for an empty path, which names nothing. */
270
+ function fileRow(path, added, deleted, binary = false) {
271
+ if (!path) return null;
272
+ path = path.slice(0, 300);
273
+ return binary ? { path, added, deleted, binary } : { path, added, deleted };
274
+ }
275
+
276
+ /**
277
+ * `git diff --numstat -z <revs…> --`, read as rows — the one reader of that
278
+ * output, for the whole and both halves alike. Throws when git does.
279
+ *
280
+ * `-z`, for the reason gitRaw exists: git's LINE-based output C-quotes any path
281
+ * that is not plain ASCII, so an accented filename arrives as "n\303\251w.txt"
282
+ * — a string that is not the path and reads as escaped garbage in the rail.
283
+ * And `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
284
+ * but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
285
+ * then the new one. An empty path is therefore the rename marker and the next
286
+ * two fields belong to it — read line-wise, a rename would report a file
287
+ * literally named "old => new".
288
+ */
289
+ function numstatRows(revs, wt) {
290
+ const rows = [];
291
+ const fields = splitNul(gitRaw(['diff', '--numstat', '-z', ...revs, '--'], wt));
292
+ for (let i = 0; i < fields.length; i++) {
293
+ const [a, d, ...rest] = fields[i].split('\t');
294
+ let path = rest.join('\t');
295
+ if (!path) {
296
+ path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
297
+ i += 2;
298
+ if (!path) continue;
299
+ }
300
+ const binary = a === '-' || d === '-';
301
+ const row = fileRow(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
302
+ if (row) rows.push(row);
303
+ }
304
+ return rows;
305
+ }
306
+
307
+ /** The same rows, or NULL when git would not answer — kept apart from an
308
+ * empty diff, so a failed read omits its half rather than reporting zeros. */
309
+ function numstatOrNull(revs, wt) {
310
+ try {
311
+ return numstatRows(revs, wt);
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ /** A diffstat summed from its rows: totals and `fileCount` over ALL of them,
318
+ * and the biggest MAX_FILES as the rows reported, `truncated` saying how many
319
+ * were left out. */
320
+ function diffstat(rows) {
321
+ // Biggest first: with a 20-row cap, the rows that survive should be the ones
322
+ // worth looking at. Ties break by path so the list doesn't shuffle per sweep.
323
+ const files = [...rows].sort(
324
+ (x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
325
+ );
326
+ let additions = 0;
327
+ let deletions = 0;
328
+ for (const f of files) {
329
+ additions += f.added;
330
+ deletions += f.deleted;
331
+ }
332
+ return {
333
+ additions,
334
+ deletions,
335
+ fileCount: files.length,
336
+ truncated: Math.max(0, files.length - MAX_FILES),
337
+ files: files.slice(0, MAX_FILES),
338
+ };
339
+ }
340
+
255
341
  /**
256
342
  * @param {string} wt the worktree directory
257
343
  * @param {string} baseRef the project's base ref (e.g. `origin/main`)
258
- * @returns {null | {branch:string, path:string, ahead:number, behind:number,
259
- * baseLabel:string, baseCommits:{sha:string, subject:string, author:string}[],
344
+ * @returns {null | {branch:string, headSha?:string, path:string, ahead:number,
345
+ * behind:number, baseLabel:string,
346
+ * baseCommits:{sha:string, subject:string, author:string}[],
260
347
  * dirty:boolean, additions:number, deletions:number, fileCount:number,
261
348
  * truncated:number,
262
349
  * files:{path:string, added:number, deleted:number, binary?:boolean}[],
350
+ * committed?:{additions:number, deletions:number, fileCount:number},
351
+ * uncommitted?:{additions:number, deletions:number, fileCount:number,
352
+ * truncated:number,
353
+ * files:{path:string, added:number, deleted:number, binary?:boolean}[]},
263
354
  * commits:{sha:string, subject:string, author:string, at:string,
264
355
  * additions:number, deletions:number, taskIds:string[]}[]}}
356
+ *
357
+ * The top-level additions/deletions/fileCount/truncated/files are the WHOLE
358
+ * (merge-base against the working tree, untracked included) — what the tab
359
+ * rails read. `committed` (`<merge-base>..HEAD`) and `uncommitted` (HEAD
360
+ * against the working tree, untracked included, with its own rows) split it
361
+ * for the agent page, which agrees with the first and lists the second apart.
362
+ * `committed` is absent without a merge-base; either half is absent when its
363
+ * git read failed — unknown, never a fabricated zero.
265
364
  */
266
365
  export function worktreeDiff(wt, baseRef) {
267
366
  if (!wt || !existsSync(wt)) return null;
@@ -289,52 +388,24 @@ export function worktreeDiff(wt, baseRef) {
289
388
  HEAD below, which still reports the uncommitted half honestly */
290
389
  }
291
390
 
292
- const files = [];
293
- let additions = 0;
294
- let deletions = 0;
295
- const push = (path, added, deleted, binary = false) => {
296
- if (!path) return;
297
- // Clamped to the server's zod cap (file path ≤ 300 — see the caps at the
298
- // return, below): one over-cap string would 400 the whole report batch,
299
- // and a readout must degrade to a shorter label, never to silence.
300
- path = path.slice(0, 300);
301
- files.push(binary ? { path, added, deleted, binary } : { path, added, deleted });
302
- additions += added;
303
- deletions += deleted;
304
- };
305
-
306
391
  // Tracked: working tree vs base. `git diff <base>` (no --cached, no second
307
- // ref) is exactly "everything this session did", committed or not. `-z`, for
308
- // the reason gitRaw exists: git's LINE-based output C-quotes any path that
309
- // is not plain ASCII, so an accented filename arrives as "n\303\251w.txt" —
310
- // a string that is not the path and reads as escaped garbage in the rail.
311
- try {
312
- // `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
313
- // but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
314
- // then the new one. An empty path is therefore the rename marker and the
315
- // next two fields belong to it — read line-wise, a rename would report a
316
- // file literally named "old => new".
317
- const fields = splitNul(gitRaw(['diff', '--numstat', '-z', base || 'HEAD', '--'], wt));
318
- for (let i = 0; i < fields.length; i++) {
319
- const [a, d, ...rest] = fields[i].split('\t');
320
- let path = rest.join('\t');
321
- if (!path) {
322
- path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
323
- i += 2;
324
- if (!path) continue;
325
- }
326
- const binary = a === '-' || d === '-';
327
- push(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
328
- }
329
- } catch {
330
- /* report what we have */
331
- }
392
+ // ref) is exactly "everything this session did", committed or not. Without a
393
+ // merge-base it is HEAD, which is the uncommitted half alone — kept as rows
394
+ // OR NULL, so that half can reuse this read below without mistaking a failed
395
+ // one for a clean tree.
396
+ const trackedRows = numstatOrNull([base || 'HEAD'], wt);
332
397
 
333
398
  // Untracked, minus everything gitignored — new files are the most visible
334
399
  // work a session does and they would otherwise show as nothing at all. `-z`
335
400
  // here is not cosmetic: a C-quoted untracked name fails the statSync below,
336
401
  // and the catch swallowed the row — a session's brand-new `café.txt` simply
337
- // vanished from the rail.
402
+ // vanished from the rail. Scanned ONCE: these rows belong to the whole and to
403
+ // the uncommitted half alike, and the scan opens files.
404
+ const untracked = [];
405
+ const pushUntracked = (path, added, binary = false) => {
406
+ const row = fileRow(path, added, 0, binary);
407
+ if (row) untracked.push(row);
408
+ };
338
409
  try {
339
410
  const others = splitNul(gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], wt));
340
411
  for (const path of others.slice(0, MAX_UNTRACKED_SCAN)) {
@@ -343,12 +414,12 @@ export function worktreeDiff(wt, baseRef) {
343
414
  const st = statSync(full);
344
415
  if (!st.isFile()) continue;
345
416
  if (st.size > MAX_COUNT_BYTES) {
346
- push(path, 0, 0, true);
417
+ pushUntracked(path, 0, true);
347
418
  continue;
348
419
  }
349
420
  const lines = countLines(readFileSync(full));
350
- if (lines === null) push(path, 0, 0, true);
351
- else push(path, lines, 0);
421
+ if (lines === null) pushUntracked(path, 0, true);
422
+ else pushUntracked(path, lines);
352
423
  } catch {
353
424
  /* vanished between listing and reading — it wasn't there to report */
354
425
  }
@@ -357,6 +428,37 @@ export function worktreeDiff(wt, baseRef) {
357
428
  /* report what we have */
358
429
  }
359
430
 
431
+ const whole = diffstat([...(trackedRows ?? []), ...untracked]);
432
+
433
+ // THE WHOLE, SPLIT IN TWO — see the header. Each half is its own git read
434
+ // and each degrades alone: a half git would not answer is OMITTED, because
435
+ // a zero there would claim a clean side nobody measured.
436
+ //
437
+ // Committed: `git diff <merge-base> HEAD`, only with a merge-base. Without
438
+ // one there is no "this branch's commits" to count, and the fallback above
439
+ // already diffed HEAD, which is the other half.
440
+ let committed = null;
441
+ if (base) {
442
+ const rows = numstatOrNull([base, 'HEAD'], wt);
443
+ if (rows) {
444
+ const { additions, deletions, fileCount } = diffstat(rows);
445
+ committed = { additions, deletions, fileCount };
446
+ }
447
+ }
448
+ // Uncommitted: `git diff HEAD` plus the untracked rows. An UNBORN HEAD has
449
+ // no commit, so everything there is uncommitted and the half is the whole —
450
+ // `git diff HEAD` fails on it, and that failure must not drop the half
451
+ // (defensive today: the branch read above already returns null on an unborn
452
+ // HEAD). With no merge-base the whole's tracked read already WAS `git diff
453
+ // HEAD`, so it is reused rather than run twice.
454
+ let uncommitted = null;
455
+ if (!headSha) {
456
+ uncommitted = { ...whole };
457
+ } else {
458
+ const rows = base ? numstatOrNull(['HEAD'], wt) : trackedRows;
459
+ if (rows) uncommitted = diffstat([...rows, ...untracked]);
460
+ }
461
+
360
462
  let ahead = 0;
361
463
  try {
362
464
  if (base) ahead = Number(git(['rev-list', '--count', `${base}..HEAD`], wt)) || 0;
@@ -403,11 +505,6 @@ export function worktreeDiff(wt, baseRef) {
403
505
  /* leave at false */
404
506
  }
405
507
 
406
- // Biggest first: with a 20-row cap, the rows that survive should be the ones
407
- // worth looking at. Ties break by path so the list doesn't shuffle per sweep.
408
- files.sort(
409
- (x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
410
- );
411
508
  // Every string here is clamped to the server's own zod caps (branch ≤ 200,
412
509
  // baseLabel ≤ 120, subject ≤ 200, author ≤ 80, file path ≤ 300): the report
413
510
  // rides in a BATCH, so a single over-cap string — a generated branch name, a
@@ -421,11 +518,13 @@ export function worktreeDiff(wt, baseRef) {
421
518
  baseLabel: String(baseRef).replace(/^origin\//, '').slice(0, 120),
422
519
  baseCommits,
423
520
  dirty,
424
- additions,
425
- deletions,
426
- fileCount: files.length,
427
- truncated: Math.max(0, files.length - MAX_FILES),
428
- files: files.slice(0, MAX_FILES),
521
+ additions: whole.additions,
522
+ deletions: whole.deletions,
523
+ fileCount: whole.fileCount,
524
+ truncated: whole.truncated,
525
+ files: whole.files,
526
+ ...(committed ? { committed } : {}),
527
+ ...(uncommitted ? { uncommitted } : {}),
429
528
  commits: branchCommits(wt, base),
430
529
  };
431
530
  }
@@ -0,0 +1,23 @@
1
+ /** Verify the directory itself, rather than a parent git happened to discover. */
2
+ import { realpathSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { git } from './git.mjs';
5
+
6
+ export function assertWorktree(wt, repoRoot) {
7
+ try {
8
+ const top = realpathSync(git(['rev-parse', '--show-toplevel'], wt));
9
+ if (top !== realpathSync(wt)) throw new Error('the directory is not a git worktree root');
10
+ const common = (dir) => realpathSync(resolve(dir, git(['rev-parse', '--git-common-dir'], dir)));
11
+ if (common(wt) !== common(repoRoot)) throw new Error('the worktree belongs to another repository');
12
+ const registered = git(['worktree', 'list', '--porcelain', '-z'], repoRoot).split('\0')
13
+ .some(line => {
14
+ if (!line.startsWith('worktree ')) return false;
15
+ try { return realpathSync(line.slice(9)) === top; } catch { return false; }
16
+ });
17
+ if (!registered) throw new Error('the worktree is not registered with this repository');
18
+ git(['rev-parse', '--verify', 'HEAD'], wt);
19
+ } catch (error) {
20
+ const why = error.stderr?.toString().trim() || error.message;
21
+ throw new Error(`The worktree at ${wt} is unavailable: ${why}`);
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.22",
3
+ "version": "0.125.24",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {