gentle-pi 3.2.1 → 3.3.0

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 (71) hide show
  1. package/docs/gentle-shell.md +27 -14
  2. package/docs/readme-reference.md +41 -7
  3. package/docs/review-integration.md +22 -17
  4. package/extensions/gentle-agents.ts +85 -17
  5. package/extensions/gentle-ai.ts +123 -21
  6. package/extensions/gentle-shell.ts +379 -31
  7. package/extensions/gentle-todo.ts +19 -1
  8. package/lib/agents-view.ts +41 -14
  9. package/lib/agents-widget.ts +84 -13
  10. package/lib/command-palette-catalog.ts +1 -0
  11. package/lib/double-esc-cancel-policy.ts +138 -0
  12. package/lib/inprocess-reviewer.ts +260 -0
  13. package/lib/native-review-cli.ts +14 -0
  14. package/lib/odd-runtime-delegation-gate.ts +88 -0
  15. package/lib/review-host-relay.ts +246 -171
  16. package/lib/review-integration-v2.ts +110 -26
  17. package/lib/shell-bar.ts +150 -75
  18. package/lib/shell-card.ts +19 -9
  19. package/lib/shell-changes-view.ts +43 -5
  20. package/lib/shell-changes.ts +92 -5
  21. package/lib/shell-hover.ts +39 -0
  22. package/lib/shell-prompt.ts +10 -1
  23. package/lib/shell-sidebar-layout.ts +111 -15
  24. package/lib/shell-sidebar.ts +16 -0
  25. package/lib/shell-todo.ts +7 -1
  26. package/lib/shell-usage-view.ts +98 -10
  27. package/package.json +1 -1
  28. package/runtime/native-review-cli.mjs +14 -0
  29. package/runtime/review-integration-v2.mjs +110 -26
  30. package/scripts/gentle-ai-installer.mjs +10 -10
  31. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  32. package/scripts/verify-package-files.mjs +3 -3
  33. package/tests/agents-grouping.test.ts +75 -18
  34. package/tests/agents-view.test.ts +28 -18
  35. package/tests/agents-widget.test.ts +100 -12
  36. package/tests/command-palette.test.ts +1 -0
  37. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  38. package/tests/double-esc-cancel-policy.test.ts +194 -0
  39. package/tests/gentle-agents.test.ts +528 -5
  40. package/tests/gentle-ai-binary.test.ts +1 -1
  41. package/tests/gentle-ai-installer.test.ts +47 -47
  42. package/tests/gentle-ai.test.ts +69 -5
  43. package/tests/gentle-shell.test.ts +795 -23
  44. package/tests/gentle-todo.test.ts +17 -4
  45. package/tests/inprocess-reviewer.test.ts +368 -0
  46. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  47. package/tests/native-review-capability-contract.test.ts +19 -1
  48. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  49. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  50. package/tests/package-manifest.test.ts +6 -6
  51. package/tests/review-host-relay-routing.test.ts +77 -0
  52. package/tests/review-host-relay.test.ts +277 -300
  53. package/tests/review-integration-v2-forward.test.ts +61 -0
  54. package/tests/review-integration-v2.test.ts +116 -1
  55. package/tests/review-relay-transport-agent.test.ts +22 -24
  56. package/tests/runtime-harness.mjs +11 -0
  57. package/tests/session-changes-shell.test.ts +27 -0
  58. package/tests/session-worktree-registry.test.ts +41 -0
  59. package/tests/shell-bar.test.ts +192 -124
  60. package/tests/shell-card.test.ts +5 -3
  61. package/tests/shell-changes-view.test.ts +47 -0
  62. package/tests/shell-changes.test.ts +177 -0
  63. package/tests/shell-hover.test.ts +19 -0
  64. package/tests/shell-prompt.test.ts +20 -0
  65. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  66. package/tests/shell-sidebar-layout.test.ts +243 -5
  67. package/tests/shell-sidebar.test.ts +25 -1
  68. package/tests/shell-todo.test.ts +36 -0
  69. package/tests/shell-usage-view.test.ts +120 -1
  70. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  71. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -1,410 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import test from "node:test";
6
- import { fileURLToPath } from "node:url";
7
- import {
8
- OPAQUE_PI_REVIEWER_ARGV,
9
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
10
- OpaquePiReviewerTransportError,
11
- extractPiAssistantText,
12
- resolvePiLaunch,
13
- runOpaquePiReviewer,
14
- } from "../lib/opaque-pi-reviewer-adapter.ts";
15
-
16
- const FAKE_PI = `#!/usr/bin/env node
17
- const fs = require("node:fs");
18
- const path = require("node:path");
19
- const argv = process.argv.slice(2);
20
- const chunks = [];
21
- process.stdin.on("data", (chunk) => chunks.push(chunk));
22
- process.stdin.on("end", () => {
23
- const stdin = Buffer.concat(chunks);
24
- if (process.env.OPAQUE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.OPAQUE_PI_STDIN_CAPTURE, stdin);
25
- const mode = process.env.OPAQUE_PI_MODE || "ok";
26
- const log = {
27
- argv,
28
- cwd: process.cwd(),
29
- entries_before: fs.readdirSync(process.cwd()),
30
- entries_after: fs.readdirSync(process.cwd()),
31
- };
32
- if (process.env.OPAQUE_PI_LOG) fs.appendFileSync(process.env.OPAQUE_PI_LOG, JSON.stringify(log) + "\\n");
33
- if (mode === "break-cleanup" || process.env.OPAQUE_PI_BREAK_CLEANUP === "true") {
34
- fs.chmodSync(path.dirname(process.cwd()), 0o500);
35
- }
36
- if (mode === "empty") process.exit(0);
37
- if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
38
- if (mode === "nonzero") { process.stderr.write("opaque pi failed\\n"); process.exit(7); }
39
- process.stdout.write(Buffer.from(process.env.OPAQUE_PI_OUTPUT_B64 || "", "base64"));
40
- });
41
- `;
42
-
43
- const PROMPT_BYTES = Buffer.concat([
44
- Buffer.from("opaque prompt\r\n\u0000", "utf8"),
45
- Buffer.from([0x01, 0xff, 0xfe, 0x00]),
46
- ]);
47
- const DEFAULT_OUTPUT_TEXT = "opaque output\r\n\u03b5 \u2014 unicode stays intact\n";
48
-
49
- const OUTPUT_BYTES = piEventStream(
50
- ...userTurn("opaque prompt"),
51
- ...assistantTurn([{ type: "text", text: DEFAULT_OUTPUT_TEXT }]),
52
- );
53
-
54
- interface OpaqueHarness {
55
- directory: string;
56
- pi: string;
57
- logPath: string;
58
- stdinCapturePath: string;
59
- environment: NodeJS.ProcessEnv;
60
- }
61
-
62
- interface OpaquePiLog {
63
- argv: string[];
64
- cwd: string;
65
- entries_before: string[];
66
- entries_after: string[];
67
- }
68
-
69
- function harness(t: test.TestContext, overrides: Record<string, string> = {}): OpaqueHarness {
70
- const directory = mkdtempSync(join(tmpdir(), "gentle-pi-opaque-reviewer-"));
71
- t.after(() => rmSync(directory, { recursive: true, force: true }));
72
- const pi = join(directory, "pi");
73
- writeFileSync(pi, FAKE_PI);
74
- chmodSync(pi, 0o755);
75
- const logPath = join(directory, "pi.log");
76
- const stdinCapturePath = join(directory, "stdin.bin");
77
- return {
78
- directory,
79
- pi,
80
- logPath,
81
- stdinCapturePath,
82
- environment: {
83
- ...process.env,
84
- OPAQUE_PI_LOG: logPath,
85
- OPAQUE_PI_STDIN_CAPTURE: stdinCapturePath,
86
- OPAQUE_PI_OUTPUT_B64: OUTPUT_BYTES.toString("base64"),
87
- ...overrides,
88
- },
89
- };
90
- }
91
-
92
- function readLog(path: string): OpaquePiLog[] {
93
- if (!existsSync(path)) return [];
94
- return readFileSync(path, "utf8")
95
- .split("\n")
96
- .filter((line) => line.length > 0)
97
- .map((line) => JSON.parse(line) as OpaquePiLog);
98
- }
99
-
100
- // The reviewer child runs `pi --mode json`, so its stdout is a newline-
101
- // delimited pi event stream. These helpers build the minimal stream shapes the
102
- // extraction has to understand, including the shapes behind the empty-output
103
- // field reports (#1140: a reviewer that answers with a tool call writes zero
104
- // usable bytes in text mode and exits 0 in silence).
105
- function piEventStream(...events: readonly unknown[]): Buffer {
106
- return Buffer.from(events.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf8");
107
- }
108
-
109
- function userTurn(text: string): unknown[] {
110
- const message = { role: "user", content: [{ type: "text", text }] };
111
- return [
112
- { type: "message_start", message },
113
- { type: "message_end", message },
114
- ];
115
- }
116
-
117
- function assistantTurn(parts: readonly Record<string, unknown>[], reviewerModel?: string): unknown[] {
118
- const content = parts.map((part) => ({ ...part }));
119
- const message: Record<string, unknown> = { role: "assistant", content };
120
- if (reviewerModel !== undefined) message.model = reviewerModel;
121
- return [
122
- { type: "message_start", message },
123
- { type: "message_end", message },
124
- { type: "agent_end", messages: [message] },
125
- ];
126
- }
127
-
128
- async function rejectsWithTransportError(
129
- promise: Promise<unknown>,
130
- kind: (typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE)[keyof typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE],
131
- ): Promise<OpaquePiReviewerTransportError> {
132
- let caught: OpaquePiReviewerTransportError | undefined;
133
- await assert.rejects(promise, (error: unknown) => {
134
- assert.ok(error instanceof OpaquePiReviewerTransportError, `expected OpaquePiReviewerTransportError, received ${String(error)}`);
135
- caught = error;
136
- return error.kind === kind;
137
- });
138
- return caught!;
139
- }
140
-
141
- // #1140/#1156: the child runs `pi --mode json` and the transport emits the
142
- // final assistant text of that event stream. The prompt side stays byte-
143
- // verbatim; the output side is pi's own envelope, so a silent or unintelligible
144
- // run becomes a typed, evidenced failure instead of zero bytes and exit 0.
145
- const OUTPUT_TEXT = "opaque output\r\n\u03b5 \u2014 unicode stays intact\nsecond assistant text part";
146
-
147
- function eventStreamFor(text: string): Buffer {
148
- return piEventStream(
149
- ...userTurn("opaque prompt"),
150
- ...assistantTurn([{ type: "text", text }], "pi-test-model"),
151
- { type: "agent_settled" },
152
- );
153
- }
154
-
155
- test("the opaque adapter streams the prompt verbatim and emits the pi event stream's final assistant text", async (t) => {
156
- const fixture = harness(t, { OPAQUE_PI_OUTPUT_B64: eventStreamFor(OUTPUT_TEXT).toString("base64") });
157
- const result = await runOpaquePiReviewer(PROMPT_BYTES, {
158
- piExecutable: fixture.pi,
159
- environment: fixture.environment,
160
- timeoutMs: 10_000,
161
- });
162
-
163
- assert.equal(result.promptByteLength, PROMPT_BYTES.length);
164
- assert.equal(result.stdoutByteLength, Buffer.byteLength(OUTPUT_TEXT));
165
- assert.deepEqual(result.stdout, Buffer.from(OUTPUT_TEXT, "utf8"));
166
- assert.deepEqual(readFileSync(fixture.stdinCapturePath), PROMPT_BYTES);
167
-
168
- const calls = readLog(fixture.logPath);
169
- assert.equal(calls.length, 1);
170
- assert.deepEqual(calls[0]!.argv, [...OPAQUE_PI_REVIEWER_ARGV]);
171
- assert.deepEqual(calls[0]!.entries_before, []);
172
- assert.deepEqual(calls[0]!.entries_after, []);
173
- assert.notEqual(calls[0]!.cwd, process.cwd());
174
- assert.equal(existsSync(calls[0]!.cwd), false, "the empty scratch directory is removed after success");
175
- });
176
-
177
- test("the extraction mirrors the pi event stream: every assistant text part in order, nothing else", () => {
178
- const streamed = piEventStream(
179
- ...userTurn("prompt"),
180
- ...assistantTurn([{ type: "thinking", thinking: "hidden" }, { type: "text", text: "first " }], "pi-a"),
181
- ...assistantTurn([{ type: "text", text: "second" }], "pi-b"),
182
- );
183
- const extracted = extractPiAssistantText(streamed);
184
- assert.equal(extracted.kind, "text");
185
- if (extracted.kind === "text") assert.equal(extracted.text, "first second");
186
-
187
- const single = extractPiAssistantText(eventStreamFor("{\"findings\": []}"));
188
- assert.equal(single.kind, "text");
189
- if (single.kind === "text") assert.equal(single.text, "{\"findings\": []}");
190
-
191
- // A stream pi could not have produced is not silently passed through.
192
- assert.equal(extractPiAssistantText(Buffer.from("garbage, not an event stream", "utf8")).kind, "none");
193
- assert.equal(extractPiAssistantText(Buffer.alloc(0)).kind, "none");
194
- });
195
-
196
- test("a silent or unintelligible reviewer run fails typed with the evidence the event stream carries", async (t) => {
197
- const fixture = harness(t);
198
-
199
- const silent = await rejectsWithTransportError(
200
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "empty" }, timeoutMs: 10_000 }),
201
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
202
- );
203
- assert.equal(silent.evidence?.stdoutKind, "no-output");
204
-
205
- const unintelligible = await rejectsWithTransportError(
206
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_OUTPUT_B64: Buffer.from("not json at all", "utf8").toString("base64") }, timeoutMs: 10_000 }),
207
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
208
- );
209
- assert.equal(unintelligible.evidence?.stdoutKind, "not-a-pi-event-stream");
210
- assert.match(unintelligible.message, /no assistant text|not a pi event stream|no output/i);
211
-
212
- const textless = await rejectsWithTransportError(
213
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_OUTPUT_B64: piEventStream(...userTurn("prompt"), { type: "agent_settled" }).toString("base64") }, timeoutMs: 10_000 }),
214
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
215
- );
216
- assert.equal(textless.evidence?.stdoutKind, "no-assistant-text");
217
- });
218
-
219
- test("a reviewer run that spent its turn on a tool call reports that in the evidence instead of vanishing", async (t) => {
220
- const fixture = harness(t, {
221
- OPAQUE_PI_OUTPUT_B64: piEventStream(
222
- ...userTurn("prompt"),
223
- ...assistantTurn([{ type: "toolCall", id: "call_1", name: "bash" }], "nan/deepseek-v4-flash"),
224
- ).toString("base64"),
225
- });
226
- const error = await rejectsWithTransportError(
227
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000 }),
228
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
229
- );
230
- assert.equal(error.evidence?.stdoutKind, "no-assistant-text");
231
- assert.equal(error.evidence?.toolCallAttempted, true);
232
- assert.equal(error.evidence?.reviewerModel, "nan/deepseek-v4-flash");
233
- assert.match(error.message, /tool call/i);
234
- assert.match(error.message, /nan\/deepseek-v4-flash/);
235
- });
236
-
237
- test("a tool-call attempt followed by text still yields the text", async (t) => {
238
- const fixture = harness(t, {
239
- OPAQUE_PI_OUTPUT_B64: piEventStream(
240
- ...userTurn("prompt"),
241
- ...assistantTurn([{ type: "toolCall", id: "call_1", name: "bash" }]),
242
- ...assistantTurn([{ type: "text", text: "{\"verdict\": \"pass\"}" }], "nan/glm5.3-flash"),
243
- ).toString("base64"),
244
- });
245
- const result = await runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000 });
246
- assert.deepEqual(result.stdout, Buffer.from("{\"verdict\": \"pass\"}", "utf8"));
247
- });
248
-
249
- test("caller-owned launch arguments ride the frozen argv verbatim and are validated before spawn", async (t) => {
250
- const fixture = harness(t, { OPAQUE_PI_OUTPUT_B64: eventStreamFor("ok").toString("base64") });
251
- await runOpaquePiReviewer(PROMPT_BYTES, {
252
- piExecutable: fixture.pi,
253
- environment: fixture.environment,
254
- timeoutMs: 10_000,
255
- extraArguments: ["--model", "nan/glm5.3-flash", "-e", "/abs/auth-adapter.ts"],
256
- });
257
- const calls = readLog(fixture.logPath);
258
- assert.equal(calls.length, 1);
259
- assert.deepEqual(calls[0]!.argv, [...OPAQUE_PI_REVIEWER_ARGV, "--model", "nan/glm5.3-flash", "-e", "/abs/auth-adapter.ts"]);
260
-
261
- await assert.rejects(
262
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000, extraArguments: ["--model", ""] }),
263
- /non-empty/,
264
- );
265
- await assert.rejects(
266
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000, extraArguments: [42 as unknown as string] }),
267
- /non-empty/,
268
- );
269
- });
270
-
271
- test("the opaque adapter returns typed transport errors for launch, nonzero, empty, timeout, and cancellation", async (t) => {
272
- const fixture = harness(t);
273
- await rejectsWithTransportError(
274
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: join(fixture.directory, "missing-pi"), environment: fixture.environment, timeoutMs: 10_000 }),
275
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED,
276
- );
277
-
278
- const nonzero = await rejectsWithTransportError(
279
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "nonzero" }, timeoutMs: 10_000 }),
280
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.NONZERO_EXIT,
281
- );
282
- assert.equal(nonzero.exitCode, 7);
283
- assert.deepEqual(nonzero.stderr, Buffer.from("opaque pi failed\n"));
284
-
285
- await rejectsWithTransportError(
286
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "empty" }, timeoutMs: 10_000 }),
287
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
288
- );
289
-
290
- const timedOut = await rejectsWithTransportError(
291
- runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "hang" }, timeoutMs: 300 }),
292
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT,
293
- );
294
- assert.equal(timedOut.timedOut, true);
295
-
296
- const controller = new AbortController();
297
- const cancellation = runOpaquePiReviewer(PROMPT_BYTES, {
298
- piExecutable: fixture.pi,
299
- environment: { ...fixture.environment, OPAQUE_PI_MODE: "hang" },
300
- timeoutMs: 10_000,
301
- signal: controller.signal,
302
- });
303
- setTimeout(() => controller.abort(), 100).unref();
304
- const cancelled = await rejectsWithTransportError(cancellation, OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED);
305
- assert.equal(cancelled.cancelled, true);
306
- });
307
-
308
- test("the opaque adapter leaves no prompt or result file in scratch and reports cleanup failure as transport-only", async (t) => {
309
- const fixture = harness(t);
310
- const scratchParent = mkdtempSync(join(tmpdir(), "gentle-pi-opaque-reviewer-cleanup-"));
311
- const originalTmpdir = process.env.TMPDIR;
312
- process.env.TMPDIR = scratchParent;
313
- try {
314
- const error = await rejectsWithTransportError(
315
- runOpaquePiReviewer(PROMPT_BYTES, {
316
- piExecutable: fixture.pi,
317
- environment: { ...fixture.environment, OPAQUE_PI_MODE: "break-cleanup" },
318
- timeoutMs: 10_000,
319
- }),
320
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CLEANUP_FAILED,
321
- );
322
- assert.match(error.message, /scratch directory/i);
323
- } finally {
324
- if (originalTmpdir === undefined) delete process.env.TMPDIR;
325
- else process.env.TMPDIR = originalTmpdir;
326
- chmodSync(scratchParent, 0o700);
327
- rmSync(scratchParent, { recursive: true, force: true });
328
- }
329
- });
330
-
331
- test("the opaque adapter preserves primary nonzero and timeout errors when scratch cleanup also fails", async (t) => {
332
- const fixture = harness(t);
333
- const scratchParent = mkdtempSync(join(tmpdir(), "gentle-pi-opaque-reviewer-primary-failure-"));
334
- const originalTmpdir = process.env.TMPDIR;
335
- process.env.TMPDIR = scratchParent;
336
- try {
337
- const nonzero = await rejectsWithTransportError(
338
- runOpaquePiReviewer(PROMPT_BYTES, {
339
- piExecutable: fixture.pi,
340
- environment: { ...fixture.environment, OPAQUE_PI_MODE: "nonzero", OPAQUE_PI_BREAK_CLEANUP: "true" },
341
- timeoutMs: 10_000,
342
- }),
343
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.NONZERO_EXIT,
344
- );
345
- assert.equal(nonzero.exitCode, 7);
346
- assert.deepEqual(nonzero.stderr, Buffer.from("opaque pi failed\n"));
347
- chmodSync(scratchParent, 0o700);
348
-
349
- const timedOut = await rejectsWithTransportError(
350
- runOpaquePiReviewer(PROMPT_BYTES, {
351
- piExecutable: fixture.pi,
352
- environment: { ...fixture.environment, OPAQUE_PI_MODE: "hang", OPAQUE_PI_BREAK_CLEANUP: "true" },
353
- timeoutMs: 300,
354
- }),
355
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT,
356
- );
357
- assert.equal(timedOut.timedOut, true);
358
- } finally {
359
- if (originalTmpdir === undefined) delete process.env.TMPDIR;
360
- else process.env.TMPDIR = originalTmpdir;
361
- chmodSync(scratchParent, 0o700);
362
- rmSync(scratchParent, { recursive: true, force: true });
363
- }
364
- });
365
-
366
- test("the opaque adapter has no review lifecycle imports or identifiers", () => {
367
- const adapterPath = fileURLToPath(new URL("../lib/opaque-pi-reviewer-adapter.ts", import.meta.url));
368
- const source = readFileSync(adapterPath, "utf8");
369
- assert.doesNotMatch(source, /review-integration|gentle-ai|materialize|submit/i);
370
- // The guard reads source, not strings: the one lifecycle-shaped word the
371
- // transport may spell is pi's own quoted wire key for the selection the
372
- // child reports in its events (#1140). It is data there, never an
373
- // identifier, so it is stripped before the scan.
374
- const withoutWireKeys = source.replaceAll('"model"', '""');
375
- for (const identifier of ["lineage", "target", "revision", "receipt", "lens", "order", "subject", "schema", "capture", "submission", "status", "model", "provider", "profile"]) {
376
- assert.doesNotMatch(withoutWireKeys, new RegExp(`\\b${identifier}\\b`, "i"), `adapter must not contain lifecycle identifier ${identifier}`);
377
- }
378
- });
379
-
380
- // #468 / #519: on Windows a bare `pi` resolves to pi.cmd, pi.ps1, or a POSIX
381
- // shim, none of which Node can spawn with shell:false (EINVAL or ENOENT). The
382
- // relay already runs inside Pi, so the host's own JavaScript entry is spawned
383
- // through process.execPath instead. Other platforms keep today's exact shape.
384
- test("the Pi launch shape spawns the host entry through process.execPath on win32 and stays byte-identical elsewhere", () => {
385
- const host = {
386
- execPath: "C:\\Program Files\\nodejs\\node.exe",
387
- entry: "C:\\Users\\dev\\AppData\\Local\\pnpm\\global\\5\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js",
388
- };
389
- const windows = resolvePiLaunch(undefined, "win32", host);
390
- assert.deepEqual(windows, { file: host.execPath, arguments: [host.entry, ...OPAQUE_PI_REVIEWER_ARGV] });
391
- assert.notEqual(windows.file, "pi");
392
- assert.equal(windows.arguments.includes("pi"), false);
393
- for (const platform of ["linux", "darwin", "freebsd"] as const) {
394
- assert.deepEqual(resolvePiLaunch(undefined, platform, host), { file: "pi", arguments: [...OPAQUE_PI_REVIEWER_ARGV] }, platform);
395
- assert.deepEqual(resolvePiLaunch("/opt/pi/bin/pi", platform, host), { file: "/opt/pi/bin/pi", arguments: [...OPAQUE_PI_REVIEWER_ARGV] }, platform);
396
- }
397
- assert.deepEqual(resolvePiLaunch("C:\\tools\\pi.exe", "win32", host), { file: "C:\\tools\\pi.exe", arguments: [...OPAQUE_PI_REVIEWER_ARGV] });
398
- for (const entry of [undefined, "", "cli.js", "dist/bundle/cli.js"]) {
399
- assert.throws(() => resolvePiLaunch(undefined, "win32", { execPath: host.execPath, entry }), /host entry/, `entry ${JSON.stringify(entry)} must fail closed instead of spawning a bare pi`);
400
- }
401
- });
402
-
403
- test("the default Pi launch resolves from the running host process", () => {
404
- const launch = resolvePiLaunch(undefined);
405
- if (process.platform === "win32") {
406
- assert.deepEqual(launch, { file: process.execPath, arguments: [process.argv[1]!, ...OPAQUE_PI_REVIEWER_ARGV] });
407
- } else {
408
- assert.deepEqual(launch, { file: "pi", arguments: [...OPAQUE_PI_REVIEWER_ARGV] });
409
- }
410
- });