@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/agent/host.js +1 -1
  3. package/dist/agent/notifications.js +10 -8
  4. package/dist/agent/observe-layout.js +3 -3
  5. package/dist/agent/parking.js +0 -1
  6. package/dist/agent/process.js +1 -4
  7. package/dist/agent/runner.js +0 -2
  8. package/dist/agent/shutdown.js +0 -1
  9. package/dist/agent/typebox.js +2 -2
  10. package/dist/build-info.js +1 -1
  11. package/dist/pi-intercom/bridge-presence.js +0 -1
  12. package/dist/pi-intercom/bridge-protocol.js +0 -2
  13. package/dist/pi-intercom/bridge-session.js +0 -1
  14. package/dist/pi-intercom/bridge-wake.js +0 -1
  15. package/dist/pi-intercom/broker/client.js +0 -2
  16. package/dist/pi-intercom/broker/spawn.js +0 -3
  17. package/dist/pi-intercom/inbound-delivery.js +2 -6
  18. package/dist/pi-intercom/reply-tracker.js +47 -55
  19. package/dist/pi-intercom/session-events.js +2 -0
  20. package/dist/pi-intercom/tool-registration.js +1 -2
  21. package/dist/pi-intercom/validation.js +0 -2
  22. package/dist/shared/cooperative.js +0 -1
  23. package/docs/code-quality.md +96 -68
  24. package/docs/intercom.md +6 -2
  25. package/package.json +9 -6
  26. package/scripts/build.mjs +11 -13
  27. package/scripts/check-compat.mjs +3 -2
  28. package/scripts/compat-host.mjs +5 -5
  29. package/scripts/intercom-bridge-credentials.mjs +4 -3
  30. package/scripts/intercom-remote.mjs +0 -1
  31. package/scripts/native-package-smoke-assertions.mjs +25 -23
  32. package/scripts/native-package-smoke.mjs +38 -54
  33. package/scripts/package-smoke.mjs +39 -30
  34. package/scripts/prepare.mjs +16 -10
  35. package/scripts/real-pi-events.mjs +92 -0
  36. package/scripts/real-pi-live.mjs +28 -108
  37. package/scripts/real-pi-processes.mjs +77 -82
  38. package/scripts/real-pi-smoke.mjs +20 -18
  39. package/src/agent/host.ts +1 -1
  40. package/src/agent/notifications.ts +11 -8
  41. package/src/agent/observe-layout.ts +3 -3
  42. package/src/agent/parking.ts +0 -1
  43. package/src/agent/process.ts +1 -4
  44. package/src/agent/runner.ts +0 -2
  45. package/src/agent/shutdown.ts +0 -1
  46. package/src/agent/typebox.ts +2 -2
  47. package/src/pi-intercom/bridge-presence.ts +0 -1
  48. package/src/pi-intercom/bridge-protocol.ts +0 -2
  49. package/src/pi-intercom/bridge-session.ts +0 -1
  50. package/src/pi-intercom/bridge-wake.ts +0 -1
  51. package/src/pi-intercom/broker/client.ts +0 -2
  52. package/src/pi-intercom/broker/spawn.ts +0 -3
  53. package/src/pi-intercom/inbound-delivery.ts +2 -6
  54. package/src/pi-intercom/reply-tracker.ts +50 -58
  55. package/src/pi-intercom/session-events.ts +3 -1
  56. package/src/pi-intercom/tool-registration.ts +1 -2
  57. package/src/pi-intercom/validation.ts +0 -2
  58. package/src/shared/cooperative.ts +0 -1
@@ -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
- * One JSON record decoded from the RPC main's stdout stream.
12
- * @typedef {Record<string, unknown> & { type?: string }} RpcEvent
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 {RpcEvent} */ let record;
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
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- the finally also runs when startup failed
233
- session?.close();
159
+ session.close();
234
160
  writeFileSync(
235
161
  join(options.root, `${label.replace(/ /g, "-")}.jsonl`),
236
- // oxlint-disable-next-line typescript/no-unnecessary-condition, typescript/strict-boolean-expressions -- the finally also runs when startup failed
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 {{ type?: string, message?: { role?: string, provider?: string, model?: string } }} */
254
- // oxlint-disable-next-line typescript/no-unsafe-assignment
255
- const entry = JSON.parse(line);
256
- if (entry.type === "message" && entry.message?.role === "assistant") {
257
- yield `${entry.message.provider ?? ""}/${entry.message.model ?? ""}`;
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 {{ id: string, status: string, error?: string }} */
311
- // oxlint-disable-next-line typescript/no-unsafe-assignment
312
- const result = JSON.parse(readFileSync(resultPath, "utf8"));
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 {{ pid: number }} */
324
- // oxlint-disable-next-line typescript/no-unsafe-assignment
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 ?? -1));
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?.status !== "completed" || notice.page?.text?.trim() !== expected) {
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 ?? ""}:${receipt ?? ""}:${context ?? ""}`,
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
- // oxlint-disable-next-line typescript/prefer-readonly -- JSDoc class fields cannot express readonly
73
- /** @type {Map<number, ProcessEntry>} */ #processes = new Map();
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
- return new Promise((resolve, reject) => {
107
- /** @type {Promise<void> | undefined} */ let discovery;
108
- /** @type {string | undefined} */ let discoveryError;
109
- const finish = async (
110
- /** @type {Error | undefined} */ error,
111
- /** @type {string} */ output = "",
112
- ) => {
113
- clearTimeout(timer);
114
- clearInterval(watcher);
115
- signal.removeEventListener("abort", cancel);
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
- resolve(output);
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
- const child = execFile(command, args, spawnOptions(cwd, env), (error, stdout, stderr) => {
133
- const output = `${stdout}${stderr}`;
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
- attachChildProcessLifecycle(child);
138
- if (child.pid !== undefined) {
139
- /** @type {ProcessEntry} */ const entry = { child };
140
- this.#processes.set(child.pid, entry);
141
- child.once("close", () => {
142
- entry.exited = !isChildTreeAlive(child);
143
- });
144
- }
145
- // Discover detached startup children even if their parent's startup fails.
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
- // A function input owns a persistent RPC stdin and closes it to request orderly shutdown.
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 {{ pid: number }} */
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
- // oxlint-disable-next-line typescript/no-unnecessary-boolean-literal-compare -- exited stays optional under tsc
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
- // oxlint-disable-next-line typescript/no-unnecessary-boolean-literal-compare -- exited stays optional under tsc
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 {string[]} */
127
- // oxlint-disable-next-line typescript/no-unsafe-assignment
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 {{ type?: string, id?: string, success?: boolean, data?: { commands?: { name?: string }[] } } | undefined}
150
+ * @returns {Record<string, unknown> | undefined}
150
151
  */
151
152
  function commandResponse(output) {
152
- /** @type {{ type?: string, id?: string, success?: boolean, data?: { commands?: { name?: string }[] } } | undefined} */
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 {{ type?: string, id?: string, success?: boolean, data?: { commands?: { name?: string }[] } }} */
157
- // oxlint-disable-next-line typescript/no-unsafe-assignment
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
- /** @type {{ success?: boolean, data?: { commands?: { name?: string }[] } } | undefined} */ response,
171
- ) {
172
- return response !== undefined &&
173
- response.success === true &&
174
- response.data !== undefined &&
175
- Array.isArray(response.data.commands)
176
- ? response.data.commands.map((command) => command.name)
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 instanceof Set) {
15
- const values: Set<unknown> = existing;
16
- const strings = [...values].filter((value): value is string => typeof value === "string");
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,
@@ -400,7 +400,6 @@ async function awaitWake(
400
400
  throw new Error(failure);
401
401
  }
402
402
  // DirectoryWatch reconciles dropped hints without imposing a deadline on native startup.
403
- // oxlint-disable-next-line no-await-in-loop
404
403
  await wake.sleep(200);
405
404
  }
406
405
  } finally {
@@ -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
  }
@@ -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 {
@@ -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
  }
@@ -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")