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
@@ -7,14 +7,18 @@
7
7
  //
8
8
  // 1. Run the exact provider-issued capture binding with `--agent pi
9
9
  // --materialize` and take stdout as opaque prompt BYTES, verbatim.
10
- // 2. Pass those prompt bytes to the pure opaque Pi adapter, which owns its
11
- // locked-down print-mode subprocess and fresh empty scratch directory;
12
- // take its stdout as raw final bytes. Model/provider/profile selection
13
- // stays user-owned: no --model, no --provider, environment untouched.
14
- // 3. Submit those bytes untouched through the provider-owned `submission`
15
- // form carried by the collect input: execute its exact operation and
16
- // argument tokens with only the tempfile path substituted into the
17
- // declared {{value}} slot (BOM-less: the buffer is written
10
+ // 2. Run that prompt through one in-process reviewer completion
11
+ // (lib/inprocess-reviewer.ts#runInProcessReviewer): resolve the lens's
12
+ // "provider/id" selection through the live model registry, authenticate
13
+ // through the registry's own resolver, and complete the frozen prompt as
14
+ // a single user message. There is no child process, no extension
15
+ // allowlist, and no ambient default model — a missing registry or a
16
+ // routing entry with no model is a typed refusal before materialize ever
17
+ // runs (gentle-ai#4611; gentle-pi#311 P2).
18
+ // 3. Submit the completion's text untouched through the provider-owned
19
+ // `submission` form carried by the collect input: execute its exact
20
+ // operation and argument tokens with only the tempfile path substituted
21
+ // into the declared {{value}} slot (BOM-less: the buffer is written
18
22
  // byte-for-byte). The host never synthesizes or filters the completing
19
23
  // form; a materialize slot without a provider submission is a typed
20
24
  // contract mismatch, never a rebuilt invocation.
@@ -25,28 +29,22 @@
25
29
  // never from transcript inference. The relay never parses or rebuilds
26
30
  // binding, evidence, prompt, schema, budgets, or admission.
27
31
 
28
- import { spawn } from "node:child_process";
29
32
  import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
33
+ import { spawn } from "node:child_process";
30
34
  import { tmpdir } from "node:os";
31
35
  import { isAbsolute, join } from "node:path";
36
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
32
37
  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";
36
38
  import {
37
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
38
- OpaquePiReviewerTransportError,
39
- runOpaquePiReviewer,
40
- type PiReviewOutputEvidence,
41
- type OpaquePiReviewerResult,
42
- } from "./opaque-pi-reviewer-adapter.ts";
39
+ INPROCESS_REVIEWER_FAILURE,
40
+ runInProcessReviewer,
41
+ type InProcessReviewerFailureCode,
42
+ type InProcessReviewerOutcome,
43
+ type InProcessReviewerRegistry,
44
+ } from "./inprocess-reviewer.ts";
43
45
  import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
44
46
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
45
47
 
46
- // Compatibility export for existing relay consumers. The pure adapter owns the
47
- // fixed Pi process boundary and its locked-down argv.
48
- export { OPAQUE_PI_REVIEWER_ARGV as REVIEW_HOST_RELAY_PI_ARGV } from "./opaque-pi-reviewer-adapter.ts";
49
-
50
48
  export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
51
49
  "provider relay requires a gentle-ai build with the pi host relay surface";
52
50
 
@@ -56,19 +54,40 @@ export const REVIEW_HOST_RELAY_FAILURE = {
56
54
  SUBMISSION_CONTRACT_MISMATCH: "submission-contract-mismatch",
57
55
  MATERIALIZE_FAILED: "materialize-failed",
58
56
  EMPTY_PROMPT: "empty-prompt",
57
+ // gentle-pi#311 P2: these four names predate the in-process completion,
58
+ // when a killed or crashed child process was the only way a reviewer
59
+ // failed. They stay exactly as they are — including their "pi" wording —
60
+ // because other test lanes (the maintainer provider-relay matrix, the
61
+ // restart-parity harness) construct `ReviewHostRelayError` literals with
62
+ // them directly and are out of this change's scope (gentle-pi#311 P4).
63
+ // PI_TIMED_OUT and PI_FAILED are still reachable from production: a
64
+ // reviewer completion that exceeds its bound or fails for any reason not
65
+ // covered by a more specific REVIEWER_* code below reuses them, since the
66
+ // semantics (a deterministic bound; a generic failure) carried over
67
+ // unchanged. PI_LAUNCH_FAILED and PI_EMPTY_OUTPUT are no longer produced
68
+ // by this module — there is no child to fail to launch, and empty output
69
+ // is now REVIEWER_EMPTY_OUTPUT with different evidence — but the codes
70
+ // stay defined for the lanes above.
59
71
  PI_LAUNCH_FAILED: "pi-launch-failed",
60
72
  PI_FAILED: "pi-failed",
61
- // gentle-pi#367: a reviewer killed by the relay bound is not a crash. It
62
- // is the one failure class that a byte-identical relaunch cannot survive,
63
- // so it carries its own kind, its own elapsed/limit evidence, and its own
64
- // continuation instead of hiding inside `pi-failed`.
65
73
  PI_TIMED_OUT: "pi-timed-out",
66
74
  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.
75
+ // gentle-shell#1158 / #1136 (superseded by gentle-pi#311 P2): a caller-owned
76
+ // reviewer selection that cannot possibly complete — no model registry, or
77
+ // a routing entry with no configured model — is a configuration failure,
78
+ // refused typed before anything runs, never a mid-review transport
79
+ // mystery. The in-process path has no ambient default model to fall back
80
+ // to, so a missing selection is refused here rather than launched anyway.
71
81
  REVIEWER_CONFIG_INVALID: "reviewer-config-invalid",
82
+ // gentle-pi#311 P2 — in-process completion outcomes with no equivalent
83
+ // above (lib/inprocess-reviewer.ts#INPROCESS_REVIEWER_FAILURE).
84
+ REVIEWER_MODEL_NOT_FOUND: "reviewer-model-not-found",
85
+ REVIEWER_AUTH_UNAVAILABLE: "reviewer-auth-unavailable",
86
+ REVIEWER_THINKING_INVALID: "reviewer-thinking-invalid",
87
+ REVIEWER_TOOL_CALL: "reviewer-tool-call-attempted",
88
+ REVIEWER_EMPTY_OUTPUT: "reviewer-empty-output",
89
+ REVIEWER_OUTPUT_TOO_LARGE: "reviewer-output-too-large",
90
+ REVIEWER_ABORTED: "reviewer-aborted",
72
91
  SUBMISSION_REFUSED: "submission-refused",
73
92
  } as const;
74
93
  export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
@@ -86,10 +105,11 @@ export class ReviewHostRelayError extends Error {
86
105
  readonly exitCode: number | null;
87
106
  readonly stderr: string;
88
107
  readonly timedOut: boolean;
89
- // Wall time the killed or failed child actually consumed, and the bound it
90
- // was measured against. Both are null only when no child process ran.
91
- // Without them a transport failure cannot be told apart from a crash, which
92
- // is what forced the gentle-pi#367 reporter to measure the relay by hand.
108
+ // Wall time the aborted, timed-out, or failed reviewer completion actually
109
+ // consumed, and the bound it was measured against. Both are null only when
110
+ // no completion ran. Without them a transport failure cannot be told apart
111
+ // from a crash, which is what forced the gentle-pi#367 reporter to measure
112
+ // the relay by hand.
93
113
  readonly elapsedMs: number | null;
94
114
  readonly timeoutMs: number | null;
95
115
  // "none" until the submission invocation launches; a launched submission
@@ -99,9 +119,9 @@ export class ReviewHostRelayError extends Error {
99
119
  // "none" again: the provider states that the lens slot was not consumed
100
120
  // (gentle-pi#522 / #524).
101
121
  readonly mutationOutcome: "none" | "unknown";
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 }) {
122
+ /** What the in-process reviewer outcome carried as structured evidence (e.g. the completion's stopReason on an empty-output refusal). */
123
+ readonly reviewerEvidence: Record<string, unknown> | undefined;
124
+ constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown"; reviewerEvidence?: Record<string, unknown> }) {
105
125
  super(message);
106
126
  this.name = "ReviewHostRelayError";
107
127
  this.kind = kind;
@@ -199,6 +219,16 @@ export interface ReviewHostRelaySlot {
199
219
  readonly lens?: string;
200
220
  readonly order?: string;
201
221
  readonly subjectHash?: string;
222
+ /**
223
+ * Overrides the routing config key the reviewer selection resolves
224
+ * through (gentle-pi#311 P3). A lens slot leaves this unset and resolves
225
+ * through `lens` instead; a v9 host-mediated refuter/targeted-validator
226
+ * slot sets it to its fixed `review-refuter` / `review-validator` key,
227
+ * since those roles carry no per-slot lens identity.
228
+ */
229
+ readonly routingKey?: string;
230
+ /** The provider-declared collect input name (e.g. `provider_refuter`), carried for diagnostics only. */
231
+ readonly name?: string;
202
232
  }
203
233
 
204
234
  function argumentValue(input: ReviewCollectInputV3, name: string): string | undefined {
@@ -260,6 +290,40 @@ export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInpu
260
290
  }));
261
291
  }
262
292
 
293
+ // ---------------------------------------------------------------------------
294
+ // Host-mediated provider role slots (gentle-pi#311 P3; provider contract
295
+ // v9) — the same two role capture operations above, but rendered exactly
296
+ // like a lens materialize slot: binding tokens plus `--agent=pi
297
+ // --materialize=true` (never `--execute`) and a provider-owned submission
298
+ // descriptor. These slots run through the SAME relay machinery a lens slot
299
+ // does (`prepareReviewHostRelaySlot` / `submitReviewHostRelayPreparedResult`)
300
+ // — there is no second relay. The only role-specific parts are the fixed
301
+ // `routingKey` (there is no per-slot lens identity to read one from) and the
302
+ // input's own schema, which already names refuter vs targeted-validator in
303
+ // every refusal that carries the request.
304
+ // ---------------------------------------------------------------------------
305
+
306
+ const REVIEW_HOST_MEDIATED_ROLE_ROUTING_KEY: Record<ReviewProviderRoleVectorSlot["captureOperation"], "review-refuter" | "review-validator"> = {
307
+ [REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION.CAPTURE_REFUTER]: "review-refuter",
308
+ [REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION.CAPTURE_VALIDATION]: "review-validator",
309
+ };
310
+
311
+ export function isReviewHostMediatedRoleCollectInput(input: ReviewCollectInputV3): boolean {
312
+ return (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(input.captureOperation)
313
+ && argumentValue(input, "materialize") === "true"
314
+ && argumentValue(input, "agent") === "pi"
315
+ && input.submission !== undefined;
316
+ }
317
+
318
+ export function reviewHostMediatedRoleSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelaySlot[] {
319
+ return inputs.filter((input) => isReviewHostMediatedRoleCollectInput(input)).map((input) => ({
320
+ captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)),
321
+ submission: input.submission!,
322
+ routingKey: REVIEW_HOST_MEDIATED_ROLE_ROUTING_KEY[input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"]],
323
+ name: input.name,
324
+ }));
325
+ }
326
+
263
327
  // Resolves the provider-owned submission form into an executable binding.
264
328
  // Fails closed with a typed contract-mismatch error whenever the completing
265
329
  // form is absent or cannot bind exactly one artifact value; the relay never
@@ -303,25 +367,27 @@ export interface ReviewHostRelayRequest {
303
367
  readonly submission?: ReviewCaptureSubmissionV1;
304
368
  /** Absolute path; defaults to the verified package-local binary. */
305
369
  readonly gentleAiExecutable?: string;
306
- /** User-owned pi launcher; defaults to `pi` on PATH. */
307
- readonly piExecutable?: string;
308
370
  readonly environment?: NodeJS.ProcessEnv;
309
371
  readonly gentleAiTimeoutMs?: number;
310
372
  /**
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).
373
+ * The live model registry the reviewer completion resolves its selection
374
+ * and credentials through — structurally, pi's own `ModelRegistry`
375
+ * (`ctx.modelRegistry`). Absent is a typed refusal before materialize ever
376
+ * runs; the relay never falls back to a child process or an ambient
377
+ * default model (gentle-ai#4611; gentle-pi#311 P2).
315
378
  */
316
- readonly reviewerModel?: string;
379
+ readonly reviewerRegistry?: InProcessReviewerRegistry;
317
380
  /**
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.
381
+ * The lens's user-owned "provider/id" selection, read from the agent model
382
+ * routing config's `review-<lens>` entry. The relay never invents one: a
383
+ * routing entry with no configured model is refused typed before
384
+ * materialize, naming {@link ReviewHostRelayRequest.routingKey}.
323
385
  */
324
- readonly reviewerExtensionPaths?: readonly string[];
386
+ readonly selection?: string;
387
+ /** The routing entry's thinking label, forwarded verbatim to the completion. */
388
+ readonly thinking?: string;
389
+ /** Names the routing config key (e.g. "review-risk") in refusal messages; defaults to a generic label when absent. */
390
+ readonly routingKey?: string;
325
391
  /**
326
392
  * Overrides the reviewer bound entirely. Production leaves it unset and the
327
393
  * relay derives the bound from the materialized prompt bytes and
@@ -356,7 +422,7 @@ export type ReviewHostRelaySubmissionRunner = (prepared: ReviewHostRelayPrepared
356
422
  const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
357
423
 
358
424
  // ---------------------------------------------------------------------------
359
- // The reviewer subprocess bound (gentle-pi#367).
425
+ // The reviewer completion bound (gentle-pi#367).
360
426
  //
361
427
  // The previous bound was a single hardcoded 600_000 ms reachable only through
362
428
  // the test-injectable runner. A field-measured lens legitimately needed 478s
@@ -378,7 +444,7 @@ const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
378
444
  // established numeric-override shape (GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS,
379
445
  // GENTLE_PI_REVIEW_MAX_BUFFER_BYTES): a positive decimal, silently ignored
380
446
  // when malformed, and clamped to the same hard ceiling so no configuration can
381
- // turn a foreground FINALIZE into an unbounded child process.
447
+ // turn a foreground FINALIZE into an unbounded completion.
382
448
  // ---------------------------------------------------------------------------
383
449
 
384
450
  export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS";
@@ -398,11 +464,12 @@ export function resolveReviewHostRelayPiTimeoutMs(promptByteLength: number, envi
398
464
  return Math.min(scaled, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
399
465
  }
400
466
 
401
- // The reviewer ran out of time; it did not crash. The message states both
402
- // measurements and names the two things that can change the outcome, because
403
- // the one thing that cannot is relaunching the identical slot.
467
+ // The reviewer completion ran out of time; it did not crash. The message
468
+ // states both measurements and names the two things that can change the
469
+ // outcome, because the one thing that cannot is relaunching the identical
470
+ // slot.
404
471
  export function reviewHostRelayPiTimeoutMessage(elapsedMs: number, timeoutMs: number, promptByteLength: number): string {
405
- return `pi reviewer subprocess exceeded the relay bound: killed after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. `
472
+ return `the reviewer completion exceeded the relay bound: aborted after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. `
406
473
  + `Relaunching the same slot unchanged reaches the same wall. Raise ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV} above the reviewer's real wall time (ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}ms) or reduce the candidate scope so the materialized prompt is smaller.`;
407
474
  }
408
475
 
@@ -463,54 +530,48 @@ function collectGentleAiProcess(
463
530
  });
464
531
  }
465
532
 
466
- function relayPiTransportError(error: unknown, promptByteLength: number, piTimeoutMs: number): ReviewHostRelayError {
467
- if (!(error instanceof OpaquePiReviewerTransportError)) {
468
- return new ReviewHostRelayError(
469
- REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED,
470
- "pi",
471
- `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`,
472
- );
473
- }
474
- const details = {
475
- exitCode: error.exitCode,
476
- stderr: error.stderr.toString("utf8"),
477
- timedOut: error.timedOut,
478
- ...(error.elapsedMs === null ? {} : { elapsedMs: error.elapsedMs }),
479
- ...(error.timeoutMs === null ? {} : { timeoutMs: error.timeoutMs }),
480
- };
481
- if (
482
- error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT
483
- && error.elapsedMs !== null
484
- && error.timeoutMs !== null
485
- ) {
486
- return new ReviewHostRelayError(
487
- REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
488
- "pi",
489
- reviewHostRelayPiTimeoutMessage(error.elapsedMs, error.timeoutMs, promptByteLength),
490
- { ...details, timedOut: true, elapsedMs: error.elapsedMs, timeoutMs: error.timeoutMs },
491
- );
492
- }
493
- if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) {
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 }) });
506
- }
507
- if (
508
- error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
509
- || error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED
510
- ) {
511
- return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error.message}`, details);
533
+ // Maps one in-process reviewer refusal onto a typed relay error. TIMED_OUT
534
+ // reuses PI_TIMED_OUT and PROVIDER_FAILED reuses PI_FAILED (identical
535
+ // semantics: a deterministic bound; a generic failure bucket); every other
536
+ // code gets its own REVIEWER_* kind with no prior equivalent.
537
+ function relayReviewerRefusalError(
538
+ outcome: Extract<InProcessReviewerOutcome, { kind: "refused" }>,
539
+ timing: { elapsedMs: number; timeoutMs: number },
540
+ promptByteLength: number,
541
+ ): ReviewHostRelayError {
542
+ const details = { ...timing, ...(outcome.evidence === undefined ? {} : { reviewerEvidence: outcome.evidence }) };
543
+ const code: InProcessReviewerFailureCode = outcome.code;
544
+ switch (code) {
545
+ case INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND:
546
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_MODEL_NOT_FOUND, "pi", outcome.message, details);
547
+ case INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE:
548
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_AUTH_UNAVAILABLE, "pi", outcome.message, details);
549
+ case INPROCESS_REVIEWER_FAILURE.THINKING_INVALID:
550
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_THINKING_INVALID, "pi", outcome.message, details);
551
+ case INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED:
552
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_TOOL_CALL, "pi", outcome.message, details);
553
+ case INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT:
554
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_EMPTY_OUTPUT, "pi", outcome.message, details);
555
+ case INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE:
556
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_OUTPUT_TOO_LARGE, "pi", outcome.message, details);
557
+ case INPROCESS_REVIEWER_FAILURE.ABORTED:
558
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_ABORTED, "pi", outcome.message, details);
559
+ case INPROCESS_REVIEWER_FAILURE.SELECTION_INVALID:
560
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi", outcome.message, details);
561
+ case INPROCESS_REVIEWER_FAILURE.TIMED_OUT:
562
+ return new ReviewHostRelayError(
563
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
564
+ "pi",
565
+ reviewHostRelayPiTimeoutMessage(timing.elapsedMs, timing.timeoutMs, promptByteLength),
566
+ { ...details, timedOut: true },
567
+ );
568
+ case INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED:
569
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", outcome.message, details);
570
+ default: {
571
+ const unreachable: never = code;
572
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", `unrecognized reviewer refusal code ${String(unreachable)}`, details);
573
+ }
512
574
  }
513
- return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", details);
514
575
  }
515
576
 
516
577
  function assertTokens(name: string, tokens: readonly string[]): void {
@@ -520,66 +581,52 @@ function assertTokens(name: string, tokens: readonly string[]): void {
520
581
  }
521
582
  }
522
583
 
523
- export const REVIEW_HOST_RELAY_EXTENSIONS_ENV = "GENTLE_PI_REVIEW_RELAY_EXTENSIONS";
524
-
525
584
  /**
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.
585
+ * Validates the caller-owned reviewer selection before any process launches: a
586
+ * missing model registry or a routing entry with no configured model is a
587
+ * typed refusal, never a mid-review transport failure and never a fallback to
588
+ * an ambient default model (gentle-ai#4611; gentle-pi#311 P2, superseding
589
+ * gentle-shell#1158 / #1136's child-process launch configuration).
530
590
  */
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
- }
591
+ function validateReviewerSelectionConfiguration(request: ReviewHostRelayRequest): { reviewerRegistry: InProcessReviewerRegistry; selection: string; thinking?: string; routingKey: string } {
592
+ const routingKey = typeof request.routingKey === "string" && request.routingKey.length > 0 ? request.routingKey : "review capture";
593
+ if (request.reviewerRegistry === undefined) {
594
+ throw new ReviewHostRelayError(
595
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID,
596
+ "pi",
597
+ `Pi host relay reviewer launch configuration is invalid: no model registry is available to complete ${routingKey}`,
598
+ );
551
599
  }
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
- }
600
+ if (typeof request.selection !== "string" || request.selection.length === 0) {
601
+ throw new ReviewHostRelayError(
602
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID,
603
+ "pi",
604
+ `Pi host relay reviewer launch configuration is invalid: no model is configured for ${routingKey}; assign it a model in the agent model routing config`,
605
+ );
564
606
  }
565
607
  return {
566
- ...(request.reviewerModel === undefined ? {} : { reviewerModel: request.reviewerModel }),
567
- ...(request.reviewerExtensionPaths === undefined || request.reviewerExtensionPaths.length === 0 ? {} : { reviewerExtensionPaths: Object.freeze([...request.reviewerExtensionPaths]) }),
608
+ reviewerRegistry: request.reviewerRegistry,
609
+ selection: request.selection,
610
+ ...(request.thinking === undefined ? {} : { thinking: request.thinking }),
611
+ routingKey,
568
612
  };
569
613
  }
570
614
 
571
615
  function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): ReviewHostRelayRequest {
616
+ // Structural malformations (empty/blank tokens, a non-absolute executable)
617
+ // are TypeErrors — a programmer mistake, never a typed relay refusal — and
618
+ // are checked before the business-level reviewer selection below.
572
619
  assertTokens("capture", request.captureArgumentTokens);
620
+ const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary();
621
+ if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
573
622
  // Caller-owned reviewer selection is validated before any process launches:
574
623
  // a broken configuration is a typed refusal, never a mid-review transport
575
- // failure (gentle-shell#1158 / #1136).
576
- const reviewerLaunch = validateReviewerLaunchConfiguration(request);
624
+ // failure (gentle-pi#311 P2).
625
+ const reviewerSelection = validateReviewerSelectionConfiguration(request);
577
626
  // The completing form is validated before any process launches: a materialize
578
627
  // slot without a provider-owned submission is a typed contract mismatch,
579
628
  // never a synthesized invocation.
580
629
  resolveReviewHostRelaySubmission(request.submission);
581
- const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary();
582
- if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
583
630
  const environment = Object.freeze({ ...(request.environment ?? process.env) }) as NodeJS.ProcessEnv;
584
631
  const submission = request.submission === undefined ? undefined : Object.freeze({
585
632
  operationToken: request.submission.operationToken,
@@ -590,7 +637,7 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
590
637
  ...request,
591
638
  captureArgumentTokens: Object.freeze([...request.captureArgumentTokens]),
592
639
  ...(submission === undefined ? {} : { submission }),
593
- ...reviewerLaunch,
640
+ ...reviewerSelection,
594
641
  gentleAiExecutable,
595
642
  environment,
596
643
  gentleAiTimeoutMs: request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS,
@@ -599,13 +646,14 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
599
646
  }
600
647
 
601
648
  /**
602
- * Materializes one provider-bound reviewer prompt and runs its opaque Pi
603
- * subprocess. It does not submit anything, so independent reviewer work can
604
- * finish before the caller performs provider-ordered admission.
649
+ * Materializes one provider-bound reviewer prompt and runs it through one
650
+ * in-process reviewer completion. It does not submit anything, so independent
651
+ * reviewer work can finish before the caller performs provider-ordered
652
+ * admission.
605
653
  */
606
654
  export async function prepareReviewHostRelaySlot(
607
655
  request: ReviewHostRelayRequest,
608
- reviewer: typeof runOpaquePiReviewer = runOpaquePiReviewer,
656
+ runReviewer: typeof runInProcessReviewer = runInProcessReviewer,
609
657
  ): Promise<ReviewHostRelayPreparedResult> {
610
658
  // Copy mutable transport configuration before the first async boundary. The
611
659
  // supplied AbortSignal intentionally stays live across materialize, reviewer,
@@ -614,9 +662,14 @@ export async function prepareReviewHostRelaySlot(
614
662
 
615
663
  // The provider materializes the opaque prompt and detects whether this relay
616
664
  // surface is available. No version sniffing or prompt reconstruction occurs.
665
+ // The materialize subcommand is the provider's own submission operation
666
+ // token (validated present above): "capture-result" for a lens slot,
667
+ // "capture-refuter" or "capture-validation" for a v9 host-mediated role
668
+ // slot — the provider always names the same operation for both the
669
+ // materialize and the submit leg of one slot.
617
670
  let materialized: ProcessCapture;
618
671
  try {
619
- materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review", "capture-result", ...preparedRequest.captureArgumentTokens], {
672
+ materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review", preparedRequest.submission!.operationToken, ...preparedRequest.captureArgumentTokens], {
620
673
  cwd: preparedRequest.targetCwd!,
621
674
  env: { ...preparedRequest.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT },
622
675
  timeoutMs: preparedRequest.gentleAiTimeoutMs!,
@@ -663,27 +716,43 @@ export async function prepareReviewHostRelaySlot(
663
716
  // both the user-owned environment override and the scale-derived bound.
664
717
  const piTimeoutMs = preparedRequest.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, preparedRequest.environment);
665
718
 
666
- // The pure adapter owns the fresh isolated Pi process. Its input and output
667
- // are opaque bytes; this coordinator only maps transport failures.
668
- const launchArguments = reviewerLaunchArguments(preparedRequest.reviewerModel, preparedRequest.reviewerExtensionPaths);
669
- let piResult: OpaquePiReviewerResult;
719
+ // The completion runs in-process through the live model registry: no
720
+ // child, no extension allowlist, no ambient default model. Elapsed time is
721
+ // measured here (there is no killed process to read it from) so a timed-out
722
+ // or aborted refusal still carries the same elapsed/limit evidence a killed
723
+ // child used to.
724
+ const startedAt = Date.now();
725
+ let outcome: InProcessReviewerOutcome;
670
726
  try {
671
- piResult = await reviewer(promptBytes, {
672
- ...(preparedRequest.piExecutable === undefined ? {} : { piExecutable: preparedRequest.piExecutable }),
673
- environment: preparedRequest.environment,
674
- timeoutMs: piTimeoutMs,
675
- ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
676
- ...(launchArguments.length === 0 ? {} : { extraArguments: launchArguments }),
677
- });
727
+ outcome = await runReviewer(
728
+ {
729
+ selection: preparedRequest.selection!,
730
+ ...(preparedRequest.thinking === undefined ? {} : { thinking: preparedRequest.thinking }),
731
+ prompt: promptBytes,
732
+ timeoutMs: piTimeoutMs,
733
+ ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
734
+ routingKey: preparedRequest.routingKey!,
735
+ },
736
+ { registry: preparedRequest.reviewerRegistry!, complete: completeSimple },
737
+ );
678
738
  } catch (error) {
679
- throw relayPiTransportError(error, promptBytes.length, piTimeoutMs);
739
+ throw new ReviewHostRelayError(
740
+ REVIEW_HOST_RELAY_FAILURE.PI_FAILED,
741
+ "pi",
742
+ `the reviewer completion could not run: ${error instanceof Error ? error.message : String(error)}`,
743
+ { elapsedMs: Date.now() - startedAt, timeoutMs: piTimeoutMs },
744
+ );
745
+ }
746
+ if (outcome.kind === "refused") {
747
+ throw relayReviewerRefusalError(outcome, { elapsedMs: Date.now() - startedAt, timeoutMs: piTimeoutMs }, promptBytes.length);
680
748
  }
749
+ const resultBytes = Buffer.from(outcome.text, "utf8");
681
750
  const prepared = Object.freeze({
682
751
  request: preparedRequest,
683
752
  promptByteLength: promptBytes.length,
684
- resultByteLength: piResult.stdoutByteLength,
753
+ resultByteLength: resultBytes.length,
685
754
  });
686
- preparedResultBytes.set(prepared, Buffer.from(piResult.stdout));
755
+ preparedResultBytes.set(prepared, resultBytes);
687
756
  return prepared;
688
757
  }
689
758
 
@@ -777,9 +846,15 @@ export async function submitReviewHostRelayPreparedResult(prepared: ReviewHostRe
777
846
  }
778
847
 
779
848
  /**
780
- * Compatibility one-binding path: materialize → opaque Pi adapter → submit.
781
- * It preserves the established API and its typed failure behavior exactly.
849
+ * Compatibility one-binding path: materialize → in-process reviewer
850
+ * completion → submit. It preserves the established API and its typed
851
+ * failure behavior exactly. `runReviewer` is the same test seam
852
+ * {@link prepareReviewHostRelaySlot} takes, threaded through so a caller never
853
+ * needs to call the two-step path just to inject a fake completion.
782
854
  */
783
- export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise<ReviewHostRelayResult> {
784
- return await submitReviewHostRelayPreparedResult(await prepareReviewHostRelaySlot(request));
855
+ export async function runReviewHostRelaySlot(
856
+ request: ReviewHostRelayRequest,
857
+ runReviewer: typeof runInProcessReviewer = runInProcessReviewer,
858
+ ): Promise<ReviewHostRelayResult> {
859
+ return await submitReviewHostRelayPreparedResult(await prepareReviewHostRelaySlot(request, runReviewer));
785
860
  }