toolroll 0.9.73 → 0.9.74
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 +32 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/agent-turns.js +5 -0
- package/dist/agents-live.d.ts +31 -0
- package/dist/agents-live.js +43 -0
- package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -30
- package/dist/browser-workspace.d.ts +6 -0
- package/dist/builder.js +1 -1
- package/dist/chat-continuity.js +10 -0
- package/dist/chat-quiet.js +3 -3
- package/dist/deploy-gate.js +5 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +35 -2
- package/dist/exec.js +13 -16
- package/dist/first-run.d.ts +17 -1
- package/dist/first-run.js +38 -0
- package/dist/invoke.d.ts +13 -0
- package/dist/invoke.js +21 -12
- package/dist/knowledge-ui.d.ts +4 -1
- package/dist/knowledge-ui.js +10 -5
- package/dist/lead-progress.d.ts +4 -1
- package/dist/lead-progress.js +3 -1
- package/dist/lead.js +4 -0
- package/dist/live-bus.d.ts +2 -0
- package/dist/live-bus.js +1 -0
- package/dist/live-children-read.d.ts +52 -0
- package/dist/live-children-read.js +333 -0
- package/dist/live-children.d.ts +73 -0
- package/dist/live-children.js +318 -0
- package/dist/live-detail.d.ts +4 -3
- package/dist/live-detail.js +9 -5
- package/dist/live-input.js +7 -8
- package/dist/live-main-read.d.ts +19 -0
- package/dist/live-main-read.js +91 -0
- package/dist/live-open.d.ts +28 -0
- package/dist/live-open.js +125 -0
- package/dist/live-records.d.ts +10 -1
- package/dist/live-records.js +16 -5
- package/dist/live.d.ts +54 -22
- package/dist/live.js +218 -69
- package/dist/operate.js +3 -2
- package/dist/peek-cli.d.ts +10 -3
- package/dist/peek-cli.js +17 -8
- package/dist/planner.js +1 -1
- package/dist/provider-limits.d.ts +3 -1
- package/dist/provider-limits.js +5 -2
- package/dist/provider.js +6 -12
- package/dist/release-adapters.js +4 -0
- package/dist/release.d.ts +3 -0
- package/dist/release.js +3 -0
- package/dist/repository-context-ui.js +2 -1
- package/dist/run-efficiency.d.ts +3 -3
- package/dist/run-efficiency.js +20 -8
- package/dist/scout.d.ts +3 -0
- package/dist/scout.js +5 -3
- package/dist/serve.js +34 -23
- package/dist/server/agents-live-lead.d.ts +47 -0
- package/dist/server/agents-live-lead.js +116 -0
- package/dist/server/agents-live-room.d.ts +23 -0
- package/dist/server/agents-live-room.js +88 -0
- package/dist/server/chat.js +84 -30
- package/dist/server/chrome.js +2 -2
- package/dist/server/console.css +68 -40
- package/dist/server/live.d.ts +4 -0
- package/dist/server/live.js +42 -2
- package/dist/server/pages.js +81 -1
- package/dist/server/render-chat.d.ts +15 -1
- package/dist/server/render-chat.js +14 -7
- package/dist/server/render-pages.d.ts +8 -4
- package/dist/server/render-pages.js +55 -50
- package/dist/server/render-tasks.js +10 -3
- package/dist/server/route-table.js +5 -1
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +3 -3
- package/dist/server/task-views.js +1 -1
- package/dist/server/tasks.js +59 -41
- package/dist/store.d.ts +31 -1
- package/dist/store.js +75 -2
- package/dist/stream-scope.d.ts +18 -0
- package/dist/stream-scope.js +28 -0
- package/dist/subscription-chat.js +5 -3
- package/package.json +1 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagents' records (docs/plans/agents-view.md §2.3): what each subagent a Claude run delegates to said and did, in
|
|
3
|
+
* `live/<run>.sub.jsonl`, beside — never inside — the run's own records. The run's own file, its step ids, sequence and
|
|
4
|
+
* caps are untouched: the parent recorder still drops every subagent event (stream-scope.ts).
|
|
5
|
+
*
|
|
6
|
+
* Routing is separate from open steps: a delegation (a Task/Agent call, the parent's own or a subagent's) maps its
|
|
7
|
+
* provider call id to a child stream, and the mapping outlives the call's result, so a background subagent's late
|
|
8
|
+
* first words still route. Each stream has its own recorder — its own open steps, key-block state and chunking, under
|
|
9
|
+
* the run's captured "Show commands" answer — and its ids are rewritten `c<s>s<n>`, a form the parent never uses.
|
|
10
|
+
* A close only ever reaches its own stream's steps. Bounded everywhere: 16 streams, 32 delegations, depth 2, 32 open
|
|
11
|
+
* child steps, the file's own size cap; what doesn't fit is counted, never shown. A checkpoint (each stream's parent,
|
|
12
|
+
* open steps and key state) is written when a stream opens and every 32 KiB, for a reader that starts mid-file.
|
|
13
|
+
*
|
|
14
|
+
* Display state, never evidence: the machine running the agent could alter the file.
|
|
15
|
+
*/
|
|
16
|
+
import { closeSync, constants, mkdirSync, openSync, writeSync } from "node:fs";
|
|
17
|
+
import { LIVE_SUB_FILE_CAP, liveDir, liveSubLogPath } from "./live.js";
|
|
18
|
+
import { createLiveRecorder } from "./live-records.js";
|
|
19
|
+
import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
|
|
20
|
+
export const CHILD_STREAMS_CAP = 16;
|
|
21
|
+
export const CHILD_DELEGATIONS_CAP = 32;
|
|
22
|
+
export const CHILD_OPEN_STEPS_CAP = 32;
|
|
23
|
+
export const CHILD_DEPTH_CAP = 2;
|
|
24
|
+
/** A checkpoint at least this often, in bytes of child records. */
|
|
25
|
+
export const CHILD_CHECKPOINT_BYTES = 32 * 1024;
|
|
26
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
27
|
+
/** A provider call id: a plain, bounded string, or nothing — never a number or an array coerced into one. */
|
|
28
|
+
const callId = (value) => (typeof value === "string" && value !== "" && value.length <= 200 ? value : null);
|
|
29
|
+
const DELEGATES = new Set(["Task", "Agent"]);
|
|
30
|
+
/** A checkpoint's display text: its first 60 characters (the line is already redacted). */
|
|
31
|
+
const brief = (text) => (text.length <= 60 ? text : `${text.slice(0, 59)}…`);
|
|
32
|
+
/** At most about this many bytes of one event's records reach the file; past it words and one-off steps give way to one
|
|
33
|
+
* `cut`, so a checkpoint (written between events) is never far behind any position. */
|
|
34
|
+
export const CHILD_EVENT_WORDS_CAP = 64 * 1024;
|
|
35
|
+
const delegationCalls = (content) => Array.isArray(content) ? content.map(object).filter((block) => block !== null && block["type"] === "tool_use" && DELEGATES.has(String(block["name"]))) : [];
|
|
36
|
+
/** The child recorder: every stream event in, the subagents' records out. Hand `parentStep` to the run's own recorder
|
|
37
|
+
* (LiveRecorderOptions.onStep) so a delegation names the parent's step it really opened. Never throws. */
|
|
38
|
+
export function createChildRecorder(options) {
|
|
39
|
+
const clock = options.clock ?? (() => new Date());
|
|
40
|
+
/** Provider call id → its delegation, or "ambiguous" once two callers have used the id (then nothing routes by it). */
|
|
41
|
+
const delegations = new Map();
|
|
42
|
+
const streams = [];
|
|
43
|
+
const parentNoted = new Map();
|
|
44
|
+
let dropped = 0;
|
|
45
|
+
let overWritten = false;
|
|
46
|
+
/** Register a delegation call; a repeated id (whoever repeats it) makes the id ambiguous: counted, never routed. */
|
|
47
|
+
const delegate = (id, depth, noted, via) => {
|
|
48
|
+
if (delegations.has(id)) {
|
|
49
|
+
delegations.set(id, "ambiguous");
|
|
50
|
+
dropped += 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (delegations.size >= CHILD_DELEGATIONS_CAP || depth > CHILD_DEPTH_CAP) {
|
|
54
|
+
dropped += 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
delegations.set(id, { depth, parent: noted?.id ?? null, ...(via === undefined ? {} : { via }), desc: noted?.target ?? null });
|
|
58
|
+
};
|
|
59
|
+
const checkpoint = (at) => ({ k: "ckpt", at, dropped, streams: streams.map(one => ({
|
|
60
|
+
s: one.s, parent: one.parent, ...(one.via === undefined ? {} : { via: one.via }), open: [...one.open], inKey: one.keys.inKey,
|
|
61
|
+
...(one.keys.tail === undefined || one.keys.tail === "" ? {} : { tail: one.keys.tail }), ...(one.ended ? { ended: true } : {}),
|
|
62
|
+
// Display text cut short here so a checkpoint always fits its record (16 streams, 32 open steps).
|
|
63
|
+
...(one.desc === null ? {} : { desc: brief(one.desc) }), at: one.startedAt,
|
|
64
|
+
...(one.labels.size === 0 ? {} : { steps: [...one.labels].map(([id, label]) => ({ id, kind: label.kind, ...(label.target === undefined ? {} : { target: brief(label.target) }) })) }),
|
|
65
|
+
...(one.endedAt === undefined ? {} : { endedAt: one.endedAt, ok: one.ok ?? null }),
|
|
66
|
+
})) });
|
|
67
|
+
/** A stream's own records: ids rewritten, open steps bounded across every stream, words bounded per event. */
|
|
68
|
+
const own = (stream, records, at) => {
|
|
69
|
+
const out = [];
|
|
70
|
+
const total = () => streams.reduce((sum, one) => sum + one.open.size, 0);
|
|
71
|
+
let bytes = 0, cut = false;
|
|
72
|
+
for (let record of records) {
|
|
73
|
+
if (record.k === "open")
|
|
74
|
+
continue;
|
|
75
|
+
// A close always lands, with its id and outcome; its output counts against the budget and goes first when over.
|
|
76
|
+
if (record.k === "done" && record.out !== undefined && bytes + Buffer.byteLength(JSON.stringify(record), "utf8") > CHILD_EVENT_WORDS_CAP) {
|
|
77
|
+
const { out: _out, ...bare } = record;
|
|
78
|
+
record = bare;
|
|
79
|
+
if (!cut)
|
|
80
|
+
out.push({ k: "cut", at, s: stream.s });
|
|
81
|
+
cut = true;
|
|
82
|
+
}
|
|
83
|
+
bytes += Buffer.byteLength(JSON.stringify(record), "utf8");
|
|
84
|
+
// Past the event's budget, words and one-off steps give way to one `cut`; tracked steps and closes still land.
|
|
85
|
+
const over = bytes > CHILD_EVENT_WORDS_CAP;
|
|
86
|
+
const tracked = record.k === "done" || (record.k === "step" && record.once !== true && total() < CHILD_OPEN_STEPS_CAP);
|
|
87
|
+
if (over && !tracked && record.k !== "start") {
|
|
88
|
+
if (!cut)
|
|
89
|
+
out.push({ k: "cut", at, s: stream.s });
|
|
90
|
+
cut = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (record.k === "step") {
|
|
94
|
+
const id = `c${stream.s}${record.id}`;
|
|
95
|
+
if (!tracked) {
|
|
96
|
+
out.push({ ...record, id, once: true, s: stream.s });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
stream.open.add(id);
|
|
100
|
+
stream.labels.set(id, { kind: record.kind, ...(record.target === undefined ? {} : { target: record.target }) });
|
|
101
|
+
out.push({ ...record, id, s: stream.s });
|
|
102
|
+
}
|
|
103
|
+
else if (record.k === "done") {
|
|
104
|
+
const id = `c${stream.s}${record.id}`;
|
|
105
|
+
if (!stream.open.delete(id)) {
|
|
106
|
+
dropped += 1;
|
|
107
|
+
continue;
|
|
108
|
+
} // never one this stream has open
|
|
109
|
+
stream.labels.delete(id);
|
|
110
|
+
out.push({ ...record, id, s: stream.s });
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
out.push({ ...record, s: stream.s });
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
};
|
|
117
|
+
const endStream = (stream, at, ok) => {
|
|
118
|
+
if (stream.ended)
|
|
119
|
+
return [];
|
|
120
|
+
const records = own(stream, stream.recorder.end(), at);
|
|
121
|
+
stream.ended = true;
|
|
122
|
+
stream.open.clear();
|
|
123
|
+
stream.labels.clear();
|
|
124
|
+
stream.endedAt = at;
|
|
125
|
+
stream.ok = ok;
|
|
126
|
+
return [...records, { k: "end", s: stream.s, at, ok }];
|
|
127
|
+
};
|
|
128
|
+
/** The main query ended: every subagent still speaking ends, and one that never spoke never will. */
|
|
129
|
+
const endAll = (at) => {
|
|
130
|
+
for (const entry of delegations.values())
|
|
131
|
+
if (entry !== "ambiguous" && entry.stream === undefined)
|
|
132
|
+
entry.ended = true;
|
|
133
|
+
return streams.flatMap(stream => endStream(stream, at, null));
|
|
134
|
+
};
|
|
135
|
+
const open = (into, at) => {
|
|
136
|
+
const keys = { inKey: false };
|
|
137
|
+
const noted = new Map();
|
|
138
|
+
const stream = { s: streams.length + 1, depth: into.depth, parent: into.parent, ...(into.via === undefined ? {} : { via: into.via }), desc: into.desc, startedAt: at,
|
|
139
|
+
recorder: createLiveRecorder({ ...options, keyState: keys, onStep: (call, id, target) => { if (call !== null)
|
|
140
|
+
noted.set(call, { id, target }); } }),
|
|
141
|
+
keys, open: new Set(), ended: false, noted, labels: new Map() };
|
|
142
|
+
streams.push(stream);
|
|
143
|
+
into.stream = stream;
|
|
144
|
+
return stream;
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
parentStep(call, id, target) {
|
|
148
|
+
if (call !== null)
|
|
149
|
+
parentNoted.set(call, { id, target });
|
|
150
|
+
},
|
|
151
|
+
observe(event) {
|
|
152
|
+
const noted = new Map(parentNoted);
|
|
153
|
+
parentNoted.clear();
|
|
154
|
+
try {
|
|
155
|
+
const at = clock().toISOString();
|
|
156
|
+
const content = event["type"] === "assistant" ? object(event["message"])?.["content"] : undefined;
|
|
157
|
+
if (topLevelEvent(event)) {
|
|
158
|
+
// The parent's delegations, each named by the step the parent's recorder actually wrote for that call.
|
|
159
|
+
for (const block of delegationCalls(content)) {
|
|
160
|
+
const id = callId(block["id"]);
|
|
161
|
+
if (id !== null)
|
|
162
|
+
delegate(id, 1, noted.get(id));
|
|
163
|
+
}
|
|
164
|
+
return primaryResultEvent(event) ? endAll(at) : [];
|
|
165
|
+
}
|
|
166
|
+
const reference = callId(event["parent_tool_use_id"]);
|
|
167
|
+
const into = reference === null ? undefined : delegations.get(reference);
|
|
168
|
+
if (into === undefined || into === "ambiguous" || into.ended === true) {
|
|
169
|
+
dropped += 1;
|
|
170
|
+
return [];
|
|
171
|
+
} // unknown, ambiguous or over
|
|
172
|
+
let stream = into.stream;
|
|
173
|
+
const records = [];
|
|
174
|
+
if (stream === undefined) {
|
|
175
|
+
if (streams.length >= CHILD_STREAMS_CAP) {
|
|
176
|
+
dropped += 1;
|
|
177
|
+
if (overWritten)
|
|
178
|
+
return [];
|
|
179
|
+
overWritten = true;
|
|
180
|
+
return [{ k: "over", at }];
|
|
181
|
+
}
|
|
182
|
+
stream = open(into, at);
|
|
183
|
+
records.push({ k: "child", s: stream.s, at, parent: stream.parent, ...(stream.via === undefined ? {} : { via: stream.via }), ...(into.desc === null ? {} : { desc: into.desc }) });
|
|
184
|
+
}
|
|
185
|
+
if (stream.ended) {
|
|
186
|
+
dropped += 1;
|
|
187
|
+
return records;
|
|
188
|
+
} // late: counted, never re-opened
|
|
189
|
+
// A delegation id seen before (here or anywhere) is refused before the subagent's recorder sees it: no step opens.
|
|
190
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
191
|
+
const repeated = new Set(delegationCalls(blocks).map(block => callId(block["id"])).filter((id) => id === null ? false : delegations.has(id)));
|
|
192
|
+
let mine = { ...event, parent_tool_use_id: null };
|
|
193
|
+
if (repeated.size > 0) {
|
|
194
|
+
for (const id of repeated) {
|
|
195
|
+
delegations.set(id, "ambiguous");
|
|
196
|
+
dropped += 1;
|
|
197
|
+
}
|
|
198
|
+
const kept = blocks.filter(part => { const block = object(part); return block === null || !(block["type"] === "tool_use" && repeated.has(String(block["id"]))); });
|
|
199
|
+
mine = { ...mine, message: { ...object(event["message"]), content: kept } };
|
|
200
|
+
}
|
|
201
|
+
// A subagent's init starts its own redaction afresh, the half-seen marker included.
|
|
202
|
+
if (event["type"] === "system" && event["subtype"] === "init") {
|
|
203
|
+
stream.keys.inKey = false;
|
|
204
|
+
stream.keys.tail = "";
|
|
205
|
+
}
|
|
206
|
+
stream.noted.clear();
|
|
207
|
+
records.push(...own(stream, stream.recorder.observe(mine), at));
|
|
208
|
+
// Its own delegations nest under it, one level more, named by the steps its recorder wrote.
|
|
209
|
+
for (const block of delegationCalls(object(mine["message"])?.["content"])) {
|
|
210
|
+
const id = callId(block["id"]);
|
|
211
|
+
const step = id === null ? undefined : stream.noted.get(id);
|
|
212
|
+
if (id !== null)
|
|
213
|
+
delegate(id, stream.depth + 1, step === undefined ? undefined : { id: `c${stream.s}${step.id}`, target: step.target }, stream.s);
|
|
214
|
+
}
|
|
215
|
+
if (event["type"] === "result")
|
|
216
|
+
records.push(...endStream(stream, at, event["is_error"] === true ? false : event["is_error"] === false ? true : null));
|
|
217
|
+
if (records.some(record => record.k === "child"))
|
|
218
|
+
records.push(checkpoint(at));
|
|
219
|
+
return records;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
end() {
|
|
226
|
+
try {
|
|
227
|
+
return endAll(clock().toISOString());
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
checkpoint() {
|
|
234
|
+
return checkpoint(clock().toISOString());
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The subagents' file, opened the first time a subagent speaks (a run without one has none): created here (O_EXCL,
|
|
240
|
+
* O_NOFOLLOW, 0600, the live directory 0700), appended to, and closed with the run. It stops at LIVE_SUB_FILE_CAP
|
|
241
|
+
* with one `cap` record, reserved inside the cap. Fail-soft: no live view is ever worth a build.
|
|
242
|
+
*/
|
|
243
|
+
export function childLogWriter(root, runId) {
|
|
244
|
+
let fd = null;
|
|
245
|
+
let failed = false, capped = false;
|
|
246
|
+
let written = 0, sinceCheckpoint = 0;
|
|
247
|
+
const write = (line) => {
|
|
248
|
+
try {
|
|
249
|
+
writeSync(fd, line);
|
|
250
|
+
written += Buffer.byteLength(line, "utf8");
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
failed = true;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const fits = (text) => Buffer.byteLength(text, "utf8") <= 8 * 1024;
|
|
259
|
+
/** One record as its line. A close too long to keep sheds output lines until it fits — never itself, its id or its
|
|
260
|
+
* outcome — so replaying the file stays consistent; anything else too long becomes a `cut` of its stream. */
|
|
261
|
+
const line = (record) => {
|
|
262
|
+
let text = JSON.stringify(record);
|
|
263
|
+
if (!fits(text) && record.k === "done") {
|
|
264
|
+
const out = [...(record.out ?? [])];
|
|
265
|
+
do {
|
|
266
|
+
out.pop();
|
|
267
|
+
text = JSON.stringify(out.length === 0 ? (({ out: _out, ...bare }) => bare)(record) : { ...record, out });
|
|
268
|
+
} while (!fits(text) && out.length > 0);
|
|
269
|
+
}
|
|
270
|
+
return `${fits(text) ? text : JSON.stringify({ k: "cut", at: record.at, ...("s" in record ? { s: record.s } : {}) })}\n`;
|
|
271
|
+
};
|
|
272
|
+
const one = (record) => {
|
|
273
|
+
const text = line(record);
|
|
274
|
+
const capLine = line({ k: "cap", at: record.at, s: 0 });
|
|
275
|
+
if (written + Buffer.byteLength(text, "utf8") + Buffer.byteLength(capLine, "utf8") > LIVE_SUB_FILE_CAP) {
|
|
276
|
+
capped = true;
|
|
277
|
+
write(capLine);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (write(text))
|
|
281
|
+
sinceCheckpoint += Buffer.byteLength(text, "utf8");
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
due() {
|
|
285
|
+
return fd !== null && !failed && !capped && sinceCheckpoint >= CHILD_CHECKPOINT_BYTES;
|
|
286
|
+
},
|
|
287
|
+
append(records, before) {
|
|
288
|
+
if (records.length === 0 || failed || capped)
|
|
289
|
+
return;
|
|
290
|
+
if (fd === null) {
|
|
291
|
+
try {
|
|
292
|
+
mkdirSync(liveDir(root), { recursive: true, mode: 0o700 });
|
|
293
|
+
fd = openSync(liveSubLogPath(root, runId), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
failed = true;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const record of before === null ? records : [before, ...records]) {
|
|
301
|
+
if (failed || capped)
|
|
302
|
+
return;
|
|
303
|
+
if (record.k === "ckpt")
|
|
304
|
+
sinceCheckpoint = 0;
|
|
305
|
+
one(record);
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
close() {
|
|
309
|
+
if (fd !== null)
|
|
310
|
+
try {
|
|
311
|
+
closeSync(fd);
|
|
312
|
+
}
|
|
313
|
+
catch { /* already gone */ }
|
|
314
|
+
fd = null;
|
|
315
|
+
failed = true;
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
package/dist/live-detail.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Store } from "./store.js";
|
|
2
2
|
/** The consent a "Show commands" form carries: a single-use nonce minted for this person and these exact terms. */
|
|
3
3
|
export declare const LIVE_VIEW_CONSENT = "live-view";
|
|
4
4
|
export declare function liveViewConsentDigest(repo: string): string;
|
|
@@ -19,8 +19,9 @@ export declare function setLiveCommands(store: Store, change: {
|
|
|
19
19
|
}, now: Date): {
|
|
20
20
|
said: string;
|
|
21
21
|
};
|
|
22
|
-
/** A run's trusted context for its live file: its own checkout, and its project's setting,
|
|
23
|
-
|
|
22
|
+
/** A run's trusted context for its live file: its own checkout, and its project's setting, captured for the run now —
|
|
23
|
+
* before the file opens. The writer uses the captured answer: a capture that fails writes no commands. */
|
|
24
|
+
export declare function liveLogContext(store: Store, runId: number, taskRef: number, worktree: string | null, now: Date): {
|
|
24
25
|
base: string | null;
|
|
25
26
|
commands: boolean;
|
|
26
27
|
};
|
package/dist/live-detail.js
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* project with their password; turning it off is one step. Every change is
|
|
6
6
|
* an action-ledger line.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* never reaches disk,
|
|
8
|
+
* Each run captures its answer once, before its live file opens
|
|
9
|
+
* (run_live_detail, v136): what is off never reaches disk, a change applies
|
|
10
|
+
* from the next run, and every reader withholds commands unless that run's
|
|
11
|
+
* own captured answer is on (never the transcript's say, never the setting now).
|
|
10
12
|
*/
|
|
11
13
|
import { createHash } from "node:crypto";
|
|
14
|
+
import { captureRunLiveDetail } from "./store.js";
|
|
12
15
|
/** The consent a "Show commands" form carries: a single-use nonce minted for this person and these exact terms. */
|
|
13
16
|
export const LIVE_VIEW_CONSENT = "live-view";
|
|
14
17
|
export function liveViewConsentDigest(repo) {
|
|
@@ -38,8 +41,9 @@ export function setLiveCommands(store, change, now) {
|
|
|
38
41
|
});
|
|
39
42
|
return { said: change.commands ? "Commands now show in the live view for this project, from the next run." : "Commands are hidden in the live view for this project, from the next run." };
|
|
40
43
|
}
|
|
41
|
-
/** A run's trusted context for its live file: its own checkout, and its project's setting,
|
|
42
|
-
|
|
44
|
+
/** A run's trusted context for its live file: its own checkout, and its project's setting, captured for the run now —
|
|
45
|
+
* before the file opens. The writer uses the captured answer: a capture that fails writes no commands. */
|
|
46
|
+
export function liveLogContext(store, runId, taskRef, worktree, now) {
|
|
43
47
|
let repo = null;
|
|
44
48
|
try {
|
|
45
49
|
repo = store.refForId(taskRef)?.repo ?? null;
|
|
@@ -47,5 +51,5 @@ export function liveLogContext(store, taskRef, worktree) {
|
|
|
47
51
|
catch {
|
|
48
52
|
repo = null;
|
|
49
53
|
}
|
|
50
|
-
return { base: worktree, commands: liveCommandsShown(store, repo) };
|
|
54
|
+
return { base: worktree, commands: captureRunLiveDetail(store, runId, liveCommandsShown(store, repo), now) };
|
|
51
55
|
}
|
package/dist/live-input.js
CHANGED
|
@@ -27,18 +27,13 @@
|
|
|
27
27
|
* touches the store; the builder's dispatcher records what happened.
|
|
28
28
|
*/
|
|
29
29
|
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
|
|
30
31
|
/** What Resume sends a paused session: only a nudge to go on, never a note. */
|
|
31
32
|
export const CONTINUE_TEXT = "continue";
|
|
32
33
|
/** A stdin user frame, in the shape the harness reads. */
|
|
33
34
|
export const userFrame = (text, uuid) => JSON.stringify({ type: "user", message: { role: "user", content: text }, uuid });
|
|
34
35
|
/** A stdin interrupt: stop the current turn, keep the session. */
|
|
35
36
|
export const interruptFrame = (requestId, cancelQueued = false) => JSON.stringify({ type: "control_request", request_id: requestId, request: { subtype: "interrupt", ...(cancelQueued ? { cancel_queued: true } : {}) } });
|
|
36
|
-
/** The session's own words count; a subagent's (parent_tool_use_id set) or a background task's never do. */
|
|
37
|
-
const topLevel = (event) => event["parent_tool_use_id"] === undefined || event["parent_tool_use_id"] === null;
|
|
38
|
-
const primaryResult = (event) => {
|
|
39
|
-
const origin = event["origin"];
|
|
40
|
-
return origin === undefined || origin === null || (typeof origin === "object" && String(origin["kind"] ?? "") === "human");
|
|
41
|
-
};
|
|
42
37
|
export class LiveInput {
|
|
43
38
|
maxTurns;
|
|
44
39
|
#options;
|
|
@@ -150,8 +145,12 @@ export class LiveInput {
|
|
|
150
145
|
}
|
|
151
146
|
/** Every parsed stdout event, in order. */
|
|
152
147
|
observe(event) {
|
|
148
|
+
// A subagent's events (parent_tool_use_id set) are never the session's: not its words, its control answers or its
|
|
149
|
+
// result (stream-scope.ts).
|
|
150
|
+
if (!topLevelEvent(event))
|
|
151
|
+
return;
|
|
153
152
|
const type = String(event["type"] ?? "");
|
|
154
|
-
if (type === "assistant"
|
|
153
|
+
if (type === "assistant") {
|
|
155
154
|
this.#turnActive = true;
|
|
156
155
|
const message = event["message"];
|
|
157
156
|
const id = typeof message?.["id"] === "string" ? message["id"] : `turn-${this.#turns.size + 1}`;
|
|
@@ -170,7 +169,7 @@ export class LiveInput {
|
|
|
170
169
|
if (requestId !== null && this.#interrupts.has(requestId) && response?.["subtype"] === "success" && this.#paused)
|
|
171
170
|
this.#notifyPaused();
|
|
172
171
|
}
|
|
173
|
-
else if (type === "result" &&
|
|
172
|
+
else if (type === "result" && primaryResultEvent(event)) {
|
|
174
173
|
this.#turnActive = false;
|
|
175
174
|
this.#interrupts.clear();
|
|
176
175
|
if (this.#paused) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type LiveRecord } from "./live-records.js";
|
|
2
|
+
export type MainWindow = {
|
|
3
|
+
ok: true;
|
|
4
|
+
records: LiveRecord[];
|
|
5
|
+
cursor: string;
|
|
6
|
+
restarted: boolean;
|
|
7
|
+
earlier: boolean;
|
|
8
|
+
quarantined: number;
|
|
9
|
+
skipped: number;
|
|
10
|
+
more: boolean;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: "missing" | "unavailable";
|
|
14
|
+
};
|
|
15
|
+
/** One window of a run's own live file. `commands` is the run's own captured answer (store.ts runLiveCommands). */
|
|
16
|
+
export declare function readMainWindow(root: string, runId: number, cursor: string | null, options: {
|
|
17
|
+
key: Buffer;
|
|
18
|
+
commands: boolean;
|
|
19
|
+
}): MainWindow;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a run's own live file for the Agents view's tiles (docs/plans/agents-view.md §2.3, N1): through the checked
|
|
3
|
+
* opener, one bounded window per request, from a sealed cursor — or, with none, from the start of a small file or
|
|
4
|
+
* the last 256 KiB of a large one. The run's own file has no checkpoints (its format is unchanged), so a read that
|
|
5
|
+
* starts mid-file can't know whether it starts inside a key block: it reads as if inside one until an END marker or
|
|
6
|
+
* 64 KiB of text pass, and a line that can't be read puts it back in that state. Every record is re-checked and
|
|
7
|
+
* re-redacted (live-records.ts parseLiveRecord) and read under the run's own captured "Show commands" answer.
|
|
8
|
+
*/
|
|
9
|
+
import { closeSync } from "node:fs";
|
|
10
|
+
import { withholdCommands } from "./live.js";
|
|
11
|
+
import { CURSOR_CAP, openCursor, sealCursor, UNKNOWN_KEY_BYTES } from "./live-children-read.js";
|
|
12
|
+
import { openLiveTrusted, READ_WINDOW, readLiveLines, SCAN_BUDGET } from "./live-open.js";
|
|
13
|
+
import { parseLiveRecord, validKeyTail } from "./live-records.js";
|
|
14
|
+
function unseal(cursor, key, run, gen) {
|
|
15
|
+
if (cursor.length > CURSOR_CAP)
|
|
16
|
+
return null;
|
|
17
|
+
const raw = openCursor(cursor, key);
|
|
18
|
+
if (raw === null || raw["run"] !== run || raw["gen"] !== gen)
|
|
19
|
+
return null;
|
|
20
|
+
const { off, skipping, inKey, tail, unknown } = raw;
|
|
21
|
+
if (!Number.isSafeInteger(off) || off < 0 || typeof skipping !== "boolean" || typeof inKey !== "boolean")
|
|
22
|
+
return null;
|
|
23
|
+
if (typeof tail !== "string" || !validKeyTail(tail) || !Number.isSafeInteger(unknown) || unknown < 0 || unknown > UNKNOWN_KEY_BYTES)
|
|
24
|
+
return null;
|
|
25
|
+
return { run, gen, off: off, skipping, inKey, tail, unknown: unknown };
|
|
26
|
+
}
|
|
27
|
+
/** The bytes of text a record line carries (its words or output, as written): what uncertainty runs down by. */
|
|
28
|
+
function rawText(line) {
|
|
29
|
+
try {
|
|
30
|
+
const record = JSON.parse(line);
|
|
31
|
+
const lines = record["k"] === "say" ? record["t"] : record["k"] === "done" ? record["out"] : undefined;
|
|
32
|
+
return Array.isArray(lines) ? lines.reduce((sum, one) => sum + (typeof one === "string" ? Buffer.byteLength(one, "utf8") : 0), 0) : 0;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** One window of a run's own live file. `commands` is the run's own captured answer (store.ts runLiveCommands). */
|
|
39
|
+
export function readMainWindow(root, runId, cursor, options) {
|
|
40
|
+
const opened = openLiveTrusted(root, `${Math.trunc(runId)}.jsonl`);
|
|
41
|
+
if (opened === "missing" || opened === "unavailable")
|
|
42
|
+
return { ok: false, reason: opened };
|
|
43
|
+
try {
|
|
44
|
+
let state = cursor === null ? null : unseal(cursor, options.key, runId, opened.gen);
|
|
45
|
+
const restarted = cursor !== null && (state === null || state.off > opened.size);
|
|
46
|
+
let earlier = false;
|
|
47
|
+
if (state === null || state.off > opened.size) {
|
|
48
|
+
// A small file from its start (its state is known there); a large one from its last 256 KiB, key state unknown.
|
|
49
|
+
earlier = opened.size > SCAN_BUDGET;
|
|
50
|
+
state = earlier
|
|
51
|
+
? { run: runId, gen: opened.gen, off: opened.size - SCAN_BUDGET, skipping: true, inKey: false, tail: "", unknown: UNKNOWN_KEY_BYTES }
|
|
52
|
+
: { run: runId, gen: opened.gen, off: 0, skipping: false, inKey: false, tail: "", unknown: 0 };
|
|
53
|
+
}
|
|
54
|
+
const from = state.off;
|
|
55
|
+
const read = readLiveLines(opened.fd, opened.size, state.off, state.skipping);
|
|
56
|
+
const unknowable = () => { state.unknown = UNKNOWN_KEY_BYTES; };
|
|
57
|
+
if (read.skipped > 0)
|
|
58
|
+
unknowable();
|
|
59
|
+
const records = [];
|
|
60
|
+
let quarantined = 0;
|
|
61
|
+
for (const line of read.lines) {
|
|
62
|
+
const keys = { inKey: state.inKey || state.unknown > 0, tail: state.tail };
|
|
63
|
+
const parsed = parseLiveRecord(line.text, keys);
|
|
64
|
+
if (parsed === null) {
|
|
65
|
+
quarantined += 1;
|
|
66
|
+
unknowable();
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// `inKey` is a block actually seen open (only a valid END ends it); `unknown` is uncertainty, which also runs
|
|
70
|
+
// down by the text the record actually carried.
|
|
71
|
+
const observed = { inKey: state.inKey, tail: state.tail };
|
|
72
|
+
parseLiveRecord(line.text, observed);
|
|
73
|
+
state.inKey = keys.inKey ? observed.inKey : false;
|
|
74
|
+
state.unknown = keys.inKey ? Math.max(0, state.unknown - rawText(line.text)) : 0;
|
|
75
|
+
state.tail = keys.tail ?? "";
|
|
76
|
+
if (parsed.k === "say" && parsed.t.length === 0)
|
|
77
|
+
continue; // all inside a key block
|
|
78
|
+
records.push(...withholdCommands([parsed], options.commands));
|
|
79
|
+
}
|
|
80
|
+
state.off = read.next;
|
|
81
|
+
state.skipping = read.skipping;
|
|
82
|
+
const more = read.next < opened.size && read.next > from && (read.skipping || opened.size - from > READ_WINDOW);
|
|
83
|
+
return { ok: true, records, cursor: sealCursor(state, options.key), restarted, earlier, quarantined, skipped: read.skipped, more };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { ok: false, reason: "unavailable" };
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
closeSync(opened.fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const READ_WINDOW: number;
|
|
2
|
+
export declare const SCAN_BUDGET: number;
|
|
3
|
+
export type LiveOpened = {
|
|
4
|
+
fd: number;
|
|
5
|
+
gen: string;
|
|
6
|
+
size: number;
|
|
7
|
+
};
|
|
8
|
+
/** Test seams only: run between the checks, to swap things under the opener. */
|
|
9
|
+
export type OpenSeams = {
|
|
10
|
+
afterOpen?: () => void;
|
|
11
|
+
};
|
|
12
|
+
/** Open `name` in the run's live directory under `root`, or say it is missing or unavailable. */
|
|
13
|
+
export declare function openLiveTrusted(root: string, name: string, seams?: OpenSeams): LiveOpened | "missing" | "unavailable";
|
|
14
|
+
export type LineRead = {
|
|
15
|
+
/** Complete lines, each with the offset just past its newline. */
|
|
16
|
+
lines: {
|
|
17
|
+
text: string;
|
|
18
|
+
end: number;
|
|
19
|
+
}[];
|
|
20
|
+
/** Where the next request starts. */
|
|
21
|
+
next: number;
|
|
22
|
+
/** Lines too long to show that were skipped here. */
|
|
23
|
+
skipped: number;
|
|
24
|
+
/** The scan budget ran out inside a line: the next request resumes discarding through that line's end. */
|
|
25
|
+
skipping: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** Read complete lines from `from` (discarding first through the next newline when `skipping`), within the budgets. */
|
|
28
|
+
export declare function readLiveLines(fd: number, size: number, from: number, skipping?: boolean): LineRead;
|