flowviant 0.44.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -233,7 +233,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
233
233
  // returned string for sentinel detection, and each activity is handed to
234
234
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
235
235
  // off and keep the raw text passthrough + line sentinels.
236
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, onActivity, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort }) {
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 }) {
237
237
  return new Promise((resolve) => {
238
238
  const rt = runtimeById(runtime);
239
239
  if (!rt.args) {
@@ -276,6 +276,11 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
276
276
  resume,
277
277
  streamJson,
278
278
  profile,
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.
283
+ adoptResumeId,
279
284
  // Only the wiki profile uses it, but it is passed unconditionally: a
280
285
  // runtime that can path-scope its writes needs to know WHERE the vault is,
281
286
  // and Claude — which cannot — simply ignores it.
package/bin/lib/fleet.mjs CHANGED
@@ -84,6 +84,7 @@ import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
84
84
  import { machineSnapshot } from './resources.mjs';
85
85
  import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
86
86
  import { createWorkManager } from './work.mjs';
87
+ import { scanLocalSessions } from './localSessions.mjs';
87
88
 
88
89
  async function fetchRoster(haveIds) {
89
90
  const url = new URL(FLEET_URL);
@@ -222,6 +223,69 @@ function sampleDiffstat(cwd, baseRef, intentId, agentId) {
222
223
  };
223
224
  }
224
225
 
226
+ /**
227
+ * Terminal-session presence: tell the server which Claude sessions exist in
228
+ * this repo (localSessions.mjs reads them off Claude's own on-disk state), so
229
+ * the Workbench can offer "adopt this terminal session as a tab". Best-effort
230
+ * in exactly the way the env/runtimes blocks are — a presence report that can
231
+ * fail a poll is worse than no presence at all — with three quiet economies:
232
+ * the scan runs at most once a minute (the reconcile loop ticks far faster), a
233
+ * report identical to the last DELIVERED one is not re-sent, and a 404 means
234
+ * an older server that has never heard of the endpoint, after which this
235
+ * process stops asking (a deploy that adds it also restarts nothing on this
236
+ * machine, so silence-until-restart costs one daemon restart, not a feature).
237
+ */
238
+ const LOCAL_SESSIONS_URL = FLEET_URL.replace(/\/agents\/?$/, '/local-sessions');
239
+ const LOCAL_SESSIONS_SCAN_MS = 60_000;
240
+ // The web hides a report older than 10 minutes (presence must not linger as
241
+ // fact after the machine dies), so an UNCHANGED report is re-sent inside that
242
+ // window anyway — the re-send is the machine's heartbeat on this fact, and
243
+ // suppressing it entirely would blank the strip while everything still holds.
244
+ const LOCAL_SESSIONS_RESEND_MS = 5 * 60_000;
245
+ let localSessionsUnsupported = false; // the server 404'd — quiet until restart
246
+ let localSessionsScanAt = 0;
247
+ let localSessionsSent = null; // last payload the server ACCEPTED, stringified
248
+ let localSessionsSentAt = 0;
249
+ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
250
+ if (localSessionsUnsupported) return;
251
+ if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
252
+ localSessionsScanAt = Date.now();
253
+ let payload;
254
+ try {
255
+ // scanLocalSessions orders deterministically, so this string only changes
256
+ // when the facts on disk do — the dedup below compares whole payloads.
257
+ payload = JSON.stringify({ sessions: scanLocalSessions({ repoRoot, excludeDirs }) });
258
+ } catch {
259
+ return; // presence must never throw into the poll loop
260
+ }
261
+ if (payload === localSessionsSent && Date.now() - localSessionsSentAt < LOCAL_SESSIONS_RESEND_MS)
262
+ return;
263
+ try {
264
+ const res = await fetch(LOCAL_SESSIONS_URL, {
265
+ method: 'POST',
266
+ headers: {
267
+ Authorization: `Bearer ${FLEET_TOKEN}`,
268
+ 'User-Agent': USER_AGENT,
269
+ 'Content-Type': 'application/json',
270
+ },
271
+ signal: AbortSignal.timeout(15_000),
272
+ body: payload,
273
+ });
274
+ if (res.status === 404) {
275
+ localSessionsUnsupported = true; // older server — it REPLACED nothing here
276
+ return;
277
+ }
278
+ // Only an accepted report counts as sent; anything else forgets the
279
+ // last-sent payload so the next pass retries instead of dedup-suppressing
280
+ // a report the server never received.
281
+ localSessionsSent = res.ok ? payload : null;
282
+ localSessionsSentAt = res.ok ? Date.now() : 0;
283
+ } catch {
284
+ localSessionsSent = null;
285
+ localSessionsSentAt = 0;
286
+ }
287
+ }
288
+
225
289
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
226
290
  // base between tasks (fresh conversation), resume in place while on a blocker.
227
291
  async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getNext, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
@@ -1754,6 +1818,10 @@ export async function runFleetDaemon() {
1754
1818
  // sessions are LIVE, and the guards above (chains, shipping) are populated
1755
1819
  // by the intake this same tick.
1756
1820
  retireWorkSessions(roster.activeWorkSessions);
1821
+ // Terminal-session presence, throttled + dedup'd inside; never awaited —
1822
+ // the daemon's own worktrees are carved out (a session the daemon spawned
1823
+ // is already a tab, not something to offer adopting).
1824
+ void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
1757
1825
  processJoinJobs(roster.joinJobs);
1758
1826
  processCleanupJobs(roster.cleanupJobs);
1759
1827
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Terminal-session presence — which Claude Code sessions exist in THIS repo,
3
+ * read off Claude's own on-disk state. Nothing here is inference: the liveness
4
+ * registry (~/.claude/sessions/<pid>.json) says what is open right now, and the
5
+ * transcript store (~/.claude/projects/<munged-cwd>/<id>.jsonl) says what was.
6
+ * The daemon RELAYS both to the server so the Workbench can offer "adopt this
7
+ * terminal session as a tab" — activity, never capacity, and only ever facts
8
+ * the user could see by looking at their own machine.
9
+ *
10
+ * The one contract that matters to callers: NOTHING in this file throws. A
11
+ * presence scan runs inside the poll loop's best-effort tail, and a torn
12
+ * registry file or a vanished cwd is a session to skip, not an error to raise.
13
+ */
14
+
15
+ import {
16
+ readdirSync,
17
+ readFileSync,
18
+ realpathSync,
19
+ statSync,
20
+ openSync,
21
+ readSync,
22
+ closeSync,
23
+ } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+
27
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
28
+ const REPORT_CAP = 30;
29
+
30
+ /** Path-prefix containment on already-realpath'd absolute paths. */
31
+ const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
32
+
33
+ /**
34
+ * Is this pid the SAME process the registry entry recorded?
35
+ *
36
+ * Registry entries go stale — Claude exits, the pid is recycled by something
37
+ * else, the file stays. `/proc/<pid>` existing only proves A process; the
38
+ * starttime (field 22 of /proc/<pid>/stat) proves it is THAT process. The comm
39
+ * field (parenthesised, may itself contain spaces and parens) makes naive
40
+ * whitespace-splitting wrong, so fields are counted from after the LAST ')':
41
+ * the first post-comm field is field 3, which puts starttime at index 19.
42
+ */
43
+ function pidAlive(pid, procStart) {
44
+ if (!Number.isInteger(pid) || pid <= 0) return false;
45
+ let stat;
46
+ try {
47
+ stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
48
+ } catch {
49
+ return false; // no /proc entry — the process is gone
50
+ }
51
+ if (procStart == null) return true; // nothing recorded to compare against
52
+ const close = stat.lastIndexOf(')');
53
+ if (close === -1) return false;
54
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
55
+ return fields[19] === String(procStart);
56
+ }
57
+
58
+ /**
59
+ * Is a terminal Claude session with this id open on the machine RIGHT NOW?
60
+ *
61
+ * The adoption path asks this at the moment of adopting: forking a session
62
+ * while its terminal is still typing into it would put two Claudes on one
63
+ * conversation, which is the exact incoherence the Workbench's own locks
64
+ * exist to prevent.
65
+ */
66
+ export function isTerminalSessionLive(sessionId) {
67
+ try {
68
+ const dir = join(homedir(), '.claude', 'sessions');
69
+ for (const name of readdirSync(dir)) {
70
+ if (!name.endsWith('.json')) continue;
71
+ let rec;
72
+ try {
73
+ rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
74
+ } catch {
75
+ continue; // torn write / not JSON — not evidence of anything
76
+ }
77
+ if (rec?.sessionId !== sessionId) continue;
78
+ if (pidAlive(rec.pid, rec.procStart)) return true;
79
+ }
80
+ } catch {
81
+ /* registry unreadable — no proof of life is "not live" */
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * First transcript record that carries a cwd, from the file's head only.
88
+ *
89
+ * A transcript can be megabytes; the cwd/gitBranch identity rides on every
90
+ * record, so ~16KB from the front is enough to verify WHOSE session this is
91
+ * without paying to read the conversation. A file whose first cwd-bearing
92
+ * line does not parse (truncated at the window edge) is skipped, not retried
93
+ * deeper — this is presence, not forensics.
94
+ */
95
+ function firstCwdRecord(file) {
96
+ let fd;
97
+ try {
98
+ fd = openSync(file, 'r');
99
+ const buf = Buffer.alloc(16384);
100
+ const n = readSync(fd, buf, 0, buf.length, 0);
101
+ for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
102
+ if (!line.includes('"cwd":"')) continue;
103
+ try {
104
+ const rec = JSON.parse(line);
105
+ if (rec && typeof rec.cwd === 'string' && rec.cwd) return rec;
106
+ } catch {
107
+ /* an incomplete line at the window edge — try the next candidate */
108
+ }
109
+ }
110
+ return null;
111
+ } catch {
112
+ return null;
113
+ } finally {
114
+ if (fd !== undefined) {
115
+ try {
116
+ closeSync(fd);
117
+ } catch {
118
+ /* best-effort */
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Every Claude terminal session belonging to this repo: LIVE ones from the
126
+ * liveness registry, ENDED ones from the transcript store. Returns
127
+ * [{ id, cwd, live, lastActiveAt, branch? }], live first, then newest ended,
128
+ * capped at 30, deterministically ordered (so a stringified report only
129
+ * changes when the facts do).
130
+ *
131
+ * `excludeDirs` carves out the daemon's own worktrees: sessions the daemon
132
+ * itself spawned are tabs already, and offering to adopt one would be the
133
+ * product offering the user their own reflection.
134
+ */
135
+ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
136
+ const live = [];
137
+ const ended = [];
138
+ try {
139
+ let realRoot;
140
+ try {
141
+ realRoot = realpathSync(repoRoot);
142
+ } catch {
143
+ realRoot = String(repoRoot ?? '');
144
+ }
145
+ if (!realRoot) return [];
146
+ const excludes = [];
147
+ for (const d of excludeDirs) {
148
+ if (!d) continue;
149
+ try {
150
+ excludes.push(realpathSync(d));
151
+ } catch {
152
+ excludes.push(String(d)); // not on disk yet — keep the literal fence
153
+ }
154
+ }
155
+ const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
156
+
157
+ // ── LIVE: the registry, validated pid by pid ─────────────────────────
158
+ const nowIso = new Date().toISOString();
159
+ const liveIds = new Set();
160
+ let regNames = [];
161
+ try {
162
+ regNames = readdirSync(join(homedir(), '.claude', 'sessions'));
163
+ } catch {
164
+ /* no registry — no live sessions */
165
+ }
166
+ for (const name of regNames) {
167
+ if (!name.endsWith('.json')) continue; // .key files ride alongside
168
+ let rec;
169
+ try {
170
+ rec = JSON.parse(readFileSync(join(homedir(), '.claude', 'sessions', name), 'utf8'));
171
+ } catch {
172
+ continue;
173
+ }
174
+ if (!rec || typeof rec.sessionId !== 'string' || typeof rec.cwd !== 'string') continue;
175
+ if (liveIds.has(rec.sessionId)) continue;
176
+ if (!pidAlive(rec.pid, rec.procStart)) continue;
177
+ let cwd;
178
+ try {
179
+ cwd = realpathSync(rec.cwd);
180
+ } catch {
181
+ continue; // the directory is gone — nothing to point a tab at
182
+ }
183
+ if (!ours(cwd)) continue;
184
+ liveIds.add(rec.sessionId);
185
+ live.push({
186
+ id: rec.sessionId,
187
+ cwd,
188
+ live: true,
189
+ lastActiveAt: nowIso,
190
+ ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
191
+ });
192
+ }
193
+ live.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
194
+
195
+ // ── ENDED: the transcript store, verified file by file ───────────────
196
+ //
197
+ // The munged directory name is a PREFIX match on purpose: a session run in
198
+ // a SUBDIRECTORY of the repo munges to a longer name sharing the root's.
199
+ // But so does a sibling repo ('flowviant-two' shares 'flowviant' + '-'),
200
+ // which is why every candidate is verified against the cwd its own records
201
+ // embed rather than trusted on its directory name.
202
+ const munged = realRoot.replace(/[/.]/g, '-');
203
+ const projectsDir = join(homedir(), '.claude', 'projects');
204
+ let projDirs = [];
205
+ try {
206
+ projDirs = readdirSync(projectsDir);
207
+ } catch {
208
+ /* no transcript store — live sessions still report */
209
+ }
210
+ const cutoff = Date.now() - SEVEN_DAYS_MS;
211
+ const candidates = [];
212
+ for (const dirName of projDirs) {
213
+ if (dirName !== munged && !dirName.startsWith(`${munged}-`)) continue;
214
+ let entries = [];
215
+ try {
216
+ entries = readdirSync(join(projectsDir, dirName), { withFileTypes: true });
217
+ } catch {
218
+ continue;
219
+ }
220
+ for (const ent of entries) {
221
+ if (!ent.isFile() || !ent.name.endsWith('.jsonl')) continue; // top-level only
222
+ const id = ent.name.slice(0, -'.jsonl'.length);
223
+ if (!id || liveIds.has(id)) continue; // a live session outranks its own transcript
224
+ const file = join(projectsDir, dirName, ent.name);
225
+ let mtimeMs;
226
+ try {
227
+ mtimeMs = statSync(file).mtimeMs;
228
+ } catch {
229
+ continue;
230
+ }
231
+ if (mtimeMs < cutoff) continue; // week-old sessions are history, not presence
232
+ candidates.push({ id, file, mtimeMs });
233
+ }
234
+ }
235
+ // Newest first, then verify only as many as the cap still has room for —
236
+ // the verification read is the expensive step, so it is not spent on
237
+ // sessions the report would drop anyway.
238
+ 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));
240
+ const endedIds = new Set();
241
+ for (const cand of candidates) {
242
+ if (ended.length >= room) break;
243
+ if (endedIds.has(cand.id)) continue; // one row per session, whatever dir names it
244
+ endedIds.add(cand.id);
245
+ const rec = firstCwdRecord(cand.file);
246
+ if (!rec) continue;
247
+ let cwd;
248
+ try {
249
+ cwd = realpathSync(rec.cwd);
250
+ } catch {
251
+ continue;
252
+ }
253
+ if (!ours(cwd)) continue;
254
+ ended.push({
255
+ id: cand.id,
256
+ cwd,
257
+ live: false,
258
+ lastActiveAt: new Date(cand.mtimeMs).toISOString(),
259
+ ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
260
+ });
261
+ }
262
+ } catch {
263
+ /* presence must never throw into the poll loop — report what was gathered */
264
+ }
265
+ return [...live.slice(0, REPORT_CAP), ...ended];
266
+ }
@@ -310,9 +310,18 @@ export const RUNTIMES = {
310
310
  * strongest form of it available anywhere: `--append-system-prompt` sits
311
311
  * above the conversation rather than inside it.
312
312
  */
313
- args({ prompt, system, model, effort, resume, streamJson, perm, mcp = [], resultSchemaArgs = [] }) {
313
+ args({ prompt, system, model, effort, resume, streamJson, perm, mcp = [], resultSchemaArgs = [], adoptResumeId }) {
314
314
  const a = [];
315
- if (resume) a.push('--continue');
315
+ // ADOPTION — the first turn of a tab born from a terminal session.
316
+ // `--resume <id> --fork-session` finds the session globally (any cwd),
317
+ // carries its full context, and writes the FORK natively into THIS cwd's
318
+ // own store, leaving the original transcript untouched (measured on
319
+ // 2.1.234). From turn 2 on the plain `--continue` below resumes the fork
320
+ // where it now lives, so nothing downstream knows the tab was adopted.
321
+ // INSTEAD of --continue, never alongside it: they are the same decision
322
+ // ("what conversation is this?") answered two different ways.
323
+ if (adoptResumeId) a.push('--resume', adoptResumeId, '--fork-session');
324
+ else if (resume) a.push('--continue');
316
325
  a.push('-p', prompt, '--append-system-prompt', system);
317
326
  a.push(...mcp, ...resultSchemaArgs);
318
327
  a.push('--model', model || MODEL);
@@ -371,7 +380,12 @@ export const RUNTIMES = {
371
380
  * placed before it. Appending them after the positional is the kind of argv
372
381
  * that parses today and stops parsing on some future clap upgrade.
373
382
  */
374
- args({ prompt, system, model, effort, resume, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [] }) {
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');
375
389
  const a = ['exec'];
376
390
  if (resume) a.push('resume', '--last');
377
391
  a.push('--json');
@@ -610,7 +624,10 @@ export const RUNTIMES = {
610
624
  */
611
625
  profiles: ['build', 'wiki', 'consult'],
612
626
  mcp: null,
613
- args({ prompt, system, model, effort, resume, profile = 'build', vaultDir, resultSchemaArgs = [] }) {
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');
614
631
  const a = [];
615
632
  if (resume) a.push('--continue');
616
633
  // No system-prompt flag, same weakening as Codex: the contract rides in
package/bin/lib/work.mjs CHANGED
@@ -15,16 +15,28 @@
15
15
  * two getters (the MCP URL and the lease TTL can change with any poll).
16
16
  */
17
17
 
18
- import { existsSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import {
19
+ existsSync,
20
+ rmSync,
21
+ readdirSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ realpathSync,
25
+ statSync,
26
+ lstatSync,
27
+ mkdirSync,
28
+ cpSync,
29
+ } from 'node:fs';
19
30
  import { execFileSync } from 'node:child_process';
20
- import { join } from 'node:path';
31
+ import { join, dirname } from 'node:path';
21
32
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, REFRESH_BEFORE_SECONDS } from './config.mjs';
22
- import { git, baseBranchName, isSafePathSegment } from './git.mjs';
33
+ import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
23
34
  import { c, note, ok, warn } from './ui.mjs';
24
35
  import { mcpFor, runTurn } from './claude.mjs';
25
36
  import { SYSTEM_WORK, WORK_TURN_KICKOFF } from './prompts.mjs';
26
37
  import { materializeInto, scrub as envScrub } from './env.mjs';
27
- import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
38
+ import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
39
+ import { isTerminalSessionLive } from './localSessions.mjs';
28
40
 
29
41
  export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLeaseTtl }) {
30
42
  const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
@@ -178,14 +190,21 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
178
190
  * is the point. If the directory was retired but the branch survives, the
179
191
  * worktree re-attaches to the branch and the committed work is still there.
180
192
  */
181
- const sessionWtFor = (sessionId) => {
193
+ const sessionWtFor = (sessionId, baseAt) => {
182
194
  if (!isSafePathSegment(sessionId)) return null;
183
195
  const wt = join(baseDir, 'sessions', sessionId);
184
196
  const fresh = !existsSync(wt);
185
197
  if (fresh) {
186
198
  const branch = `session/${sessionId}`;
199
+ // `baseAt` is the adoption override: a tab born from a terminal session
200
+ // branches from THAT checkout's HEAD, because the conversation being
201
+ // resumed was had against those commits — putting it on the project base
202
+ // would hand it a repo state it has never seen. Everything else is
203
+ // unchanged, the attach fallback included: a surviving branch already
204
+ // chose its base, and re-basing it here would move committed work.
205
+ const at = baseAt || baseRef;
187
206
  try {
188
- git(['worktree', 'add', '-b', branch, wt, baseRef], repoRoot);
207
+ git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
189
208
  } catch {
190
209
  git(['worktree', 'prune'], repoRoot);
191
210
  try {
@@ -193,7 +212,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
193
212
  git(['worktree', 'add', wt, branch], repoRoot);
194
213
  } catch {
195
214
  try {
196
- git(['worktree', 'add', '-b', branch, wt, baseRef], repoRoot);
215
+ git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
197
216
  } catch {
198
217
  return null;
199
218
  }
@@ -233,6 +252,84 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
233
252
  }
234
253
  };
235
254
 
255
+ /**
256
+ * Carry a terminal checkout's DIRTY state into a fresh adopt worktree. The
257
+ * source is strictly READ-ONLY — nothing here writes to it, because it is
258
+ * the human's own checkout and adoption promises to leave it exactly as the
259
+ * closed terminal did. Tracked changes travel as one binary patch staged
260
+ * through the worktree's PRIVATE git dir (invisible to status, dies with the
261
+ * tree); untracked files are copied one by one, skipping anything over 5MB.
262
+ *
263
+ * Returns '' or ONE bracketed line for the turn's prompt: a carry problem is
264
+ * the AGENT's to explain to the user, never a reason to fail the adoption —
265
+ * the conversation is the thing being adopted, and it resumes either way.
266
+ */
267
+ const carryDirtyState = (srcCwd, wt) => {
268
+ const problems = [];
269
+ try {
270
+ // A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
271
+ // text file) must round-trip byte-exact or the apply corrupts what it
272
+ // carries. 64MB of headroom — a dirtier tree than that fails the read
273
+ // here and is SAID, below, rather than half-applied.
274
+ const patch = execFileSync('git', ['diff', 'HEAD', '--binary'], {
275
+ cwd: srcCwd,
276
+ stdio: ['ignore', 'pipe', 'pipe'],
277
+ maxBuffer: 64 * 1024 * 1024,
278
+ });
279
+ if (patch.length) {
280
+ const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch');
281
+ if (!patchPath) throw new Error('no private git dir to stage the patch in');
282
+ try {
283
+ writeFileSync(patchPath, patch);
284
+ git(['apply', '--whitespace=nowarn', patchPath], wt);
285
+ } finally {
286
+ try {
287
+ rmSync(patchPath, { force: true });
288
+ } catch {
289
+ /* best-effort — the private git dir dies with the worktree anyway */
290
+ }
291
+ }
292
+ }
293
+ } catch {
294
+ problems.push(
295
+ 'their uncommitted TRACKED changes did not carry over (they are still in the terminal checkout, untouched)'
296
+ );
297
+ }
298
+ try {
299
+ const skipped = [];
300
+ for (const rel of splitNul(
301
+ gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], srcCwd)
302
+ )) {
303
+ try {
304
+ const from = join(srcCwd, rel);
305
+ // lstat, not stat: a symlink is carried as itself, and its own size
306
+ // is what the 5MB budget judges — never the file it points at.
307
+ if (lstatSync(from).size > 5 * 1024 * 1024) {
308
+ skipped.push(rel);
309
+ continue;
310
+ }
311
+ const to = join(wt, rel);
312
+ mkdirSync(dirname(to), { recursive: true });
313
+ cpSync(from, to);
314
+ } catch {
315
+ skipped.push(rel);
316
+ }
317
+ }
318
+ if (skipped.length) {
319
+ problems.push(
320
+ `${skipped.length} untracked file${skipped.length === 1 ? '' : 's'} did not carry (over 5MB or unreadable): ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ', …' : ''}`
321
+ );
322
+ }
323
+ } catch {
324
+ problems.push(
325
+ 'untracked files could not be listed in the terminal checkout, so none were carried'
326
+ );
327
+ }
328
+ return problems.length
329
+ ? `[ADOPTION NOTE from the daemon — tell the user plainly at the start of your reply: ${problems.join('; ')}.]`
330
+ : '';
331
+ };
332
+
236
333
  /**
237
334
  * WHICH CLI drives this session — picked ONCE, on the first turn, and pinned
238
335
  * in the worktree's meta dir. The held context belongs to the CLI that made
@@ -241,8 +338,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
241
338
  * same reason). If the pinned CLI has left the machine, the turn settles
242
339
  * honestly instead of substituting. A retired-and-reattached directory has
243
340
  * no marker and no held context either, so re-picking there is correct.
244
- * Returns { id } | { id: null } (nothing installed) | { missing: label }.
341
+ * Returns { id } | { id: null } (nothing installed) | { missing: label } |
342
+ * { unsupported: label } (pinned to a runtime no session can run on).
343
+ *
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.
245
350
  */
351
+ const sessionCapable = (rid) => Boolean(RUNTIMES[rid]?.mcp) && canRun(RUNTIMES[rid], 'build');
246
352
  const sessionRuntime = (wt) => {
247
353
  const marker = sessionMetaPath(wt, 'flowviant-runtime');
248
354
  let pinned = null;
@@ -254,10 +360,19 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
254
360
  }
255
361
  }
256
362
  if (pinned && RUNTIMES[pinned]) {
363
+ // A pin that names a non-session-capable runtime is settled honestly by
364
+ // the caller, not silently re-picked: re-picking would hand the held
365
+ // context to a different brain, which is the exact substitution the pin
366
+ // exists to prevent.
367
+ if (!sessionCapable(pinned)) return { unsupported: RUNTIMES[pinned].label || pinned };
257
368
  const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
258
369
  return installed ? { id: pinned } : { missing: RUNTIMES[pinned].label || pinned };
259
370
  }
260
- const id = pickRuntimeFor('build');
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.
373
+ const rows = detectRuntimes();
374
+ const okFor = (rid) => sessionCapable(rid) && Boolean(rows.find((r) => r.id === rid)?.installed);
375
+ const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);
261
376
  if (!id) return { id: null };
262
377
  if (marker) {
263
378
  try {
@@ -394,7 +509,110 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
394
509
  note(
395
510
  `${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
396
511
  );
397
- const dir = sessionWtFor(job.sessionId);
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
+ // ── ADOPTION: a tab born from a TERMINAL session ────────────────
525
+ // The server sends `adopt {id, cwd}` only while the session has no
526
+ // sessionRef — no turn has ever spoken from a worktree here — and
527
+ // the first turn resumes the terminal conversation by forking it
528
+ // into the tab's own worktree. Everything the server asserts is
529
+ // re-validated MACHINE-side: the id shape, the source directory,
530
+ // and — decisive — that the terminal is actually closed, because
531
+ // forking a session someone is still typing into puts two Claudes
532
+ // on one conversation.
533
+ const adopting = Boolean(job.adopt) && !job.sessionRef;
534
+ let srcHead = null;
535
+ let adoptSrc = null; // the validated, realpath'd source checkout
536
+ if (adopting) {
537
+ if (
538
+ typeof job.adopt.id !== 'string' ||
539
+ !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
540
+ ) {
541
+ await settleWorkTurn(job.id, {
542
+ ok: false,
543
+ answer: 'that terminal session id is not one this machine can resume',
544
+ });
545
+ return;
546
+ }
547
+ let srcCwd = null;
548
+ try {
549
+ srcCwd = realpathSync(String(job.adopt.cwd ?? ''));
550
+ if (!statSync(srcCwd).isDirectory()) srcCwd = null;
551
+ } catch {
552
+ srcCwd = null;
553
+ }
554
+ if (!srcCwd) {
555
+ await settleWorkTurn(job.id, {
556
+ ok: false,
557
+ answer: "the terminal session's directory no longer exists on the machine",
558
+ });
559
+ return;
560
+ }
561
+ // Inside the repo, outside the daemon's own worktrees: an adopt
562
+ // source is a HUMAN's checkout, and one of our directories showing
563
+ // up here means a stale or confused offer, not a session to fork.
564
+ const under = (p, root) =>
565
+ p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
566
+ let realRoot = repoRoot;
567
+ let realBase = baseDir;
568
+ try {
569
+ realRoot = realpathSync(repoRoot);
570
+ } catch {
571
+ /* keep the literal path */
572
+ }
573
+ try {
574
+ realBase = realpathSync(baseDir);
575
+ } catch {
576
+ /* keep the literal path */
577
+ }
578
+ if (!under(srcCwd, realRoot)) {
579
+ await settleWorkTurn(job.id, {
580
+ ok: false,
581
+ answer: "the terminal session's directory is outside this project's repository",
582
+ });
583
+ return;
584
+ }
585
+ if (under(srcCwd, realBase)) {
586
+ await settleWorkTurn(job.id, {
587
+ ok: false,
588
+ answer:
589
+ "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
590
+ });
591
+ return;
592
+ }
593
+ try {
594
+ srcHead = git(['rev-parse', 'HEAD'], srcCwd);
595
+ } catch {
596
+ await settleWorkTurn(job.id, {
597
+ ok: false,
598
+ answer:
599
+ "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
600
+ });
601
+ return;
602
+ }
603
+ if (isTerminalSessionLive(job.adopt.id)) {
604
+ await settleWorkTurn(job.id, {
605
+ ok: false,
606
+ answer:
607
+ 'That terminal session is still open on the machine — close it there first, then adopt.',
608
+ });
609
+ return;
610
+ }
611
+ adoptSrc = srcCwd;
612
+ }
613
+ // Based at the SOURCE's HEAD when adopting — the resumed
614
+ // conversation was had against those commits, not the project base.
615
+ const dir = sessionWtFor(job.sessionId, adopting ? srcHead : undefined);
398
616
  if (!dir) {
399
617
  await settleWorkTurn(job.id, {
400
618
  ok: false,
@@ -422,6 +640,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
422
640
  });
423
641
  return;
424
642
  }
643
+ 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.
648
+ await settleWorkTurn(job.id, {
649
+ ok: false,
650
+ answer: `this session is pinned to ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
651
+ });
652
+ return;
653
+ }
425
654
  if (!rt.id) {
426
655
  await settleWorkTurn(job.id, {
427
656
  ok: false,
@@ -430,6 +659,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
430
659
  });
431
660
  return;
432
661
  }
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.
666
+ await settleWorkTurn(job.id, {
667
+ ok: false,
668
+ answer:
669
+ 'adopting a terminal session needs Claude Code on the machine — install it, then try again',
670
+ });
671
+ return;
672
+ }
433
673
  let mint = await mintWorkToken(job.sessionId);
434
674
  if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
435
675
  if (mint?.gone) {
@@ -454,6 +694,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
454
694
  // just opened. Anything else starts fresh IN the existing worktree —
455
695
  // never a reset; the dirty state is the session.
456
696
  const resume = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
697
+ // The dirty carry, on the adopt worktree's FIRST life only: a
698
+ // re-attempted adoption (the directory already exists) carried what
699
+ // it could the first time, and re-applying would double it. A carry
700
+ // problem never fails the adoption — it becomes one bracketed line
701
+ // in the prompt, so the AGENT tells the user what stayed behind.
702
+ let carryNote = '';
703
+ if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
457
704
  const mcp = mcpFor(rt.id, mint.token, getMcpUrl());
458
705
  // Attempts count RUNS: the infra refusals above consumed nothing and
459
706
  // settled on their own terms.
@@ -465,9 +712,14 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
465
712
  prompt: WORK_TURN_KICKOFF({
466
713
  sessionId: job.sessionId,
467
714
  sessionName: job.sessionName,
468
- message: job.body,
715
+ message: carryNote ? `${job.body}\n\n${carryNote}` : job.body,
469
716
  askedByName: job.askedByName,
470
717
  }),
718
+ // The adopt turn resumes the TERMINAL conversation by forking it
719
+ // into this cwd (claude: --resume <id> --fork-session). After it
720
+ // speaks once, the fork lives natively here and turn 2+ is the
721
+ // ordinary --continue resume path, unchanged.
722
+ ...(adopting ? { adoptResumeId: job.adopt.id } : {}),
471
723
  system: SYSTEM_WORK,
472
724
  cwd: dir.wt,
473
725
  mcpArgs: mcp.args,
@@ -491,8 +743,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
491
743
  // A resume that produced NOTHING usually means the held
492
744
  // conversation is gone (a first turn that crashed before writing
493
745
  // state, a wiped CLI dir). Retry once fresh in the SAME worktree —
494
- // never reset — instead of bricking the tab forever.
495
- if (resume && !(out || '').trim()) out = await runTurn({ ...turnArgs, resume: false });
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.
751
+ if (!adopting && resume && !(out || '').trim())
752
+ out = await runTurn({ ...turnArgs, resume: false });
496
753
  } finally {
497
754
  for (const ch of spawned) workChildren.delete(ch);
498
755
  if (lockPath) {
@@ -509,6 +766,18 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
509
766
  // workers' no-sentinel case) — drop the cached token so the next
510
767
  // turn re-mints instead of failing the same way forever.
511
768
  if (!answer) workTokens.delete(job.sessionId);
769
+ if (adopting && !answer) {
770
+ // The fork came back with nothing — the terminal session's
771
+ // transcript is most likely gone (cleaned, expired, deleted). Say
772
+ // exactly that; no sessionRef is recorded, so the server keeps
773
+ // offering the adoption and a retry after the user checks is cheap.
774
+ await settleWorkTurn(job.id, {
775
+ ok: false,
776
+ answer: "Couldn't resume the terminal session — it may have been removed.",
777
+ });
778
+ warn('adopt turn produced no output — settled as failed');
779
+ return;
780
+ }
512
781
  await settleWorkTurn(job.id, {
513
782
  ok: answer.length > 0,
514
783
  answer:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.44.1",
3
+ "version": "0.45.0",
4
4
  "description": "Run your own coding CLIs as headless build agents for Flowviant — Claude Code or Codex, on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {