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