flowviant 0.44.1 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/claude.mjs +19 -1
- package/bin/lib/fleet.mjs +75 -0
- package/bin/lib/localSessions.mjs +398 -0
- package/bin/lib/prompts.mjs +43 -0
- package/bin/lib/runtimes.mjs +51 -10
- package/bin/lib/work.mjs +538 -41
- package/package.json +1 -1
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
|
|
@@ -15,16 +16,34 @@
|
|
|
15
16
|
* two getters (the MCP URL and the lease TTL can change with any poll).
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
realpathSync,
|
|
26
|
+
statSync,
|
|
27
|
+
lstatSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
cpSync,
|
|
30
|
+
} from 'node:fs';
|
|
19
31
|
import { execFileSync } from 'node:child_process';
|
|
20
|
-
import { join } from 'node:path';
|
|
32
|
+
import { join, dirname } from 'node:path';
|
|
21
33
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, REFRESH_BEFORE_SECONDS } from './config.mjs';
|
|
22
|
-
import { git, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
34
|
+
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
23
35
|
import { c, note, ok, warn } from './ui.mjs';
|
|
24
36
|
import { mcpFor, runTurn } from './claude.mjs';
|
|
25
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
SYSTEM_WORK,
|
|
39
|
+
WORK_TURN_KICKOFF,
|
|
40
|
+
SYSTEM_WORK_PLAIN,
|
|
41
|
+
WORK_TURN_KICKOFF_PLAIN,
|
|
42
|
+
} from './prompts.mjs';
|
|
26
43
|
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
27
|
-
import { detectRuntimes,
|
|
44
|
+
import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
|
|
45
|
+
import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
|
|
46
|
+
import { homedir } from 'node:os';
|
|
28
47
|
|
|
29
48
|
export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLeaseTtl }) {
|
|
30
49
|
const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
|
|
@@ -178,14 +197,21 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
178
197
|
* is the point. If the directory was retired but the branch survives, the
|
|
179
198
|
* worktree re-attaches to the branch and the committed work is still there.
|
|
180
199
|
*/
|
|
181
|
-
const sessionWtFor = (sessionId) => {
|
|
200
|
+
const sessionWtFor = (sessionId, baseAt) => {
|
|
182
201
|
if (!isSafePathSegment(sessionId)) return null;
|
|
183
202
|
const wt = join(baseDir, 'sessions', sessionId);
|
|
184
203
|
const fresh = !existsSync(wt);
|
|
185
204
|
if (fresh) {
|
|
186
205
|
const branch = `session/${sessionId}`;
|
|
206
|
+
// `baseAt` is the adoption override: a tab born from a terminal session
|
|
207
|
+
// branches from THAT checkout's HEAD, because the conversation being
|
|
208
|
+
// resumed was had against those commits — putting it on the project base
|
|
209
|
+
// would hand it a repo state it has never seen. Everything else is
|
|
210
|
+
// unchanged, the attach fallback included: a surviving branch already
|
|
211
|
+
// chose its base, and re-basing it here would move committed work.
|
|
212
|
+
const at = baseAt || baseRef;
|
|
187
213
|
try {
|
|
188
|
-
git(['worktree', 'add', '-b', branch, wt,
|
|
214
|
+
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
189
215
|
} catch {
|
|
190
216
|
git(['worktree', 'prune'], repoRoot);
|
|
191
217
|
try {
|
|
@@ -193,7 +219,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
193
219
|
git(['worktree', 'add', wt, branch], repoRoot);
|
|
194
220
|
} catch {
|
|
195
221
|
try {
|
|
196
|
-
git(['worktree', 'add', '-b', branch, wt,
|
|
222
|
+
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
197
223
|
} catch {
|
|
198
224
|
return null;
|
|
199
225
|
}
|
|
@@ -233,6 +259,84 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
233
259
|
}
|
|
234
260
|
};
|
|
235
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Carry a terminal checkout's DIRTY state into a fresh adopt worktree. The
|
|
264
|
+
* source is strictly READ-ONLY — nothing here writes to it, because it is
|
|
265
|
+
* the human's own checkout and adoption promises to leave it exactly as the
|
|
266
|
+
* closed terminal did. Tracked changes travel as one binary patch staged
|
|
267
|
+
* through the worktree's PRIVATE git dir (invisible to status, dies with the
|
|
268
|
+
* tree); untracked files are copied one by one, skipping anything over 5MB.
|
|
269
|
+
*
|
|
270
|
+
* Returns '' or ONE bracketed line for the turn's prompt: a carry problem is
|
|
271
|
+
* the AGENT's to explain to the user, never a reason to fail the adoption —
|
|
272
|
+
* the conversation is the thing being adopted, and it resumes either way.
|
|
273
|
+
*/
|
|
274
|
+
const carryDirtyState = (srcCwd, wt) => {
|
|
275
|
+
const problems = [];
|
|
276
|
+
try {
|
|
277
|
+
// A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
|
|
278
|
+
// text file) must round-trip byte-exact or the apply corrupts what it
|
|
279
|
+
// carries. 64MB of headroom — a dirtier tree than that fails the read
|
|
280
|
+
// here and is SAID, below, rather than half-applied.
|
|
281
|
+
const patch = execFileSync('git', ['diff', 'HEAD', '--binary'], {
|
|
282
|
+
cwd: srcCwd,
|
|
283
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
284
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
285
|
+
});
|
|
286
|
+
if (patch.length) {
|
|
287
|
+
const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch');
|
|
288
|
+
if (!patchPath) throw new Error('no private git dir to stage the patch in');
|
|
289
|
+
try {
|
|
290
|
+
writeFileSync(patchPath, patch);
|
|
291
|
+
git(['apply', '--whitespace=nowarn', patchPath], wt);
|
|
292
|
+
} finally {
|
|
293
|
+
try {
|
|
294
|
+
rmSync(patchPath, { force: true });
|
|
295
|
+
} catch {
|
|
296
|
+
/* best-effort — the private git dir dies with the worktree anyway */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
problems.push(
|
|
302
|
+
'their uncommitted TRACKED changes did not carry over (they are still in the terminal checkout, untouched)'
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const skipped = [];
|
|
307
|
+
for (const rel of splitNul(
|
|
308
|
+
gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], srcCwd)
|
|
309
|
+
)) {
|
|
310
|
+
try {
|
|
311
|
+
const from = join(srcCwd, rel);
|
|
312
|
+
// lstat, not stat: a symlink is carried as itself, and its own size
|
|
313
|
+
// is what the 5MB budget judges — never the file it points at.
|
|
314
|
+
if (lstatSync(from).size > 5 * 1024 * 1024) {
|
|
315
|
+
skipped.push(rel);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const to = join(wt, rel);
|
|
319
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
320
|
+
cpSync(from, to);
|
|
321
|
+
} catch {
|
|
322
|
+
skipped.push(rel);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (skipped.length) {
|
|
326
|
+
problems.push(
|
|
327
|
+
`${skipped.length} untracked file${skipped.length === 1 ? '' : 's'} did not carry (over 5MB or unreadable): ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ', …' : ''}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
problems.push(
|
|
332
|
+
'untracked files could not be listed in the terminal checkout, so none were carried'
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return problems.length
|
|
336
|
+
? `[ADOPTION NOTE from the daemon — tell the user plainly at the start of your reply: ${problems.join('; ')}.]`
|
|
337
|
+
: '';
|
|
338
|
+
};
|
|
339
|
+
|
|
236
340
|
/**
|
|
237
341
|
* WHICH CLI drives this session — picked ONCE, on the first turn, and pinned
|
|
238
342
|
* in the worktree's meta dir. The held context belongs to the CLI that made
|
|
@@ -241,9 +345,31 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
241
345
|
* same reason). If the pinned CLI has left the machine, the turn settles
|
|
242
346
|
* honestly instead of substituting. A retired-and-reattached directory has
|
|
243
347
|
* no marker and no held context either, so re-picking there is correct.
|
|
244
|
-
*
|
|
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
|
+
*
|
|
358
|
+
* Returns { id } | { id: null } (nothing installed) | { missing: label } |
|
|
359
|
+
* { unsupported: label } (a runtime no session can run on) |
|
|
360
|
+
* { mismatch: { pin, runtime } } (labels, for the caller's sentence).
|
|
361
|
+
*
|
|
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.
|
|
245
369
|
*/
|
|
246
|
-
const
|
|
370
|
+
const sessionCapable = (rid) =>
|
|
371
|
+
(Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
|
|
372
|
+
const sessionRuntime = (wt, jobRuntime) => {
|
|
247
373
|
const marker = sessionMetaPath(wt, 'flowviant-runtime');
|
|
248
374
|
let pinned = null;
|
|
249
375
|
if (marker && existsSync(marker)) {
|
|
@@ -254,10 +380,50 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
254
380
|
}
|
|
255
381
|
}
|
|
256
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
|
+
}
|
|
391
|
+
// A pin that names a non-session-capable runtime is settled honestly by
|
|
392
|
+
// the caller, not silently re-picked: re-picking would hand the held
|
|
393
|
+
// context to a different brain, which is the exact substitution the pin
|
|
394
|
+
// exists to prevent.
|
|
395
|
+
if (!sessionCapable(pinned)) return { unsupported: RUNTIMES[pinned].label || pinned };
|
|
257
396
|
const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
|
|
258
397
|
return installed ? { id: pinned } : { missing: RUNTIMES[pinned].label || pinned };
|
|
259
398
|
}
|
|
260
|
-
|
|
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.
|
|
421
|
+
const rows = detectRuntimes();
|
|
422
|
+
const okFor = (rid) =>
|
|
423
|
+
Boolean(RUNTIMES[rid]?.mcp) &&
|
|
424
|
+
sessionCapable(rid) &&
|
|
425
|
+
Boolean(rows.find((r) => r.id === rid)?.installed);
|
|
426
|
+
const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);
|
|
261
427
|
if (!id) return { id: null };
|
|
262
428
|
if (marker) {
|
|
263
429
|
try {
|
|
@@ -269,6 +435,51 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
269
435
|
return { id };
|
|
270
436
|
};
|
|
271
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
|
+
|
|
272
483
|
/**
|
|
273
484
|
* The spawn lock: the pid of the CLI currently live in this worktree. A
|
|
274
485
|
* restarted daemon must not put a second Claude into a directory the orphan
|
|
@@ -394,7 +605,107 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
394
605
|
note(
|
|
395
606
|
`${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
|
|
396
607
|
);
|
|
397
|
-
|
|
608
|
+
// ── ADOPTION: a tab born from a TERMINAL session ────────────────
|
|
609
|
+
// The server sends `adopt {id, cwd}` only while the session has no
|
|
610
|
+
// sessionRef — no turn has ever spoken from a worktree here — and
|
|
611
|
+
// the first turn resumes the terminal conversation by forking it
|
|
612
|
+
// into the tab's own worktree. Everything the server asserts is
|
|
613
|
+
// re-validated MACHINE-side: the id shape, the source directory,
|
|
614
|
+
// and — decisive — that the terminal is actually closed, because
|
|
615
|
+
// forking a session someone is still typing into puts two Claudes
|
|
616
|
+
// on one conversation.
|
|
617
|
+
const adopting = Boolean(job.adopt) && !job.sessionRef;
|
|
618
|
+
let srcHead = null;
|
|
619
|
+
let adoptSrc = null; // the validated, realpath'd source checkout
|
|
620
|
+
if (adopting) {
|
|
621
|
+
if (
|
|
622
|
+
typeof job.adopt.id !== 'string' ||
|
|
623
|
+
!/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
|
|
624
|
+
) {
|
|
625
|
+
await settleWorkTurn(job.id, {
|
|
626
|
+
ok: false,
|
|
627
|
+
answer: 'that terminal session id is not one this machine can resume',
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
let srcCwd = null;
|
|
632
|
+
try {
|
|
633
|
+
srcCwd = realpathSync(String(job.adopt.cwd ?? ''));
|
|
634
|
+
if (!statSync(srcCwd).isDirectory()) srcCwd = null;
|
|
635
|
+
} catch {
|
|
636
|
+
srcCwd = null;
|
|
637
|
+
}
|
|
638
|
+
if (!srcCwd) {
|
|
639
|
+
await settleWorkTurn(job.id, {
|
|
640
|
+
ok: false,
|
|
641
|
+
answer: "the terminal session's directory no longer exists on the machine",
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// Inside the repo, outside the daemon's own worktrees: an adopt
|
|
646
|
+
// source is a HUMAN's checkout, and one of our directories showing
|
|
647
|
+
// up here means a stale or confused offer, not a session to fork.
|
|
648
|
+
const under = (p, root) =>
|
|
649
|
+
p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
|
|
650
|
+
let realRoot = repoRoot;
|
|
651
|
+
let realBase = baseDir;
|
|
652
|
+
try {
|
|
653
|
+
realRoot = realpathSync(repoRoot);
|
|
654
|
+
} catch {
|
|
655
|
+
/* keep the literal path */
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
realBase = realpathSync(baseDir);
|
|
659
|
+
} catch {
|
|
660
|
+
/* keep the literal path */
|
|
661
|
+
}
|
|
662
|
+
if (!under(srcCwd, realRoot)) {
|
|
663
|
+
await settleWorkTurn(job.id, {
|
|
664
|
+
ok: false,
|
|
665
|
+
answer: "the terminal session's directory is outside this project's repository",
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (under(srcCwd, realBase)) {
|
|
670
|
+
await settleWorkTurn(job.id, {
|
|
671
|
+
ok: false,
|
|
672
|
+
answer:
|
|
673
|
+
"that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
|
|
674
|
+
});
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
srcHead = git(['rev-parse', 'HEAD'], srcCwd);
|
|
679
|
+
} catch {
|
|
680
|
+
await settleWorkTurn(job.id, {
|
|
681
|
+
ok: false,
|
|
682
|
+
answer:
|
|
683
|
+
"the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
|
|
684
|
+
});
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
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) {
|
|
697
|
+
await settleWorkTurn(job.id, {
|
|
698
|
+
ok: false,
|
|
699
|
+
answer:
|
|
700
|
+
'That terminal session is still open on the machine — close it there first, then adopt.',
|
|
701
|
+
});
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
adoptSrc = srcCwd;
|
|
705
|
+
}
|
|
706
|
+
// Based at the SOURCE's HEAD when adopting — the resumed
|
|
707
|
+
// conversation was had against those commits, not the project base.
|
|
708
|
+
const dir = sessionWtFor(job.sessionId, adopting ? srcHead : undefined);
|
|
398
709
|
if (!dir) {
|
|
399
710
|
await settleWorkTurn(job.id, {
|
|
400
711
|
ok: false,
|
|
@@ -414,7 +725,20 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
414
725
|
);
|
|
415
726
|
return;
|
|
416
727
|
}
|
|
417
|
-
|
|
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
|
+
}
|
|
418
742
|
if (rt.missing) {
|
|
419
743
|
await settleWorkTurn(job.id, {
|
|
420
744
|
ok: false,
|
|
@@ -422,58 +746,173 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
422
746
|
});
|
|
423
747
|
return;
|
|
424
748
|
}
|
|
425
|
-
if (
|
|
749
|
+
if (rt.unsupported) {
|
|
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.
|
|
426
755
|
await settleWorkTurn(job.id, {
|
|
427
756
|
ok: false,
|
|
428
|
-
answer:
|
|
429
|
-
'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
|
|
757
|
+
answer: `this session runs on ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
|
|
430
758
|
});
|
|
431
759
|
return;
|
|
432
760
|
}
|
|
433
|
-
|
|
434
|
-
if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
|
|
435
|
-
if (mint?.gone) {
|
|
761
|
+
if (!rt.id) {
|
|
436
762
|
await settleWorkTurn(job.id, {
|
|
437
763
|
ok: false,
|
|
438
764
|
answer:
|
|
439
|
-
'
|
|
765
|
+
'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
|
|
440
766
|
});
|
|
441
767
|
return;
|
|
442
768
|
}
|
|
443
|
-
if (
|
|
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.
|
|
444
775
|
await settleWorkTurn(job.id, {
|
|
445
776
|
ok: false,
|
|
446
777
|
answer:
|
|
447
|
-
'
|
|
778
|
+
'adopting this terminal session needs its own CLI on the machine — install it, then try again',
|
|
448
779
|
});
|
|
449
780
|
return;
|
|
450
781
|
}
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
const
|
|
457
|
-
|
|
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
|
+
}
|
|
808
|
+
}
|
|
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
|
+
}
|
|
845
|
+
}
|
|
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;
|
|
865
|
+
// The dirty carry, on the adopt worktree's FIRST life only: a
|
|
866
|
+
// re-attempted adoption (the directory already exists) carried what
|
|
867
|
+
// it could the first time, and re-applying would double it. A carry
|
|
868
|
+
// problem never fails the adoption — it becomes one bracketed line
|
|
869
|
+
// in the prompt, so the AGENT tells the user what stayed behind.
|
|
870
|
+
let carryNote = '';
|
|
871
|
+
if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
|
|
872
|
+
const mcp = plainTab
|
|
873
|
+
? { args: [], env: null, dir: null }
|
|
874
|
+
: mcpFor(rt.id, mint.token, getMcpUrl());
|
|
458
875
|
// Attempts count RUNS: the infra refusals above consumed nothing and
|
|
459
876
|
// settled on their own terms.
|
|
460
877
|
workAttempts.set(job.id, tries + 1);
|
|
461
878
|
let out;
|
|
879
|
+
let seenThreadId = null; // codex's conversation id, off thread.started
|
|
462
880
|
const spawned = []; // this turn's children, for the teardown registry
|
|
463
881
|
try {
|
|
882
|
+
const message = carryNote ? `${job.body}\n\n${carryNote}` : job.body;
|
|
464
883
|
const turnArgs = {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
884
|
+
// A plain tab has no tools to name and no session id to pass —
|
|
885
|
+
// its kickoff asks for one complete report instead of a stream.
|
|
886
|
+
prompt: plainTab
|
|
887
|
+
? WORK_TURN_KICKOFF_PLAIN({
|
|
888
|
+
sessionName: job.sessionName,
|
|
889
|
+
message,
|
|
890
|
+
askedByName: job.askedByName,
|
|
891
|
+
})
|
|
892
|
+
: WORK_TURN_KICKOFF({
|
|
893
|
+
sessionId: job.sessionId,
|
|
894
|
+
sessionName: job.sessionName,
|
|
895
|
+
message,
|
|
896
|
+
askedByName: job.askedByName,
|
|
897
|
+
}),
|
|
898
|
+
// The adopt turn resumes the TERMINAL conversation by forking it
|
|
899
|
+
// into this cwd (claude: --resume <id> --fork-session). After it
|
|
900
|
+
// speaks once, the fork lives natively here and turn 2+ is the
|
|
901
|
+
// ordinary --continue resume path, unchanged.
|
|
902
|
+
...(adopting ? { adoptResumeId: job.adopt.id } : {}),
|
|
903
|
+
system: plainTab ? SYSTEM_WORK_PLAIN : SYSTEM_WORK,
|
|
472
904
|
cwd: dir.wt,
|
|
473
905
|
mcpArgs: mcp.args,
|
|
474
906
|
mcpEnv: mcp.env,
|
|
475
907
|
runtime: rt.id,
|
|
476
908
|
label: c.cyan('[tab]'),
|
|
909
|
+
// Only codex announces one (thread.started); held here so the id
|
|
910
|
+
// this turn actually SPOKE under is what gets persisted after it
|
|
911
|
+
// ends. Last write wins on purpose: a failed resume that fell
|
|
912
|
+
// back to fresh reports the fresh run's id, healing the marker.
|
|
913
|
+
onThreadId: (id) => {
|
|
914
|
+
seenThreadId = String(id ?? '').trim() || seenThreadId;
|
|
915
|
+
},
|
|
477
916
|
onSpawn: (ch) => {
|
|
478
917
|
if (!ch) return;
|
|
479
918
|
spawned.push(ch);
|
|
@@ -487,12 +926,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
487
926
|
}
|
|
488
927
|
},
|
|
489
928
|
};
|
|
490
|
-
out = await runTurn({
|
|
929
|
+
out = await runTurn({
|
|
930
|
+
...turnArgs,
|
|
931
|
+
resume,
|
|
932
|
+
resumeThreadId: codexResumeId || undefined,
|
|
933
|
+
resumeConversationId: agyConvId || undefined,
|
|
934
|
+
});
|
|
491
935
|
// A resume that produced NOTHING usually means the held
|
|
492
936
|
// conversation is gone (a first turn that crashed before writing
|
|
493
|
-
// state, a wiped CLI dir
|
|
494
|
-
// never reset — instead of
|
|
495
|
-
|
|
937
|
+
// state, a wiped CLI dir — or, on codex, a deleted thread). Retry
|
|
938
|
+
// once fresh in the SAME worktree — never reset — instead of
|
|
939
|
+
// bricking the tab forever; the retry carries no resumeThreadId,
|
|
940
|
+
// so codex genuinely starts over rather than re-asking for the
|
|
941
|
+
// thread that just came back empty. NEVER on an adopt turn
|
|
942
|
+
// (`resume` is structurally false there, and the guard says so out
|
|
943
|
+
// loud): a fresh conversation would silently discard the adoption
|
|
944
|
+
// and answer as a new session wearing its name — the empty adopt
|
|
945
|
+
// turn settles failed below instead.
|
|
946
|
+
if (!adopting && resume && !(out || '').trim())
|
|
947
|
+
out = await runTurn({ ...turnArgs, resume: false });
|
|
496
948
|
} finally {
|
|
497
949
|
for (const ch of spawned) workChildren.delete(ch);
|
|
498
950
|
if (lockPath) {
|
|
@@ -504,11 +956,56 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
504
956
|
}
|
|
505
957
|
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
506
958
|
}
|
|
959
|
+
// Persist the codex thread id AFTER the turn ends, so the next turn
|
|
960
|
+
// resumes exactly the conversation that just spoke. Shape-guarded
|
|
961
|
+
// before it ever touches disk — it later rides in argv as
|
|
962
|
+
// `resume <id>` — and best-effort, like the runtime pin: an
|
|
963
|
+
// unwritable marker just means the tab runs fresh next turn.
|
|
964
|
+
if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
|
|
965
|
+
const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
|
|
966
|
+
if (threadMarker) {
|
|
967
|
+
try {
|
|
968
|
+
writeFileSync(threadMarker, seenThreadId);
|
|
969
|
+
} catch {
|
|
970
|
+
/* best-effort */
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
507
974
|
const answer = (out || '').trim();
|
|
508
975
|
// No output at all smells like a dead MCP credential (the lane
|
|
509
976
|
// workers' no-sentinel case) — drop the cached token so the next
|
|
510
977
|
// turn re-mints instead of failing the same way forever.
|
|
511
978
|
if (!answer) workTokens.delete(job.sessionId);
|
|
979
|
+
if (adopting && !answer) {
|
|
980
|
+
// The fork came back with nothing — the terminal session's
|
|
981
|
+
// transcript is most likely gone (cleaned, expired, deleted). Say
|
|
982
|
+
// exactly that; no sessionRef is recorded, so the server keeps
|
|
983
|
+
// offering the adoption and a retry after the user checks is cheap.
|
|
984
|
+
await settleWorkTurn(job.id, {
|
|
985
|
+
ok: false,
|
|
986
|
+
answer: "Couldn't resume the terminal session — it may have been removed.",
|
|
987
|
+
});
|
|
988
|
+
warn('adopt turn produced no output — settled as failed');
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
// Persist the agy conversation id once the turn actually SPOKE — an
|
|
992
|
+
// adopted tab pins the id it moved in (the adopt hint); a new tab
|
|
993
|
+
// learns the one its first fresh turn just created, from agy's own
|
|
994
|
+
// cwd registry. From here on the marker is the tab's identity and
|
|
995
|
+
// the registry is never trusted again.
|
|
996
|
+
if (rt.id === 'antigravity' && answer.length > 0) {
|
|
997
|
+
const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
|
|
998
|
+
if (convMarker && !existsSync(convMarker)) {
|
|
999
|
+
const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
|
|
1000
|
+
if (learned && AGY_CONV_RE.test(learned)) {
|
|
1001
|
+
try {
|
|
1002
|
+
writeFileSync(convMarker, learned);
|
|
1003
|
+
} catch {
|
|
1004
|
+
/* best-effort — an unpinned tab resumes via --continue's cwd key */
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
512
1009
|
await settleWorkTurn(job.id, {
|
|
513
1010
|
ok: answer.length > 0,
|
|
514
1011
|
answer:
|