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.
- package/CHANGELOG.md +15 -0
- 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 +1 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment.d.ts +4 -0
- package/dist/assignment.js +8 -6
- package/dist/browser/workspace.css +1 -1
- 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 +113 -13
- package/dist/chat-channel.js +9 -0
- 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/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/desktop-update.d.ts +1 -1
- package/dist/evidence-pack.js +1 -1
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +14 -3
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +229 -10
- 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 +45 -1
- package/dist/lead-tools.js +40 -7
- 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/maintenance.d.ts +2 -0
- package/dist/maintenance.js +5 -2
- 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 +188 -16
- 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 +65 -8
- package/dist/provider.js +80 -21
- 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 +19 -1
- 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 +12 -0
- package/dist/server/render-tasks.d.ts +12 -2
- package/dist/server/render-tasks.js +27 -7
- package/dist/server/route-table.js +4 -1
- package/dist/server/settings.js +2 -1
- package/dist/server/task-views.js +9 -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/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-status.d.ts +8 -2
- package/dist/steer-status.js +19 -5
- 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 +163 -2
- package/dist/store.js +350 -15
- package/dist/surface.js +4 -2
- 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/test-temp.d.ts +6 -3
- package/dist/test-temp.js +15 -9
- package/dist/toolroll-update.js +3 -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 +1 -1
- package/scripts/provider-canary.mjs +235 -147
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agents board on the build's existing minute pulse (agent-board.ts):
|
|
3
|
+
* the agent's status file, the live transcript's size, the changed-file
|
|
4
|
+
* count and the four-minute Stuck mark. No model and no tokens: files the
|
|
5
|
+
* plane already owns, read with the same discipline as every protocol file.
|
|
6
|
+
*
|
|
7
|
+
* The status file is the agent's: one JSON object per line, appended.
|
|
8
|
+
* {"status":"working","note":"…"}
|
|
9
|
+
* {"status":"needs-decision","key":"short-key","question":"…"}
|
|
10
|
+
* {"status":"blocked","note":"…"}
|
|
11
|
+
* {"status":"done","note":"…"}
|
|
12
|
+
* It is read forward from a saved offset, only through complete lines, never
|
|
13
|
+
* past its caps, opened O_NOFOLLOW as a regular file; a line that is not one
|
|
14
|
+
* of these shapes is skipped, and every word goes through the live view's
|
|
15
|
+
* sanitizer (a credential-shaped line is replaced whole).
|
|
16
|
+
*/
|
|
17
|
+
import { closeSync, constants, fstatSync, openSync, readSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { ingestStatus, markStuck, noteDiff, noteTranscript, NOTE_MAX, pageQuestions, QUESTION_KEY, QUESTION_MAX, QUESTIONS_OPEN_MAX, QUESTIONS_PER_ATTEMPT } from "./agent-board.js";
|
|
20
|
+
import { readVerifiedArtifact } from "./evidence.js";
|
|
21
|
+
import { liveLogPath, sanitizeTranscriptLine } from "./live.js";
|
|
22
|
+
import { observeWorktree, parseBaseTreeSnapshot, PEEK_LIMITS } from "./peek.js";
|
|
23
|
+
/** The whole file may grow this far; past it, nothing more is read. */
|
|
24
|
+
export const STATUS_FILE_CAP = 256 * 1024;
|
|
25
|
+
/** At most this much is read per beat. */
|
|
26
|
+
const STATUS_WINDOW = 64 * 1024;
|
|
27
|
+
/** A line longer than this is skipped, never cut. */
|
|
28
|
+
const STATUS_LINE_CAP = 4 * 1024;
|
|
29
|
+
const clean = (value, cap) => {
|
|
30
|
+
if (typeof value !== "string")
|
|
31
|
+
return null;
|
|
32
|
+
const one = sanitizeTranscriptLine(value.replace(/\s+/g, " ").trim());
|
|
33
|
+
if (one === "")
|
|
34
|
+
return null;
|
|
35
|
+
return one.length > cap ? `${one.slice(0, cap - 1).trimEnd()}…` : one;
|
|
36
|
+
};
|
|
37
|
+
/** One status line, strictly: a known status and its fields, or null. */
|
|
38
|
+
export function parseStatusLine(line) {
|
|
39
|
+
if (line.trim() === "" || Buffer.byteLength(line, "utf8") > STATUS_LINE_CAP)
|
|
40
|
+
return null;
|
|
41
|
+
let value;
|
|
42
|
+
try {
|
|
43
|
+
value = JSON.parse(line);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
49
|
+
return null;
|
|
50
|
+
const record = value;
|
|
51
|
+
const status = record["status"];
|
|
52
|
+
if (status === "needs-decision") {
|
|
53
|
+
const key = typeof record["key"] === "string" ? record["key"].trim().toLowerCase() : "";
|
|
54
|
+
const question = clean(record["question"], QUESTION_MAX);
|
|
55
|
+
return QUESTION_KEY.test(key) && question !== null ? { status, key, question } : null;
|
|
56
|
+
}
|
|
57
|
+
if (status === "working" || status === "blocked" || status === "done")
|
|
58
|
+
return { status, note: clean(record["note"], NOTE_MAX) };
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** The complete lines after `from`, and where the next read starts. */
|
|
62
|
+
export function readStatusFile(path, from) {
|
|
63
|
+
if (!Number.isSafeInteger(from) || from < 0)
|
|
64
|
+
return { ok: false, reason: "unreadable" };
|
|
65
|
+
let fd;
|
|
66
|
+
try {
|
|
67
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return { ok: false, reason: error.code === "ENOENT" ? "missing" : "unreadable" };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const stat = fstatSync(fd);
|
|
74
|
+
if (!stat.isFile())
|
|
75
|
+
return { ok: false, reason: "unreadable" };
|
|
76
|
+
const size = Math.min(Number(stat.size), STATUS_FILE_CAP);
|
|
77
|
+
if (Number(stat.size) < from)
|
|
78
|
+
return { ok: false, reason: "replaced" };
|
|
79
|
+
if (from >= STATUS_FILE_CAP)
|
|
80
|
+
return { ok: false, reason: "full" };
|
|
81
|
+
const want = Math.min(size - from, STATUS_WINDOW);
|
|
82
|
+
if (want <= 0)
|
|
83
|
+
return { ok: true, read: { lines: [], nextOffset: from } };
|
|
84
|
+
const buffer = Buffer.alloc(want);
|
|
85
|
+
let got = 0;
|
|
86
|
+
while (got < want) {
|
|
87
|
+
const read = readSync(fd, buffer, got, want - got, from + got);
|
|
88
|
+
if (read <= 0)
|
|
89
|
+
break;
|
|
90
|
+
got += read;
|
|
91
|
+
}
|
|
92
|
+
const chunk = buffer.subarray(0, got);
|
|
93
|
+
const lastLf = chunk.lastIndexOf(0x0a);
|
|
94
|
+
if (lastLf === -1) {
|
|
95
|
+
// One line longer than the whole window is never going to parse: step past it.
|
|
96
|
+
return { ok: true, read: { lines: [], nextOffset: got >= STATUS_WINDOW ? from + got : from } };
|
|
97
|
+
}
|
|
98
|
+
const lines = chunk.subarray(0, lastLf).toString("utf8").split("\n").map(parseStatusLine).filter((one) => one !== null);
|
|
99
|
+
return { ok: true, read: { lines, nextOffset: from + lastLf + 1 } };
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { ok: false, reason: "unreadable" };
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
closeSync(fd);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Create the attempt's empty status file (created here, never adopted); null when it cannot be. */
|
|
109
|
+
export function openStatusFile(worktree, name) {
|
|
110
|
+
const path = join(worktree, name);
|
|
111
|
+
try {
|
|
112
|
+
writeFileSync(path, "", { flag: "wx", mode: 0o600 });
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function removeStatusFile(path) {
|
|
120
|
+
if (path === null)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(path);
|
|
124
|
+
}
|
|
125
|
+
catch { /* already gone */ }
|
|
126
|
+
}
|
|
127
|
+
/** The brief's few lines: where to write, what each status means, and how a reply arrives. */
|
|
128
|
+
export function statusBrief(name, steerFolder) {
|
|
129
|
+
return [
|
|
130
|
+
"",
|
|
131
|
+
"Status for the person watching (the Agents board):",
|
|
132
|
+
`- Append one JSON object per line to ${name} in the worktree root. Never rewrite or delete it; it is never committed.`,
|
|
133
|
+
' {"status":"working","note":"<what you are doing, one short line>"}',
|
|
134
|
+
' {"status":"needs-decision","key":"<short-key>","question":"<one question a person must answer>"}',
|
|
135
|
+
' {"status":"blocked","note":"<what stops you>"}',
|
|
136
|
+
' {"status":"done","note":"<one-line outcome>"}',
|
|
137
|
+
"- Use needs-decision only for a real judgement call inside the agreed scope; a key is lowercase letters, digits and",
|
|
138
|
+
" hyphens, at most 40 characters, and is asked once. Keep working on anything that does not depend on the answer.",
|
|
139
|
+
`- At most ${QUESTIONS_OPEN_MAX} questions can be open at once, and ${QUESTIONS_PER_ATTEMPT} in all; any more reach no one.`,
|
|
140
|
+
steerFolder === null
|
|
141
|
+
? "- A reply cannot reach this attempt while it runs; if the answer is required to finish, park instead."
|
|
142
|
+
: `- A reply arrives as a note in ${steerFolder}/ that begins 'Reply to your question "<short-key>"'.`,
|
|
143
|
+
"",
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
/** The run's verified base snapshot, as the live peek reads it; null when it was not captured cleanly. */
|
|
147
|
+
export function baseEntriesOf(store, root, run) {
|
|
148
|
+
const artifact = store.artifactsFor(run).find(one => one.kind === "base-tree");
|
|
149
|
+
if (artifact === undefined || artifact.captureStatus !== "ok" || artifact.truncated)
|
|
150
|
+
return null;
|
|
151
|
+
const read = readVerifiedArtifact(root, artifact);
|
|
152
|
+
if (!read.ok)
|
|
153
|
+
return null;
|
|
154
|
+
const snapshot = parseBaseTreeSnapshot(read.content.toString("utf8"));
|
|
155
|
+
return snapshot === null || snapshot.run !== run ? null : snapshot.entries;
|
|
156
|
+
}
|
|
157
|
+
/** What a look at the checkout found: changed, deleted and new files together; null when it could not look. */
|
|
158
|
+
export async function changedFileCount(worktree, base) {
|
|
159
|
+
const seen = await observeWorktree(worktree, base, { ...PEEK_LIMITS, deadlineMs: 1_500 });
|
|
160
|
+
if (!seen.ok)
|
|
161
|
+
return null;
|
|
162
|
+
return seen.rows.filter(one => one.kind === "changed" || one.kind === "deleted").length + seen.newPaths.length;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* One beat for the board. Each part is its own fail-soft step: a status file
|
|
166
|
+
* that cannot be read, or a checkout that cannot be looked at, costs only
|
|
167
|
+
* its own line on the board, never the build.
|
|
168
|
+
*/
|
|
169
|
+
export function pulseAgentBoard(store, state, now) {
|
|
170
|
+
try {
|
|
171
|
+
const bytes = statSync(liveLogPath(state.root, state.run)).size;
|
|
172
|
+
noteTranscript(store, state.run, state.attemptKey, bytes, now);
|
|
173
|
+
}
|
|
174
|
+
catch { /* no live log: the activity row still counts */ }
|
|
175
|
+
if (state.status !== null) {
|
|
176
|
+
try {
|
|
177
|
+
const from = store.handle.prepare("SELECT status_offset FROM agent_now WHERE run = ? AND attempt_key = ?").get(state.run, state.attemptKey);
|
|
178
|
+
if (from !== undefined) {
|
|
179
|
+
const read = readStatusFile(state.status, Number(from["status_offset"] ?? 0));
|
|
180
|
+
if (read.ok)
|
|
181
|
+
ingestStatus(store, state.run, state.attemptKey, read.read, now);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* the next beat reads from the same offset */ }
|
|
185
|
+
// Questions asked inside the page window go out once it ends, with or without a new line.
|
|
186
|
+
try {
|
|
187
|
+
pageQuestions(store, state.run, state.attemptKey, now);
|
|
188
|
+
}
|
|
189
|
+
catch { /* the next beat */ }
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
markStuck(store, state.run, state.attemptKey, now);
|
|
193
|
+
}
|
|
194
|
+
catch { /* the next beat */ }
|
|
195
|
+
if (state.looking)
|
|
196
|
+
return;
|
|
197
|
+
if (state.base === undefined) {
|
|
198
|
+
try {
|
|
199
|
+
state.base = baseEntriesOf(store, state.root, state.run);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
state.base = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const base = state.base;
|
|
206
|
+
if (base === null)
|
|
207
|
+
return;
|
|
208
|
+
state.looking = true;
|
|
209
|
+
void changedFileCount(state.worktree, base)
|
|
210
|
+
.then(count => { if (count !== null)
|
|
211
|
+
noteDiff(store, state.run, state.attemptKey, count, now); })
|
|
212
|
+
.catch(() => { })
|
|
213
|
+
.finally(() => { state.looking = false; });
|
|
214
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live turn states from the agent's own output stream (Agents board): no
|
|
3
|
+
* hooks, no model. The builder already parses Claude stream-json and Codex
|
|
4
|
+
* --json events for the live log; this reads the same events for three facts:
|
|
5
|
+
*
|
|
6
|
+
* - busy: a session, turn or tool call started (or the agent is mid-turn);
|
|
7
|
+
* - idle: the turn's result arrived (success or not, it is a result);
|
|
8
|
+
* - crashed: the process ended while a turn was still open — no result.
|
|
9
|
+
*
|
|
10
|
+
* Each change is recorded once, under the run's attempt key
|
|
11
|
+
* (agent-board.ts decides whether it may move the current snapshot).
|
|
12
|
+
* The same events say which tool calls are open — a Claude tool_use until its
|
|
13
|
+
* tool_result, a Codex command or tool item until it completes — so a long,
|
|
14
|
+
* silent command is not mistaken for a stuck agent. Only those known shapes
|
|
15
|
+
* count, at most OPEN_TOOLS_CAP at once, and a turn result or the process
|
|
16
|
+
* ending closes them all.
|
|
17
|
+
* Fail-soft: a recording that throws never touches the build.
|
|
18
|
+
*/
|
|
19
|
+
import type { AgentTurn, ToolKind } from "./agent-board.js";
|
|
20
|
+
type Observer = {
|
|
21
|
+
observe(event: Record<string, unknown>): void;
|
|
22
|
+
close(): void;
|
|
23
|
+
};
|
|
24
|
+
/** What one stream event says about the turn; null when it says nothing. */
|
|
25
|
+
export declare function turnSignalOf(event: Record<string, unknown>): Exclude<AgentTurn, "crashed"> | null;
|
|
26
|
+
/** At most this many open calls are tracked; past it, a new one is not (the oldest still holds the long threshold). */
|
|
27
|
+
export declare const OPEN_TOOLS_CAP = 64;
|
|
28
|
+
/** The tool calls one stream event opens and closes, by id: only Claude's and Codex's known shapes. */
|
|
29
|
+
export declare function toolCallsOf(event: Record<string, unknown>): {
|
|
30
|
+
opened: {
|
|
31
|
+
id: string;
|
|
32
|
+
kind: ToolKind;
|
|
33
|
+
}[];
|
|
34
|
+
closed: string[];
|
|
35
|
+
};
|
|
36
|
+
export type OpenTool = {
|
|
37
|
+
kind: ToolKind;
|
|
38
|
+
at: Date;
|
|
39
|
+
} | null;
|
|
40
|
+
export type TurnRecorder = Observer & {
|
|
41
|
+
/** The agent process is gone: a turn still open ended without its result. */
|
|
42
|
+
ended(): void;
|
|
43
|
+
/** The last state recorded (null: nothing yet). */
|
|
44
|
+
readonly state: AgentTurn | null;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Watch a run's stream for turn changes, beside its live log (which may be
|
|
48
|
+
* null), and for the oldest tool call still open (recordTool hears it move;
|
|
49
|
+
* a turn that leaves busy closes them all, and its own record clears them).
|
|
50
|
+
*/
|
|
51
|
+
export declare function withAgentTurns(inner: Observer | null, record: (state: AgentTurn, at: Date) => void, clock: () => Date, recordTool?: (open: OpenTool, at: Date) => void): TurnRecorder;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** What one stream event says about the turn; null when it says nothing. */
|
|
2
|
+
export function turnSignalOf(event) {
|
|
3
|
+
const type = typeof event["type"] === "string" ? event["type"] : "";
|
|
4
|
+
switch (type) {
|
|
5
|
+
// Claude stream-json: the session starts, the assistant speaks or calls a tool, a tool answers; then one result.
|
|
6
|
+
case "system": return event["subtype"] === "init" ? "busy" : null;
|
|
7
|
+
case "assistant":
|
|
8
|
+
case "user": return "busy";
|
|
9
|
+
case "result": return "idle";
|
|
10
|
+
// Codex --json: a thread and its turns start, items run; a turn completes or fails.
|
|
11
|
+
case "thread.started":
|
|
12
|
+
case "turn.started":
|
|
13
|
+
case "item.started":
|
|
14
|
+
case "item.updated":
|
|
15
|
+
case "item.completed": return "busy";
|
|
16
|
+
case "turn.completed":
|
|
17
|
+
case "turn.failed": return "idle";
|
|
18
|
+
// Gemini stream-json, which the same live log reads.
|
|
19
|
+
case "init":
|
|
20
|
+
case "tool_use":
|
|
21
|
+
case "tool_call":
|
|
22
|
+
case "message": return "busy";
|
|
23
|
+
default: return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** At most this many open calls are tracked; past it, a new one is not (the oldest still holds the long threshold). */
|
|
27
|
+
export const OPEN_TOOLS_CAP = 64;
|
|
28
|
+
const CODEX_TOOL_ITEMS = new Map([["command_execution", "command"], ["mcp_tool_call", "tool"], ["web_search", "tool"], ["file_change", "tool"]]);
|
|
29
|
+
const COMMAND_TOOLS = new Set(["Bash", "BashOutput"]);
|
|
30
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
31
|
+
const callId = (value) => typeof value === "string" && value !== "" && value.length <= 200 ? value : null;
|
|
32
|
+
/** The tool calls one stream event opens and closes, by id: only Claude's and Codex's known shapes. */
|
|
33
|
+
export function toolCallsOf(event) {
|
|
34
|
+
const opened = [], closed = [];
|
|
35
|
+
const type = event["type"];
|
|
36
|
+
if (type === "assistant" || type === "user") {
|
|
37
|
+
const content = object(event["message"])?.["content"];
|
|
38
|
+
for (const part of Array.isArray(content) ? content : []) {
|
|
39
|
+
const block = object(part);
|
|
40
|
+
if (block === null)
|
|
41
|
+
continue;
|
|
42
|
+
if (type === "assistant" && block["type"] === "tool_use") {
|
|
43
|
+
const id = callId(block["id"]);
|
|
44
|
+
if (id !== null)
|
|
45
|
+
opened.push({ id, kind: COMMAND_TOOLS.has(String(block["name"])) ? "command" : "tool" });
|
|
46
|
+
}
|
|
47
|
+
else if (type === "user" && block["type"] === "tool_result") {
|
|
48
|
+
const id = callId(block["tool_use_id"]);
|
|
49
|
+
if (id !== null)
|
|
50
|
+
closed.push(id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else if (type === "item.started" || type === "item.completed") {
|
|
55
|
+
const item = object(event["item"]);
|
|
56
|
+
const id = callId(item?.["id"]), kind = CODEX_TOOL_ITEMS.get(item?.["type"]);
|
|
57
|
+
if (id !== null && kind !== undefined)
|
|
58
|
+
(type === "item.started" ? opened.push({ id, kind }) : closed.push(id));
|
|
59
|
+
}
|
|
60
|
+
return { opened, closed };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Watch a run's stream for turn changes, beside its live log (which may be
|
|
64
|
+
* null), and for the oldest tool call still open (recordTool hears it move;
|
|
65
|
+
* a turn that leaves busy closes them all, and its own record clears them).
|
|
66
|
+
*/
|
|
67
|
+
export function withAgentTurns(inner, record, clock, recordTool = () => { }) {
|
|
68
|
+
let state = null;
|
|
69
|
+
let spoke = false;
|
|
70
|
+
const open = new Map();
|
|
71
|
+
let oldest = null;
|
|
72
|
+
const move = (next) => {
|
|
73
|
+
if (next === state)
|
|
74
|
+
return;
|
|
75
|
+
state = next;
|
|
76
|
+
if (next !== "busy") {
|
|
77
|
+
open.clear();
|
|
78
|
+
oldest = null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
record(next, clock());
|
|
82
|
+
}
|
|
83
|
+
catch { /* display state: the next change tries again */ }
|
|
84
|
+
};
|
|
85
|
+
const tools = (event) => {
|
|
86
|
+
const calls = toolCallsOf(event);
|
|
87
|
+
if (calls.opened.length === 0 && calls.closed.length === 0)
|
|
88
|
+
return;
|
|
89
|
+
const at = clock();
|
|
90
|
+
for (const id of calls.closed)
|
|
91
|
+
open.delete(id);
|
|
92
|
+
for (const one of calls.opened)
|
|
93
|
+
if (!open.has(one.id) && open.size < OPEN_TOOLS_CAP)
|
|
94
|
+
open.set(one.id, { kind: one.kind, at });
|
|
95
|
+
const first = open.values().next().value ?? null;
|
|
96
|
+
if (first === oldest)
|
|
97
|
+
return;
|
|
98
|
+
oldest = first;
|
|
99
|
+
try {
|
|
100
|
+
recordTool(first, at);
|
|
101
|
+
}
|
|
102
|
+
catch { /* display state: the next change tries again */ }
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
observe(event) {
|
|
106
|
+
inner?.observe(event);
|
|
107
|
+
if (state === "crashed")
|
|
108
|
+
return;
|
|
109
|
+
let signal = null;
|
|
110
|
+
try {
|
|
111
|
+
signal = turnSignalOf(event);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
signal = null;
|
|
115
|
+
}
|
|
116
|
+
if (signal === null)
|
|
117
|
+
return;
|
|
118
|
+
spoke = true;
|
|
119
|
+
move(signal);
|
|
120
|
+
if (signal === "busy") {
|
|
121
|
+
try {
|
|
122
|
+
tools(event);
|
|
123
|
+
}
|
|
124
|
+
catch { /* an unreadable call: the turn's result closes it */ }
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
close() { inner?.close(); },
|
|
128
|
+
ended() {
|
|
129
|
+
if (state === "crashed")
|
|
130
|
+
return;
|
|
131
|
+
// A process that never spoke, or whose last turn never reported its result, ended without one.
|
|
132
|
+
if (!spoke || state !== "idle")
|
|
133
|
+
move("crashed");
|
|
134
|
+
},
|
|
135
|
+
get state() { return state; },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agents board's projection (one place to check on running agents): each
|
|
3
|
+
* task's newest build attempt from the last day, in the viewer's admitted
|
|
4
|
+
* projects, grouped Needs you, Working, Stuck, Done (not viewed yet), Idle.
|
|
5
|
+
* A protocol repair never takes the row: while one runs, its parent's row says
|
|
6
|
+
* "Fixing the handoff" and keeps the parent's transcript, changes and link.
|
|
7
|
+
* Saved facts only (agent-board.ts, the run, its activity and its sealed diff
|
|
8
|
+
* stats); nothing here verifies evidence or grants an action.
|
|
9
|
+
*/
|
|
10
|
+
import { type AgentTurn, type ToolKind } from "./agent-board.js";
|
|
11
|
+
import type { AgentGroupKey, BrowserAgentRow } from "./browser-workspace.js";
|
|
12
|
+
import type { Store } from "./store.js";
|
|
13
|
+
export declare const AGENT_GROUPS: readonly {
|
|
14
|
+
key: AgentGroupKey;
|
|
15
|
+
label: string;
|
|
16
|
+
}[];
|
|
17
|
+
export type AgentFacts = {
|
|
18
|
+
runId: number;
|
|
19
|
+
taskRef: number;
|
|
20
|
+
taskId: string;
|
|
21
|
+
title: string;
|
|
22
|
+
repo: string | null;
|
|
23
|
+
provider: string;
|
|
24
|
+
runner: string;
|
|
25
|
+
outcome: string | null;
|
|
26
|
+
reason: string | null;
|
|
27
|
+
startedAt: string;
|
|
28
|
+
finishedAt: string | null;
|
|
29
|
+
legacy: boolean;
|
|
30
|
+
taskState: string;
|
|
31
|
+
turn: AgentTurn | null;
|
|
32
|
+
stateAt: string | null;
|
|
33
|
+
transcriptAt: string | null;
|
|
34
|
+
stuckAt: string | null;
|
|
35
|
+
said: string | null;
|
|
36
|
+
saidNote: string | null;
|
|
37
|
+
saidAt: string | null;
|
|
38
|
+
diffFiles: number | null;
|
|
39
|
+
toolKind: ToolKind | null;
|
|
40
|
+
toolAt: string | null;
|
|
41
|
+
phase: string | null;
|
|
42
|
+
/** The note the board shows once the agent asked past the questions cap. */
|
|
43
|
+
questionNote: string | null;
|
|
44
|
+
/** A protocol repair of this attempt is running now. */
|
|
45
|
+
repairing: boolean;
|
|
46
|
+
question: {
|
|
47
|
+
id: number;
|
|
48
|
+
key: string;
|
|
49
|
+
text: string;
|
|
50
|
+
} | null;
|
|
51
|
+
seen: boolean;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The board's candidate builds, through the partial indexes (agent_board_open, agent_board_recent): open ones and
|
|
55
|
+
* those finished since $kept, one per task (its newest), never a scan of every run. A candidate a newer build of the
|
|
56
|
+
* same task has replaced is dropped by an indexed probe, so the row is still the task's newest build.
|
|
57
|
+
*/
|
|
58
|
+
export declare const BOARD_CANDIDATES = "WITH candidate AS (\n SELECT task_ref, id FROM run WHERE role = 'builder' AND outcome IS NULL\n UNION ALL\n SELECT task_ref, id FROM run WHERE role = 'builder' AND outcome IS NOT NULL AND finished_at >= $kept\n ), newest AS (SELECT task_ref, MAX(id) AS id FROM candidate GROUP BY task_ref)";
|
|
59
|
+
/** The board's candidate attempts: each task's newest build, open or recently finished, in the admitted projects. */
|
|
60
|
+
export declare function agentFacts(store: Store, now: Date, access: {
|
|
61
|
+
repos: readonly string[] | null;
|
|
62
|
+
}, account: string): AgentFacts[];
|
|
63
|
+
/** The board's query, exported for its query-plan test. */
|
|
64
|
+
export declare function agentFactsSql(): string;
|
|
65
|
+
/** One exact build attempt, whatever its age (the opened row), in the admitted projects: a repair opens as its parent. */
|
|
66
|
+
export declare function agentFactsFor(store: Store, run: number, access: {
|
|
67
|
+
repos: readonly string[] | null;
|
|
68
|
+
}, account: string): AgentFacts | null;
|
|
69
|
+
/** Where an attempt sits on the board, and its one state word. Stuck is worked out from the facts now, so fresh
|
|
70
|
+
* transcript or activity clears it at once (the pulse's saved mark only tells open boards it crossed). */
|
|
71
|
+
export declare function agentGroupOf(facts: AgentFacts, activityAt: string | null, now: Date): {
|
|
72
|
+
group: AgentGroupKey;
|
|
73
|
+
label: string;
|
|
74
|
+
};
|
|
75
|
+
type DiffSummary = {
|
|
76
|
+
fileCount: number;
|
|
77
|
+
additions: number;
|
|
78
|
+
deletions: number;
|
|
79
|
+
files: {
|
|
80
|
+
path: string;
|
|
81
|
+
additions: number | null;
|
|
82
|
+
deletions: number | null;
|
|
83
|
+
}[];
|
|
84
|
+
};
|
|
85
|
+
/** A finished attempt's sealed diff stats (cached by artifact: sealed bytes never change). */
|
|
86
|
+
export declare function sealedDiffOf(store: Store, root: string | undefined, run: number): DiffSummary | null;
|
|
87
|
+
/** A row's diff size: the sealed stats once finished, the pulse's count while it runs. */
|
|
88
|
+
export declare function diffWords(facts: AgentFacts, diff: DiffSummary | null): string | null;
|
|
89
|
+
/** One board row. */
|
|
90
|
+
export declare function agentRowOf(store: Store, facts: AgentFacts, now: Date, root?: string): BrowserAgentRow;
|
|
91
|
+
/** The whole board: groups in order, rows newest first within each; empty groups dropped. */
|
|
92
|
+
export declare function agentsBoardOf(store: Store, now: Date, access: {
|
|
93
|
+
repos: readonly string[] | null;
|
|
94
|
+
}, account: string, root?: string): {
|
|
95
|
+
key: AgentGroupKey;
|
|
96
|
+
label: string;
|
|
97
|
+
rows: BrowserAgentRow[];
|
|
98
|
+
}[];
|
|
99
|
+
/** A board fingerprint for one exact build: what its open view shows moved, its repairs included. */
|
|
100
|
+
export declare function agentRunFingerprint(store: Store, run: number): string | null;
|
|
101
|
+
export {};
|