flowviant 0.116.0 → 0.124.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -0,0 +1,79 @@
1
+ /**
2
+ * AN IDLE AGENT'S DELIVERY, RECONCILED WITHOUT A TURN (2026-10-11).
3
+ *
4
+ * A local discard reached the server only when a turn settled, so an agent
5
+ * waiting in Review kept every stored file a daemon had not relayed removing:
6
+ * the marketing imagery card showed nine images while its worktree held the
7
+ * two the person kept, and nothing would change until somebody sent another
8
+ * message. So each agent worktree this machine holds is measured against its
9
+ * stored inventory once per process, under the place's writer lock and never
10
+ * beside a turn — the reporter's own evidence rule (artifactIteration.mjs)
11
+ * decides what is a discard. Only agents: a tab's next turn is the natural
12
+ * moment to reconcile a conversation, and a delivery in Review has none.
13
+ *
14
+ * A failure is said in the log and tried again on a later beat, a bounded
15
+ * number of times; the stored files stay listed, which is the truth.
16
+ */
17
+ import { existsSync } from 'node:fs';
18
+
19
+ /** Attempts per agent per process, and the wait between two of them. */
20
+ const MAX_TRIES = 4;
21
+ const RETRY_MS = 60_000;
22
+
23
+ export function createWorkArtifactReconcile({
24
+ artifacts,
25
+ placeLocks,
26
+ inPlace,
27
+ placeDir,
28
+ beforeArtifacts,
29
+ getArtifactsAccepted,
30
+ log = () => {},
31
+ now = Date.now,
32
+ }) {
33
+ const done = new Set(); // agent places reconciled by this process
34
+ const tries = new Map(); // agent place -> { n, at }
35
+ let sweeping = null;
36
+
37
+ const reconcileOne = async (place) => {
38
+ const wt = placeDir(place);
39
+ if (!wt || !existsSync(wt)) return;
40
+ const agentId = place.slice(2);
41
+ try {
42
+ await inPlace(place, true, () => artifacts.reconcile({ placeDir: wt, before: beforeArtifacts(wt), agentId }));
43
+ done.add(place);
44
+ tries.delete(place);
45
+ } catch (error) {
46
+ const n = (tries.get(place)?.n ?? 0) + 1;
47
+ tries.set(place, { n, at: now() });
48
+ log(`agent ${agentId}: artifact cleanup incomplete — ${error?.message ?? error}` +
49
+ (n >= MAX_TRIES ? ' (not retried again until the daemon restarts)' : ''));
50
+ }
51
+ };
52
+
53
+ /** The roster's live places; agent ones are `a-<agentId>`. Never awaited by the poll. */
54
+ const reconcileIdleArtifacts = (activeIds) => {
55
+ if (!Array.isArray(activeIds) || !getArtifactsAccepted()) return null;
56
+ if (sweeping) return sweeping;
57
+ const live = new Set(activeIds);
58
+ for (const id of done) if (!live.has(id)) done.delete(id);
59
+ for (const id of tries.keys()) if (!live.has(id)) tries.delete(id);
60
+ const due = activeIds.filter((place) => {
61
+ if (typeof place !== 'string' || !place.startsWith('a-') || place.length <= 2) return false;
62
+ if (done.has(place) || placeLocks.has(place)) return false; // a turn or a merge is there
63
+ const t = tries.get(place);
64
+ return !t || (t.n < MAX_TRIES && now() - t.at >= RETRY_MS);
65
+ });
66
+ if (due.length === 0) return null;
67
+ // One place at a time: each is an inventory read and its removals.
68
+ sweeping = (async () => {
69
+ try {
70
+ for (const place of due) await reconcileOne(place);
71
+ } finally {
72
+ sweeping = null;
73
+ }
74
+ })();
75
+ return sweeping;
76
+ };
77
+
78
+ return { reconcileIdleArtifacts };
79
+ }
@@ -9,8 +9,9 @@
9
9
  * serves both, so the two lanes cannot disagree about what a safe name is.
10
10
  * The name rule itself is safeFileName.mjs's.
11
11
  */
12
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
14
15
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
15
16
  import { excludeInWorktree } from './git.mjs';
16
17
  import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
@@ -59,13 +60,60 @@ export function createWorkAttachments() {
59
60
  // and the two sides never disagree about the cut. An empty name is
60
61
  // `attachment`, the server's own fallback.
61
62
  const safeUploadName = (raw) => safeFileName(raw, 'attachment');
62
- /** The file at `path` holds exactly `buf` — read only when the sizes agree. */
63
+ /**
64
+ * NO LINK UNDER `.flowviant/` IS FOLLOWED, AND NOTHING THERE IS REPLACED
65
+ * (2026-10-10, the audit's F080). This lane writes a MEMBER'S bytes under a
66
+ * member's name, and it was the one `.flowviant/` writer with no guard:
67
+ * `mkdirSync` and `writeFileSync` went through a committed `.flowviant ->
68
+ * ~/.ssh` or `.flowviant/uploads -> ../../../.ssh`, and `existsSync` answered
69
+ * "absent" for a dangling link at the file's own name, which the write then
70
+ * followed to create its target — an attachment named `authorized_keys`
71
+ * landing in the operator's `~/.ssh`. Now: both directories must be real
72
+ * (ownedDirs.mjs `ensureRealSubdir`, the artifacts directory's rule — refused,
73
+ * never replaced), every look at a name is an `lstat` or an `O_NOFOLLOW`
74
+ * open, and each file is CREATED (`O_EXCL`): anything already at the name,
75
+ * a dangling link included, is never written through.
76
+ */
77
+ const NEW_FILE = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
78
+ /** Anything at all at `path` — a dangling link too. */
79
+ const taken = (path) => {
80
+ try {
81
+ lstatSync(path);
82
+ return true;
83
+ } catch (e) {
84
+ return e?.code !== 'ENOENT';
85
+ }
86
+ };
87
+ /** The REGULAR file at `path` holds exactly `buf` — read only when the sizes
88
+ * agree, and never through a link. Non-blocking, so a fifo at the name is
89
+ * answered "no" rather than waited on. */
63
90
  const holds = (path, buf) => {
91
+ let fd;
64
92
  try {
65
- return statSync(path).size === buf.byteLength && readFileSync(path).equals(buf);
93
+ const at = lstatSync(path);
94
+ if (!at.isFile() || at.size !== buf.byteLength) return false;
95
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
96
+ const st = fstatSync(fd);
97
+ return st.isFile() && st.size === buf.byteLength && readFileSync(fd).equals(buf);
66
98
  } catch {
67
99
  return false;
100
+ } finally {
101
+ if (fd !== undefined) closeSync(fd);
102
+ }
103
+ };
104
+ /** A NEW regular file at `path` holding `buf`, at the mode `writeFileSync`
105
+ * used. Throws (EEXIST) when anything is already there; a write that fails
106
+ * part-way takes its own half-file with it. */
107
+ const createFile = (path, buf) => {
108
+ const fd = openSync(path, NEW_FILE, 0o666);
109
+ try {
110
+ writeFileSync(fd, buf);
111
+ } catch (e) {
112
+ closeSync(fd);
113
+ rmSync(path, { force: true });
114
+ throw e;
68
115
  }
116
+ closeSync(fd);
69
117
  };
70
118
  /**
71
119
  * THE ONE DOWNLOAD LOOP. Each entry's relative path, or null where it did
@@ -81,11 +129,10 @@ export function createWorkAttachments() {
81
129
  */
82
130
  const landFiles = async (wt, list, endpoint, sameBytesKept) => {
83
131
  const dir = join(wt, UPLOAD_DIR);
84
- try {
85
- mkdirSync(dir, { recursive: true });
86
- } catch {
87
- return list.map(() => null);
88
- }
132
+ // A `.flowviant` or `uploads` that is a link or not a directory refuses
133
+ // the whole batch before anything is fetched: every file is a miss the
134
+ // turn is told about, and the repository's content is left as it is.
135
+ if (!ensureRealSubdir(wt, UPLOAD_DIR)) return list.map(() => null);
89
136
  // "Never committed by us" has to be true for GIT, not just for this code:
90
137
  // an untracked `.flowviant/` makes the whole worktree dirty, which refuses
91
138
  // every ship, exempts the tree from closed-tab retirement forever, and
@@ -115,14 +162,22 @@ export function createWorkAttachments() {
115
162
  // silently overwriting one with the other loses a file the human sent.
116
163
  let name = safeUploadName(a.name);
117
164
  let already = sameBytesKept && holds(join(dir, name), buf);
118
- if (!already && existsSync(join(dir, name))) {
165
+ if (!already && taken(join(dir, name))) {
119
166
  const dot = name.lastIndexOf('.');
120
167
  const stem = dot > 0 ? name.slice(0, dot) : name;
121
168
  const ext = dot > 0 ? name.slice(dot) : '';
122
169
  name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
123
- already = sameBytesKept && holds(join(dir, name), buf);
170
+ // The id-suffixed name is this file's own: holding these bytes, it
171
+ // IS this file, on either lane (it used to be rewritten with them).
172
+ // Anything else there is a miss — never replaced, never followed.
173
+ already = holds(join(dir, name), buf);
174
+ }
175
+ if (!already) {
176
+ // Looked at again right before the write: the batch's first check
177
+ // is up to a download old.
178
+ if (realDirState(join(wt, '.flowviant')) !== 'dir' || realDirState(dir) !== 'dir') continue;
179
+ createFile(join(dir, name), buf);
124
180
  }
125
- if (!already) writeFileSync(join(dir, name), buf);
126
181
  landed[landed.length - 1] = `${UPLOAD_DIR}/${name}`;
127
182
  } catch {
128
183
  /* one file failing must not fail the turn — the prompt lists what
@@ -7,7 +7,7 @@
7
7
  * — each importing it directly — and it is pure over the job and the runtime
8
8
  * table, so its home is a module of its own rather than either lane.
9
9
  */
10
- import { RUNTIMES } from './runtimes.mjs';
10
+ import { runtimeById } from './runtimes.mjs';
11
11
 
12
12
  /**
13
13
  * The shape a per-tab model name must have before it rides argv as
@@ -15,8 +15,13 @@ import { RUNTIMES } from './runtimes.mjs';
15
15
  * (below): it comes off the wire and lands in a child process's arguments —
16
16
  * alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
17
17
  * leading dash, which is an argv that parses as a flag.
18
+ *
19
+ * The server's route schemas copy it as `AGENT_MODEL_RE` (shared
20
+ * agent-runner.schema.ts), and the release parity gate holds the two equal
21
+ * (2026-10-10, audit F613): a server that accepts a name this drops has the
22
+ * board assert a pin the machine silently replaces with its default.
18
23
  */
19
- const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
24
+ export const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
20
25
 
21
26
  /**
22
27
  * WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
@@ -39,7 +44,7 @@ const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
39
44
  * One global ladder once accepted `max` for every runtime, and Codex has no
40
45
  * `max`: a skewed or previously queued Codex job reached argv as a
41
46
  * `model_reasoning_effort` its CLI refuses. The per-CLI list lives beside each
42
- * runtime's argv builder (`RUNTIMES[id].efforts`, runtime{Claude,Codex,Antigravity}.mjs); a runtime this
47
+ * runtime's argv builder (`runtimeById(id).efforts`, runtime{Claude,Codex,Antigravity}.mjs); a runtime this
43
48
  * daemon does not define runs nothing, so it spells no effort either.
44
49
  */
45
50
  export function brainFor(job, runtimeId) {
@@ -47,7 +52,7 @@ export function brainFor(job, runtimeId) {
47
52
  const model = typeof job?.model === 'string' ? job.model.trim() : '';
48
53
  if (model && WORK_MODEL_RE.test(model)) out.model = model;
49
54
  const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
50
- const efforts = RUNTIMES[runtimeId]?.efforts ?? [];
55
+ const efforts = runtimeById(runtimeId)?.efforts ?? [];
51
56
  if (effort && efforts.includes(effort)) out.effort = effort;
52
57
  return out;
53
58
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * ONE CAPPED READ-ONLY CONSULT TURN — the process plumbing the unattended
3
+ * one-shot lanes share: the Deploy-press planner (workAgentPlans.mjs), the AI
4
+ * pre-review (workAgentPrecheck.mjs) and intake (workIntake.mjs).
5
+ *
6
+ * ONE HOME (2026-10-10, the audit). Each lane carried its own forty-to-eighty
7
+ * lines of this, and the copies had already begun to drift. Every step below
8
+ * is a rule some incident taught, and a lane that copied the wrong one would
9
+ * show it only as a daemon that never auto-updates again:
10
+ * · THE CHILD IS REGISTERED IN `workChildren`, AND REMOVED IN A `finally`.
11
+ * It is what `workBusy()` counts and what counts against the machine's
12
+ * ceiling (liveTurnCount); a leaked entry keeps the daemon "busy" forever,
13
+ * which blocks every auto-update, silently, until a restart. No task id: a
14
+ * consult turn belongs to no card, and the snapshot's per-task rows must
15
+ * not invent one.
16
+ * · THE ADMISSION RESERVATION IS RELEASED AT THE SPAWN, and again in the
17
+ * `finally` for a turn that never spawned — release is idempotent, so
18
+ * doing both is the normal case (admission.mjs).
19
+ * · THE CAP IS ARMED AT THE SPAWN, not at entry: time spent waiting for a
20
+ * checkout lock is not a wedged CLI. It SIGKILLs the CHILD and never its
21
+ * group — the rule teardown keeps: a read-only turn starts no server, so
22
+ * there is nothing behind it worth signalling and everything to lose by
23
+ * signalling somebody else's.
24
+ * · THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
25
+ * the kill: a SIGKILLed process whose stdio a grandchild still holds can be
26
+ * slow to emit `close`, or never emit it, and the caller is holding a
27
+ * claimed job (or a place's writer lock) open behind this promise.
28
+ * · THE TURN'S TRANSCRIPT IS REMOVED ON EVERY EXIT — settled, wedged or
29
+ * thrown. `claude -p` writes `~/.claude/projects/<munged-cwd>/<id>.jsonl`,
30
+ * and the CLI's `--continue` is cwd-keyed: left in the checkout it displaces
31
+ * the operator's own conversation from the `+` adopt menu and is what their
32
+ * `claude --continue` resumes; left in an agent's worktree it is what the
33
+ * agent's next turn resumes, under build permissions. The id is the one the
34
+ * CLI reports at `system.init`, so the file is ours by name.
35
+ *
36
+ * WHAT STAYS WITH EACH LANE is policy, never plumbing: its lock, its prompt,
37
+ * profile and brain, its usage callback (set or accumulate is a per-lane
38
+ * choice), its cap length, its narration, and the words it settles with.
39
+ */
40
+ import { removeTranscriptSoon } from './runtimeCapabilities.mjs';
41
+
42
+ /**
43
+ * Run `runTurn(turnArgs)` under a `capMs` cap. Resolves
44
+ * `{ out, wedged, spawned, sessionId }`: `out` is the turn's answer, or `''`
45
+ * when the cap fired (`wedged`); `spawned` says a CLI process existed; the
46
+ * session id is the conversation the CLI named, or null. A turn that throws
47
+ * rethrows here, after the same cleanup.
48
+ *
49
+ * `onSpawn` and `onInit` are the caller's own hooks, run after this file's
50
+ * bookkeeping — which is why they ride beside `turnArgs` and never inside it.
51
+ * `removeTranscript` is a test's.
52
+ */
53
+ export async function runCappedTurn({
54
+ runTurn,
55
+ turnArgs,
56
+ capMs,
57
+ workChildren,
58
+ releaseSlot = () => {},
59
+ transcriptCwd,
60
+ onSpawn = () => {},
61
+ onInit = () => {},
62
+ removeTranscript = removeTranscriptSoon,
63
+ signal,
64
+ }) {
65
+ // Wiring mistakes, refused before anything spawns: a hook inside turnArgs
66
+ // would be silently replaced below, and a missing cap would fire at once.
67
+ if (turnArgs && ('onSpawn' in turnArgs || 'onInit' in turnArgs))
68
+ throw new TypeError('runCappedTurn: pass onSpawn/onInit beside turnArgs, never inside it');
69
+ if (!(Number.isFinite(capMs) && capMs > 0)) throw new TypeError('runCappedTurn: capMs must be a positive number');
70
+ let child = null;
71
+ let timer = null;
72
+ let wedged = false;
73
+ let sessionId = null;
74
+ let stopWaiting = () => {};
75
+ const capped = new Promise((resolve) => {
76
+ stopWaiting = resolve;
77
+ });
78
+ const cancel = () => {
79
+ wedged = true;
80
+ try { child?.kill('SIGKILL'); } catch { /* already gone */ }
81
+ stopWaiting('');
82
+ };
83
+ signal?.addEventListener('abort', cancel, { once: true });
84
+ try {
85
+ if (signal?.aborted) { cancel(); return { out: '', wedged, spawned: false, sessionId }; }
86
+ const turn = runTurn({
87
+ ...turnArgs,
88
+ onInit: (i) => {
89
+ if (typeof i?.sessionId === 'string' && i.sessionId.trim()) sessionId = i.sessionId.trim();
90
+ onInit(i);
91
+ },
92
+ onSpawn: (ch) => {
93
+ child = ch;
94
+ workChildren.set(ch, null);
95
+ releaseSlot();
96
+ timer = setTimeout(() => {
97
+ wedged = true;
98
+ try {
99
+ ch.kill('SIGKILL');
100
+ } catch {
101
+ /* already gone */
102
+ }
103
+ stopWaiting('');
104
+ }, capMs);
105
+ timer.unref?.();
106
+ onSpawn(ch);
107
+ if (signal?.aborted) cancel();
108
+ },
109
+ });
110
+ const out = await Promise.race([turn, capped]);
111
+ return { out, wedged, spawned: child !== null, sessionId };
112
+ } finally {
113
+ signal?.removeEventListener('abort', cancel);
114
+ if (timer) clearTimeout(timer);
115
+ if (child) workChildren.delete(child);
116
+ releaseSlot();
117
+ if (sessionId) removeTranscript(transcriptCwd, sessionId);
118
+ }
119
+ }
@@ -10,6 +10,7 @@
10
10
  * on this box" belong together — and apart from any one lane.
11
11
  */
12
12
  import { createAdmission } from './admission.mjs';
13
+ import { killGroup } from './processGroup.mjs';
13
14
 
14
15
  export function createWorkChildren({ extraLiveTurns }) {
15
16
  /**
@@ -54,11 +55,10 @@ export function createWorkChildren({ extraLiveTurns }) {
54
55
  const groupKillChildren = new Set();
55
56
  const shutdownWork = () => {
56
57
  for (const [ch] of workChildren) {
57
- try {
58
- if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
59
- else ch.kill('SIGTERM');
60
- } catch {
61
- // A group that has already gone, or a pid that is no longer a leader.
58
+ // A group that has already gone, or a pid that is no longer a leader,
59
+ // falls back to the child (processGroup.mjs).
60
+ if (groupKillChildren.has(ch)) killGroup(ch, 'SIGTERM');
61
+ else {
62
62
  try {
63
63
  ch.kill('SIGTERM');
64
64
  } catch {
@@ -8,7 +8,7 @@
8
8
  * blocking the turn.
9
9
  */
10
10
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
11
- import { scrub as envScrub } from './uplinkScrub.mjs';
11
+ import { scrubCut } from './uplinkScrub.mjs';
12
12
  import { fleetEndpoint } from './fleetWire.mjs';
13
13
 
14
14
  // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
@@ -21,6 +21,10 @@ import { fleetEndpoint } from './fleetWire.mjs';
21
21
  // a kill, and again at settle. Best-effort: a failed post drops the
22
22
  // batch rather than blocking the turn — the surface says it is the
23
23
  // machine's report, not a syscall trace.
24
+ /** The server's own cap on one audited command (sessionCommands.ts
25
+ * `MAX_COMMAND_CHARS`), applied here AFTER the scrub. */
26
+ const MAX_COMMAND_CHARS = 2000;
27
+
24
28
  export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
25
29
  const SESSION_COMMANDS_URL = fleetEndpoint('session-commands', FLEET_URL);
26
30
  const auditBatch = [];
@@ -55,7 +59,19 @@ export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
55
59
  // `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
56
60
  // in the admin view, so the one uplink that omitted scrub was the
57
61
  // one most likely to carry a plaintext secret.
58
- auditBatch.push({ command: envScrub(a.command), at: new Date().toISOString() });
62
+ //
63
+ // SCRUBBED WHOLE, THEN CUT (2026-10-10, the audit's F128): the parsers
64
+ // used to cut the command at 2000 before it reached this scrub, so a
65
+ // secret straddling that character kept its prefix. They now hand a
66
+ // bounded window, and the server's cap is applied here, last.
67
+ auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString(),
68
+ ...(a.observation ? { observation: {
69
+ ...(a.observation.id ? { id: scrubCut(a.observation.id, 160) } : {}),
70
+ outcome: a.observation.outcome ?? 'unknown',
71
+ ...(Number.isSafeInteger(a.observation.exitCode) ? { exitCode: a.observation.exitCode } : {}),
72
+ ...(a.observation.errorCategory ? { errorCategory: a.observation.errorCategory } : {}),
73
+ } } : {}),
74
+ });
59
75
  if (auditBatch.length >= 25) flushAudit();
60
76
  };
61
77
  return { flushAudit, auditCommand };
@@ -1,7 +1,25 @@
1
1
  /** Commit diff requests and their bounded reports. */
2
2
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
3
- import { git } from './git.mjs';
3
+ import { git, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
4
4
  import { fleetEndpoint } from './fleetWire.mjs';
5
+ import { scrub, scrubCut } from './uplinkScrub.mjs';
6
+
7
+ /**
8
+ * A PATCH BIGGER THAN NODE'S 1 MiB DEFAULT BUFFER (2026-10-10, the audit's
9
+ * F150). `git()` (git.mjs) defaults to Node's 1 MiB `maxBuffer`, so any
10
+ * commit whose patch passed 1 MiB failed `ENOBUFS` and the viewer was told it
11
+ * errored, when it should have been shown the first 256 KB marked truncated.
12
+ * The same call with room for GIT_WIDE_BUFFER (8 MiB); past that, what git
13
+ * printed up to the bound is still a patch to truncate, never an error.
14
+ */
15
+ function gitBig(args, cwd) {
16
+ try {
17
+ return git(args, cwd, { maxBuffer: GIT_WIDE_BUFFER });
18
+ } catch (e) {
19
+ if (e?.code === 'ENOBUFS' && typeof e.stdout === 'string' && e.stdout) return e.stdout;
20
+ throw e;
21
+ }
22
+ }
5
23
 
6
24
  export function createWorkDiffs({ repoRoot }) {
7
25
  const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
@@ -23,6 +41,18 @@ export function createWorkDiffs({ repoRoot }) {
23
41
  * machine" is a real answer, and a viewer spinning forever is the worst thing
24
42
  * this can do. The one thing never sent is a guess — an empty patch would
25
43
  * tell a reader the commit changed nothing.
44
+ *
45
+ * ONLY A COMMIT, AND SCRUBBED (2026-10-10, the audit's F150). The sha is a
46
+ * server-named hex string, and `git show <sha>` prints whatever object it
47
+ * names — a blob, a tree, a tag — so it is first resolved as
48
+ * `<sha>^{commit}` and anything else is answered "no such commit on this
49
+ * machine" with no content. (The hex check below already keeps it from
50
+ * reading as an option, so no `--end-of-options`, which git before 2.30
51
+ * does not take in `rev-parse`.) No reachability check: the run page asks
52
+ * for unpushed agent commits on load, and a remote-branch bound would refuse
53
+ * them. And the patch is the one repository uplink that skipped `scrub`; it
54
+ * now passes through it WHOLE, before the line-boundary cut — the
55
+ * scrub-then-cut order every other lane keeps.
26
56
  */
27
57
  const MAX_PATCH_BYTES = 256 * 1024;
28
58
  const servedDiffs = new Set();
@@ -48,7 +78,7 @@ export function createWorkDiffs({ repoRoot }) {
48
78
  const sha = String(job?.sha || '').toLowerCase();
49
79
  // A sha becomes a `git` argument. The server validates too; one place
50
80
  // doing this check is one deploy away from being zero places.
51
- if (!/^[0-9a-f]{7,64}$/.test(sha)) continue;
81
+ if (!OBJECT_ID_RE.test(sha)) continue;
52
82
  // In-flight guard, not a cache: the server stops offering a sha the
53
83
  // moment it settles, so this only stops the SAME poll's job being
54
84
  // started twice while its `git show` is still running.
@@ -56,6 +86,22 @@ export function createWorkDiffs({ repoRoot }) {
56
86
  servedDiffs.add(sha);
57
87
  void (async () => {
58
88
  try {
89
+ let commit = '';
90
+ try {
91
+ commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], repoRoot);
92
+ } catch (e) {
93
+ // Exit 1 is `--verify --quiet`'s "not a commit here" (absent,
94
+ // ambiguous, or another object type); anything else is git's own
95
+ // failure, relayed in its words as before.
96
+ if (e?.status !== 1) {
97
+ await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
98
+ return;
99
+ }
100
+ }
101
+ if (!/^[0-9a-f]{40,64}$/.test(commit)) {
102
+ await postDiff({ sha, error: 'no such commit on this machine' });
103
+ return;
104
+ }
59
105
  let patch = '';
60
106
  const files = [];
61
107
  try {
@@ -63,13 +109,13 @@ export function createWorkDiffs({ repoRoot }) {
63
109
  // date already reached the card on the worktree sweep, and
64
110
  // repeating them inside the patch would put a second copy above
65
111
  // every hunk.
66
- patch = git(['show', '--patch', '--format=', sha], repoRoot);
112
+ patch = scrub(gitBig(['show', '--patch', '--format=', commit], repoRoot));
67
113
  } catch (e) {
68
- await postDiff({ sha, error: String(e?.message || e).slice(0, 500) });
114
+ await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
69
115
  return;
70
116
  }
71
117
  try {
72
- const raw = git(['show', '--numstat', '--format=', sha], repoRoot);
118
+ const raw = gitBig(['show', '--numstat', '--format=', commit], repoRoot);
73
119
  for (const line of raw.split('\n')) {
74
120
  if (!line.trim()) continue;
75
121
  const [a, d, ...rest] = line.split('\t');
@@ -77,7 +123,7 @@ export function createWorkDiffs({ repoRoot }) {
77
123
  if (!path) continue;
78
124
  const binary = a === '-' || d === '-';
79
125
  files.push({
80
- path: path.slice(0, 300),
126
+ path: scrubCut(path, 300),
81
127
  added: binary ? 0 : Number(a) || 0,
82
128
  deleted: binary ? 0 : Number(d) || 0,
83
129
  ...(binary ? { binary: true } : {}),
@@ -1,9 +1,10 @@
1
1
  /** One leased incoming ticket, drafted by this machine's read-only CLI. */
2
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
2
+ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
3
3
  import { fleetEndpoint } from './fleetWire.mjs';
4
+ import { claimFleetLease } from './fleetPost.mjs';
4
5
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
5
6
  import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
6
- import { removeProbeTranscript } from './runtimeCapabilities.mjs';
7
+ import { runCappedTurn } from './workCappedTurn.mjs';
7
8
  import { REPO_PLACE } from './workPlaces.mjs';
8
9
  import { SYSTEM_INTAKE, INTAKE_KICKOFF } from './prompts.mjs';
9
10
  import { parseIntakeDraft } from './intakeDraft.mjs';
@@ -31,25 +32,12 @@ export function createWorkIntake({
31
32
  const done = fleetEndpoint(INTAKE_DONE_ENDPOINT, FLEET_URL);
32
33
  const intake = new Set();
33
34
 
34
- const claim = async (id) => {
35
- try {
36
- const res = await fetch(claiming, {
37
- method: 'POST',
38
- headers: {
39
- Authorization: `Bearer ${FLEET_TOKEN}`,
40
- 'User-Agent': USER_AGENT,
41
- 'Content-Type': 'application/json',
42
- },
43
- signal: AbortSignal.timeout(15_000),
44
- body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
45
- });
46
- // A 409 means another holder (or a dismissed/landed ticket). No CLI and
47
- // no settle: this machine never acquired the right to answer for it.
48
- return res.status === 200 && (await res.json().catch(() => null))?.ok === true;
49
- } catch {
50
- return false;
51
- }
52
- };
35
+ // The intake route answers `{ ok: true }` on a 200, not `data.claimed`
36
+ // (machineIntake.routes.ts) — the same lease rule with its own winner test.
37
+ // A 409 means another holder (or a dismissed/landed ticket). No CLI and no
38
+ // settle: this machine never acquired the right to answer for it.
39
+ const claim = (id) =>
40
+ claimFleetLease(claiming, { id }, { won: (r) => r.status === 200 && r.json?.ok === true });
53
41
 
54
42
  const report = async (body) => {
55
43
  await postBestEffort(done, { ...body, instance: DAEMON_INSTANCE });
@@ -68,56 +56,43 @@ export function createWorkIntake({
68
56
  }
69
57
 
70
58
  let out = '';
71
- let child = null;
72
- let session = null;
73
- let timer = null;
74
59
  let wedged = false;
60
+ let spawned = false;
75
61
  let usage = null;
76
62
  // A spawned CLI can fail before reporting counters. Null records that the
77
63
  // spend is unmeasured; four zeroes would falsely say it spent nothing.
78
- const spent = () => child ? { usage } : {};
64
+ const spent = () => spawned ? { usage } : {};
79
65
  try {
80
66
  await inPlace(REPO_PLACE, false, async () => {
81
- let stopWaiting = () => {};
82
- const capped = new Promise((resolve) => { stopWaiting = resolve; });
83
- const turn = runTurn({
84
- prompt: INTAKE_KICKOFF(job),
85
- system: SYSTEM_INTAKE,
86
- profile: 'consult',
87
- cwd: repoRoot,
88
- runtime,
89
- streamJson: true,
90
- answerFromResult: true,
91
- label: c.cyan('[intake]'),
92
- // There is no MCP principal for a ticket, and no model or effort pin:
93
- // this one-shot reader uses the machine's consult defaults.
94
- onInit: (i) => {
95
- if (typeof i?.sessionId === 'string' && i.sessionId.trim()) session = i.sessionId.trim();
96
- },
97
- onUsage: (u) => { usage = addUsage(usage, { ...u, runtime }); },
98
- onSpawn: (ch) => {
99
- child = ch;
100
- workChildren.set(ch, null);
101
- releaseSlot();
102
- // Start the cap at spawn, never while a checkout writer holds the
103
- // reader lock; SIGKILL and resolve even if a grandchild holds stdio.
104
- timer = setTimeout(() => {
105
- wedged = true;
106
- try { ch.kill('SIGKILL'); } catch { /* already gone */ }
107
- stopWaiting('');
108
- }, 10 * 60_000);
109
- timer.unref?.();
67
+ // One capped consult turn (workCappedTurn.mjs): the cap starts at
68
+ // spawn, never while a checkout writer holds the reader lock, and
69
+ // SIGKILLs the child and resolves even if a grandchild holds stdio.
70
+ ({ out, wedged } = await runCappedTurn({
71
+ runTurn,
72
+ capMs: 10 * 60_000,
73
+ workChildren,
74
+ releaseSlot,
75
+ transcriptCwd: repoRoot,
76
+ turnArgs: {
77
+ prompt: INTAKE_KICKOFF(job),
78
+ system: SYSTEM_INTAKE,
79
+ profile: 'consult',
80
+ cwd: repoRoot,
81
+ runtime,
82
+ answerFromResult: true,
83
+ label: c.cyan('[intake]'),
84
+ // There is no MCP principal for a ticket, and no model or effort pin:
85
+ // this one-shot reader uses the machine's consult defaults.
86
+ onUsage: (u) => { usage = addUsage(usage, { ...u, runtime }); },
110
87
  },
111
- });
112
- out = await Promise.race([turn, capped]);
88
+ // Marked by the lane itself, so a turn that throws after its spawn
89
+ // still reports what it measured.
90
+ onSpawn: () => { spawned = true; },
91
+ }));
113
92
  });
114
93
  } catch (e) {
115
94
  await report({ id, outcome: INTAKE_OUTCOMES.nothing, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
116
95
  return;
117
- } finally {
118
- if (timer) clearTimeout(timer);
119
- if (child) workChildren.delete(child);
120
- if (session) setTimeout(() => removeProbeTranscript(repoRoot, session), 750).unref?.();
121
96
  }
122
97
 
123
98
  if (wedged) {