gentle-pi 3.2.0 → 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 (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -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,24 +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
38
  import {
34
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
35
- OpaquePiReviewerTransportError,
36
- runOpaquePiReviewer,
37
- type OpaquePiReviewerResult,
38
- } 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";
39
45
  import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
40
46
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
41
47
 
42
- // Compatibility export for existing relay consumers. The pure adapter owns the
43
- // fixed Pi process boundary and its locked-down argv.
44
- export { OPAQUE_PI_REVIEWER_ARGV as REVIEW_HOST_RELAY_PI_ARGV } from "./opaque-pi-reviewer-adapter.ts";
45
-
46
48
  export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
47
49
  "provider relay requires a gentle-ai build with the pi host relay surface";
48
50
 
@@ -52,14 +54,40 @@ export const REVIEW_HOST_RELAY_FAILURE = {
52
54
  SUBMISSION_CONTRACT_MISMATCH: "submission-contract-mismatch",
53
55
  MATERIALIZE_FAILED: "materialize-failed",
54
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.
55
71
  PI_LAUNCH_FAILED: "pi-launch-failed",
56
72
  PI_FAILED: "pi-failed",
57
- // gentle-pi#367: a reviewer killed by the relay bound is not a crash. It
58
- // is the one failure class that a byte-identical relaunch cannot survive,
59
- // so it carries its own kind, its own elapsed/limit evidence, and its own
60
- // continuation instead of hiding inside `pi-failed`.
61
73
  PI_TIMED_OUT: "pi-timed-out",
62
74
  PI_EMPTY_OUTPUT: "pi-empty-output",
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.
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",
63
91
  SUBMISSION_REFUSED: "submission-refused",
64
92
  } as const;
65
93
  export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
@@ -77,10 +105,11 @@ export class ReviewHostRelayError extends Error {
77
105
  readonly exitCode: number | null;
78
106
  readonly stderr: string;
79
107
  readonly timedOut: boolean;
80
- // Wall time the killed or failed child actually consumed, and the bound it
81
- // was measured against. Both are null only when no child process ran.
82
- // Without them a transport failure cannot be told apart from a crash, which
83
- // 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.
84
113
  readonly elapsedMs: number | null;
85
114
  readonly timeoutMs: number | null;
86
115
  // "none" until the submission invocation launches; a launched submission
@@ -90,7 +119,9 @@ export class ReviewHostRelayError extends Error {
90
119
  // "none" again: the provider states that the lens slot was not consumed
91
120
  // (gentle-pi#522 / #524).
92
121
  readonly mutationOutcome: "none" | "unknown";
93
- constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown" }) {
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> }) {
94
125
  super(message);
95
126
  this.name = "ReviewHostRelayError";
96
127
  this.kind = kind;
@@ -101,6 +132,7 @@ export class ReviewHostRelayError extends Error {
101
132
  this.elapsedMs = details?.elapsedMs ?? null;
102
133
  this.timeoutMs = details?.timeoutMs ?? null;
103
134
  this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none");
135
+ this.reviewerEvidence = details?.reviewerEvidence;
104
136
  }
105
137
  }
106
138
 
@@ -187,6 +219,16 @@ export interface ReviewHostRelaySlot {
187
219
  readonly lens?: string;
188
220
  readonly order?: string;
189
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;
190
232
  }
191
233
 
192
234
  function argumentValue(input: ReviewCollectInputV3, name: string): string | undefined {
@@ -248,6 +290,40 @@ export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInpu
248
290
  }));
249
291
  }
250
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
+
251
327
  // Resolves the provider-owned submission form into an executable binding.
252
328
  // Fails closed with a typed contract-mismatch error whenever the completing
253
329
  // form is absent or cannot bind exactly one artifact value; the relay never
@@ -291,10 +367,27 @@ export interface ReviewHostRelayRequest {
291
367
  readonly submission?: ReviewCaptureSubmissionV1;
292
368
  /** Absolute path; defaults to the verified package-local binary. */
293
369
  readonly gentleAiExecutable?: string;
294
- /** User-owned pi launcher; defaults to `pi` on PATH. */
295
- readonly piExecutable?: string;
296
370
  readonly environment?: NodeJS.ProcessEnv;
297
371
  readonly gentleAiTimeoutMs?: number;
372
+ /**
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).
378
+ */
379
+ readonly reviewerRegistry?: InProcessReviewerRegistry;
380
+ /**
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}.
385
+ */
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;
298
391
  /**
299
392
  * Overrides the reviewer bound entirely. Production leaves it unset and the
300
393
  * relay derives the bound from the materialized prompt bytes and
@@ -329,7 +422,7 @@ export type ReviewHostRelaySubmissionRunner = (prepared: ReviewHostRelayPrepared
329
422
  const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
330
423
 
331
424
  // ---------------------------------------------------------------------------
332
- // The reviewer subprocess bound (gentle-pi#367).
425
+ // The reviewer completion bound (gentle-pi#367).
333
426
  //
334
427
  // The previous bound was a single hardcoded 600_000 ms reachable only through
335
428
  // the test-injectable runner. A field-measured lens legitimately needed 478s
@@ -351,7 +444,7 @@ const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
351
444
  // established numeric-override shape (GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS,
352
445
  // GENTLE_PI_REVIEW_MAX_BUFFER_BYTES): a positive decimal, silently ignored
353
446
  // when malformed, and clamped to the same hard ceiling so no configuration can
354
- // turn a foreground FINALIZE into an unbounded child process.
447
+ // turn a foreground FINALIZE into an unbounded completion.
355
448
  // ---------------------------------------------------------------------------
356
449
 
357
450
  export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS";
@@ -371,11 +464,12 @@ export function resolveReviewHostRelayPiTimeoutMs(promptByteLength: number, envi
371
464
  return Math.min(scaled, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
372
465
  }
373
466
 
374
- // The reviewer ran out of time; it did not crash. The message states both
375
- // measurements and names the two things that can change the outcome, because
376
- // 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.
377
471
  export function reviewHostRelayPiTimeoutMessage(elapsedMs: number, timeoutMs: number, promptByteLength: number): string {
378
- 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. `
379
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.`;
380
474
  }
381
475
 
@@ -436,43 +530,48 @@ function collectGentleAiProcess(
436
530
  });
437
531
  }
438
532
 
439
- function relayPiTransportError(error: unknown, promptByteLength: number, piTimeoutMs: number): ReviewHostRelayError {
440
- if (!(error instanceof OpaquePiReviewerTransportError)) {
441
- return new ReviewHostRelayError(
442
- REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED,
443
- "pi",
444
- `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`,
445
- );
446
- }
447
- const details = {
448
- exitCode: error.exitCode,
449
- stderr: error.stderr.toString("utf8"),
450
- timedOut: error.timedOut,
451
- ...(error.elapsedMs === null ? {} : { elapsedMs: error.elapsedMs }),
452
- ...(error.timeoutMs === null ? {} : { timeoutMs: error.timeoutMs }),
453
- };
454
- if (
455
- error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT
456
- && error.elapsedMs !== null
457
- && error.timeoutMs !== null
458
- ) {
459
- return new ReviewHostRelayError(
460
- REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
461
- "pi",
462
- reviewHostRelayPiTimeoutMessage(error.elapsedMs, error.timeoutMs, promptByteLength),
463
- { ...details, timedOut: true, elapsedMs: error.elapsedMs, timeoutMs: error.timeoutMs },
464
- );
465
- }
466
- if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) {
467
- return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", details);
468
- }
469
- if (
470
- error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
471
- || error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED
472
- ) {
473
- 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
+ }
474
574
  }
475
- return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", details);
476
575
  }
477
576
 
478
577
  function assertTokens(name: string, tokens: readonly string[]): void {
@@ -482,14 +581,52 @@ function assertTokens(name: string, tokens: readonly string[]): void {
482
581
  }
483
582
  }
484
583
 
584
+ /**
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).
590
+ */
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
+ );
599
+ }
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
+ );
606
+ }
607
+ return {
608
+ reviewerRegistry: request.reviewerRegistry,
609
+ selection: request.selection,
610
+ ...(request.thinking === undefined ? {} : { thinking: request.thinking }),
611
+ routingKey,
612
+ };
613
+ }
614
+
485
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.
486
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");
622
+ // Caller-owned reviewer selection is validated before any process launches:
623
+ // a broken configuration is a typed refusal, never a mid-review transport
624
+ // failure (gentle-pi#311 P2).
625
+ const reviewerSelection = validateReviewerSelectionConfiguration(request);
487
626
  // The completing form is validated before any process launches: a materialize
488
627
  // slot without a provider-owned submission is a typed contract mismatch,
489
628
  // never a synthesized invocation.
490
629
  resolveReviewHostRelaySubmission(request.submission);
491
- const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary();
492
- if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
493
630
  const environment = Object.freeze({ ...(request.environment ?? process.env) }) as NodeJS.ProcessEnv;
494
631
  const submission = request.submission === undefined ? undefined : Object.freeze({
495
632
  operationToken: request.submission.operationToken,
@@ -500,6 +637,7 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
500
637
  ...request,
501
638
  captureArgumentTokens: Object.freeze([...request.captureArgumentTokens]),
502
639
  ...(submission === undefined ? {} : { submission }),
640
+ ...reviewerSelection,
503
641
  gentleAiExecutable,
504
642
  environment,
505
643
  gentleAiTimeoutMs: request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS,
@@ -508,13 +646,14 @@ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): Review
508
646
  }
509
647
 
510
648
  /**
511
- * Materializes one provider-bound reviewer prompt and runs its opaque Pi
512
- * subprocess. It does not submit anything, so independent reviewer work can
513
- * 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.
514
653
  */
515
654
  export async function prepareReviewHostRelaySlot(
516
655
  request: ReviewHostRelayRequest,
517
- reviewer: typeof runOpaquePiReviewer = runOpaquePiReviewer,
656
+ runReviewer: typeof runInProcessReviewer = runInProcessReviewer,
518
657
  ): Promise<ReviewHostRelayPreparedResult> {
519
658
  // Copy mutable transport configuration before the first async boundary. The
520
659
  // supplied AbortSignal intentionally stays live across materialize, reviewer,
@@ -523,9 +662,14 @@ export async function prepareReviewHostRelaySlot(
523
662
 
524
663
  // The provider materializes the opaque prompt and detects whether this relay
525
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.
526
670
  let materialized: ProcessCapture;
527
671
  try {
528
- materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review", "capture-result", ...preparedRequest.captureArgumentTokens], {
672
+ materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review", preparedRequest.submission!.operationToken, ...preparedRequest.captureArgumentTokens], {
529
673
  cwd: preparedRequest.targetCwd!,
530
674
  env: { ...preparedRequest.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT },
531
675
  timeoutMs: preparedRequest.gentleAiTimeoutMs!,
@@ -572,25 +716,43 @@ export async function prepareReviewHostRelaySlot(
572
716
  // both the user-owned environment override and the scale-derived bound.
573
717
  const piTimeoutMs = preparedRequest.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, preparedRequest.environment);
574
718
 
575
- // The pure adapter owns the fresh isolated Pi process. Its input and output
576
- // are opaque bytes; this coordinator only maps transport failures.
577
- 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;
578
726
  try {
579
- piResult = await reviewer(promptBytes, {
580
- ...(preparedRequest.piExecutable === undefined ? {} : { piExecutable: preparedRequest.piExecutable }),
581
- environment: preparedRequest.environment,
582
- timeoutMs: piTimeoutMs,
583
- ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
584
- });
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
+ );
585
738
  } catch (error) {
586
- 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);
587
748
  }
749
+ const resultBytes = Buffer.from(outcome.text, "utf8");
588
750
  const prepared = Object.freeze({
589
751
  request: preparedRequest,
590
752
  promptByteLength: promptBytes.length,
591
- resultByteLength: piResult.stdoutByteLength,
753
+ resultByteLength: resultBytes.length,
592
754
  });
593
- preparedResultBytes.set(prepared, Buffer.from(piResult.stdout));
755
+ preparedResultBytes.set(prepared, resultBytes);
594
756
  return prepared;
595
757
  }
596
758
 
@@ -684,9 +846,15 @@ export async function submitReviewHostRelayPreparedResult(prepared: ReviewHostRe
684
846
  }
685
847
 
686
848
  /**
687
- * Compatibility one-binding path: materialize → opaque Pi adapter → submit.
688
- * 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.
689
854
  */
690
- export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise<ReviewHostRelayResult> {
691
- 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));
692
860
  }