flowviant 0.125.21 → 0.125.23

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.
@@ -2,7 +2,7 @@ import { reportLocalCleanup } from './localCleanupReport.mjs';
2
2
  import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
3
3
  import { createCaptureRetention } from './referenceCaptures.mjs';
4
4
  import { imageRevisionInstructions } from './artifactIteration.mjs';
5
- import { agentChildScope } from './agentQueueContext.mjs';
5
+ import { agentChildScope, sourceChatText } from './agentQueueContext.mjs';
6
6
  import { measureTestFindings } from './testFindings.mjs';
7
7
  import { fetchResultInputs } from './resultInputs.mjs';
8
8
  import { agentWork } from './workActivity.mjs';
@@ -33,7 +33,7 @@ import { setRuntimeLimit } from './daemonState.mjs';
33
33
  import { gitOrNull } from './git.mjs';
34
34
  import { turnCommits } from './turnReceipts.mjs';
35
35
  import { addUsage } from './turnUsage.mjs';
36
- import { stashCard } from './agentCards.mjs';
36
+ import { stashCard, stashGaps } from './agentCards.mjs';
37
37
  import { c } from './ui.mjs';
38
38
  import { runTurn } from './runTurn.mjs';
39
39
  import { liveGroups, processesSupported } from './processes.mjs';
@@ -67,6 +67,7 @@ import { codexSubagentsSafe } from './runtimeCodex.mjs';
67
67
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
68
68
  import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
69
69
  import { createUiReview } from './uiReview.mjs';
70
+ import { createVideoRender } from './videoRender.mjs';
70
71
  import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
71
72
 
72
73
  export function createAgentTurnExecution({
@@ -98,6 +99,7 @@ export function createAgentTurnExecution({
98
99
  runTurnImpl = runTurn,
99
100
  exportModel,
100
101
  makeUiReview = createUiReview,
102
+ makeVideoRender = createVideoRender,
101
103
  }) {
102
104
  const reportDelivery = createDeliveryReporter();
103
105
  /** How long the final flush may hold the settle. Bounded because the settle
@@ -326,7 +328,7 @@ export function createAgentTurnExecution({
326
328
  stashCard(
327
329
  sessionMetaPath(wt, 'flowviant-agent-cards', agentId),
328
330
  job.task.id,
329
- AGENT_TASK_SPEC(job.task, job.cardThread, files.card)
331
+ AGENT_TASK_SPEC(job.task, job.cardThread, files.card) + sourceChatText(job.queueContext, job.task.id)
330
332
  );
331
333
  }
332
334
 
@@ -404,9 +406,14 @@ export function createAgentTurnExecution({
404
406
  let uiReview = null;
405
407
  let uiReviewNote = '';
406
408
  let uiReviewEvidence = '';
409
+ // A video card's render tool (videoRender.mjs), and the words a turn
410
+ // gets when it could not be held, so it stops rather than guesses.
411
+ let videoRender = null;
412
+ let videoNote = '';
407
413
  const runCli = (args) => {
408
414
  if (run.interrupted) return Promise.resolve('');
409
415
  args = withUiReview(args, uiReviewNote, uiReview?.env);
416
+ args = withUiReview(args, videoNote);
410
417
  return runTurnImpl(args);
411
418
  };
412
419
  /**
@@ -468,6 +475,10 @@ export function createAgentTurnExecution({
468
475
  uiReviewEvidence = uiReviewNote;
469
476
  }
470
477
  }
478
+ if (taskKind === 'video') {
479
+ try { videoRender = await makeVideoRender({ wt, signal: setupController.signal }); }
480
+ 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.`; }
481
+ }
471
482
  if (orchestrating) {
472
483
  orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
473
484
  assignmentContext: () => agentChildScope(job),
@@ -478,7 +489,7 @@ export function createAgentTurnExecution({
478
489
  notify: message => liveInput ? run.live?.inject(message) === true : false,
479
490
  taken: id => run.live?.onTaken(id) });
480
491
  }
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 });
492
+ 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
493
  projectTools = tools.projectTools;
483
494
  localCleanup?.begin(turnId);
484
495
  const { toolsNote } = tools;
@@ -733,6 +744,7 @@ export function createAgentTurnExecution({
733
744
  // The builder has finished its fix/recheck loop. Close only resources
734
745
  // owned by its review tool, then bind evidence to the final source
735
746
  // before the existing artifact upload and delivery settlement.
747
+ try { await videoRender?.close(); } catch { /* a closed listener */ }
736
748
  try { await uiReview?.close(); }
737
749
  finally {
738
750
  try { uiReviewEvidence = uiReview?.finalize() ?? uiReviewEvidence; }
@@ -845,6 +857,9 @@ export function createAgentTurnExecution({
845
857
  modelExports: intermediateDelivery?.modelExports,
846
858
  });
847
859
  appendUiReviewEvidence(body, uiReviewEvidence);
860
+ // The agent's own account of what it did not deliver, for the pre-review.
861
+ if (job.kind === 'task' && job.task && body.outcome === 'delivered' && res?.gaps)
862
+ stashGaps(sessionMetaPath(wt, 'flowviant-agent-cards', agentId), job.task.id, res.gaps);
848
863
  if (body.outcome === 'delivered') intermediateDelivery?.commit();
849
864
  // This place's writer lock is still held. Measure the exited children
850
865
  // and their groups before releasing any file requirements. Dirty repository files are unrelated to managed scratch eligibility.
@@ -34,6 +34,7 @@ import { usableImageArtifact } from './imageArtifact.mjs';
34
34
  import { withoutTurnDeclaration } from './turnDeclaration.mjs';
35
35
  import { ANSWER_DELIVERED_USES, researchUseOf, withResearchHeader } from './researchUse.mjs';
36
36
  import { measuredModelExports } from './intermediateArtifacts.mjs';
37
+ import { gapsMarkdown } from './turnGaps.mjs';
37
38
 
38
39
  /** Materialization runs only for a declared delivery; settlement remains the
39
40
  * sole parser and the sole authority deciding whether it really delivered. */
@@ -166,6 +167,11 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
166
167
  : prerequisite?.taskKind === 'image' && (job.runtime ?? 'claude') !== 'codex'
167
168
  ? 'This agent runs on a CLI that cannot generate images. Image cards require Codex’s own image generation; this agent cannot run the requested texture work.'
168
169
  : null;
170
+ /* WHAT IT SAYS IT DID NOT DELIVER (turnGaps.mjs), where the person reads
171
+ the delivery: Verification on a code card, which carries it; the summary
172
+ on every other kind, which has no Verification lane. */
173
+ const gaps = res.outcome === 'delivered' && !cannotRequest ? gapsMarkdown(res.gaps) : null;
174
+ const verification = [res.verification, kind.posture === 'build' ? gaps : null].filter(Boolean).join('\n\n');
169
175
  return report(
170
176
  {
171
177
  turnId,
@@ -173,16 +179,20 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
173
179
  outcome: res.outcome,
174
180
  answer: cannotRequest ?? envScrub((kind.posture === 'research' && researchUse
175
181
  ? withResearchHeader(researchUse, res.answer ?? '') : res.answer ?? '') +
176
- (job.task?.taskType === 'tests' && res.raised?.length ? '\n\nAdditional CLI-reported findings (not filed as fixes):\n' + res.raised.map(r => `${r.title}\n${r.brief ?? ''}`).join('\n\n') : '')),
182
+ (job.task?.taskType === 'tests' && res.raised?.length ? '\n\nAdditional CLI-reported findings (not filed as fixes):\n' + res.raised.map(r => `${r.title}\n${r.brief ?? ''}`).join('\n\n') : '') +
183
+ (gaps && kind.posture !== 'build' ? `\n\n${gaps}` : '')),
177
184
  // CLI-authored evidence travels separately from the short answer and
178
185
  // measured commits/checks. Older turns simply send no report.
179
- ...(kind.posture === 'build' && res.outcome === 'delivered' && res.verification
180
- ? { verification: envScrub(res.verification) } : {}),
186
+ ...(kind.posture === 'build' && res.outcome === 'delivered' && verification
187
+ ? { verification: envScrub(verification) } : {}),
181
188
  ...(kind.posture === 'build' && res.outcome === 'delivered' && res.handover
182
189
  ? { handover: envScrub(res.handover) } : {}),
183
190
  ...(kind.posture === 'build' && res.outcome === 'delivered' && res.automation
184
191
  ? { automation: envScrub(res.automation) } : {}),
185
192
  ...(commits.length ? { commits } : {}),
193
+ // …and the same gaps as data, so the server can propose the parts no
194
+ // card owned (`gapFollowUps.ts`). A report: an older server drops it.
195
+ ...(gaps && res.gaps?.length ? { gaps: res.gaps } : {}),
186
196
  // SCRUBBED like the answer beside it: a raised card lands in the
187
197
  // project doc every member reads, and a brief quoting the `.env` the
188
198
  // agent just read ("value sk_live_… is logged in pay.ts") went out
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.21",
3
+ "version": "0.125.23",
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": {