@fitchmultz/pi-subagents 0.44.4 → 0.44.5
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 +9 -0
- package/README.md +4 -0
- package/dist/extension/build-info.js +1 -1
- package/dist/extension/compact-tools.js +1 -1
- package/dist/extension/schemas.js +9 -9
- package/dist/history/index.js +14 -9
- package/dist/history/ingest.js +26 -5
- package/dist/history/worker.js +13 -8
- package/dist/pi-intercom/bridge.js +750 -0
- package/dist/runs/foreground/question-control.js +23 -12
- package/dist/runs/foreground/wait-run.js +5 -3
- package/dist/runs/shared/run-history.js +8 -1
- package/dist/runs/shared/supervisor-questions.js +32 -2
- package/dist/shared/status-format.js +1 -1
- package/docs/intercom-bridge.md +212 -0
- package/docs/intercom.md +3 -1
- package/package.json +2 -1
- package/scripts/intercom-bridge-credentials.mjs +143 -0
- package/scripts/intercom-remote.mjs +103 -0
- package/scripts/package-smoke.mjs +4 -0
- package/src/extension/compact-tools.ts +1 -1
- package/src/extension/schemas.ts +9 -9
- package/src/history/index.ts +13 -9
- package/src/history/ingest.ts +22 -5
- package/src/history/worker.ts +11 -7
- package/src/pi-intercom/bridge.ts +668 -0
- package/src/runs/foreground/question-control.ts +23 -11
- package/src/runs/foreground/wait-run.ts +5 -2
- package/src/runs/shared/run-history.ts +8 -1
- package/src/runs/shared/supervisor-questions.ts +45 -2
- package/src/shared/status-format.ts +2 -2
- package/src/shared/types.ts +2 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
|
|
6
|
+
function help() {
|
|
7
|
+
console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack> [options] [IDs...]
|
|
8
|
+
|
|
9
|
+
Environment (required):
|
|
10
|
+
INTERCOM_BRIDGE_CA Path to trusted bridge CA certificate
|
|
11
|
+
INTERCOM_BRIDGE_CERT Path to your client certificate
|
|
12
|
+
INTERCOM_BRIDGE_KEY Path to your private client key (owned, mode 0600)
|
|
13
|
+
INTERCOM_BRIDGE_URL HTTPS origin (default: https://127.0.0.1:9443)
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--to PEER Recipient name or ID for send/ask
|
|
17
|
+
--message TEXT Message for send/ask/reply
|
|
18
|
+
--reply-to ID Inbound ask ID from inbox, for reply
|
|
19
|
+
--timeout-ms MS Ask timeout (100..120000, default: 120000)
|
|
20
|
+
-h, --help Show help
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
node intercom-remote.mjs list
|
|
24
|
+
node intercom-remote.mjs ask --to planner --message 'What is the current status?'
|
|
25
|
+
node intercom-remote.mjs inbox
|
|
26
|
+
node intercom-remote.mjs reply --reply-to QUESTION_ID --message 'Proceed.'
|
|
27
|
+
node intercom-remote.mjs ack MESSAGE_ID
|
|
28
|
+
|
|
29
|
+
Outputs JSON. Exit: 0 success, 1 API/TLS/transport failure, 2 invalid arguments.
|
|
30
|
+
No automatic retries: a lost response does not prove a message was unsent.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let options, body, action, origin, timeout;
|
|
34
|
+
try {
|
|
35
|
+
const parsed = parseArgs({ allowPositionals: true, options: {
|
|
36
|
+
help: { type: "boolean", short: "h" }, to: { type: "string" }, message: { type: "string" },
|
|
37
|
+
"reply-to": { type: "string" }, "timeout-ms": { type: "string" },
|
|
38
|
+
} });
|
|
39
|
+
if (parsed.values.help) { help(); process.exit(0); }
|
|
40
|
+
options = parsed.values;
|
|
41
|
+
[action] = parsed.positionals;
|
|
42
|
+
if (!["register", "list", "inbox", "send", "ask", "reply", "ack"].includes(action)) throw new Error("Choose an action; use --help.");
|
|
43
|
+
const allowed = ["send", "ask"].includes(action) ? ["to", "message", ...(action === "ask" ? ["timeout-ms"] : [])] : action === "reply" ? ["reply-to", "message"] : [];
|
|
44
|
+
if (Object.keys(options).some((key) => !allowed.includes(key)) ||
|
|
45
|
+
(action !== "ack" && parsed.positionals.length !== 1)) throw new Error("Unexpected arguments for this action.");
|
|
46
|
+
timeout = options["timeout-ms"] === undefined ? 120000 : Number(options["timeout-ms"]);
|
|
47
|
+
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 120000) throw new Error("--timeout-ms must be 100..120000.");
|
|
48
|
+
body = ["send", "ask"].includes(action) ? { to: options.to, message: options.message, ...(action === "ask" ? { timeoutMs: timeout } : {}) }
|
|
49
|
+
: action === "reply" ? { replyTo: options["reply-to"], message: options.message }
|
|
50
|
+
: action === "ack" ? { ids: parsed.positionals.slice(1) } : {};
|
|
51
|
+
if (["send", "ask", "reply"].includes(action) && (!body.message?.trim() || !(action === "reply" ? body.replyTo : body.to)?.trim())) throw new Error("Recipient/ask ID and message are required.");
|
|
52
|
+
if (action === "ack" && body.ids.length === 0) throw new Error("ack needs at least one message ID.");
|
|
53
|
+
origin = new URL(process.env.INTERCOM_BRIDGE_URL || "https://127.0.0.1:9443");
|
|
54
|
+
if (origin.protocol !== "https:" || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) throw new Error("INTERCOM_BRIDGE_URL must be an HTTPS origin, without credentials or a path.");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(JSON.stringify({ ok: false, error: { code: "invalid_arguments", message: error.message } }));
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const files = {};
|
|
62
|
+
for (const field of ["CA", "CERT", "KEY"]) {
|
|
63
|
+
const path = process.env[`INTERCOM_BRIDGE_${field}`];
|
|
64
|
+
if (!path) throw new Error(`INTERCOM_BRIDGE_${field} is required.`);
|
|
65
|
+
if (field === "KEY") {
|
|
66
|
+
const st = lstatSync(path);
|
|
67
|
+
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) throw new Error("Client key must be an owned private regular file (0600).");
|
|
68
|
+
}
|
|
69
|
+
files[field] = readFileSync(path);
|
|
70
|
+
}
|
|
71
|
+
origin.pathname = `/v1/${action}`;
|
|
72
|
+
const payload = JSON.stringify(body);
|
|
73
|
+
const get = ["list", "inbox"].includes(action);
|
|
74
|
+
const result = await new Promise((resolve, reject) => {
|
|
75
|
+
const req = https.request(origin, {
|
|
76
|
+
method: get ? "GET" : "POST", ca: files.CA, cert: files.CERT, key: files.KEY,
|
|
77
|
+
rejectUnauthorized: true, minVersion: "TLSv1.2", agent: false,
|
|
78
|
+
headers: get ? {} : { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
|
|
79
|
+
}, (res) => {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let bytes = 0;
|
|
82
|
+
res.on("data", (chunk) => {
|
|
83
|
+
bytes += chunk.length;
|
|
84
|
+
if (bytes > 16 * 1024 * 1024) { req.destroy(new Error("Bridge response exceeds 16 MiB.")); return; }
|
|
85
|
+
chunks.push(chunk);
|
|
86
|
+
});
|
|
87
|
+
res.on("error", reject);
|
|
88
|
+
res.on("end", () => {
|
|
89
|
+
try { resolve({ status: res.statusCode, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) }); }
|
|
90
|
+
catch { reject(new Error("Bridge returned invalid JSON.")); }
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
const timer = setTimeout(() => req.destroy(new Error("Bridge request deadline exceeded; delivery may be unknown.")), (action === "ask" ? timeout : 0) + 15000);
|
|
94
|
+
req.once("close", () => clearTimeout(timer));
|
|
95
|
+
req.once("error", reject);
|
|
96
|
+
req.end(get ? undefined : payload);
|
|
97
|
+
});
|
|
98
|
+
console.log(JSON.stringify(result.value, null, 2));
|
|
99
|
+
if (result.status < 200 || result.status >= 300 || result.value?.ok !== true) process.exitCode = 1;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error(JSON.stringify({ ok: false, error: { code: "transport_error", message: error.message, delivery: "unknown; do not blindly retry mutations" } }));
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
}
|
|
@@ -77,6 +77,10 @@ for (const path of [
|
|
|
77
77
|
"dist/extension/index.js",
|
|
78
78
|
"dist/pi-intercom/index.js",
|
|
79
79
|
"dist/pi-intercom/broker/broker.js",
|
|
80
|
+
"dist/pi-intercom/bridge.js",
|
|
81
|
+
"scripts/intercom-bridge-credentials.mjs",
|
|
82
|
+
"scripts/intercom-remote.mjs",
|
|
83
|
+
"docs/intercom-bridge.md",
|
|
80
84
|
"dist/runs/background/subagent-runner-launcher.js",
|
|
81
85
|
"src/extension/index.ts",
|
|
82
86
|
"src/extension/schemas.ts",
|
|
@@ -67,7 +67,7 @@ export function registerCompactSubagentTools(pi: ExtensionAPI, options: {
|
|
|
67
67
|
name: "agent_runs",
|
|
68
68
|
defaultActive: false,
|
|
69
69
|
label: "Agent Runs",
|
|
70
|
-
description: `List ${childSafe ? "only this child's directly owned" : "your delegated"} runs across working directories (questions/failures, then live work, then unreviewed results; 20 per page). Filter globally by agent/state/text, sort, and page with the returned cursor. history reads 100 bounded native-entry previews; search finds saved visible text using words or quoted phrases, not operators or prefixes. Browse freshness is not completion or delivery proof. Inspect concise results, paths and continuations; full:true includes the full task/configuration. Answer durable questions, nudge, stop, continue, or save parent-only review. Review notes are not sent to children; put actionable instructions in continue/nudge. Inspect/review/nudge never restart finished work. Continue/answer can launch a saved child; async:false waits for its actual result. Saved continuations keep settings unless agent selects a current profile; model overrides win. Live guidance never mutates model or acceptance. profiles lists roles, sources, context and model/thinking/fallback defaults. History survives reload.`,
|
|
70
|
+
description: `List ${childSafe ? "only this child's directly owned" : "your delegated"} runs across working directories (questions/failures, then live work, then unreviewed results; 20 per page). Filter globally by agent/state/text, sort, and page with the returned cursor. history reads 100 bounded native-entry previews; search finds saved visible text using words or quoted phrases, not operators or prefixes. questions pages 20 compact previews (offset/limit); questionId selects one, and questionPath/answerPath retain full records. Browse freshness is not completion or delivery proof. Inspect concise results, paths and continuations; full:true includes the full task/configuration. Answer durable questions, nudge, stop, continue, or save parent-only review. Review notes are not sent to children; put actionable instructions in continue/nudge. Inspect/review/nudge never restart finished work. Continue/answer can launch a saved child; async:false waits for its actual result. Saved continuations keep settings unless agent selects a current profile; model overrides win. Live guidance never mutates model or acceptance. profiles lists roles, sources, context and model/thinking/fallback defaults. History survives reload.`,
|
|
71
71
|
parameters: AgentRunsParams,
|
|
72
72
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
73
73
|
const normalized = normalizeEverydayParams(params, true);
|
package/src/extension/schemas.ts
CHANGED
|
@@ -279,13 +279,13 @@ const HistoryQueryFields = {
|
|
|
279
279
|
export const AgentRunsValidationParams = Type.Object({
|
|
280
280
|
action: Type.Enum(["list", "inspect", "history", "search", "nudge", "stop", "continue", "profiles", "questions", "answer", "review"] as const, { type: "string" }),
|
|
281
281
|
id: Type.Optional(Type.String({ minLength: 1, description: "Run ID or unambiguous prefix." })),
|
|
282
|
-
questionId: Type.Optional(Type.String({ minLength: 1, description: "Durable supervisor question ID for answer." })),
|
|
282
|
+
questionId: Type.Optional(Type.String({ minLength: 1, description: "Durable supervisor question ID for exact questions lookup or answer." })),
|
|
283
283
|
async: Type.Optional(Type.Boolean({ description: "Continue/answer: false waits for the actual continuation result." })),
|
|
284
284
|
index: Type.Optional(Type.Integer({ minimum: 0, description: "Child index for a multi-child run." })),
|
|
285
285
|
message: Type.Optional(Type.String({ minLength: 1, description: "Guidance, follow-up, answer, or optional parent-only review note. Review notes are not sent to the child; put actionable instructions in continue/nudge. Only continue/answer can start a saved child." })),
|
|
286
286
|
decision: Type.Optional(Type.Enum(["accepted", "needs_changes"] as const, { type: "string", description: "Parent-only review outcome; not sent to the child. Separate from execution and runtime acceptance checks." })),
|
|
287
|
-
offset: Type.Optional(Type.Integer({ minimum: 0, description: "List offset; history is retained regardless of page size." })),
|
|
288
|
-
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Results per list/search/history page (default 20; history 100)." })),
|
|
287
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "List/questions offset; history is retained regardless of page size." })),
|
|
288
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Results per list/questions/search/history page (default 20; history 100)." })),
|
|
289
289
|
...HistoryQueryFields,
|
|
290
290
|
full: Type.Optional(Type.Boolean({ description: "Inspect only: include the full task and saved launch configuration. Default is a concise report; stored data is unchanged." })),
|
|
291
291
|
agent: Type.Optional(Type.String({ minLength: 1, description: "List/search: filter by child agent. Continue/answer: adopt this current profile, including model, thinking and fallbacks; a separate model override wins. Otherwise keep saved settings. Required for old runs without a saved profile." })),
|
|
@@ -303,8 +303,8 @@ export const AgentRunsValidationParams = Type.Object({
|
|
|
303
303
|
{ if: { properties: { action: { enum: ["review"] } } }, then: requiredObject("decision") },
|
|
304
304
|
{ if: { anyOf: [requiredObject("async"), requiredObject("model"), requiredObject("cwd"), requiredObject("output"), requiredObject("acceptance")] }, then: { properties: { action: { enum: ["continue", "answer"] } } } },
|
|
305
305
|
{ if: requiredObject("agent"), then: { properties: { action: { enum: ["list", "search", "continue", "answer"] } } } },
|
|
306
|
-
{ if: requiredObject("offset"), then: { properties: { action: { enum: ["list"] } } } },
|
|
307
|
-
{ if: requiredObject("limit"), then: { properties: { action: { enum: ["list", "history", "search"] } } } },
|
|
306
|
+
{ if: requiredObject("offset"), then: { properties: { action: { enum: ["list", "questions"] } } } },
|
|
307
|
+
{ if: requiredObject("limit"), then: { properties: { action: { enum: ["list", "questions", "history", "search"] } } } },
|
|
308
308
|
{ if: requiredObject("cursor"), then: { properties: { action: { enum: ["list", "history", "search"] } } } },
|
|
309
309
|
{ if: requiredObject("sort"), then: { properties: { action: { enum: ["list", "search"] } } } },
|
|
310
310
|
{ not: { anyOf: [requiredObject("cursor", "offset"), requiredObject("cursor", "before")] } },
|
|
@@ -339,9 +339,9 @@ export const SubagentParams = Type.Object({
|
|
|
339
339
|
runId: Type.Optional(Type.String({
|
|
340
340
|
description: "Target run ID; prefer id. Defaults to the most recently active controllable run for interrupt/extend/nudge."
|
|
341
341
|
})),
|
|
342
|
-
questionId:
|
|
342
|
+
questionId: AgentRunsValidationParams.properties.questionId,
|
|
343
343
|
decision: Type.Optional(Type.Enum(["accepted", "needs_changes"] as const, { type: "string", description: "Parent-only review decision; not sent to the child. Put actionable instructions in resume/nudge. Does not launch work or change runtime acceptance." })),
|
|
344
|
-
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Status list offset." })),
|
|
344
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Status list/questions offset." })),
|
|
345
345
|
limit: AgentRunsValidationParams.properties.limit,
|
|
346
346
|
...HistoryQueryFields,
|
|
347
347
|
full: Type.Optional(Type.Boolean({ description: "Exact status only: include the full task and saved launch configuration; default is concise." })),
|
|
@@ -418,8 +418,8 @@ export const SubagentParams = Type.Object({
|
|
|
418
418
|
{ if: requiredObject("decision"), then: { ...requiredObject("action"), properties: { action: { enum: ["review"] } } } },
|
|
419
419
|
{ if: { ...requiredObject("action"), properties: { action: { enum: ["history"] } } }, then: { anyOf: [requiredObject("id"), requiredObject("runId")] } },
|
|
420
420
|
{ if: { ...requiredObject("action"), properties: { action: { enum: ["search"] } } }, then: requiredObject("query") },
|
|
421
|
-
{ if: requiredObject("offset"), then: { ...requiredObject("action"), properties: { action: { enum: ["status"] } } } },
|
|
422
|
-
{ if: requiredObject("limit"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "history", "search"] } } } },
|
|
421
|
+
{ if: requiredObject("offset"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "questions"] } } } },
|
|
422
|
+
{ if: requiredObject("limit"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "questions", "history", "search"] } } } },
|
|
423
423
|
{ if: requiredObject("cursor"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "history", "search"] } } } },
|
|
424
424
|
{ if: requiredObject("sort"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "search"] } } } },
|
|
425
425
|
{ if: { allOf: [{ ...requiredObject("action"), properties: { action: { enum: ["search"] } } }, requiredObject("index")] }, then: { anyOf: [requiredObject("id"), requiredObject("runId")] } },
|
package/src/history/index.ts
CHANGED
|
@@ -87,6 +87,15 @@ export class SubagentHistoryIndex {
|
|
|
87
87
|
}
|
|
88
88
|
await this.ready;
|
|
89
89
|
}
|
|
90
|
+
/** Arms an ordinary deadline only in the continuation that saw the current admission and worker settle, never behind a replacement admission. */
|
|
91
|
+
private async admitted<T>(method: string, input?: unknown, signal?: AbortSignal, timeout?: number): Promise<T> {
|
|
92
|
+
await this.ensure();
|
|
93
|
+
const ready = this.ready, child = this.process;
|
|
94
|
+
await ready;
|
|
95
|
+
// setOwner() or a restart may have replaced readiness during either await; wait for the replacement.
|
|
96
|
+
if (!child || ready !== this.ready || child !== this.process) return this.admitted<T>(method, input, signal, timeout);
|
|
97
|
+
return this.send<T>(method, input, signal, timeout);
|
|
98
|
+
}
|
|
90
99
|
async setOwner(input: HistoryOwner): Promise<void> {
|
|
91
100
|
if (this.closed) throw new HistoryIndexError("CLOSED", "History index is closed.");
|
|
92
101
|
if (!this.process) await Promise.all(this.children.values());
|
|
@@ -104,20 +113,16 @@ export class SubagentHistoryIndex {
|
|
|
104
113
|
await this.ensure();
|
|
105
114
|
if (run.ownerSessionId !== this.owner!.ownerSessionId || foreground && foreground.runId !== run.runId) throw new HistoryIndexError("OWNERSHIP", "Run does not belong to the current owner.");
|
|
106
115
|
const copy = structuredClone(run), fg = foreground && structuredClone(foreground);
|
|
107
|
-
await this.
|
|
116
|
+
await this.admitted<void>("updateRun", { run: copy, foreground: fg });
|
|
108
117
|
this.owner!.runs = [...this.owner!.runs.filter((entry) => entry.runId !== run.runId), copy];
|
|
109
118
|
if (fg) this.owner!.foregroundRuns = [...(this.owner!.foregroundRuns ?? []).filter((entry) => entry.runId !== run.runId), fg];
|
|
110
119
|
}
|
|
111
120
|
private async query<T>(method: string, input: { signal?: AbortSignal }): Promise<T> {
|
|
112
|
-
await this.ensure();
|
|
113
121
|
const { signal, ...wire } = input;
|
|
114
|
-
return this.
|
|
122
|
+
return this.admitted<T>(method, wire, signal);
|
|
115
123
|
}
|
|
116
124
|
/** Tool discovery waits for run metadata only, not transcript backfill. */
|
|
117
|
-
|
|
118
|
-
await this.ensure();
|
|
119
|
-
return this.send("needsControls", undefined, undefined, 15_000);
|
|
120
|
-
}
|
|
125
|
+
needsControls(): Promise<boolean> { return this.admitted("needsControls", undefined, undefined, 15_000); }
|
|
121
126
|
listRuns(options: HistoryRunOptions = {}): Promise<HistoryRunPage> { return this.query("listRuns", options); }
|
|
122
127
|
historyPage(input: HistoryPageInput): Promise<HistoryPage> { return this.query("historyPage", input); }
|
|
123
128
|
search(input: HistorySearchInput): Promise<HistorySearchPage> { return this.query("search", input); }
|
|
@@ -130,8 +135,7 @@ export class SubagentHistoryIndex {
|
|
|
130
135
|
result(input: Pick<HistoryEntryInput, "runId" | "index" | "signal">): Promise<HistoryResult | null> { return this.query("result", input); }
|
|
131
136
|
async refresh(runId?: string, options: { signal?: AbortSignal } = {}): Promise<void> {
|
|
132
137
|
if (!this.closed) this.unavailable = undefined;
|
|
133
|
-
|
|
134
|
-
return this.send("refresh", { runId }, options.signal, 120_000);
|
|
138
|
+
return this.admitted("refresh", { runId }, options.signal, 120_000);
|
|
135
139
|
}
|
|
136
140
|
onChanged(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
137
141
|
/** Hard cancellation affects this instance's outstanding work, not native sessions or other indexers. */
|
package/src/history/ingest.ts
CHANGED
|
@@ -175,8 +175,13 @@ export class SourceIngest {
|
|
|
175
175
|
private fd: number;
|
|
176
176
|
private initial: fs.BigIntStats;
|
|
177
177
|
private source: SourceRow;
|
|
178
|
-
private prefix: Hash = createHash("sha256");
|
|
179
178
|
private recordDigest: Hash = createHash("sha256");
|
|
179
|
+
/** prefix_digest is "start:sha256" of the last indexed line; earlier releases hashed the whole prefix. */
|
|
180
|
+
private legacy = false;
|
|
181
|
+
private expected: string | null = null;
|
|
182
|
+
private verified: Hash = createHash("sha256");
|
|
183
|
+
private line: Hash = createHash("sha256");
|
|
184
|
+
private lineStart = 0;
|
|
180
185
|
private verifyAt = 0;
|
|
181
186
|
private position: number;
|
|
182
187
|
private frames: JournalFrames;
|
|
@@ -208,6 +213,10 @@ export class SourceIngest {
|
|
|
208
213
|
this.headerSeen = source.cursor > 0;
|
|
209
214
|
store.clearUnpublished(source.id);
|
|
210
215
|
store.run("DELETE FROM pending_text");
|
|
216
|
+
// Appends re-hash only the last indexed line, so live sources cost their new bytes, not their size.
|
|
217
|
+
const anchor = /^(\d+):([0-9a-f]{64})$/.exec(source.prefix_digest ?? "");
|
|
218
|
+
this.legacy = !anchor; this.expected = anchor ? anchor[2] : source.prefix_digest;
|
|
219
|
+
this.verifyAt = this.lineStart = anchor ? Number(anchor[1]) : 0;
|
|
211
220
|
this.phase = !force && source.stamp === stamp(this.initial) ? "done" : source.cursor ? "verify" : "read";
|
|
212
221
|
if (this.phase !== "done") store.run("UPDATE sources SET state='indexing',error=NULL WHERE id=?", source.id);
|
|
213
222
|
this.frames = new JournalFrames(previewProjection, (record) => {
|
|
@@ -224,7 +233,7 @@ export class SourceIngest {
|
|
|
224
233
|
const current = this.store.source(this.id)!;
|
|
225
234
|
if (!current || current.generation !== this.source.generation || current.cursor !== this.source.cursor) throw new HistoryIndexError("SOURCE_CHANGED", "Another writer advanced this source; reconcile before replaying.");
|
|
226
235
|
work?.();
|
|
227
|
-
this.store.run("UPDATE sources SET cursor=?,prefix_digest=?,identity=? WHERE id=?", end, this.
|
|
236
|
+
this.store.run("UPDATE sources SET cursor=?,prefix_digest=?,identity=? WHERE id=?", end, `${this.source.cursor}:${this.recordDigest.copy().digest("hex")}`, identity(this.initial), this.id);
|
|
228
237
|
this.store.bump();
|
|
229
238
|
});
|
|
230
239
|
this.source.cursor = end;
|
|
@@ -293,11 +302,19 @@ export class SourceIngest {
|
|
|
293
302
|
const count = fs.readSync(this.fd, bytes, 0, bytes.length, this.verifyAt);
|
|
294
303
|
if (count !== bytes.length) throw new Error("Journal truncated during prefix verification.");
|
|
295
304
|
this.store.operations.sourceBytesRead += count;
|
|
296
|
-
this.
|
|
305
|
+
this.verified.update(bytes);
|
|
306
|
+
if (this.legacy) {
|
|
307
|
+
// A whole-prefix check also finds the last indexed line, which anchors every later check.
|
|
308
|
+
const before = this.source.cursor - 2 - this.verifyAt, lf = before < 0 ? -1 : bytes.lastIndexOf(10, before);
|
|
309
|
+
if (lf < 0) this.line.update(bytes);
|
|
310
|
+
else { this.lineStart = this.verifyAt + lf + 1; this.line = createHash("sha256").update(bytes.subarray(lf + 1)); }
|
|
311
|
+
}
|
|
312
|
+
this.verifyAt += count;
|
|
297
313
|
if (this.verifyAt === this.source.cursor) {
|
|
298
|
-
if (this.
|
|
314
|
+
if (this.verified.digest("hex") !== this.expected) {
|
|
299
315
|
this.store.resetSource(this.id); throw new HistoryIndexError("SOURCE_CHANGED", "Journal prefix changed; a new physical generation was scheduled.");
|
|
300
316
|
}
|
|
317
|
+
if (this.legacy) this.store.run("UPDATE sources SET prefix_digest=? WHERE id=?", `${this.lineStart}:${this.line.digest("hex")}`, this.id);
|
|
301
318
|
this.phase = "read";
|
|
302
319
|
}
|
|
303
320
|
return false;
|
|
@@ -313,7 +330,7 @@ export class SourceIngest {
|
|
|
313
330
|
}
|
|
314
331
|
const newline = this.buffer.indexOf(10);
|
|
315
332
|
const bytes = newline < 0 ? this.buffer : this.buffer.subarray(0, newline + 1);
|
|
316
|
-
this.recordDigest.update(bytes); this.
|
|
333
|
+
this.recordDigest.update(bytes); this.frames.write(bytes); this.text.flush(this.store);
|
|
317
334
|
this.buffer = this.buffer.subarray(bytes.length);
|
|
318
335
|
if (newline >= 0) this.finishRecord(this.frames.offset);
|
|
319
336
|
return false;
|
package/src/history/worker.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { ownedRunView } from "../runs/shared/run-records.ts";
|
|
4
4
|
import { acceptanceHumanAction } from "../runs/shared/acceptance-evaluation.ts";
|
|
5
|
-
import { getRunMetadataDir, listRunQuestions } from "../runs/shared/supervisor-questions.ts";
|
|
5
|
+
import { getRunMetadataDir, listRunQuestions, summarizeSupervisorQuestion } from "../runs/shared/supervisor-questions.ts";
|
|
6
6
|
import { HistoryIndexError } from "./types.ts";
|
|
7
7
|
import type { ForegroundResumeRun, HistoryOwner, HistoryRunRow, OwnedRun, Request, Response } from "./types.ts";
|
|
8
8
|
import type { SubagentState } from "../shared/types.ts";
|
|
@@ -28,7 +28,10 @@ const controlQueries: number[] = [];
|
|
|
28
28
|
function replyControls(id: number): void {
|
|
29
29
|
send({ id, value: Boolean(store!.get("SELECT id FROM runs WHERE attention<4 OR state='live' LIMIT 1")) });
|
|
30
30
|
}
|
|
31
|
-
function send(response: Response): void {
|
|
31
|
+
function send(response: Response): void {
|
|
32
|
+
// The parent can disconnect after process.connected was read; then this and later replies are undeliverable, so stop as on disconnect.
|
|
33
|
+
if (process.connected) process.send!(response, undefined, undefined, (error) => { if (error) shutdown(); });
|
|
34
|
+
}
|
|
32
35
|
function changed(): void { send({ changed: true }); }
|
|
33
36
|
function errorReply(id: number, error: unknown): void {
|
|
34
37
|
const code = error instanceof HistoryIndexError ? error.code : "UNAVAILABLE";
|
|
@@ -43,10 +46,10 @@ function compactView(view: HistoryRunRow): HistoryRunRow {
|
|
|
43
46
|
return {
|
|
44
47
|
...view, task: safeText(view.task, 2048), error: view.error && safeText(view.error), diagnosis: view.diagnosis && safeText(view.diagnosis), recoveryError: view.recoveryError && safeText(view.recoveryError),
|
|
45
48
|
review: view.review && { ...view.review, message: view.review.message && safeText(view.review.message) }, continuations: [],
|
|
46
|
-
questions: view.questions?.map((question) =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
questions: view.questions?.map((question) => {
|
|
50
|
+
const summary = summarizeSupervisorQuestion(question);
|
|
51
|
+
return { ...summary, message: safeText(question.message, 2048), answer: summary.answer && { ...summary.answer, message: safeText(question.answer?.message, 2048) } };
|
|
52
|
+
}),
|
|
50
53
|
children: view.children.map((child) => ({
|
|
51
54
|
agent: child.agent, index: child.index, workflowNodeId: child.workflowNodeId, sessionFile: child.sessionFile, task: child.task && safeText(child.task), label: child.label && safeText(child.label, 256),
|
|
52
55
|
state: child.state, configuration: child.configuration, missingSession: child.missingSession, identityUnavailable: child.identityUnavailable, modelSelection: child.modelSelection,
|
|
@@ -314,4 +317,5 @@ async function handle(request: Request): Promise<void> {
|
|
|
314
317
|
} catch (error) { errorReply(request.id, error); }
|
|
315
318
|
}
|
|
316
319
|
process.on("message", (request: Request) => { void handle(request); });
|
|
317
|
-
|
|
320
|
+
function shutdown(): void { closed = true; clearInterval(timer); clear(); process.exit(0); }
|
|
321
|
+
process.on("disconnect", shutdown);
|