toolroll 0.9.60 → 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 (118) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/contracts/console-api.d.ts +3 -1
  34. package/dist/contracts/console-api.js +5 -1
  35. package/dist/contracts/lead-tools.d.ts +16 -2
  36. package/dist/contracts/lead-tools.js +5 -1
  37. package/dist/desktop-update.d.ts +1 -1
  38. package/dist/evidence-pack.js +1 -1
  39. package/dist/evidence.d.ts +6 -0
  40. package/dist/evidence.js +14 -3
  41. package/dist/exec.d.ts +32 -0
  42. package/dist/exec.js +229 -10
  43. package/dist/invoke.js +110 -18
  44. package/dist/landed-cleanup.d.ts +157 -0
  45. package/dist/landed-cleanup.js +707 -0
  46. package/dist/lead-cli.js +22 -19
  47. package/dist/lead-contract.js +1 -1
  48. package/dist/lead-doors.js +45 -1
  49. package/dist/lead-tools.js +40 -7
  50. package/dist/live-input.d.ts +68 -0
  51. package/dist/live-input.js +254 -0
  52. package/dist/live.d.ts +1 -1
  53. package/dist/live.js +4 -2
  54. package/dist/maintenance.d.ts +2 -0
  55. package/dist/maintenance.js +5 -2
  56. package/dist/operate-remote-arguments.js +5 -1
  57. package/dist/operate-remote.js +1 -1
  58. package/dist/operate.d.ts +1 -1
  59. package/dist/operate.js +188 -16
  60. package/dist/orphan-sweep.d.ts +2 -0
  61. package/dist/orphan-sweep.js +5 -3
  62. package/dist/project-delete-ui.d.ts +11 -0
  63. package/dist/project-delete-ui.js +29 -2
  64. package/dist/project-tools.d.ts +7 -11
  65. package/dist/project-tools.js +13 -37
  66. package/dist/provider.d.ts +65 -8
  67. package/dist/provider.js +80 -21
  68. package/dist/result-completion.d.ts +7 -0
  69. package/dist/result-completion.js +9 -0
  70. package/dist/runner.d.ts +4 -2
  71. package/dist/runner.js +3 -3
  72. package/dist/serve.js +19 -1
  73. package/dist/server/live.d.ts +3 -0
  74. package/dist/server/live.js +35 -2
  75. package/dist/server/pages.js +59 -35
  76. package/dist/server/render-chat.js +12 -0
  77. package/dist/server/render-tasks.d.ts +12 -2
  78. package/dist/server/render-tasks.js +27 -7
  79. package/dist/server/route-table.js +4 -1
  80. package/dist/server/settings.js +2 -1
  81. package/dist/server/task-views.js +9 -0
  82. package/dist/server/tasks.js +63 -0
  83. package/dist/shared-deps.d.ts +9 -4
  84. package/dist/shared-deps.js +89 -13
  85. package/dist/staged-runtimes.d.ts +16 -4
  86. package/dist/staged-runtimes.js +160 -38
  87. package/dist/steer-live.d.ts +19 -0
  88. package/dist/steer-live.js +93 -0
  89. package/dist/steer-status.d.ts +8 -2
  90. package/dist/steer-status.js +19 -5
  91. package/dist/storage-sweep-child.d.ts +1 -0
  92. package/dist/storage-sweep-child.js +76 -0
  93. package/dist/storage-sweep-process.d.ts +41 -0
  94. package/dist/storage-sweep-process.js +187 -0
  95. package/dist/storage-sweep.d.ts +30 -6
  96. package/dist/storage-sweep.js +214 -28
  97. package/dist/storage-ui.d.ts +2 -0
  98. package/dist/storage-ui.js +16 -4
  99. package/dist/storage.d.ts +6 -0
  100. package/dist/storage.js +35 -0
  101. package/dist/store.d.ts +163 -2
  102. package/dist/store.js +350 -15
  103. package/dist/surface.js +4 -2
  104. package/dist/sweep-quarantine.d.ts +28 -0
  105. package/dist/sweep-quarantine.js +153 -0
  106. package/dist/task-status.d.ts +2 -0
  107. package/dist/task-status.js +5 -1
  108. package/dist/test-temp.d.ts +6 -3
  109. package/dist/test-temp.js +15 -9
  110. package/dist/toolroll-update.js +3 -2
  111. package/dist/work-index.d.ts +1 -0
  112. package/dist/work-index.js +32 -12
  113. package/dist/work-summary.d.ts +2 -2
  114. package/dist/workspace-revision.js +3 -1
  115. package/dist/worktree.d.ts +3 -1
  116. package/dist/worktree.js +93 -10
  117. package/package.json +1 -1
  118. package/scripts/provider-canary.mjs +235 -147
@@ -0,0 +1,454 @@
1
+ /**
2
+ * The Agents board's saved state (one place to check on running agents).
3
+ *
4
+ * Every builder run is admitted with an opaque attempt key (run.attempt_key).
5
+ * The stream observer (agent-turns.ts) records each turn change — busy, idle,
6
+ * crashed — as one append-only agent_turn row, and moves the task's current
7
+ * snapshot (agent_now) only when the writer is the task's newest build
8
+ * attempt AND presents that run's own key: a late event from an earlier or
9
+ * replaced attempt is kept as history and never repaints the row. A protocol
10
+ * repair (role 'repair') mends its parent build and never owns the row: the
11
+ * board shows it as the parent's own activity ("Fixing the handoff").
12
+ * The same stream says which tool calls are open, so a long command reads
13
+ * as Working (Stuck only after TOOL_STUCK_AFTER_MS) rather than as silence.
14
+ *
15
+ * The rest rides the build's existing minute pulse (agent-pulse.ts): the
16
+ * transcript's size, the changed-file count, the agent's own status lines
17
+ * (working, needs-decision, blocked, done) and the four-minute Stuck mark.
18
+ * A needs-decision line is an agent_question; Reply closes exactly that key
19
+ * and files the answer as an ordinary verified steering note, which the
20
+ * steering mailbox delivers to the running agent.
21
+ *
22
+ * Who has seen a finished run (agent_seen) makes Done "not viewed yet"; who
23
+ * is watching a run right now (agent_view, short leases kept by the open
24
+ * page's live stream) holds that person's Needs you and Done pings: they stay
25
+ * pending in their delivery ledgers and go out once the watch lapses, only
26
+ * if the question is still open or the run still unseen (watchedPing).
27
+ * Display state, never evidence: nothing here changes a run's outcome.
28
+ */
29
+ import { randomBytes } from "node:crypto";
30
+ export const AGENT_TURNS = ["busy", "idle", "crashed"];
31
+ /** A busy run whose transcript and activity are both this old is Stuck. */
32
+ export const STUCK_AFTER_MS = 4 * 60_000;
33
+ /** With a tool call still open (a long test run is silent), it is Stuck only after this long. */
34
+ export const TOOL_STUCK_AFTER_MS = 15 * 60_000;
35
+ /** A page's watch on one run lapses this long after its last keep-alive (the stream's keep-alive is every 15 s). */
36
+ export const VIEW_LEASE_MS = 20_000;
37
+ export const QUESTION_KEY = /^[a-z0-9][a-z0-9-]{0,39}$/;
38
+ export const QUESTION_MAX = 300;
39
+ export const NOTE_MAX = 200;
40
+ export const ANSWER_MAX = 1500;
41
+ /** An agent is untrusted: at most this many of its questions are open at once per run… */
42
+ export const QUESTIONS_OPEN_MAX = 3;
43
+ /** …and at most this many are ever opened per attempt. A needs-decision past either opens nothing and pages no one. */
44
+ export const QUESTIONS_PER_ATTEMPT = 10;
45
+ /** At most one question page per run in this long; questions asked meanwhile wait for the next page. */
46
+ export const QUESTION_PAGE_EVERY_MS = 5 * 60_000;
47
+ /** The board's one note on a run whose agent asked past the caps (set once). */
48
+ export const QUESTIONS_OVERFLOW_NOTE = "The agent asked more questions than the board takes; see its transcript.";
49
+ /** Notification kinds held while the person watches the run, and re-checked when the watch lapses. */
50
+ export const WATCHED_KINDS = new Set(["agent-question", "run-finished", "build-failed"]);
51
+ export const AGENT_BOARD_SCHEMA = `
52
+ CREATE TABLE IF NOT EXISTS agent_turn (
53
+ id INTEGER PRIMARY KEY,
54
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
55
+ attempt_key TEXT NOT NULL,
56
+ state TEXT NOT NULL CHECK (state IN ('busy','idle','crashed')),
57
+ at TEXT NOT NULL
58
+ );
59
+ CREATE INDEX IF NOT EXISTS agent_turn_run ON agent_turn (run, id);
60
+ CREATE TABLE IF NOT EXISTS agent_now (
61
+ task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id) ON DELETE CASCADE,
62
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
63
+ attempt_key TEXT NOT NULL,
64
+ state TEXT NOT NULL CHECK (state IN ('busy','idle','crashed')),
65
+ state_at TEXT NOT NULL,
66
+ transcript_bytes INTEGER NOT NULL DEFAULT 0,
67
+ transcript_at TEXT NOT NULL,
68
+ stuck_at TEXT,
69
+ said TEXT CHECK (said IN ('working','blocked','done')),
70
+ said_note TEXT,
71
+ said_at TEXT,
72
+ status_offset INTEGER NOT NULL DEFAULT 0,
73
+ diff_files INTEGER,
74
+ diff_at TEXT
75
+ );
76
+ CREATE TABLE IF NOT EXISTS agent_question (
77
+ id INTEGER PRIMARY KEY,
78
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
79
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
80
+ attempt_key TEXT NOT NULL,
81
+ key TEXT NOT NULL,
82
+ question TEXT NOT NULL,
83
+ asked_at TEXT NOT NULL,
84
+ closed_at TEXT,
85
+ closed_how TEXT CHECK (closed_how IN ('answered','ended')),
86
+ answer_note INTEGER REFERENCES task_steer(id),
87
+ answered_by TEXT,
88
+ UNIQUE (run, key)
89
+ );
90
+ CREATE INDEX IF NOT EXISTS agent_question_open ON agent_question (task_ref) WHERE closed_at IS NULL;
91
+ CREATE TABLE IF NOT EXISTS agent_seen (
92
+ account TEXT NOT NULL,
93
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
94
+ seen_at TEXT NOT NULL,
95
+ PRIMARY KEY (account, run)
96
+ );
97
+ CREATE TABLE IF NOT EXISTS agent_view (
98
+ view TEXT PRIMARY KEY,
99
+ account TEXT NOT NULL,
100
+ run INTEGER NOT NULL,
101
+ expires_at TEXT NOT NULL
102
+ );
103
+ CREATE INDEX IF NOT EXISTS agent_view_run ON agent_view (run, account);
104
+ CREATE INDEX IF NOT EXISTS agent_board_open ON run (task_ref, id) WHERE role = 'builder' AND outcome IS NULL;
105
+ CREATE INDEX IF NOT EXISTS agent_board_recent ON run (finished_at, task_ref, id) WHERE role = 'builder' AND outcome IS NOT NULL;
106
+ CREATE INDEX IF NOT EXISTS agent_board_repair ON run (parent_run, id) WHERE role = 'repair';
107
+ `;
108
+ /** Added to agent_now after the table first shipped (store.ts addColumn): the oldest open tool call's kind and start. */
109
+ export const AGENT_NOW_COLUMNS = [["tool_kind", "TEXT"], ["tool_at", "TEXT"],
110
+ // The questions cap's note (set once), and the last question page: when it went and the newest question it covered.
111
+ ["question_note", "TEXT"], ["paged_at", "TEXT"], ["paged_upto", "INTEGER"]];
112
+ /** Unpredictable per attempt: the run row is the only place it is written down. */
113
+ export function mintAttemptKey() {
114
+ return randomBytes(12).toString("hex");
115
+ }
116
+ const text = (value) => value === null || value === undefined ? null : String(value);
117
+ /** A run's attempt key; null for a run admitted before the board existed (or no such run). */
118
+ export function attemptKeyOf(store, run) {
119
+ const row = store.handle.prepare("SELECT attempt_key FROM run WHERE id = ?").get(run);
120
+ return row === undefined ? null : text(row["attempt_key"]);
121
+ }
122
+ /** The task's newest build attempt: a repair mends its parent and never stands in for it. */
123
+ export const NEWEST_BUILDER = (task) => `(SELECT MAX(id) FROM run n WHERE n.task_ref = ${task} AND n.role = 'builder')`;
124
+ /** The build attempt a run belongs to on the board: a repair's parent, otherwise itself. */
125
+ export function boardRunOf(store, run) {
126
+ const row = store.handle.prepare("SELECT role, parent_run FROM run WHERE id = ?").get(run);
127
+ return row !== undefined && row["role"] === "repair" && row["parent_run"] != null ? Number(row["parent_run"]) : run;
128
+ }
129
+ /** The writer's own run, proved: it exists, carries exactly this key, and is the task's newest build attempt. */
130
+ function provedRun(store, run, attemptKey) {
131
+ const row = store.handle.prepare(`SELECT r.task_ref, r.attempt_key, ${NEWEST_BUILDER("r.task_ref")} AS newest
132
+ FROM run r WHERE r.id = ?`).get(run);
133
+ if (row === undefined || row["attempt_key"] === null || String(row["attempt_key"]) !== attemptKey)
134
+ return null;
135
+ return { taskRef: Number(row["task_ref"]), newest: Number(row["newest"]) === run };
136
+ }
137
+ /** The current snapshot moves only for the same attempt, or a strictly newer run; never back. */
138
+ const SAME_ATTEMPT = "agent_now.run = ?2 AND agent_now.attempt_key = ?3";
139
+ /**
140
+ * Record one turn change. Always appended to the run's own history (under its
141
+ * key); the task's current snapshot moves only when this run is the task's
142
+ * newest and is not already newer-owned. Returns whether the snapshot moved.
143
+ */
144
+ export function recordAgentTurn(store, fact) {
145
+ return store.transact(() => {
146
+ const proved = provedRun(store, fact.run, fact.attemptKey);
147
+ if (proved === null)
148
+ return false;
149
+ const at = fact.at.toISOString();
150
+ store.handle.prepare("INSERT INTO agent_turn (run, attempt_key, state, at) VALUES (?, ?, ?, ?)").run(fact.run, fact.attemptKey, fact.state, at);
151
+ if (!proved.newest)
152
+ return false;
153
+ const moved = store.handle.prepare(`INSERT INTO agent_now (task_ref, run, attempt_key, state, state_at, transcript_at)
154
+ VALUES (?1, ?2, ?3, ?4, ?5, ?5)
155
+ ON CONFLICT (task_ref) DO UPDATE SET
156
+ state = excluded.state, state_at = excluded.state_at,
157
+ stuck_at = CASE WHEN excluded.state = 'busy' AND agent_now.state = 'busy' THEN agent_now.stuck_at ELSE NULL END,
158
+ run = excluded.run, attempt_key = excluded.attempt_key,
159
+ transcript_at = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.transcript_at ELSE excluded.transcript_at END,
160
+ transcript_bytes = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.transcript_bytes ELSE 0 END,
161
+ said = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.said END,
162
+ said_note = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.said_note END,
163
+ said_at = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.said_at END,
164
+ status_offset = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.status_offset ELSE 0 END,
165
+ diff_files = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.diff_files END,
166
+ diff_at = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.diff_at END,
167
+ tool_kind = CASE WHEN ${SAME_ATTEMPT} AND excluded.state = 'busy' THEN agent_now.tool_kind END,
168
+ tool_at = CASE WHEN ${SAME_ATTEMPT} AND excluded.state = 'busy' THEN agent_now.tool_at END,
169
+ question_note = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.question_note END,
170
+ paged_at = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.paged_at END,
171
+ paged_upto = CASE WHEN ${SAME_ATTEMPT} THEN agent_now.paged_upto END
172
+ WHERE agent_now.run < excluded.run OR (${SAME_ATTEMPT} AND agent_now.state <> excluded.state)`)
173
+ .run(proved.taskRef, fact.run, fact.attemptKey, fact.state, at);
174
+ return Number(moved.changes) > 0;
175
+ });
176
+ }
177
+ /** One run's turn history, oldest first. */
178
+ export function agentTurnHistory(store, run) {
179
+ return store.handle.prepare("SELECT attempt_key, state, at FROM agent_turn WHERE run = ? ORDER BY id").all(run)
180
+ .map(row => ({ attemptKey: String(row["attempt_key"]), state: String(row["state"]), at: String(row["at"]) }));
181
+ }
182
+ function readNow(row) {
183
+ return {
184
+ taskRef: Number(row["task_ref"]), run: Number(row["run"]), attemptKey: String(row["attempt_key"]), state: String(row["state"]),
185
+ stateAt: String(row["state_at"]), transcriptBytes: Number(row["transcript_bytes"] ?? 0), transcriptAt: String(row["transcript_at"]),
186
+ stuckAt: text(row["stuck_at"]), said: text(row["said"]), saidNote: text(row["said_note"]), saidAt: text(row["said_at"]),
187
+ statusOffset: Number(row["status_offset"] ?? 0), diffFiles: row["diff_files"] == null ? null : Number(row["diff_files"]), diffAt: text(row["diff_at"]),
188
+ toolKind: text(row["tool_kind"]), toolAt: text(row["tool_at"]),
189
+ questionNote: text(row["question_note"]), pagedAt: text(row["paged_at"]), pagedUpto: Number(row["paged_upto"] ?? 0),
190
+ };
191
+ }
192
+ /** The task's current snapshot, whichever run owns it. */
193
+ export function agentNowForTask(store, taskRef) {
194
+ const row = store.handle.prepare("SELECT * FROM agent_now WHERE task_ref = ?").get(taskRef);
195
+ return row === undefined ? null : readNow(row);
196
+ }
197
+ /** The snapshot this attempt owns, or null when another (newer) attempt does or none was recorded. */
198
+ export function agentNowForRun(store, run, attemptKey) {
199
+ const row = store.handle.prepare("SELECT * FROM agent_now WHERE run = ? AND attempt_key = ?").get(run, attemptKey);
200
+ return row === undefined ? null : readNow(row);
201
+ }
202
+ /** Fields the pulse may move, for the attempt that owns the snapshot only. */
203
+ function updateOwned(store, run, attemptKey, set, values) {
204
+ const moved = store.handle.prepare(`UPDATE agent_now SET ${set} WHERE run = ? AND attempt_key = ?`).run(...values, run, attemptKey);
205
+ return Number(moved.changes) > 0;
206
+ }
207
+ /** The pulse saw the live transcript's size: new bytes are news, and news is not Stuck. */
208
+ export function noteTranscript(store, run, attemptKey, bytes, at) {
209
+ const now = agentNowForRun(store, run, attemptKey);
210
+ if (now === null || now.transcriptBytes === bytes)
211
+ return false;
212
+ return updateOwned(store, run, attemptKey, "transcript_bytes = ?, transcript_at = ?, stuck_at = NULL", [bytes, at.toISOString()]);
213
+ }
214
+ /** The changed-file count the pulse observed (null: it could not look). */
215
+ export function noteDiff(store, run, attemptKey, files, at) {
216
+ const now = agentNowForRun(store, run, attemptKey);
217
+ if (now === null || now.diffFiles === files)
218
+ return false;
219
+ return updateOwned(store, run, attemptKey, "diff_files = ?, diff_at = ?", [files, at.toISOString()]);
220
+ }
221
+ /** The stream opened or closed a tool call: the oldest still open, or null. The stream moved, so it clears Stuck. */
222
+ export function noteOpenTool(store, run, attemptKey, open, at) {
223
+ return updateOwned(store, run, attemptKey, "tool_kind = ?, tool_at = ?, transcript_at = max(transcript_at, ?), stuck_at = NULL", [open?.kind ?? null, open === null ? null : open.at.toISOString(), at.toISOString()]);
224
+ }
225
+ /** When the agent last showed a sign of life: its turn, transcript, recorded activity or the tool it opened. */
226
+ export function quietSince(now, activityAt) {
227
+ return Math.max(...[now.stateAt, now.transcriptAt, activityAt, now.toolAt ?? null].filter((one) => one !== null).map(one => Date.parse(one)));
228
+ }
229
+ /**
230
+ * Stuck, from saved facts and the clock alone (no model, no tokens): busy, and
231
+ * neither the transcript, the recorded activity nor the turn itself has moved
232
+ * for STUCK_AFTER_MS — or for TOOL_STUCK_AFTER_MS while a tool call is open.
233
+ */
234
+ export function stuckSince(now, activityAt, clock) {
235
+ if (now.state !== "busy")
236
+ return null;
237
+ const latest = quietSince(now, activityAt);
238
+ const after = (now.toolAt ?? null) === null ? STUCK_AFTER_MS : TOOL_STUCK_AFTER_MS;
239
+ const quiet = clock.getTime() - latest;
240
+ return Number.isFinite(quiet) && quiet >= after ? new Date(latest + after).toISOString() : null;
241
+ }
242
+ /** The newest recorded activity of a run: its last kind, or its newest progress checkpoint. */
243
+ function activityAtOf(store, run) {
244
+ const row = store.handle.prepare(`SELECT max(coalesce((SELECT at FROM run_activity WHERE run = ?1), ''),
245
+ coalesce((SELECT created_at FROM run_checkpoint WHERE run = ?1 ORDER BY id DESC LIMIT 1), '')) AS at`).get(run);
246
+ const at = row === undefined ? "" : String(row["at"] ?? "");
247
+ return at === "" ? null : at;
248
+ }
249
+ /** The pulse persists the Stuck crossing (and its clearing), so open boards hear it through /live. */
250
+ export function markStuck(store, run, attemptKey, clock) {
251
+ const now = agentNowForRun(store, run, attemptKey);
252
+ if (now === null)
253
+ return false;
254
+ const stuck = stuckSince(now, activityAtOf(store, run), clock);
255
+ if ((stuck === null) === (now.stuckAt === null))
256
+ return false;
257
+ return updateOwned(store, run, attemptKey, "stuck_at = ?", [stuck]);
258
+ }
259
+ /**
260
+ * The agent's status lines since the saved offset, for the attempt that owns
261
+ * the snapshot. The offset only moves forward; a needs-decision opens its key
262
+ * once (a key already asked this run is never reopened), within the caps:
263
+ * QUESTIONS_OPEN_MAX open at once and QUESTIONS_PER_ATTEMPT in all. One past
264
+ * either opens nothing and pages no one; the run gets QUESTIONS_OVERFLOW_NOTE,
265
+ * once. New questions page the people the task reaches (pageQuestions).
266
+ */
267
+ export function ingestStatus(store, run, attemptKey, read, at) {
268
+ return store.transact(() => {
269
+ const now = agentNowForRun(store, run, attemptKey);
270
+ if (now === null || read.nextOffset <= now.statusOffset)
271
+ return { asked: [], moved: false };
272
+ const iso = at.toISOString();
273
+ const asked = [];
274
+ let said = null;
275
+ let overflow = false;
276
+ const known = store.handle.prepare("SELECT 1 AS hit FROM agent_question WHERE run = ? AND key = ?");
277
+ const counts = store.handle.prepare(`SELECT count(*) AS total, coalesce(sum(closed_at IS NULL), 0) AS open
278
+ FROM agent_question WHERE run = ? AND attempt_key = ?`);
279
+ for (const line of read.lines) {
280
+ if (line.status === "needs-decision") {
281
+ if (known.get(run, line.key) !== undefined)
282
+ continue;
283
+ const count = counts.get(run, attemptKey);
284
+ if (Number(count["open"]) >= QUESTIONS_OPEN_MAX || Number(count["total"]) >= QUESTIONS_PER_ATTEMPT) {
285
+ overflow = true;
286
+ continue;
287
+ }
288
+ store.handle.prepare(`INSERT INTO agent_question (run, task_ref, attempt_key, key, question, asked_at)
289
+ VALUES (?, ?, ?, ?, ?, ?)`).run(run, now.taskRef, attemptKey, line.key, line.question, iso);
290
+ asked.push(line.key);
291
+ }
292
+ else {
293
+ said = line;
294
+ }
295
+ }
296
+ updateOwned(store, run, attemptKey, said === null ? "status_offset = ?" : "status_offset = ?, said = ?, said_note = ?, said_at = ?", said === null ? [read.nextOffset] : [read.nextOffset, said.status, said.note, iso]);
297
+ const noted = overflow && now.questionNote === null && updateOwned(store, run, attemptKey, "question_note = ?", [QUESTIONS_OVERFLOW_NOTE]);
298
+ pageQuestions(store, run, attemptKey, at);
299
+ return { asked, moved: said !== null || asked.length > 0 || noted };
300
+ });
301
+ }
302
+ /**
303
+ * Page the run's open questions no page has covered yet, at most once per
304
+ * QUESTION_PAGE_EVERY_MS. A question asked outside that window, alone, pages
305
+ * at once with its own words; questions asked inside it wait, and the next
306
+ * page after the window says how many are waiting. The pulse calls this every
307
+ * beat, so the waiting ones go out when the window ends.
308
+ */
309
+ export function pageQuestions(store, run, attemptKey, at) {
310
+ const due = () => {
311
+ const now = agentNowForRun(store, run, attemptKey);
312
+ if (now === null)
313
+ return null;
314
+ const lastPage = now.pagedAt === null ? null : Date.parse(now.pagedAt);
315
+ if (lastPage !== null && at.getTime() - lastPage < QUESTION_PAGE_EVERY_MS)
316
+ return null;
317
+ const rows = store.handle.prepare(`SELECT q.id, q.key, q.question, q.asked_at, t.title
318
+ FROM agent_question q JOIN task_ref r ON r.id = q.task_ref JOIN task t ON t.id = r.external_id
319
+ WHERE q.run = ? AND q.attempt_key = ? AND q.closed_at IS NULL ORDER BY q.id`).all(run, attemptKey);
320
+ const waiting = rows.filter(row => Number(row["id"]) > now.pagedUpto);
321
+ return waiting.length === 0 ? null : { lastPage, rows, waiting };
322
+ };
323
+ // Read first: most beats have nothing waiting, and need no write.
324
+ if (due() === null)
325
+ return false;
326
+ return store.transact(() => {
327
+ const found = due();
328
+ if (found === null)
329
+ return false;
330
+ const { lastPage, rows, waiting } = found;
331
+ const newest = waiting[waiting.length - 1];
332
+ const title = String(newest["title"]);
333
+ const alone = waiting.length === 1 && (lastPage === null || Date.parse(String(newest["asked_at"])) >= lastPage + QUESTION_PAGE_EVERY_MS);
334
+ const page = { kind: "agent-question", pushClass: "attention", link: `/agents?run=${run}`, source: { run } };
335
+ if (alone) {
336
+ store.enqueueNotification({ ...page, dedupeKey: `agent-question:${run}:${String(newest["key"])}`,
337
+ subject: `${title}: the agent has a question`, body: String(newest["question"]) }, at);
338
+ }
339
+ else {
340
+ // "~" never starts a question key, so this never takes a question's own dedupe key.
341
+ store.enqueueNotification({ ...page, dedupeKey: `agent-question:${run}:~${Number(newest["id"])}`,
342
+ subject: `${title}: the agent has questions`, body: questionsWaiting(rows.length) }, at);
343
+ }
344
+ updateOwned(store, run, attemptKey, "paged_at = ?, paged_upto = ?", [at.toISOString(), Number(newest["id"])]);
345
+ return true;
346
+ });
347
+ }
348
+ export const questionsWaiting = (count) => `${count} ${count === 1 ? "question" : "questions"} waiting`;
349
+ const readQuestion = (row) => ({ id: Number(row["id"]), run: Number(row["run"]), taskRef: Number(row["task_ref"]),
350
+ key: String(row["key"]), question: String(row["question"]), askedAt: String(row["asked_at"]) });
351
+ /** A task's open questions, oldest first: only its newest run's, and only while that run is open. */
352
+ export function openAgentQuestions(store, taskRef) {
353
+ return store.handle.prepare(`SELECT q.* FROM agent_question q JOIN run r ON r.id = q.run
354
+ WHERE q.task_ref = ? AND q.closed_at IS NULL AND r.outcome IS NULL AND r.attempt_key = q.attempt_key ORDER BY q.id`).all(taskRef).map(readQuestion);
355
+ }
356
+ export function agentQuestion(store, id) {
357
+ const row = store.handle.prepare("SELECT * FROM agent_question WHERE id = ?").get(id);
358
+ return row === undefined ? null : { ...readQuestion(row), closedAt: text(row["closed_at"]), closedHow: text(row["closed_how"]),
359
+ answerNote: row["answer_note"] == null ? null : Number(row["answer_note"]) };
360
+ }
361
+ /** A run ended: every question it still had open is closed unanswered (its answer would reach no one). */
362
+ export function closeRunQuestions(store, run, at) {
363
+ const closed = Number(store.handle.prepare("UPDATE agent_question SET closed_at = ?, closed_how = 'ended' WHERE run = ? AND closed_at IS NULL")
364
+ .run(at.toISOString(), run).changes);
365
+ if (closed > 0) {
366
+ const head = `agent-question:${run}:`;
367
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE substr(dedupe_key, 1, ?) = ? AND resolved_at IS NULL").run(at.toISOString(), head.length, head);
368
+ }
369
+ return closed;
370
+ }
371
+ /** The steering note a reply becomes: the agent reads it in its steering folder, under its own key. */
372
+ export function replyNoteText(key, answer) {
373
+ return `Reply to your question "${key}": ${answer}`;
374
+ }
375
+ /**
376
+ * Reply to one open question, atomically: the question is still open, its run
377
+ * is still the task's open newest attempt under the same key, the answer saves
378
+ * as an ordinary verified steering note (the mailbox pulse delivers it), and
379
+ * exactly that key closes. Anything stale changes nothing.
380
+ */
381
+ export function answerAgentQuestion(store, reply) {
382
+ return store.transact(() => {
383
+ const answer = reply.answer.trim();
384
+ if (answer === "" || answer.length > ANSWER_MAX)
385
+ return { ok: false, reason: "invalid-answer", problem: `Write an answer of up to ${ANSWER_MAX} characters.` };
386
+ const row = store.handle.prepare(`SELECT q.*, r.outcome, r.attempt_key AS run_key, tr.external_id AS task_id,
387
+ ${NEWEST_BUILDER("q.task_ref")} AS newest
388
+ FROM agent_question q JOIN run r ON r.id = q.run JOIN task_ref tr ON tr.id = q.task_ref WHERE q.id = ?`).get(reply.question);
389
+ if (row === undefined)
390
+ return { ok: false, reason: "no-question" };
391
+ if (row["closed_at"] !== null)
392
+ return { ok: false, reason: "closed" };
393
+ if (row["outcome"] !== null || text(row["run_key"]) !== String(row["attempt_key"]) || Number(row["newest"]) !== Number(row["run"])) {
394
+ return { ok: false, reason: "stale" };
395
+ }
396
+ const key = String(row["key"]);
397
+ const filed = reply.file(String(row["task_id"]), replyNoteText(key, answer));
398
+ if (!filed.ok)
399
+ return { ok: false, reason: "not-saved", ...(filed.problem === undefined ? {} : { problem: filed.problem }) };
400
+ const closed = store.handle.prepare(`UPDATE agent_question SET closed_at = ?, closed_how = 'answered', answer_note = ?, answered_by = ?
401
+ WHERE id = ? AND closed_at IS NULL`).run(reply.at.toISOString(), filed.id, reply.author, reply.question);
402
+ if (Number(closed.changes) !== 1)
403
+ throw new Error("the question closed while the reply was saved");
404
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE dedupe_key = ? AND resolved_at IS NULL")
405
+ .run(reply.at.toISOString(), `agent-question:${String(row["run"])}:${key}`);
406
+ // The last open question answered: a "questions waiting" page has nothing left to wait on.
407
+ const left = store.handle.prepare("SELECT 1 AS hit FROM agent_question WHERE run = ? AND closed_at IS NULL LIMIT 1").get(row["run"]);
408
+ if (left === undefined) {
409
+ const head = `agent-question:${String(row["run"])}:~`;
410
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE substr(dedupe_key, 1, ?) = ? AND resolved_at IS NULL").run(reply.at.toISOString(), head.length, head);
411
+ }
412
+ return { ok: true, note: filed.id, key };
413
+ });
414
+ }
415
+ // ---- who has seen, who is watching ------------------------------------------
416
+ /** This person opened a finished run (on the board or its result page): it leaves their unread Done. */
417
+ export function markAgentSeen(store, account, run, at) {
418
+ return Number(store.handle.prepare("INSERT OR IGNORE INTO agent_seen (account, run, seen_at) VALUES (?, ?, ?)").run(account, boardRunOf(store, run), at.toISOString()).changes) > 0;
419
+ }
420
+ /** A page is watching a run: hold (or renew) its short lease. */
421
+ export function holdAgentView(store, view, account, run, at) {
422
+ store.handle.prepare(`INSERT INTO agent_view (view, account, run, expires_at) VALUES (?, ?, ?, ?)
423
+ ON CONFLICT (view) DO UPDATE SET expires_at = excluded.expires_at`).run(view, account, run, new Date(at.getTime() + VIEW_LEASE_MS).toISOString());
424
+ }
425
+ export function releaseAgentView(store, view, at) {
426
+ store.handle.prepare("DELETE FROM agent_view WHERE view = ? OR expires_at < ?").run(view, at.toISOString());
427
+ }
428
+ /** Until when this person has this exact run open (its repairs count as the run); null when they do not. */
429
+ export function watchingAgentUntil(store, account, run, at) {
430
+ const row = store.handle.prepare("SELECT max(expires_at) AS until FROM agent_view WHERE run = ? AND account = ? AND expires_at > ?")
431
+ .get(boardRunOf(store, run), account, at.toISOString());
432
+ return row === undefined || row["until"] == null ? null : String(row["until"]);
433
+ }
434
+ /** Someone has this exact run open right now. */
435
+ export function watchingAgent(store, account, run, at) {
436
+ return watchingAgentUntil(store, account, run, at) !== null;
437
+ }
438
+ /**
439
+ * A watched ping, decided when its delivery is claimed (every chat, phone and browser asks here, after the
440
+ * permanent rules in store.pingAllowed): held until the watch lapses while this person has the run open; then
441
+ * skipped when they have already seen the finished run (an answered question is resolved, so never claimed);
442
+ * otherwise sent. Anything that is not a watched kind about a run is sent.
443
+ */
444
+ export function watchedPing(store, row, account, now) {
445
+ if (row.run === null || !WATCHED_KINDS.has(row.kind))
446
+ return "send";
447
+ const until = watchingAgentUntil(store, account, row.run, now);
448
+ if (until !== null)
449
+ return { hold: until };
450
+ if (row.kind === "agent-question")
451
+ return "send";
452
+ const seen = store.handle.prepare("SELECT 1 AS hit FROM agent_seen WHERE account = ? AND run = ?").get(account, boardRunOf(store, row.run));
453
+ return seen === undefined ? "send" : "skip";
454
+ }
@@ -13,7 +13,26 @@ export declare function agentFence(options: {
13
13
  worktree: string | null;
14
14
  home?: string;
15
15
  extra?: readonly string[];
16
+ boundary?: TaskBoundary | null;
16
17
  }): string[];
18
+ /** The task's boundary: `shell` joins the OS fence; `fileTools` only Claude's file-tool deny rules. */
19
+ export type TaskBoundary = {
20
+ shell: string[];
21
+ fileTools: string[];
22
+ };
23
+ /**
24
+ * Everything outside a task's own folders that a build could otherwise
25
+ * reach: the home configuration above, the other repositories beside its
26
+ * project (and other tasks' worktrees beside its own), and the project's
27
+ * primary checkout — all but its `.git`, which the worktree's git needs.
28
+ * The task's worktree, the files Toolroll makes for the run (in the
29
+ * system temp folder) and everything else stay as they are.
30
+ */
31
+ export declare function taskBoundary(options: {
32
+ worktree: string;
33
+ provider: string;
34
+ home?: string;
35
+ }): TaskBoundary;
17
36
  /**
18
37
  * Codex's own sandbox, fenced. Auto: the `:workspace` profile Codex's
19
38
  * `--sandbox workspace-write` already gives (workspace and temp writes, no
@@ -20,6 +20,10 @@
20
20
  *
21
21
  * Checked against codex-cli 0.156 and Claude Code 2.1.281 with a canary
22
22
  * file read by an obfuscated script: "Operation not permitted".
23
+ *
24
+ * A build's fence also holds its task boundary (`taskBoundary`): home
25
+ * configuration and the repositories beside its own, so a build works in
26
+ * its task's folders and nowhere else it could do harm.
23
27
  */
24
28
  import { spawnSync } from "node:child_process";
25
29
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
@@ -97,25 +101,113 @@ export function agentFence(options) {
97
101
  for (const path of options.extra ?? [])
98
102
  if (existsSync(path))
99
103
  fence.add(real(path));
104
+ for (const path of options.boundary?.shell ?? [])
105
+ fence.add(path);
100
106
  // Never fence the run's own worktree or anything above it.
101
107
  return [...fence].filter(path => worktree === null || (path !== worktree && !inside(worktree, path))).sort();
102
108
  }
109
+ /**
110
+ * Credentials and agent configuration in the home folder. A build is the
111
+ * operator's own OS user, so without this it could read their SSH keys,
112
+ * cloud logins or another agent's sign-in, or rewrite their own Claude or
113
+ * Codex setup for the next interactive session.
114
+ */
115
+ const HOME_CONFIGURATION = [".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json")];
116
+ /** What a provider's own CLI reads to sign in and keep its sessions. The OS fence around Claude and Gemini holds
117
+ * their whole process, so those stay reachable there; Codex fences only its agent's commands, so it keeps none. */
118
+ const PROVIDER_HOME = { claude: [".claude", ".claude.json"], gemini: [".gemini"], codex: [], openrouter: [] };
119
+ /** What Claude's own file tools refuse inside its home: the CLI reads these itself, its model never needs to. */
120
+ const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.json"), join(".claude", "settings.json"), join(".claude", "settings.local.json"), ".npmrc"];
121
+ /**
122
+ * Everything outside a task's own folders that a build could otherwise
123
+ * reach: the home configuration above, the other repositories beside its
124
+ * project (and other tasks' worktrees beside its own), and the project's
125
+ * primary checkout — all but its `.git`, which the worktree's git needs.
126
+ * The task's worktree, the files Toolroll makes for the run (in the
127
+ * system temp folder) and everything else stay as they are.
128
+ */
129
+ export function taskBoundary(options) {
130
+ const home = real(options.home ?? homedir());
131
+ const worktree = real(options.worktree);
132
+ const main = mainRepositoryOf(worktree);
133
+ const own = new Set(PROVIDER_HOME[options.provider] ?? []);
134
+ const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
135
+ const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
136
+ const repository = (path) => existsSync(join(path, ".git"));
137
+ for (const parent of new Set([dirname(worktree), ...(main === null ? [] : [dirname(main)])])) {
138
+ // Home contains toolchain clones such as .nvm and .oh-my-zsh. The
139
+ // explicit configuration fence above owns home, not repository discovery.
140
+ if (real(parent) === home)
141
+ continue;
142
+ let entries = [];
143
+ try {
144
+ entries = readdirSync(parent);
145
+ }
146
+ catch {
147
+ entries = [];
148
+ }
149
+ for (const entry of entries) {
150
+ if (entry.startsWith("."))
151
+ continue;
152
+ const path = join(parent, entry);
153
+ if (path === worktree || path === main || inside(worktree, path) || !repository(path))
154
+ continue;
155
+ shell.add(path);
156
+ }
157
+ }
158
+ if (main !== null && main !== worktree) {
159
+ let entries = [];
160
+ try {
161
+ entries = readdirSync(main);
162
+ }
163
+ catch {
164
+ entries = [];
165
+ }
166
+ for (const entry of entries) {
167
+ const path = join(main, entry);
168
+ if (entry !== ".git" && path !== worktree && !inside(worktree, path))
169
+ shell.add(path);
170
+ }
171
+ }
172
+ // Git needs this worktree's metadata and the shared objects/refs, but
173
+ // must not prune or rewrite the metadata of another task's checkout.
174
+ const gitdir = worktreeGitDir(worktree);
175
+ if (gitdir !== null && main !== null) {
176
+ const siblings = dirname(gitdir);
177
+ let entries = [];
178
+ try {
179
+ entries = readdirSync(siblings);
180
+ }
181
+ catch {
182
+ entries = [];
183
+ }
184
+ for (const entry of entries) {
185
+ const path = real(join(siblings, entry));
186
+ if (path !== gitdir)
187
+ shell.add(path);
188
+ }
189
+ }
190
+ const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
191
+ return { shell: [...shell].sort(), fileTools: fileTools.sort() };
192
+ }
103
193
  /** The repository a git worktree belongs to (its `.git` file names the main repository's git folder). */
104
- function mainRepositoryOf(worktree) {
194
+ function worktreeGitDir(worktree) {
105
195
  try {
106
196
  const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(join(worktree, ".git"), "utf8"))?.[1]?.trim();
107
- if (pointer === undefined)
108
- return null;
109
- const gitdir = real(isAbsolute(pointer) ? pointer : join(worktree, pointer));
110
- // <repo>/.git/worktrees/<name> → <repo>
111
- const marker = `${sep}.git${sep}worktrees${sep}`;
112
- const at = gitdir.lastIndexOf(marker);
113
- return at < 0 ? null : gitdir.slice(0, at);
197
+ return pointer === undefined ? null : real(isAbsolute(pointer) ? pointer : join(worktree, pointer));
114
198
  }
115
199
  catch {
116
200
  return null;
117
201
  }
118
202
  }
203
+ function mainRepositoryOf(worktree) {
204
+ const gitdir = worktreeGitDir(worktree);
205
+ if (gitdir === null)
206
+ return null;
207
+ // <repo>/.git/worktrees/<name> → <repo>
208
+ const at = gitdir.lastIndexOf(`${sep}.git${sep}worktrees${sep}`);
209
+ return at < 0 ? null : gitdir.slice(0, at);
210
+ }
119
211
  const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
120
212
  /**
121
213
  * Codex's own sandbox, fenced. Auto: the `:workspace` profile Codex's
@@ -0,0 +1,43 @@
1
+ import { type StatusIngest, type StatusLine } from "./agent-board.js";
2
+ import { type BaseTreeEntry } from "./peek.js";
3
+ import type { Store } from "./store.js";
4
+ /** The whole file may grow this far; past it, nothing more is read. */
5
+ export declare const STATUS_FILE_CAP: number;
6
+ /** One status line, strictly: a known status and its fields, or null. */
7
+ export declare function parseStatusLine(line: string): StatusLine | null;
8
+ export type StatusRead = {
9
+ ok: true;
10
+ read: StatusIngest;
11
+ } | {
12
+ ok: false;
13
+ reason: "missing" | "replaced" | "unreadable" | "full";
14
+ };
15
+ /** The complete lines after `from`, and where the next read starts. */
16
+ export declare function readStatusFile(path: string, from: number): StatusRead;
17
+ /** Create the attempt's empty status file (created here, never adopted); null when it cannot be. */
18
+ export declare function openStatusFile(worktree: string, name: string): string | null;
19
+ export declare function removeStatusFile(path: string | null): void;
20
+ /** The brief's few lines: where to write, what each status means, and how a reply arrives. */
21
+ export declare function statusBrief(name: string, steerFolder: string | null): string;
22
+ type BaseEntries = readonly BaseTreeEntry[];
23
+ /** The run's verified base snapshot, as the live peek reads it; null when it was not captured cleanly. */
24
+ export declare function baseEntriesOf(store: Store, root: string, run: number): BaseEntries | null;
25
+ /** What a look at the checkout found: changed, deleted and new files together; null when it could not look. */
26
+ export declare function changedFileCount(worktree: string, base: BaseEntries): Promise<number | null>;
27
+ export type AgentPulseState = {
28
+ run: number;
29
+ attemptKey: string;
30
+ root: string;
31
+ worktree: string;
32
+ /** The status file, or null when this attempt has none. */
33
+ status: string | null;
34
+ base: BaseEntries | null | undefined;
35
+ looking: boolean;
36
+ };
37
+ /**
38
+ * One beat for the board. Each part is its own fail-soft step: a status file
39
+ * that cannot be read, or a checkout that cannot be looked at, costs only
40
+ * its own line on the board, never the build.
41
+ */
42
+ export declare function pulseAgentBoard(store: Store, state: AgentPulseState, now: Date): void;
43
+ export {};