flowviant 0.45.0 → 0.47.1

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"
@@ -24,8 +24,66 @@ import {
24
24
  import { homedir } from 'node:os';
25
25
  import { join } from 'node:path';
26
26
 
27
- const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
28
27
  const REPORT_CAP = 30;
28
+ // ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
29
+ // "closed my laptop terminal, picking it up here". Claude Code prunes its own
30
+ // history anyway, so a week-old row was a soon-to-be-dead offer — 48 hours,
31
+ // newest per directory, few. (The first ship reported 7 days of everything
32
+ // and the strip read as session history instead of presence.)
33
+ const ENDED_WINDOW_MS = 48 * 60 * 60 * 1000;
34
+ const ENDED_CAP = 5;
35
+
36
+ /**
37
+ * The conversation's own title, off the transcript's `ai-title` records
38
+ * (the LAST one wins — titles get rewritten as a session evolves), falling
39
+ * back to the first real user message. Those records sit anywhere in the
40
+ * file (measured: line 81 to line 4457), so this reads the WHOLE transcript
41
+ * — behind an mtime cache, because the scan runs every minute and a title
42
+ * only changes when the file does: steady state is a stat, not a read.
43
+ */
44
+ const titleCache = new Map(); // file → { mtimeMs, title }
45
+ function transcriptTitle(file, mtimeMs) {
46
+ const hit = titleCache.get(file);
47
+ if (hit && hit.mtimeMs === mtimeMs) return hit.title;
48
+ let title = null;
49
+ try {
50
+ const stat = statSync(file);
51
+ // A transcript past this is not worth a read per minute of drift.
52
+ if (stat.size <= 64 * 1024 * 1024) {
53
+ let firstUser = null;
54
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
55
+ if (line.includes('"type":"ai-title"')) {
56
+ try {
57
+ const t = JSON.parse(line)?.aiTitle;
58
+ if (typeof t === 'string' && t.trim()) title = t.trim(); // last wins
59
+ } catch {
60
+ /* torn line */
61
+ }
62
+ } else if (!firstUser && !title && line.includes('"type":"user"') && !line.includes('"isMeta":true')) {
63
+ try {
64
+ const content = JSON.parse(line)?.message?.content;
65
+ const text =
66
+ typeof content === 'string'
67
+ ? content
68
+ : Array.isArray(content)
69
+ ? (content.find((b) => typeof b?.text === 'string')?.text ?? '')
70
+ : '';
71
+ if (text.trim() && !text.startsWith('<')) firstUser = text.trim();
72
+ } catch {
73
+ /* torn line */
74
+ }
75
+ }
76
+ }
77
+ if (!title && firstUser) title = firstUser;
78
+ if (title) title = title.replace(/\s+/g, ' ').slice(0, 120);
79
+ }
80
+ } catch {
81
+ title = null;
82
+ }
83
+ if (titleCache.size > 400) titleCache.clear(); // a bound, not an LRU — refills in one scan
84
+ titleCache.set(file, { mtimeMs, title });
85
+ return title;
86
+ }
29
87
 
30
88
  /** Path-prefix containment on already-realpath'd absolute paths. */
31
89
  const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
@@ -182,12 +240,29 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
182
240
  }
183
241
  if (!ours(cwd)) continue;
184
242
  liveIds.add(rec.sessionId);
243
+ // A live session's title, off its own transcript (the registry `name`
244
+ // is a machine-y fallback like "flowviant-35").
245
+ let liveTitle = null;
246
+ try {
247
+ const liveFile = join(
248
+ homedir(),
249
+ '.claude',
250
+ 'projects',
251
+ cwd.replace(/[/.]/g, '-'),
252
+ `${rec.sessionId}.jsonl`
253
+ );
254
+ liveTitle = transcriptTitle(liveFile, statSync(liveFile).mtimeMs);
255
+ } catch {
256
+ /* no transcript yet */
257
+ }
258
+ if (!liveTitle && typeof rec.name === 'string' && rec.name.trim()) liveTitle = rec.name.trim();
185
259
  live.push({
186
260
  id: rec.sessionId,
187
261
  cwd,
188
262
  live: true,
189
263
  lastActiveAt: nowIso,
190
264
  ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
265
+ ...(liveTitle ? { title: liveTitle } : {}),
191
266
  });
192
267
  }
193
268
  live.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
@@ -207,7 +282,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
207
282
  } catch {
208
283
  /* no transcript store — live sessions still report */
209
284
  }
210
- const cutoff = Date.now() - SEVEN_DAYS_MS;
285
+ const cutoff = Date.now() - ENDED_WINDOW_MS;
211
286
  const candidates = [];
212
287
  for (const dirName of projDirs) {
213
288
  if (dirName !== munged && !dirName.startsWith(`${munged}-`)) continue;
@@ -228,7 +303,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
228
303
  } catch {
229
304
  continue;
230
305
  }
231
- if (mtimeMs < cutoff) continue; // week-old sessions are history, not presence
306
+ if (mtimeMs < cutoff) continue; // an aged session is history, not presence
232
307
  candidates.push({ id, file, mtimeMs });
233
308
  }
234
309
  }
@@ -236,8 +311,14 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
236
311
  // the verification read is the expensive step, so it is not spent on
237
312
  // sessions the report would drop anyway.
238
313
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.id < b.id ? -1 : 1));
239
- const room = Math.max(0, REPORT_CAP - Math.min(live.length, REPORT_CAP));
314
+ // ONE row per DIRECTORY, newest first, few: twenty sessions in the repo
315
+ // root are one offer — the newest is the one `--resume`'s picker would
316
+ // reach for and the only one worth importing 95% of the time. The rest
317
+ // are scrollback, and the product's own law says scrollback doesn't
318
+ // matter.
319
+ const room = Math.min(ENDED_CAP, Math.max(0, REPORT_CAP - Math.min(live.length, REPORT_CAP)));
240
320
  const endedIds = new Set();
321
+ const seenCwds = new Set(live.map((s) => s.cwd));
241
322
  for (const cand of candidates) {
242
323
  if (ended.length >= room) break;
243
324
  if (endedIds.has(cand.id)) continue; // one row per session, whatever dir names it
@@ -251,16 +332,152 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
251
332
  continue;
252
333
  }
253
334
  if (!ours(cwd)) continue;
335
+ if (seenCwds.has(cwd)) continue; // newest per directory; a live one owns its cwd
336
+ seenCwds.add(cwd);
337
+ const title = transcriptTitle(cand.file, cand.mtimeMs);
254
338
  ended.push({
255
339
  id: cand.id,
256
340
  cwd,
257
341
  live: false,
258
342
  lastActiveAt: new Date(cand.mtimeMs).toISOString(),
259
343
  ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
344
+ ...(title ? { title } : {}),
260
345
  });
261
346
  }
262
347
  } catch {
263
348
  /* presence must never throw into the poll loop — report what was gathered */
264
349
  }
265
- return [...live.slice(0, REPORT_CAP), ...ended];
350
+ const claude = [...live.slice(0, REPORT_CAP), ...ended];
351
+ // agy rides in whatever room the cap leaves — Claude sessions first, they
352
+ // are the ones adoption serves best (fork, never move).
353
+ const agy = scanAgyConversations({ repoRoot, excludeDirs }).slice(
354
+ 0,
355
+ Math.max(0, REPORT_CAP - claude.length)
356
+ );
357
+ return [...claude, ...agy];
358
+ }
359
+
360
+ // ── Antigravity (agy) ──────────────────────────────────────────────────────
361
+ //
362
+ // agy's store is nothing like Claude's: one SQLite db per conversation at
363
+ // ~/.gemini/antigravity-cli/conversations/<uuid>.db (global, not cwd-keyed),
364
+ // no per-pid liveness registry that survives contact (the presence/*.lock
365
+ // files sit untouched by real runs — measured), and the only cwd mapping is
366
+ // cache/last_conversations.json: {cwd → the LAST conversation run there}.
367
+ // So the honest agy report is a SUBSET — the last conversation per directory
368
+ // inside this repo — and that is exactly the one `agy --continue` would give
369
+ // the person at that keyboard, i.e. the one worth offering to adopt.
370
+
371
+ const AGY_DIR = () => join(homedir(), '.gemini', 'antigravity-cli');
372
+ 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;
373
+
374
+ /** Newest write to the conversation's store — the wal carries recent turns,
375
+ * so its mtime (not the db's) is the real "last active" (measured: a resume
376
+ * touched db+wal, and never the presence lock). 0 = no such conversation. */
377
+ function agyLastWriteMs(id) {
378
+ if (!AGY_UUID_RE.test(id)) return 0;
379
+ let newest = 0;
380
+ for (const suffix of ['.db', '.db-wal']) {
381
+ try {
382
+ const t = statSync(join(AGY_DIR(), 'conversations', `${id}${suffix}`)).mtimeMs;
383
+ if (t > newest) newest = t;
384
+ } catch {
385
+ /* absent half is fine — the db alone still answers */
386
+ }
387
+ }
388
+ return newest;
389
+ }
390
+
391
+ /** Any agy process on the machine right now? /proc comm scan — cheap at the
392
+ * 60s cadence, and the only liveness signal agy leaves (locks are inert). */
393
+ function agyProcessAlive() {
394
+ try {
395
+ for (const name of readdirSync('/proc')) {
396
+ if (!/^\d+$/.test(name)) continue;
397
+ try {
398
+ if (readFileSync(`/proc/${name}/comm`, 'utf8').trim() === 'agy') return true;
399
+ } catch {
400
+ /* raced exit — keep scanning */
401
+ }
402
+ }
403
+ } catch {
404
+ /* no /proc — call nothing live rather than everything */
405
+ }
406
+ return false;
407
+ }
408
+
409
+ /**
410
+ * Is this agy conversation being driven RIGHT NOW? agy cannot answer
411
+ * per-conversation, so this is the conservative composite: an agy process
412
+ * exists AND this conversation's store was written in the last 10 minutes.
413
+ * Adoption is a MOVE for agy (no fork exists — measured, "trajectory not
414
+ * found" on a renamed copy), so refusing a maybe-live conversation for a few
415
+ * minutes costs a retry; adopting an actually-live one puts two drivers on
416
+ * one store.
417
+ */
418
+ const AGY_LIVE_WINDOW_MS = 10 * 60 * 1000;
419
+ export function isAgyConversationLive(id) {
420
+ try {
421
+ if (!agyProcessAlive()) return false;
422
+ const t = agyLastWriteMs(id);
423
+ return t > 0 && Date.now() - t < AGY_LIVE_WINDOW_MS;
424
+ } catch {
425
+ return false;
426
+ }
427
+ }
428
+
429
+ /** The repo's agy conversations, via the cwd registry — see the section
430
+ * comment for why this is deliberately a subset. */
431
+ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
432
+ const out = [];
433
+ try {
434
+ let realRoot;
435
+ try {
436
+ realRoot = realpathSync(repoRoot);
437
+ } catch {
438
+ return out;
439
+ }
440
+ const excludes = [];
441
+ for (const d of excludeDirs) {
442
+ if (!d) continue;
443
+ try {
444
+ excludes.push(realpathSync(d));
445
+ } catch {
446
+ excludes.push(String(d));
447
+ }
448
+ }
449
+ const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
450
+ const raw = readFileSync(join(AGY_DIR(), 'cache', 'last_conversations.json'), 'utf8');
451
+ const map = JSON.parse(raw);
452
+ if (!map || typeof map !== 'object') return out;
453
+ const cutoff = Date.now() - ENDED_WINDOW_MS;
454
+ const processUp = agyProcessAlive();
455
+ for (const [cwd, id] of Object.entries(map)) {
456
+ if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
457
+ let real;
458
+ try {
459
+ real = realpathSync(cwd);
460
+ } catch {
461
+ continue; // the directory is gone — nothing to point a tab at
462
+ }
463
+ if (!ours(real)) continue;
464
+ const lastMs = agyLastWriteMs(id);
465
+ if (!lastMs || lastMs < cutoff) continue;
466
+ out.push({
467
+ id,
468
+ cwd: real,
469
+ live: processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
470
+ lastActiveAt: new Date(lastMs).toISOString(),
471
+ runtime: 'antigravity',
472
+ });
473
+ }
474
+ out.sort(
475
+ (a, b) =>
476
+ (b.lastActiveAt < a.lastActiveAt ? -1 : b.lastActiveAt > a.lastActiveAt ? 1 : 0) ||
477
+ (a.id < b.id ? -1 : 1)
478
+ );
479
+ } catch {
480
+ /* no agy on this machine, or an unreadable registry — nothing to report */
481
+ }
482
+ return out;
266
483
  }
@@ -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,57 @@ 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
+ // The tab's transcript starts EMPTY on adoption (scrollback is
873
+ // disposable, the held context is the brain — never import an
874
+ // archive), so the first reply opens with a recap: the human sees
875
+ // the thread they are picking up without asking for it.
876
+ const adoptNote = adopting
877
+ ? '[ADOPTED SESSION — this conversation was brought in from a terminal. Begin your reply with a 2-3 sentence recap of where it left off and what state carried over, then answer the message.]'
878
+ : '';
879
+ const mcp = plainTab
880
+ ? { args: [], env: null, dir: null }
881
+ : mcpFor(rt.id, mint.token, getMcpUrl());
705
882
  // Attempts count RUNS: the infra refusals above consumed nothing and
706
883
  // settled on their own terms.
707
884
  workAttempts.set(job.id, tries + 1);
708
885
  let out;
886
+ let seenThreadId = null; // codex's conversation id, off thread.started
709
887
  const spawned = []; // this turn's children, for the teardown registry
710
888
  try {
889
+ const message = [job.body, adoptNote, carryNote].filter(Boolean).join('\n\n');
711
890
  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
- }),
891
+ // A plain tab has no tools to name and no session id to pass —
892
+ // its kickoff asks for one complete report instead of a stream.
893
+ prompt: plainTab
894
+ ? WORK_TURN_KICKOFF_PLAIN({
895
+ sessionName: job.sessionName,
896
+ message,
897
+ askedByName: job.askedByName,
898
+ })
899
+ : WORK_TURN_KICKOFF({
900
+ sessionId: job.sessionId,
901
+ sessionName: job.sessionName,
902
+ message,
903
+ askedByName: job.askedByName,
904
+ }),
718
905
  // The adopt turn resumes the TERMINAL conversation by forking it
719
906
  // into this cwd (claude: --resume <id> --fork-session). After it
720
907
  // speaks once, the fork lives natively here and turn 2+ is the
721
908
  // ordinary --continue resume path, unchanged.
722
909
  ...(adopting ? { adoptResumeId: job.adopt.id } : {}),
723
- system: SYSTEM_WORK,
910
+ system: plainTab ? SYSTEM_WORK_PLAIN : SYSTEM_WORK,
724
911
  cwd: dir.wt,
725
912
  mcpArgs: mcp.args,
726
913
  mcpEnv: mcp.env,
727
914
  runtime: rt.id,
728
915
  label: c.cyan('[tab]'),
916
+ // Only codex announces one (thread.started); held here so the id
917
+ // this turn actually SPOKE under is what gets persisted after it
918
+ // ends. Last write wins on purpose: a failed resume that fell
919
+ // back to fresh reports the fresh run's id, healing the marker.
920
+ onThreadId: (id) => {
921
+ seenThreadId = String(id ?? '').trim() || seenThreadId;
922
+ },
729
923
  onSpawn: (ch) => {
730
924
  if (!ch) return;
731
925
  spawned.push(ch);
@@ -739,15 +933,23 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
739
933
  }
740
934
  },
741
935
  };
742
- out = await runTurn({ ...turnArgs, resume });
936
+ out = await runTurn({
937
+ ...turnArgs,
938
+ resume,
939
+ resumeThreadId: codexResumeId || undefined,
940
+ resumeConversationId: agyConvId || undefined,
941
+ });
743
942
  // A resume that produced NOTHING usually means the held
744
943
  // 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.
944
+ // state, a wiped CLI dir or, on codex, a deleted thread). Retry
945
+ // once fresh in the SAME worktree never reset instead of
946
+ // bricking the tab forever; the retry carries no resumeThreadId,
947
+ // so codex genuinely starts over rather than re-asking for the
948
+ // thread that just came back empty. NEVER on an adopt turn
949
+ // (`resume` is structurally false there, and the guard says so out
950
+ // loud): a fresh conversation would silently discard the adoption
951
+ // and answer as a new session wearing its name — the empty adopt
952
+ // turn settles failed below instead.
751
953
  if (!adopting && resume && !(out || '').trim())
752
954
  out = await runTurn({ ...turnArgs, resume: false });
753
955
  } finally {
@@ -761,6 +963,21 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
761
963
  }
762
964
  if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
763
965
  }
966
+ // Persist the codex thread id AFTER the turn ends, so the next turn
967
+ // resumes exactly the conversation that just spoke. Shape-guarded
968
+ // before it ever touches disk — it later rides in argv as
969
+ // `resume <id>` — and best-effort, like the runtime pin: an
970
+ // unwritable marker just means the tab runs fresh next turn.
971
+ if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
972
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
973
+ if (threadMarker) {
974
+ try {
975
+ writeFileSync(threadMarker, seenThreadId);
976
+ } catch {
977
+ /* best-effort */
978
+ }
979
+ }
980
+ }
764
981
  const answer = (out || '').trim();
765
982
  // No output at all smells like a dead MCP credential (the lane
766
983
  // workers' no-sentinel case) — drop the cached token so the next
@@ -778,6 +995,24 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
778
995
  warn('adopt turn produced no output — settled as failed');
779
996
  return;
780
997
  }
998
+ // Persist the agy conversation id once the turn actually SPOKE — an
999
+ // adopted tab pins the id it moved in (the adopt hint); a new tab
1000
+ // learns the one its first fresh turn just created, from agy's own
1001
+ // cwd registry. From here on the marker is the tab's identity and
1002
+ // the registry is never trusted again.
1003
+ if (rt.id === 'antigravity' && answer.length > 0) {
1004
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
1005
+ if (convMarker && !existsSync(convMarker)) {
1006
+ const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
1007
+ if (learned && AGY_CONV_RE.test(learned)) {
1008
+ try {
1009
+ writeFileSync(convMarker, learned);
1010
+ } catch {
1011
+ /* best-effort — an unpinned tab resumes via --continue's cwd key */
1012
+ }
1013
+ }
1014
+ }
1015
+ }
781
1016
  await settleWorkTurn(job.id, {
782
1017
  ok: answer.length > 0,
783
1018
  answer:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.45.0",
3
+ "version": "0.47.1",
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": {