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.
- package/bin/lib/claude.mjs +6 -1
- package/bin/lib/fleet.mjs +68 -0
- package/bin/lib/localSessions.mjs +266 -0
- package/bin/lib/runtimes.mjs +21 -4
- package/bin/lib/work.mjs +282 -13
- 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 }) {
|
|
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
|
+
}
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|