flowviant 0.116.0 → 0.117.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.
Files changed (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +71 -139
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -1,19 +1,19 @@
1
1
  /** Agent plan claims, read-only planning turns, and proposal reports. */
2
2
  import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
5
- import { git, isSafePathSegment } from './git.mjs';
3
+ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
4
+ import { gitOrNull, isSafePathSegment } from './git.mjs';
6
5
  import { parseProposal } from './agentPlan.mjs';
7
6
  import { c, note } from './ui.mjs';
8
7
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
9
8
  import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF } from './prompts.mjs';
10
9
  import { scrub as envScrub } from './uplinkScrub.mjs';
11
- import { RUNTIMES, canRun } from './runtimes.mjs';
10
+ import { canRun, runtimeById, runtimeLabel } from './runtimes.mjs';
12
11
  import { detectRuntimes as defaultDetectRuntimes, pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
13
12
  import { brainFor } from './workBrain.mjs';
14
- import { removeProbeTranscript } from './runtimeCapabilities.mjs';
13
+ import { runCappedTurn } from './workCappedTurn.mjs';
15
14
  import { fleetEndpoint } from './fleetWire.mjs';
16
- import { REPO_PLACE } from './workPlaces.mjs';
15
+ import { claimFleetLease, fleetHeaders } from './fleetPost.mjs';
16
+ import { REPO_PLACE, worktreePath } from './workPlaces.mjs';
17
17
  import { addUsage } from './turnUsage.mjs';
18
18
 
19
19
  export function createWorkAgentPlans({
@@ -43,8 +43,11 @@ export function createWorkAgentPlans({
43
43
  // creates a worktree, a branch or a card.
44
44
  //
45
45
  // READ-ONLY IN THE CHECKOUT. `profile: 'consult'` selects `consultPermFor` (the
46
- // fenced Read/Glob rules plus a few path-validated `git`/`ls`/`cat` reads —
47
- // no Write, no Edit, no mkdir, no rm) and NO MCP is passed at all, so this
46
+ // fenced Read/Glob rules plus a few shell readers: `ls`/`cat`/`head`, which
47
+ // the CLI path-validates itself, and the `git` readers, which it does not —
48
+ // their path fence is the read guard's refusal (hooks/readGuard.mjs), of
49
+ // `--no-index` and of any git argument aimed off the checkout — and no
50
+ // Write, no Edit, no mkdir, no rm) and NO MCP is passed at all, so this
48
51
  // turn has no control plane to reach even if the repository it reads tries
49
52
  // to steer it. The proposal comes back as the turn's final message rather
50
53
  // than through a tool, which is exactly what lets that permission set be
@@ -68,8 +71,8 @@ export function createWorkAgentPlans({
68
71
  * on a PRESS instead of an agent.
69
72
  *
70
73
  * This turn was already streaming and the daemon was already reading it:
71
- * `streamJson` humanizes every read, grep, command and thought and prints
72
- * it behind `[plan]` on the operator's terminal. Every one of those lines
74
+ * runTurn's event reader humanizes every read, grep, command and thought
75
+ * and prints it behind `[plan]` on the operator's terminal. Every one of those lines
73
76
  * was then thrown away, so a Deploy press said "planning" on the board and
74
77
  * nothing else for as long as it took — which on an overnight queue is the
75
78
  * whole night. Forwarding the CLI's own tail costs nothing that is not
@@ -90,11 +93,7 @@ export function createWorkAgentPlans({
90
93
  try {
91
94
  await fetch(AGENT_PLAN_ACTIVITY_URL, {
92
95
  method: 'POST',
93
- headers: {
94
- Authorization: `Bearer ${FLEET_TOKEN}`,
95
- 'User-Agent': USER_AGENT,
96
- 'Content-Type': 'application/json',
97
- },
96
+ headers: fleetHeaders(),
98
97
  signal: AbortSignal.timeout(15_000),
99
98
  body: JSON.stringify({ planId, line: text }),
100
99
  });
@@ -131,24 +130,8 @@ export function createWorkAgentPlans({
131
130
  */
132
131
  const PLAN_TURN_TIMEOUT_MS = 15 * 60_000;
133
132
 
134
- const claimAgentPlan = async (id) => {
135
- try {
136
- const res = await fetch(AGENT_PLAN_CLAIM_URL, {
137
- method: 'POST',
138
- headers: {
139
- Authorization: `Bearer ${FLEET_TOKEN}`,
140
- 'User-Agent': USER_AGENT,
141
- 'Content-Type': 'application/json',
142
- },
143
- signal: AbortSignal.timeout(15_000),
144
- body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
145
- });
146
- const j = await res.json().catch(() => null);
147
- return j?.data?.claimed === true;
148
- } catch {
149
- return false; // the peer may hold it; doing nothing is the safe answer
150
- }
151
- };
133
+ // The peer may hold it; doing nothing is the safe answer.
134
+ const claimAgentPlan = (id) => claimFleetLease(AGENT_PLAN_CLAIM_URL, { id });
152
135
 
153
136
  /**
154
137
  * What a live agent has already TOUCHED, measured here rather than sent.
@@ -164,7 +147,7 @@ export function createWorkAgentPlans({
164
147
  */
165
148
  const agentChangedFiles = (placeId) => {
166
149
  if (!placeId || !isSafePathSegment(placeId)) return [];
167
- const wt = join(baseDir, 'sessions', placeId);
150
+ const wt = worktreePath(baseDir, placeId);
168
151
  if (!existsSync(wt)) return [];
169
152
  const out = new Set();
170
153
  for (const args of [
@@ -185,16 +168,12 @@ export function createWorkAgentPlans({
185
168
  * `planning` until its expiry, having spent nothing and explained
186
169
  * nothing, on what would be an ordinary second Deploy.
187
170
  *
188
- * An agent whose files cannot be read contributes none. That is the
189
- * honest answer and it is what the planner should be told.
171
+ * An agent whose files cannot be read contributes none — `gitOrNull`
172
+ * (git.mjs) reads a failure as null. That is the honest answer and it
173
+ * is what the planner should be told.
190
174
  */
191
- let r;
192
- try {
193
- r = git(args, wt);
194
- } catch {
195
- continue;
196
- }
197
- if (typeof r !== 'string') continue;
175
+ const r = gitOrNull(args, wt);
176
+ if (r == null) continue;
198
177
  for (const line of r.split('\n')) {
199
178
  const f = line.trim();
200
179
  if (f) out.add(f);
@@ -238,16 +217,17 @@ export function createWorkAgentPlans({
238
217
  : job.model != null || job.effort != null ? 'claude' : null;
239
218
  if (pickedRuntime) {
240
219
  rt = pickedRuntime;
241
- if (!Object.hasOwn(RUNTIMES, rt)) {
220
+ const runtime = runtimeById(rt);
221
+ if (!runtime) {
242
222
  await postAgentPlan({ id, error: `${rt} is not a CLI this machine knows` });
243
223
  return;
244
224
  }
245
- const label = RUNTIMES[rt].label;
225
+ const label = runtime.label;
246
226
  if (!detectRuntimes().find((r) => r.id === rt)?.installed) {
247
227
  await postAgentPlan({ id, error: `${label} is not installed on this machine` });
248
228
  return;
249
229
  }
250
- if (!canRun(RUNTIMES[rt], 'consult')) {
230
+ if (!canRun(runtime, 'consult')) {
251
231
  await postAgentPlan({ id, error: `${label} can't run a read-only turn on this machine` });
252
232
  return;
253
233
  }
@@ -304,23 +284,6 @@ export function createWorkAgentPlans({
304
284
  }));
305
285
 
306
286
  let out = '';
307
- /** REMOVED IN A `finally`. `workChildren` is what `workBusy()` counts, and
308
- * a leaked entry keeps the daemon permanently "busy" — which blocks every
309
- * auto-update from that moment on, silently, until a restart. */
310
- let planChild = null;
311
- let planTimer = null;
312
- /**
313
- * THE PLANNER'S OWN CONVERSATION, so its transcript can be removed.
314
- *
315
- * `claude -p` in the CHECKOUT writes `~/.claude/projects/<checkout>/<id>.jsonl`,
316
- * and the checkout is exactly where the operator runs their own terminal
317
- * Claude. Left behind, every Deploy press became the newest ENDED session
318
- * there: it displaced the operator's real conversation from the `+` adopt
319
- * menu (one row per directory), offered to fork a read-only planner into a
320
- * build tab, and was what their own `claude --continue` resumed. The
321
- * pre-review and the skills probe delete theirs for the same reason.
322
- */
323
- let planSession = null;
324
287
  /** The cap fired: the CLI was still running when this machine stopped it. */
325
288
  let wedged = false;
326
289
  /**
@@ -341,89 +304,58 @@ export function createWorkAgentPlans({
341
304
  try {
342
305
  await inPlace(REPO_PLACE, false, async () => {
343
306
  /**
344
- * THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close`
345
- * after the kill — the shape the project check's timeout already
346
- * keeps. A SIGKILLed process whose stdio a grandchild still holds can
347
- * be slow to emit `close`, or never emit it, and this promise is what
348
- * holds the claimed press open.
307
+ * ONE CAPPED CONSULT TURN (workCappedTurn.mjs): the child registered
308
+ * in `workChildren` and removed on every exit, the reservation
309
+ * released at the spawn, the cap ARMED AT THE SPAWN — time spent
310
+ * waiting for the checkout is not a wedged CLI, and a press that never
311
+ * got to run is what the server's own clock is for — and the
312
+ * planner's transcript removed from the CHECKOUT, where it would
313
+ * otherwise become the newest ENDED session: displacing the
314
+ * operator's real conversation from the `+` adopt menu, offering to
315
+ * fork a read-only planner into a build tab, and being what their own
316
+ * `claude --continue` resumed.
349
317
  */
350
- let stopWaiting = () => {};
351
- const capped = new Promise((r) => {
352
- stopWaiting = r;
353
- });
354
- const turn = runTurn({
355
- prompt: AGENT_PLAN_KICKOFF({
356
- tasks,
357
- liveAgents,
358
- agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
359
- }),
360
- system: SYSTEM_PLAN,
361
- // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
362
- // passed empty, so there is no control plane on this turn at all.
363
- profile: 'consult',
364
- cwd: repoRoot,
365
- runtime: rt,
366
- ...brainFor(job, rt),
367
- streamJson: true,
368
- answerFromResult: true,
369
- label: c.cyan('[plan]'),
370
- // The CLI's own tail, forwarded to the press. Same rule as an agent
371
- // turn's: throttled, overwritten rather than appended, and never
372
- // awaited by the turn.
373
- onActivity: (a) => {
374
- if (a?.label) narrate(String(a.label));
375
- },
376
- onInit: (i) => {
377
- if (typeof i.sessionId === 'string' && i.sessionId.trim())
378
- planSession = i.sessionId.trim();
379
- },
380
- // Tagged with the CLI that counted it; one turn, so the sum is the
381
- // one report (turnUsage.mjs keeps it null-safe).
382
- onUsage: (u) => {
383
- usage = addUsage(usage, { ...u, runtime: rt });
318
+ ({ out, wedged } = await runCappedTurn({
319
+ runTurn,
320
+ capMs: PLAN_TURN_TIMEOUT_MS,
321
+ workChildren,
322
+ releaseSlot,
323
+ transcriptCwd: repoRoot,
324
+ turnArgs: {
325
+ prompt: AGENT_PLAN_KICKOFF({
326
+ tasks,
327
+ liveAgents,
328
+ agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
329
+ }),
330
+ system: SYSTEM_PLAN,
331
+ // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
332
+ // passed empty, so there is no control plane on this turn at all.
333
+ profile: 'consult',
334
+ cwd: repoRoot,
335
+ runtime: rt,
336
+ ...brainFor(job, rt),
337
+ answerFromResult: true,
338
+ label: c.cyan('[plan]'),
339
+ // The CLI's own tail, forwarded to the press. Same rule as an agent
340
+ // turn's: throttled, overwritten rather than appended, and never
341
+ // awaited by the turn.
342
+ onActivity: (a) => {
343
+ if (a?.label) narrate(String(a.label));
344
+ },
345
+ // Tagged with the CLI that counted it; one turn, so the sum is the
346
+ // one report (turnUsage.mjs keeps it null-safe).
347
+ onUsage: (u) => {
348
+ usage = addUsage(usage, { ...u, runtime: rt });
349
+ },
384
350
  },
385
- onSpawn: (ch) => {
386
- planChild = ch;
387
- // No id: a Deploy press is not a task, and the snapshot's per-task
388
- // rows must not invent one. It still COUNTS against the machine's
389
- // ceiling — see liveTurnCount.
390
- workChildren.set(ch, null);
391
- releaseSlot();
392
- say(`${RUNTIMES[rt]?.label ?? rt} started on this machine`);
393
- /**
394
- * ARMED AT THE SPAWN, not at the claim: time spent waiting for the
395
- * checkout is not a wedged CLI, and a press that never got to run
396
- * is what the server's own clock is for.
397
- *
398
- * The CHILD, never its group — the rule teardown keeps. A
399
- * read-only planning turn starts no dev server, so there is
400
- * nothing behind it worth signalling and everything to lose by
401
- * signalling somebody else's.
402
- */
403
- planTimer = setTimeout(() => {
404
- wedged = true;
405
- try {
406
- ch.kill('SIGKILL');
407
- } catch {
408
- /* already gone */
409
- }
410
- stopWaiting('');
411
- }, PLAN_TURN_TIMEOUT_MS);
412
- planTimer.unref?.();
351
+ onSpawn: () => {
352
+ say(`${runtimeLabel(rt)} started on this machine`);
413
353
  },
414
- });
415
- out = await Promise.race([turn, capped]);
354
+ }));
416
355
  });
417
356
  } catch (e) {
418
357
  await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
419
358
  return;
420
- } finally {
421
- if (planTimer) clearTimeout(planTimer);
422
- if (planChild) workChildren.delete(planChild);
423
- // On EVERY exit, after the kill and on a delay — the transcript is the
424
- // child's file, and removing it while the child is still dying races a
425
- // recreate. The pre-review's own shape.
426
- if (planSession) setTimeout(() => removeProbeTranscript(repoRoot, planSession), 750).unref?.();
427
359
  }
428
360
 
429
361
  if (wedged) {
@@ -7,8 +7,8 @@
7
7
  * is parsed, scrubbed and re-sent), the project check for SECURITY ones. The
8
8
  * review entry beat sequences the two and owns neither.
9
9
  */
10
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
11
- import { git } from './git.mjs';
10
+ import { FLEET_URL } from './config.mjs';
11
+ import { gitOrNull } from './git.mjs';
12
12
  import { parsePrecheck } from './agentPlan.mjs';
13
13
  import { readStash } from './agentCards.mjs';
14
14
  import { c, note } from './ui.mjs';
@@ -16,10 +16,11 @@ import { runTurn } from './runTurn.mjs';
16
16
  import { SYSTEM_PRECHECK, AGENT_PRECHECK_KICKOFF } from './prompts.mjs';
17
17
  import { scrub as envScrub } from './uplinkScrub.mjs';
18
18
  import { pickRuntimeFor } from './runtimeDetection.mjs';
19
- import { removeProbeTranscript } from './runtimeCapabilities.mjs';
19
+ import { runCappedTurn } from './workCappedTurn.mjs';
20
20
  import { taskIdsFromMessage } from './worktreeDiff.mjs';
21
21
  import { limitLine } from './cliLimit.mjs';
22
- import { fleetEndpoint } from './fleetWire.mjs';
22
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
23
+ import { postFleetJson } from './fleetPost.mjs';
23
24
  import { agentBrowserHome } from './noWindowEnv.mjs';
24
25
 
25
26
  export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren }) {
@@ -38,7 +39,8 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
38
39
  // meets the card it answers from the very context that produced the work. The
39
40
  // reviewer stands IN the agent's worktree because it needs the code and the
40
41
  // diff, under the `consult` profile (`consultPermFor` — the fenced Read/Glob rules plus
41
- // a few path-validated git reads) with NO MCP passed at all, so there is no
42
+ // a few shell readers; the `git` ones are fenced by the read guard's refusal,
43
+ // hooks/readGuard.mjs, not by the CLI) with NO MCP passed at all, so there is no
42
44
  // control plane on this turn even if the repository it reads tries to steer
43
45
  // it.
44
46
  //
@@ -94,20 +96,8 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
94
96
  */
95
97
  const postPre = async (body) => {
96
98
  try {
97
- const res = await fetch(AGENT_PRECHECK_URL, {
98
- method: 'POST',
99
- headers: {
100
- Authorization: `Bearer ${FLEET_TOKEN}`,
101
- 'User-Agent': USER_AGENT,
102
- 'Content-Type': 'application/json',
103
- },
104
- signal: AbortSignal.timeout(30_000),
105
- body: JSON.stringify(body),
106
- });
107
- return (
108
- res.ok ||
109
- (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
110
- );
99
+ const res = await postFleetJson(AGENT_PRECHECK_URL, body, { timeoutMs: 30_000 });
100
+ return res.ok || isConsideredRefusal(res.status);
111
101
  } catch {
112
102
  return false; // a blip — worth one more attempt at a model call's answer
113
103
  }
@@ -127,13 +117,8 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
127
117
  * review-entry beat it is standing in.
128
118
  */
129
119
  const branchLog = (wt) => {
130
- let out;
131
- try {
132
- out = git(['log', '--format=%s%n%b%n--', `${baseRef()}..HEAD`, '--not', baseRef()], wt);
133
- } catch {
134
- return { text: '', taskIds: [] };
135
- }
136
- if (typeof out !== 'string') return { text: '', taskIds: [] };
120
+ const out = gitOrNull(['log', '--format=%s%n%b%n--', `${baseRef()}..HEAD`, '--not', baseRef()], wt);
121
+ if (out == null) return { text: '', taskIds: [] };
137
122
  // LINEAR, never backtracking (audit 2026-09-24) — this was
138
123
  // `out.matchAll(/^\s*Flowviant-Task:\s*(\S+)\s*$/gm)`, whose `\s*$` is
139
124
  // quadratic against a long trailing run of whitespace on one line
@@ -178,12 +163,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
178
163
  // `checkFingerprint` shape, so a commit landing after this voids it rather
179
164
  // than letting an old reading label a branch it never saw. Optional: an
180
165
  // unreadable head costs the staleness comparison, not the precheck.
181
- let headSha = null;
182
- try {
183
- headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
184
- } catch {
185
- headSha = null;
186
- }
166
+ const headSha = gitOrNull(['rev-parse', 'HEAD'], wt);
187
167
 
188
168
  const stash = readStash(sessionMetaPath(wt, 'flowviant-agent-cards', agentId));
189
169
  const log = branchLog(wt);
@@ -193,8 +173,6 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
193
173
  const missingSpecs = log.taskIds.filter((id) => !held.has(id)).length;
194
174
 
195
175
  let out = '';
196
- let child = null;
197
- let timer = null;
198
176
  /** The cap fired: the CLI was still running when this machine stopped it. */
199
177
  let wedged = false;
200
178
  /**
@@ -202,8 +180,8 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
202
180
  * other lane that charges an AGENT (2026-09-19).
203
181
  *
204
182
  * It rides the agent and never a card link: a pre-review belongs to no card
205
- * (the same reason `workChildren` gets a null task id below), so the four
206
- * counters it adds to are the container's alone. That is also why an
183
+ * (the same reason its `workChildren` entry carries a null task id), so the
184
+ * four counters it adds to are the container's alone. That is also why an
207
185
  * agent's links can never sum to its total, and the schema says so.
208
186
  *
209
187
  * A WEDGED OR UNPARSEABLE READING POSTS NOTHING AT ALL, so it charges
@@ -212,116 +190,71 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
212
190
  * settle to carry a number would be a post whose only content is a bill.
213
191
  */
214
192
  let usage = null;
215
- /**
216
- * THE CONVERSATION THIS READING SPEAKS UNDER — held for exactly one reason:
217
- * to DELETE the transcript it leaves behind (review, 2026-09-17).
218
- *
219
- * This is the first thing in the daemon to run a second `claude -p` inside
220
- * an AGENT's worktree, and the agent's own resume is `--continue`, which is
221
- * CWD-KEYED — the invariant `runAgentTurn` states in words ("ONE AGENT IS
222
- * ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly right here").
223
- * Leaving this turn's `~/.claude/projects/<munged-cwd>/<id>.jsonl` in place
224
- * makes the read-only stranger the newest conversation in that directory,
225
- * so the agent's NEXT turn — a send-back's re-queued card, a merge-resolve,
226
- * a human's typed answer — resumes "you are a SECOND reviewer… YOU ARE
227
- * READ-ONLY" instead of its own four-turn context, under build permissions.
228
- * That is the 0.69.0 Workbench cross-resume and codex's `resume --last`,
229
- * arriving a third time by a third route.
230
- *
231
- * `removeProbeTranscript` is exported for precisely this and already serves
232
- * the skills probe and the dev-command resolver.
233
- */
234
- let preSession = null;
235
193
  try {
236
194
  /**
237
- * THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
238
- * the kill — the shape the project check and the planner both keep. A
239
- * SIGKILLed process whose stdio a grandchild still holds can be slow to
240
- * emit `close`, or never emit it, and this promise is inside the place's
241
- * writer lock.
195
+ * ONE CAPPED CONSULT TURN (workCappedTurn.mjs), the planner's and
196
+ * intake's shape: the child registered and removed on every exit, the
197
+ * reservation released at the spawn, the cap armed at the spawn and
198
+ * resolving the wait itself — this promise is inside the place's writer
199
+ * lock — and THIS READING'S TRANSCRIPT REMOVED FROM THE WORKTREE
200
+ * (review, 2026-09-17).
201
+ *
202
+ * This is the first thing in the daemon to run a second `claude -p`
203
+ * inside an AGENT's worktree, and the agent's own resume is
204
+ * `--continue`, which is CWD-KEYED — the invariant `runAgentTurn` states
205
+ * in words ("ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed
206
+ * resume is exactly right here"). Leaving this turn's
207
+ * `~/.claude/projects/<munged-cwd>/<id>.jsonl` in place makes the
208
+ * read-only stranger the newest conversation in that directory, so the
209
+ * agent's NEXT turn — a send-back's re-queued card, a merge-resolve, a
210
+ * human's typed answer — resumes "you are a SECOND reviewer… YOU ARE
211
+ * READ-ONLY" instead of its own four-turn context, under build
212
+ * permissions. That is the 0.69.0 Workbench cross-resume and codex's
213
+ * `resume --last`, arriving a third time by a third route.
242
214
  */
243
- let stopWaiting = () => {};
244
- const capped = new Promise((r) => {
245
- stopWaiting = r;
246
- });
247
- const turn = runTurn({
248
- prompt: AGENT_PRECHECK_KICKOFF({
249
- agentName,
250
- cards: stash.map((s) => s.prompt).join('\n---\n'),
251
- missingSpecs,
252
- commits: log.text,
253
- diffCommand: `git diff ${baseRef()}...HEAD`,
254
- }),
255
- system: SYSTEM_PRECHECK,
256
- // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
257
- // passed empty, so there is no control plane on this turn at all.
258
- profile: 'consult',
259
- cwd: wt,
260
- runtime: rt,
261
- // The agent's own browser profile, in the worktree this reader stands
262
- // in; no display either way (noWindowEnv.mjs, 2026-09-29).
263
- browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
264
- // NO `resume`, AND THAT IS THE FEATURE. A resumed turn would be the
265
- // agent grading its own homework out of its own context; this is a
266
- // stranger reading a diff.
267
- streamJson: true,
268
- answerFromResult: true,
269
- label: c.cyan('[pre-review]'),
270
- // The id the CLI reports at `system.init` — harvested off the stream
271
- // this turn already parses, no probe and no extra spawn. Held only so
272
- // the `finally` below can delete this turn's transcript; see
273
- // `preSession`.
274
- onInit: (i) => {
275
- if (typeof i.sessionId === 'string' && i.sessionId.trim())
276
- preSession = i.sessionId.trim();
277
- },
278
- // The CLI's own count for this reading. Set, never accumulated: one
279
- // turn is one `result` event, and the adding-up is the server's.
280
- // Tagged with the CLI, so a Claude pre-review of a Codex agent is
281
- // charged as Claude's and not folded into the agent's own figure.
282
- onUsage: (u) => {
283
- usage = { ...u, runtime: rt };
215
+ ({ out, wedged } = await runCappedTurn({
216
+ runTurn,
217
+ capMs: PRECHECK_TIMEOUT_MS,
218
+ workChildren,
219
+ releaseSlot,
220
+ transcriptCwd: wt,
221
+ turnArgs: {
222
+ prompt: AGENT_PRECHECK_KICKOFF({
223
+ agentName,
224
+ cards: stash.map((s) => s.prompt).join('\n---\n'),
225
+ missingSpecs,
226
+ commits: log.text,
227
+ diffCommand: `git diff ${baseRef()}...HEAD`,
228
+ }),
229
+ system: SYSTEM_PRECHECK,
230
+ // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
231
+ // passed empty, so there is no control plane on this turn at all.
232
+ profile: 'consult',
233
+ cwd: wt,
234
+ runtime: rt,
235
+ // The agent's own browser profile, in the worktree this reader stands
236
+ // in; no display either way (noWindowEnv.mjs, 2026-09-29).
237
+ browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
238
+ // NO `resume`, AND THAT IS THE FEATURE. A resumed turn would be the
239
+ // agent grading its own homework out of its own context; this is a
240
+ // stranger reading a diff.
241
+ answerFromResult: true,
242
+ label: c.cyan('[pre-review]'),
243
+ // The CLI's own count for this reading. Set, never accumulated: one
244
+ // turn is one `result` event, and the adding-up is the server's.
245
+ // Tagged with the CLI, so a Claude pre-review of a Codex agent is
246
+ // charged as Claude's and not folded into the agent's own figure.
247
+ onUsage: (u) => {
248
+ usage = { ...u, runtime: rt };
249
+ },
284
250
  },
285
- onSpawn: (ch) => {
286
- child = ch;
287
- // No task id: a pre-review belongs to no card, and the machine
288
- // snapshot's per-task rows must not invent one. It still COUNTS
289
- // against the machine's ceiling — see liveTurnCount.
290
- workChildren.set(ch, null);
291
- releaseSlot();
292
- // ARMED AT THE SPAWN, not at entry: time spent getting here is not a
293
- // wedged CLI. The CHILD and never its group — a read-only turn starts
294
- // no server, so there is nothing behind it worth signalling and
295
- // everything to lose by signalling somebody else's.
296
- timer = setTimeout(() => {
297
- wedged = true;
298
- try {
299
- ch.kill('SIGKILL');
300
- } catch {
301
- /* already gone */
302
- }
303
- stopWaiting('');
304
- }, PRECHECK_TIMEOUT_MS);
305
- timer.unref?.();
306
- },
307
- });
308
- out = await Promise.race([turn, capped]);
251
+ }));
309
252
  } catch {
310
253
  return; // a label may never fail the beat it rides
311
254
  } finally {
312
- if (timer) clearTimeout(timer);
313
- if (child) workChildren.delete(child);
255
+ // The reservation this lane took itself, released on every exit — the
256
+ // turn's own release covers a spawn; this covers everything else.
314
257
  releaseSlot();
315
- /**
316
- * DELETE THIS READING'S TRANSCRIPT, on EVERY exit — settled, wedged and
317
- * killed, or thrown — because every one of them leaves the file behind
318
- * and the agent's `--continue` reads the newest one in the directory.
319
- *
320
- * AFTER the kill and ON A DELAY, the skills probe's own shape: the
321
- * transcript is the CHILD's file, so removing it while the child is still
322
- * dying races a recreate. Unref'd — it must not hold the process open.
323
- */
324
- if (preSession) setTimeout(() => removeProbeTranscript(wt, preSession), 750).unref?.();
325
258
  }
326
259
 
327
260
  // A WEDGED READING POSTS NOTHING. There is no row to settle and nobody
@@ -10,7 +10,7 @@
10
10
  * begun-guard, the sweep keeps a published branch current, the merge lane
11
11
  * retires the ref — all through the maps and two functions returned here.
12
12
  */
13
- import { git } from './git.mjs';
13
+ import { gitOrNull, sessionBranchRef } from './git.mjs';
14
14
  import { publishPushArgs, publishFetchArgs, publishErrorText } from './agentPublish.mjs';
15
15
  import { scrub as envScrub } from './uplinkScrub.mjs';
16
16
 
@@ -67,13 +67,11 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
67
67
  * — which is a perfectly ordinary state (the begun-guard's whole subject) and
68
68
  * means there is nothing to publish, never that a push failed. */
69
69
  const agentBranchSha = (place) => {
70
- try {
71
- return (
72
- git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot) || null
73
- );
74
- } catch {
75
- return null;
76
- }
70
+ // The one branch-name definition (git.mjs): an unsafe place names no
71
+ // branch, which is the same "nothing to publish" as an absent one.
72
+ const ref = sessionBranchRef(place);
73
+ if (!ref) return null;
74
+ return gitOrNull(['rev-parse', '--verify', '--quiet', ref], repoRoot);
77
75
  };
78
76
  /**
79
77
  * PUSH ONE AGENT'S BRANCH TO THE NAME THE SERVER GAVE IT.