flowviant 0.45.0 → 0.47.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.
@@ -233,7 +233,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
233
233
  // returned string for sentinel detection, and each activity is handed to
234
234
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
235
235
  // off and keep the raw text passthrough + line sentinels.
236
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, onActivity, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId }) {
236
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, onActivity, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
237
237
  return new Promise((resolve) => {
238
238
  const rt = runtimeById(runtime);
239
239
  if (!rt.args) {
@@ -277,9 +277,11 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
277
277
  streamJson,
278
278
  profile,
279
279
  // Adopting a terminal session (work.mjs): Claude turns it into
280
- // `--resume <id> --fork-session`; every other runtime THROWS on it, so a
281
- // mis-wired adoption fails as a loud turn error rather than a silent
282
- // fresh conversation wearing an adopted session's name.
280
+ // `--resume <id> --fork-session` (a FORK the original is untouched);
281
+ // agy turns it into `--conversation <id>` (a MOVE agy has no fork, the
282
+ // tab continues the terminal conversation itself). Codex THROWS on it,
283
+ // so a mis-wired adoption fails as a loud turn error rather than a
284
+ // silent fresh conversation wearing an adopted session's name.
283
285
  adoptResumeId,
284
286
  // Only the wiki profile uses it, but it is passed unconditionally: a
285
287
  // runtime that can path-scope its writes needs to know WHERE the vault is,
@@ -296,6 +298,13 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
296
298
  // positional, so a flag after it is a flag in the wrong place.
297
299
  // Wiki-vault turns are pure file work and pass neither — no MCP at all.
298
300
  mcp: mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
301
+ // Resuming a SPECIFIC held conversation by its own id (work.mjs, codex
302
+ // sessions). Runtimes without a by-id resume ignore it and keep their
303
+ // `resume` behavior unchanged.
304
+ resumeThreadId,
305
+ // agy's by-id resume (work.mjs, antigravity sessions): the conversation
306
+ // id learned from the adopt hint or the cwd registry after a turn.
307
+ resumeConversationId,
299
308
  });
300
309
  // Whatever this machine is signed in with, we use. We do NOT pick.
301
310
  //
@@ -338,6 +347,10 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
338
347
  if (!rt.parse) return handleStreamLine(line, { cwd, emit, onActivity, appendText });
339
348
  const ev = rt.parse(line, cwd);
340
349
  if (!ev) return;
350
+ // The conversation id, when the runtime announces one (codex's
351
+ // thread.started). Purely additive: callers that pass no onThreadId —
352
+ // every dispatch path — see zero behavior change.
353
+ if (ev.threadId) onThreadId?.(ev.threadId);
341
354
  if (ev.text) appendText(ev.text);
342
355
  if (ev.activity) {
343
356
  emit(`${ev.activity.label}\n`);
package/bin/lib/fleet.mjs CHANGED
@@ -94,6 +94,13 @@ async function fetchRoster(haveIds) {
94
94
  // machine knows its cores, its RAM and whose Claude quota is being spent.
95
95
  // Older servers ignore the param, so sending it is always safe.
96
96
  url.searchParams.set('capacity', String(MAX_CONCURRENT));
97
+ // WHICH DAEMON this machine runs, so the server can gate version-dependent
98
+ // work — codex Workbench tabs are only created for machines whose daemon can
99
+ // actually serve them (dv >= 0.46.0). The same source the self-update check
100
+ // compares against the roster's daemon.latest (config.mjs VERSION, read off
101
+ // our own package.json). Older servers ignore unknown params, so sending it
102
+ // unconditionally is always safe.
103
+ url.searchParams.set('dv', VERSION);
97
104
  // WHICH CLIs this machine actually has, so the app can stop guessing.
98
105
  //
99
106
  // Until now every surface that listed Gemini or Codex said "not wired up yet"
@@ -262,5 +262,137 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
262
262
  } catch {
263
263
  /* presence must never throw into the poll loop — report what was gathered */
264
264
  }
265
- return [...live.slice(0, REPORT_CAP), ...ended];
265
+ const claude = [...live.slice(0, REPORT_CAP), ...ended];
266
+ // agy rides in whatever room the cap leaves — Claude sessions first, they
267
+ // are the ones adoption serves best (fork, never move).
268
+ const agy = scanAgyConversations({ repoRoot, excludeDirs }).slice(
269
+ 0,
270
+ Math.max(0, REPORT_CAP - claude.length)
271
+ );
272
+ return [...claude, ...agy];
273
+ }
274
+
275
+ // ── Antigravity (agy) ──────────────────────────────────────────────────────
276
+ //
277
+ // agy's store is nothing like Claude's: one SQLite db per conversation at
278
+ // ~/.gemini/antigravity-cli/conversations/<uuid>.db (global, not cwd-keyed),
279
+ // no per-pid liveness registry that survives contact (the presence/*.lock
280
+ // files sit untouched by real runs — measured), and the only cwd mapping is
281
+ // cache/last_conversations.json: {cwd → the LAST conversation run there}.
282
+ // So the honest agy report is a SUBSET — the last conversation per directory
283
+ // inside this repo — and that is exactly the one `agy --continue` would give
284
+ // the person at that keyboard, i.e. the one worth offering to adopt.
285
+
286
+ const AGY_DIR = () => join(homedir(), '.gemini', 'antigravity-cli');
287
+ const AGY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
288
+
289
+ /** Newest write to the conversation's store — the wal carries recent turns,
290
+ * so its mtime (not the db's) is the real "last active" (measured: a resume
291
+ * touched db+wal, and never the presence lock). 0 = no such conversation. */
292
+ function agyLastWriteMs(id) {
293
+ if (!AGY_UUID_RE.test(id)) return 0;
294
+ let newest = 0;
295
+ for (const suffix of ['.db', '.db-wal']) {
296
+ try {
297
+ const t = statSync(join(AGY_DIR(), 'conversations', `${id}${suffix}`)).mtimeMs;
298
+ if (t > newest) newest = t;
299
+ } catch {
300
+ /* absent half is fine — the db alone still answers */
301
+ }
302
+ }
303
+ return newest;
304
+ }
305
+
306
+ /** Any agy process on the machine right now? /proc comm scan — cheap at the
307
+ * 60s cadence, and the only liveness signal agy leaves (locks are inert). */
308
+ function agyProcessAlive() {
309
+ try {
310
+ for (const name of readdirSync('/proc')) {
311
+ if (!/^\d+$/.test(name)) continue;
312
+ try {
313
+ if (readFileSync(`/proc/${name}/comm`, 'utf8').trim() === 'agy') return true;
314
+ } catch {
315
+ /* raced exit — keep scanning */
316
+ }
317
+ }
318
+ } catch {
319
+ /* no /proc — call nothing live rather than everything */
320
+ }
321
+ return false;
322
+ }
323
+
324
+ /**
325
+ * Is this agy conversation being driven RIGHT NOW? agy cannot answer
326
+ * per-conversation, so this is the conservative composite: an agy process
327
+ * exists AND this conversation's store was written in the last 10 minutes.
328
+ * Adoption is a MOVE for agy (no fork exists — measured, "trajectory not
329
+ * found" on a renamed copy), so refusing a maybe-live conversation for a few
330
+ * minutes costs a retry; adopting an actually-live one puts two drivers on
331
+ * one store.
332
+ */
333
+ const AGY_LIVE_WINDOW_MS = 10 * 60 * 1000;
334
+ export function isAgyConversationLive(id) {
335
+ try {
336
+ if (!agyProcessAlive()) return false;
337
+ const t = agyLastWriteMs(id);
338
+ return t > 0 && Date.now() - t < AGY_LIVE_WINDOW_MS;
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ /** The repo's agy conversations, via the cwd registry — see the section
345
+ * comment for why this is deliberately a subset. */
346
+ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
347
+ const out = [];
348
+ try {
349
+ let realRoot;
350
+ try {
351
+ realRoot = realpathSync(repoRoot);
352
+ } catch {
353
+ return out;
354
+ }
355
+ const excludes = [];
356
+ for (const d of excludeDirs) {
357
+ if (!d) continue;
358
+ try {
359
+ excludes.push(realpathSync(d));
360
+ } catch {
361
+ excludes.push(String(d));
362
+ }
363
+ }
364
+ const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
365
+ const raw = readFileSync(join(AGY_DIR(), 'cache', 'last_conversations.json'), 'utf8');
366
+ const map = JSON.parse(raw);
367
+ if (!map || typeof map !== 'object') return out;
368
+ const cutoff = Date.now() - SEVEN_DAYS_MS;
369
+ const processUp = agyProcessAlive();
370
+ for (const [cwd, id] of Object.entries(map)) {
371
+ if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
372
+ let real;
373
+ try {
374
+ real = realpathSync(cwd);
375
+ } catch {
376
+ continue; // the directory is gone — nothing to point a tab at
377
+ }
378
+ if (!ours(real)) continue;
379
+ const lastMs = agyLastWriteMs(id);
380
+ if (!lastMs || lastMs < cutoff) continue;
381
+ out.push({
382
+ id,
383
+ cwd: real,
384
+ live: processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
385
+ lastActiveAt: new Date(lastMs).toISOString(),
386
+ runtime: 'antigravity',
387
+ });
388
+ }
389
+ out.sort(
390
+ (a, b) =>
391
+ (b.lastActiveAt < a.lastActiveAt ? -1 : b.lastActiveAt > a.lastActiveAt ? 1 : 0) ||
392
+ (a.id < b.id ? -1 : 1)
393
+ );
394
+ } catch {
395
+ /* no agy on this machine, or an unreadable registry — nothing to report */
396
+ }
397
+ return out;
266
398
  }
@@ -486,6 +486,41 @@ the way, say so — fixing it is allowed if it's small and obviously wanted.
486
486
 
487
487
  Write plain Markdown for a person watching a live session.`;
488
488
 
489
+ /**
490
+ * The PLAIN tab — a work session on a runtime that cannot mount MCP
491
+ * (Antigravity: its server list is machine-wide, measured). No Flowviant
492
+ * tools means no streaming, no cards, no purpose line — and the product
493
+ * stays honest anyway: the final answer is delivered by the daemon's own
494
+ * report, an uncarded session's rail says "no card yet" (a readout, not a
495
+ * failure), and ship-time reconciliation turns every branch commit into the
496
+ * ledger's record. What this prompt must NOT do is pretend the tools exist,
497
+ * or apologize for their absence every turn.
498
+ */
499
+ export const SYSTEM_WORK_PLAIN = `You are the human's own coding agent, working WITH them in their repository.
500
+ This is a persistent session — a tab they keep open — and it should feel like
501
+ working in a terminal: they talk, you work.
502
+
503
+ MECHANICS OF THIS TAB:
504
+
505
+ 1. THIS WORKTREE IS THE SESSION. You are on this tab's own branch. Edit freely,
506
+ commit as coherent units complete — small, honest commits with real
507
+ messages. Uncommitted state survives between turns; this directory is yours.
508
+ 2. YOUR FINAL MESSAGE IS YOUR REPLY. It is delivered into the tab when the turn
509
+ ends — there is no live streaming from this runtime, so make the final
510
+ message the complete, self-contained report of what you did and found.
511
+ 3. YOU HAVE NO FLOWVIANT TOOLS in this session — no cards, no ledger calls.
512
+ Don't mention or simulate them. Your commits ARE your record: when this
513
+ tab's branch ships, every commit is reconciled onto the project ledger.
514
+ 4. NEVER merge to main, deploy, or force-push unless the human explicitly says
515
+ so in this conversation. Branch pushes are fine when asked. Shipping is
516
+ their word to say, not yours to infer.
517
+
518
+ POSTURE: terminal, not ticket. Don't ask permission to look at things. Ground
519
+ claims in files you opened. When they ask a question, answer it; when they ask
520
+ for work, do it.
521
+
522
+ Write plain Markdown for a person reading your reply in a chat tab.`;
523
+
489
524
  export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
490
525
  // The speaker is the tab's OWNER — the same person who owns this machine —
491
526
  // so this is the one prompt whose author is fully trusted. The fence stays
@@ -497,6 +532,14 @@ export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName
497
532
  `${fence('WHAT THEY SAID', message)}\n\n` +
498
533
  `Stream your reply with stream_session_turn as you work.`;
499
534
 
535
+ /** The plain tab's kickoff: no session id (there is no tool to pass it to)
536
+ * and no streaming instruction — the final message is the reply. */
537
+ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =>
538
+ `Continue the session${sessionName ? ` "${sessionName}"` : ''}.\n\n` +
539
+ `${fence('WHO IS TALKING', askedByName || 'the tab owner')}\n\n` +
540
+ `${fence('WHAT THEY SAID', message)}\n\n` +
541
+ `Reply with your complete report when the work is done.`;
542
+
500
543
  /**
501
544
  * A quick edit running ALONGSIDE the task's own agent.
502
545
  *
@@ -123,9 +123,11 @@ function humanizeCodexItem(item = {}, cwd = '') {
123
123
  }
124
124
 
125
125
  /**
126
- * Codex `--json` emits JSONL of ThreadEvents. Returns `{ activity, text }` —
127
- * `text` accumulates the agent's own words, because the turn loop reads its
128
- * sentinels (NOTHING / BLOCKED:<id> / DONE) out of exactly that.
126
+ * Codex `--json` emits JSONL of ThreadEvents. Returns `{ activity, text,
127
+ * threadId }` — `text` accumulates the agent's own words, because the turn
128
+ * loop reads its sentinels (NOTHING / BLOCKED:<id> / DONE) out of exactly
129
+ * that; `threadId` surfaces once, off the lifecycle event, for callers that
130
+ * need to resume THIS conversation later (runTurn's onThreadId).
129
131
  */
130
132
  function parseCodexLine(line, cwd) {
131
133
  let ev;
@@ -135,6 +137,14 @@ function parseCodexLine(line, cwd) {
135
137
  return null; // not every line is JSON (warnings go to stderr, but be safe)
136
138
  }
137
139
  switch (ev.type) {
140
+ // The conversation's own id, announced before any item (`ThreadStarted` on
141
+ // the shipped 0.147 binary, like the measured events below). Surfaced so a
142
+ // SESSION turn can resume this exact thread next time: `resume --last` is
143
+ // a machine-global guess, and on a box running two tabs — or a tab plus a
144
+ // dispatch — it resumes someone else's conversation. No activity and no
145
+ // text: nothing here is the model speaking.
146
+ case 'thread.started':
147
+ return { activity: null, text: '', threadId: String(ev.thread_id ?? '') || null };
138
148
  case 'item.completed': {
139
149
  const item = ev.item ?? {};
140
150
  const activity = humanizeCodexItem(item, cwd);
@@ -165,7 +175,7 @@ function parseCodexLine(line, cwd) {
165
175
  text: `${ev.message ?? ''}\n`,
166
176
  };
167
177
  default:
168
- return null; // thread.started / turn.started / item.started / item.updated
178
+ return null; // turn.started / item.started / item.updated
169
179
  }
170
180
  }
171
181
 
@@ -380,14 +390,22 @@ export const RUNTIMES = {
380
390
  * placed before it. Appending them after the positional is the kind of argv
381
391
  * that parses today and stops parsing on some future clap upgrade.
382
392
  */
383
- args({ prompt, system, model, effort, resume, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [], adoptResumeId }) {
384
- // Adoption resumes a CLAUDE terminal session its transcript store, its
385
- // fork semantics. Reaching here with an adopt id is a wiring mistake
386
- // upstream, and it fails loudly on purpose: quietly dropping the flag
387
- // would answer that session's held context with a different brain.
388
- if (adoptResumeId) throw new Error('adoption is Claude-only — codex cannot resume a Claude terminal session');
393
+ args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [], adoptResumeId }) {
394
+ // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
395
+ // agy moves) codex has no adoptable store wired yet. Reaching here
396
+ // with an adopt id is a wiring mistake upstream, and it fails loudly on
397
+ // purpose: quietly dropping the flag would answer that session's held
398
+ // context with a different brain.
399
+ if (adoptResumeId) throw new Error("codex has no adoptable terminal store — an adopt id can't reach this builder");
389
400
  const a = ['exec'];
390
- if (resume) a.push('resume', '--last');
401
+ // BY ID when the caller knows WHICH conversation this is — a Workbench
402
+ // tab's held context, captured off thread.started and stored with its
403
+ // worktree. `--last` resumes the machine's most recent codex conversation,
404
+ // which is only safe on the dispatch path (one lane, one turn at a time,
405
+ // in its own worktree); for a session it is a machine-global guess that
406
+ // two tabs — or a tab plus a dispatch — would cross-resume.
407
+ if (resumeThreadId) a.push('resume', resumeThreadId);
408
+ else if (resume) a.push('resume', '--last');
391
409
  a.push('--json');
392
410
  if (model) a.push('--model', model);
393
411
  // Effort is a config value on Codex rather than a flag.
@@ -624,12 +642,18 @@ export const RUNTIMES = {
624
642
  */
625
643
  profiles: ['build', 'wiki', 'consult'],
626
644
  mcp: null,
627
- args({ prompt, system, model, effort, resume, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
628
- // Same loud refusal as Codex: an adopt id names a Claude session, and no
629
- // other runtime can resume one — see the claude builder for the contract.
630
- if (adoptResumeId) throw new Error('adoption is Claude-only — agy cannot resume a Claude terminal session');
645
+ args({ prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
631
646
  const a = [];
632
- if (resume) a.push('--continue');
647
+ // WHICH CONVERSATION IS THIS — one decision, answered one way (the same
648
+ // rule as Claude's --resume/--continue). By id when the caller knows:
649
+ // `--conversation <id>` resumes globally, any cwd (measured on 1.1.12).
650
+ // Adoption is the SAME argv because agy has no fork — the id in the db
651
+ // is the identity (a renamed copy fails "trajectory not found",
652
+ // measured), so adopting MOVES the conversation: the tab continues the
653
+ // terminal session itself, appending to its one store. `--continue` is
654
+ // the cwd-keyed fallback for a session whose id was never learned.
655
+ if (adoptResumeId || resumeConversationId) a.push('--conversation', adoptResumeId || resumeConversationId);
656
+ else if (resume) a.push('--continue');
633
657
  // No system-prompt flag, same weakening as Codex: the contract rides in
634
658
  // the prompt, fenced and first.
635
659
  a.push('-p', `${system}\n\n---\n\n${prompt}`);
package/bin/lib/work.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Work sessions — the Workbench tabs, daemon side.
3
3
  *
4
- * A tab is a held Claude session with BUILD permissions in a PERSISTENT
4
+ * A tab is a held coding-CLI session (Claude or codex the server names the
5
+ * brain per tab, and the pin holds it) with BUILD permissions in a PERSISTENT
5
6
  * worktree on its own `session/<id>` branch. Nothing here is detached and
6
7
  * nothing is ever reset — uncommitted state between turns IS the session, and
7
8
  * blowing it away would be closing the human's editor mid-thought. (Plan
@@ -33,10 +34,16 @@ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, REFRESH_BEFORE_SECONDS } from './co
33
34
  import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
34
35
  import { c, note, ok, warn } from './ui.mjs';
35
36
  import { mcpFor, runTurn } from './claude.mjs';
36
- import { SYSTEM_WORK, WORK_TURN_KICKOFF } from './prompts.mjs';
37
+ import {
38
+ SYSTEM_WORK,
39
+ WORK_TURN_KICKOFF,
40
+ SYSTEM_WORK_PLAIN,
41
+ WORK_TURN_KICKOFF_PLAIN,
42
+ } from './prompts.mjs';
37
43
  import { materializeInto, scrub as envScrub } from './env.mjs';
38
44
  import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
39
- import { isTerminalSessionLive } from './localSessions.mjs';
45
+ import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
46
+ import { homedir } from 'node:os';
40
47
 
41
48
  export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLeaseTtl }) {
42
49
  const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
@@ -338,18 +345,31 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
338
345
  * same reason). If the pinned CLI has left the machine, the turn settles
339
346
  * honestly instead of substituting. A retired-and-reattached directory has
340
347
  * no marker and no held context either, so re-picking there is correct.
348
+ *
349
+ * THE SERVER'S WORD COMES FIRST. A tab is created AS a runtime's tab
350
+ * (`job.runtime`; null/absent = Claude, which is what every tab ran on until
351
+ * now), so on the first turn a named runtime IS the pick — never a
352
+ * preference the machine may override. And a named runtime that DISAGREES
353
+ * with an existing pin is an identity change mid-life: something upstream
354
+ * now calls this tab a different brain's, and the only honest move is to
355
+ * settle the turn and say so ({ mismatch }), because a held context must
356
+ * never be answered by a different brain.
357
+ *
341
358
  * Returns { id } | { id: null } (nothing installed) | { missing: label } |
342
- * { unsupported: label } (pinned to a runtime no session can run on).
359
+ * { unsupported: label } (a runtime no session can run on) |
360
+ * { mismatch: { pin, runtime } } (labels, for the caller's sentence).
343
361
  *
344
- * SESSION-CAPABLE means rt.mcp is truthy, and the gate is not optional:
345
- * a session turn hands its per-session token over a real MCP config, so
346
- * `pickRuntimeFor('build')` is the WRONG question here it also says yes
347
- * to the MEDIATED build path (Antigravity, mcp: null), and a session pinned
348
- * that way threw in mcpFor on every turn, failing the tab with an internal
349
- * error instead of a sentence.
362
+ * SESSION-CAPABLE means rt.mcp is truthy the session tools ride a real
363
+ * per-invocation MCP config OR the runtime runs tabs PLAIN (Antigravity):
364
+ * no MCP at all, no cards, no streaming; the final answer is delivered by
365
+ * the daemon's own report and ship-time reconciliation keeps the ledger
366
+ * whole. `pickRuntimeFor('build')` is still the WRONG question here it
367
+ * says yes to the mediated DISPATCH path without saying how a tab would
368
+ * speak, and a session pinned by it once threw in mcpFor on every turn.
350
369
  */
351
- const sessionCapable = (rid) => Boolean(RUNTIMES[rid]?.mcp) && canRun(RUNTIMES[rid], 'build');
352
- const sessionRuntime = (wt) => {
370
+ const sessionCapable = (rid) =>
371
+ (Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
372
+ const sessionRuntime = (wt, jobRuntime) => {
353
373
  const marker = sessionMetaPath(wt, 'flowviant-runtime');
354
374
  let pinned = null;
355
375
  if (marker && existsSync(marker)) {
@@ -360,6 +380,14 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
360
380
  }
361
381
  }
362
382
  if (pinned && RUNTIMES[pinned]) {
383
+ if (jobRuntime && jobRuntime !== pinned) {
384
+ return {
385
+ mismatch: {
386
+ pin: RUNTIMES[pinned].label || pinned,
387
+ runtime: RUNTIMES[jobRuntime]?.label || jobRuntime,
388
+ },
389
+ };
390
+ }
363
391
  // A pin that names a non-session-capable runtime is settled honestly by
364
392
  // the caller, not silently re-picked: re-picking would hand the held
365
393
  // context to a different brain, which is the exact substitution the pin
@@ -368,10 +396,33 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
368
396
  const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
369
397
  return installed ? { id: pinned } : { missing: RUNTIMES[pinned].label || pinned };
370
398
  }
371
- // The fresh pick, gated the same way Claude first when it qualifies, for
372
- // the reason pickRuntimeFor gives: the prompts were tuned against it.
399
+ // First turn, and the server named the brain: that IS the pick, gated the
400
+ // same two ways as a pin not session-capable and not installed both
401
+ // settle honestly via the caller's existing paths, never substituted.
402
+ if (jobRuntime) {
403
+ if (!sessionCapable(jobRuntime))
404
+ return { unsupported: RUNTIMES[jobRuntime]?.label || jobRuntime };
405
+ const installed = detectRuntimes().find((r) => r.id === jobRuntime)?.installed;
406
+ if (!installed) return { missing: RUNTIMES[jobRuntime]?.label || jobRuntime };
407
+ if (marker) {
408
+ try {
409
+ writeFileSync(marker, jobRuntime);
410
+ } catch {
411
+ /* best-effort — an unpinnable session just re-picks next turn */
412
+ }
413
+ }
414
+ return { id: jobRuntime };
415
+ }
416
+ // The fresh pick — Claude first when it qualifies, for the reason
417
+ // pickRuntimeFor gives: the prompts were tuned against it. DELIBERATELY
418
+ // NARROWER than sessionCapable: a PLAIN tab (Antigravity — no cards, no
419
+ // streaming) is a degraded mode someone CHOOSES, so it is honored only
420
+ // when the server names it, never handed out as a default.
373
421
  const rows = detectRuntimes();
374
- const okFor = (rid) => sessionCapable(rid) && Boolean(rows.find((r) => r.id === rid)?.installed);
422
+ const okFor = (rid) =>
423
+ Boolean(RUNTIMES[rid]?.mcp) &&
424
+ sessionCapable(rid) &&
425
+ Boolean(rows.find((r) => r.id === rid)?.installed);
375
426
  const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);
376
427
  if (!id) return { id: null };
377
428
  if (marker) {
@@ -384,6 +435,51 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
384
435
  return { id };
385
436
  };
386
437
 
438
+ /**
439
+ * The shape a codex thread id must have before it is written to disk or —
440
+ * decisive — pushed into argv as `resume <id>`. Conservative on purpose:
441
+ * alphanumeric plus dash/underscore, never a leading dash (an argv that
442
+ * parses as a flag), never whitespace. Anything else is dropped and the
443
+ * session simply runs fresh in its own worktree.
444
+ */
445
+ const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
446
+
447
+ /** agy conversation ids are plain UUIDs (the db filename IS the identity —
448
+ * measured: a renamed copy fails "trajectory not found"). Guarded the same
449
+ * way as the codex id: it rides in argv as `--conversation <id>`. */
450
+ const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
451
+
452
+ /** agy's own cwd registry — {cwd → the conversation that ran there LAST}.
453
+ * Read once, right after a fresh agy turn, to learn the id the turn just
454
+ * created; from then on the tab's marker is the identity and this registry
455
+ * is never consulted again (a dispatch sharing the machine may overwrite
456
+ * the cwd's entry between turns). */
457
+ const agyRegistryLookup = (cwd) => {
458
+ try {
459
+ const raw = readFileSync(
460
+ join(homedir(), '.gemini', 'antigravity-cli', 'cache', 'last_conversations.json'),
461
+ 'utf8'
462
+ );
463
+ const map = JSON.parse(raw);
464
+ if (!map || typeof map !== 'object') return null;
465
+ // agy keys by the cwd as IT resolved it — try our literal path and its
466
+ // realpath, so a symlinked home doesn't orphan the lookup.
467
+ let keys = [cwd];
468
+ try {
469
+ keys.push(realpathSync(cwd));
470
+ } catch {
471
+ /* the literal alone, then */
472
+ }
473
+ for (const k of keys) {
474
+ const id = map[k];
475
+ if (typeof id === 'string' && AGY_CONV_RE.test(id)) return id;
476
+ }
477
+ return null;
478
+ } catch {
479
+ return null;
480
+ }
481
+ };
482
+
387
483
  /**
388
484
  * The spawn lock: the pid of the CLI currently live in this worktree. A
389
485
  * restarted daemon must not put a second Claude into a directory the orphan
@@ -509,18 +605,6 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
509
605
  note(
510
606
  `${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
511
607
  );
512
- // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
513
- // which is what every tab ran on until now). The phase-2 hook: this
514
- // daemon drives Claude tabs only, and a runtime it cannot honor is
515
- // settled honestly — never answered by a different brain wearing the
516
- // session's name.
517
- if (job.runtime && job.runtime !== 'claude') {
518
- await settleWorkTurn(job.id, {
519
- ok: false,
520
- answer: `This machine's daemon serves Claude tabs only for now — runtime '${job.runtime}' isn't supported yet.`,
521
- });
522
- return;
523
- }
524
608
  // ── ADOPTION: a tab born from a TERMINAL session ────────────────
525
609
  // The server sends `adopt {id, cwd}` only while the session has no
526
610
  // sessionRef — no turn has ever spoken from a worktree here — and
@@ -600,7 +684,16 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
600
684
  });
601
685
  return;
602
686
  }
603
- if (isTerminalSessionLive(job.adopt.id)) {
687
+ // Liveness by the SESSION's own runtime: Claude has a real pid
688
+ // registry; agy only leaves store-write recency + a process check,
689
+ // and adoption there is a MOVE (no fork exists), so the composite
690
+ // errs toward refusing — a false "live" costs a retry in minutes,
691
+ // a false "ended" puts two drivers on one conversation store.
692
+ const adoptLive =
693
+ job.runtime === 'antigravity'
694
+ ? isAgyConversationLive(job.adopt.id)
695
+ : isTerminalSessionLive(job.adopt.id);
696
+ if (adoptLive) {
604
697
  await settleWorkTurn(job.id, {
605
698
  ok: false,
606
699
  answer:
@@ -632,7 +725,20 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
632
725
  );
633
726
  return;
634
727
  }
635
- const rt = sessionRuntime(dir.wt);
728
+ // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
729
+ // which is what every tab ran on until now) — honored by
730
+ // sessionRuntime: on a first turn a named runtime IS the pick, and a
731
+ // named runtime that disagrees with the pin settles below.
732
+ const rt = sessionRuntime(dir.wt, job.runtime || null);
733
+ if (rt.mismatch) {
734
+ // Something upstream changed this tab's identity mid-life. A held
735
+ // context must never be answered by a different brain — say so.
736
+ await settleWorkTurn(job.id, {
737
+ ok: false,
738
+ answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
739
+ });
740
+ return;
741
+ }
636
742
  if (rt.missing) {
637
743
  await settleWorkTurn(job.id, {
638
744
  ok: false,
@@ -641,13 +747,14 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
641
747
  return;
642
748
  }
643
749
  if (rt.unsupported) {
644
- // A pin from before the session-capable gate existed can name a
645
- // runtime no tab can run on (Antigravity has no MCP config, and
646
- // the session's whole control plane rides one). An honest sentence
647
- // beats the mcpFor throw this used to crash into every turn.
750
+ // A pin from before the session-capable gate existed or a
751
+ // first-turn tab the server named for one can carry a runtime no
752
+ // tab can run on (Antigravity has no MCP config, and the session's
753
+ // whole control plane rides one). An honest sentence beats the
754
+ // mcpFor throw this used to crash into every turn.
648
755
  await settleWorkTurn(job.id, {
649
756
  ok: false,
650
- answer: `this session is pinned to ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
757
+ answer: `this session runs on ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
651
758
  });
652
759
  return;
653
760
  }
@@ -659,41 +766,102 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
659
766
  });
660
767
  return;
661
768
  }
662
- if (adopting && rt.id !== 'claude') {
663
- // The adopt id names a CLAUDE conversation; only claude can fork
664
- // it (--resume --fork-session). The runtimes registry backstops
665
- // this with a loud throw, but a sentence here beats a stack there.
769
+ if (adopting && rt.id !== 'claude' && rt.id !== 'antigravity') {
770
+ // An adopt id names a conversation in ITS OWN CLI's store: claude
771
+ // forks it (--resume --fork-session), agy moves it
772
+ // (--conversation). Codex has no adoptable store yet, and its
773
+ // args builder backstops this with a loud throw — but a sentence
774
+ // here beats a stack there.
666
775
  await settleWorkTurn(job.id, {
667
776
  ok: false,
668
777
  answer:
669
- 'adopting a terminal session needs Claude Code on the machine — install it, then try again',
778
+ 'adopting this terminal session needs its own CLI on the machine — install it, then try again',
670
779
  });
671
780
  return;
672
781
  }
673
- let mint = await mintWorkToken(job.sessionId);
674
- if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip a dead turn
675
- if (mint?.gone) {
676
- await settleWorkTurn(job.id, {
677
- ok: false,
678
- answer:
679
- 'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
680
- });
681
- return;
782
+ // A PLAIN tab (agy) mounts no MCP: no credential to mint, no config
783
+ // to write. The trade is stated in SYSTEM_WORK_PLAIN no cards, no
784
+ // streaming — and the honesty survives on the existing rails: the
785
+ // answer lands via work-turn-done, the rail says "no card yet", and
786
+ // ship-time reconciliation books every branch commit.
787
+ const plainTab = rt.id === 'antigravity';
788
+ let mint = null;
789
+ if (!plainTab) {
790
+ mint = await mintWorkToken(job.sessionId);
791
+ if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
792
+ if (mint?.gone) {
793
+ await settleWorkTurn(job.id, {
794
+ ok: false,
795
+ answer:
796
+ 'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
797
+ });
798
+ return;
799
+ }
800
+ if (!mint?.token) {
801
+ await settleWorkTurn(job.id, {
802
+ ok: false,
803
+ answer:
804
+ 'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
805
+ });
806
+ return;
807
+ }
682
808
  }
683
- if (!mint?.token) {
684
- await settleWorkTurn(job.id, {
685
- ok: false,
686
- answer:
687
- 'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
688
- });
689
- return;
809
+ // CODEX RESUMES BY THREAD ID, never by `--last`: `resume --last` is
810
+ // the MACHINE's most recent codex conversation, and two codex tabs —
811
+ // or a tab plus a codex dispatch — would cross-resume each other's
812
+ // context. The id was captured off thread.started (runtimes.mjs) and
813
+ // persisted below, beside the runtime pin; absent, the turn runs
814
+ // FRESH in the same worktree — the dirty state is most of the held
815
+ // context, and a machine-global guess is someone else's conversation.
816
+ let codexResumeId = null;
817
+ if (rt.id === 'codex') {
818
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
819
+ if (threadMarker && existsSync(threadMarker)) {
820
+ try {
821
+ const v = readFileSync(threadMarker, 'utf8').trim();
822
+ if (CODEX_THREAD_RE.test(v)) codexResumeId = v;
823
+ } catch {
824
+ /* unreadable marker — run fresh */
825
+ }
826
+ }
827
+ }
828
+ // AGY RESUMES BY CONVERSATION ID, learned once and pinned beside the
829
+ // runtime marker: an adopted tab knows it from the adopt hint; a new
830
+ // tab learns it from agy's own cwd registry after its first turn.
831
+ // The marker beats `--continue` because it is the tab's OWN identity
832
+ // — the registry maps a cwd to whatever ran there LAST, and a
833
+ // dispatch sharing the machine could overwrite that between turns.
834
+ let agyConvId = null;
835
+ if (rt.id === 'antigravity' && !adopting) {
836
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
837
+ if (convMarker && existsSync(convMarker)) {
838
+ try {
839
+ const v = readFileSync(convMarker, 'utf8').trim();
840
+ if (AGY_CONV_RE.test(v)) agyConvId = v;
841
+ } catch {
842
+ /* unreadable marker — run fresh */
843
+ }
844
+ }
690
845
  }
691
- // Resume iff a conversation is known to live in THIS directory: the
692
- // server's sessionRef is only ever a path some turn actually SPOKE
693
- // from (see the settle below), and it must match the directory we
694
- // just opened. Anything else starts fresh IN the existing worktree —
695
- // never a reset; the dirty state is the session.
696
- const resume = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
846
+ // Resume iff a conversation is known to live in THIS directory. For
847
+ // Claude that proof is the server's sessionRef only ever a path
848
+ // some turn actually SPOKE from (see the settle below), and it must
849
+ // match the directory we just opened. For codex it is the stored
850
+ // thread id, which lives IN the directory and is stronger. Anything
851
+ // else starts fresh IN the existing worktree never a reset; the
852
+ // dirty state is the session.
853
+ // agy layers its two resumes: the pinned conversation id when the
854
+ // marker exists (deterministic, registry-proof), else the Claude
855
+ // rule — a tab that has SPOKEN from this directory may `--continue`
856
+ // it (cwd-keyed; measured safe), so a lost marker degrades to the
857
+ // weaker resume instead of silently starting over.
858
+ const spokeHere = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
859
+ const resume =
860
+ rt.id === 'codex'
861
+ ? Boolean(codexResumeId)
862
+ : rt.id === 'antigravity'
863
+ ? Boolean(agyConvId) || spokeHere
864
+ : spokeHere;
697
865
  // The dirty carry, on the adopt worktree's FIRST life only: a
698
866
  // re-attempted adoption (the directory already exists) carried what
699
867
  // it could the first time, and re-applying would double it. A carry
@@ -701,31 +869,50 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
701
869
  // in the prompt, so the AGENT tells the user what stayed behind.
702
870
  let carryNote = '';
703
871
  if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
704
- const mcp = mcpFor(rt.id, mint.token, getMcpUrl());
872
+ const mcp = plainTab
873
+ ? { args: [], env: null, dir: null }
874
+ : mcpFor(rt.id, mint.token, getMcpUrl());
705
875
  // Attempts count RUNS: the infra refusals above consumed nothing and
706
876
  // settled on their own terms.
707
877
  workAttempts.set(job.id, tries + 1);
708
878
  let out;
879
+ let seenThreadId = null; // codex's conversation id, off thread.started
709
880
  const spawned = []; // this turn's children, for the teardown registry
710
881
  try {
882
+ const message = carryNote ? `${job.body}\n\n${carryNote}` : job.body;
711
883
  const turnArgs = {
712
- prompt: WORK_TURN_KICKOFF({
713
- sessionId: job.sessionId,
714
- sessionName: job.sessionName,
715
- message: carryNote ? `${job.body}\n\n${carryNote}` : job.body,
716
- askedByName: job.askedByName,
717
- }),
884
+ // A plain tab has no tools to name and no session id to pass —
885
+ // its kickoff asks for one complete report instead of a stream.
886
+ prompt: plainTab
887
+ ? WORK_TURN_KICKOFF_PLAIN({
888
+ sessionName: job.sessionName,
889
+ message,
890
+ askedByName: job.askedByName,
891
+ })
892
+ : WORK_TURN_KICKOFF({
893
+ sessionId: job.sessionId,
894
+ sessionName: job.sessionName,
895
+ message,
896
+ askedByName: job.askedByName,
897
+ }),
718
898
  // The adopt turn resumes the TERMINAL conversation by forking it
719
899
  // into this cwd (claude: --resume <id> --fork-session). After it
720
900
  // speaks once, the fork lives natively here and turn 2+ is the
721
901
  // ordinary --continue resume path, unchanged.
722
902
  ...(adopting ? { adoptResumeId: job.adopt.id } : {}),
723
- system: SYSTEM_WORK,
903
+ system: plainTab ? SYSTEM_WORK_PLAIN : SYSTEM_WORK,
724
904
  cwd: dir.wt,
725
905
  mcpArgs: mcp.args,
726
906
  mcpEnv: mcp.env,
727
907
  runtime: rt.id,
728
908
  label: c.cyan('[tab]'),
909
+ // Only codex announces one (thread.started); held here so the id
910
+ // this turn actually SPOKE under is what gets persisted after it
911
+ // ends. Last write wins on purpose: a failed resume that fell
912
+ // back to fresh reports the fresh run's id, healing the marker.
913
+ onThreadId: (id) => {
914
+ seenThreadId = String(id ?? '').trim() || seenThreadId;
915
+ },
729
916
  onSpawn: (ch) => {
730
917
  if (!ch) return;
731
918
  spawned.push(ch);
@@ -739,15 +926,23 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
739
926
  }
740
927
  },
741
928
  };
742
- out = await runTurn({ ...turnArgs, resume });
929
+ out = await runTurn({
930
+ ...turnArgs,
931
+ resume,
932
+ resumeThreadId: codexResumeId || undefined,
933
+ resumeConversationId: agyConvId || undefined,
934
+ });
743
935
  // A resume that produced NOTHING usually means the held
744
936
  // conversation is gone (a first turn that crashed before writing
745
- // state, a wiped CLI dir). Retry once fresh in the SAME worktree
746
- // never reset instead of bricking the tab forever. NEVER on an
747
- // adopt turn (`resume` is structurally false there, and the guard
748
- // says so out loud): a fresh conversation would silently discard
749
- // the adoption and answer as a new session wearing its name — the
750
- // empty adopt turn settles failed below instead.
937
+ // state, a wiped CLI dir or, on codex, a deleted thread). Retry
938
+ // once fresh in the SAME worktree never reset instead of
939
+ // bricking the tab forever; the retry carries no resumeThreadId,
940
+ // so codex genuinely starts over rather than re-asking for the
941
+ // thread that just came back empty. NEVER on an adopt turn
942
+ // (`resume` is structurally false there, and the guard says so out
943
+ // loud): a fresh conversation would silently discard the adoption
944
+ // and answer as a new session wearing its name — the empty adopt
945
+ // turn settles failed below instead.
751
946
  if (!adopting && resume && !(out || '').trim())
752
947
  out = await runTurn({ ...turnArgs, resume: false });
753
948
  } finally {
@@ -761,6 +956,21 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
761
956
  }
762
957
  if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
763
958
  }
959
+ // Persist the codex thread id AFTER the turn ends, so the next turn
960
+ // resumes exactly the conversation that just spoke. Shape-guarded
961
+ // before it ever touches disk — it later rides in argv as
962
+ // `resume <id>` — and best-effort, like the runtime pin: an
963
+ // unwritable marker just means the tab runs fresh next turn.
964
+ if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
965
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
966
+ if (threadMarker) {
967
+ try {
968
+ writeFileSync(threadMarker, seenThreadId);
969
+ } catch {
970
+ /* best-effort */
971
+ }
972
+ }
973
+ }
764
974
  const answer = (out || '').trim();
765
975
  // No output at all smells like a dead MCP credential (the lane
766
976
  // workers' no-sentinel case) — drop the cached token so the next
@@ -778,6 +988,24 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
778
988
  warn('adopt turn produced no output — settled as failed');
779
989
  return;
780
990
  }
991
+ // Persist the agy conversation id once the turn actually SPOKE — an
992
+ // adopted tab pins the id it moved in (the adopt hint); a new tab
993
+ // learns the one its first fresh turn just created, from agy's own
994
+ // cwd registry. From here on the marker is the tab's identity and
995
+ // the registry is never trusted again.
996
+ if (rt.id === 'antigravity' && answer.length > 0) {
997
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
998
+ if (convMarker && !existsSync(convMarker)) {
999
+ const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
1000
+ if (learned && AGY_CONV_RE.test(learned)) {
1001
+ try {
1002
+ writeFileSync(convMarker, learned);
1003
+ } catch {
1004
+ /* best-effort — an unpinned tab resumes via --continue's cwd key */
1005
+ }
1006
+ }
1007
+ }
1008
+ }
781
1009
  await settleWorkTurn(job.id, {
782
1010
  ok: answer.length > 0,
783
1011
  answer:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.45.0",
3
+ "version": "0.47.0",
4
4
  "description": "Run your own coding CLIs as headless build agents for Flowviant — Claude Code or Codex, 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": {