flowviant 0.102.2 → 0.104.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/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A WORKBENCH TAB'S TURN — offered, admitted, placed, adopted, pinned to its
|
|
3
|
+
* CLI, credentialed, resumed, run, persisted and settled.
|
|
4
|
+
*
|
|
5
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). This is ONE lifecycle and it
|
|
6
|
+
* stays one function on purpose — the order of its refusals IS the contract
|
|
7
|
+
* (no place, no worktree, a live lock, the wrong brain, no credential: each
|
|
8
|
+
* settles in words or waits, before anything is spawned), and pulling its
|
|
9
|
+
* steps apart would scatter that order across files. What it USES and does not
|
|
10
|
+
* own went to siblings, each named at the factory call in work.mjs: the place
|
|
11
|
+
* and its markers (workPlaces.mjs), the settle queue (workReportQueue.mjs),
|
|
12
|
+
* the credential (workSessionTokens.mjs), the runtime pin (workSessionRuntime
|
|
13
|
+
* .mjs), the adopt carry (workAdoptCarry.mjs), attachments
|
|
14
|
+
* (workAttachments.mjs), the live line (workNarration.mjs), the tool log
|
|
15
|
+
* (workToolLog.mjs) and the command audit (workCommandAudit.mjs).
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { git, isSafePathSegment } from './git.mjs';
|
|
19
|
+
import { processStartTime } from './procRegistry.mjs';
|
|
20
|
+
import { c, note, ok, warn } from './ui.mjs';
|
|
21
|
+
import { mcpFor } from './claude.mjs';
|
|
22
|
+
import { runTurn } from './runTurn.mjs';
|
|
23
|
+
import {
|
|
24
|
+
SYSTEM_WORK,
|
|
25
|
+
SYSTEM_CAPTURE,
|
|
26
|
+
WORK_TURN_KICKOFF,
|
|
27
|
+
CAPTURE_TURN_KICKOFF,
|
|
28
|
+
SYSTEM_WORK_PLAIN,
|
|
29
|
+
WORK_TURN_KICKOFF_PLAIN,
|
|
30
|
+
withProjectContext,
|
|
31
|
+
} from './prompts.mjs';
|
|
32
|
+
import { knowledgeDirFor } from './knowledgeFiles.mjs';
|
|
33
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
34
|
+
import { recordSkills, recordMcpServers } from './runtimeCapabilities.mjs';
|
|
35
|
+
import { isTerminalSessionLive } from './claudeSessions.mjs';
|
|
36
|
+
import { isAgyConversationLive } from './agySessions.mjs';
|
|
37
|
+
import { resumeRetriesFresh } from './resumeLost.mjs';
|
|
38
|
+
import { turnLockedByLivePid } from './turnLock.mjs';
|
|
39
|
+
import { brainFor } from './workBrain.mjs';
|
|
40
|
+
import { REPO_PLACE } from './workPlaces.mjs';
|
|
41
|
+
import { CODEX_THREAD_RE, AGY_CONV_RE } from './workSessionRuntime.mjs';
|
|
42
|
+
import { createToolLog } from './workToolLog.mjs';
|
|
43
|
+
import { createCommandAudit } from './workCommandAudit.mjs';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
|
|
47
|
+
* rather than in prompts.mjs because it is a MODIFIER on whichever contract
|
|
48
|
+
* the tab runs, not a contract of its own.
|
|
49
|
+
*
|
|
50
|
+
* The enforcement is not this sentence — it is `--permission-mode plan`, under
|
|
51
|
+
* which the CLI itself refuses every write (claudePosture.mjs, PLAN_MODE_PERM). What
|
|
52
|
+
* the sentence buys is the ANSWER: the plain contract above it says "edit
|
|
53
|
+
* freely, commit", and a turn told only that would spend itself discovering
|
|
54
|
+
* refusals. It also says not to reach for `ExitPlanMode` (measured: disabled
|
|
55
|
+
* under `-p`, and the probe turn then asked the person to leave plan mode
|
|
56
|
+
* themselves) — leaving plan mode is the person's switch in the tab.
|
|
57
|
+
*/
|
|
58
|
+
export const PLAN_TURN_SENTENCE =
|
|
59
|
+
'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
|
|
60
|
+
|
|
61
|
+
export function createWorkSessionTurns({
|
|
62
|
+
repoRoot,
|
|
63
|
+
baseDir,
|
|
64
|
+
getMcpUrl,
|
|
65
|
+
getArtifactsAccepted,
|
|
66
|
+
admit,
|
|
67
|
+
inPlace,
|
|
68
|
+
workChildren,
|
|
69
|
+
workAttempts,
|
|
70
|
+
sessionPlaces,
|
|
71
|
+
placeOf,
|
|
72
|
+
placeWtFor,
|
|
73
|
+
sessionMetaPath,
|
|
74
|
+
beforeArtifacts,
|
|
75
|
+
artifacts,
|
|
76
|
+
settleWorkTurn,
|
|
77
|
+
pendingWorkReports,
|
|
78
|
+
workTokens,
|
|
79
|
+
mintWorkToken,
|
|
80
|
+
sessionRuntime,
|
|
81
|
+
agyRegistryLookup,
|
|
82
|
+
carryDirtyState,
|
|
83
|
+
fetchAttachments,
|
|
84
|
+
makeNarrator,
|
|
85
|
+
sayTurnDeferred,
|
|
86
|
+
lastDeferSaid,
|
|
87
|
+
noteSessionGroup,
|
|
88
|
+
reportPlaceWorktrees,
|
|
89
|
+
burstListeners,
|
|
90
|
+
}) {
|
|
91
|
+
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
92
|
+
const MAX_WORK_TRIES = 3;
|
|
93
|
+
|
|
94
|
+
const processWorkTurns = (jobs) => {
|
|
95
|
+
for (const job of jobs ?? []) {
|
|
96
|
+
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
97
|
+
if (workAnswering.has(job.id)) continue;
|
|
98
|
+
// The turn already RAN and its answer sits in the delivery queue — never
|
|
99
|
+
// run it again while the report is merely undelivered.
|
|
100
|
+
if (pendingWorkReports.has(job.id)) continue;
|
|
101
|
+
/**
|
|
102
|
+
* THE BOX IS ABOUT TO FALL OVER, OR THIS MACHINE IS ALREADY AT ITS
|
|
103
|
+
* CEILING. Defer: return without settling and without consuming an
|
|
104
|
+
* attempt — the shape the live-CLI lock below uses, for the same reason.
|
|
105
|
+
* The job stays pending and the server re-offers it next poll; settling
|
|
106
|
+
* it would tell the human their message failed when nothing ran.
|
|
107
|
+
*
|
|
108
|
+
* `interactive`, not `churn`: somebody is watching a composer they just
|
|
109
|
+
* pressed enter in, so this holds out until the box is genuinely about to
|
|
110
|
+
* die rather than yielding early the way the unattended lanes do.
|
|
111
|
+
*
|
|
112
|
+
* AND THE SLOT IS RESERVED BEFORE THE NEXT ITERATION ASKS. This loop is
|
|
113
|
+
* synchronous and every spawn under it is not — `inPlace` resolves its
|
|
114
|
+
* callback in a later microtask — so `liveTurnCount()` could not move
|
|
115
|
+
* between jobs and a roster offering eight turns admitted all eight
|
|
116
|
+
* against a ceiling of one. See admission.mjs.
|
|
117
|
+
*/
|
|
118
|
+
const hold = admit('interactive');
|
|
119
|
+
if (hold) {
|
|
120
|
+
sayTurnDeferred(job.sessionId, job.id, hold.reason);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const releaseSlot = admit.reserve();
|
|
124
|
+
lastDeferSaid.delete(job.sessionId);
|
|
125
|
+
workAnswering.add(job.id);
|
|
126
|
+
const place = job.place || job.sessionId;
|
|
127
|
+
/**
|
|
128
|
+
* VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
|
|
129
|
+
* the roster's map — this is the OTHER writer of `sessionPlaces`, in the
|
|
130
|
+
* same reconcile tick, BEFORE the preview jobs and the sweep read it. An
|
|
131
|
+
* unchecked value stored here reaches every `placeDir` consumer: the
|
|
132
|
+
* turn is spawned in it, `burstListeners` measures it, and a share would
|
|
133
|
+
* publicly tunnel a directory OUTSIDE the checkout — the exact traversal
|
|
134
|
+
* the preview feature's port attribution exists to prevent. Settled out
|
|
135
|
+
* loud rather than skipped, because a silently dropped turn strands the
|
|
136
|
+
* tab for the server's whole expiry window.
|
|
137
|
+
*/
|
|
138
|
+
if (place !== REPO_PLACE && !isSafePathSegment(place)) {
|
|
139
|
+
void settleWorkTurn(job.id, {
|
|
140
|
+
ok: false,
|
|
141
|
+
answer:
|
|
142
|
+
'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
|
|
143
|
+
}).finally(() => workAnswering.delete(job.id));
|
|
144
|
+
releaseSlot();
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// Remembered for every other beat — the sweep, ship, the preview
|
|
148
|
+
// re-check — so they all ask the same directory this turn runs in.
|
|
149
|
+
sessionPlaces.set(job.sessionId, place);
|
|
150
|
+
// A READER: other turns in this place run alongside it. See `inPlace`.
|
|
151
|
+
inPlace(place, false, async () => {
|
|
152
|
+
/** This turn's artifact snapshot — set once the place is resolved and
|
|
153
|
+
* a CLI is about to spawn there; read in the `finally`. Null on every
|
|
154
|
+
* path that never ran a CLI, which is exactly when nothing is new. */
|
|
155
|
+
let artifactScan = null;
|
|
156
|
+
/** This turn's tool log — declared HERE, outside the `try`, because
|
|
157
|
+
* the `catch` below spreads it into the failed settle. Declared inside,
|
|
158
|
+
* the catch threw a ReferenceError instead of settling: the error's
|
|
159
|
+
* words were lost and the turn was re-offered and re-run. Null until
|
|
160
|
+
* the turn gets as far as building one. */
|
|
161
|
+
let toolLog = null;
|
|
162
|
+
try {
|
|
163
|
+
const tries = workAttempts.get(job.id) ?? 0;
|
|
164
|
+
if (tries >= MAX_WORK_TRIES) {
|
|
165
|
+
// Out of local tries: SETTLE, don't skip — a silently skipped turn
|
|
166
|
+
// strands the tab for the server's whole 24h expiry window.
|
|
167
|
+
await settleWorkTurn(job.id, {
|
|
168
|
+
ok: false,
|
|
169
|
+
answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
note(
|
|
174
|
+
`${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
|
|
175
|
+
);
|
|
176
|
+
// ── ADOPTION: a tab born from a TERMINAL session ────────────────
|
|
177
|
+
// The server sends `adopt {id, cwd}` only while the session has no
|
|
178
|
+
// sessionRef — no turn has ever spoken from a worktree here — and
|
|
179
|
+
// the first turn resumes the terminal conversation by forking it
|
|
180
|
+
// into the tab's own worktree. Everything the server asserts is
|
|
181
|
+
// re-validated MACHINE-side: the id shape, the source directory,
|
|
182
|
+
// and — decisive — that the terminal is actually closed, because
|
|
183
|
+
// forking a session someone is still typing into puts two Claudes
|
|
184
|
+
// on one conversation.
|
|
185
|
+
const adopting = Boolean(job.adopt) && !job.sessionRef;
|
|
186
|
+
let srcHead = null;
|
|
187
|
+
let adoptSrc = null; // the validated, realpath'd source checkout
|
|
188
|
+
if (adopting) {
|
|
189
|
+
if (
|
|
190
|
+
typeof job.adopt.id !== 'string' ||
|
|
191
|
+
!/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
|
|
192
|
+
) {
|
|
193
|
+
await settleWorkTurn(job.id, {
|
|
194
|
+
ok: false,
|
|
195
|
+
answer: 'that terminal session id is not one this machine can resume',
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
let srcCwd = null;
|
|
200
|
+
try {
|
|
201
|
+
srcCwd = realpathSync(String(job.adopt.cwd ?? ''));
|
|
202
|
+
if (!statSync(srcCwd).isDirectory()) srcCwd = null;
|
|
203
|
+
} catch {
|
|
204
|
+
srcCwd = null;
|
|
205
|
+
}
|
|
206
|
+
if (!srcCwd) {
|
|
207
|
+
await settleWorkTurn(job.id, {
|
|
208
|
+
ok: false,
|
|
209
|
+
answer: "the terminal session's directory no longer exists on the machine",
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// Inside the repo, outside the daemon's own worktrees: an adopt
|
|
214
|
+
// source is a HUMAN's checkout, and one of our directories showing
|
|
215
|
+
// up here means a stale or confused offer, not a session to fork.
|
|
216
|
+
const under = (p, root) =>
|
|
217
|
+
p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
|
|
218
|
+
let realRoot = repoRoot;
|
|
219
|
+
let realBase = baseDir;
|
|
220
|
+
try {
|
|
221
|
+
realRoot = realpathSync(repoRoot);
|
|
222
|
+
} catch {
|
|
223
|
+
/* keep the literal path */
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
realBase = realpathSync(baseDir);
|
|
227
|
+
} catch {
|
|
228
|
+
/* keep the literal path */
|
|
229
|
+
}
|
|
230
|
+
if (!under(srcCwd, realRoot)) {
|
|
231
|
+
await settleWorkTurn(job.id, {
|
|
232
|
+
ok: false,
|
|
233
|
+
answer: "the terminal session's directory is outside this project's repository",
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (under(srcCwd, realBase)) {
|
|
238
|
+
await settleWorkTurn(job.id, {
|
|
239
|
+
ok: false,
|
|
240
|
+
answer:
|
|
241
|
+
"that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
srcHead = git(['rev-parse', 'HEAD'], srcCwd);
|
|
247
|
+
} catch {
|
|
248
|
+
await settleWorkTurn(job.id, {
|
|
249
|
+
ok: false,
|
|
250
|
+
answer:
|
|
251
|
+
"the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Liveness by the SESSION's own runtime: Claude has a real pid
|
|
256
|
+
// registry; agy only leaves store-write recency + a process check,
|
|
257
|
+
// and adoption there is a MOVE (no fork exists), so the composite
|
|
258
|
+
// errs toward refusing — a false "live" costs a retry in minutes,
|
|
259
|
+
// a false "ended" puts two drivers on one conversation store.
|
|
260
|
+
const adoptLive =
|
|
261
|
+
job.runtime === 'antigravity'
|
|
262
|
+
? isAgyConversationLive(job.adopt.id)
|
|
263
|
+
: isTerminalSessionLive(job.adopt.id);
|
|
264
|
+
if (adoptLive) {
|
|
265
|
+
await settleWorkTurn(job.id, {
|
|
266
|
+
ok: false,
|
|
267
|
+
answer:
|
|
268
|
+
'That terminal session is still open on the machine — close it there first, then adopt.',
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
adoptSrc = srcCwd;
|
|
273
|
+
}
|
|
274
|
+
// Based at the SOURCE's HEAD when adopting — the resumed
|
|
275
|
+
// conversation was had against those commits, not the project base.
|
|
276
|
+
// The PLACE this tab works in — its own worktree unless the server
|
|
277
|
+
// named another. An older server sends no `place` and the default is
|
|
278
|
+
// the session's own id, which is what every tab has always done.
|
|
279
|
+
const dir = placeWtFor(place, adopting ? srcHead : undefined);
|
|
280
|
+
if (!dir) {
|
|
281
|
+
await settleWorkTurn(job.id, {
|
|
282
|
+
ok: false,
|
|
283
|
+
answer:
|
|
284
|
+
'the session worktree could not be opened on the machine — check the daemon log',
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// A live CLI is ALREADY in this worktree — this daemon's previous
|
|
289
|
+
// life, most likely; the lock outlives a restart. Leave the job
|
|
290
|
+
// pending and look again next poll; spawning a second CLI would put
|
|
291
|
+
// two Claudes in one held context. Costs no attempt: nothing ran.
|
|
292
|
+
const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
|
|
293
|
+
if (turnLockedByLivePid(lockPath)) {
|
|
294
|
+
warn(
|
|
295
|
+
`a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
|
|
300
|
+
// which is what every tab ran on until now) — honored by
|
|
301
|
+
// sessionRuntime: on a first turn a named runtime IS the pick, and a
|
|
302
|
+
// named runtime that disagrees with the pin settles below.
|
|
303
|
+
// What the artifact directory held BEFORE this turn — the capture chat
|
|
304
|
+
// excepted: it runs read-only, is never told about artifacts, and
|
|
305
|
+
// whatever sibling tabs wrote in this shared place is theirs.
|
|
306
|
+
if (job.capture !== true) artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt) };
|
|
307
|
+
const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
|
|
308
|
+
if (rt.mismatch) {
|
|
309
|
+
// Something upstream changed this tab's identity mid-life. A held
|
|
310
|
+
// context must never be answered by a different brain — say so.
|
|
311
|
+
await settleWorkTurn(job.id, {
|
|
312
|
+
ok: false,
|
|
313
|
+
answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (rt.missing) {
|
|
318
|
+
await settleWorkTurn(job.id, {
|
|
319
|
+
ok: false,
|
|
320
|
+
answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (rt.unsupported) {
|
|
325
|
+
// A pin from before the session-capable gate existed — or a
|
|
326
|
+
// first-turn tab the server named for one — can carry a runtime no
|
|
327
|
+
// tab can run on (Antigravity has no MCP config, and the session's
|
|
328
|
+
// whole control plane rides one). An honest sentence beats the
|
|
329
|
+
// mcpFor throw this used to crash into every turn.
|
|
330
|
+
await settleWorkTurn(job.id, {
|
|
331
|
+
ok: false,
|
|
332
|
+
answer: `this session runs on ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (!rt.id) {
|
|
337
|
+
await settleWorkTurn(job.id, {
|
|
338
|
+
ok: false,
|
|
339
|
+
answer:
|
|
340
|
+
'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (adopting && rt.id !== 'claude' && rt.id !== 'antigravity') {
|
|
345
|
+
// An adopt id names a conversation in ITS OWN CLI's store: claude
|
|
346
|
+
// forks it (--resume --fork-session), agy moves it
|
|
347
|
+
// (--conversation). Codex has no adoptable store yet, and its
|
|
348
|
+
// args builder backstops this with a loud throw — but a sentence
|
|
349
|
+
// here beats a stack there.
|
|
350
|
+
await settleWorkTurn(job.id, {
|
|
351
|
+
ok: false,
|
|
352
|
+
answer:
|
|
353
|
+
'adopting this terminal session needs its own CLI on the machine — install it, then try again',
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* A PLAN TURN (0.97.0): the tab's switch, off the job. CLAUDE ONLY —
|
|
359
|
+
* `--permission-mode plan` is Claude Code's, and a codex or agy turn
|
|
360
|
+
* has no way to spell it, so it is refused HERE in words rather than
|
|
361
|
+
* run as a build turn wearing the word. The server never sends the
|
|
362
|
+
* key for a non-Claude tab; this is the machine's own half. Never on
|
|
363
|
+
* a capture chat, which is read-only by its own profile already.
|
|
364
|
+
*/
|
|
365
|
+
const planTurn = job.planMode === true && job.capture !== true;
|
|
366
|
+
if (planTurn && rt.id !== 'claude') {
|
|
367
|
+
await settleWorkTurn(job.id, {
|
|
368
|
+
ok: false,
|
|
369
|
+
answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// A PLAIN tab (agy) mounts no MCP: no credential to mint, no config
|
|
374
|
+
// to write. The trade is stated in SYSTEM_WORK_PLAIN — no cards, no
|
|
375
|
+
// streaming — and the honesty survives on the existing rails: the
|
|
376
|
+
// answer lands via work-turn-done, the rail says "no card yet", and
|
|
377
|
+
// ship-time reconciliation books every branch commit.
|
|
378
|
+
const plainTab = rt.id === 'antigravity';
|
|
379
|
+
let mint = null;
|
|
380
|
+
if (!plainTab) {
|
|
381
|
+
mint = await mintWorkToken(job.sessionId);
|
|
382
|
+
if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
|
|
383
|
+
// Another daemon on this credential holds the session. Return
|
|
384
|
+
// WITHOUT settling: the holder is answering this same turn, and
|
|
385
|
+
// settling it here — even as a failure — would race the real
|
|
386
|
+
// answer and could win. Dropping it means the turn stays pending
|
|
387
|
+
// and the holder's answer lands, which is the whole point.
|
|
388
|
+
if (mint?.heldElsewhere) {
|
|
389
|
+
workAnswering.delete(job.id);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (mint?.gone) {
|
|
393
|
+
await settleWorkTurn(job.id, {
|
|
394
|
+
ok: false,
|
|
395
|
+
answer:
|
|
396
|
+
'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (!mint?.token) {
|
|
401
|
+
await settleWorkTurn(job.id, {
|
|
402
|
+
ok: false,
|
|
403
|
+
answer:
|
|
404
|
+
'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// CODEX RESUMES BY THREAD ID, never by `--last`: `resume --last` is
|
|
410
|
+
// the MACHINE's most recent codex conversation, and two codex tabs —
|
|
411
|
+
// or a tab plus a codex dispatch — would cross-resume each other's
|
|
412
|
+
// context. The id was captured off thread.started (runtimeEvents.mjs) and
|
|
413
|
+
// persisted below, beside the runtime pin; absent, the turn runs
|
|
414
|
+
// FRESH in the same worktree — the dirty state is most of the held
|
|
415
|
+
// context, and a machine-global guess is someone else's conversation.
|
|
416
|
+
/**
|
|
417
|
+
* CLAUDE RESUMES BY THE CONVERSATION ID THIS TAB SPOKE UNDER.
|
|
418
|
+
*
|
|
419
|
+
* `--continue` is CWD-KEYED. That was unambiguous while one directory
|
|
420
|
+
* meant one tab, and it stopped being true the day tabs moved into
|
|
421
|
+
* their driver's project folder: every tab there said `--continue`
|
|
422
|
+
* and every one of them resumed whichever conversation had spoken
|
|
423
|
+
* most recently in that directory. Tab B inherited tab A's entire
|
|
424
|
+
* context, and each turn afterwards ping-ponged between them — the
|
|
425
|
+
* exact failure the codex note two blocks down warns about for
|
|
426
|
+
* `resume --last`, arriving for Claude by a different route.
|
|
427
|
+
*
|
|
428
|
+
* The id comes from the CLI's own `system.init` event, which the
|
|
429
|
+
* stream parser already surfaces, and is pinned per session so it is
|
|
430
|
+
* unambiguous wherever the tab is standing.
|
|
431
|
+
*
|
|
432
|
+
* NO ID, NO `--continue`: a tab whose place is shared starts FRESH
|
|
433
|
+
* rather than guessing, because in a shared directory the guess is
|
|
434
|
+
* someone else's conversation. `--continue` survives only where the
|
|
435
|
+
* directory belongs to this tab alone, which is the one case it was
|
|
436
|
+
* ever right for.
|
|
437
|
+
*/
|
|
438
|
+
let claudeResumeId = null;
|
|
439
|
+
if (rt.id === 'claude') {
|
|
440
|
+
const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
|
|
441
|
+
if (convMarker && existsSync(convMarker)) {
|
|
442
|
+
try {
|
|
443
|
+
const v = readFileSync(convMarker, 'utf8').trim();
|
|
444
|
+
if (/^[A-Za-z0-9_-]{8,64}$/.test(v)) claudeResumeId = v;
|
|
445
|
+
} catch {
|
|
446
|
+
/* unreadable marker — run fresh */
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
let codexResumeId = null;
|
|
451
|
+
if (rt.id === 'codex') {
|
|
452
|
+
const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
|
|
453
|
+
if (threadMarker && existsSync(threadMarker)) {
|
|
454
|
+
try {
|
|
455
|
+
const v = readFileSync(threadMarker, 'utf8').trim();
|
|
456
|
+
if (CODEX_THREAD_RE.test(v)) codexResumeId = v;
|
|
457
|
+
} catch {
|
|
458
|
+
/* unreadable marker — run fresh */
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// AGY RESUMES BY CONVERSATION ID, learned once and pinned beside the
|
|
463
|
+
// runtime marker: an adopted tab knows it from the adopt hint; a new
|
|
464
|
+
// tab learns it from agy's own cwd registry after its first turn.
|
|
465
|
+
// The marker beats `--continue` because it is the tab's OWN identity
|
|
466
|
+
// — the registry maps a cwd to whatever ran there LAST, and a
|
|
467
|
+
// dispatch sharing the machine could overwrite that between turns.
|
|
468
|
+
let agyConvId = null;
|
|
469
|
+
if (rt.id === 'antigravity' && !adopting) {
|
|
470
|
+
const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
|
|
471
|
+
if (convMarker && existsSync(convMarker)) {
|
|
472
|
+
try {
|
|
473
|
+
const v = readFileSync(convMarker, 'utf8').trim();
|
|
474
|
+
if (AGY_CONV_RE.test(v)) agyConvId = v;
|
|
475
|
+
} catch {
|
|
476
|
+
/* unreadable marker — run fresh */
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Resume iff a conversation is known to live in THIS directory. For
|
|
481
|
+
// Claude that proof is the server's sessionRef — only ever a path
|
|
482
|
+
// some turn actually SPOKE from (see the settle below), and it must
|
|
483
|
+
// match the directory we just opened. For codex it is the stored
|
|
484
|
+
// thread id, which lives IN the directory and is stronger. Anything
|
|
485
|
+
// else starts fresh IN the existing worktree — never a reset; the
|
|
486
|
+
// dirty state is the session.
|
|
487
|
+
// agy layers its two resumes: the pinned conversation id when the
|
|
488
|
+
// marker exists (deterministic, registry-proof), else the Claude
|
|
489
|
+
// rule — a tab that has SPOKEN from this directory may `--continue`
|
|
490
|
+
// it (cwd-keyed; measured safe), so a lost marker degrades to the
|
|
491
|
+
// weaker resume instead of silently starting over.
|
|
492
|
+
const spokeHere = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
|
|
493
|
+
// A place this tab does NOT have to itself: `--continue` there is a
|
|
494
|
+
// guess at somebody else's conversation, so it is withheld and only
|
|
495
|
+
// a pinned id may resume.
|
|
496
|
+
const placeIsMine = !placeOf(job.sessionId) || placeOf(job.sessionId) === job.sessionId;
|
|
497
|
+
const resume =
|
|
498
|
+
rt.id === 'codex'
|
|
499
|
+
? Boolean(codexResumeId)
|
|
500
|
+
: rt.id === 'antigravity'
|
|
501
|
+
? Boolean(agyConvId) || (placeIsMine && spokeHere)
|
|
502
|
+
: Boolean(claudeResumeId) || (placeIsMine && spokeHere);
|
|
503
|
+
// The dirty carry, on the adopt worktree's FIRST life only: a
|
|
504
|
+
// re-attempted adoption (the directory already exists) carried what
|
|
505
|
+
// it could the first time, and re-applying would double it. A carry
|
|
506
|
+
// problem never fails the adoption — it becomes one bracketed line
|
|
507
|
+
// in the prompt, so the AGENT tells the user what stayed behind.
|
|
508
|
+
let carryNote = '';
|
|
509
|
+
if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt, job.sessionId);
|
|
510
|
+
// The tab's transcript starts EMPTY on adoption (scrollback is
|
|
511
|
+
// disposable, the held context is the brain — never import an
|
|
512
|
+
// archive), so the first reply opens with a recap: the human sees
|
|
513
|
+
// the thread they are picking up without asking for it.
|
|
514
|
+
const adoptNote = adopting
|
|
515
|
+
? '[ADOPTED SESSION — this conversation was brought in from a terminal. Begin your reply with a 2-3 sentence recap of where it left off and what state carried over, then answer the message.]'
|
|
516
|
+
: '';
|
|
517
|
+
// A PLAN TURN RUNS PLAIN TOO (0.97.0): measured on 2.1.281, plan mode
|
|
518
|
+
// refuses every `mcp__flowviant` call ("Cannot call … while in plan
|
|
519
|
+
// mode") unless the tool is annotated read-only, and none of the
|
|
520
|
+
// session tools are — so mounting the control plane would hand the
|
|
521
|
+
// turn a list of tools it can only fail at. The credential is still
|
|
522
|
+
// minted above: its lease answers (held elsewhere, gone) are about
|
|
523
|
+
// the SESSION, and a plan turn needs them as much as any other.
|
|
524
|
+
const mcp =
|
|
525
|
+
plainTab || planTurn
|
|
526
|
+
? { args: [], env: null, dir: null }
|
|
527
|
+
: mcpFor(rt.id, mint.token, getMcpUrl());
|
|
528
|
+
// The tab's model/effort, if it named any. Spread into turnArgs so
|
|
529
|
+
// BOTH runTurn calls below carry it — the retry is the same turn on
|
|
530
|
+
// the same brain, not a quieter second opinion.
|
|
531
|
+
const brain = brainFor(job, rt.id);
|
|
532
|
+
// Attempts count RUNS: the infra refusals above consumed nothing and
|
|
533
|
+
// settled on their own terms.
|
|
534
|
+
workAttempts.set(job.id, tries + 1);
|
|
535
|
+
let out;
|
|
536
|
+
let seenThreadId = null; // codex's conversation id, off thread.started
|
|
537
|
+
let seenClaudeSession = null; // claude's own conversation id, off system.init
|
|
538
|
+
const spawned = []; // this turn's children, for the teardown registry
|
|
539
|
+
// THE TURN'S TOOL LOG — see workToolLog.mjs for its shape rules.
|
|
540
|
+
const turnLog = createToolLog(dir.wt);
|
|
541
|
+
toolLog = turnLog.toolLog;
|
|
542
|
+
const { pushToolEvent } = turnLog;
|
|
543
|
+
const narrator = makeNarrator(job.sessionId, job.id, () =>
|
|
544
|
+
toolLog.ev.length > 0 ? toolLog : undefined
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
// THE COMMAND AUDIT — see workCommandAudit.mjs.
|
|
549
|
+
const { flushAudit, auditCommand } = createCommandAudit({
|
|
550
|
+
sessionId: job.sessionId,
|
|
551
|
+
turnId: job.id,
|
|
552
|
+
runtime: rt.id,
|
|
553
|
+
cwd: dir.wt,
|
|
554
|
+
});
|
|
555
|
+
try {
|
|
556
|
+
// Files first, then the message that references them: the agent
|
|
557
|
+
// must be able to open what it is being told about. Only the ones
|
|
558
|
+
// that actually landed are named.
|
|
559
|
+
const files = await fetchAttachments(dir.wt, job.attachments);
|
|
560
|
+
const filesNote = files.length
|
|
561
|
+
? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${files
|
|
562
|
+
.map((f) => `- ${f}`)
|
|
563
|
+
.join('\n')}`
|
|
564
|
+
: '';
|
|
565
|
+
const message = [job.body, filesNote, adoptNote, carryNote]
|
|
566
|
+
.filter(Boolean)
|
|
567
|
+
.join('\n\n');
|
|
568
|
+
// A CAPTURE chat (the board's New task conversation): its own
|
|
569
|
+
// system prompt, its own kickoff, and the scratch planner's
|
|
570
|
+
// READ-ONLY permission profile — the prompt says stage-never-file
|
|
571
|
+
// and the profile is what makes "read-only" true rather than
|
|
572
|
+
// asserted. Server-flagged per job; a server too old to flag it
|
|
573
|
+
// simply runs an ordinary tab, which the web's version floor
|
|
574
|
+
// prevents ever being offered.
|
|
575
|
+
const captureTab = job.capture === true;
|
|
576
|
+
const turnArgs = {
|
|
577
|
+
// A plain tab has no tools to name and no session id to pass —
|
|
578
|
+
// its kickoff asks for one complete report instead of a stream.
|
|
579
|
+
prompt: plainTab || planTurn
|
|
580
|
+
? WORK_TURN_KICKOFF_PLAIN({
|
|
581
|
+
sessionName: job.sessionName,
|
|
582
|
+
message,
|
|
583
|
+
askedByName: job.askedByName,
|
|
584
|
+
})
|
|
585
|
+
: captureTab
|
|
586
|
+
? CAPTURE_TURN_KICKOFF({
|
|
587
|
+
sessionId: job.sessionId,
|
|
588
|
+
sessionName: job.sessionName,
|
|
589
|
+
message,
|
|
590
|
+
askedByName: job.askedByName,
|
|
591
|
+
})
|
|
592
|
+
: WORK_TURN_KICKOFF({
|
|
593
|
+
sessionId: job.sessionId,
|
|
594
|
+
sessionName: job.sessionName,
|
|
595
|
+
message,
|
|
596
|
+
askedByName: job.askedByName,
|
|
597
|
+
}),
|
|
598
|
+
// ONE PROFILE (turnProfile.mjs): the capture chat's read-only
|
|
599
|
+
// `plan` list; or a plan turn's `plan-mode` — `--permission-mode
|
|
600
|
+
// plan` INSTEAD of the build posture, never beside
|
|
601
|
+
// `--dangerously-skip-permissions`, which silently wins
|
|
602
|
+
// (claudePosture.mjs, PLAN_MODE_PERM); else the build posture.
|
|
603
|
+
profile: captureTab ? 'plan' : planTurn ? 'plan-mode' : 'build',
|
|
604
|
+
// The adopt turn resumes the TERMINAL conversation by forking it
|
|
605
|
+
// into this cwd (claude: --resume <id> --fork-session). After it
|
|
606
|
+
// speaks once, the fork lives natively here and turn 2+ is the
|
|
607
|
+
// ordinary --continue resume path, unchanged.
|
|
608
|
+
...(adopting ? { adoptResumeId: job.adopt.id } : {}),
|
|
609
|
+
// THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0) rides as one appended
|
|
610
|
+
// paragraph, and only while this box holds one — see
|
|
611
|
+
// `withProjectContext`. Resolved at spawn, not at startup, so a
|
|
612
|
+
// library that synced a second ago is in THIS turn.
|
|
613
|
+
...(() => {
|
|
614
|
+
const knowledgeDir = knowledgeDirFor(repoRoot);
|
|
615
|
+
return {
|
|
616
|
+
// A PLAN TURN (0.97.0) runs the PLAIN contract — it has no
|
|
617
|
+
// Flowviant tools, see `mcp` above — with no artifacts
|
|
618
|
+
// paragraph (plan mode cannot write one) and the planning
|
|
619
|
+
// sentence appended. Every other tab is untouched.
|
|
620
|
+
system: planTurn
|
|
621
|
+
? `${withProjectContext(SYSTEM_WORK_PLAIN, { knowledgeDir, artifacts: false })}\n\n${PLAN_TURN_SENTENCE}`
|
|
622
|
+
: withProjectContext(
|
|
623
|
+
plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
|
|
624
|
+
// ARTIFACTS (0.94.0): every tab but the read-only capture
|
|
625
|
+
// chat, and only while the server can show one.
|
|
626
|
+
{ knowledgeDir, artifacts: !captureTab && getArtifactsAccepted() }
|
|
627
|
+
),
|
|
628
|
+
// The directory is OUTSIDE a worktree's cwd; Claude Code is
|
|
629
|
+
// told it may read there (`--add-dir`) rather than left to
|
|
630
|
+
// refuse a read-only capture turn a path it was just handed.
|
|
631
|
+
knowledgeDir,
|
|
632
|
+
};
|
|
633
|
+
})(),
|
|
634
|
+
// Present only when the tab named one — see brainFor.
|
|
635
|
+
...brain,
|
|
636
|
+
// The tab watches the CLI work. Claude needs the flag to speak
|
|
637
|
+
// events at all (codex and agy always do); `answerFromResult`
|
|
638
|
+
// keeps `out` — which IS the reply posted to the transcript — to
|
|
639
|
+
// the final result, so streamed prose is narrated once and
|
|
640
|
+
// posted once. Every line goes to the narrator above, throttled.
|
|
641
|
+
streamJson: true,
|
|
642
|
+
answerFromResult: true,
|
|
643
|
+
onActivity: (a) => {
|
|
644
|
+
narrator.line(a?.label);
|
|
645
|
+
auditCommand(a);
|
|
646
|
+
},
|
|
647
|
+
// The structured twin of the line above — see toolLog.
|
|
648
|
+
onToolEvent: pushToolEvent,
|
|
649
|
+
// What this CLI says it can be asked for by name. Harvested off
|
|
650
|
+
// the init event the stream already carries — no probe, no scan,
|
|
651
|
+
// no extra spawn — and reported on the next roster poll so the
|
|
652
|
+
// composer can autocomplete a `/`. See runtimeCapabilities.mjs for why it is
|
|
653
|
+
// learned from a turn rather than looked up.
|
|
654
|
+
onInit: (i) => {
|
|
655
|
+
recordSkills(i.skills);
|
|
656
|
+
// The CLI's mounted MCP servers and connectors (0.97.0) — the
|
|
657
|
+
// same free fact off the same event; see recordMcpServers.
|
|
658
|
+
recordMcpServers(i.mcpServers);
|
|
659
|
+
// The conversation this turn is actually speaking under. Held
|
|
660
|
+
// and persisted after the turn ends, so the NEXT one resumes
|
|
661
|
+
// this exact thread rather than whatever the directory saw
|
|
662
|
+
// last. Last write wins on purpose: a resume that fell back to
|
|
663
|
+
// fresh reports the fresh id, healing the marker.
|
|
664
|
+
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
665
|
+
seenClaudeSession = i.sessionId.trim();
|
|
666
|
+
},
|
|
667
|
+
cwd: dir.wt,
|
|
668
|
+
mcpArgs: mcp.args,
|
|
669
|
+
mcpEnv: mcp.env,
|
|
670
|
+
runtime: rt.id,
|
|
671
|
+
label: c.cyan('[tab]'),
|
|
672
|
+
// Only codex announces one (thread.started); held here so the id
|
|
673
|
+
// this turn actually SPOKE under is what gets persisted after it
|
|
674
|
+
// ends. Last write wins on purpose: a failed resume that fell
|
|
675
|
+
// back to fresh reports the fresh run's id, healing the marker.
|
|
676
|
+
onThreadId: (id) => {
|
|
677
|
+
seenThreadId = String(id ?? '').trim() || seenThreadId;
|
|
678
|
+
},
|
|
679
|
+
onSpawn: (ch) => {
|
|
680
|
+
if (!ch) return;
|
|
681
|
+
spawned.push(ch);
|
|
682
|
+
// Keyed to the SESSION it serves — that id is what the machine
|
|
683
|
+
// snapshot charges this child's memory to.
|
|
684
|
+
workChildren.set(ch, job.sessionId);
|
|
685
|
+
// The process exists, so the reserved slot is now counted by
|
|
686
|
+
// the registry itself. Idempotent — the `finally` below releases
|
|
687
|
+
// it again for every path that never got here.
|
|
688
|
+
releaseSlot();
|
|
689
|
+
// The CLI is spawned `detached`, so its pid IS its process
|
|
690
|
+
// group id — and every process it starts inherits that, through
|
|
691
|
+
// `nohup` and `setsid` alike. Remembered per SESSION rather
|
|
692
|
+
// than per turn, because the whole point is the watcher that
|
|
693
|
+
// outlives the turn that started it.
|
|
694
|
+
if (ch.pid) noteSessionGroup(job.sessionId, ch.pid);
|
|
695
|
+
if (lockPath && ch.pid) {
|
|
696
|
+
try {
|
|
697
|
+
const start = processStartTime(ch.pid);
|
|
698
|
+
writeFileSync(lockPath, start ? `${ch.pid}:${start}` : String(ch.pid));
|
|
699
|
+
} catch {
|
|
700
|
+
/* best-effort */
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
out = await runTurn({
|
|
706
|
+
...turnArgs,
|
|
707
|
+
resume,
|
|
708
|
+
resumeThreadId: codexResumeId || claudeResumeId || undefined,
|
|
709
|
+
resumeConversationId: agyConvId || undefined,
|
|
710
|
+
});
|
|
711
|
+
// A resume that produced NOTHING usually means the held
|
|
712
|
+
// conversation is gone (a first turn that crashed before writing
|
|
713
|
+
// state, a wiped CLI dir — or, on codex, a deleted thread). Retry
|
|
714
|
+
// once fresh in the SAME worktree — never reset — instead of
|
|
715
|
+
// bricking the tab forever; the retry carries no resumeThreadId,
|
|
716
|
+
// so codex genuinely starts over rather than re-asking for the
|
|
717
|
+
// thread that just came back empty. NEVER on an adopt turn
|
|
718
|
+
// (`resume` is structurally false there, and the guard says so out
|
|
719
|
+
// loud): a fresh conversation would silently discard the adoption
|
|
720
|
+
// and answer as a new session wearing its name — the empty adopt
|
|
721
|
+
// turn settles failed below instead.
|
|
722
|
+
/**
|
|
723
|
+
* …OR PRODUCED ONLY THE CLI SAYING THE CONVERSATION IS GONE.
|
|
724
|
+
*
|
|
725
|
+
* "Produced nothing" was the whole test, and it is not the shape
|
|
726
|
+
* these failures take: Claude Code answers a dead `--resume` id
|
|
727
|
+
* with a result event carrying `errors: ['No conversation found
|
|
728
|
+
* with session ID: …']` and writes the same line to stderr, so
|
|
729
|
+
* `out` is non-empty. The backstop never fired, the turn settled
|
|
730
|
+
* SUCCESSFULLY with that error as its answer, and — because the
|
|
731
|
+
* marker is only rewritten when an init event is seen, and there
|
|
732
|
+
* was none — the dead id stayed pinned. Every later message in
|
|
733
|
+
* that tab replied identically, with nothing on any surface able
|
|
734
|
+
* to clear it. A tab bricked forever by its own CLI pruning its
|
|
735
|
+
* history, which it does on its own schedule.
|
|
736
|
+
*/
|
|
737
|
+
if (
|
|
738
|
+
!adopting &&
|
|
739
|
+
resumeRetriesFresh({ resume, out, runtime: rt.id, sawInit: Boolean(seenClaudeSession) })
|
|
740
|
+
) {
|
|
741
|
+
out = await runTurn({ ...turnArgs, resume: false });
|
|
742
|
+
}
|
|
743
|
+
} finally {
|
|
744
|
+
// The CLI has stopped printing, so stop relaying. The LINE itself
|
|
745
|
+
// is cleared server-side at settle — clearing it here would race
|
|
746
|
+
// the settle and blank the tab a beat before the reply lands.
|
|
747
|
+
narrator.stop();
|
|
748
|
+
flushAudit();
|
|
749
|
+
for (const ch of spawned) workChildren.delete(ch);
|
|
750
|
+
if (lockPath) {
|
|
751
|
+
try {
|
|
752
|
+
rmSync(lockPath, { force: true });
|
|
753
|
+
} catch {
|
|
754
|
+
/* best-effort */
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
758
|
+
}
|
|
759
|
+
// Persist the codex thread id AFTER the turn ends, so the next turn
|
|
760
|
+
// resumes exactly the conversation that just spoke. Shape-guarded
|
|
761
|
+
// before it ever touches disk — it later rides in argv as
|
|
762
|
+
// `resume <id>` — and best-effort, like the runtime pin: an
|
|
763
|
+
// unwritable marker just means the tab runs fresh next turn.
|
|
764
|
+
// The conversation THIS TAB just spoke under, pinned so the next
|
|
765
|
+
// turn resumes it by id rather than asking the directory. Written
|
|
766
|
+
// after the turn for the same reason codex's is: an id learned
|
|
767
|
+
// mid-turn is only true once the turn that learned it finished.
|
|
768
|
+
if (
|
|
769
|
+
rt.id === 'claude' &&
|
|
770
|
+
seenClaudeSession &&
|
|
771
|
+
/^[A-Za-z0-9_-]{8,64}$/.test(seenClaudeSession)
|
|
772
|
+
) {
|
|
773
|
+
const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
|
|
774
|
+
if (convMarker) {
|
|
775
|
+
try {
|
|
776
|
+
writeFileSync(convMarker, seenClaudeSession);
|
|
777
|
+
} catch {
|
|
778
|
+
/* best-effort — the next turn re-learns it */
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
|
|
783
|
+
const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
|
|
784
|
+
if (threadMarker) {
|
|
785
|
+
try {
|
|
786
|
+
writeFileSync(threadMarker, seenThreadId);
|
|
787
|
+
} catch {
|
|
788
|
+
/* best-effort */
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const answer = (out || '').trim();
|
|
793
|
+
// No output at all smells like a dead MCP credential (the lane
|
|
794
|
+
// workers' no-sentinel case) — drop the cached token so the next
|
|
795
|
+
// turn re-mints instead of failing the same way forever.
|
|
796
|
+
if (!answer) workTokens.delete(job.sessionId);
|
|
797
|
+
if (adopting && !answer) {
|
|
798
|
+
// The fork came back with nothing — the terminal session's
|
|
799
|
+
// transcript is most likely gone (cleaned, expired, deleted). Say
|
|
800
|
+
// exactly that; no sessionRef is recorded, so the server keeps
|
|
801
|
+
// offering the adoption and a retry after the user checks is cheap.
|
|
802
|
+
await settleWorkTurn(job.id, {
|
|
803
|
+
ok: false,
|
|
804
|
+
answer: "Couldn't resume the terminal session — it may have been removed.",
|
|
805
|
+
// Whatever it DID before coming back empty is exactly the
|
|
806
|
+
// question a failed turn's log answers.
|
|
807
|
+
...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
|
|
808
|
+
});
|
|
809
|
+
warn('adopt turn produced no output — settled as failed');
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
// Persist the agy conversation id once the turn actually SPOKE — an
|
|
813
|
+
// adopted tab pins the id it moved in (the adopt hint); a new tab
|
|
814
|
+
// learns the one its first fresh turn just created, from agy's own
|
|
815
|
+
// cwd registry. From here on the marker is the tab's identity and
|
|
816
|
+
// the registry is never trusted again.
|
|
817
|
+
if (rt.id === 'antigravity' && answer.length > 0) {
|
|
818
|
+
const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
|
|
819
|
+
if (convMarker && !existsSync(convMarker)) {
|
|
820
|
+
const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
|
|
821
|
+
if (learned && AGY_CONV_RE.test(learned)) {
|
|
822
|
+
try {
|
|
823
|
+
writeFileSync(convMarker, learned);
|
|
824
|
+
} catch {
|
|
825
|
+
/* best-effort — an unpinned tab resumes via --continue's cwd key */
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
await settleWorkTurn(job.id, {
|
|
831
|
+
ok: answer.length > 0,
|
|
832
|
+
answer:
|
|
833
|
+
answer.length > 0
|
|
834
|
+
? // Scrub: a reply can quote config or env-adjacent code.
|
|
835
|
+
envScrub(answer).slice(0, 16000)
|
|
836
|
+
: 'the turn produced no output on the machine — its CLI may be signed out; try again',
|
|
837
|
+
// Only a turn that actually SPOKE proves a conversation lives
|
|
838
|
+
// here. Recording the path unconditionally is how a crashed first
|
|
839
|
+
// turn used to brick resume for the session's whole life.
|
|
840
|
+
...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
|
|
841
|
+
// The turn's tool log, in final form — the durable copy that lands
|
|
842
|
+
// on the settled message (the live copy on the record is cleared
|
|
843
|
+
// at settle). Already scrubbed at collection.
|
|
844
|
+
...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
|
|
845
|
+
});
|
|
846
|
+
if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
|
|
847
|
+
else warn('session turn produced no output — settled as failed');
|
|
848
|
+
} catch (e) {
|
|
849
|
+
await settleWorkTurn(job.id, {
|
|
850
|
+
ok: false,
|
|
851
|
+
// Scrub, like every string that leaves this machine: an exception
|
|
852
|
+
// routinely quotes command output, and command output can quote a
|
|
853
|
+
// synced secret.
|
|
854
|
+
answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
|
|
855
|
+
// "What did it do before it failed" is exactly the question a
|
|
856
|
+
// crashed turn's log answers — same spread as the main settle.
|
|
857
|
+
...(toolLog?.ev.length > 0 ? { tools: toolLog } : {}),
|
|
858
|
+
});
|
|
859
|
+
warn(`session turn failed: ${e?.message ?? e}`);
|
|
860
|
+
} finally {
|
|
861
|
+
workAnswering.delete(job.id);
|
|
862
|
+
// Nothing spawned, or everything already has: releasing twice is the
|
|
863
|
+
// normal case and costs nothing. A reservation that leaked would
|
|
864
|
+
// shrink this machine's ceiling for the life of the process.
|
|
865
|
+
releaseSlot();
|
|
866
|
+
// The turn just changed the directory — say what it looks like now,
|
|
867
|
+
// whether it succeeded or blew up (a failed turn can still have
|
|
868
|
+
// written half a file, and the tab should show that honestly). NOT
|
|
869
|
+
// awaited: this runs inside the session's chain, and a slow POST
|
|
870
|
+
// would delay the next turn of that tab behind a readout.
|
|
871
|
+
void reportPlaceWorktrees(job.sessionId).catch(() => {});
|
|
872
|
+
burstListeners(job.sessionId);
|
|
873
|
+
// …and relay what it wrote to SHOW its owner (2026-09-22) — the same
|
|
874
|
+
// beat, the same reason it is not awaited. A failed turn's half-drawn
|
|
875
|
+
// page is still what the turn left, and the tab should see it.
|
|
876
|
+
if (artifactScan) {
|
|
877
|
+
void artifacts
|
|
878
|
+
.report({
|
|
879
|
+
placeDir: artifactScan.dir,
|
|
880
|
+
before: artifactScan.before,
|
|
881
|
+
sessionId: job.sessionId,
|
|
882
|
+
turnId: job.id,
|
|
883
|
+
})
|
|
884
|
+
.catch(() => {});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
return { processWorkTurns, workAnswering };
|
|
892
|
+
}
|