gentle-pi 3.2.0 → 3.2.1
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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +15 -5
- package/docs/readme-reference.md +6 -6
- package/docs/review-integration.md +15 -6
- package/extensions/gentle-ai.ts +72 -7
- package/extensions/gentle-shell.ts +29 -7
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +9 -0
- package/lib/opaque-pi-reviewer-adapter.ts +130 -10
- package/lib/review-host-relay.ts +95 -2
- package/lib/shell-bar.ts +63 -9
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +9 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-shell.test.ts +109 -3
- package/tests/native-review-capability-contract.test.ts +14 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/opaque-pi-reviewer-adapter.test.ts +153 -9
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay.test.ts +83 -14
- package/tests/review-relay-transport-agent.test.ts +86 -1
- package/tests/shell-bar.test.ts +153 -3
- package/tests/shell-usage-view.test.ts +3 -2
- package/tests/shell-usage.test.ts +254 -6
|
@@ -5,7 +5,11 @@ import { join, posix, win32 } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
export const OPAQUE_PI_REVIEWER_ARGV = Object.freeze([
|
|
7
7
|
"--print",
|
|
8
|
-
|
|
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",
|
|
9
13
|
"--no-session",
|
|
10
14
|
"--no-tools",
|
|
11
15
|
"--no-extensions",
|
|
@@ -32,6 +36,12 @@ export interface OpaquePiReviewerOptions {
|
|
|
32
36
|
readonly environment?: NodeJS.ProcessEnv;
|
|
33
37
|
readonly timeoutMs?: number;
|
|
34
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[];
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
export interface OpaquePiReviewerResult {
|
|
@@ -49,6 +59,8 @@ export interface OpaquePiReviewerTransportDetails {
|
|
|
49
59
|
readonly elapsedMs?: number;
|
|
50
60
|
/** The bound applied to a launched Pi process; absent when no process was launched. */
|
|
51
61
|
readonly timeoutMs?: number;
|
|
62
|
+
/** What the child's output stream revealed when no assistant answer could be recovered. */
|
|
63
|
+
readonly evidence?: PiReviewOutputEvidence;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
export class OpaquePiReviewerTransportError extends Error {
|
|
@@ -59,6 +71,7 @@ export class OpaquePiReviewerTransportError extends Error {
|
|
|
59
71
|
readonly cancelled: boolean;
|
|
60
72
|
readonly elapsedMs: number | null;
|
|
61
73
|
readonly timeoutMs: number | null;
|
|
74
|
+
readonly evidence: PiReviewOutputEvidence | undefined;
|
|
62
75
|
|
|
63
76
|
constructor(kind: OpaquePiReviewerTransportFailureKind, message: string, details: OpaquePiReviewerTransportDetails = {}) {
|
|
64
77
|
super(message);
|
|
@@ -70,6 +83,7 @@ export class OpaquePiReviewerTransportError extends Error {
|
|
|
70
83
|
this.cancelled = details.cancelled ?? false;
|
|
71
84
|
this.elapsedMs = details.elapsedMs ?? null;
|
|
72
85
|
this.timeoutMs = details.timeoutMs ?? null;
|
|
86
|
+
this.evidence = details.evidence;
|
|
73
87
|
}
|
|
74
88
|
}
|
|
75
89
|
|
|
@@ -107,25 +121,126 @@ export function resolvePiLaunch(
|
|
|
107
121
|
piExecutable: string | undefined,
|
|
108
122
|
platform: NodeJS.Platform = process.platform,
|
|
109
123
|
host: PiHostProcess = { execPath: process.execPath, entry: process.argv[1] },
|
|
124
|
+
extraArguments: readonly string[] = [],
|
|
110
125
|
): PiLaunch {
|
|
111
|
-
|
|
112
|
-
if (
|
|
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_ };
|
|
113
129
|
if (typeof host.entry !== "string" || host.entry.length === 0 || !(platform === "win32" ? win32 : posix).isAbsolute(host.entry)) {
|
|
114
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`);
|
|
115
131
|
}
|
|
116
|
-
return { file: host.execPath, arguments: [host.entry, ...
|
|
132
|
+
return { file: host.execPath, arguments: [host.entry, ...arguments_] };
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
function errorMessage(error: unknown): string {
|
|
120
136
|
return error instanceof Error ? error.message : String(error);
|
|
121
137
|
}
|
|
122
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
|
+
|
|
123
238
|
function runPiProcess(prompt: Buffer, scratchDirectory: string, options: OpaquePiReviewerOptions): Promise<OpaquePiProcessResult> {
|
|
124
239
|
return new Promise((resolve, reject) => {
|
|
125
240
|
const startedAt = Date.now();
|
|
126
241
|
let launch: PiLaunch;
|
|
127
242
|
try {
|
|
128
|
-
launch = resolvePiLaunch(options.piExecutable);
|
|
243
|
+
launch = resolvePiLaunch(options.piExecutable, process.platform, { execPath: process.execPath, entry: process.argv[1] }, options.extraArguments ?? []);
|
|
129
244
|
} catch (error) {
|
|
130
245
|
reject(error);
|
|
131
246
|
return;
|
|
@@ -197,6 +312,9 @@ export async function runOpaquePiReviewer(prompt: Buffer, options: OpaquePiRevie
|
|
|
197
312
|
{ cancelled: true },
|
|
198
313
|
);
|
|
199
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
|
+
}
|
|
200
318
|
|
|
201
319
|
let scratchDirectory: string | undefined;
|
|
202
320
|
let primaryFailure = false;
|
|
@@ -252,17 +370,19 @@ export async function runOpaquePiReviewer(prompt: Buffer, options: OpaquePiRevie
|
|
|
252
370
|
{ exitCode: processResult.exitCode, stderr: processResult.stderr, ...timing },
|
|
253
371
|
);
|
|
254
372
|
}
|
|
255
|
-
|
|
373
|
+
const extraction = extractPiAssistantText(processResult.stdout);
|
|
374
|
+
if (extraction.kind === "none") {
|
|
256
375
|
throw new OpaquePiReviewerTransportError(
|
|
257
376
|
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
|
|
258
|
-
|
|
259
|
-
{ exitCode: 0, stderr: processResult.stderr, ...timing },
|
|
377
|
+
emptyOutputMessage(extraction.evidence),
|
|
378
|
+
{ exitCode: 0, stderr: processResult.stderr, evidence: extraction.evidence, ...timing },
|
|
260
379
|
);
|
|
261
380
|
}
|
|
381
|
+
const stdout = Buffer.from(extraction.text, "utf8");
|
|
262
382
|
return {
|
|
263
|
-
stdout
|
|
383
|
+
stdout,
|
|
264
384
|
promptByteLength: prompt.length,
|
|
265
|
-
stdoutByteLength:
|
|
385
|
+
stdoutByteLength: stdout.length,
|
|
266
386
|
};
|
|
267
387
|
} catch (error) {
|
|
268
388
|
primaryFailure = true;
|
package/lib/review-host-relay.ts
CHANGED
|
@@ -30,10 +30,14 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
30
30
|
import { tmpdir } from "node:os";
|
|
31
31
|
import { isAbsolute, join } from "node:path";
|
|
32
32
|
import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
33
|
+
import { SAFE_MODEL_ID_PATTERN } from "./model-routing-authority.ts";
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
35
|
+
import { delimiter as pathDelimiter } from "node:path";
|
|
33
36
|
import {
|
|
34
37
|
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
|
|
35
38
|
OpaquePiReviewerTransportError,
|
|
36
39
|
runOpaquePiReviewer,
|
|
40
|
+
type PiReviewOutputEvidence,
|
|
37
41
|
type OpaquePiReviewerResult,
|
|
38
42
|
} from "./opaque-pi-reviewer-adapter.ts";
|
|
39
43
|
import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
|
|
@@ -60,6 +64,11 @@ export const REVIEW_HOST_RELAY_FAILURE = {
|
|
|
60
64
|
// continuation instead of hiding inside `pi-failed`.
|
|
61
65
|
PI_TIMED_OUT: "pi-timed-out",
|
|
62
66
|
PI_EMPTY_OUTPUT: "pi-empty-output",
|
|
67
|
+
// gentle-shell#1158 / #1136: a caller-owned reviewer selection that cannot
|
|
68
|
+
// possibly launch (a malformed selection id, a relative or missing extension
|
|
69
|
+
// path) is a configuration failure, refused typed before anything runs —
|
|
70
|
+
// never a mid-review transport mystery.
|
|
71
|
+
REVIEWER_CONFIG_INVALID: "reviewer-config-invalid",
|
|
63
72
|
SUBMISSION_REFUSED: "submission-refused",
|
|
64
73
|
} as const;
|
|
65
74
|
export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
|
|
@@ -90,7 +99,9 @@ export class ReviewHostRelayError extends Error {
|
|
|
90
99
|
// "none" again: the provider states that the lens slot was not consumed
|
|
91
100
|
// (gentle-pi#522 / #524).
|
|
92
101
|
readonly mutationOutcome: "none" | "unknown";
|
|
93
|
-
|
|
102
|
+
/** What the reviewer child's own event stream revealed on an empty-output failure. */
|
|
103
|
+
readonly reviewerEvidence: PiReviewOutputEvidence | undefined;
|
|
104
|
+
constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown"; reviewerEvidence?: PiReviewOutputEvidence }) {
|
|
94
105
|
super(message);
|
|
95
106
|
this.name = "ReviewHostRelayError";
|
|
96
107
|
this.kind = kind;
|
|
@@ -101,6 +112,7 @@ export class ReviewHostRelayError extends Error {
|
|
|
101
112
|
this.elapsedMs = details?.elapsedMs ?? null;
|
|
102
113
|
this.timeoutMs = details?.timeoutMs ?? null;
|
|
103
114
|
this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none");
|
|
115
|
+
this.reviewerEvidence = details?.reviewerEvidence;
|
|
104
116
|
}
|
|
105
117
|
}
|
|
106
118
|
|
|
@@ -295,6 +307,21 @@ export interface ReviewHostRelayRequest {
|
|
|
295
307
|
readonly piExecutable?: string;
|
|
296
308
|
readonly environment?: NodeJS.ProcessEnv;
|
|
297
309
|
readonly gentleAiTimeoutMs?: number;
|
|
310
|
+
/**
|
|
311
|
+
* User-owned reviewer selection forwarded to the child as `--model`. The
|
|
312
|
+
* relay never invents one; the default launch stays selection-free. Must
|
|
313
|
+
* match {@link SAFE_MODEL_ID_PATTERN}; anything else is refused typed
|
|
314
|
+
* before any process launches (gentle-shell#1136).
|
|
315
|
+
*/
|
|
316
|
+
readonly reviewerModel?: string;
|
|
317
|
+
/**
|
|
318
|
+
* User-owned extension files loaded into the isolated child through
|
|
319
|
+
* explicit `-e` paths (pi keeps explicit loads under `--no-extensions`),
|
|
320
|
+
* so a subscription provider's auth adapter can ride along without
|
|
321
|
+
* re-enabling extension discovery (gentle-shell#1158). Every path must be
|
|
322
|
+
* absolute and exist; anything else is refused typed before launch.
|
|
323
|
+
*/
|
|
324
|
+
readonly reviewerExtensionPaths?: readonly string[];
|
|
298
325
|
/**
|
|
299
326
|
* Overrides the reviewer bound entirely. Production leaves it unset and the
|
|
300
327
|
* relay derives the bound from the materialized prompt bytes and
|
|
@@ -464,7 +491,18 @@ function relayPiTransportError(error: unknown, promptByteLength: number, piTimeo
|
|
|
464
491
|
);
|
|
465
492
|
}
|
|
466
493
|
if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) {
|
|
467
|
-
|
|
494
|
+
// #1156: the envelope says what the child's own stream revealed, and the
|
|
495
|
+
// message names the remedies, because a bare kind code is what sent the
|
|
496
|
+
// #1140 reporter into a retry loop with nothing to inspect.
|
|
497
|
+
const evidence = error.evidence;
|
|
498
|
+
const summary: string[] = [`stdout kind: ${evidence?.stdoutKind ?? "unknown"}`];
|
|
499
|
+
if (evidence?.reviewerModel !== undefined) summary.push(`reviewer selection: ${evidence.reviewerModel}`);
|
|
500
|
+
if (evidence?.toolCallAttempted) summary.push("a tool call was attempted");
|
|
501
|
+
const stderrExcerpt = details.stderr.length > 0 ? ` child stderr: ${details.stderr.slice(0, 400).replace(/\s+/g, " ").trim()}` : "";
|
|
502
|
+
const message = `pi subprocess produced no assistant text (${summary.join("; ")}).${stderrExcerpt}`
|
|
503
|
+
+ " A reviewer that spent its turn on a tool call, a selection the child could not resolve, or a stream pi did not produce all land here; the evidence above says which."
|
|
504
|
+
+ " Assign the lens a reviewer selection that answers in text (the lens's entry in the agent model routing config), and when the provider needs an auth adapter, name its absolute file path in " + REVIEW_HOST_RELAY_EXTENSIONS_ENV + ".";
|
|
505
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", message, { ...details, ...(evidence === undefined ? {} : { reviewerEvidence: evidence }) });
|
|
468
506
|
}
|
|
469
507
|
if (
|
|
470
508
|
error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
|
|
@@ -482,8 +520,60 @@ function assertTokens(name: string, tokens: readonly string[]): void {
|
|
|
482
520
|
}
|
|
483
521
|
}
|
|
484
522
|
|
|
523
|
+
export const REVIEW_HOST_RELAY_EXTENSIONS_ENV = "GENTLE_PI_REVIEW_RELAY_EXTENSIONS";
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The user-owned extension allowlist for the reviewer child, read from the
|
|
527
|
+
* environment (gentle-shell#1158). Entries are split on the platform path
|
|
528
|
+
* delimiter; empty entries are skipped. Validation of each path happens at
|
|
529
|
+
* snapshot time, so a broken entry is refused typed before anything launches.
|
|
530
|
+
*/
|
|
531
|
+
export function resolveReviewHostRelayExtensionPaths(environment: NodeJS.ProcessEnv = process.env): readonly string[] {
|
|
532
|
+
const configured = environment[REVIEW_HOST_RELAY_EXTENSIONS_ENV];
|
|
533
|
+
if (configured === undefined || configured.trim().length === 0) return [];
|
|
534
|
+
return configured.split(pathDelimiter).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The tokens a validated caller-owned selection contributes to the child's
|
|
538
|
+
// argv. Only these two shapes may ride the forwarding path.
|
|
539
|
+
function reviewerLaunchArguments(reviewerModel: string | undefined, reviewerExtensionPaths: readonly string[] | undefined): readonly string[] {
|
|
540
|
+
return [
|
|
541
|
+
...(reviewerModel === undefined ? [] : ["--model", reviewerModel]),
|
|
542
|
+
...(reviewerExtensionPaths ?? []).flatMap((path) => ["-e", path]),
|
|
543
|
+
];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function validateReviewerLaunchConfiguration(request: ReviewHostRelayRequest): { reviewerModel?: string; reviewerExtensionPaths?: readonly string[] } {
|
|
547
|
+
if (request.reviewerModel !== undefined) {
|
|
548
|
+
if (typeof request.reviewerModel !== "string" || request.reviewerModel.length === 0 || !SAFE_MODEL_ID_PATTERN.test(request.reviewerModel)) {
|
|
549
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the caller-owned reviewer selection ${JSON.stringify(request.reviewerModel)} is not a safe model id`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (request.reviewerExtensionPaths !== undefined) {
|
|
553
|
+
if (!Array.isArray(request.reviewerExtensionPaths) || request.reviewerExtensionPaths.some((path) => typeof path !== "string" || path.length === 0)) {
|
|
554
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", "Pi host relay reviewer launch configuration is invalid: extension paths must all be non-empty strings");
|
|
555
|
+
}
|
|
556
|
+
for (const path of request.reviewerExtensionPaths) {
|
|
557
|
+
if (!isAbsolute(path)) {
|
|
558
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the extension path ${JSON.stringify(path)} is not absolute`);
|
|
559
|
+
}
|
|
560
|
+
if (!existsSync(path)) {
|
|
561
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", `Pi host relay reviewer launch configuration is invalid: the extension path ${JSON.stringify(path)} does not exist`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
...(request.reviewerModel === undefined ? {} : { reviewerModel: request.reviewerModel }),
|
|
567
|
+
...(request.reviewerExtensionPaths === undefined || request.reviewerExtensionPaths.length === 0 ? {} : { reviewerExtensionPaths: Object.freeze([...request.reviewerExtensionPaths]) }),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
485
571
|
function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): ReviewHostRelayRequest {
|
|
486
572
|
assertTokens("capture", request.captureArgumentTokens);
|
|
573
|
+
// Caller-owned reviewer selection is validated before any process launches:
|
|
574
|
+
// a broken configuration is a typed refusal, never a mid-review transport
|
|
575
|
+
// failure (gentle-shell#1158 / #1136).
|
|
576
|
+
const reviewerLaunch = validateReviewerLaunchConfiguration(request);
|
|
487
577
|
// The completing form is validated before any process launches: a materialize
|
|
488
578
|
// slot without a provider-owned submission is a typed contract mismatch,
|
|
489
579
|
// never a synthesized invocation.
|
|
@@ -500,6 +590,7 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
|
|
|
500
590
|
...request,
|
|
501
591
|
captureArgumentTokens: Object.freeze([...request.captureArgumentTokens]),
|
|
502
592
|
...(submission === undefined ? {} : { submission }),
|
|
593
|
+
...reviewerLaunch,
|
|
503
594
|
gentleAiExecutable,
|
|
504
595
|
environment,
|
|
505
596
|
gentleAiTimeoutMs: request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS,
|
|
@@ -574,6 +665,7 @@ export async function prepareReviewHostRelaySlot(
|
|
|
574
665
|
|
|
575
666
|
// The pure adapter owns the fresh isolated Pi process. Its input and output
|
|
576
667
|
// are opaque bytes; this coordinator only maps transport failures.
|
|
668
|
+
const launchArguments = reviewerLaunchArguments(preparedRequest.reviewerModel, preparedRequest.reviewerExtensionPaths);
|
|
577
669
|
let piResult: OpaquePiReviewerResult;
|
|
578
670
|
try {
|
|
579
671
|
piResult = await reviewer(promptBytes, {
|
|
@@ -581,6 +673,7 @@ export async function prepareReviewHostRelaySlot(
|
|
|
581
673
|
environment: preparedRequest.environment,
|
|
582
674
|
timeoutMs: piTimeoutMs,
|
|
583
675
|
...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
|
|
676
|
+
...(launchArguments.length === 0 ? {} : { extraArguments: launchArguments }),
|
|
584
677
|
});
|
|
585
678
|
} catch (error) {
|
|
586
679
|
throw relayPiTransportError(error, promptBytes.length, piTimeoutMs);
|
package/lib/shell-bar.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
|
|
3
|
-
import { renderUsageBar, type ProviderUsage } from "./shell-usage.ts";
|
|
3
|
+
import { allowanceGroupsSupported, groupUsageLimits, renderUsageBar, selectUsageLimit, type ProviderUsage } from "./shell-usage.ts";
|
|
4
4
|
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
5
5
|
import { CARD_TONE, cardInnerWidth, renderCard } from "./shell-card.ts";
|
|
6
6
|
|
|
@@ -48,11 +48,17 @@ const ROLE = {
|
|
|
48
48
|
SESSION: "dim",
|
|
49
49
|
} as const;
|
|
50
50
|
|
|
51
|
-
export const SHELL_BAR_BRAND = "✿ gentle
|
|
51
|
+
export const SHELL_BAR_BRAND = "✿ gentle shell";
|
|
52
52
|
export const SHELL_BAR_SEPARATOR = "⟡";
|
|
53
53
|
export const SHELL_BAR_GAUGE_CELLS = GAUGE_CELLS;
|
|
54
54
|
const RIGHT_PADDING = 2;
|
|
55
55
|
const COMPACT_BRANCH_WIDTH = 15;
|
|
56
|
+
// The rows the sidebar prints for a provider with per-model allowances use the
|
|
57
|
+
// bar's shorter meter: the rail is 50 columns wide, and the panel's 16 cells
|
|
58
|
+
// would leave no room for the model ids.
|
|
59
|
+
const SIDEBAR_USAGE_METER_CELLS = GAUGE_CELLS;
|
|
60
|
+
// Meter, its two spaces and the right-aligned percentage.
|
|
61
|
+
const SIDEBAR_USAGE_ROW_FIXED = SIDEBAR_USAGE_METER_CELLS + 6;
|
|
56
62
|
|
|
57
63
|
export function shellEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
58
64
|
if (env.GENTLE_PI_AGENTS_CHILD === "1") return false;
|
|
@@ -90,7 +96,7 @@ function buildSegments(model: ShellBarModel, theme: ShellBarTheme): string[] {
|
|
|
90
96
|
const percentText = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
|
|
91
97
|
const context = `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(model.contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
|
|
92
98
|
const cost = theme.fg(ROLE.VALUE, formatCost(model.costTotal, model.subscription));
|
|
93
|
-
const usage = model.usage ? renderUsageBar(model.usage, theme) : undefined;
|
|
99
|
+
const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId) : undefined;
|
|
94
100
|
const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
|
|
95
101
|
return [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND), location, modelSegment, context, cost, ...(usage ? [usage] : []), ...statuses];
|
|
96
102
|
}
|
|
@@ -119,6 +125,52 @@ function joinSegments(segments: string[], theme: ShellBarTheme): string {
|
|
|
119
125
|
return segments.join(` ${theme.fg(ROLE.SEPARATOR, SHELL_BAR_SEPARATOR)} `);
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
// A row exists to show what is being consumed, so a window that consumed
|
|
129
|
+
// nothing is noise the sidebar drops. The threshold is the row's own number and
|
|
130
|
+
// nothing else: the render path prints `Math.round(percent)`, so a fraction
|
|
131
|
+
// below half a percent prints `0%` and disappears while half a percent keeps its
|
|
132
|
+
// row and prints `1%` — no second scale and no separate epsilon. A window whose
|
|
133
|
+
// percent is not a number never equals zero, so it keeps its row instead of
|
|
134
|
+
// being dropped in silence. The bar and the panel keep their own contract and
|
|
135
|
+
// still print a zero allowance.
|
|
136
|
+
function consumedNothing(usedPercent: number): boolean {
|
|
137
|
+
return Math.round(usedPercent) === 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The sidebar is the surface that never needs opening, so a provider with
|
|
141
|
+
// per-model allowances prints the panel's model rows there too — the most
|
|
142
|
+
// consumed family first, its models inside it — and leaves the aggregate totals
|
|
143
|
+
// and the reset dates to the bar and the panel. Providers without raw
|
|
144
|
+
// allowances keep the one aggregate line the bar has always drawn for the model
|
|
145
|
+
// in use.
|
|
146
|
+
function sidebarUsageLines(usage: ProviderUsage, modelId: string, theme: ShellBarTheme, available: number): string[] {
|
|
147
|
+
if (!allowanceGroupsSupported(usage.limits)) {
|
|
148
|
+
// The aggregate line is one row, so its windows decide together: one
|
|
149
|
+
// consumed window keeps the sharing row, all of them zero drop it. A limit
|
|
150
|
+
// with no windows is not "zero consumption" — there is nothing to draw, and
|
|
151
|
+
// renderUsageBar already answers that — so the rule only speaks when there
|
|
152
|
+
// is a window to judge.
|
|
153
|
+
const windows = selectUsageLimit(usage, modelId)?.windows ?? [];
|
|
154
|
+
if (windows.length > 0 && windows.every((window) => consumedNothing(window.usedPercent))) return [];
|
|
155
|
+
const line = renderUsageBar(usage, theme, modelId);
|
|
156
|
+
return line ? [line] : [];
|
|
157
|
+
}
|
|
158
|
+
const rows = groupUsageLimits(usage.limits)
|
|
159
|
+
.flatMap((limit) =>
|
|
160
|
+
limit.windows.map((window) => ({ name: [limit.name, window.label].filter((part) => part.length > 0).join(" "), window })),
|
|
161
|
+
)
|
|
162
|
+
.filter((row) => !consumedNothing(row.window.usedPercent));
|
|
163
|
+
// The name column gives way first: it is the only part that can be clipped
|
|
164
|
+
// without losing the number the row exists to show.
|
|
165
|
+
const widest = rows.reduce((width, row) => Math.max(width, row.name.length), 0);
|
|
166
|
+
const nameWidth = Math.min(widest, Math.max(1, available - SIDEBAR_USAGE_ROW_FIXED));
|
|
167
|
+
return rows.map((row) => {
|
|
168
|
+
const name = row.name.length > nameWidth ? clipText(row.name, nameWidth) : row.name.padEnd(nameWidth);
|
|
169
|
+
const percent = `${Math.round(row.window.usedPercent)}%`.padStart(4);
|
|
170
|
+
return `${theme.fg(ROLE.LABEL, name)} ${paintGauge(row.window.usedPercent, theme, SIDEBAR_USAGE_METER_CELLS)} ${theme.fg(ROLE.VALUE, percent)}`;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
122
174
|
// Sidebar groups use structured fields, never positional compact-bar segments
|
|
123
175
|
// or inferred meanings from opaque extension status strings.
|
|
124
176
|
export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
|
|
@@ -128,7 +180,11 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
128
180
|
const branch = model.branch ? `${label("Branch")} ${value(model.branch)}` : "";
|
|
129
181
|
const percent = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
|
|
130
182
|
const capacity = label(`${formatTokens(model.contextWindow)} tokens`);
|
|
131
|
-
|
|
183
|
+
// Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
|
|
184
|
+
// the same inset without consuming the card's right border.
|
|
185
|
+
const innerWidth = cardInnerWidth(width);
|
|
186
|
+
const inset = Math.min(1, innerWidth - 1);
|
|
187
|
+
const usageLines = model.usage ? sidebarUsageLines(model.usage, model.modelId, theme, innerWidth - inset) : [];
|
|
132
188
|
const groups: Array<{ title: string; lines: string[] }> = [
|
|
133
189
|
{
|
|
134
190
|
title: "Project",
|
|
@@ -157,17 +213,15 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
157
213
|
`${label("Context")} ${paintGauge(model.contextPercent, theme)} ${value(percent)}`,
|
|
158
214
|
capacity,
|
|
159
215
|
`${label("Cost")} ${value(formatCost(model.costTotal, model.subscription))}`,
|
|
160
|
-
...
|
|
216
|
+
...usageLines,
|
|
161
217
|
],
|
|
162
218
|
},
|
|
163
219
|
{ title: "Integrations", lines: model.statuses.length
|
|
164
220
|
? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)))
|
|
165
221
|
: [label("No status reported")] },
|
|
166
222
|
];
|
|
167
|
-
//
|
|
168
|
-
// the same inset without consuming the
|
|
169
|
-
const innerWidth = cardInnerWidth(width);
|
|
170
|
-
const inset = Math.min(1, innerWidth - 1);
|
|
223
|
+
// Wrap and indent every group line before it reaches the card, so Unicode and
|
|
224
|
+
// ANSI continuation lines keep the same inset without consuming the right border.
|
|
171
225
|
const body = groups.flatMap((group, index) => [
|
|
172
226
|
...(index ? [""] : []),
|
|
173
227
|
label(group.title),
|