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.
- package/bin/lib/claude.mjs +17 -4
- package/bin/lib/fleet.mjs +7 -0
- package/bin/lib/localSessions.mjs +222 -5
- package/bin/lib/prompts.mjs +43 -0
- package/bin/lib/runtimes.mjs +40 -16
- package/bin/lib/work.mjs +311 -76
- package/package.json +1 -1
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
|
281
|
-
//
|
|
282
|
-
//
|
|
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() -
|
|
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; //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -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
|
*
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -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
|
|
128
|
-
* sentinels (NOTHING / BLOCKED:<id> / DONE) out of exactly
|
|
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; //
|
|
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
|
|
385
|
-
//
|
|
386
|
-
// upstream, and it fails loudly on
|
|
387
|
-
// would answer that session's held
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 } (
|
|
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
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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) =>
|
|
352
|
-
|
|
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
|
-
//
|
|
372
|
-
//
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
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
|
|
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
|
-
//
|
|
664
|
-
// it (--resume --fork-session)
|
|
665
|
-
//
|
|
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
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
692
|
-
// server's sessionRef
|
|
693
|
-
// from (see the settle below), and it must
|
|
694
|
-
// just opened.
|
|
695
|
-
//
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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({
|
|
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
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
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.
|
|
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": {
|