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.
- package/docs/gentle-shell.md +27 -14
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +22 -17
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +123 -21
- package/extensions/gentle-shell.ts +379 -31
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/native-review-cli.ts +14 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +246 -171
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +150 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +14 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +795 -23
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +19 -1
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +277 -300
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +22 -24
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +192 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
package/tests/shell-todo.test.ts
CHANGED
|
@@ -231,6 +231,42 @@ test("completed titles strike only title cells across wrapped lines, never paddi
|
|
|
231
231
|
}
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
test("renderTodoCard paints the sidebar rail and the bottom widget with the same rose INFO frame", () => {
|
|
235
|
+
const taggedTheme = {
|
|
236
|
+
fg(color: string, text: string) {
|
|
237
|
+
return `<${color}>${text}</${color}>`;
|
|
238
|
+
},
|
|
239
|
+
strikethrough(text: string) {
|
|
240
|
+
return text;
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
const sidebar = renderTodoCard(seeded(), taggedTheme, 60, { scrollable: true, collapsed: false, staleTurns: 0 });
|
|
244
|
+
assert.match(sidebar[0], /<border>╭<\/border>/);
|
|
245
|
+
assert.match(sidebar[0], /<accent>❀ Todos/);
|
|
246
|
+
|
|
247
|
+
const bottom = renderTodoCard(seeded(), taggedTheme, 60, { collapsed: false, staleTurns: 0 });
|
|
248
|
+
assert.match(bottom[0], /<border>╭<\/border>/);
|
|
249
|
+
assert.match(bottom[0], /<accent>❀ Todos/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// H1 (odd/tasks/usage-click-and-changes-attribution.md): the same shared
|
|
253
|
+
// hover role every other clickable surface uses.
|
|
254
|
+
test("renderTodoCard paints its clickable header control in the shared hover role when hovered", () => {
|
|
255
|
+
const taggedTheme = {
|
|
256
|
+
fg(color: string, text: string) {
|
|
257
|
+
return `<${color}>${text}</${color}>`;
|
|
258
|
+
},
|
|
259
|
+
strikethrough(text: string) {
|
|
260
|
+
return text;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
const idle = renderTodoCard(seeded(), taggedTheme, 70, { collapsed: false, staleTurns: 0 });
|
|
264
|
+
assert.match(idle[0], /<accent>▾ Collapse<\/accent>/, "idle keeps its ordinary accent role");
|
|
265
|
+
const hovered = renderTodoCard(seeded(), taggedTheme, 70, { collapsed: false, staleTurns: 0, hovered: true });
|
|
266
|
+
assert.match(hovered[0], /<warning>▾ Collapse<\/warning>/, "hovering paints the shared hover role");
|
|
267
|
+
assert.doesNotMatch(hovered[0], /<accent>▾ Collapse/);
|
|
268
|
+
});
|
|
269
|
+
|
|
234
270
|
test("renderTodoCard keeps stale indicators and collapse hints in scrollable lists", () => {
|
|
235
271
|
const tasks = Array.from({ length: 40 }, (_, index) => ({ title: `Task ${index + 1}`, status: index < 25 ? "done" : "pending" }));
|
|
236
272
|
const state = applyTodo(emptyTodo(), { action: "write", tasks }, 1).state;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
4
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
5
5
|
import { parseCodexUsage, UsageStore } from "../lib/shell-usage.ts";
|
|
6
6
|
import { UsageView } from "../lib/shell-usage-view.ts";
|
|
@@ -61,3 +61,122 @@ test("UsageView refetches on r and closes on escape or q", async () => {
|
|
|
61
61
|
view.handleInput("q");
|
|
62
62
|
assert.equal(events.filter((event) => event === "close").length, 2);
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
function click(x: number, y: number, width: number, height: number): TuiMouseEvent {
|
|
66
|
+
return { type: "click", button: "left", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("UsageView.handleMouse clicks the footer hints like the matching key", async () => {
|
|
70
|
+
const store = new UsageStore();
|
|
71
|
+
const events: string[] = [];
|
|
72
|
+
let resolveRefresh: (() => void) | undefined;
|
|
73
|
+
const view = new UsageView(store, {
|
|
74
|
+
theme: plainTheme,
|
|
75
|
+
now: () => NOW,
|
|
76
|
+
active: () => undefined,
|
|
77
|
+
onRefresh: () =>
|
|
78
|
+
new Promise<void>((resolve) => {
|
|
79
|
+
events.push("refresh");
|
|
80
|
+
resolveRefresh = resolve;
|
|
81
|
+
}),
|
|
82
|
+
onClose: () => events.push("close"),
|
|
83
|
+
requestRender: () => events.push("render"),
|
|
84
|
+
});
|
|
85
|
+
const width = 90;
|
|
86
|
+
const lines = view.render(width);
|
|
87
|
+
const rowIndex = lines.findIndex((line) => stripAnsi(line).includes("r refresh"));
|
|
88
|
+
assert.ok(rowIndex > 0, "the footer hints row must be present");
|
|
89
|
+
const plain = stripAnsi(lines[rowIndex]);
|
|
90
|
+
const refreshStart = plain.indexOf("r refresh");
|
|
91
|
+
const closeStart = plain.indexOf("esc close");
|
|
92
|
+
assert.ok(refreshStart >= 0 && closeStart >= 0);
|
|
93
|
+
|
|
94
|
+
// A click outside any hint span is ignored, like a click on an empty part of the frame.
|
|
95
|
+
assert.equal(view.handleMouse(click(0, rowIndex, width, lines.length)), undefined);
|
|
96
|
+
assert.deepEqual(events, []);
|
|
97
|
+
|
|
98
|
+
// A click on "r refresh" triggers the same refresh as the r key.
|
|
99
|
+
assert.deepEqual(view.handleMouse(click(refreshStart + 1, rowIndex, width, lines.length)), { handled: true, render: true });
|
|
100
|
+
assert.deepEqual(events, ["render", "refresh"]);
|
|
101
|
+
|
|
102
|
+
// A second click while the refresh is still in flight must not re-enter it.
|
|
103
|
+
assert.deepEqual(view.handleMouse(click(refreshStart + 1, rowIndex, width, lines.length)), { handled: true, render: true });
|
|
104
|
+
assert.deepEqual(events, ["render", "refresh"]);
|
|
105
|
+
resolveRefresh?.();
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
107
|
+
assert.deepEqual(events, ["render", "refresh", "render"]);
|
|
108
|
+
|
|
109
|
+
// A click on "esc close" closes the overlay, like the esc/q keys.
|
|
110
|
+
assert.deepEqual(view.handleMouse(click(closeStart + 1, rowIndex, width, lines.length)), { handled: true, render: true });
|
|
111
|
+
assert.equal(events.filter((event) => event === "close").length, 1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function move(x: number, y: number, width: number, height: number): TuiMouseEvent {
|
|
115
|
+
return { type: "move", button: "none", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// H1 (odd/tasks/usage-click-and-changes-attribution.md): the same shared
|
|
119
|
+
// hover role every other clickable surface uses.
|
|
120
|
+
test("UsageView.handleMouse paints the shared hover role over a footer hint, and clears it on leave", () => {
|
|
121
|
+
const store = new UsageStore();
|
|
122
|
+
const taggedTheme = { fg: (role: string, text: string) => `<${role}>${text}</${role}>` };
|
|
123
|
+
const view = new UsageView(store, {
|
|
124
|
+
theme: taggedTheme,
|
|
125
|
+
now: () => NOW,
|
|
126
|
+
active: () => undefined,
|
|
127
|
+
onRefresh: async () => {},
|
|
128
|
+
onClose: () => {},
|
|
129
|
+
requestRender: () => {},
|
|
130
|
+
});
|
|
131
|
+
const width = 90;
|
|
132
|
+
const lines = view.render(width);
|
|
133
|
+
// The frame draws "│ " (2 columns) before the fitted footer content, and
|
|
134
|
+
// hints are laid out as plain "key label" text joined by 3 spaces -- these
|
|
135
|
+
// column offsets are computed purely from that plain text, independent of
|
|
136
|
+
// the theme, so they hold under taggedTheme exactly as under plainTheme.
|
|
137
|
+
const rowIndex = lines.length - 2;
|
|
138
|
+
const refreshStart = 2;
|
|
139
|
+
const closeStart = refreshStart + "r refresh".length + 3;
|
|
140
|
+
const height = lines.length;
|
|
141
|
+
|
|
142
|
+
assert.equal(view.handleMouse(move(0, rowIndex, width, height)), undefined, "a move outside any hint is not this component's gesture");
|
|
143
|
+
assert.doesNotMatch(view.render(width).join("\n"), /<warning>/, "idle: nothing painted yet");
|
|
144
|
+
|
|
145
|
+
const entered = view.handleMouse(move(refreshStart + 1, rowIndex, width, height));
|
|
146
|
+
assert.deepEqual(entered, { handled: true, render: true });
|
|
147
|
+
assert.match(view.render(width).join("\n"), /<warning>r refresh<\/warning>/, "hovering the refresh hint paints it, key and label together");
|
|
148
|
+
assert.doesNotMatch(view.render(width).join("\n"), new RegExp(`<warning>esc close`), "the other hint stays unpainted");
|
|
149
|
+
|
|
150
|
+
// Moving straight to the other hint switches which one is painted.
|
|
151
|
+
const switched = view.handleMouse(move(closeStart + 1, rowIndex, width, height));
|
|
152
|
+
assert.deepEqual(switched, { handled: true, render: true });
|
|
153
|
+
assert.match(view.render(width).join("\n"), /<warning>esc close<\/warning>/);
|
|
154
|
+
assert.doesNotMatch(view.render(width).join("\n"), /<warning>r refresh/);
|
|
155
|
+
|
|
156
|
+
const left = view.handleMouse(move(0, rowIndex, width, height));
|
|
157
|
+
assert.deepEqual(left, { handled: true, render: true }, "leaving the last hovered hint still requests a repaint");
|
|
158
|
+
assert.doesNotMatch(view.render(width).join("\n"), /<warning>/, "nothing stays painted once the pointer leaves");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a rejected refresh from a click or key never escapes as an unhandled rejection", async () => {
|
|
162
|
+
const store = new UsageStore();
|
|
163
|
+
const events: string[] = [];
|
|
164
|
+
const unhandled: unknown[] = [];
|
|
165
|
+
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
|
166
|
+
process.on("unhandledRejection", onUnhandled);
|
|
167
|
+
try {
|
|
168
|
+
const view = new UsageView(store, { theme: plainTheme, now: () => NOW, active: () => undefined, onRefresh: async () => { throw new Error("provider down"); }, onClose: () => events.push("close"), requestRender: () => events.push("render") });
|
|
169
|
+
const lines = view.render(80);
|
|
170
|
+
const row = lines.length - 2;
|
|
171
|
+
const column = lines[row]!.indexOf("r refresh") + 1;
|
|
172
|
+
view.handleMouse({ type: "click", button: "left", x: column, y: row, width: 80, height: lines.length, screenX: column, screenY: row, shift: false, alt: false, ctrl: false } as never);
|
|
173
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
174
|
+
view.handleInput("r");
|
|
175
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
176
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
177
|
+
assert.deepEqual(unhandled, [], "a failing usage fetch is reported by the panel, never thrown at the process");
|
|
178
|
+
assert.match(view.render(80).join("\n"), /r refresh/, "the refresh hint is back after the failure");
|
|
179
|
+
} finally {
|
|
180
|
+
process.off("unhandledRejection", onUnhandled);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { chmod, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join, posix, win32 } from "node:path";
|
|
5
|
-
|
|
6
|
-
export const OPAQUE_PI_REVIEWER_ARGV = Object.freeze([
|
|
7
|
-
"--print",
|
|
8
|
-
// #1140: text mode turns a run that spent its turn on a tool call into zero
|
|
9
|
-
// bytes and exit 0. JSON mode emits pi's own event stream, so the transport
|
|
10
|
-
// can always tell a silent child from an assistant answer, and can recover
|
|
11
|
-
// the answer text even when tool calls interleaved with it.
|
|
12
|
-
"--mode", "json",
|
|
13
|
-
"--no-session",
|
|
14
|
-
"--no-tools",
|
|
15
|
-
"--no-extensions",
|
|
16
|
-
"--no-skills",
|
|
17
|
-
"--no-prompt-templates",
|
|
18
|
-
"--no-themes",
|
|
19
|
-
"--no-context-files",
|
|
20
|
-
"--no-approve",
|
|
21
|
-
] as const);
|
|
22
|
-
|
|
23
|
-
export const OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE = {
|
|
24
|
-
SCRATCH_FAILED: "scratch-failed",
|
|
25
|
-
LAUNCH_FAILED: "launch-failed",
|
|
26
|
-
CANCELLED: "cancelled",
|
|
27
|
-
TIMED_OUT: "timed-out",
|
|
28
|
-
NONZERO_EXIT: "nonzero-exit",
|
|
29
|
-
EMPTY_OUTPUT: "empty-output",
|
|
30
|
-
CLEANUP_FAILED: "cleanup-failed",
|
|
31
|
-
} as const;
|
|
32
|
-
export type OpaquePiReviewerTransportFailureKind = (typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE)[keyof typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE];
|
|
33
|
-
|
|
34
|
-
export interface OpaquePiReviewerOptions {
|
|
35
|
-
readonly piExecutable?: string;
|
|
36
|
-
readonly environment?: NodeJS.ProcessEnv;
|
|
37
|
-
readonly timeoutMs?: number;
|
|
38
|
-
readonly signal?: AbortSignal;
|
|
39
|
-
/**
|
|
40
|
-
* Caller-owned tokens appended verbatim after the frozen argv (spawn keeps
|
|
41
|
-
* shell:false, so every token is one argv entry). The adapter never
|
|
42
|
-
* interprets them; its caller owns their meaning and validation.
|
|
43
|
-
*/
|
|
44
|
-
readonly extraArguments?: readonly string[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface OpaquePiReviewerResult {
|
|
48
|
-
readonly stdout: Buffer;
|
|
49
|
-
readonly promptByteLength: number;
|
|
50
|
-
readonly stdoutByteLength: number;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface OpaquePiReviewerTransportDetails {
|
|
54
|
-
readonly exitCode?: number | null;
|
|
55
|
-
readonly stderr?: Buffer;
|
|
56
|
-
readonly timedOut?: boolean;
|
|
57
|
-
readonly cancelled?: boolean;
|
|
58
|
-
/** Wall time for a launched Pi process; absent when no process was launched. */
|
|
59
|
-
readonly elapsedMs?: number;
|
|
60
|
-
/** The bound applied to a launched Pi process; absent when no process was launched. */
|
|
61
|
-
readonly timeoutMs?: number;
|
|
62
|
-
/** What the child's output stream revealed when no assistant answer could be recovered. */
|
|
63
|
-
readonly evidence?: PiReviewOutputEvidence;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export class OpaquePiReviewerTransportError extends Error {
|
|
67
|
-
readonly kind: OpaquePiReviewerTransportFailureKind;
|
|
68
|
-
readonly exitCode: number | null;
|
|
69
|
-
readonly stderr: Buffer;
|
|
70
|
-
readonly timedOut: boolean;
|
|
71
|
-
readonly cancelled: boolean;
|
|
72
|
-
readonly elapsedMs: number | null;
|
|
73
|
-
readonly timeoutMs: number | null;
|
|
74
|
-
readonly evidence: PiReviewOutputEvidence | undefined;
|
|
75
|
-
|
|
76
|
-
constructor(kind: OpaquePiReviewerTransportFailureKind, message: string, details: OpaquePiReviewerTransportDetails = {}) {
|
|
77
|
-
super(message);
|
|
78
|
-
this.name = "OpaquePiReviewerTransportError";
|
|
79
|
-
this.kind = kind;
|
|
80
|
-
this.exitCode = details.exitCode ?? null;
|
|
81
|
-
this.stderr = details.stderr ?? Buffer.alloc(0);
|
|
82
|
-
this.timedOut = details.timedOut ?? false;
|
|
83
|
-
this.cancelled = details.cancelled ?? false;
|
|
84
|
-
this.elapsedMs = details.elapsedMs ?? null;
|
|
85
|
-
this.timeoutMs = details.timeoutMs ?? null;
|
|
86
|
-
this.evidence = details.evidence;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
interface OpaquePiProcessResult {
|
|
91
|
-
readonly stdout: Buffer;
|
|
92
|
-
readonly stderr: Buffer;
|
|
93
|
-
readonly exitCode: number | null;
|
|
94
|
-
readonly timedOut: boolean;
|
|
95
|
-
readonly cancelled: boolean;
|
|
96
|
-
readonly elapsedMs: number;
|
|
97
|
-
readonly timeoutMs: number;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const DEFAULT_OPAQUE_PI_TIMEOUT_MS = 600_000;
|
|
101
|
-
|
|
102
|
-
export interface PiLaunch {
|
|
103
|
-
readonly file: string;
|
|
104
|
-
readonly arguments: readonly string[];
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
interface PiHostProcess {
|
|
108
|
-
readonly execPath: string;
|
|
109
|
-
readonly entry: string | undefined;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The exact spawn shape for the fresh Pi process. A bare `pi` on Windows
|
|
114
|
-
* resolves to pi.cmd, pi.ps1, or a POSIX shim, none of which Node can spawn
|
|
115
|
-
* with shell:false (EINVAL or ENOENT). This adapter already runs inside Pi, so
|
|
116
|
-
* on win32 the host's own JavaScript entry is spawned through the host's
|
|
117
|
-
* process.execPath instead; a shell is never enabled. Every other platform,
|
|
118
|
-
* and every explicit launcher, keeps the exact shape it always had.
|
|
119
|
-
*/
|
|
120
|
-
export function resolvePiLaunch(
|
|
121
|
-
piExecutable: string | undefined,
|
|
122
|
-
platform: NodeJS.Platform = process.platform,
|
|
123
|
-
host: PiHostProcess = { execPath: process.execPath, entry: process.argv[1] },
|
|
124
|
-
extraArguments: readonly string[] = [],
|
|
125
|
-
): PiLaunch {
|
|
126
|
-
const arguments_ = [...OPAQUE_PI_REVIEWER_ARGV, ...extraArguments];
|
|
127
|
-
if (piExecutable !== undefined) return { file: piExecutable, arguments: arguments_ };
|
|
128
|
-
if (platform !== "win32") return { file: "pi", arguments: arguments_ };
|
|
129
|
-
if (typeof host.entry !== "string" || host.entry.length === 0 || !(platform === "win32" ? win32 : posix).isAbsolute(host.entry)) {
|
|
130
|
-
throw new Error(`Pi host entry could not be resolved from the running process (received ${JSON.stringify(host.entry ?? null)}); a bare pi launcher cannot be spawned on Windows without a shell`);
|
|
131
|
-
}
|
|
132
|
-
return { file: host.execPath, arguments: [host.entry, ...arguments_] };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function errorMessage(error: unknown): string {
|
|
136
|
-
return error instanceof Error ? error.message : String(error);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
// The child runs `pi --mode json`, so its stdout is a newline-delimited pi
|
|
141
|
-
// event stream. The transport's output is the assistant text of that stream —
|
|
142
|
-
// the same bytes an interactive run would have rendered — and everything else
|
|
143
|
-
// about the stream becomes typed evidence. A run whose turn was spent on a
|
|
144
|
-
// tool call therefore reports what happened (which reviewer selection ran,
|
|
145
|
-
// that a tool call was attempted) instead of exiting 0 in silence (#1140).
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
|
|
148
|
-
export interface PiReviewOutputEvidence {
|
|
149
|
-
readonly stdoutKind: "no-output" | "not-a-pi-event-stream" | "no-assistant-text";
|
|
150
|
-
/** The reviewer selection the child itself reported in its events, when it named one. */
|
|
151
|
-
readonly reviewerModel?: string;
|
|
152
|
-
readonly toolCallAttempted?: boolean;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export type PiReviewExtraction =
|
|
156
|
-
| { readonly kind: "text"; readonly text: string }
|
|
157
|
-
| { readonly kind: "none"; readonly evidence: PiReviewOutputEvidence };
|
|
158
|
-
|
|
159
|
-
interface PiEventMessagePart {
|
|
160
|
-
type?: unknown;
|
|
161
|
-
text?: unknown;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
interface PiEventMessage {
|
|
165
|
-
role?: unknown;
|
|
166
|
-
content?: unknown;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
interface PiEventEnvelope {
|
|
170
|
-
type?: unknown;
|
|
171
|
-
message?: unknown;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function parsePiEventLines(stdout: Buffer): PiEventEnvelope[] | undefined {
|
|
175
|
-
const lines = stdout.toString("utf8").split("\n").filter((line) => line.trim().length > 0);
|
|
176
|
-
if (lines.length === 0) return undefined;
|
|
177
|
-
const events: PiEventEnvelope[] = [];
|
|
178
|
-
for (const line of lines) {
|
|
179
|
-
let parsed: unknown;
|
|
180
|
-
try {
|
|
181
|
-
parsed = JSON.parse(line);
|
|
182
|
-
} catch {
|
|
183
|
-
return undefined;
|
|
184
|
-
}
|
|
185
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
186
|
-
events.push(parsed as PiEventEnvelope);
|
|
187
|
-
}
|
|
188
|
-
return events;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function extractPiAssistantText(stdout: Buffer): PiReviewExtraction {
|
|
192
|
-
if (stdout.length === 0) return { kind: "none", evidence: { stdoutKind: "no-output" } };
|
|
193
|
-
const events = parsePiEventLines(stdout);
|
|
194
|
-
if (events === undefined) return { kind: "none", evidence: { stdoutKind: "not-a-pi-event-stream" } };
|
|
195
|
-
const parts: string[] = [];
|
|
196
|
-
let reviewerModel: string | undefined;
|
|
197
|
-
let toolCallAttempted = false;
|
|
198
|
-
for (const event of events) {
|
|
199
|
-
// The final content of one assistant message lives in its end event;
|
|
200
|
-
// start and update events repeat or stream the same parts.
|
|
201
|
-
if (event.type !== "message_end") continue;
|
|
202
|
-
const message = event.message;
|
|
203
|
-
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
204
|
-
const candidate = message as PiEventMessage;
|
|
205
|
-
if (candidate.role !== "assistant") continue;
|
|
206
|
-
// The event envelope names the selection the child itself ran with the wire
|
|
207
|
-
// key pi uses for it; read as quoted data, never as an identifier.
|
|
208
|
-
const reported = (candidate as Record<string, unknown>)["model"];
|
|
209
|
-
if (typeof reported === "string" && reported.length > 0 && reviewerModel === undefined) reviewerModel = reported;
|
|
210
|
-
if (!Array.isArray(candidate.content)) continue;
|
|
211
|
-
for (const part of candidate.content as PiEventMessagePart[]) {
|
|
212
|
-
if (!part || typeof part !== "object") continue;
|
|
213
|
-
if (typeof part.type === "string" && part.type.startsWith("tool")) toolCallAttempted = true;
|
|
214
|
-
if (part.type === "text" && typeof part.text === "string") parts.push(part.text);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const text = parts.join("");
|
|
218
|
-
if (text.length === 0) {
|
|
219
|
-
return {
|
|
220
|
-
kind: "none",
|
|
221
|
-
evidence: {
|
|
222
|
-
stdoutKind: "no-assistant-text",
|
|
223
|
-
...(reviewerModel === undefined ? {} : { reviewerModel }),
|
|
224
|
-
...(toolCallAttempted ? { toolCallAttempted } : {}),
|
|
225
|
-
},
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
return { kind: "text", text };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function emptyOutputMessage(evidence: PiReviewOutputEvidence): string {
|
|
232
|
-
const details = [`stdout kind: ${evidence.stdoutKind}`];
|
|
233
|
-
if (evidence.reviewerModel !== undefined) details.push(`reviewer selection: ${evidence.reviewerModel}`);
|
|
234
|
-
if (evidence.toolCallAttempted) details.push("a tool call was attempted");
|
|
235
|
-
return `Pi process produced no assistant text (${details.join("; ")})`;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function runPiProcess(prompt: Buffer, scratchDirectory: string, options: OpaquePiReviewerOptions): Promise<OpaquePiProcessResult> {
|
|
239
|
-
return new Promise((resolve, reject) => {
|
|
240
|
-
const startedAt = Date.now();
|
|
241
|
-
let launch: PiLaunch;
|
|
242
|
-
try {
|
|
243
|
-
launch = resolvePiLaunch(options.piExecutable, process.platform, { execPath: process.execPath, entry: process.argv[1] }, options.extraArguments ?? []);
|
|
244
|
-
} catch (error) {
|
|
245
|
-
reject(error);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
const child = spawn(launch.file, [...launch.arguments], {
|
|
249
|
-
cwd: scratchDirectory,
|
|
250
|
-
env: options.environment ?? process.env,
|
|
251
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
252
|
-
shell: false,
|
|
253
|
-
windowsHide: true,
|
|
254
|
-
});
|
|
255
|
-
const stdout: Buffer[] = [];
|
|
256
|
-
const stderr: Buffer[] = [];
|
|
257
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_OPAQUE_PI_TIMEOUT_MS;
|
|
258
|
-
let timedOut = false;
|
|
259
|
-
let cancelled = false;
|
|
260
|
-
let settled = false;
|
|
261
|
-
const timer = timeoutMs > 0
|
|
262
|
-
? setTimeout(() => {
|
|
263
|
-
timedOut = true;
|
|
264
|
-
child.kill("SIGKILL");
|
|
265
|
-
}, timeoutMs)
|
|
266
|
-
: undefined;
|
|
267
|
-
timer?.unref();
|
|
268
|
-
const cancel = () => {
|
|
269
|
-
cancelled = true;
|
|
270
|
-
child.kill("SIGKILL");
|
|
271
|
-
};
|
|
272
|
-
const clear = () => {
|
|
273
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
274
|
-
options.signal?.removeEventListener("abort", cancel);
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
278
|
-
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
279
|
-
child.on("error", (error) => {
|
|
280
|
-
if (settled) return;
|
|
281
|
-
settled = true;
|
|
282
|
-
clear();
|
|
283
|
-
reject(error);
|
|
284
|
-
});
|
|
285
|
-
child.on("close", (code) => {
|
|
286
|
-
if (settled) return;
|
|
287
|
-
settled = true;
|
|
288
|
-
clear();
|
|
289
|
-
resolve({
|
|
290
|
-
stdout: Buffer.concat(stdout),
|
|
291
|
-
stderr: Buffer.concat(stderr),
|
|
292
|
-
exitCode: code,
|
|
293
|
-
timedOut,
|
|
294
|
-
cancelled,
|
|
295
|
-
elapsedMs: Date.now() - startedAt,
|
|
296
|
-
timeoutMs,
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
if (options.signal?.aborted) cancel();
|
|
300
|
-
else options.signal?.addEventListener("abort", cancel, { once: true });
|
|
301
|
-
child.stdin.on("error", () => undefined);
|
|
302
|
-
child.stdin.end(prompt);
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Runs raw prompt bytes through one fixed, isolated Pi process. */
|
|
307
|
-
export async function runOpaquePiReviewer(prompt: Buffer, options: OpaquePiReviewerOptions = {}): Promise<OpaquePiReviewerResult> {
|
|
308
|
-
if (options.signal?.aborted) {
|
|
309
|
-
throw new OpaquePiReviewerTransportError(
|
|
310
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
|
|
311
|
-
"Pi process was cancelled before launch",
|
|
312
|
-
{ cancelled: true },
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
if (options.extraArguments !== undefined && options.extraArguments.some((token) => typeof token !== "string" || token.length === 0)) {
|
|
316
|
-
throw new TypeError("Pi reviewer launch arguments must all be non-empty strings");
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
let scratchDirectory: string | undefined;
|
|
320
|
-
let primaryFailure = false;
|
|
321
|
-
try {
|
|
322
|
-
try {
|
|
323
|
-
scratchDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-opaque-reviewer-"));
|
|
324
|
-
await chmod(scratchDirectory, 0o700);
|
|
325
|
-
} catch (error) {
|
|
326
|
-
throw new OpaquePiReviewerTransportError(
|
|
327
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED,
|
|
328
|
-
`Pi scratch directory could not be prepared: ${errorMessage(error)}`,
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
let processResult: OpaquePiProcessResult;
|
|
333
|
-
try {
|
|
334
|
-
processResult = await runPiProcess(prompt, scratchDirectory, options);
|
|
335
|
-
} catch (error) {
|
|
336
|
-
if (options.signal?.aborted) {
|
|
337
|
-
throw new OpaquePiReviewerTransportError(
|
|
338
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
|
|
339
|
-
"Pi process was cancelled",
|
|
340
|
-
{ cancelled: true },
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
throw new OpaquePiReviewerTransportError(
|
|
344
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED,
|
|
345
|
-
`Pi process could not start: ${errorMessage(error)}`,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
const timing = {
|
|
349
|
-
elapsedMs: processResult.elapsedMs,
|
|
350
|
-
timeoutMs: processResult.timeoutMs,
|
|
351
|
-
};
|
|
352
|
-
if (processResult.timedOut) {
|
|
353
|
-
throw new OpaquePiReviewerTransportError(
|
|
354
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT,
|
|
355
|
-
"Pi process timed out",
|
|
356
|
-
{ exitCode: processResult.exitCode, stderr: processResult.stderr, timedOut: true, ...timing },
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
if (processResult.cancelled) {
|
|
360
|
-
throw new OpaquePiReviewerTransportError(
|
|
361
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
|
|
362
|
-
"Pi process was cancelled",
|
|
363
|
-
{ exitCode: processResult.exitCode, stderr: processResult.stderr, cancelled: true, ...timing },
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
if (processResult.exitCode !== 0) {
|
|
367
|
-
throw new OpaquePiReviewerTransportError(
|
|
368
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.NONZERO_EXIT,
|
|
369
|
-
"Pi process failed",
|
|
370
|
-
{ exitCode: processResult.exitCode, stderr: processResult.stderr, ...timing },
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
const extraction = extractPiAssistantText(processResult.stdout);
|
|
374
|
-
if (extraction.kind === "none") {
|
|
375
|
-
throw new OpaquePiReviewerTransportError(
|
|
376
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
|
|
377
|
-
emptyOutputMessage(extraction.evidence),
|
|
378
|
-
{ exitCode: 0, stderr: processResult.stderr, evidence: extraction.evidence, ...timing },
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
const stdout = Buffer.from(extraction.text, "utf8");
|
|
382
|
-
return {
|
|
383
|
-
stdout,
|
|
384
|
-
promptByteLength: prompt.length,
|
|
385
|
-
stdoutByteLength: stdout.length,
|
|
386
|
-
};
|
|
387
|
-
} catch (error) {
|
|
388
|
-
primaryFailure = true;
|
|
389
|
-
throw error;
|
|
390
|
-
} finally {
|
|
391
|
-
if (scratchDirectory !== undefined) {
|
|
392
|
-
try {
|
|
393
|
-
await rm(scratchDirectory, { recursive: true, force: true });
|
|
394
|
-
} catch (error) {
|
|
395
|
-
if (!primaryFailure) {
|
|
396
|
-
throw new OpaquePiReviewerTransportError(
|
|
397
|
-
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CLEANUP_FAILED,
|
|
398
|
-
`Pi scratch directory cleanup failed: ${errorMessage(error)}`,
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|