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.
- package/CHANGELOG.md +26 -0
- package/README.md +8 -6
- package/dist/agent-board.d.ts +208 -0
- package/dist/agent-board.js +454 -0
- package/dist/agent-fence.d.ts +19 -0
- package/dist/agent-fence.js +100 -8
- package/dist/agent-pulse.d.ts +43 -0
- package/dist/agent-pulse.js +214 -0
- package/dist/agent-turns.d.ts +52 -0
- package/dist/agent-turns.js +137 -0
- package/dist/agents-board.d.ts +101 -0
- package/dist/agents-board.js +225 -0
- package/dist/assignment-adapters.d.ts +2 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +14 -5
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +10 -2
- package/dist/assignment.js +21 -14
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +29 -25
- package/dist/browser-crew.js +2 -0
- package/dist/browser-workspace.d.ts +79 -1
- package/dist/browser-workspace.js +1 -0
- package/dist/builder.d.ts +8 -0
- package/dist/builder.js +178 -29
- package/dist/chat-channel.js +10 -1
- package/dist/chat-core.d.ts +6 -0
- package/dist/chat-core.js +8 -0
- package/dist/chat-delivery.js +22 -0
- package/dist/chat-messages.js +8 -1
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +2 -0
- package/dist/chat-voice.js +2 -0
- package/dist/checkout-cleanup.d.ts +3 -0
- package/dist/checkout-cleanup.js +11 -2
- package/dist/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- package/dist/contracts/console-api.d.ts +3 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/contracts/lead-tools.d.ts +16 -2
- package/dist/contracts/lead-tools.js +5 -1
- package/dist/design/tokens.css +234 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord.js +2 -1
- package/dist/evidence-pack.js +1 -1
- package/dist/evidence.d.ts +28 -0
- package/dist/evidence.js +87 -17
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +229 -10
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- package/dist/invoke.js +110 -18
- package/dist/landed-cleanup.d.ts +157 -0
- package/dist/landed-cleanup.js +707 -0
- package/dist/lead-cli.js +22 -19
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +49 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +48 -8
- package/dist/live-input.d.ts +68 -0
- package/dist/live-input.js +254 -0
- package/dist/live.d.ts +1 -1
- package/dist/live.js +4 -2
- package/dist/mailbox.js +3 -2
- package/dist/maintenance.d.ts +2 -0
- package/dist/maintenance.js +5 -2
- package/dist/mcp-core.js +1 -1
- package/dist/operate-remote-arguments.js +5 -1
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +201 -19
- package/dist/orphan-sweep.d.ts +2 -0
- package/dist/orphan-sweep.js +5 -3
- package/dist/project-delete-ui.d.ts +11 -0
- package/dist/project-delete-ui.js +29 -2
- package/dist/project-tools.d.ts +7 -11
- package/dist/project-tools.js +13 -37
- package/dist/provider.d.ts +73 -8
- package/dist/provider.js +87 -17
- package/dist/result-completion.d.ts +7 -0
- package/dist/result-completion.js +9 -0
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +3 -3
- package/dist/serve.js +22 -3
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- package/dist/server/live.d.ts +3 -0
- package/dist/server/live.js +35 -2
- package/dist/server/pages.js +59 -35
- package/dist/server/render-chat.js +14 -2
- package/dist/server/render-tasks.d.ts +14 -2
- package/dist/server/render-tasks.js +38 -20
- package/dist/server/route-table.js +4 -1
- package/dist/server/settings.js +2 -1
- package/dist/server/task-views.js +10 -0
- package/dist/server/tasks.js +63 -0
- package/dist/shared-deps.d.ts +9 -4
- package/dist/shared-deps.js +89 -13
- package/dist/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- package/dist/staged-runtimes.d.ts +16 -4
- package/dist/staged-runtimes.js +160 -38
- package/dist/steer-live.d.ts +19 -0
- package/dist/steer-live.js +93 -0
- package/dist/steer-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +25 -0
- package/dist/steer-status.js +44 -0
- package/dist/storage-sweep-child.d.ts +1 -0
- package/dist/storage-sweep-child.js +76 -0
- package/dist/storage-sweep-process.d.ts +41 -0
- package/dist/storage-sweep-process.js +187 -0
- package/dist/storage-sweep.d.ts +30 -6
- package/dist/storage-sweep.js +214 -28
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +16 -4
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +35 -0
- package/dist/store.d.ts +202 -2
- package/dist/store.js +440 -13
- package/dist/surface.js +5 -3
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- package/dist/sweep-quarantine.d.ts +28 -0
- package/dist/sweep-quarantine.js +153 -0
- package/dist/task-status.d.ts +2 -0
- package/dist/task-status.js +5 -1
- package/dist/team-updates.js +3 -3
- package/dist/test-temp.d.ts +6 -3
- package/dist/test-temp.js +15 -9
- package/dist/toolroll-update.js +15 -2
- package/dist/work-index.d.ts +1 -0
- package/dist/work-index.js +32 -12
- package/dist/work-summary.d.ts +2 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +3 -1
- package/dist/worktree.js +93 -10
- package/package.json +21 -21
- 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
|
+
}
|
package/dist/agent-fence.d.ts
CHANGED
|
@@ -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
|
package/dist/agent-fence.js
CHANGED
|
@@ -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
|
|
194
|
+
function worktreeGitDir(worktree) {
|
|
105
195
|
try {
|
|
106
196
|
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(join(worktree, ".git"), "utf8"))?.[1]?.trim();
|
|
107
|
-
|
|
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 {};
|