@fitchmultz/pi-subagents 0.52.16 → 0.52.18
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 +30 -0
- package/dist/agent/host.js +1 -1
- package/dist/agent/notifications.js +10 -8
- package/dist/agent/observe-layout.js +3 -3
- package/dist/agent/parking.js +0 -1
- package/dist/agent/process.js +1 -4
- package/dist/agent/runner.js +0 -2
- package/dist/agent/shutdown.js +0 -1
- package/dist/agent/typebox.js +2 -2
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-presence.js +0 -1
- package/dist/pi-intercom/bridge-protocol.js +0 -2
- package/dist/pi-intercom/bridge-session.js +0 -1
- package/dist/pi-intercom/bridge-wake.js +0 -1
- package/dist/pi-intercom/broker/client.js +0 -2
- package/dist/pi-intercom/broker/spawn.js +0 -3
- package/dist/pi-intercom/inbound-delivery.js +2 -6
- package/dist/pi-intercom/reply-tracker.js +47 -55
- package/dist/pi-intercom/session-events.js +2 -0
- package/dist/pi-intercom/tool-registration.js +1 -2
- package/dist/pi-intercom/validation.js +0 -2
- package/dist/shared/cooperative.js +0 -1
- package/docs/code-quality.md +96 -68
- package/docs/intercom.md +6 -2
- package/package.json +9 -6
- package/scripts/build.mjs +11 -13
- package/scripts/check-compat.mjs +3 -2
- package/scripts/compat-host.mjs +5 -5
- package/scripts/intercom-bridge-credentials.mjs +4 -3
- package/scripts/intercom-remote.mjs +0 -1
- package/scripts/native-package-smoke-assertions.mjs +25 -23
- package/scripts/native-package-smoke.mjs +38 -54
- package/scripts/package-smoke.mjs +39 -30
- package/scripts/prepare.mjs +16 -10
- package/scripts/real-pi-events.mjs +92 -0
- package/scripts/real-pi-live.mjs +28 -108
- package/scripts/real-pi-processes.mjs +77 -82
- package/scripts/real-pi-smoke.mjs +20 -18
- package/src/agent/host.ts +1 -1
- package/src/agent/notifications.ts +11 -8
- package/src/agent/observe-layout.ts +3 -3
- package/src/agent/parking.ts +0 -1
- package/src/agent/process.ts +1 -4
- package/src/agent/runner.ts +0 -2
- package/src/agent/shutdown.ts +0 -1
- package/src/agent/typebox.ts +2 -2
- package/src/pi-intercom/bridge-presence.ts +0 -1
- package/src/pi-intercom/bridge-protocol.ts +0 -2
- package/src/pi-intercom/bridge-session.ts +0 -1
- package/src/pi-intercom/bridge-wake.ts +0 -1
- package/src/pi-intercom/broker/client.ts +0 -2
- package/src/pi-intercom/broker/spawn.ts +0 -3
- package/src/pi-intercom/inbound-delivery.ts +2 -6
- package/src/pi-intercom/reply-tracker.ts +50 -58
- package/src/pi-intercom/session-events.ts +3 -1
- package/src/pi-intercom/tool-registration.ts +1 -2
- package/src/pi-intercom/validation.ts +0 -2
- package/src/shared/cooperative.ts +0 -1
package/scripts/real-pi-live.mjs
CHANGED
|
@@ -6,48 +6,14 @@ import process from "node:process";
|
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
7
|
import { isChildTreeAlive } from "../src/shared/post-exit-stdio-guard.ts";
|
|
8
8
|
import { filesIn, processRef } from "./real-pi-processes.mjs";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { isRecord } from "../src/shared/unknown.ts";
|
|
11
|
+
import { sessionView } from "./real-pi-events.mjs";
|
|
9
12
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*/
|
|
14
|
-
/**
|
|
15
|
-
* A message record projected from a message_end event.
|
|
16
|
-
* @typedef {object} StreamedMessage
|
|
17
|
-
* @property {string | undefined} customType
|
|
18
|
-
* @property {string | undefined} role
|
|
19
|
-
* @property {{ type?: string, text?: string }[] | undefined} content
|
|
20
|
-
* @property {string | undefined} stopReason
|
|
21
|
-
* @property {SessionNotice | undefined} details
|
|
22
|
-
*/
|
|
23
|
-
/**
|
|
24
|
-
* A completion notification saved from a custom agent-completion message.
|
|
25
|
-
* @typedef {object} SessionNotice
|
|
26
|
-
* @property {string | undefined} id
|
|
27
|
-
* @property {number | undefined} index
|
|
28
|
-
* @property {{ status?: string } | undefined} result
|
|
29
|
-
* @property {{ text?: string } | undefined} page
|
|
30
|
-
*/
|
|
31
|
-
/**
|
|
32
|
-
* A launch/continuation receipt projected from a tool_execution_end event.
|
|
33
|
-
* @typedef {object} SessionReceipt
|
|
34
|
-
* @property {string | undefined} tool
|
|
35
|
-
* @property {string | undefined} receipt
|
|
36
|
-
* @property {string | undefined} name
|
|
37
|
-
* @property {string | undefined} id
|
|
38
|
-
* @property {string | undefined} sessionFile
|
|
39
|
-
* @property {string | undefined} context
|
|
40
|
-
* @property {number | undefined} index
|
|
41
|
-
* @property {string | undefined} predecessor
|
|
42
|
-
*/
|
|
43
|
-
/**
|
|
44
|
-
* The verified view of one live RPC session.
|
|
45
|
-
* @typedef {object} SessionView
|
|
46
|
-
* @property {RpcEvent[]} events
|
|
47
|
-
* @property {SessionReceipt[]} receipts
|
|
48
|
-
* @property {Map<string | undefined, SessionNotice>} notices
|
|
49
|
-
* @property {{ index: number, text: string, stopReason: string | undefined }[]} replies
|
|
50
|
-
*/
|
|
13
|
+
/** @typedef {import("./real-pi-events.mjs").RpcEvent} RpcEvent */
|
|
14
|
+
/** @typedef {import("./real-pi-events.mjs").SessionReceipt} SessionReceipt */
|
|
15
|
+
/** @typedef {import("./real-pi-events.mjs").SessionNotice} SessionNotice */
|
|
16
|
+
/** @typedef {import("./real-pi-events.mjs").SessionView} SessionView */
|
|
51
17
|
/**
|
|
52
18
|
* The shared smoke runner options (built by scripts/real-pi-smoke.mjs).
|
|
53
19
|
* @typedef {object} LiveSmokeOptions
|
|
@@ -96,47 +62,6 @@ function modelArgs() {
|
|
|
96
62
|
return args;
|
|
97
63
|
}
|
|
98
64
|
|
|
99
|
-
function addMessage(
|
|
100
|
-
/** @type {SessionView} */ view,
|
|
101
|
-
/** @type {StreamedMessage | undefined} */ message,
|
|
102
|
-
/** @type {number} */ index,
|
|
103
|
-
) {
|
|
104
|
-
if (message?.customType === "agent-completion" && message.details !== undefined) {
|
|
105
|
-
view.notices.set(message.details.id, { ...message.details, index });
|
|
106
|
-
} else if (message?.role === "assistant") {
|
|
107
|
-
const text = (message.content ?? []).filter((part) => part.type === "text");
|
|
108
|
-
view.replies.push({
|
|
109
|
-
index,
|
|
110
|
-
text: text.map((part) => part.text).join("\n"),
|
|
111
|
-
stopReason: message.stopReason,
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function sessionView(/** @type {RpcEvent[]} */ events) {
|
|
117
|
-
/** @type {SessionView} */
|
|
118
|
-
const view = { events, receipts: [], notices: new Map(), replies: [] };
|
|
119
|
-
for (const [index, event] of events.entries()) {
|
|
120
|
-
// oxlint-disable typescript/no-unsafe-type-assertion, typescript/no-unnecessary-type-assertion -- RpcEvent carries unknown payloads
|
|
121
|
-
const details = /** @type {SessionReceipt | undefined} */ (
|
|
122
|
-
/** @type {{ details?: SessionReceipt } | undefined} */ (event.result)?.details
|
|
123
|
-
);
|
|
124
|
-
// oxlint-enable typescript/no-unsafe-type-assertion, typescript/no-unnecessary-type-assertion
|
|
125
|
-
if (event.type === "message_end") {
|
|
126
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- RpcEvent carries unknown payloads
|
|
127
|
-
addMessage(view, /** @type {StreamedMessage} */ (event.message), index);
|
|
128
|
-
} else if (
|
|
129
|
-
event.type === "tool_execution_end" &&
|
|
130
|
-
details !== undefined &&
|
|
131
|
-
(details.receipt === "launch" || details.receipt === "continuation")
|
|
132
|
-
) {
|
|
133
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- RpcEvent carries unknown payloads
|
|
134
|
-
view.receipts.push({ ...details, index, tool: /** @type {string} */ (event.toolName) });
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return view;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
65
|
// Persistent RPC main: launch/continuation receipts are not completion, so the main stays
|
|
141
66
|
// alive until every receipt's native completion notification has resumed and settled it.
|
|
142
67
|
function rpcSession(
|
|
@@ -158,13 +83,15 @@ function rpcSession(
|
|
|
158
83
|
for (let end = buffer.indexOf("\n"); end >= 0; end = buffer.indexOf("\n")) {
|
|
159
84
|
const line = buffer.slice(0, end).replace(/\r$/, "");
|
|
160
85
|
buffer = buffer.slice(end + 1);
|
|
161
|
-
/** @type {
|
|
86
|
+
/** @type {unknown} */ let record;
|
|
162
87
|
try {
|
|
163
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
164
88
|
record = JSON.parse(line);
|
|
165
89
|
} catch {
|
|
166
90
|
continue;
|
|
167
91
|
}
|
|
92
|
+
if (!isRecord(record)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
168
95
|
events.push(record);
|
|
169
96
|
if (record.type === "response" && record.id === "smoke" && record.success !== true) {
|
|
170
97
|
settle.reject(new Error(`RPC prompt was rejected: ${JSON.stringify(record.error)}`));
|
|
@@ -229,19 +156,13 @@ async function runLiveSession(
|
|
|
229
156
|
// Verify while the main is alive; only then request its orderly shutdown.
|
|
230
157
|
await verify(view);
|
|
231
158
|
} finally {
|
|
232
|
-
|
|
233
|
-
session?.close();
|
|
159
|
+
session.close();
|
|
234
160
|
writeFileSync(
|
|
235
161
|
join(options.root, `${label.replace(/ /g, "-")}.jsonl`),
|
|
236
|
-
|
|
237
|
-
session ? session.events.map((event) => JSON.stringify(event)).join("\n") : "",
|
|
162
|
+
session.events.map((event) => JSON.stringify(event)).join("\n"),
|
|
238
163
|
);
|
|
239
164
|
}
|
|
240
165
|
await exited;
|
|
241
|
-
// oxlint-disable-next-line typescript/no-unnecessary-condition -- flow analysis misses the race failure path
|
|
242
|
-
if (view === undefined) {
|
|
243
|
-
throw new Error(`${label} settled without a session view`);
|
|
244
|
-
}
|
|
245
166
|
return view;
|
|
246
167
|
}
|
|
247
168
|
|
|
@@ -250,11 +171,12 @@ function* savedAssistantModels(/** @type {string} */ file) {
|
|
|
250
171
|
if (line.trim() === "") {
|
|
251
172
|
continue;
|
|
252
173
|
}
|
|
253
|
-
/** @type {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
174
|
+
/** @type {unknown} */ const entry = JSON.parse(line);
|
|
175
|
+
assert.ok(isRecord(entry));
|
|
176
|
+
if (entry.type === "message" && isRecord(entry.message) && entry.message.role === "assistant") {
|
|
177
|
+
const { provider = "", model = "" } = entry.message;
|
|
178
|
+
assert.ok(typeof provider === "string" && typeof model === "string");
|
|
179
|
+
yield `${provider}/${model}`;
|
|
258
180
|
}
|
|
259
181
|
}
|
|
260
182
|
}
|
|
@@ -307,9 +229,10 @@ function asyncCompletion(/** @type {string} */ dir, /** @type {string} */ runId)
|
|
|
307
229
|
if (!existsSync(resultPath)) {
|
|
308
230
|
return { status: "missing" };
|
|
309
231
|
}
|
|
310
|
-
/** @type {
|
|
311
|
-
|
|
312
|
-
|
|
232
|
+
/** @type {unknown} */ const result = JSON.parse(readFileSync(resultPath, "utf8"));
|
|
233
|
+
assert.ok(isRecord(result));
|
|
234
|
+
assert.ok(typeof result.status === "string");
|
|
235
|
+
assert.ok(result.error === undefined || typeof result.error === "string");
|
|
313
236
|
if (result.id !== runId) {
|
|
314
237
|
throw new Error("Async completion belongs to another saved run");
|
|
315
238
|
}
|
|
@@ -320,9 +243,8 @@ function asyncCompletion(/** @type {string} */ dir, /** @type {string} */ runId)
|
|
|
320
243
|
if (!existsSync(processPath)) {
|
|
321
244
|
return { status: "completed; actor exit unconfirmed" };
|
|
322
245
|
}
|
|
323
|
-
/** @type {
|
|
324
|
-
|
|
325
|
-
const actor = JSON.parse(readFileSync(processPath, "utf8"));
|
|
246
|
+
/** @type {unknown} */ const actor = JSON.parse(readFileSync(processPath, "utf8"));
|
|
247
|
+
assert.ok(isRecord(actor) && typeof actor.pid === "number");
|
|
326
248
|
return {
|
|
327
249
|
status: result.status,
|
|
328
250
|
output: isChildTreeAlive(processRef(actor.pid))
|
|
@@ -356,7 +278,6 @@ async function waitForAsyncCompletion(
|
|
|
356
278
|
return;
|
|
357
279
|
}
|
|
358
280
|
// Result publication is not process exit; retain resources until final actor writes finish.
|
|
359
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
360
281
|
await delay(100, undefined, { signal });
|
|
361
282
|
}
|
|
362
283
|
throw new Error(
|
|
@@ -395,7 +316,7 @@ function requireFinalReply(
|
|
|
395
316
|
/** @type {RegExp} */ pattern,
|
|
396
317
|
) {
|
|
397
318
|
const last = view.replies.at(-1);
|
|
398
|
-
const notified = Math.max(-1, ...[...view.notices.values()].map(({ index }) => index
|
|
319
|
+
const notified = Math.max(-1, ...[...view.notices.values()].map(({ index }) => index));
|
|
399
320
|
if (last === undefined || last.stopReason !== "stop" || last.index < notified) {
|
|
400
321
|
throw new Error(
|
|
401
322
|
`${label} did not finish a successful reply after its final completion notification.`,
|
|
@@ -410,7 +331,7 @@ function requireCompletion(
|
|
|
410
331
|
/** @type {string} */ expected,
|
|
411
332
|
) {
|
|
412
333
|
const notice = view.notices.get(receipt.id);
|
|
413
|
-
if (notice?.result
|
|
334
|
+
if (notice?.result.status !== "completed" || notice.page.text?.trim() !== expected) {
|
|
414
335
|
throw new Error(`Run ${receipt.id ?? ""} completion notification did not report: ${expected}`);
|
|
415
336
|
}
|
|
416
337
|
}
|
|
@@ -435,7 +356,6 @@ async function runBasicLiveSmoke(
|
|
|
435
356
|
for (const [kind, prompt, tool] of prompts) {
|
|
436
357
|
const label = `real Pi ${kind} prompt`;
|
|
437
358
|
// Keep separate native parent sessions so each smoke owns its saved runs.
|
|
438
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
439
359
|
await runLiveSession(runOptions, {
|
|
440
360
|
label,
|
|
441
361
|
prompt,
|
|
@@ -524,7 +444,7 @@ function topologyLinksPreserved(first, continued, forked, firstNotice) {
|
|
|
524
444
|
|
|
525
445
|
function requireForkTopology(/** @type {SessionView} */ view) {
|
|
526
446
|
const kinds = view.receipts.map(
|
|
527
|
-
({ tool, receipt, context }) => `${tool
|
|
447
|
+
({ tool, receipt, context }) => `${tool}:${receipt}:${context ?? ""}`,
|
|
528
448
|
);
|
|
529
449
|
const expected = [
|
|
530
450
|
"agent_runs:continuation:fresh",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
2
3
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
4
|
import { basename, dirname, join } from "node:path";
|
|
4
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -9,7 +10,6 @@ import {
|
|
|
9
10
|
trySignalChildTree,
|
|
10
11
|
} from "../src/shared/post-exit-stdio-guard.ts";
|
|
11
12
|
|
|
12
|
-
// oxlint-disable-next-line typescript/strict-void-return
|
|
13
13
|
const execFileAsync = promisify(execFile);
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -69,10 +69,8 @@ export function processRef(/** @type {number} */ pid) {
|
|
|
69
69
|
// This attempt owns only PIDs discovered from private run files or known direct children.
|
|
70
70
|
// Credentials and artifacts must remain available until all owned workers and the broker exit.
|
|
71
71
|
export class SmokeProcesses {
|
|
72
|
-
|
|
73
|
-
/** @type {
|
|
74
|
-
// oxlint-disable-next-line typescript/prefer-readonly -- JSDoc class fields cannot express readonly
|
|
75
|
-
/** @type {SmokeProcessOptions} */ #options;
|
|
72
|
+
/** @readonly @type {Map<number, ProcessEntry>} */ #processes = new Map();
|
|
73
|
+
/** @readonly @type {SmokeProcessOptions} */ #options;
|
|
76
74
|
|
|
77
75
|
constructor(/** @type {SmokeProcessOptions} */ { cwd, env, timeoutMs, signal, root }) {
|
|
78
76
|
this.#options = { cwd, env, timeoutMs, signal, root };
|
|
@@ -103,83 +101,84 @@ export class SmokeProcesses {
|
|
|
103
101
|
run(label, command, args, input) {
|
|
104
102
|
const { cwd, env, timeoutMs, signal } = this.#options;
|
|
105
103
|
signal.throwIfAborted();
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
) => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
await discovery;
|
|
117
|
-
if (error) {
|
|
118
|
-
reject(error);
|
|
104
|
+
/** @type {PromiseWithResolvers<string>} */
|
|
105
|
+
const completion = Promise.withResolvers();
|
|
106
|
+
/** @type {Promise<void> | undefined} */ let discovery;
|
|
107
|
+
/** @type {string | undefined} */ let discoveryError;
|
|
108
|
+
let child;
|
|
109
|
+
try {
|
|
110
|
+
child = execFile(command, args, spawnOptions(cwd, env), (error, stdout, stderr) => {
|
|
111
|
+
const output = `${stdout}${stderr}`;
|
|
112
|
+
if (error === null) {
|
|
113
|
+
completion.resolve(output);
|
|
119
114
|
} else {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
};
|
|
123
|
-
const failure = (
|
|
124
|
-
/** @type {import("node:child_process").ExecFileException | null} */ error,
|
|
125
|
-
/** @type {string} */ output,
|
|
126
|
-
) =>
|
|
127
|
-
error === null
|
|
128
|
-
? undefined
|
|
129
|
-
: new Error(
|
|
115
|
+
completion.reject(
|
|
116
|
+
new Error(
|
|
130
117
|
`${label} failed with ${error.code ?? "spawn error"}\nCommand: ${command} ${args.join(" ")}\n${output}`,
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// oxlint-disable-next-line typescript/no-floating-promises -- finish settles the outer promise by design
|
|
135
|
-
void finish(failure(error, output), output);
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
136
121
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const watcher = setInterval(() => {
|
|
147
|
-
discovery ??= this.#collect()
|
|
148
|
-
.catch((/** @type {unknown} */ error) => {
|
|
149
|
-
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
|
150
|
-
if (message !== discoveryError) {
|
|
151
|
-
console.error(`[real-pi-smoke] process discovery: ${message}`);
|
|
152
|
-
}
|
|
153
|
-
discoveryError = message;
|
|
154
|
-
})
|
|
155
|
-
.finally(() => {
|
|
156
|
-
discovery = undefined;
|
|
157
|
-
});
|
|
158
|
-
}, 100);
|
|
159
|
-
// The outer finally stops parents AND detached workers; rejecting must not kill parents first.
|
|
160
|
-
const timer = setTimeout(() => {
|
|
161
|
-
// oxlint-disable-next-line typescript/no-floating-promises -- finish settles the outer promise by design
|
|
162
|
-
void finish(
|
|
163
|
-
new Error(
|
|
164
|
-
`${label} timed out after ${timeoutMs}ms\nCommand: ${command} ${args.join(" ")}`,
|
|
165
|
-
),
|
|
166
|
-
);
|
|
167
|
-
}, timeoutMs);
|
|
168
|
-
const cancel = () => {
|
|
169
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion, typescript/no-floating-promises -- signal.reason is any; finish settles the outer promise
|
|
170
|
-
void finish(/** @type {Error | undefined} */ (signal.reason));
|
|
171
|
-
};
|
|
172
|
-
signal.addEventListener("abort", cancel, { once: true });
|
|
173
|
-
child.stdin?.on("error", () => {
|
|
174
|
-
// A command may exit without reading RPC input; execFile reports its exit failure.
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return Promise.reject(error);
|
|
124
|
+
}
|
|
125
|
+
attachChildProcessLifecycle(child);
|
|
126
|
+
if (child.pid !== undefined) {
|
|
127
|
+
/** @type {ProcessEntry} */ const entry = { child };
|
|
128
|
+
this.#processes.set(child.pid, entry);
|
|
129
|
+
child.once("close", () => {
|
|
130
|
+
entry.exited = !isChildTreeAlive(child);
|
|
175
131
|
});
|
|
176
|
-
|
|
132
|
+
}
|
|
133
|
+
// Discover detached startup children even if their parent's startup fails.
|
|
134
|
+
const watcher = setInterval(() => {
|
|
135
|
+
discovery ??= this.#collect()
|
|
136
|
+
.catch((/** @type {unknown} */ error) => {
|
|
137
|
+
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
|
138
|
+
if (message !== discoveryError) {
|
|
139
|
+
console.error(`[real-pi-smoke] process discovery: ${message}`);
|
|
140
|
+
}
|
|
141
|
+
discoveryError = message;
|
|
142
|
+
})
|
|
143
|
+
.finally(() => {
|
|
144
|
+
discovery = undefined;
|
|
145
|
+
});
|
|
146
|
+
}, 100);
|
|
147
|
+
// The outer finally stops parents AND detached workers; rejecting must not kill parents first.
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
completion.reject(
|
|
150
|
+
new Error(`${label} timed out after ${timeoutMs}ms\nCommand: ${command} ${args.join(" ")}`),
|
|
151
|
+
);
|
|
152
|
+
}, timeoutMs);
|
|
153
|
+
const cancel = () => {
|
|
154
|
+
/** @type {unknown} */ const reason = signal.reason;
|
|
155
|
+
completion.reject(reason);
|
|
156
|
+
};
|
|
157
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
158
|
+
child.stdin?.on("error", () => {
|
|
159
|
+
// A command may exit without reading RPC input; execFile reports its exit failure.
|
|
160
|
+
});
|
|
161
|
+
// A function input owns a persistent RPC stdin and closes it to request orderly shutdown.
|
|
162
|
+
try {
|
|
177
163
|
if (typeof input === "function") {
|
|
178
164
|
input(child);
|
|
179
165
|
} else {
|
|
180
166
|
child.stdin?.end(input);
|
|
181
167
|
}
|
|
182
|
-
})
|
|
168
|
+
} catch (error) {
|
|
169
|
+
completion.reject(error);
|
|
170
|
+
}
|
|
171
|
+
async function waitForCompletion() {
|
|
172
|
+
try {
|
|
173
|
+
return await completion.promise;
|
|
174
|
+
} finally {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
clearInterval(watcher);
|
|
177
|
+
signal.removeEventListener("abort", cancel);
|
|
178
|
+
await discovery;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return waitForCompletion();
|
|
183
182
|
}
|
|
184
183
|
|
|
185
184
|
#readRunPids() {
|
|
@@ -194,9 +193,10 @@ export class SmokeProcesses {
|
|
|
194
193
|
const runs = join(agentDir, "sessions", "agent-runs");
|
|
195
194
|
for (const file of filesIn(runs)) {
|
|
196
195
|
if (basename(file) === "process.json" && basename(dirname(file)) === "actor.lock") {
|
|
197
|
-
/** @type {
|
|
198
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
196
|
+
/** @type {unknown} */
|
|
199
197
|
const record = JSON.parse(readFileSync(file, "utf8"));
|
|
198
|
+
assert.ok(typeof record === "object" && record !== null && "pid" in record);
|
|
199
|
+
assert.ok(typeof record.pid === "number");
|
|
200
200
|
this.#remember(record.pid);
|
|
201
201
|
}
|
|
202
202
|
}
|
|
@@ -231,10 +231,8 @@ export class SmokeProcesses {
|
|
|
231
231
|
if (entry.exited !== true) {
|
|
232
232
|
entry.exited = !isChildTreeAlive(entry.child);
|
|
233
233
|
}
|
|
234
|
-
|
|
235
|
-
if (entry.exited !== true) {
|
|
234
|
+
if (!entry.exited) {
|
|
236
235
|
// The Map expands during discovery; sequential traversal covers newly found grandchildren.
|
|
237
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
238
236
|
await this.#discoverChildren(entry.child);
|
|
239
237
|
}
|
|
240
238
|
}
|
|
@@ -246,8 +244,7 @@ export class SmokeProcesses {
|
|
|
246
244
|
if (entry.exited !== true) {
|
|
247
245
|
entry.exited = !isChildTreeAlive(entry.child);
|
|
248
246
|
}
|
|
249
|
-
|
|
250
|
-
if (entry.exited !== true) {
|
|
247
|
+
if (!entry.exited) {
|
|
251
248
|
alive.push(entry);
|
|
252
249
|
}
|
|
253
250
|
}
|
|
@@ -273,7 +270,6 @@ export class SmokeProcesses {
|
|
|
273
270
|
let collected = false;
|
|
274
271
|
try {
|
|
275
272
|
// Ownership discovery must finish before this iteration signals processes.
|
|
276
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
277
273
|
await this.#collect();
|
|
278
274
|
collected = true;
|
|
279
275
|
} catch (error) {
|
|
@@ -290,7 +286,6 @@ export class SmokeProcesses {
|
|
|
290
286
|
}
|
|
291
287
|
this.#signal(alive);
|
|
292
288
|
// Recheck liveness after signals; concurrent polling would race tree cleanup.
|
|
293
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
294
289
|
await delay(50);
|
|
295
290
|
}
|
|
296
291
|
}
|
|
@@ -4,6 +4,8 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { runLiveSmoke } from "./real-pi-live.mjs";
|
|
6
6
|
import { SmokeProcesses } from "./real-pi-processes.mjs";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { isRecord, isUnknownArray } from "../src/shared/unknown.ts";
|
|
7
9
|
|
|
8
10
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
9
11
|
|
|
@@ -123,9 +125,8 @@ async function verifyBundledResources(
|
|
|
123
125
|
throw new Error(`bundled Pi resources did not register ${name}:\n${output}`);
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
|
-
/** @type {
|
|
127
|
-
|
|
128
|
-
const tools = JSON.parse(readFileSync(marker, "utf8"));
|
|
128
|
+
/** @type {unknown} */ const tools = JSON.parse(readFileSync(marker, "utf8"));
|
|
129
|
+
assert.ok(isUnknownArray(tools));
|
|
129
130
|
for (const name of ["delegate", "agent_runs", "intercom"]) {
|
|
130
131
|
if (!tools.includes(name)) {
|
|
131
132
|
throw new Error(`${name} must be eager on the single package entry`);
|
|
@@ -146,17 +147,15 @@ async function verifyBundledResources(
|
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
149
|
* @param {string} output
|
|
149
|
-
* @returns {
|
|
150
|
+
* @returns {Record<string, unknown> | undefined}
|
|
150
151
|
*/
|
|
151
152
|
function commandResponse(output) {
|
|
152
|
-
/** @type {
|
|
153
|
+
/** @type {Record<string, unknown> | undefined} */
|
|
153
154
|
let response;
|
|
154
155
|
for (const line of output.split(/\r?\n/)) {
|
|
155
156
|
try {
|
|
156
|
-
/** @type {
|
|
157
|
-
|
|
158
|
-
const parsed = JSON.parse(line);
|
|
159
|
-
if (parsed.type === "response" && parsed.id === "commands") {
|
|
157
|
+
/** @type {unknown} */ const parsed = JSON.parse(line);
|
|
158
|
+
if (isRecord(parsed) && parsed.type === "response" && parsed.id === "commands") {
|
|
160
159
|
response = parsed;
|
|
161
160
|
}
|
|
162
161
|
} catch {
|
|
@@ -166,15 +165,18 @@ function commandResponse(output) {
|
|
|
166
165
|
return response;
|
|
167
166
|
}
|
|
168
167
|
|
|
169
|
-
function registeredCommands(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
response.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
168
|
+
function registeredCommands(/** @type {Record<string, unknown> | undefined} */ response) {
|
|
169
|
+
if (
|
|
170
|
+
response?.success !== true ||
|
|
171
|
+
!isRecord(response.data) ||
|
|
172
|
+
!isUnknownArray(response.data.commands)
|
|
173
|
+
) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
return response.data.commands.map((command) => {
|
|
177
|
+
assert.ok(isRecord(command));
|
|
178
|
+
return command.name;
|
|
179
|
+
});
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
function createProject(/** @type {string} */ root) {
|
package/src/agent/host.ts
CHANGED
|
@@ -108,7 +108,7 @@ export async function loadSDK(root: string): Promise<HostSDK> {
|
|
|
108
108
|
return resolvedSDK;
|
|
109
109
|
}
|
|
110
110
|
// Selected host SDK, not a second package-local runtime hidden behind Jiti aliases.
|
|
111
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
111
|
+
// oxlint-disable-next-line typescript/no-unsafe-assignment -- QX-004: selected host module must retain its runtime identity.
|
|
112
112
|
const sdk: HostSDK = await import(pathToFileURL(path.join(root, "dist/index.js")).href);
|
|
113
113
|
return sdk;
|
|
114
114
|
}
|
|
@@ -8,17 +8,20 @@ import type { Launch, Result } from "./contracts.ts";
|
|
|
8
8
|
import { launchAt, list, runDirectory } from "./storage.ts";
|
|
9
9
|
import { readCanonicalResult, readResult } from "./usage.ts";
|
|
10
10
|
|
|
11
|
+
function isStringSet(value: unknown): value is Set<string> {
|
|
12
|
+
if (!(value instanceof Set)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const entries: Set<unknown> = value;
|
|
16
|
+
return [...entries].every((entry) => typeof entry === "string");
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
function queuedAdmissions(): Set<string> {
|
|
12
20
|
const store = globalThis as Record<string, unknown>;
|
|
13
21
|
const existing = store.__pi_single_agent_admissions__;
|
|
14
|
-
if (existing
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (strings.length === values.size) {
|
|
18
|
-
// Keep one live queue identity across native extension reloads.
|
|
19
|
-
// oxlint-disable-next-line typescript/no-unsafe-return
|
|
20
|
-
return existing;
|
|
21
|
-
}
|
|
22
|
+
if (isStringSet(existing)) {
|
|
23
|
+
// Keep one live queue identity across native extension reloads.
|
|
24
|
+
return existing;
|
|
22
25
|
}
|
|
23
26
|
const queued = new Set<string>();
|
|
24
27
|
store.__pi_single_agent_admissions__ = queued;
|
|
@@ -15,11 +15,11 @@ if (manifest === undefined) {
|
|
|
15
15
|
throw new Error("Cannot locate the native Pi TUI package.");
|
|
16
16
|
}
|
|
17
17
|
const root = pathToFileURL(path.join(path.dirname(manifest), "dist/index.js"));
|
|
18
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
18
|
+
// oxlint-disable-next-line typescript/no-unsafe-assignment -- QX-004: resolve the selected host TUI, not a second package instance.
|
|
19
19
|
const tuiPromise: Promise<typeof Tui> = import(root.href);
|
|
20
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
20
|
+
// oxlint-disable-next-line typescript/no-unsafe-assignment -- QX-004: layout must share the selected host TUI instance.
|
|
21
21
|
const layoutPromise: Promise<typeof Layout> = import(new URL("./layout.js", root).href);
|
|
22
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
22
|
+
// oxlint-disable-next-line typescript/no-unsafe-assignment -- QX-004: dispatch must share the selected host TUI instance.
|
|
23
23
|
const dispatchPromise: Promise<typeof Dispatch> = import(new URL("./tui.js", root).href);
|
|
24
24
|
const [tui, layout, dispatch, sdk] = await Promise.all([
|
|
25
25
|
tuiPromise,
|
package/src/agent/parking.ts
CHANGED
package/src/agent/process.ts
CHANGED
|
@@ -63,9 +63,7 @@ export async function observeAlive(identities: readonly ClaimedProcess[]): Promi
|
|
|
63
63
|
// One `ps` at a time: each spawn briefly holds the event loop, so concurrent spawns add up.
|
|
64
64
|
const { pid, identity, clock } = claim;
|
|
65
65
|
verdicts.push(
|
|
66
|
-
probe(claim) ??
|
|
67
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
68
|
-
!(await observeChildProcessReplaced(pid, identity, claim.published, clock)),
|
|
66
|
+
probe(claim) ?? !(await observeChildProcessReplaced(pid, identity, claim.published, clock)),
|
|
69
67
|
);
|
|
70
68
|
}
|
|
71
69
|
return verdicts;
|
|
@@ -243,7 +241,6 @@ async function awaitStopping(
|
|
|
243
241
|
throw new Error("Previous actor is still stopping; no replacement was launched.");
|
|
244
242
|
}
|
|
245
243
|
// The old actor must actually exit before replacing its generation.
|
|
246
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
247
244
|
await delay(25);
|
|
248
245
|
}
|
|
249
246
|
}
|
package/src/agent/runner.ts
CHANGED
|
@@ -344,13 +344,11 @@ class Actor {
|
|
|
344
344
|
this.retry = false;
|
|
345
345
|
this.pending().forEach((command) => this.accept(command));
|
|
346
346
|
}
|
|
347
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
348
347
|
if (await this.park(runtime, parked)) {
|
|
349
348
|
return;
|
|
350
349
|
}
|
|
351
350
|
const polling = this.retry || this.stopping || watch === "unwatched";
|
|
352
351
|
const tick = Math.max(1, this.nextParentCheck - performance.now());
|
|
353
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
354
352
|
await this.wake.sleep(polling ? 100 : tick);
|
|
355
353
|
}
|
|
356
354
|
} finally {
|
package/src/agent/shutdown.ts
CHANGED
|
@@ -44,7 +44,6 @@ async function waitForExit(targets: readonly ProcessIdentity[], timeout: number)
|
|
|
44
44
|
const deadline = Date.now() + timeout;
|
|
45
45
|
while (Date.now() < deadline && targets.some(alive)) {
|
|
46
46
|
// Join actual process exit while native abort persists the interrupted turn.
|
|
47
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
48
47
|
await delay(25);
|
|
49
48
|
}
|
|
50
49
|
}
|
package/src/agent/typebox.ts
CHANGED
|
@@ -8,12 +8,12 @@ import { isChild, packageRoot } from "./host.ts";
|
|
|
8
8
|
// Pi aliases these imports to its own instance (including bundled virtual modules).
|
|
9
9
|
// Only the detached runner needs absolute imports from the selected host package.
|
|
10
10
|
const require = isChild() ? createRequire(path.join(packageRoot(), "dist/index.js")) : undefined;
|
|
11
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
11
|
+
// oxlint-disable-next-line typescript/no-unsafe-assignment -- QX-004: detached runners resolve the selected host schema instance.
|
|
12
12
|
const typeboxPromise: Promise<typeof NativeTypebox> =
|
|
13
13
|
require === undefined
|
|
14
14
|
? import("typebox")
|
|
15
15
|
: import(pathToFileURL(require.resolve("typebox")).href);
|
|
16
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
16
|
+
// oxlint-disable-next-line typescript/no-unsafe-assignment -- QX-004: validation must use the selected host schema instance.
|
|
17
17
|
const valuePromise: Promise<typeof NativeValue> =
|
|
18
18
|
require === undefined
|
|
19
19
|
? import("typebox/value")
|