flowviant 0.103.0 → 0.105.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 (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +130 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactContracts.mjs +153 -0
  7. package/bin/lib/artifactPolicy.mjs +45 -0
  8. package/bin/lib/artifactPreview.mjs +52 -0
  9. package/bin/lib/artifactReporter.mjs +163 -0
  10. package/bin/lib/artifacts.mjs +49 -228
  11. package/bin/lib/authReject.mjs +16 -4
  12. package/bin/lib/authproxy.mjs +47 -304
  13. package/bin/lib/boxIdentity.mjs +245 -0
  14. package/bin/lib/childEnv.mjs +11 -9
  15. package/bin/lib/claude.mjs +14 -851
  16. package/bin/lib/claudeAuth.mjs +3 -3
  17. package/bin/lib/claudePosture.mjs +370 -0
  18. package/bin/lib/claudeSessions.mjs +411 -0
  19. package/bin/lib/claudeStream.mjs +207 -0
  20. package/bin/lib/cliLimit.mjs +34 -0
  21. package/bin/lib/commandSpecs.mjs +173 -0
  22. package/bin/lib/config.mjs +11 -42
  23. package/bin/lib/credentialDate.mjs +22 -0
  24. package/bin/lib/credentials.mjs +21 -26
  25. package/bin/lib/daemonLogging.mjs +55 -0
  26. package/bin/lib/daemonState.mjs +64 -0
  27. package/bin/lib/deploy.mjs +148 -647
  28. package/bin/lib/deployCheckout.mjs +265 -0
  29. package/bin/lib/deployConfig.mjs +142 -0
  30. package/bin/lib/deployRunner.mjs +311 -0
  31. package/bin/lib/deployWire.mjs +41 -0
  32. package/bin/lib/desktopContract.mjs +56 -105
  33. package/bin/lib/env.mjs +49 -484
  34. package/bin/lib/fleet.mjs +183 -2383
  35. package/bin/lib/fleetJobs.mjs +120 -0
  36. package/bin/lib/fleetPost.mjs +40 -0
  37. package/bin/lib/fleetReports.mjs +392 -0
  38. package/bin/lib/fleetRoster.mjs +470 -0
  39. package/bin/lib/fleetStartup.mjs +196 -0
  40. package/bin/lib/fleetWake.mjs +45 -0
  41. package/bin/lib/fleetWire.mjs +46 -0
  42. package/bin/lib/git.mjs +2 -2
  43. package/bin/lib/gitMergeIdentity.mjs +51 -0
  44. package/bin/lib/grant.mjs +5 -3
  45. package/bin/lib/help.mjs +3 -125
  46. package/bin/lib/holder.mjs +482 -0
  47. package/bin/lib/instance.mjs +124 -46
  48. package/bin/lib/knowledge.mjs +16 -799
  49. package/bin/lib/knowledgeFetch.mjs +38 -0
  50. package/bin/lib/knowledgeFileSync.mjs +112 -0
  51. package/bin/lib/knowledgeFiles.mjs +489 -0
  52. package/bin/lib/knowledgeLibrary.mjs +299 -0
  53. package/bin/lib/landed.mjs +2 -1
  54. package/bin/lib/listeners.mjs +99 -141
  55. package/bin/lib/localSessions.mjs +30 -555
  56. package/bin/lib/login.mjs +3 -2
  57. package/bin/lib/loginPath.mjs +3 -5
  58. package/bin/lib/machineDisconnect.mjs +203 -0
  59. package/bin/lib/machineEnv.mjs +22 -0
  60. package/bin/lib/machineLimits.mjs +61 -0
  61. package/bin/lib/machineListing.mjs +338 -0
  62. package/bin/lib/machines.mjs +78 -455
  63. package/bin/lib/machinesCommand.mjs +214 -0
  64. package/bin/lib/mcp-cli.mjs +2 -1
  65. package/bin/lib/patchRevert.mjs +60 -0
  66. package/bin/lib/prWorkflow.mjs +157 -0
  67. package/bin/lib/preflight.mjs +2 -1
  68. package/bin/lib/pressurePolicy.mjs +104 -0
  69. package/bin/lib/preview.mjs +2 -272
  70. package/bin/lib/previewFramePolicy.mjs +112 -0
  71. package/bin/lib/previewGatePolicy.mjs +236 -0
  72. package/bin/lib/previewRegistry.mjs +235 -0
  73. package/bin/lib/printable.mjs +2 -2
  74. package/bin/lib/procRegistry.mjs +28 -18
  75. package/bin/lib/procScan.mjs +146 -0
  76. package/bin/lib/processes.mjs +29 -37
  77. package/bin/lib/projectToolRuntimes.mjs +138 -0
  78. package/bin/lib/projectTools.mjs +13 -88
  79. package/bin/lib/prompts.mjs +105 -139
  80. package/bin/lib/repoPath.mjs +26 -0
  81. package/bin/lib/resources.mjs +7 -69
  82. package/bin/lib/resumeLost.mjs +107 -0
  83. package/bin/lib/runTurn.mjs +324 -0
  84. package/bin/lib/runtimeAntigravity.mjs +230 -0
  85. package/bin/lib/runtimeCapabilities.mjs +334 -0
  86. package/bin/lib/runtimeClaude.mjs +126 -0
  87. package/bin/lib/runtimeCodex.mjs +217 -0
  88. package/bin/lib/runtimeDetection.mjs +121 -0
  89. package/bin/lib/runtimeEvents.mjs +482 -0
  90. package/bin/lib/runtimeOfferings.mjs +4 -1
  91. package/bin/lib/runtimes.mjs +42 -1402
  92. package/bin/lib/safeFileName.mjs +74 -0
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +17 -17
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3034
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +219 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +91 -898
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -1,26 +1,33 @@
1
- /** Agent turns, trace delivery, admission, and held settlement reports. */
2
- import { limitLine } from './workAgentReview.mjs';
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, PROJECT_ID } from './config.mjs';
6
- import { setRuntimeLimit } from './desktopContract.mjs';
7
- import { git, isSafePathSegment } from './git.mjs';
8
- import { parseTurnResult } from './agentPlan.mjs';
9
- import { stashCard } from './agentCards.mjs';
1
+ /**
2
+ * THE AGENT-TURN LANE: which offered turns run on this machine, and that each
3
+ * one is settled exactly once.
4
+ *
5
+ * Since the 2026-09-26 SOLID pass (F036) this file is the coordinator and
6
+ * nothing else. It admits a roster's turns against the machine's pressure,
7
+ * re-POSTs a finished turn's held body instead of re-running its CLI, refuses
8
+ * the turns no box should run (a traversal place, a card that is gone), takes
9
+ * the agent's place lock as a WRITER, publishes the branch after the settle,
10
+ * and settles everything in flight when the machine moves away. What it hands
11
+ * off, each to the module named for it:
12
+ * - every POST and the held-body map: workAgentTurnReports.mjs
13
+ * - running one turn in its worktree: workAgentTurnExecution.mjs
14
+ * - the begun-guard: workAgentTurnBegun.mjs
15
+ * - what a finished (or failed) run reports: workAgentTurnOutcome.mjs
16
+ *
17
+ * The split exposed the defect it fixes: a run that THREW left its turn
18
+ * unsettled, so the next offer ran it again. The lane now catches, measures
19
+ * what the run had learned, and settles `nothing` once — unless the run had
20
+ * already sent a settle, which always outranks the crash.
21
+ */
22
+ import { existsSync } from 'node:fs';
23
+ import { isSafePathSegment } from './git.mjs';
10
24
  import { c, note } from './ui.mjs';
11
- import { runTurn } from './claude.mjs';
12
- import { SYSTEM_AGENT_FOR, agentTaskKindOf, unknownAgentTaskKind, AGENT_TASK_KICKOFF, AGENT_TASK_SPEC, AGENT_HUMAN_KICKOFF, withProjectContext } from './prompts.mjs';
13
- import { knowledgeDirFor } from './knowledge.mjs';
14
- import { changedArtifacts, scanArtifacts } from './artifacts.mjs';
15
- import { scrub as envScrub } from './env.mjs';
16
- import { canRun, recordSkills, recordMcpServers, toolEventOf, CLAUDE_TOOL_PROSE_KINDS, RUNTIMES } from './runtimes.mjs';
17
- import { makeTraceRelay } from './trace.mjs';
18
- import { readBaseTools, prepareAgentTools } from './projectTools.mjs';
25
+ import { createAgentTurnReports } from './workAgentTurnReports.mjs';
26
+ import { createAgentTurnExecution } from './workAgentTurnExecution.mjs';
27
+ import { failedTurnSettlement } from './workAgentTurnOutcome.mjs';
19
28
 
20
29
  export function createWorkAgentTurns({
21
30
  REJECT_RETRY_MS,
22
- CODEX_THREAD_RE,
23
- resumeConversationLost,
24
31
  baseRef,
25
32
  inPlace,
26
33
  baseDir,
@@ -28,7 +35,6 @@ export function createWorkAgentTurns({
28
35
  fetchPublishedBranch,
29
36
  placeWtFor,
30
37
  sessionMetaPath,
31
- brainFor,
32
38
  beforeArtifacts,
33
39
  getArtifactsAccepted,
34
40
  noteSessionGroup,
@@ -37,14 +43,19 @@ export function createWorkAgentTurns({
37
43
  runReviewEntry,
38
44
  publishAgentBranch,
39
45
  admit,
40
- state,
46
+ workChildren,
47
+ agentPublished,
48
+ agentRemoteAt,
41
49
  }) {
42
- const AGENT_TURN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-turn-done');
43
- const AGENT_TRACE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-trace');
44
- const AGENT_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-activity');
45
- const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
46
-
47
- const { workChildren, agentPublished, agentRemoteAt } = state;
50
+ const {
51
+ postAgentTurn,
52
+ postAgentTrace,
53
+ postAgentActivity,
54
+ postAgentParked,
55
+ pruneHeldReports,
56
+ agentReported,
57
+ agentRejectedUntil,
58
+ } = createAgentTurnReports({ REJECT_RETRY_MS });
48
59
 
49
60
  // ── AGENT TURNS: one task per prompt ───────────────────────────────────────
50
61
  //
@@ -79,164 +90,25 @@ export function createWorkAgentTurns({
79
90
  */
80
91
  const agentChildren = new Map(); // placeId -> child process
81
92
 
82
- /** Returns the server's reply, because it carries ONE instruction the machine
83
- * can act on immediately: `review: true` means the agent's queue just
84
- * emptied, so run the project's own check in the worktree we are already
85
- * standing in. A job lane for that would need a claim, a floor and a settle
86
- * to say something this reply already can. */
87
- /**
88
- * Turns whose work is DONE but whose settle has not landed, keyed to the
89
- * finished BODY. The delivery half of what `pendingWorkReports` is for a
90
- * tab: a settle that fails to POST must not re-run the turn — that is a
91
- * second CLI, a second set of commits, and the operator's quota spent again
92
- * — but a guard that only SKIPPED left the other half undone. One failed
93
- * POST parked the agent for the server's whole six-hour expiry, holding a
94
- * cap slot the entire time, and then expired into "nobody ran it" — a false
95
- * sentence about a turn this machine finished. The server's settle is
96
- * idempotent (conditional on the row still being pending), so a re-offer of
97
- * a held turn re-POSTs the stored body instead: safe, and it lands the
98
- * moment the network heals rather than six hours later.
99
- *
100
- * BOUNDED by the roster itself: a held body's clock is refreshed while the
101
- * server keeps offering its turn, and once offering stops — settled by the
102
- * re-POST, or expired server-side — the grace below is all that keeps it.
103
- */
104
- const agentReported = new Map(); // turnId -> { body, at }
105
- const agentRejectedUntil = new Map(); // turnId -> earliest re-POST of a refused body
106
- const AGENT_REPORT_GRACE_MS = 30 * 60_000;
107
-
108
- const postAgentTurn = async (body) => {
109
- const turnId = String(body.turnId);
110
- agentReported.set(turnId, { body, at: Date.now() });
111
- try {
112
- const res = await fetch(AGENT_TURN_DONE_URL, {
113
- method: 'POST',
114
- headers: {
115
- Authorization: `Bearer ${FLEET_TOKEN}`,
116
- 'User-Agent': USER_AGENT,
117
- 'Content-Type': 'application/json',
118
- },
119
- signal: AbortSignal.timeout(30_000),
120
- body: JSON.stringify(body),
121
- });
122
- const j = await res.json().catch(() => null);
123
- /**
124
- * ONLY A 2xx IS DELIVERED. The server answers an already-settled,
125
- * expired or unknown turn with 200 `{ settled: false }` — its only 4xx
126
- * are a body it could not parse (deploy skew) and auth, and an edge WAF
127
- * rule tripped by a summary that quotes an exploit string is a 403 from
128
- * in front of it. Each of those left the turn row PENDING, and dropping
129
- * the held body on them meant the next offer found nothing held and ran
130
- * the CLI again: a second set of commits and the operator's quota spent,
131
- * every poll, for six hours. The tab lane learned this as its 'reject'
132
- * class. So a refused body stays HELD — it is the skip-guard — and its
133
- * re-POST backs off instead of hammering a body the server just refused.
134
- */
135
- if (res.ok) {
136
- agentReported.delete(turnId);
137
- agentRejectedUntil.delete(turnId);
138
- } else if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
139
- agentRejectedUntil.set(turnId, Date.now() + REJECT_RETRY_MS);
140
- }
141
- return j?.data ?? null;
142
- } catch {
143
- // Unsettled — a network error, so the body STAYS held and the next
144
- // re-offer retries the POST rather than the CLI.
145
- return null;
146
- }
147
- };
148
-
149
- /**
150
- * ONE BATCH OF A TURN'S TRACE. See trace.mjs for the whole contract.
151
- *
152
- * Resolves TRUE for a permanent refusal as well as a success, and that is
153
- * deliberate: a server with no such route 404s every batch, and a relay that
154
- * held them would fill its buffer, shed the turn's real steps and retry the
155
- * same rejected body for the life of the turn. There is no version floor here
156
- * — this is a daemon→server report, so an older server simply never learns
157
- * the trace and the board renders what it always did.
158
- */
159
- const postAgentTrace = async (body) => {
160
- try {
161
- const res = await fetch(AGENT_TRACE_URL, {
162
- method: 'POST',
163
- headers: {
164
- Authorization: `Bearer ${FLEET_TOKEN}`,
165
- 'User-Agent': USER_AGENT,
166
- 'Content-Type': 'application/json',
167
- },
168
- signal: AbortSignal.timeout(15_000),
169
- body: JSON.stringify(body),
170
- });
171
- return (
172
- res.ok ||
173
- (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
174
- );
175
- } catch {
176
- return false; // a blip — the same entries go again at the same seq
177
- }
178
- };
179
-
180
- /** How long the final flush may hold the settle. Bounded because the settle
181
- * is the turn's contract and the trace is a readout: a wedged uplink costs
182
- * the tail of a trace, never the answer behind it. */
183
- const TRACE_FINAL_FLUSH_MS = 8_000;
184
-
185
- const postAgentActivity = async (agentId, text) => {
186
- try {
187
- await fetch(AGENT_ACTIVITY_URL, {
188
- method: 'POST',
189
- headers: {
190
- Authorization: `Bearer ${FLEET_TOKEN}`,
191
- 'User-Agent': USER_AGENT,
192
- 'Content-Type': 'application/json',
193
- },
194
- signal: AbortSignal.timeout(15_000),
195
- body: JSON.stringify({ agentId, text }),
196
- });
197
- } catch {
198
- /* narration is a readout; losing a line costs nothing */
199
- }
200
- };
201
-
202
- /** Every sha that landed on this branch between two points. Measured, never
203
- * asserted — this is the whole reason an agent is not asked for its own. */
204
- const commitsBetween = (wt, from) => {
205
- if (!from) return [];
206
- /**
207
- * `--not <base>` and `--no-merges`, because `from..HEAD` alone reports
208
- * BASE'S commits as this turn's receipts the moment anything folds base in
209
- * — which the stale path does on purpose before a merge. A receipt naming
210
- * somebody else's commit is worse than a missing one.
211
- */
212
- /**
213
- * IT CANNOT THROW, and that guard is the whole point of it being here.
214
- *
215
- * `git()` throws on a non-zero exit, and `baseRef()` is built from the
216
- * project's Base branch setting — free text an owner types, never verified
217
- * against the remote. Point it at a branch with no tracking ref (`develop`
218
- * on a repo whose remote branch is `dev`) and this exits "fatal: ambiguous
219
- * argument". The throw escaped `runAgentTurn` AFTER the CLI had already run
220
- * the card, so `postAgentTurn` was never reached, the server never settled
221
- * the turn, and the next poll handed back the identical turn — the same
222
- * card re-run every poll for six hours, on the operator's shared account,
223
- * piling commits onto the review branch, silently.
224
- *
225
- * An unreadable range means we cannot MEASURE the receipts, which is a
226
- * smaller failure than not settling: the turn still reports, and ship-time
227
- * reconciliation books whatever no card claimed. Missing beats fabricated
228
- * and both beat a stall.
229
- */
230
- let out;
231
- try {
232
- out = git(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()], wt);
233
- } catch {
234
- return [];
235
- }
236
- return typeof out === 'string'
237
- ? out.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50)
238
- : [];
239
- };
93
+ const { runAgentTurnInPlace, commitsBetween } = createAgentTurnExecution({
94
+ baseRef,
95
+ baseDir,
96
+ repoRoot,
97
+ fetchPublishedBranch,
98
+ placeWtFor,
99
+ sessionMetaPath,
100
+ beforeArtifacts,
101
+ getArtifactsAccepted,
102
+ noteSessionGroup,
103
+ reportSessionWorktree,
104
+ artifacts,
105
+ runReviewEntry,
106
+ workChildren,
107
+ agentChildren,
108
+ postAgentTrace,
109
+ postAgentActivity,
110
+ postAgentParked,
111
+ });
240
112
 
241
113
  /** `releaseSlot` hands back the admission reservation the caller took on this
242
114
  * turn's behalf, at the moment the CLI actually exists. Idempotent and
@@ -252,7 +124,7 @@ export function createWorkAgentTurns({
252
124
  /**
253
125
  * A TASK TURN WITH NO CARD IS REFUSED, NOT IMPROVISED.
254
126
  *
255
- * The kickoff below branches on `job.kind === 'task' && job.task` and falls
127
+ * The kickoff (workAgentTurnExecution.mjs) branches on `job.kind === 'task' && job.task` and falls
256
128
  * through to the HUMAN kickoff otherwise — and a task turn's body is empty
257
129
  * by design, because the daemon composes the prompt from the card's own
258
130
  * spec. So a card deleted while its agent held it spawned a real CLI on a
@@ -275,688 +147,45 @@ export function createWorkAgentTurns({
275
147
  return;
276
148
  }
277
149
 
150
+ /**
151
+ * A RUN THAT THROWS IS STILL SETTLED — ONCE (SOLID F036).
152
+ *
153
+ * The run posts its own settle on every path it knows about. `settled`
154
+ * records that one was SENT (delivered or held for re-POST, either way the
155
+ * turn is answered), so a throw after it — the review-entry beat, say —
156
+ * never posts a second, contradicting `nothing`. A throw before it is
157
+ * settled here, with what the run had measured by then: the commits on the
158
+ * branch, the spend, the branch and worktree. Through the same held-body
159
+ * wire, so a failed POST is re-sent on the next offer and the CLI is not
160
+ * run again.
161
+ */
162
+ const measured = { wt: null, before: null, branch: null, usage: null };
163
+ let settled = false;
164
+ const settleOnce = (body) => {
165
+ settled = true;
166
+ return postAgentTurn(body);
167
+ };
168
+
278
169
  // A WRITER on the agent's own place — see the lane header. Only `a-<id>`
279
170
  // places: those are agents' by construction, and anything else here would
280
171
  // be a tab's directory, where a turn is a reader by the product's own law.
281
172
  await inPlace(place, place.startsWith('a-'), async () => {
282
- /**
283
- * WORK THAT HAS BEGUN LIVES ON EXACTLY ONE BOX, AND THIS MAY NOT BE IT.
284
- *
285
- * A project has ONE machine credential and every device is handed the same
286
- * raw token, so two boxes can both be polling for the same agents. Nothing
287
- * pushes an agent's branch before approve, so a turn that has already run
288
- * somewhere has its worktree, its branch and its CONVERSATION on that box's
289
- * disk and nowhere else. `placeWtFor` cannot tell the difference: it finds
290
- * no directory, cuts a fresh `session/a-<id>` off base, and the CLI starts
291
- * with no memory of the card — a confident, context-free redo of work
292
- * somebody is in the middle of, on the operator's shared account, landing
293
- * on a rival branch of the same name.
294
- *
295
- * So: if the server says this agent has BEGUN and this box holds neither
296
- * its directory nor its branch, refuse before anything is cut. `nothing` is
297
- * the honest outcome — this machine did not run the turn — and the sentence
298
- * says what was measured (two absences here, and the box name only when the
299
- * server recorded one; inferring where the work is would be invention).
300
- *
301
- * THE BRANCH ALONE IS ENOUGH TO CONTINUE. `placeWtFor`'s attach fallback
302
- * re-attaches a worktree to a surviving branch, so a directory somebody
303
- * cleaned up on THIS box is same-box recovery of real committed work and
304
- * behaves exactly as it did before this guard existed.
305
- *
306
- * The remedy is the stop path and nothing else. "Reconnect the other
307
- * machine" is not reachable from here once holdership has moved, and a
308
- * remedy somebody cannot carry out is worse than none.
309
- */
310
- if (job.begun) {
311
- const wtDir = join(baseDir, 'sessions', place);
312
- let hasBranch = false;
313
- /**
314
- * THREE STATES, AND THE MIDDLE ONE IS WHY THIS IS NOT A BARE CATCH.
315
- *
316
- * `rev-parse --verify --quiet` exits 1 and prints nothing for a ref that
317
- * is not there — that exit code IS the measurement, and it is the one
318
- * this guard acts on. Any OTHER failure (128 for "not a repository",
319
- * ENOENT for no git at all, a momentary index lock) measured nothing;
320
- * collapsing it onto "the branch is absent" would make the daemon assert
321
- * "this machine does not hold this agent's branch" off a repo it could
322
- * not read — the guard inventing the very fact it exists to relay.
323
- *
324
- * So an unmeasured branch stands the guard DOWN. That re-enters the path
325
- * this guard is a belt for, which is the fail-open direction it wants;
326
- * `placeWtFor` is about to fail on the same unreadable repo and say so
327
- * in its own words, which is the honest sentence.
328
- */
329
- let branchMeasured = true;
330
- try {
331
- hasBranch = Boolean(
332
- git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot)
333
- );
334
- } catch (e) {
335
- if (e?.status === 1) hasBranch = false;
336
- else branchMeasured = false;
337
- }
338
- if (branchMeasured && !existsSync(wtDir) && !hasBranch) {
339
- /**
340
- * …UNLESS THE WORK IS ON THE REMOTE (0.86.0).
341
- *
342
- * The guard's premise was "nothing pushes an agent's branch before
343
- * approve", and a project that publishes has changed exactly that
344
- * third of it: the COMMITS are on `origin` under `flowviant/`, so a
345
- * box that holds neither the directory nor the branch can fetch them
346
- * and continue the work instead of redoing it. The other two thirds
347
- * are untouched — the conversation still does not move, which is why
348
- * the kickoff re-prompts from the card either way.
349
- *
350
- * The refusal below is still the fallback, and it is the fallback for
351
- * BOTH shapes of failure: a project that does not publish (no ref,
352
- * sentence unchanged) and a fetch that could not land (sentence
353
- * extended with git's own reason, because "this machine does not hold
354
- * it" alone would hide that we tried and how it went).
355
- */
356
- const fetched = fetchPublishedBranch(place, job.publishedRef);
357
- if (!fetched?.ok) {
358
- const on = typeof job.begunOn === 'string' && job.begunOn.trim()
359
- ? job.begunOn.trim().slice(0, 64)
360
- : null;
361
- await postAgentTurn({
362
- turnId,
363
- outcome: 'nothing',
364
- answer:
365
- `This machine does not hold this agent's worktree or branch${on ? ` — its work is on ${on}` : ''}. ` +
366
- 'Stop the agent to re-plan it here.' +
367
- (fetched ? ` Its published branch could not be fetched (${fetched.why}).` : ''),
368
- });
369
- return;
370
- }
371
- // The branch is here and measured. `placeWtFor`'s attach fallback
372
- // opens a worktree on it below — the same path a directory somebody
373
- // cleaned up already takes, on commits this box now genuinely holds.
374
- }
375
- }
376
- const dir = placeWtFor(place);
377
- if (!dir) {
378
- // No worktree and none could be cut. `nothing`, WITH git's own words:
379
- // without them the agent sat in Stuck with no question and no reason
380
- // (the owner, 2026-09-25: "it got stuck without saying why").
381
- const why = placeWtFor.lastError ?? null;
382
- await postAgentTurn({ turnId, outcome: 'nothing', ...(why ? { answer: `This machine could not start the turn: ${why}` } : {}) });
383
- return;
384
- }
385
- const wt = dir.wt;
386
- /**
387
- * NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
388
- * of these exit non-zero in states a real worktree reaches: `symbolic-ref`
389
- * on a DETACHED HEAD (an agent that ran `git checkout <sha>`, a rebase
390
- * left half-done), and `rev-parse HEAD` on a branch with no commits yet.
391
- * An escape here happens BEFORE any settle, so the turn stays pending
392
- * until the six-hour expiry while the agent sits in Working with nothing
393
- * behind it — the wedge this whole lane exists to avoid.
394
- *
395
- * `runAgentMerge` learned this two commits ago and says so in its own
396
- * comment; this is the same call, in the same file, left unguarded. Both
397
- * facts are OPTIONAL to a turn — they describe the branch, they do not
398
- * gate the work — so failing to read them costs a null, not the turn.
399
- */
400
- const readGit = (args) => {
401
- try {
402
- return (git(args, wt) || '').trim() || null;
403
- } catch {
404
- return null;
405
- }
406
- };
407
- // WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
408
- // ones this turn actually made.
409
- const before = readGit(['rev-parse', 'HEAD']);
410
- const branch = readGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
411
-
412
- const rt = job.runtime || 'claude';
413
- /**
414
- * WHAT THIS CARD HANDS BACK, AND THE POSTURE THAT FOLLOWS (0.97.0).
415
- *
416
- * `code` (the default — absent on the job IS code) is the build turn,
417
- * byte-for-byte what every agent turn was. `design` and `research` run
418
- * their own contract (SYSTEM_AGENT_DESIGN / _RESEARCH) under their own
419
- * posture, which can write ONLY `.flowviant/artifacts/` (claude.mjs).
420
- *
421
- * A RUNTIME THAT CANNOT EXPRESS THE POSTURE IS REFUSED, NOT IMPROVISED.
422
- * Codex and antigravity declare neither (runtimes.mjs), and the only
423
- * alternative to refusing is running the card as a BUILD turn with
424
- * permissions skipped — an agent writing code for an ask that was a
425
- * mockup, reporting success. `nothing` puts the agent in Stuck saying so.
426
- *
427
- * THE JOB'S OWN KIND FIRST (2026-09-23). A `human` turn — a send-back
428
- * from the review deck, an answer — usually names NO card: the queue has
429
- * emptied, or the card it is about was just re-queued behind it. Read off
430
- * `job.task` alone, a send-back on a design agent ran as a BUILD turn
431
- * under the code contract with permissions skipped — the one turn in the
432
- * owner's loop ("iterate in the review column, the agent opened") that
433
- * could edit code on an ask that was a mockup. So the server projects the
434
- * kind of the card the turn is ABOUT onto the job itself (`taskKind`,
435
- * sent only for design and research; the same `DAEMON_TASK_KIND_MIN`
436
- * floor as the card's own key, no new one), and the card's key stays the
437
- * fallback for a server that sends only that. Absent on both is code.
438
- */
439
- /**
440
- * A KIND THIS DAEMON DOES NOT KNOW IS REFUSED, NEVER BUILT (2026-09-23).
441
- * `agentTaskKindOf` reads anything unrecognised as code — right for a
442
- * printed label, wrong here, where code means the build posture with
443
- * permissions skipped. A server that learned a fourth kind ahead of
444
- * this daemon is exactly the case the version floor cannot cover (the
445
- * handout does not re-check it), and the answer is an update, said in
446
- * the word the server used. `nothing` puts the agent in Stuck with it.
447
- */
448
- const strangeKind = unknownAgentTaskKind(job.taskKind ?? job.task?.taskKind);
449
- if (strangeKind !== null) {
450
- await postAgentTurn({
451
- turnId,
452
- outcome: 'nothing',
453
- answer: `this daemon does not know the card kind '${strangeKind}' — update it`,
454
- branch,
455
- worktree: wt,
456
- });
457
- return;
458
- }
459
- const taskKind = agentTaskKindOf(job.taskKind ?? job.task?.taskKind);
460
- const posture = taskKind === 'code' ? 'build' : taskKind;
461
- if (!canRun(RUNTIMES[rt], posture)) {
462
- await postAgentTurn({
463
- turnId,
464
- outcome: 'nothing',
465
- answer:
466
- posture === 'build'
467
- ? `this machine cannot run ${rt}`
468
- : 'design and research cards run on Claude on this machine',
469
- branch,
470
- worktree: wt,
471
- });
472
- return;
473
- }
474
-
475
- // ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
476
- // right here — the ambiguity that forced per-tab session pinning in the
477
- // Workbench (many tabs, one place) cannot arise. The marker is what
478
- // distinguishes the first turn from every later one across restarts.
479
- //
480
- // IT MEANS "CLAUDE HAS SPOKEN HERE", and since 2026-09-24 only a Claude
481
- // turn writes it: an agent can be switched between CLIs, and a marker a
482
- // codex turn left behind would send the first Claude turn to
483
- // `--continue` a directory holding no Claude conversation at all.
484
- const ranMarker = sessionMetaPath(wt, 'flowviant-agent-ran');
485
- /**
486
- * CODEX RESUMES BY ITS OWN THREAD ID, PINNED PER AGENT (2026-09-24).
487
- *
488
- * Resume was Claude-only, because codex's `resume --last` is
489
- * MACHINE-GLOBAL and would cross-resume whichever conversation spoke
490
- * last anywhere on the box. The cost was that every codex card started
491
- * a stranger: no memory of the card before it, and — worse — an answer
492
- * to the agent's own question arrived at a codex that never asked it.
493
- * The Workbench solved the same problem in 0.69.0 by pinning the id
494
- * `thread.started` announces; this is that, keyed by the AGENT rather
495
- * than a tab. The id is shape-checked before it rides argv.
496
- */
497
- const codexThreadMarker =
498
- rt === 'codex' ? sessionMetaPath(wt, 'flowviant-agent-codex-thread', agentId) : null;
499
- let codexResumeId = null;
500
- if (codexThreadMarker && existsSync(codexThreadMarker)) {
501
- try {
502
- const v = readFileSync(codexThreadMarker, 'utf8').trim();
503
- if (CODEX_THREAD_RE.test(v)) codexResumeId = v;
504
- } catch {
505
- /* unreadable marker — run fresh */
506
- }
507
- }
508
- let seenThreadId = null;
509
- const resume =
510
- rt === 'codex'
511
- ? Boolean(codexResumeId)
512
- : rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
513
-
514
- /**
515
- * WRITE THE CARD DOWN BEFORE HANDING IT OVER — the material the AI
516
- * pre-review reads at review entry (see agentCards.mjs).
517
- *
518
- * HERE rather than at review entry because here is the ONLY moment this
519
- * machine holds the card at all: the server feeds an agent one card per
520
- * prompt and keeps no copy on this disk, so by the time the queue empties
521
- * card one exists locally as nothing but its own commit messages — which
522
- * are a claim about the work, not the specification it was judged against.
523
- *
524
- * BEFORE the CLI runs rather than after, so a turn that crashes still
525
- * leaves the spec behind: the card really was given to the agent, the
526
- * commits it made are on the branch, and a reviewer is entitled to read
527
- * what was asked for either way.
528
- *
529
- * ONE SPEC BUILDER (`AGENT_TASK_SPEC`) shared with the kickoff below, so
530
- * what the reviewer reads is byte-identical to what the agent read.
531
- * Failure is swallowed inside `stashCard`: a note about a turn may never
532
- * cost the turn.
533
- */
534
- if (job.kind === 'task' && job.task) {
535
- stashCard(
536
- sessionMetaPath(wt, 'flowviant-agent-cards', agentId),
537
- job.task.id,
538
- AGENT_TASK_SPEC(job.task)
539
- );
540
- }
541
-
542
- /**
543
- * THE WHOLE STREAM, not just its latest line — see trace.mjs.
544
- *
545
- * The pulse below is untouched and still sent: it carries staleness (how
546
- * long the machine has been quiet), which an append-only list of steps
547
- * cannot say, because a list that stopped growing looks exactly like a
548
- * list that is finished.
549
- */
550
- const trace = makeTraceRelay({
551
- agentId,
552
- turnId,
553
- post: postAgentTrace,
554
- scrub: envScrub,
555
- });
556
- /**
557
- * Prose the structured event will carry anyway, dropped so a read does
558
- * not render twice — but ONLY on a runtime whose stream reaches
559
- * `onToolEvent` at all. Codex and agy have their own parsers and never
560
- * call it, so dropping their tool prose would blank their agents' traces.
561
- * `parse: null` is exactly the claude.mjs stream path. See
562
- * CLAUDE_TOOL_PROSE_KINDS.
563
- */
564
- const doubledKinds = RUNTIMES[rt]?.parse ? null : CLAUDE_TOOL_PROSE_KINDS;
565
-
566
- /**
567
- * WHICH BRAIN THIS CONTAINER WAS PINNED TO — the same `brainFor` the tab
568
- * lane runs, on the same two job keys, because an agent turn and a
569
- * session turn differ in who is watching and in nothing else that a model
570
- * name touches. Every guard lives in `brainFor`: a second copy here would
571
- * be a second answer to "is this a model we can spell", and the two would
572
- * drift the first time one of them learned a new effort.
573
- *
574
- * Absent stays genuinely absent — an agent nobody pinned produces the
575
- * byte-identical argv it produced yesterday, on the machine's own
576
- * default. That is also what an OLDER server yields, since it sends
577
- * neither key.
578
- */
579
- const brain = brainFor(job);
580
-
581
- let out = '';
582
- let child = null;
583
- /**
584
- * WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
585
- *
586
- * Held across the whole turn so every settle below can carry it — a turn
587
- * that hit a limit, produced no parseable outcome or delivered properly
588
- * all spent real tokens, and a readout that only charged the happy path
589
- * would under-report the runs somebody opens the number to understand.
590
- *
591
- * The three settles it is spread into are the ones that follow a CLI
592
- * actually running. The pre-spawn refusals above (a bad place, a missing
593
- * card, the begun-guard) and the teardown sweep below deliberately do NOT
594
- * take it: nothing ran there, and `usage` stays null anyway, which is
595
- * what makes `...(usage ? … : {})` the whole guard.
596
- */
597
- let usage = null;
598
- /**
599
- * THE AGENT'S LAST MESSAGE, ALONE — where the final JSON object lives.
600
- * Codex's `out` is every message plus stderr plus error text, and
601
- * `parseTurnResult` takes the first object that fits, so an early
602
- * message quoting the format could be read as the outcome. Claude never
603
- * calls this (its `out` is already the result under `answerFromResult`).
604
- */
605
- let lastAnswer = null;
606
- /** The agent's artifact directory as it stood before this turn — the
607
- * tab lane's rule, in the agent's own worktree (2026-09-22). */
608
- const artifactsBefore = beforeArtifacts(wt);
609
- let projectTools = null;
610
173
  try {
611
- // A turn reads the base ref afresh. Changes the agent has made to its
612
- // own worktree cannot silently change the next turn's tools.
613
- projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt);
614
- const agentTurnArgs = {
615
- prompt:
616
- job.kind === 'task' && job.task
617
- ? AGENT_TASK_KICKOFF({
618
- agentName: job.agentName,
619
- task: job.task,
620
- position: job.position ?? 1,
621
- total: job.total ?? 1,
622
- })
623
- : AGENT_HUMAN_KICKOFF({
624
- agentName: job.agentName,
625
- message: job.body ?? '',
626
- askedByName: job.askedByName,
627
- task: job.task,
628
- position: job.position ?? 1,
629
- total: job.total ?? 1,
630
- }),
631
- // The knowledge paragraph, when this box holds a library — the same
632
- // composer the tabs use, so an agent and a tab can never be told two
633
- // different things about the same directory.
634
- system: withProjectContext(SYSTEM_AGENT_FOR(taskKind), {
635
- knowledgeDir: knowledgeDirFor(repoRoot),
636
- // ARTIFACTS (0.94.0), while the server can show one — an agent's
637
- // land on its page, under the facts row.
638
- artifacts: getArtifactsAccepted(),
639
- }) + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
640
- agentTools: projectTools,
641
- ...(projectTools.codexHome ? { mcpEnv: { CODEX_HOME: projectTools.codexHome } } : {}),
642
- ...(posture === 'build' && rt === 'claude'
643
- ? { mcpArgs: ['--strict-mcp-config', '--mcp-config', projectTools.mcpPath] }
644
- : posture === 'build' && rt === 'codex'
645
- ? { mcpArgs: projectTools.codexArgs }
646
- : {}),
647
- knowledgeDir: knowledgeDirFor(repoRoot),
648
- // Named only for the two non-code kinds; a code turn passes nothing
649
- // and keeps the build branch it always had.
650
- ...(posture !== 'build' ? { posture } : {}),
651
- cwd: wt,
652
- runtime: rt,
653
- resume,
654
- resumeThreadId: codexResumeId || undefined,
655
- onThreadId: (id) => {
656
- seenThreadId = String(id ?? '').trim() || seenThreadId;
657
- },
658
- onAnswer: (t) => {
659
- lastAnswer = t;
660
- },
661
- // Present only when the container named one — see brainFor.
662
- ...brain,
663
- streamJson: true,
664
- answerFromResult: true,
665
- label: c.cyan('[agent]'),
666
- // The CLI's own tail, relayed. Throttled by the same rule the tab's
667
- // narrator keeps: overwritten, never appended, and ignored by the
668
- // board past ~90 seconds.
669
- onActivity: (a) => {
670
- const line = a?.label;
671
- if (!line) return;
672
- // THE TRACE TAKES EVERY LINE; the pulse takes one every two
673
- // seconds. Two channels, one stream, and the drop-sampler stays a
674
- // drop-sampler — buffering the pulse would make a stale line look
675
- // fresh, which is the one thing it exists to answer.
676
- //
677
- // …AND THE TRACE TAKES THE WHOLE LINE. `label` is a 160-char
678
- // console readout; `full` is what the CLI actually said, when the
679
- // parser had more than the label could hold (see runtimes.mjs).
680
- // The fallback is not a degradation — it is what every activity
681
- // without a fuller form carries, and what an entire pre-0.87.0
682
- // daemon carried for all of them. The PULSE below is deliberately
683
- // still `line`: it is one overwritten line on a board, and a
684
- // paragraph there would be a paragraph nobody can read.
685
- if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, a.full ?? line);
686
- const now = Date.now();
687
- if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
688
- lastAgentBeat.set(agentId, now);
689
- void postAgentActivity(agentId, envScrub(String(line)).slice(0, 400));
690
- },
691
- // The structured twin of the line above — the same `tool_use` the
692
- // Workbench's tool cards are built from, scrubbed at collection by
693
- // the builder itself (bounded window BEFORE its caps; see
694
- // toolEventOf). A tool it does not know pushes nothing.
695
- onToolEvent: (name, input) => {
696
- trace.tool(toolEventOf(name, input, wt, envScrub));
697
- },
698
- // The CLI's own per-turn token counts, off the `result` event. One
699
- // result per turn, so this is a set and not an accumulate — the
700
- // adding-up happens SERVER-side, behind the settle's idempotent win,
701
- // because a retried settle must not charge the same turn twice.
702
- // …tagged with WHICH CLI counted it (2026-09-24), so the server can
703
- // split an agent's spend per CLI rather than labelling a codex
704
- // agent's total with whatever the pre-review spent.
705
- onUsage: (u) => {
706
- usage = { ...u, runtime: rt };
707
- },
708
- // WHAT THE CLI SAYS IT CAN BE ASKED FOR, AND WHICH CONNECTORS NEED A
709
- // LOGIN — the same free fact off the same init event the tab lane
710
- // records (2026-09-23). Without it a box that only ever runs agents
711
- // taught the app its skills and connectors ONCE, from the startup
712
- // probe, and never again: a connector signed into at the box kept
713
- // reading "needs authorization" for the life of the process.
714
- onInit: (i) => {
715
- recordSkills(i.skills);
716
- recordMcpServers(i.mcpServers);
717
- },
718
- onSpawn: (ch) => {
719
- child = ch;
720
- // The AGENT it serves — what the machine snapshot charges this
721
- // child's memory to.
722
- workChildren.set(ch, agentId);
723
- // …and the registry now counts what the reservation was standing
724
- // in for.
725
- releaseSlot();
726
- // Under the PLACE, the id every reader asks with — the worktree
727
- // sweep and `killTargetOk` both key on `a-<agentId>`. Recorded under
728
- // the bare agent id it was never read, reported `processes: []`
729
- // over a running watcher, and left one dead entry per turn.
730
- noteSessionGroup(place, ch.pid);
731
- // Keyed by PLACE, because the retire sweep iterates directory names
732
- // and a place id IS one. It is what lets a hard stop actually reach
733
- // the CLI — see `retireWorkSessions`.
734
- agentChildren.set(place, ch);
735
- },
736
- };
737
- out = await runTurn(agentTurnArgs);
738
- /**
739
- * A RESUME THAT CAME BACK EMPTY, OR WITH ONLY THE CLI SAYING THE
740
- * CONVERSATION IS GONE, RUNS ONCE MORE FRESH — the tab lane's rule,
741
- * for the tab lane's reason: a pinned id the CLI has since pruned
742
- * (or a `--continue` over a directory Claude never spoke in, which an
743
- * agent switched between CLIs can reach) would otherwise settle
744
- * `nothing` on every turn forever. Same worktree, never a reset.
745
- */
746
- if (resume && (!(out || '').trim() || resumeConversationLost(out))) {
747
- seenThreadId = null;
748
- lastAnswer = null;
749
- out = await runTurn({ ...agentTurnArgs, resume: false, resumeThreadId: undefined });
750
- }
751
- } finally {
752
- projectTools?.cleanup();
753
- /**
754
- * THE TAIL, BEFORE THE SETTLE — so the last thing the agent did is on
755
- * the record by the time the board is told the turn is over.
756
- *
757
- * The server deliberately does NOT require a pending turn to accept a
758
- * trace batch (a late tail is still that turn's record), so a race here
759
- * is survivable rather than lossy; flushing first simply means it
760
- * almost never happens. Bounded, and the settle is what matters: an
761
- * uplink that will not answer costs the tail and nothing else.
762
- */
763
- trace.stop();
764
- await trace.flush(TRACE_FINAL_FLUSH_MS);
765
- if (child) workChildren.delete(child);
766
- if (agentChildren.get(place) === child) agentChildren.delete(place);
767
- if (ranMarker && rt === 'claude') {
768
- try {
769
- writeFileSync(ranMarker, '1');
770
- } catch {
771
- /* a missing marker only costs one un-resumed turn */
772
- }
773
- }
774
- // The thread THIS turn spoke under, pinned so the next card, answer
775
- // or merge-resolve resumes it. After the turn, like the tab lane: an
776
- // id learned mid-turn is only true once the turn that learned it ends.
777
- if (codexThreadMarker && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
778
- try {
779
- writeFileSync(codexThreadMarker, seenThreadId);
780
- } catch {
781
- /* best-effort — the next turn runs fresh */
782
- }
783
- }
784
- /**
785
- * AN ACTION THAT CHANGES WHAT THE MACHINE WOULD MEASURE MUST CAUSE A
786
- * NEW MEASUREMENT — the rule every session settle keeps, and the one
787
- * lane that did not. An agent's branch diff, head sha and trailered
788
- * commits only refreshed on the ≤60s sweep, so review opened right
789
- * after a turn described the branch as it was BEFORE the work.
790
- *
791
- * Here rather than after the settle POST because the CLI has exited,
792
- * so the tree is final — and because a queue that just emptied runs
793
- * the project's check next, which may hold this function for ten
794
- * minutes. Fire-and-forget on an endpoint that already exists, so no
795
- * floor: it must never delay the settle behind it.
796
- */
797
- void reportSessionWorktree(place).catch(() => {});
798
- // …and what it drew to SHOW the person (2026-09-22): uploaded against
799
- // the AGENT, so it lands on the agent's page whoever is looking.
800
- // Fire-and-forget for the same reason — never delay the settle.
801
- void artifacts
802
- .report({ placeDir: wt, before: artifactsBefore, agentId, turnId })
803
- .catch(() => {});
804
- }
805
-
806
- const commits = commitsBetween(wt, before);
807
- const res = (lastAnswer && parseTurnResult(lastAnswer)) || parseTurnResult(out);
808
- /**
809
- * A LIMIT IS ONLY A LIMIT WHEN THE TURN PRODUCED NOTHING.
810
- *
811
- * `limitLine` is a literal phrase match over the CLI's output, and the
812
- * output of a successful turn contains whatever the agent wrote — so a
813
- * card about rate limiting, or a summary mentioning one, parked every
814
- * agent on the project. Gating on "the turn declared no outcome" is what
815
- * makes the match mean what it says: the CLI failed and this is the
816
- * sentence it failed with.
817
- */
818
- const limit = res ? null : limitLine(out);
819
- if (res) setRuntimeLimit(PROJECT_ID, rt, null);
820
- if (limit) {
821
- setRuntimeLimit(PROJECT_ID, rt, limit);
822
- // Every agent ON THIS CLI parks, because that account is shared: one
823
- // hitting the limit means all of them have. Named since 2026-09-24 —
824
- // a machine can hold a Claude login and a Codex login, and a Codex
825
- // limit is no reason to stop the Claude agents. The turn itself is
826
- // reported as `nothing` — it did not deliver and it did not ask.
827
- await postAgentParked(limit, rt);
828
- await postAgentTurn({
829
- turnId,
830
- outcome: 'nothing',
831
- answer: limit,
832
- ...(usage ? { usage } : {}),
833
- branch,
834
- worktree: wt,
835
- });
836
- return;
837
- }
838
-
839
- if (!res) {
840
- await postAgentTurn({
841
- turnId,
842
- outcome: 'nothing',
843
- // The CLI's own words when it produced any. A turn that explained why
844
- // it stopped is far more use than "the agent stopped".
845
- answer: out.trim()
846
- ? envScrub(out).slice(-1500)
847
- : 'the turn produced no output on the machine — its CLI may be signed out',
848
- ...(commits.length ? { commits } : {}),
849
- ...(usage ? { usage } : {}),
850
- branch,
851
- worktree: wt,
852
- });
853
- return;
854
- }
855
- /**
856
- * A DESIGN OR RESEARCH CARD IS NOT DELIVERED UNTIL ITS ARTIFACT EXISTS
857
- * (0.97.0) — measured, never taken on the agent's word.
858
- *
859
- * Its whole product is one file under `.flowviant/artifacts/`: a `.html`
860
- * mockup for design, a `.md` write-up for research. A turn that says
861
- * "delivered" without having written one would land the agent in Review
862
- * with nothing to look at and a summary describing a page that does not
863
- * exist — so it settles `nothing` with the measured sentence, and the
864
- * agent lands in Stuck with a true reason instead.
865
- *
866
- * WHAT COUNTS: on a TASK turn, only what the scan found NEW OR CHANGED
867
- * this turn (`changedArtifacts` against the snapshot taken before the
868
- * spawn) — a second design card in the same agent must not pass on the
869
- * first card's page. On a HUMAN turn (an answer, a send-back) a matching
870
- * file already standing in the directory also counts: "keep it as it
871
- * is" is a legitimate answer to a question the agent asked after
872
- * drawing, and the turn did not have to rewrite the page to deliver it.
873
- *
874
- * …AND ON A REDO (2026-09-23): a TASK turn re-running a card this agent
875
- * already delivered, which is what a send-back's "Needs work" queues —
876
- * the human turn in front of it usually rewrote the mockup already, so
877
- * the task turn that follows truthfully says "revised last turn" with
878
- * nothing new to write, and the task rule sent it to Stuck with a false
879
- * sentence. The server marks such a job `redo: true` (a link delivered
880
- * before, or carrying a `needs_work` verdict), and then a standing match
881
- * counts, as it does for a human turn. No floor: an older server never
882
- * sends the key, and absent keeps the stricter task rule.
883
- *
884
- * Commits are still reported if any exist — the posture prevents them,
885
- * and a report never lies by omission about what is on the branch.
886
- */
887
- if (res.outcome === 'delivered' && taskKind !== 'code') {
888
- const want = taskKind === 'design' ? /\.html?$/i : /\.md$/i;
889
- const standing = scanArtifacts(wt);
890
- const wrote = changedArtifacts(artifactsBefore, standing).some((e) => want.test(e.name));
891
- const present =
892
- wrote || ((job.kind !== 'task' || job.redo === true) && standing.some((e) => want.test(e.name)));
893
- if (!present) {
894
- await postAgentTurn({
174
+ await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured });
175
+ } catch (error) {
176
+ if (settled) return;
177
+ const commits = measured.wt ? commitsBetween(measured.wt, measured.before) : [];
178
+ await postAgentTurn(
179
+ failedTurnSettlement({
895
180
  turnId,
896
- outcome: 'nothing',
897
- answer:
898
- taskKind === 'design'
899
- ? 'the turn ended without writing a mockup under .flowviant/artifacts/'
900
- : 'the turn ended without writing a write-up under .flowviant/artifacts/',
901
- ...(commits.length ? { commits } : {}),
902
- ...(usage ? { usage } : {}),
903
- branch,
904
- worktree: wt,
905
- });
906
- return;
907
- }
181
+ error,
182
+ commits,
183
+ usage: measured.usage,
184
+ branch: measured.branch,
185
+ wt: measured.wt,
186
+ })
187
+ );
908
188
  }
909
- const reply = await postAgentTurn({
910
- turnId,
911
- outcome: res.outcome,
912
- answer: envScrub(res.answer ?? '').slice(0, 8000),
913
- ...(commits.length ? { commits } : {}),
914
- // SCRUBBED like the answer beside it: a raised card lands in the
915
- // project doc every member reads, and a brief quoting the `.env` the
916
- // agent just read ("value sk_live_… is logged in pay.ts") went out
917
- // verbatim while the same value in the summary was redacted.
918
- ...(res.raised?.length
919
- ? {
920
- raised: res.raised.map((r) => ({
921
- title: envScrub(r.title).slice(0, 300),
922
- ...(r.brief ? { brief: envScrub(r.brief).slice(0, 2000) } : {}),
923
- })),
924
- }
925
- : {}),
926
- ...(usage ? { usage } : {}),
927
- /**
928
- * THE AGENT'S RUNNING ACCOUNT OF THIS BRANCH (2026-09-22).
929
- *
930
- * The owner: "we should add a brief summary of what the agent has done
931
- * overall at the top that updates." A RELAY — the agent wrote it in its
932
- * own final JSON object and nothing here composes, narrows or infers
933
- * one. It rides only this settle, which is the one that follows a
934
- * PARSED result: the two `nothing` settles above it come from a turn
935
- * that declared no outcome at all, so there is no account to carry and
936
- * inventing one would be the machine speaking for the agent.
937
- *
938
- * SCRUBBED BEFORE IT IS CUT, the order that matters and the one the
939
- * check's output lane learned the expensive way: `envScrub` replaces
940
- * EXACT values, so a paragraph capped first hands the scrub a
941
- * credential already cut in half — it matches nothing and the surviving
942
- * prefix ships. `parseTurnResult` trims it and bounds it for absurdity
943
- * at 8000, DELIBERATELY above the 1000 below, so this slice is the
944
- * first one a value of any interest meets and the scrub has already
945
- * run when it does. The first cut of this feature bounded the parser at
946
- * 1000 as well, which read identically and quietly put the cap first.
947
- *
948
- * OMITTED WHEN THE TURN DID NOT WRITE ONE, never sent empty. Absence is
949
- * the server's signal to KEEP the last account that was true; an empty
950
- * string would blank the head because a model dropped a key.
951
- */
952
- ...(res.progress ? { progress: envScrub(res.progress).slice(0, 1000) } : {}),
953
- branch,
954
- worktree: wt,
955
- });
956
- // The queue just emptied. Run the project's own check and the AI
957
- // pre-review HERE, in the worktree we are already standing in and still
958
- // hold the lock on — see `runReviewEntry`.
959
- if (reply?.review === true) await runReviewEntry(agentId, wt, job.agentName);
960
189
  });
961
190
  /**
962
191
  * …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
@@ -1002,45 +231,9 @@ export function createWorkAgentTurns({
1002
231
  }
1003
232
  };
1004
233
 
1005
- const lastAgentBeat = new Map(); // agentId -> last activity POST, ms
1006
-
1007
- const postAgentParked = async (reason, runtime) => {
1008
- try {
1009
- await fetch(AGENT_PARKED_URL, {
1010
- method: 'POST',
1011
- headers: {
1012
- Authorization: `Bearer ${FLEET_TOKEN}`,
1013
- 'User-Agent': USER_AGENT,
1014
- 'Content-Type': 'application/json',
1015
- },
1016
- signal: AbortSignal.timeout(15_000),
1017
- // `runtime` scopes the park to that CLI's agents; a server that does
1018
- // not know the key parks every agent, which is what it always did.
1019
- body: JSON.stringify({ reason, ...(runtime ? { runtime } : {}) }),
1020
- });
1021
- } catch {
1022
- /* the next turn will hit the same limit and try again */
1023
- }
1024
- };
1025
-
1026
234
  const processAgentTurnJobs = (jobs) => {
1027
235
  const list = Array.isArray(jobs) ? jobs : [];
1028
- if (agentReported.size) {
1029
- // The roster is the held bodies' clock: an offered turn is still pending
1030
- // server-side and worth retrying; one the roster stopped naming was
1031
- // settled or expired, and holding its body past a generous grace would
1032
- // grow this map for the life of the process. The server's own expiry is
1033
- // the true bound — the grace only covers its POST racing a final offer.
1034
- const offered = new Set(list.map((j) => String(j?.id || '')));
1035
- const now = Date.now();
1036
- for (const [id, held] of agentReported) {
1037
- if (offered.has(id)) held.at = now;
1038
- else if (now - held.at > AGENT_REPORT_GRACE_MS) {
1039
- agentReported.delete(id);
1040
- agentRejectedUntil.delete(id);
1041
- }
1042
- }
1043
- }
236
+ pruneHeldReports(list);
1044
237
  /** One deferral line per tick, however many turns were offered. */
1045
238
  let saidPressure = false;
1046
239
  for (const job of list.slice(0, 4)) {