toolroll 0.9.59 → 0.9.61

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 (152) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +8 -6
  3. package/dist/agent-board.d.ts +208 -0
  4. package/dist/agent-board.js +454 -0
  5. package/dist/agent-fence.d.ts +19 -0
  6. package/dist/agent-fence.js +100 -8
  7. package/dist/agent-pulse.d.ts +43 -0
  8. package/dist/agent-pulse.js +214 -0
  9. package/dist/agent-turns.d.ts +52 -0
  10. package/dist/agent-turns.js +137 -0
  11. package/dist/agents-board.d.ts +101 -0
  12. package/dist/agents-board.js +225 -0
  13. package/dist/assignment-adapters.d.ts +2 -1
  14. package/dist/assignment-adapters.js +1 -1
  15. package/dist/assignment-delivery.d.ts +16 -0
  16. package/dist/assignment-delivery.js +54 -28
  17. package/dist/assignment-status.d.ts +14 -5
  18. package/dist/assignment-status.js +70 -39
  19. package/dist/assignment.d.ts +10 -2
  20. package/dist/assignment.js +21 -14
  21. package/dist/bin.js +11 -2
  22. package/dist/brief-fence.d.ts +9 -0
  23. package/dist/brief-fence.js +22 -0
  24. package/dist/browser/workspace.css +2 -2
  25. package/dist/browser/workspace.js +29 -25
  26. package/dist/browser-crew.js +2 -0
  27. package/dist/browser-workspace.d.ts +79 -1
  28. package/dist/browser-workspace.js +1 -0
  29. package/dist/builder.d.ts +8 -0
  30. package/dist/builder.js +178 -29
  31. package/dist/chat-channel.js +10 -1
  32. package/dist/chat-core.d.ts +6 -0
  33. package/dist/chat-core.js +8 -0
  34. package/dist/chat-delivery.js +22 -0
  35. package/dist/chat-messages.js +8 -1
  36. package/dist/chat-quiet.js +1 -0
  37. package/dist/chat-voice.d.ts +2 -0
  38. package/dist/chat-voice.js +2 -0
  39. package/dist/checkout-cleanup.d.ts +3 -0
  40. package/dist/checkout-cleanup.js +11 -2
  41. package/dist/cli-fast.d.ts +8 -0
  42. package/dist/cli-fast.js +18 -0
  43. package/dist/cli-help.d.ts +5 -0
  44. package/dist/cli-help.js +91 -0
  45. package/dist/cli.d.ts +6 -5
  46. package/dist/cli.js +17 -96
  47. package/dist/contracts/console-api.d.ts +3 -1
  48. package/dist/contracts/console-api.js +5 -1
  49. package/dist/contracts/lead-tools.d.ts +16 -2
  50. package/dist/contracts/lead-tools.js +5 -1
  51. package/dist/design/tokens.css +234 -0
  52. package/dist/desktop-update.d.ts +1 -1
  53. package/dist/discord.js +2 -1
  54. package/dist/evidence-pack.js +1 -1
  55. package/dist/evidence.d.ts +28 -0
  56. package/dist/evidence.js +87 -17
  57. package/dist/exec.d.ts +32 -0
  58. package/dist/exec.js +229 -10
  59. package/dist/flow-actions.js +2 -1
  60. package/dist/guides.js +14 -11
  61. package/dist/invoke.js +110 -18
  62. package/dist/landed-cleanup.d.ts +157 -0
  63. package/dist/landed-cleanup.js +707 -0
  64. package/dist/lead-cli.js +22 -19
  65. package/dist/lead-contract.js +1 -1
  66. package/dist/lead-doors.js +49 -2
  67. package/dist/lead-follow.d.ts +2 -0
  68. package/dist/lead-follow.js +138 -88
  69. package/dist/lead-tools.js +48 -8
  70. package/dist/live-input.d.ts +68 -0
  71. package/dist/live-input.js +254 -0
  72. package/dist/live.d.ts +1 -1
  73. package/dist/live.js +4 -2
  74. package/dist/mailbox.js +3 -2
  75. package/dist/maintenance.d.ts +2 -0
  76. package/dist/maintenance.js +5 -2
  77. package/dist/mcp-core.js +1 -1
  78. package/dist/operate-remote-arguments.js +5 -1
  79. package/dist/operate-remote.js +1 -1
  80. package/dist/operate.d.ts +1 -1
  81. package/dist/operate.js +201 -19
  82. package/dist/orphan-sweep.d.ts +2 -0
  83. package/dist/orphan-sweep.js +5 -3
  84. package/dist/project-delete-ui.d.ts +11 -0
  85. package/dist/project-delete-ui.js +29 -2
  86. package/dist/project-tools.d.ts +7 -11
  87. package/dist/project-tools.js +13 -37
  88. package/dist/provider.d.ts +73 -8
  89. package/dist/provider.js +87 -17
  90. package/dist/result-completion.d.ts +7 -0
  91. package/dist/result-completion.js +9 -0
  92. package/dist/runner.d.ts +4 -2
  93. package/dist/runner.js +3 -3
  94. package/dist/serve.js +22 -3
  95. package/dist/server/chrome.d.ts +6 -3
  96. package/dist/server/chrome.js +8 -4
  97. package/dist/server/console.css +9 -64
  98. package/dist/server/live.d.ts +3 -0
  99. package/dist/server/live.js +35 -2
  100. package/dist/server/pages.js +59 -35
  101. package/dist/server/render-chat.js +14 -2
  102. package/dist/server/render-tasks.d.ts +14 -2
  103. package/dist/server/render-tasks.js +38 -20
  104. package/dist/server/route-table.js +4 -1
  105. package/dist/server/settings.js +2 -1
  106. package/dist/server/task-views.js +10 -0
  107. package/dist/server/tasks.js +63 -0
  108. package/dist/shared-deps.d.ts +9 -4
  109. package/dist/shared-deps.js +89 -13
  110. package/dist/slack-socket.d.ts +12 -0
  111. package/dist/slack-socket.js +29 -0
  112. package/dist/slack.d.ts +0 -7
  113. package/dist/slack.js +2 -24
  114. package/dist/staged-runtimes.d.ts +16 -4
  115. package/dist/staged-runtimes.js +160 -38
  116. package/dist/steer-live.d.ts +19 -0
  117. package/dist/steer-live.js +93 -0
  118. package/dist/steer-mailbox.d.ts +39 -0
  119. package/dist/steer-mailbox.js +207 -0
  120. package/dist/steer-status.d.ts +25 -0
  121. package/dist/steer-status.js +44 -0
  122. package/dist/storage-sweep-child.d.ts +1 -0
  123. package/dist/storage-sweep-child.js +76 -0
  124. package/dist/storage-sweep-process.d.ts +41 -0
  125. package/dist/storage-sweep-process.js +187 -0
  126. package/dist/storage-sweep.d.ts +30 -6
  127. package/dist/storage-sweep.js +214 -28
  128. package/dist/storage-ui.d.ts +2 -0
  129. package/dist/storage-ui.js +16 -4
  130. package/dist/storage.d.ts +6 -0
  131. package/dist/storage.js +35 -0
  132. package/dist/store.d.ts +202 -2
  133. package/dist/store.js +440 -13
  134. package/dist/surface.js +5 -3
  135. package/dist/sweep-gate.d.ts +10 -0
  136. package/dist/sweep-gate.js +18 -0
  137. package/dist/sweep-quarantine.d.ts +28 -0
  138. package/dist/sweep-quarantine.js +153 -0
  139. package/dist/task-status.d.ts +2 -0
  140. package/dist/task-status.js +5 -1
  141. package/dist/team-updates.js +3 -3
  142. package/dist/test-temp.d.ts +6 -3
  143. package/dist/test-temp.js +15 -9
  144. package/dist/toolroll-update.js +15 -2
  145. package/dist/work-index.d.ts +1 -0
  146. package/dist/work-index.js +32 -12
  147. package/dist/work-summary.d.ts +2 -2
  148. package/dist/workspace-revision.js +3 -1
  149. package/dist/worktree.d.ts +3 -1
  150. package/dist/worktree.js +93 -10
  151. package/package.json +21 -21
  152. package/scripts/provider-canary.mjs +235 -147
@@ -8,9 +8,12 @@
8
8
  * and in the plane's daily storage sweep. `rollback-*` records and anything else in the folder are never touched.
9
9
  * With no current runtime found, nothing goes: which one is safe to remove is then unknown.
10
10
  */
11
- import { lstatSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
11
+ import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync } from "node:fs";
12
+ import { execFileSync } from "node:child_process";
12
13
  import { homedir } from "node:os";
13
14
  import { basename, dirname, join, resolve, sep } from "node:path";
15
+ import { quarantine, quarantineEntries, claimQuarantine, releaseQuarantine, restoreQuarantine, deleteQuarantineSync } from "./sweep-quarantine.js";
16
+ import { processCensus } from "./run-closeout.js";
14
17
  const DAY_MS = 86_400_000;
15
18
  /** A deploy or update that never finished keeps its stage this long (it may be resumed). */
16
19
  export const UNFINISHED_KEEP_MS = 7 * DAY_MS;
@@ -63,38 +66,46 @@ export function stagedRuntimes(stateDir) {
63
66
  catch {
64
67
  continue;
65
68
  }
66
- const deploy = name.startsWith("browser-") ? readJson(join(path, "deployment.json")) : null;
67
- const update = name.startsWith("release-") ? readJson(join(path, "update.json")) : null;
68
- const journal = deploy ?? update;
69
- const stamp = String(journal?.["deployedAt"] ?? journal?.["startedAt"] ?? journal?.["createdAt"] ?? "");
70
- let at = Date.parse(stamp);
71
- if (!Number.isFinite(at)) {
72
- try {
73
- at = statSync(path).mtimeMs;
74
- }
75
- catch {
76
- at = 0;
77
- }
78
- }
79
- const finished = deploy !== null ? deploy["phase"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
80
- const from = update?.["from"];
81
- const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
82
- let keptAside = false;
69
+ stages.push(readStage(path, name));
70
+ }
71
+ return stages.sort((a, b) => b.at - a.at);
72
+ }
73
+ function readStage(path, name, fallbackAt) {
74
+ const deploy = name.startsWith("browser-") ? readJson(join(path, "deployment.json")) : null;
75
+ const update = name.startsWith("release-") ? readJson(join(path, "update.json")) : null;
76
+ const journal = deploy ?? update;
77
+ const stamp = String(journal?.["deployedAt"] ?? journal?.["startedAt"] ?? journal?.["createdAt"] ?? "");
78
+ let at = Date.parse(stamp);
79
+ if (!Number.isFinite(at)) {
83
80
  try {
84
- keptAside = readdirSync(path).some(one => /^orders\.(?:kept|unreadable)\./.test(one));
81
+ at = fallbackAt ?? statSync(path).mtimeMs;
85
82
  }
86
83
  catch {
87
- keptAside = true;
84
+ at = 0;
88
85
  }
89
- stages.push({ path, name, at, finished, prior, keptAside });
90
86
  }
91
- return stages.sort((a, b) => b.at - a.at);
87
+ const finished = deploy !== null ? deploy["phase"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
88
+ const from = update?.["from"];
89
+ const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
90
+ let keptAside = false;
91
+ try {
92
+ keptAside = readdirSync(path).some(one => /^orders\.(?:kept|unreadable)\./.test(one));
93
+ }
94
+ catch {
95
+ keptAside = true;
96
+ }
97
+ return { path, name, at, finished, prior, keptAside };
98
+ }
99
+ /** Where the CLI links usually are (link.ts, an npm or Homebrew global), on PATH or not: a launchd worker's PATH is short. */
100
+ function linkFolders(home) {
101
+ return [join(home, ".local", "bin"), join(home, "bin"), "/usr/local/bin", "/opt/homebrew/bin"];
92
102
  }
93
103
  /**
94
104
  * Paths a runtime may be running from: the launchd services' definitions, every `toolroll` (or older
95
- * `standing-orders`) command on PATH or under nvm, this process, and `extra`.
105
+ * `standing-orders`) command on PATH, in the usual link folders or under nvm (a link counts by its target), this
106
+ * process, and `extra`.
96
107
  */
97
- export function runtimesInUse(extra = [], home = homedir(), path = process.env["PATH"] ?? "") {
108
+ export function runtimesInUse(extra = [], home = homedir(), path = process.env["PATH"] ?? "", stateDir) {
98
109
  const found = [...extra];
99
110
  const agents = join(home, "Library", "LaunchAgents");
100
111
  try {
@@ -104,7 +115,7 @@ export function runtimesInUse(extra = [], home = homedir(), path = process.env["
104
115
  }
105
116
  }
106
117
  catch { /* no launch agents here */ }
107
- const commands = path.split(":").filter(Boolean).flatMap(dir => ["toolroll", "standing-orders"].map(bin => join(dir, bin)));
118
+ const commands = [...new Set([...path.split(":").filter(Boolean), ...linkFolders(home)])].flatMap(dir => ["toolroll", "standing-orders"].map(bin => join(dir, bin)));
108
119
  try {
109
120
  for (const version of readdirSync(join(home, ".nvm", "versions", "node")))
110
121
  for (const bin of ["toolroll", "standing-orders"])
@@ -116,12 +127,84 @@ export function runtimesInUse(extra = [], home = homedir(), path = process.env["
116
127
  lstatSync(command);
117
128
  found.push(command);
118
129
  }
119
- catch { /* not there */ }
130
+ catch {
131
+ continue; /* not there */
132
+ }
133
+ // A link counts by its target, even one that doesn't resolve now.
134
+ try {
135
+ found.push(resolve(dirname(command), readlinkSync(command)));
136
+ }
137
+ catch { /* not a link */ }
120
138
  }
121
139
  if (process.argv[1] !== undefined)
122
140
  found.push(process.argv[1]);
141
+ if (stateDir !== undefined)
142
+ found.push(...runtimeScriptPaths(stateDir));
123
143
  return found;
124
144
  }
145
+ /** Retain only recognized Toolroll entry scripts in candidate stages, never complete argv. ps is asked only
146
+ * for Node/Toolroll processes; /proc supplies NUL-separated argv on Linux. Fail closed with a fixed message,
147
+ * since an execFileSync exception can otherwise contain private argument text in stdout/stderr. */
148
+ export function runtimeScriptPaths(stateDir, onlyPids, observations = {}) {
149
+ const stages = stagedRuntimes(stateDir);
150
+ const trash = quarantineEntries(real(join(stateDir, STAGED_FOLDER)));
151
+ const bases = new Set(stages.flatMap(one => [one.path, join(stateDir, STAGED_FOLDER, one.name)]));
152
+ for (const one of trash) {
153
+ bases.add(one.path);
154
+ bases.add(one.original);
155
+ bases.add(join(stateDir, STAGED_FOLDER, one.journal.name));
156
+ }
157
+ if (bases.size === 0 || process.platform === "win32")
158
+ return [];
159
+ const scripts = new Set();
160
+ const collect = (args) => {
161
+ for (const base of bases) {
162
+ const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
+ // All supported installs: runtime/dist, runtime/node_modules/{toolroll,standing-orders}/dist,
164
+ // or dist directly under a release stage. Matching a known base handles spaces without logging argv.
165
+ const pattern = new RegExp(`(?:^|[\\s\\0"'])(${escaped}/(?:runtime/)?(?:node_modules/(?:toolroll|standing-orders)/)?dist/(?:cli|bin|storage-sweep-child)\\.js)(?=$|[\\s\\0"'])`, "g");
166
+ for (const match of args.matchAll(pattern))
167
+ scripts.add(match[1]);
168
+ }
169
+ };
170
+ try {
171
+ const pids = (observations.census ?? processCensus)().filter(row => /^(?:node(?:js)?|toolroll|standing-orders)(?:\.exe)?$/.test(row.name) &&
172
+ (onlyPids === undefined || onlyPids.includes(row.pid))).map(row => row.pid);
173
+ if (observations.argv !== undefined) {
174
+ for (const args of observations.argv(pids))
175
+ collect(args);
176
+ }
177
+ else if (process.platform === "linux") {
178
+ for (const pid of pids) {
179
+ try {
180
+ collect(readFileSync(`/proc/${pid}/cmdline`, "utf8"));
181
+ }
182
+ catch (error) {
183
+ if (error.code !== "ENOENT" && error.code !== "ESRCH")
184
+ throw error;
185
+ }
186
+ }
187
+ }
188
+ else {
189
+ for (let at = 0; at < pids.length; at += 200) {
190
+ try {
191
+ collect(execFileSync("/bin/ps", ["-ww", "-p", pids.slice(at, at + 200).join(","), "-o", "args="], {
192
+ encoding: "utf8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"],
193
+ }));
194
+ }
195
+ catch (error) {
196
+ // ps exits 1 when every requested process has just gone. Any other failure keeps stages.
197
+ if (error.status !== 1)
198
+ throw error;
199
+ }
200
+ }
201
+ }
202
+ }
203
+ catch {
204
+ throw new Error("Toolroll runtime process paths could not be read");
205
+ }
206
+ return [...scripts];
207
+ }
125
208
  /** The stage `path` lies in, or null. */
126
209
  function stageOf(stages, path) {
127
210
  if (path === null || path === undefined || path === "")
@@ -138,8 +221,21 @@ function stageOf(stages, path) {
138
221
  /** What stays and what goes now, and why. */
139
222
  export function stagePlan(stateDir, inUse, now) {
140
223
  const stages = stagedRuntimes(stateDir);
224
+ const trash = quarantineEntries(real(join(stateDir, STAGED_FOLDER)));
225
+ // A renamed stage still has its original identity for current/previous/newest protection, including a CLI
226
+ // symlink switched just before rename. Partial-delete journals retain this identity for the next retry.
227
+ for (const one of trash)
228
+ if (!stages.some(stage => stage.path === one.original)) {
229
+ const facts = one.journal.deleting ? one.journal.data : readStage(one.path, one.journal.name, one.journal.data.at);
230
+ stages.push({ ...facts, path: one.original });
231
+ }
232
+ stages.sort((a, b) => b.at - a.at);
233
+ const paths = inUse.map(path => {
234
+ const moved = trash.find(one => path === one.path || path.startsWith(one.path + sep));
235
+ return moved === undefined ? path : moved.original + path.slice(moved.path.length);
236
+ });
141
237
  const keep = new Map();
142
- const current = inUse.map(path => stageOf(stages, path)).filter((one) => one !== null);
238
+ const current = paths.map(path => stageOf(stages, path)).filter((one) => one !== null);
143
239
  if (stages.length > 0 && current.length === 0)
144
240
  return { keep: [], go: [], refused: "no staged runtime is in use, so which one runs now is unknown" };
145
241
  for (const one of current)
@@ -162,29 +258,55 @@ export function stagePlan(stateDir, inUse, now) {
162
258
  return { keep: [...keep].map(([path, why]) => ({ path, why })), go: stages.filter(one => !keep.has(one.path)), refused: null };
163
259
  }
164
260
  /**
165
- * Remove the stages that go. Right before each goes, the plan is made again (what's in use now) and the stage must
166
- * still be a real folder directly in staged-upgrades.
261
+ * Remove the stages that go. Right before each goes, the plan is made again (what's in use now), nothing live may use
262
+ * it (`spare`, storage-sweep.ts) and the stage must still be a real folder directly in staged-upgrades.
167
263
  */
168
264
  export function pruneStagedRuntimes(stateDir, now, options = {}) {
169
- const inUse = options.inUse ?? (() => runtimesInUse());
170
- const first = stagePlan(stateDir, inUse(), now);
171
- if (first.refused !== null)
172
- return { removed: [], failed: [], refused: first.refused };
265
+ const inUse = options.inUse ?? (() => runtimesInUse([], undefined, undefined, stateDir));
173
266
  const root = real(join(stateDir, STAGED_FOLDER));
174
267
  const removed = [];
175
268
  const failed = [];
269
+ const finish = (trash) => {
270
+ try {
271
+ options.quarantined?.(trash.original, trash.path);
272
+ const again = stagePlan(stateDir, inUse(), now);
273
+ if (again.refused !== null || !again.go.some(one => one.path === trash.original) || options.spare?.(trash.original) === true || options.spare?.(trash.path) === true) {
274
+ if (!restoreQuarantine(trash))
275
+ failed.push(trash.original);
276
+ return;
277
+ }
278
+ deleteQuarantineSync(trash, options.remove ?? (path => rmSync(path, { recursive: true, force: true, maxRetries: 3 })));
279
+ removed.push(trash.original);
280
+ }
281
+ catch {
282
+ failed.push(trash.original);
283
+ }
284
+ finally {
285
+ releaseQuarantine(trash);
286
+ }
287
+ };
288
+ for (const entry of quarantineEntries(root)) {
289
+ const claimed = claimQuarantine(entry);
290
+ if (claimed !== null)
291
+ finish(claimed);
292
+ }
293
+ const first = stagePlan(stateDir, inUse(), now);
294
+ if (first.refused !== null)
295
+ return { removed, failed, refused: first.refused };
176
296
  for (const one of first.go) {
177
297
  const again = stagePlan(stateDir, inUse(), now);
178
- if (again.refused !== null || !again.go.some(other => other.path === one.path))
298
+ if (again.refused !== null || !again.go.some(other => other.path === one.path) || options.spare?.(one.path) === true)
179
299
  continue;
180
300
  try {
181
301
  if (lstatSync(one.path).isSymbolicLink() || dirname(one.path) !== root)
182
302
  continue;
183
- (options.remove ?? (path => rmSync(path, { recursive: true, force: true, maxRetries: 3 })))(one.path);
184
- removed.push(one.path);
303
+ const trash = quarantine(one.path, one);
304
+ if (trash !== null)
305
+ finish(trash);
185
306
  }
186
- catch {
187
- failed.push(one.path);
307
+ catch (error) {
308
+ if (error.code !== "ENOENT")
309
+ failed.push(one.path);
188
310
  }
189
311
  }
190
312
  return { removed, failed, refused: null };
@@ -0,0 +1,19 @@
1
+ import type { LiveInput } from "./live-input.js";
2
+ import { type SteerMailbox } from "./steer-mailbox.js";
3
+ import type { SteerNote, Store } from "./store.js";
4
+ /** How often the dispatcher looks: a note reaches a running Claude build within about this long. */
5
+ export declare const LIVE_TICK_MS = 500;
6
+ /** How long an unread note waits before it is sent again. */
7
+ export declare const LIVE_RESEND_MS = 90000;
8
+ /** How many times an unread note is sent again before it reads Not picked up. */
9
+ export declare const LIVE_RESENDS = 3;
10
+ export type LiveSteer = {
11
+ box: SteerMailbox;
12
+ session: LiveInput | null;
13
+ };
14
+ /** The stdin copy of a note: the same fenced text and key as its file, and what to do with the file. */
15
+ export declare function liveNoteText(box: SteerMailbox, number: number, note: SteerNote): string;
16
+ /** A re-send: only the note's number and file, and what to do with it. The note itself was sent in full once. */
17
+ export declare function liveReminderText(box: SteerMailbox, number: number): string;
18
+ /** One tick. `agentRunning`: the agent process is up, so a note written now can be read. */
19
+ export declare function tickLiveSteer(store: Store, steer: LiveSteer, now: Date, agentRunning: boolean): void;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Live steering's dispatcher: the builder runs one tick every half second
3
+ * while its agent works, independent of the 60-second lease pulse.
4
+ *
5
+ * - Every provider: what the agent moved to handled/ is recorded read, and a
6
+ * note filed since the last tick is written to the steering folder at once
7
+ * (steer-mailbox.ts). Codex reads it there.
8
+ * - A Claude build with live input (live-input.ts) also gets each note on
9
+ * stdin as soon as it is in the folder, each Pause as an interrupt and
10
+ * each Resume as a short continue frame — pauses first, so Pause and steer
11
+ * stops the turn before the note arrives.
12
+ * - A note the agent has not moved to handled/ is followed up every 90
13
+ * seconds, up to 3 times, with a short reminder naming its file — never
14
+ * the note again, so an agent that already acted on it does not redo the
15
+ * work; 90 seconds after the last one it reads Not picked up. A paused
16
+ * session is not reminded: only a new note or Resume continues it. Moving
17
+ * the file is still the only proof of reading, and a note still unread
18
+ * when the attempt ends reaches the next attempt's brief as before.
19
+ *
20
+ * Every step is bookkeeping: a failure waits for the next tick and never
21
+ * touches the build.
22
+ */
23
+ import { randomUUID } from "node:crypto";
24
+ import { fence } from "./brief-fence.js";
25
+ import { pulseSteerMailbox, STEER_HANDLED, steerNoteName } from "./steer-mailbox.js";
26
+ /** How often the dispatcher looks: a note reaches a running Claude build within about this long. */
27
+ export const LIVE_TICK_MS = 500;
28
+ /** How long an unread note waits before it is sent again. */
29
+ export const LIVE_RESEND_MS = 90_000;
30
+ /** How many times an unread note is sent again before it reads Not picked up. */
31
+ export const LIVE_RESENDS = 3;
32
+ /** The stdin copy of a note: the same fenced text and key as its file, and what to do with the file. */
33
+ export function liveNoteText(box, number, note) {
34
+ const name = steerNoteName(number);
35
+ return [
36
+ `Operator steering note ${number}, key ${box.key}, also saved as ${box.folder}/${name}:`,
37
+ fence(note.note),
38
+ `Act on it within the agreed scope, under the brief's rules for steering notes, then move ${box.folder}/${name} into ${box.folder}/${STEER_HANDLED}/ — moving the file is how the operator learns you read it.`,
39
+ ].join("\n");
40
+ }
41
+ /** A re-send: only the note's number and file, and what to do with it. The note itself was sent in full once. */
42
+ export function liveReminderText(box, number) {
43
+ const name = steerNoteName(number);
44
+ return `Steering note ${number} is still unread: ${box.folder}/${name}. Act on it if you have not already, then move it into ${box.folder}/${STEER_HANDLED}/.`;
45
+ }
46
+ /** One tick. `agentRunning`: the agent process is up, so a note written now can be read. */
47
+ export function tickLiveSteer(store, steer, now, agentRunning) {
48
+ const { box, session } = steer;
49
+ pulseSteerMailbox(store, box, now, agentRunning);
50
+ if (session === null || !session.open || !agentRunning)
51
+ return;
52
+ for (const pause of store.unsentPauses(box.runId)) {
53
+ const sent = session.interrupt(pause.requestId);
54
+ if (sent === "closed") {
55
+ store.endPause(pause.id, now);
56
+ continue;
57
+ }
58
+ store.markPauseSent(pause.id, now);
59
+ if (sent === "idle")
60
+ store.markRunPaused(box.runId, now);
61
+ }
62
+ for (const resume of store.unsentResumes(box.runId)) {
63
+ const sent = session.resume(randomUUID());
64
+ if (sent === "sent")
65
+ store.markResumeSent(resume.id, box.runId, now);
66
+ else
67
+ store.endResume(resume.id, now);
68
+ }
69
+ for (const [number, noteId] of [...box.waiting].sort((a, b) => a[0] - b[0])) {
70
+ const note = store.steerNote(noteId);
71
+ if (note === null || note.mailedRun !== box.runId || note.deliveredAt !== null || note.supersededAt !== null || note.liveMissedAt !== null)
72
+ continue;
73
+ const first = note.liveSends === 0;
74
+ if (!first) {
75
+ // A paused session waits for a new note; an old one sent again would continue it.
76
+ if (session.paused)
77
+ continue;
78
+ if (note.liveSentAt === null || now.getTime() - Date.parse(note.liveSentAt) < LIVE_RESEND_MS)
79
+ continue;
80
+ if (note.liveSends > LIVE_RESENDS) {
81
+ store.markLiveMissed(noteId, box.runId, now);
82
+ continue;
83
+ }
84
+ }
85
+ // The first send is the note itself, under its own identity; a re-send is a fresh frame (the harness may drop a
86
+ // repeated uuid) that only names the file.
87
+ if (!session.send(first ? liveNoteText(box, number, note) : liveReminderText(box, number), first ? (note.liveUuid ?? randomUUID()) : randomUUID()))
88
+ return;
89
+ store.recordLiveSend(noteId, box.runId, now);
90
+ if (first)
91
+ store.markRunResumed(box.runId, now);
92
+ }
93
+ }
@@ -0,0 +1,39 @@
1
+ import type { Store } from "./store.js";
2
+ export declare const STEER_HANDLED = "handled";
3
+ export type SteerMailbox = {
4
+ runId: number;
5
+ taskRef: number;
6
+ /** Worktree-relative, as the brief names it. */
7
+ folder: string;
8
+ dir: string;
9
+ /** The folder's real path, resolved once when it was made inside the worktree. */
10
+ realDir: string;
11
+ /** This attempt's note key: in the brief and in every genuine note, nowhere else. */
12
+ key: string;
13
+ /** The highest number this pulse has written. */
14
+ last: number;
15
+ /** Mailed and not yet moved to handled/: number → note id. */
16
+ waiting: Map<number, number>;
17
+ };
18
+ export declare const steerNoteName: (number: number) => string;
19
+ /** Make the folder and its handled/ inside the worktree; null when it cannot be made. */
20
+ export declare function openSteerMailbox(worktree: string, folder: string, runId: number, taskRef: number, key: string): SteerMailbox | null;
21
+ /** One beat: record what the agent read, then mail what is new while the agent runs. */
22
+ export declare function pulseSteerMailbox(store: Store, box: SteerMailbox, now: Date, agentRunning: boolean): void;
23
+ /** The attempt is over: one last read, then the folder goes. Unread notes stay pending for the next attempt. */
24
+ export declare function closeSteerMailbox(store: Store, box: SteerMailbox, now: Date): void;
25
+ /**
26
+ * Claude's doorbell: a synchronous PostToolUse hook that lists the folder and,
27
+ * while a note is unread, hands the agent one line of context between tool
28
+ * calls. It only says notes are waiting — it never quotes one, and its firing
29
+ * is never an acknowledgement. Rendered into the run's own --settings.
30
+ */
31
+ export declare function claudeSteerHooks(dir: string, folder: string): {
32
+ PostToolUse: {
33
+ matcher: string;
34
+ hooks: {
35
+ type: "command";
36
+ command: string;
37
+ }[];
38
+ }[];
39
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The running build's steering mailbox (v120). A note an operator files
3
+ * while a build runs used to wait for the next attempt; now the builder's
4
+ * pulse writes it into one nonce-named folder in the worktree, as a numbered
5
+ * file the agent reads and acknowledges by moving it into `handled/` — the
6
+ * same small contract a person's own inbox keeps:
7
+ *
8
+ * - The durable record is the delivery. A note is mailed (written into the
9
+ * folder) and read (moved by the agent); nothing else counts. A hook
10
+ * reminding the agent, or a file the pulse wrote, proves nothing.
11
+ * - Numbers are never reused: the next one is past every name in the folder
12
+ * and in `handled/`, and past every one this pulse has used.
13
+ * - Whatever is still unread when the attempt ends keeps today's road: the
14
+ * note stays pending, the next attempt's brief quotes it, and a task that
15
+ * ends first supersedes it.
16
+ *
17
+ * Every step is bookkeeping, and bookkeeping never fails a build: a folder
18
+ * the agent removed is made again, one it replaced with anything but a real
19
+ * directory is never written through, and an error waits for the next beat.
20
+ *
21
+ * Two boundaries, because the folder lives where the agent and anything it
22
+ * runs can write:
23
+ * - Every genuine note carries this attempt's key, which only the brief and
24
+ * the notes themselves name. A file in the folder without it was not
25
+ * written by the operator, and the brief tells the agent to act on none.
26
+ * (The brief rides the agent's argv, so a process that reads argv could
27
+ * learn it: this keeps ordinary repository code from posing as the
28
+ * operator, not a determined local process.)
29
+ * - The pulse writes only while the folder still resolves to this worktree's
30
+ * own folder, before and after each write, so swapping it for a link can
31
+ * never make the plane write a note somewhere else.
32
+ */
33
+ import { lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { fence } from "./brief-fence.js";
36
+ export const STEER_HANDLED = "handled";
37
+ const NOTE_NAME = /^(\d+)\.json$/;
38
+ export const steerNoteName = (number) => `${String(number).padStart(3, "0")}.json`;
39
+ const isRealDirectory = (path) => {
40
+ try {
41
+ return lstatSync(path).isDirectory();
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ };
47
+ /** Make the folder and its handled/ inside the worktree; null when it cannot be made. */
48
+ export function openSteerMailbox(worktree, folder, runId, taskRef, key) {
49
+ let realDir;
50
+ try {
51
+ realDir = join(realpathSync(worktree), folder);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ const box = { runId, taskRef, folder, dir: join(worktree, folder), realDir, key, last: 0, waiting: new Map() };
57
+ return ensureFolder(box) ? box : null;
58
+ }
59
+ /** The folder is still this worktree's own, not a link to somewhere else. */
60
+ const stillInside = (box) => {
61
+ try {
62
+ return realpathSync(box.dir) === box.realDir;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ };
68
+ function ensureFolder(box) {
69
+ for (const path of [box.dir, join(box.dir, STEER_HANDLED)]) {
70
+ try {
71
+ mkdirSync(path, { mode: 0o700 });
72
+ }
73
+ catch (error) {
74
+ if (error.code !== "EEXIST")
75
+ return false;
76
+ }
77
+ // Something the agent put in the folder's place is never written through.
78
+ if (!isRealDirectory(path))
79
+ return false;
80
+ }
81
+ return stillInside(box);
82
+ }
83
+ function highestNumber(box) {
84
+ let highest = box.last;
85
+ for (const path of [box.dir, join(box.dir, STEER_HANDLED)]) {
86
+ let names;
87
+ try {
88
+ names = readdirSync(path);
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ for (const name of names) {
94
+ const number = NOTE_NAME.exec(name)?.[1];
95
+ if (number !== undefined)
96
+ highest = Math.max(highest, Number(number));
97
+ }
98
+ }
99
+ return highest;
100
+ }
101
+ /** Every waiting note the agent moved into handled/ is read, by this run. */
102
+ function collectAcknowledgements(store, box, now) {
103
+ if (box.waiting.size === 0)
104
+ return;
105
+ const handled = join(box.dir, STEER_HANDLED);
106
+ if (!isRealDirectory(box.dir) || !isRealDirectory(handled))
107
+ return;
108
+ for (const [number, noteId] of [...box.waiting]) {
109
+ let moved;
110
+ try {
111
+ moved = lstatSync(join(handled, steerNoteName(number))).isFile();
112
+ }
113
+ catch {
114
+ moved = false;
115
+ }
116
+ if (!moved)
117
+ continue;
118
+ box.waiting.delete(number);
119
+ store.ackSteerNote(noteId, box.runId, now);
120
+ }
121
+ }
122
+ /** Remove a name this pulse wrote, only while the folder is still this worktree's own; a link goes, never its target. */
123
+ function removeOwn(box, name) {
124
+ if (!stillInside(box))
125
+ return;
126
+ try {
127
+ unlinkSync(join(box.dir, name));
128
+ }
129
+ catch { /* never written, or the agent already took it */ }
130
+ }
131
+ /** Write each note this run has not seen yet: a temp name, then the number. */
132
+ function mailNotes(store, box, now) {
133
+ const notes = store.mailableSteerNotes(box.taskRef, box.runId);
134
+ if (notes.length === 0 || !ensureFolder(box))
135
+ return;
136
+ for (const note of notes) {
137
+ if (!stillInside(box))
138
+ return;
139
+ const number = highestNumber(box) + 1;
140
+ const name = steerNoteName(number);
141
+ const temp = `.${name}.tmp`;
142
+ const body = { version: 1, number, key: box.key, filedAt: note.createdAt, note: fence(note.note) };
143
+ try {
144
+ writeFileSync(join(box.dir, temp), `${JSON.stringify(body, null, 2)}\n`, { flag: "wx", mode: 0o600 });
145
+ renameSync(join(box.dir, temp), join(box.dir, name));
146
+ }
147
+ catch {
148
+ removeOwn(box, temp);
149
+ return;
150
+ }
151
+ // Checked again after the write: a folder swapped for a link mid-write
152
+ // leaves the note unrecorded, deletes nothing, and mails nothing more this beat.
153
+ if (!stillInside(box))
154
+ return;
155
+ box.last = number;
156
+ let recorded;
157
+ try {
158
+ recorded = store.markSteerMailed(note.id, box.runId, now);
159
+ }
160
+ catch {
161
+ recorded = false;
162
+ }
163
+ if (recorded) {
164
+ box.waiting.set(number, note.id);
165
+ }
166
+ else {
167
+ // The task ended or the note settled between the read and the write, or
168
+ // the mailing could not be recorded: an unrecorded file would only be
169
+ // mailed twice, so it goes, and the next beat tries again.
170
+ removeOwn(box, name);
171
+ }
172
+ }
173
+ }
174
+ /** One beat: record what the agent read, then mail what is new while the agent runs. */
175
+ export function pulseSteerMailbox(store, box, now, agentRunning) {
176
+ collectAcknowledgements(store, box, now);
177
+ if (agentRunning)
178
+ mailNotes(store, box, now);
179
+ }
180
+ /** The attempt is over: one last read, then the folder goes. Unread notes stay pending for the next attempt. */
181
+ export function closeSteerMailbox(store, box, now) {
182
+ try {
183
+ collectAcknowledgements(store, box, now);
184
+ }
185
+ catch { /* the notes stay pending */ }
186
+ try {
187
+ rmSync(box.dir, { recursive: true, force: true });
188
+ }
189
+ catch { /* the next attempt's sweep removes it */ }
190
+ }
191
+ const shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
192
+ /**
193
+ * Claude's doorbell: a synchronous PostToolUse hook that lists the folder and,
194
+ * while a note is unread, hands the agent one line of context between tool
195
+ * calls. It only says notes are waiting — it never quotes one, and its firing
196
+ * is never an acknowledgement. Rendered into the run's own --settings.
197
+ */
198
+ export function claudeSteerHooks(dir, folder) {
199
+ const context = {
200
+ hookSpecificOutput: {
201
+ hookEventName: "PostToolUse",
202
+ additionalContext: `Operator steering waiting: %s unread note(s) in ${folder}/. Read them in number order now, act on each within the agreed scope, then move each into ${folder}/${STEER_HANDLED}/.`,
203
+ },
204
+ };
205
+ const command = `set -- ${shellQuote(dir)}/[0-9]*.json; [ -e "$1" ] || exit 0; printf ${shellQuote(`${JSON.stringify(context)}\n`)} "$#"`;
206
+ return { PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command }] }] };
207
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where a steering note stands, in the one set of words the task page, the
3
+ * CLI and the lead all use (v120). A note is read only when the agent proves
4
+ * it: the stream's receipt of a brief that quoted it, or the agent moving it
5
+ * to handled/ in a running build's steering folder. Everything else is
6
+ * either waiting for the agent that is running now, or for the next attempt.
7
+ */
8
+ import type { SteerNote } from "./store.js";
9
+ export type SteerStanding = "read" | "waiting" | "not-picked-up" | "next-attempt" | "ended" | "unverified";
10
+ /** `liveBuild`: the task has a build whose agent is running and reads its steering folder (Store.steeringLiveBuild). */
11
+ export declare function steerStandingOf(note: SteerNote, liveBuild: boolean): SteerStanding;
12
+ /** The words, and the moment the agent read it when it has: each surface renders that time its own way. */
13
+ export declare function steerStandingParts(note: SteerNote, liveBuild: boolean): {
14
+ words: string;
15
+ readAt: string | null;
16
+ };
17
+ export declare function steerStandingWords(note: SteerNote, liveBuild: boolean, time: (iso: string) => string): string;
18
+ /** Where a note filed now goes, said before or right after filing it. */
19
+ export declare function steerDestinationWords(liveBuild: boolean, liveInput?: boolean): string;
20
+ /** A paused live build's one sentence: until when it can wait before it ends, and how long that is (live-input.ts
21
+ * idleLimit), or only that it is paused when it has no idle limit. `time` renders the moment its surface's way. */
22
+ export declare function pausedWords(pause: {
23
+ until: string | null;
24
+ limitMs: number | null;
25
+ }, time: (iso: string) => string): string;