flowviant 0.114.1 → 0.115.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.
@@ -37,7 +37,7 @@ import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
37
37
  import { brainFor } from './workBrain.mjs';
38
38
  import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
39
39
  import { agentTurnSettlement } from './workAgentTurnOutcome.mjs';
40
- import { ensureArtifactDir } from './artifacts.mjs';
40
+ import { ensureArtifactDir, ensureFenceScratch } from './artifacts.mjs';
41
41
  import { agentBrowserHome } from './noWindowEnv.mjs';
42
42
 
43
43
  export function createAgentTurnExecution({
@@ -176,7 +176,8 @@ export function createAgentTurnExecution({
176
176
  * posture, which can write ONLY `.flowviant/artifacts/` (claudePosture.mjs).
177
177
  *
178
178
  * A RUNTIME THAT CANNOT EXPRESS THE POSTURE IS REFUSED, NOT IMPROVISED.
179
- * Codex and antigravity declare neither (runtimeCodex.mjs,
179
+ * Antigravity declares none of them and Codex declares `design` (since
180
+ * 0.115.0) and `image` but not `research` (runtimeCodex.mjs,
180
181
  * runtimeAntigravity.mjs), and the only
181
182
  * alternative to refusing is running the card as a BUILD turn with
182
183
  * permissions skipped — an agent writing code for an ask that was a
@@ -248,6 +249,23 @@ export function createAgentTurnExecution({
248
249
  });
249
250
  return;
250
251
  }
252
+ /**
253
+ * …AND ITS SCRATCH BESIDE IT, EMPTY (0.115.0): a fenced shell's temp files
254
+ * (a here-document, `mktemp`) go under `.flowviant/tmp`, which Codex's
255
+ * design fence grants because `/tmp` is read-only in there
256
+ * (`ensureFenceScratch`). Made for every file kind — one rule, no kind
257
+ * named — and refused in words where it cannot stand as a real directory.
258
+ */
259
+ if (kind.artifact && !ensureFenceScratch(wt)) {
260
+ await postAgentTurn({
261
+ turnId,
262
+ outcome: 'nothing',
263
+ answer: "this worktree's .flowviant/tmp is not a real directory, so the fenced turn has no scratch to work in",
264
+ branch,
265
+ worktree: wt,
266
+ });
267
+ return;
268
+ }
251
269
 
252
270
  // ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
253
271
  // right here — the ambiguity that forced per-tab session pinning in the
@@ -469,7 +487,9 @@ export function createAgentTurnExecution({
469
487
  // The knowledge paragraph, when this box holds a library — the same
470
488
  // composer the tabs use, so an agent and a tab can never be told two
471
489
  // different things about the same directory.
472
- system: withProjectContext(SYSTEM_AGENT_FOR(taskKind), {
490
+ // Spoken to the CLI that runs it (0.115.0): a mockup on Codex is
491
+ // told it is Codex, never Claude.
492
+ system: withProjectContext(SYSTEM_AGENT_FOR(taskKind, RUNTIMES[rt]?.label), {
473
493
  knowledgeDir: knowledgeDirFor(repoRoot),
474
494
  // ARTIFACTS (0.94.0), while the server can show one — an agent's
475
495
  // land on its page, under the facts row.
@@ -42,6 +42,7 @@ import { REPO_PLACE } from './workPlaces.mjs';
42
42
  import { CODEX_THREAD_RE, AGY_CONV_RE } from './workSessionRuntime.mjs';
43
43
  import { createToolLog } from './workToolLog.mjs';
44
44
  import { createCommandAudit } from './workCommandAudit.mjs';
45
+ import { APPROVAL_REFUSED, codexApprovalSentence, codexLoginFailure, codexLoginSentence, withRelay } from './codexRelay.mjs';
45
46
 
46
47
  /**
47
48
  * THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
@@ -59,6 +60,14 @@ import { createCommandAudit } from './workCommandAudit.mjs';
59
60
  export const PLAN_TURN_SENTENCE =
60
61
  'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
61
62
 
63
+ /**
64
+ * HOW LONG, AND HOW MANY, SETTLED TURNS THE LANE REMEMBERS (2026-09-29) — see
65
+ * `settledHere` below. Ten minutes is far past any roster a poll could still
66
+ * be holding; the cap keeps a box that answers all day from growing the map.
67
+ */
68
+ export const SETTLED_HERE_TTL_MS = 10 * 60 * 1000;
69
+ export const SETTLED_HERE_MAX = 500;
70
+
62
71
  export function createWorkSessionTurns({
63
72
  repoRoot,
64
73
  baseDir,
@@ -91,14 +100,67 @@ export function createWorkSessionTurns({
91
100
  // The real CLI driver; injectable only so the lane can be driven in a test
92
101
  // without spending a model turn (the wiki runner's shape).
93
102
  runTurn = defaultRunTurn,
103
+ // The clock `settledHere` ages by; injectable only for its bound's test.
104
+ now = Date.now,
94
105
  }) {
95
106
  const workAnswering = new Set(); // turn ids currently queued/running here
96
107
  const MAX_WORK_TRIES = 3;
97
108
 
109
+ /**
110
+ * A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
111
+ *
112
+ * A delivered settle forgets the turn everywhere: the report queue drops it
113
+ * from `pendingWorkReports` and `workAttempts`, and the turn's `finally`
114
+ * drops it from `workAnswering`. A roster READ made before that settle
115
+ * landed still offers it — the poll was in flight while the POST went out —
116
+ * and nothing was left to say it had run: the turn ran twice, settled
117
+ * twice, the rerun's reply overwrote the first, and the person's follow-up
118
+ * waited behind a turn nobody asked for. So every settle this lane makes
119
+ * goes through `settleTurn`, which remembers the turn id, and a job in the
120
+ * map is skipped where jobs are picked. Remembered BEFORE the POST: a
121
+ * settle still in flight is covered by `workAnswering`, one that could not
122
+ * be delivered by `pendingWorkReports`, and this covers the one that was.
123
+ */
124
+ const settledHere = new Map(); // turnId -> when this lane settled it, oldest first
125
+ const forgetStale = (at) => {
126
+ for (const [id, when] of settledHere) {
127
+ if (settledHere.size <= SETTLED_HERE_MAX && at - when < SETTLED_HERE_TTL_MS) break;
128
+ settledHere.delete(id);
129
+ }
130
+ };
131
+ const settledRecently = (turnId) => {
132
+ forgetStale(now());
133
+ return settledHere.has(turnId);
134
+ };
135
+ const settleTurn = (turnId, payload) => {
136
+ const at = now();
137
+ settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
138
+ settledHere.set(turnId, at);
139
+ forgetStale(at);
140
+ return settleWorkTurn(turnId, payload);
141
+ };
142
+
143
+ /**
144
+ * A PLACE A TURN OF THIS PROCESS IS STARTING IN (2026-09-29) — the turn
145
+ * lock's in-process half, keyed by the lock's own path.
146
+ *
147
+ * `flowviant-turn.lock` is written at SPAWN, several awaits after it is
148
+ * read (a credential mint, the attachment fetch), so two turns of one
149
+ * session offered in one roster both read "unlocked" and both spawned: two
150
+ * CLIs resuming one conversation at once, the incoherence the lock exists
151
+ * to prevent. The claim is checked and taken synchronously beside the file
152
+ * check, so the second turn waits for a later poll like any locked one,
153
+ * and it is released in the turn's outer `finally`, after its settle.
154
+ */
155
+ const startingIn = new Set();
156
+
98
157
  const processWorkTurns = (jobs) => {
99
158
  for (const job of jobs ?? []) {
100
159
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
101
160
  if (workAnswering.has(job.id)) continue;
161
+ // Settled by this lane already: a roster read from before the settle
162
+ // landed. See `settledHere`.
163
+ if (settledRecently(job.id)) continue;
102
164
  // The turn already RAN and its answer sits in the delivery queue — never
103
165
  // run it again while the report is merely undelivered.
104
166
  if (pendingWorkReports.has(job.id)) continue;
@@ -140,7 +202,7 @@ export function createWorkSessionTurns({
140
202
  * tab for the server's whole expiry window.
141
203
  */
142
204
  if (place !== REPO_PLACE && !isSafePathSegment(place)) {
143
- void settleWorkTurn(job.id, {
205
+ void settleTurn(job.id, {
144
206
  ok: false,
145
207
  answer:
146
208
  'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
@@ -178,12 +240,15 @@ export function createWorkSessionTurns({
178
240
  * out here beside `usage`, and spread wherever it is. Last write wins
179
241
  * across a fresh retry: that spawn is the one that answered. */
180
242
  let model = null;
243
+ /** The `startingIn` key this turn claimed — released in the outer
244
+ * `finally`, and only by the turn that took it. */
245
+ let startClaim = null;
181
246
  try {
182
247
  const tries = workAttempts.get(job.id) ?? 0;
183
248
  if (tries >= MAX_WORK_TRIES) {
184
249
  // Out of local tries: SETTLE, don't skip — a silently skipped turn
185
250
  // strands the tab for the server's whole 24h expiry window.
186
- await settleWorkTurn(job.id, {
251
+ await settleTurn(job.id, {
187
252
  ok: false,
188
253
  answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
189
254
  });
@@ -209,7 +274,7 @@ export function createWorkSessionTurns({
209
274
  typeof job.adopt.id !== 'string' ||
210
275
  !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
211
276
  ) {
212
- await settleWorkTurn(job.id, {
277
+ await settleTurn(job.id, {
213
278
  ok: false,
214
279
  answer: 'that terminal session id is not one this machine can resume',
215
280
  });
@@ -223,7 +288,7 @@ export function createWorkSessionTurns({
223
288
  srcCwd = null;
224
289
  }
225
290
  if (!srcCwd) {
226
- await settleWorkTurn(job.id, {
291
+ await settleTurn(job.id, {
227
292
  ok: false,
228
293
  answer: "the terminal session's directory no longer exists on the machine",
229
294
  });
@@ -247,14 +312,14 @@ export function createWorkSessionTurns({
247
312
  /* keep the literal path */
248
313
  }
249
314
  if (!under(srcCwd, realRoot)) {
250
- await settleWorkTurn(job.id, {
315
+ await settleTurn(job.id, {
251
316
  ok: false,
252
317
  answer: "the terminal session's directory is outside this project's repository",
253
318
  });
254
319
  return;
255
320
  }
256
321
  if (under(srcCwd, realBase)) {
257
- await settleWorkTurn(job.id, {
322
+ await settleTurn(job.id, {
258
323
  ok: false,
259
324
  answer:
260
325
  "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
@@ -264,7 +329,7 @@ export function createWorkSessionTurns({
264
329
  try {
265
330
  srcHead = git(['rev-parse', 'HEAD'], srcCwd);
266
331
  } catch {
267
- await settleWorkTurn(job.id, {
332
+ await settleTurn(job.id, {
268
333
  ok: false,
269
334
  answer:
270
335
  "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
@@ -281,7 +346,7 @@ export function createWorkSessionTurns({
281
346
  ? isAgyConversationLive(job.adopt.id)
282
347
  : isTerminalSessionLive(job.adopt.id);
283
348
  if (adoptLive) {
284
- await settleWorkTurn(job.id, {
349
+ await settleTurn(job.id, {
285
350
  ok: false,
286
351
  answer:
287
352
  'That terminal session is still open on the machine — close it there first, then adopt.',
@@ -297,7 +362,7 @@ export function createWorkSessionTurns({
297
362
  // the session's own id, which is what every tab has always done.
298
363
  const dir = placeWtFor(place, adopting ? srcHead : undefined);
299
364
  if (!dir) {
300
- await settleWorkTurn(job.id, {
365
+ await settleTurn(job.id, {
301
366
  ok: false,
302
367
  answer:
303
368
  'the session worktree could not be opened on the machine — check the daemon log',
@@ -308,13 +373,20 @@ export function createWorkSessionTurns({
308
373
  // life, most likely; the lock outlives a restart. Leave the job
309
374
  // pending and look again next poll; spawning a second CLI would put
310
375
  // two Claudes in one held context. Costs no attempt: nothing ran.
376
+ // …AND ONE OF OURS MAY BE ABOUT TO: `startingIn`, checked and
377
+ // claimed with no await between, because the file is only written
378
+ // at spawn. Keyed by the lock's path (the directory, when the git
379
+ // dir cannot be named) — the file lock's own scope.
311
380
  const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
312
- if (turnLockedByLivePid(lockPath)) {
381
+ const startKey = lockPath || dir.wt;
382
+ if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
313
383
  warn(
314
384
  `a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
315
385
  );
316
386
  return;
317
387
  }
388
+ startingIn.add(startKey);
389
+ startClaim = startKey;
318
390
  // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
319
391
  // which is what every tab ran on until now) — honored by
320
392
  // sessionRuntime: on a first turn a named runtime IS the pick, and a
@@ -327,14 +399,14 @@ export function createWorkSessionTurns({
327
399
  if (rt.mismatch) {
328
400
  // Something upstream changed this tab's identity mid-life. A held
329
401
  // context must never be answered by a different brain — say so.
330
- await settleWorkTurn(job.id, {
402
+ await settleTurn(job.id, {
331
403
  ok: false,
332
404
  answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
333
405
  });
334
406
  return;
335
407
  }
336
408
  if (rt.missing) {
337
- await settleWorkTurn(job.id, {
409
+ await settleTurn(job.id, {
338
410
  ok: false,
339
411
  answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
340
412
  });
@@ -346,14 +418,14 @@ export function createWorkSessionTurns({
346
418
  // tab can run on (Antigravity has no MCP config, and the session's
347
419
  // whole control plane rides one). An honest sentence beats the
348
420
  // mcpFor throw this used to crash into every turn.
349
- await settleWorkTurn(job.id, {
421
+ await settleTurn(job.id, {
350
422
  ok: false,
351
423
  answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
352
424
  });
353
425
  return;
354
426
  }
355
427
  if (!rt.id) {
356
- await settleWorkTurn(job.id, {
428
+ await settleTurn(job.id, {
357
429
  ok: false,
358
430
  answer:
359
431
  'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
@@ -366,7 +438,7 @@ export function createWorkSessionTurns({
366
438
  // (--conversation). Codex has no adoptable store yet, and its
367
439
  // args builder backstops this with a loud throw — but a sentence
368
440
  // here beats a stack there.
369
- await settleWorkTurn(job.id, {
441
+ await settleTurn(job.id, {
370
442
  ok: false,
371
443
  answer:
372
444
  'adopting this terminal session needs its own CLI on the machine — install it, then try again',
@@ -383,7 +455,7 @@ export function createWorkSessionTurns({
383
455
  */
384
456
  const planTurn = job.planMode === true && job.capture !== true;
385
457
  if (planTurn && rt.id !== 'claude') {
386
- await settleWorkTurn(job.id, {
458
+ await settleTurn(job.id, {
387
459
  ok: false,
388
460
  answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
389
461
  });
@@ -409,7 +481,7 @@ export function createWorkSessionTurns({
409
481
  return;
410
482
  }
411
483
  if (mint?.gone) {
412
- await settleWorkTurn(job.id, {
484
+ await settleTurn(job.id, {
413
485
  ok: false,
414
486
  answer:
415
487
  'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
@@ -417,7 +489,7 @@ export function createWorkSessionTurns({
417
489
  return;
418
490
  }
419
491
  if (!mint?.token) {
420
- await settleWorkTurn(job.id, {
492
+ await settleTurn(job.id, {
421
493
  ok: false,
422
494
  answer:
423
495
  'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
@@ -554,6 +626,11 @@ export function createWorkSessionTurns({
554
626
  let out;
555
627
  let seenThreadId = null; // codex's conversation id, off thread.started
556
628
  let seenClaudeSession = null; // claude's own conversation id, off system.init
629
+ // What a Codex turn's own events say went wrong (codexRelay.mjs):
630
+ // whether the agent said anything at all, and each tool call Codex
631
+ // refused for want of an approval, by `server.tool`.
632
+ let codexSpoke = false;
633
+ const approvalRefused = [];
557
634
  const spawned = []; // this turn's children, for the teardown registry
558
635
  // THE TURN'S TOOL LOG — see workToolLog.mjs for its shape rules.
559
636
  const turnLog = createToolLog(dir.wt);
@@ -662,6 +739,12 @@ export function createWorkSessionTurns({
662
739
  onActivity: (a) => {
663
740
  narrator.line(a?.label);
664
741
  auditCommand(a);
742
+ if (a?.kind === 'tool' && APPROVAL_REFUSED.test(a.error ?? '')) approvalRefused.push(a.label);
743
+ },
744
+ // One Codex agent MESSAGE (runtimeEvents.mjs `answer`): the turn
745
+ // spoke, so a 401 in its words is its words, not its failure.
746
+ onAnswer: (a) => {
747
+ if (String(a ?? '').trim()) codexSpoke = true;
665
748
  },
666
749
  // The structured twin of the line above — see toolLog.
667
750
  onToolEvent: pushToolEvent,
@@ -765,6 +848,11 @@ export function createWorkSessionTurns({
765
848
  * that tab replied identically, with nothing on any surface able
766
849
  * to clear it. A tab bricked forever by its own CLI pruning its
767
850
  * history, which it does on its own schedule.
851
+ *
852
+ * An EMPTY Claude resume that emitted init is not retried
853
+ * (2026-09-29, resumeLost.mjs): its conversation was found, and a
854
+ * fresh run would answer with no memory of the chat and re-pin the
855
+ * tab to that new conversation.
768
856
  */
769
857
  if (
770
858
  !adopting &&
@@ -831,7 +919,7 @@ export function createWorkSessionTurns({
831
919
  // transcript is most likely gone (cleaned, expired, deleted). Say
832
920
  // exactly that; no sessionRef is recorded, so the server keeps
833
921
  // offering the adoption and a retry after the user checks is cheap.
834
- await settleWorkTurn(job.id, {
922
+ await settleTurn(job.id, {
835
923
  ok: false,
836
924
  answer: "Couldn't resume the terminal session — it may have been removed.",
837
925
  // Whatever it DID before coming back empty is exactly the
@@ -862,17 +950,42 @@ export function createWorkSessionTurns({
862
950
  }
863
951
  }
864
952
  }
865
- await settleWorkTurn(job.id, {
866
- ok: answer.length > 0,
867
- answer:
868
- answer.length > 0
869
- ? // Scrub: a reply can quote config or env-adjacent code.
870
- envScrub(answer).slice(0, 16000)
871
- : 'the turn produced no output on the machine — its CLI may be signed out; try again',
953
+ /**
954
+ * NO WORDS, SAID AS MEASURED (2026-09-29). Claude's init event means
955
+ * it ran in this conversation — kept, and resumed by the next
956
+ * message, since an empty resume that found it no longer runs fresh
957
+ * (resumeLost.mjs) — and its result held no words. "Signed out" is
958
+ * a guess, left to the case where no init came.
959
+ */
960
+ const noWords =
961
+ rt.id === 'claude' && seenClaudeSession
962
+ ? 'Claude Code ran in this conversation and returned no words — send the message again'
963
+ : 'the turn produced no output on the machine — its CLI may be signed out; try again';
964
+ /**
965
+ * A CODEX TURN SAYS WHAT ITS OWN WORDS MEASURED (2026-09-29,
966
+ * codexRelay.mjs). A turn that never spoke and whose output is a
967
+ * 401 was not answered: it fails, its sentence first and Codex's
968
+ * retry log kept under it — it used to settle as the chat's REPLY.
969
+ * Tool calls Codex refused for want of an approval are named above
970
+ * whatever the agent said, so a chat that staged nothing says why.
971
+ */
972
+ const loginFailure = rt.id === 'codex' && !codexSpoke ? codexLoginFailure(answer) : null;
973
+ const refusedLine = rt.id === 'codex' ? codexApprovalSentence(approvalRefused) : null;
974
+ const spoke = answer.length > 0 && !loginFailure;
975
+ await settleTurn(job.id, {
976
+ ok: spoke,
977
+ answer: loginFailure
978
+ ? withRelay(codexLoginSentence(loginFailure), answer)
979
+ : answer.length > 0
980
+ ? refusedLine
981
+ ? withRelay(refusedLine, answer)
982
+ : // Scrub: a reply can quote config or env-adjacent code.
983
+ envScrub(answer).slice(0, 16000)
984
+ : (refusedLine ?? noWords),
872
985
  // Only a turn that actually SPOKE proves a conversation lives
873
986
  // here. Recording the path unconditionally is how a crashed first
874
987
  // turn used to brick resume for the session's whole life.
875
- ...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
988
+ ...(spoke ? { sessionRef: dir.wt } : {}),
876
989
  // The turn's tool log, in final form — the durable copy that lands
877
990
  // on the settled message (the live copy on the record is cleared
878
991
  // at settle). Already scrubbed at collection.
@@ -880,10 +993,11 @@ export function createWorkSessionTurns({
880
993
  ...(usage ? { usage } : {}),
881
994
  ...(model ? { model } : {}),
882
995
  });
883
- if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
996
+ if (spoke) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
997
+ else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
884
998
  else warn('session turn produced no output — settled as failed');
885
999
  } catch (e) {
886
- await settleWorkTurn(job.id, {
1000
+ await settleTurn(job.id, {
887
1001
  ok: false,
888
1002
  // Scrub, like every string that leaves this machine: an exception
889
1003
  // routinely quotes command output, and command output can quote a
@@ -899,6 +1013,7 @@ export function createWorkSessionTurns({
899
1013
  warn(`session turn failed: ${e?.message ?? e}`);
900
1014
  } finally {
901
1015
  workAnswering.delete(job.id);
1016
+ if (startClaim) startingIn.delete(startClaim);
902
1017
  // Nothing spawned, or everything already has: releasing twice is the
903
1018
  // normal case and costs nothing. A reservation that leaked would
904
1019
  // shrink this machine's ceiling for the life of the process.
@@ -928,5 +1043,5 @@ export function createWorkSessionTurns({
928
1043
  }
929
1044
  };
930
1045
 
931
- return { processWorkTurns, workAnswering };
1046
+ return { processWorkTurns, workAnswering, settledHere };
932
1047
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.114.1",
3
+ "version": "0.115.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — 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": {