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
@@ -13,8 +13,8 @@
13
13
  * through the one turn-done wire (workAgentTurnReports.mjs). Null means go on.
14
14
  */
15
15
  import { existsSync } from 'node:fs';
16
- import { join } from 'node:path';
17
- import { git } from './git.mjs';
16
+ import { git, sessionBranchRef } from './git.mjs';
17
+ import { worktreePath } from './workPlaces.mjs';
18
18
 
19
19
  /**
20
20
  * WORK THAT HAS BEGUN LIVES ON EXACTLY ONE BOX, AND THIS MAY NOT BE IT.
@@ -45,7 +45,11 @@ import { git } from './git.mjs';
45
45
  * remedy somebody cannot carry out is worse than none.
46
46
  */
47
47
  export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublishedBranch }) {
48
- const wtDir = join(baseDir, 'sessions', place);
48
+ // The caller validated `place` (workAgentTurns); an unsafe one would give a
49
+ // null directory AND a null ref below, and the null ref stands the guard
50
+ // down — the same fail-open direction as an unreadable repo.
51
+ const wtDir = worktreePath(baseDir, place);
52
+ const ref = sessionBranchRef(place);
49
53
  let hasBranch = false;
50
54
  /**
51
55
  * THREE STATES, AND THE MIDDLE ONE IS WHY THIS IS NOT A BARE CATCH.
@@ -63,14 +67,14 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
63
67
  * `placeWtFor` is about to fail on the same unreadable repo and say so
64
68
  * in its own words, which is the honest sentence.
65
69
  */
66
- let branchMeasured = true;
67
- try {
68
- hasBranch = Boolean(
69
- git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot)
70
- );
71
- } catch (e) {
72
- if (e?.status === 1) hasBranch = false;
73
- else branchMeasured = false;
70
+ let branchMeasured = ref !== null;
71
+ if (branchMeasured) {
72
+ try {
73
+ hasBranch = Boolean(git(['rev-parse', '--verify', '--quiet', ref], repoRoot));
74
+ } catch (e) {
75
+ if (e?.status === 1) hasBranch = false;
76
+ else branchMeasured = false;
77
+ }
74
78
  }
75
79
  if (branchMeasured && !existsSync(wtDir) && !hasBranch) {
76
80
  /**
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
20
  import { PROJECT_ID } from './config.mjs';
21
21
  import { setRuntimeLimit } from './daemonState.mjs';
22
- import { git } from './git.mjs';
22
+ import { gitOrNull } from './git.mjs';
23
23
  import { stashCard } from './agentCards.mjs';
24
24
  import { c } from './ui.mjs';
25
25
  import { runTurn } from './runTurn.mjs';
@@ -27,12 +27,12 @@ import { SYSTEM_AGENT_FOR, AGENT_TASK_KICKOFF, AGENT_TASK_SPEC, AGENT_HUMAN_KICK
27
27
  import { AGENT_TASK_KINDS, agentTaskKindOf, unknownAgentTaskKind } from './agentTaskKinds.mjs';
28
28
  import { knowledgeDirFor } from './knowledgeFiles.mjs';
29
29
  import { scrub as envScrub } from './uplinkScrub.mjs';
30
- import { canRun, RUNTIMES } from './runtimes.mjs';
31
- import { recordSkills, recordMcpServers } from './runtimeCapabilities.mjs';
30
+ import { canRun, runtimeById } from './runtimes.mjs';
32
31
  import { toolEventOf, CLAUDE_TOOL_PROSE_KINDS } from './runtimeEvents.mjs';
33
32
  import { makeTraceRelay } from './trace.mjs';
34
33
  import { readBaseTools, prepareAgentTools } from './projectTools.mjs';
35
34
  import { runTurnResumingOnce } from './resumeLost.mjs';
35
+ import { INTERNAL_WAIT_CONTRACT, runAgentWithInternalWaits } from './agentInternalWait.mjs';
36
36
  import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
37
37
  import { brainFor } from './workBrain.mjs';
38
38
  import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
@@ -95,15 +95,8 @@ export function createAgentTurnExecution({
95
95
  * reconciliation books whatever no card claimed. Missing beats fabricated
96
96
  * and both beat a stall.
97
97
  */
98
- let out;
99
- try {
100
- out = git(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()], wt);
101
- } catch {
102
- return [];
103
- }
104
- return typeof out === 'string'
105
- ? out.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50)
106
- : [];
98
+ const out = gitOrNull(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()], wt);
99
+ return out?.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50) ?? [];
107
100
  };
108
101
 
109
102
  /**
@@ -147,19 +140,13 @@ export function createAgentTurnExecution({
147
140
  * `runAgentMerge` learned this two commits ago and says so in its own
148
141
  * comment; this is the same call, in the same file, left unguarded. Both
149
142
  * facts are OPTIONAL to a turn — they describe the branch, they do not
150
- * gate the work — so failing to read them costs a null, not the turn.
143
+ * gate the work — so failing to read them costs a null, not the turn:
144
+ * git.mjs's `gitOrNull`, the one spelling of that rule.
151
145
  */
152
- const readGit = (args) => {
153
- try {
154
- return (git(args, wt) || '').trim() || null;
155
- } catch {
156
- return null;
157
- }
158
- };
159
146
  // WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
160
147
  // ones this turn actually made.
161
- const before = readGit(['rev-parse', 'HEAD']);
162
- const branch = readGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
148
+ const before = gitOrNull(['rev-parse', 'HEAD'], wt);
149
+ const branch = gitOrNull(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt);
163
150
  // …and the same three facts for the lane's catch: a turn that throws past
164
151
  // here is still settled with what was measured (workAgentTurns.mjs).
165
152
  measured.wt = wt;
@@ -176,9 +163,9 @@ export function createAgentTurnExecution({
176
163
  * posture, which can write ONLY `.flowviant/artifacts/` (claudePosture.mjs).
177
164
  *
178
165
  * A RUNTIME THAT CANNOT EXPRESS THE POSTURE IS REFUSED, NOT IMPROVISED.
179
- * Antigravity declares none of them and Codex declares `design` (since
180
- * 0.115.0) and `image` but not `research` (runtimeCodex.mjs,
181
- * runtimeAntigravity.mjs), and the only
166
+ * Antigravity declares none of them; Codex declares `design` (since
167
+ * 0.115.0), `image` and `research` (0.117.0, over an isolated copy —
168
+ * codexResearch.mjs) (runtimeCodex.mjs, runtimeAntigravity.mjs), and the only
182
169
  * alternative to refusing is running the card as a BUILD turn with
183
170
  * permissions skipped — an agent writing code for an ask that was a
184
171
  * mockup, reporting success. `nothing` puts the agent in Stuck saying so.
@@ -220,7 +207,7 @@ export function createAgentTurnExecution({
220
207
  // entry in agentTaskKinds.mjs; nothing below branches on the kind name.
221
208
  const kind = AGENT_TASK_KINDS[taskKind];
222
209
  const posture = kind.posture;
223
- if (!canRun(RUNTIMES[rt], posture)) {
210
+ if (!canRun(runtimeById(rt), posture)) {
224
211
  await postAgentTurn({
225
212
  turnId,
226
213
  outcome: 'nothing',
@@ -393,7 +380,7 @@ export function createAgentTurnExecution({
393
380
  * `parse: null` is exactly the claudeStream.mjs stream path. See
394
381
  * CLAUDE_TOOL_PROSE_KINDS.
395
382
  */
396
- const doubledKinds = RUNTIMES[rt]?.parse ? null : CLAUDE_TOOL_PROSE_KINDS;
383
+ const doubledKinds = runtimeById(rt)?.parse ? null : CLAUDE_TOOL_PROSE_KINDS;
397
384
 
398
385
  /**
399
386
  * WHICH BRAIN THIS CONTAINER WAS PINNED TO — the same `brainFor` the tab
@@ -489,12 +476,12 @@ export function createAgentTurnExecution({
489
476
  // different things about the same directory.
490
477
  // Spoken to the CLI that runs it (0.115.0): a mockup on Codex is
491
478
  // told it is Codex, never Claude.
492
- system: withProjectContext(SYSTEM_AGENT_FOR(taskKind, RUNTIMES[rt]?.label), {
479
+ system: withProjectContext(SYSTEM_AGENT_FOR(taskKind, runtimeById(rt)?.label), {
493
480
  knowledgeDir: knowledgeDirFor(repoRoot),
494
481
  // ARTIFACTS (0.94.0), while the server can show one — an agent's
495
482
  // land on its page, under the facts row.
496
483
  artifacts: getArtifactsAccepted(),
497
- }) + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
484
+ }) + `\n\n${INTERNAL_WAIT_CONTRACT}` + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
498
485
  agentTools: projectTools,
499
486
  ...(projectTools.codexHome ? { mcpEnv: { CODEX_HOME: projectTools.codexHome } } : {}),
500
487
  // NOBODY IS AT THIS SCREEN (2026-09-29): no display, BROWSER=none, and
@@ -502,11 +489,13 @@ export function createAgentTurnExecution({
502
489
  // so a Chrome it starts to look at its page never reaches the
503
490
  // person's (noWindowEnv.mjs).
504
491
  browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
492
+ // The base branch's MCP servers, for a code card: Claude's in this
493
+ // turn's own 0600 file, strictly; Codex's already in its private
494
+ // home's config.toml (projectToolRuntimes.mjs, 2026-10-10), which
495
+ // its fenced postures ignore — never `-c` pairs on argv.
505
496
  ...(posture === 'build' && rt === 'claude'
506
497
  ? { mcpArgs: ['--strict-mcp-config', '--mcp-config', projectTools.mcpPath] }
507
- : posture === 'build' && rt === 'codex'
508
- ? { mcpArgs: projectTools.codexArgs }
509
- : {}),
498
+ : {}),
510
499
  knowledgeDir: knowledgeDirFor(repoRoot),
511
500
  // The kind's posture, as the one turn profile (turnProfile.mjs):
512
501
  // build for code, the fenced design/research postures otherwise.
@@ -523,7 +512,6 @@ export function createAgentTurnExecution({
523
512
  },
524
513
  // Present only when the container named one — see brainFor.
525
514
  ...brain,
526
- streamJson: true,
527
515
  answerFromResult: true,
528
516
  label: c.cyan('[agent]'),
529
517
  // The CLI's own tail, relayed. Throttled by the same rule the tab's
@@ -559,8 +547,9 @@ export function createAgentTurnExecution({
559
547
  trace.tool(toolEventOf(name, input, wt, envScrub));
560
548
  },
561
549
  // The CLI's own per-turn token counts, off the `result` event. One
562
- // result per turn, so this is a set and not an accumulate — the
563
- // adding-up happens SERVER-side, behind the settle's idempotent win,
550
+ // result per CLI invocation. Internal-wait recovery combines those
551
+ // counts before this callback; project accounting still happens
552
+ // SERVER-side, behind the settle's idempotent win,
564
553
  // because a retried settle must not charge the same turn twice.
565
554
  // …tagged with WHICH CLI counted it (2026-09-24), so the server can
566
555
  // split an agent's spend per CLI rather than labelling a codex
@@ -571,16 +560,11 @@ export function createAgentTurnExecution({
571
560
  onModel: (m) => {
572
561
  model = m;
573
562
  },
574
- // WHAT THE CLI SAYS IT CAN BE ASKED FOR, AND WHICH CONNECTORS NEED A
575
- // LOGIN — the same free fact off the same init event the tab lane
576
- // records (2026-09-23). Without it a box that only ever runs agents
577
- // taught the app its skills and connectors ONCE, from the startup
578
- // probe, and never again: a connector signed into at the box kept
579
- // reading "needs authorization" for the life of the process.
580
- onInit: (i) => {
581
- recordSkills(i.skills);
582
- recordMcpServers(i.mcpServers);
583
- },
563
+ // No skills or connectors are harvested here (2026-10-10): every
564
+ // agent turn narrows them — `--setting-sources user` and the reviewed
565
+ // plugin, `--strict-mcp-config` — so its init is not the machine's
566
+ // (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
567
+ // startup probe and its Terminal tabs.
584
568
  onSpawn: (ch) => {
585
569
  child = ch;
586
570
  spawnedChildren.add(ch);
@@ -612,15 +596,24 @@ export function createAgentTurnExecution({
612
596
  * (resumeLost.mjs, SOLID F001): a successful answer that merely
613
597
  * MENTIONS a missing session is never run twice.
614
598
  */
615
- out = await runTurnResumingOnce(runTurn, agentTurnArgs, {
616
- resume,
617
- runtime: rt,
618
- beforeFresh: () => {
619
- seenThreadId = null;
599
+ out = await runAgentWithInternalWaits(
600
+ (args, continuing) => continuing ? runTurn(args) : runTurnResumingOnce(runTurn, args, {
601
+ // A Codex write-up always runs fresh over its copy (codexResearch
602
+ // .mjs), so there is no lost conversation to retry it for.
603
+ resume: rt === 'codex' && posture === 'research' ? false : args.resume,
604
+ runtime: rt,
605
+ beforeFresh: () => {
606
+ seenThreadId = null;
607
+ lastAnswer = null;
608
+ model = null;
609
+ },
610
+ }),
611
+ { ...agentTurnArgs, onContinuation: (n) => {
620
612
  lastAnswer = null;
621
- model = null;
622
- },
623
- });
613
+ trace.prose('text', `The machine resumed this card after an internal wait (continuation ${n}).`);
614
+ void postAgentActivity(agentId, 'resuming after an internal wait');
615
+ } }
616
+ );
624
617
  } finally {
625
618
  // What the CLI counted, for the lane's catch if anything below throws.
626
619
  measured.usage = usage;
@@ -14,8 +14,9 @@
14
14
  * Daemon→server reports, every one of them: no version floor, and a permanent
15
15
  * 4xx is the server saying it will never take the body (see each POST).
16
16
  */
17
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
18
- import { fleetEndpoint } from './fleetWire.mjs';
17
+ import { FLEET_URL } from './config.mjs';
18
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
19
+ import { fleetHeaders, postFleetJson } from './fleetPost.mjs';
19
20
 
20
21
  export function createAgentTurnReports({ REJECT_RETRY_MS }) {
21
22
  const AGENT_TURN_DONE_URL = fleetEndpoint('agent-turn-done', FLEET_URL);
@@ -53,16 +54,7 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
53
54
  const turnId = String(body.turnId);
54
55
  agentReported.set(turnId, { body, at: Date.now() });
55
56
  try {
56
- const res = await fetch(AGENT_TURN_DONE_URL, {
57
- method: 'POST',
58
- headers: {
59
- Authorization: `Bearer ${FLEET_TOKEN}`,
60
- 'User-Agent': USER_AGENT,
61
- 'Content-Type': 'application/json',
62
- },
63
- signal: AbortSignal.timeout(30_000),
64
- body: JSON.stringify(body),
65
- });
57
+ const res = await postFleetJson(AGENT_TURN_DONE_URL, body, { timeoutMs: 30_000 });
66
58
  const j = await res.json().catch(() => null);
67
59
  /**
68
60
  * ONLY A 2xx IS DELIVERED. The server answers an already-settled,
@@ -79,7 +71,7 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
79
71
  if (res.ok) {
80
72
  agentReported.delete(turnId);
81
73
  agentRejectedUntil.delete(turnId);
82
- } else if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
74
+ } else if (isConsideredRefusal(res.status)) {
83
75
  agentRejectedUntil.set(turnId, Date.now() + REJECT_RETRY_MS);
84
76
  }
85
77
  return j?.data ?? null;
@@ -102,20 +94,8 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
102
94
  */
103
95
  const postAgentTrace = async (body) => {
104
96
  try {
105
- const res = await fetch(AGENT_TRACE_URL, {
106
- method: 'POST',
107
- headers: {
108
- Authorization: `Bearer ${FLEET_TOKEN}`,
109
- 'User-Agent': USER_AGENT,
110
- 'Content-Type': 'application/json',
111
- },
112
- signal: AbortSignal.timeout(15_000),
113
- body: JSON.stringify(body),
114
- });
115
- return (
116
- res.ok ||
117
- (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
118
- );
97
+ const res = await postFleetJson(AGENT_TRACE_URL, body, { timeoutMs: 15_000 });
98
+ return res.ok || isConsideredRefusal(res.status);
119
99
  } catch {
120
100
  return false; // a blip — the same entries go again at the same seq
121
101
  }
@@ -125,11 +105,7 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
125
105
  try {
126
106
  await fetch(AGENT_ACTIVITY_URL, {
127
107
  method: 'POST',
128
- headers: {
129
- Authorization: `Bearer ${FLEET_TOKEN}`,
130
- 'User-Agent': USER_AGENT,
131
- 'Content-Type': 'application/json',
132
- },
108
+ headers: fleetHeaders(),
133
109
  signal: AbortSignal.timeout(15_000),
134
110
  body: JSON.stringify({ agentId, text }),
135
111
  });
@@ -142,11 +118,7 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
142
118
  try {
143
119
  await fetch(AGENT_PARKED_URL, {
144
120
  method: 'POST',
145
- headers: {
146
- Authorization: `Bearer ${FLEET_TOKEN}`,
147
- 'User-Agent': USER_AGENT,
148
- 'Content-Type': 'application/json',
149
- },
121
+ headers: fleetHeaders(),
150
122
  signal: AbortSignal.timeout(15_000),
151
123
  // `runtime` scopes the park to that CLI's agents; a server that does
152
124
  // not know the key parks every agent, which is what it always did.
@@ -318,9 +318,10 @@ export function createWorkAgentTurns({
318
318
  /**
319
319
  * SETTLE EVERYTHING IN FLIGHT, because this process is about to go away.
320
320
  *
321
- * The one caller is the displacement stand-down: the project's machine moved
322
- * to another box, so nothing here will be re-offered to us and nothing else
323
- * knows these turns were running. An abandoned turn sits pending until the
321
+ * The one caller is the roster's stand-down (holder.mjs): the project's
322
+ * machine moved to another box, this box was removed from the project, or
323
+ * Flowviant stopped it (2026-10-10) — so nothing here will be re-offered to
324
+ * us and nothing else knows these turns were running. An abandoned turn sits pending until the
324
325
  * server's six-hour expiry while the board shows an agent working on a machine
325
326
  * that has gone — the wedge every settle path in this lane exists to avoid.
326
327
  *
@@ -9,8 +9,9 @@
9
9
  * serves both, so the two lanes cannot disagree about what a safe name is.
10
10
  * The name rule itself is safeFileName.mjs's.
11
11
  */
12
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
14
15
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
15
16
  import { excludeInWorktree } from './git.mjs';
16
17
  import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
@@ -59,13 +60,60 @@ export function createWorkAttachments() {
59
60
  // and the two sides never disagree about the cut. An empty name is
60
61
  // `attachment`, the server's own fallback.
61
62
  const safeUploadName = (raw) => safeFileName(raw, 'attachment');
62
- /** The file at `path` holds exactly `buf` — read only when the sizes agree. */
63
+ /**
64
+ * NO LINK UNDER `.flowviant/` IS FOLLOWED, AND NOTHING THERE IS REPLACED
65
+ * (2026-10-10, the audit's F080). This lane writes a MEMBER'S bytes under a
66
+ * member's name, and it was the one `.flowviant/` writer with no guard:
67
+ * `mkdirSync` and `writeFileSync` went through a committed `.flowviant ->
68
+ * ~/.ssh` or `.flowviant/uploads -> ../../../.ssh`, and `existsSync` answered
69
+ * "absent" for a dangling link at the file's own name, which the write then
70
+ * followed to create its target — an attachment named `authorized_keys`
71
+ * landing in the operator's `~/.ssh`. Now: both directories must be real
72
+ * (ownedDirs.mjs `ensureRealSubdir`, the artifacts directory's rule — refused,
73
+ * never replaced), every look at a name is an `lstat` or an `O_NOFOLLOW`
74
+ * open, and each file is CREATED (`O_EXCL`): anything already at the name,
75
+ * a dangling link included, is never written through.
76
+ */
77
+ const NEW_FILE = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
78
+ /** Anything at all at `path` — a dangling link too. */
79
+ const taken = (path) => {
80
+ try {
81
+ lstatSync(path);
82
+ return true;
83
+ } catch (e) {
84
+ return e?.code !== 'ENOENT';
85
+ }
86
+ };
87
+ /** The REGULAR file at `path` holds exactly `buf` — read only when the sizes
88
+ * agree, and never through a link. Non-blocking, so a fifo at the name is
89
+ * answered "no" rather than waited on. */
63
90
  const holds = (path, buf) => {
91
+ let fd;
64
92
  try {
65
- return statSync(path).size === buf.byteLength && readFileSync(path).equals(buf);
93
+ const at = lstatSync(path);
94
+ if (!at.isFile() || at.size !== buf.byteLength) return false;
95
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
96
+ const st = fstatSync(fd);
97
+ return st.isFile() && st.size === buf.byteLength && readFileSync(fd).equals(buf);
66
98
  } catch {
67
99
  return false;
100
+ } finally {
101
+ if (fd !== undefined) closeSync(fd);
102
+ }
103
+ };
104
+ /** A NEW regular file at `path` holding `buf`, at the mode `writeFileSync`
105
+ * used. Throws (EEXIST) when anything is already there; a write that fails
106
+ * part-way takes its own half-file with it. */
107
+ const createFile = (path, buf) => {
108
+ const fd = openSync(path, NEW_FILE, 0o666);
109
+ try {
110
+ writeFileSync(fd, buf);
111
+ } catch (e) {
112
+ closeSync(fd);
113
+ rmSync(path, { force: true });
114
+ throw e;
68
115
  }
116
+ closeSync(fd);
69
117
  };
70
118
  /**
71
119
  * THE ONE DOWNLOAD LOOP. Each entry's relative path, or null where it did
@@ -81,11 +129,10 @@ export function createWorkAttachments() {
81
129
  */
82
130
  const landFiles = async (wt, list, endpoint, sameBytesKept) => {
83
131
  const dir = join(wt, UPLOAD_DIR);
84
- try {
85
- mkdirSync(dir, { recursive: true });
86
- } catch {
87
- return list.map(() => null);
88
- }
132
+ // A `.flowviant` or `uploads` that is a link or not a directory refuses
133
+ // the whole batch before anything is fetched: every file is a miss the
134
+ // turn is told about, and the repository's content is left as it is.
135
+ if (!ensureRealSubdir(wt, UPLOAD_DIR)) return list.map(() => null);
89
136
  // "Never committed by us" has to be true for GIT, not just for this code:
90
137
  // an untracked `.flowviant/` makes the whole worktree dirty, which refuses
91
138
  // every ship, exempts the tree from closed-tab retirement forever, and
@@ -115,14 +162,22 @@ export function createWorkAttachments() {
115
162
  // silently overwriting one with the other loses a file the human sent.
116
163
  let name = safeUploadName(a.name);
117
164
  let already = sameBytesKept && holds(join(dir, name), buf);
118
- if (!already && existsSync(join(dir, name))) {
165
+ if (!already && taken(join(dir, name))) {
119
166
  const dot = name.lastIndexOf('.');
120
167
  const stem = dot > 0 ? name.slice(0, dot) : name;
121
168
  const ext = dot > 0 ? name.slice(dot) : '';
122
169
  name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
123
- already = sameBytesKept && holds(join(dir, name), buf);
170
+ // The id-suffixed name is this file's own: holding these bytes, it
171
+ // IS this file, on either lane (it used to be rewritten with them).
172
+ // Anything else there is a miss — never replaced, never followed.
173
+ already = holds(join(dir, name), buf);
174
+ }
175
+ if (!already) {
176
+ // Looked at again right before the write: the batch's first check
177
+ // is up to a download old.
178
+ if (realDirState(join(wt, '.flowviant')) !== 'dir' || realDirState(dir) !== 'dir') continue;
179
+ createFile(join(dir, name), buf);
124
180
  }
125
- if (!already) writeFileSync(join(dir, name), buf);
126
181
  landed[landed.length - 1] = `${UPLOAD_DIR}/${name}`;
127
182
  } catch {
128
183
  /* one file failing must not fail the turn — the prompt lists what
@@ -7,7 +7,7 @@
7
7
  * — each importing it directly — and it is pure over the job and the runtime
8
8
  * table, so its home is a module of its own rather than either lane.
9
9
  */
10
- import { RUNTIMES } from './runtimes.mjs';
10
+ import { runtimeById } from './runtimes.mjs';
11
11
 
12
12
  /**
13
13
  * The shape a per-tab model name must have before it rides argv as
@@ -15,8 +15,13 @@ import { RUNTIMES } from './runtimes.mjs';
15
15
  * (below): it comes off the wire and lands in a child process's arguments —
16
16
  * alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
17
17
  * leading dash, which is an argv that parses as a flag.
18
+ *
19
+ * The server's route schemas copy it as `AGENT_MODEL_RE` (shared
20
+ * agent-runner.schema.ts), and the release parity gate holds the two equal
21
+ * (2026-10-10, audit F613): a server that accepts a name this drops has the
22
+ * board assert a pin the machine silently replaces with its default.
18
23
  */
19
- const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
24
+ export const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
20
25
 
21
26
  /**
22
27
  * WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
@@ -39,7 +44,7 @@ const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
39
44
  * One global ladder once accepted `max` for every runtime, and Codex has no
40
45
  * `max`: a skewed or previously queued Codex job reached argv as a
41
46
  * `model_reasoning_effort` its CLI refuses. The per-CLI list lives beside each
42
- * runtime's argv builder (`RUNTIMES[id].efforts`, runtime{Claude,Codex,Antigravity}.mjs); a runtime this
47
+ * runtime's argv builder (`runtimeById(id).efforts`, runtime{Claude,Codex,Antigravity}.mjs); a runtime this
43
48
  * daemon does not define runs nothing, so it spells no effort either.
44
49
  */
45
50
  export function brainFor(job, runtimeId) {
@@ -47,7 +52,7 @@ export function brainFor(job, runtimeId) {
47
52
  const model = typeof job?.model === 'string' ? job.model.trim() : '';
48
53
  if (model && WORK_MODEL_RE.test(model)) out.model = model;
49
54
  const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
50
- const efforts = RUNTIMES[runtimeId]?.efforts ?? [];
55
+ const efforts = runtimeById(runtimeId)?.efforts ?? [];
51
56
  if (effort && efforts.includes(effort)) out.effort = effort;
52
57
  return out;
53
58
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * ONE CAPPED READ-ONLY CONSULT TURN — the process plumbing the unattended
3
+ * one-shot lanes share: the Deploy-press planner (workAgentPlans.mjs), the AI
4
+ * pre-review (workAgentPrecheck.mjs) and intake (workIntake.mjs).
5
+ *
6
+ * ONE HOME (2026-10-10, the audit). Each lane carried its own forty-to-eighty
7
+ * lines of this, and the copies had already begun to drift. Every step below
8
+ * is a rule some incident taught, and a lane that copied the wrong one would
9
+ * show it only as a daemon that never auto-updates again:
10
+ * · THE CHILD IS REGISTERED IN `workChildren`, AND REMOVED IN A `finally`.
11
+ * It is what `workBusy()` counts and what counts against the machine's
12
+ * ceiling (liveTurnCount); a leaked entry keeps the daemon "busy" forever,
13
+ * which blocks every auto-update, silently, until a restart. No task id: a
14
+ * consult turn belongs to no card, and the snapshot's per-task rows must
15
+ * not invent one.
16
+ * · THE ADMISSION RESERVATION IS RELEASED AT THE SPAWN, and again in the
17
+ * `finally` for a turn that never spawned — release is idempotent, so
18
+ * doing both is the normal case (admission.mjs).
19
+ * · THE CAP IS ARMED AT THE SPAWN, not at entry: time spent waiting for a
20
+ * checkout lock is not a wedged CLI. It SIGKILLs the CHILD and never its
21
+ * group — the rule teardown keeps: a read-only turn starts no server, so
22
+ * there is nothing behind it worth signalling and everything to lose by
23
+ * signalling somebody else's.
24
+ * · THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
25
+ * the kill: a SIGKILLed process whose stdio a grandchild still holds can be
26
+ * slow to emit `close`, or never emit it, and the caller is holding a
27
+ * claimed job (or a place's writer lock) open behind this promise.
28
+ * · THE TURN'S TRANSCRIPT IS REMOVED ON EVERY EXIT — settled, wedged or
29
+ * thrown. `claude -p` writes `~/.claude/projects/<munged-cwd>/<id>.jsonl`,
30
+ * and the CLI's `--continue` is cwd-keyed: left in the checkout it displaces
31
+ * the operator's own conversation from the `+` adopt menu and is what their
32
+ * `claude --continue` resumes; left in an agent's worktree it is what the
33
+ * agent's next turn resumes, under build permissions. The id is the one the
34
+ * CLI reports at `system.init`, so the file is ours by name.
35
+ *
36
+ * WHAT STAYS WITH EACH LANE is policy, never plumbing: its lock, its prompt,
37
+ * profile and brain, its usage callback (set or accumulate is a per-lane
38
+ * choice), its cap length, its narration, and the words it settles with.
39
+ */
40
+ import { removeTranscriptSoon } from './runtimeCapabilities.mjs';
41
+
42
+ /**
43
+ * Run `runTurn(turnArgs)` under a `capMs` cap. Resolves
44
+ * `{ out, wedged, spawned, sessionId }`: `out` is the turn's answer, or `''`
45
+ * when the cap fired (`wedged`); `spawned` says a CLI process existed; the
46
+ * session id is the conversation the CLI named, or null. A turn that throws
47
+ * rethrows here, after the same cleanup.
48
+ *
49
+ * `onSpawn` and `onInit` are the caller's own hooks, run after this file's
50
+ * bookkeeping — which is why they ride beside `turnArgs` and never inside it.
51
+ * `removeTranscript` is a test's.
52
+ */
53
+ export async function runCappedTurn({
54
+ runTurn,
55
+ turnArgs,
56
+ capMs,
57
+ workChildren,
58
+ releaseSlot = () => {},
59
+ transcriptCwd,
60
+ onSpawn = () => {},
61
+ onInit = () => {},
62
+ removeTranscript = removeTranscriptSoon,
63
+ }) {
64
+ // Wiring mistakes, refused before anything spawns: a hook inside turnArgs
65
+ // would be silently replaced below, and a missing cap would fire at once.
66
+ if (turnArgs && ('onSpawn' in turnArgs || 'onInit' in turnArgs))
67
+ throw new TypeError('runCappedTurn: pass onSpawn/onInit beside turnArgs, never inside it');
68
+ if (!(Number.isFinite(capMs) && capMs > 0)) throw new TypeError('runCappedTurn: capMs must be a positive number');
69
+ let child = null;
70
+ let timer = null;
71
+ let wedged = false;
72
+ let sessionId = null;
73
+ let stopWaiting = () => {};
74
+ const capped = new Promise((resolve) => {
75
+ stopWaiting = resolve;
76
+ });
77
+ try {
78
+ const turn = runTurn({
79
+ ...turnArgs,
80
+ onInit: (i) => {
81
+ if (typeof i?.sessionId === 'string' && i.sessionId.trim()) sessionId = i.sessionId.trim();
82
+ onInit(i);
83
+ },
84
+ onSpawn: (ch) => {
85
+ child = ch;
86
+ workChildren.set(ch, null);
87
+ releaseSlot();
88
+ timer = setTimeout(() => {
89
+ wedged = true;
90
+ try {
91
+ ch.kill('SIGKILL');
92
+ } catch {
93
+ /* already gone */
94
+ }
95
+ stopWaiting('');
96
+ }, capMs);
97
+ timer.unref?.();
98
+ onSpawn(ch);
99
+ },
100
+ });
101
+ const out = await Promise.race([turn, capped]);
102
+ return { out, wedged, spawned: child !== null, sessionId };
103
+ } finally {
104
+ if (timer) clearTimeout(timer);
105
+ if (child) workChildren.delete(child);
106
+ releaseSlot();
107
+ if (sessionId) removeTranscript(transcriptCwd, sessionId);
108
+ }
109
+ }
@@ -10,6 +10,7 @@
10
10
  * on this box" belong together — and apart from any one lane.
11
11
  */
12
12
  import { createAdmission } from './admission.mjs';
13
+ import { killGroup } from './processGroup.mjs';
13
14
 
14
15
  export function createWorkChildren({ extraLiveTurns }) {
15
16
  /**
@@ -54,11 +55,10 @@ export function createWorkChildren({ extraLiveTurns }) {
54
55
  const groupKillChildren = new Set();
55
56
  const shutdownWork = () => {
56
57
  for (const [ch] of workChildren) {
57
- try {
58
- if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
59
- else ch.kill('SIGTERM');
60
- } catch {
61
- // A group that has already gone, or a pid that is no longer a leader.
58
+ // A group that has already gone, or a pid that is no longer a leader,
59
+ // falls back to the child (processGroup.mjs).
60
+ if (groupKillChildren.has(ch)) killGroup(ch, 'SIGTERM');
61
+ else {
62
62
  try {
63
63
  ch.kill('SIGTERM');
64
64
  } catch {