@dieulc/workflow 0.3.0 → 0.3.3

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/README.md CHANGED
@@ -190,6 +190,46 @@ The reviewer is an in-process child session created with `createAgentSession`, a
190
190
 
191
191
  An environment marker (`PI_WORKFLOW_REVIEW_CHILD`) is set only for the duration of session creation and restored immediately — the child runs in the same process, so leaking it would affect a later `/reload`.
192
192
 
193
+ ### Provider seeding
194
+
195
+ The isolation above has one consequence: a provider that exists **only** because an extension registered it in the parent session (`pi.registerProvider(...)`) does not exist in the reviewer's freshly created runtime — every review round would fail with `No API key found for <provider>.` even though `/login` had stored a valid credential.
196
+
197
+ Pi composes providers from three sources: the **built-in catalog**, the **static `~/.pi/agent/models.json`**, and **extension registrations** on the parent runtime. Only the last one is parent-only. Review Mode bridges it with a **present-probe**, not layer names (`review-providers.ts`):
198
+
199
+ 1. **Probe the parent** for a provider-registration surface: the session's model registry (`ctx.modelRegistry`) first, then the underlying runtime (`ctx.modelRuntime`). Both expose the same three accessors; a future Pi surface is picked up by adding one entry to the probe list.
200
+ 2. **Compare with the child.** Providers the child already composes are skipped. The built-in catalog and `~/.pi/agent/models.json` are loaded by *every* runtime by construction, so for them the seeder is a guaranteed no-op — and any future globally-composed provider source is skipped automatically, with zero layer-name drift.
201
+ 3. **Seed the difference, native-first.** For every provider the parent knows and the child does not, the seeder registers the parent's provider *object* when there is one, otherwise its config — each in its own try/catch, so a broken unrelated provider can never block a review. Idempotent: already-seeded ids are skipped on a second pass.
202
+ 4. **Preflight auth, provider-agnostically.** After seeding and before the session is created, the reviewer runtime is asked `hasConfiguredAuth(provider) || checkAuth(provider)` for the *reviewer model's own* provider. On absence the review fails fast with the provider name, the seed report, and the universal escape hatch below — never the bare mid-review error.
203
+
204
+ Every bridged method is feature-detected: on a Pi version without a given accessor the seeder degrades to "nothing to seed" and the preflight reports the escape hatch instead of crashing.
205
+
206
+ **The universal escape hatch (works for any provider, even without this fix):** `~/.pi/agent/models.json` is loaded by every runtime, including the reviewer's:
207
+
208
+ ```json
209
+ {
210
+ "providers": {
211
+ "<provider>": {
212
+ "baseUrl": "https://…/v1",
213
+ "apiKey": "$MY_API_KEY",
214
+ "api": "openai-completions",
215
+ "models": [
216
+ {
217
+ "id": "<model-id>",
218
+ "name": "<display name>",
219
+ "reasoning": true,
220
+ "input": ["text"],
221
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
222
+ "contextWindow": 200000,
223
+ "maxTokens": 65536
224
+ }
225
+ ]
226
+ }
227
+ }
228
+ }
229
+ ```
230
+
231
+ A stored credential (`/login <provider>` → `auth.json`) always wins over the `apiKey` template, so `$MY_API_KEY` needs no environment variable while you are logged in. Extensions that register the same provider at runtime keep working: their live model list replaces the static one in the parent, so nothing regresses there.
232
+
193
233
  ### Configuration
194
234
 
195
235
  Settings live under `workflow.reviewMode` in `~/.pi/agent/settings.json` (global) or `<cwd>/.pi/settings.json` (project). Project values win field-by-field over global. Writing them preserves every other key in the file.
@@ -222,7 +262,7 @@ Review Mode settings live under `workflow.reviewMode`:
222
262
 
223
263
  ## How it works
224
264
 
225
- See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gating, todo/plan extraction, plan hashing), `roles.ts` (role registry, one model per role), and the Review Mode modules: `review.ts` (config, prompt, state, transcript, child session — no runtime imports, so it is unit-testable), `review-tools.ts` (the reviewer's tool set), `review-runtime.ts` (round orchestration), `review-ui.ts` (the main-transcript entries: line buffer, formatters, renderers). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
265
+ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gating, todo/plan extraction, plan hashing), `roles.ts` (role registry, one model per role), and the Review Mode modules: `review.ts` (config, prompt, state, transcript, child session — no runtime imports, so it is unit-testable), `review-providers.ts` (parent→reviewer provider seeding and the provider-agnostic auth preflight), `review-tools.ts` (the reviewer's tool set), `review-runtime.ts` (round orchestration), `review-ui.ts` (the main-transcript entries: line buffer, formatters, renderers). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
226
266
 
227
267
  ### Todo protocol (industry-aligned)
228
268
 
@@ -232,6 +272,34 @@ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gat
232
272
  - **Reminder cadence** (Claude Code-style throttle): a hidden `[TODO LIST]` context block is injected only when `turnsSinceLastTodoWrite ≥ TURNS_SINCE_WRITE` **and** `turnsSinceLastReminder ≥ TURNS_BETWEEN_REMINDERS` (`=3`). Unresolved `[DONE:…]` refs from the previous turn bypass the throttle once so the model learns the right numbers. A one-line `[TODO …]` footer is appended to the first successful `edit`/`write`/`bash` result of each turn.
233
273
  - **Stale notice (no forced turns)**: if a run mutated files but recorded no todo progress, the user gets one `⚠` status marker + a `ctx.ui.notify` (30 s dedupe); plan-file `- [x]` ticks are reconciled at `agent_end` in all modes, skipped when the plan content is unchanged (path+hash guard). No auto-completion — the list is never marked done based on intent. Legacy lists that already contain duplicate rows are not auto-merged; run `/todos clear` (or approve a new plan) to reset.
234
274
 
275
+ ## Troubleshooting
276
+
277
+ ### `No API key found for <provider>.` in a Review Mode round
278
+
279
+ The transcript shows `Review Mode: … (could not prompt the reviewer: No API key found for <provider>.)`. This means the **reviewer session** could not compose auth for the provider — your login itself is fine (Plan/Build keep working off the same `auth.json`).
280
+
281
+ The reviewer runs in an isolated session without ambient extensions by design (see [Provider seeding](#provider-seeding)), so a provider registered only by an extension in the parent session is invisible to it unless seeded, and its auth must resolve on its own. Since 0.3.2 the provider is seeded automatically and a missing auth fails fast with a message naming the provider, what the seed found, and the escape hatch.
282
+
283
+ Fixes, in order of preference:
284
+
285
+ 1. **`/login <provider>`** — a stored credential in `~/.pi/agent/auth.json` is read by every runtime, including the reviewer's. No environment variable needed.
286
+ 2. **Static provider in `~/.pi/agent/models.json`** — the universal workaround (works even on an installed version without the seeding fix): add the provider under `"providers"` with `baseUrl`, `apiKey: "$MY_API_KEY"`, `api` and its `models` (template in [Provider seeding](#provider-seeding)).
287
+ 3. **`export MY_API_KEY=…`** — only needed when the provider config references `"$MY_API_KEY"` and no credential is stored.
288
+
289
+ After editing `models.json`, run `/reload` or restart `pi` so every runtime picks it up.
290
+
291
+ ### `the reviewer's provider failed after N retry attempt(s): …` in a Review Mode round
292
+
293
+ The warning reads `review skipped (the reviewer's provider failed after 3 retry attempt(s): 429 Too Many Requests …)` and the reviewer pane shows the provider's error text live. This is the reviewer's **model provider** failing mid-run — its API returned errors until Pi's auto-retry budget (`settings.retry.maxRetries`, default 3) ran out, so the run settled without ever reaching `review_submit_plan`. It is **not** a Review Mode fault and nothing was written; the author's plan is handed over unchanged.
294
+
295
+ Earlier versions mislabeled this as `the reviewer finished without submitting a plan — it has no write path…`; since 0.3.3 the real provider error is captured from the reviewer session and reported here, in the pane, and via `/review-status` (the old message now appears only when the reviewer genuinely settled without calling `review_submit_plan`).
296
+
297
+ What to do:
298
+
299
+ 1. **Check the provider** — the truncated message is the provider's own reply (rate limit, quota, overloaded, auth). Retry the review with `/review` once the provider recovers; a review that dies this way can simply be re-run.
300
+ 2. **A long review shares one API key with the parent** — planner + reviewer often hit the same key, so a long review can exhaust rate limits. Stagger heavy Plan Mode work or raise the provider's quota.
301
+ 3. **Switch the reviewer model** if the failures recur — `/role set reviewer <provider/model>` (see [Review Mode](#review-mode)); pick a model whose provider is currently healthy.
302
+
235
303
  ## Development
236
304
 
237
305
  ```bash
package/index.ts CHANGED
@@ -132,6 +132,7 @@ import {
132
132
  type ReviewTranscriptOp,
133
133
  } from "./review.ts";
134
134
  import { createReviewRuntime, type ReviewRuntime } from "./review-runtime.ts";
135
+ import { findParentProviderSurface } from "./review-providers.ts";
135
136
  import {
136
137
  createReviewBandGate,
137
138
  createReviewLineBuffer,
@@ -891,6 +892,13 @@ export default function workflowExtension(pi: ExtensionAPI) {
891
892
  },
892
893
  findModel: (provider: string, id: string) =>
893
894
  (currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
895
+ // Structural probe of the live parent context, evaluated at reviewer
896
+ // session-creation time. Providers the parent registered (e.g. via an
897
+ // extension) that the isolated reviewer runtime does not compose yet
898
+ // are seeded onto it before the session exists; when the probe finds
899
+ // no surface, nothing is seeded and the preflight reports the escape
900
+ // hatch. No-op for providers every runtime composes by construction.
901
+ parentProviders: () => findParentProviderSurface(currentSessionCtx),
894
902
  planPath: () => currentPlanFile,
895
903
  readPlan: () => {
896
904
  const p = currentPlanFile;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
@@ -29,6 +29,7 @@
29
29
  "roles.ts",
30
30
  "permissions.ts",
31
31
  "review.ts",
32
+ "review-providers.ts",
32
33
  "review-tools.ts",
33
34
  "review-runtime.ts",
34
35
  "review-ui.ts",
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Provider seeding for Review Mode's reviewer session.
3
+ *
4
+ * Why this exists: `createReviewSession()` builds the reviewer as an isolated
5
+ * child (`noExtensions: true`, its own `ModelRuntime`). That isolation is
6
+ * deliberate — the reviewer must not inherit ambient extensions — but it has
7
+ * one consequence: a provider that is registered ONLY by an extension in the
8
+ * parent session (e.g. `pi.registerProvider("synthetic", …)`) does not exist in
9
+ * the child's runtime, so the child's prompt preflight throws
10
+ * `No API key found for <provider>.` even though `auth.json` holds a valid
11
+ * credential for it.
12
+ *
13
+ * The fix is a *present-probe*, not layer names: the seeder asks the child
14
+ * runtime what it already composes (`getRegisteredProviderIds()`), asks the
15
+ * parent surface for the same list, and registers only the difference —
16
+ * native provider objects first, plain provider configs otherwise. Providers
17
+ * the child already composes by construction (Pi's provider catalog and
18
+ * `~/.pi/agent/models.json` — every `ModelRuntime` loads both) never appear in
19
+ * these id lists at all, so the probe is automatically a no-op for them and for
20
+ * any future provider source Pi composes globally. No code here mentions or
21
+ * branches on provider layers; grep this file for "built-in"/"extension" to
22
+ * confirm the decision logic stays structural.
23
+ *
24
+ * Everything is feature-detected and per-provider fault-isolated:
25
+ * - a missing method degrades to "nothing to seed / cannot verify" — never a
26
+ * crash (the dev-dependency pins a different Pi version than is installed);
27
+ * - a broken unrelated provider registration is recorded in `errors` and must
28
+ * never block a review.
29
+ */
30
+
31
+ /**
32
+ * A provider registration as the parent holds it, copied opaquely to the
33
+ * child. The seeder never interprets a field — the child runtime validates
34
+ * and composes it exactly as it would a registration of its own.
35
+ */
36
+ interface ProviderRegistration {
37
+ /** Provider id (present on native provider objects). */
38
+ readonly id?: string;
39
+ readonly [field: string]: unknown;
40
+ }
41
+
42
+ /**
43
+ * The parent-side provider-registration surface the seeder reads from.
44
+ *
45
+ * Structural, not nominal: any object exposing these three accessors
46
+ * qualifies. Both real Pi surfaces — the session's `ModelRegistry` facade
47
+ * (`ctx.modelRegistry`) and its underlying `ModelRuntime` (`ctx.modelRuntime`)
48
+ * — expose exactly this shape; a future surface is picked up by adding one
49
+ * entry to `PARENT_SURFACE_KEYS`.
50
+ */
51
+ export interface ParentProviderSurface {
52
+ getRegisteredProviderIds(): readonly string[];
53
+ getRegisteredProviderConfig(providerId: string): ProviderRegistration | undefined;
54
+ getRegisteredNativeProvider(providerId: string): ProviderRegistration | undefined;
55
+ }
56
+
57
+ /** One provider that could not be seeded, with the reason. */
58
+ export interface ProviderSeedError {
59
+ name: string;
60
+ error: string;
61
+ }
62
+
63
+ /** What one seeding pass did (or did not) do. */
64
+ export interface ProviderSeedResult {
65
+ /** Ids that were newly registered on the child runtime. */
66
+ seeded: string[];
67
+ /** Parent ids the child already composes — untouched, proving the no-op. */
68
+ skippedPresent: string[];
69
+ /** Per-provider failures; the review is never blocked by these. */
70
+ errors: ProviderSeedError[];
71
+ }
72
+
73
+ export interface ProviderGapScan {
74
+ /** Parent ids the child does not compose yet. */
75
+ missing: string[];
76
+ /** Parent ids the child already composes. */
77
+ present: string[];
78
+ }
79
+
80
+ /** Junk-tolerant `getRegisteredProviderIds()` reader. Empty when unavailable. */
81
+ function registeredIdsOn(surface: unknown): string[] {
82
+ try {
83
+ const get = (surface as { getRegisteredProviderIds?: unknown } | undefined)
84
+ ?.getRegisteredProviderIds;
85
+ if (typeof get !== "function") return [];
86
+ const ids = (get as (this: unknown) => unknown).call(surface);
87
+ if (!Array.isArray(ids)) return [];
88
+ const seen = new Set<string>();
89
+ for (const id of ids) {
90
+ if (typeof id === "string" && id.trim()) seen.add(id.trim());
91
+ }
92
+ return [...seen];
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Structural probe: does this object expose the full provider-registration
100
+ * surface (id list + both getters)? Both real Pi surfaces — the parent
101
+ * runtime and its `ModelRegistry` facade — expose all three; anything less
102
+ * cannot seed anything, so it is treated as "no surface".
103
+ */
104
+ function isProviderSurface(value: unknown): value is ParentProviderSurface {
105
+ if (typeof value !== "object" || value === null) return false;
106
+ const s = value as Partial<ParentProviderSurface>;
107
+ return (
108
+ typeof s.getRegisteredProviderIds === "function" &&
109
+ typeof s.getRegisteredProviderConfig === "function" &&
110
+ typeof s.getRegisteredNativeProvider === "function"
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Public alias for the structural probe, so callers can distinguish a parent
116
+ * with no surface (worth reporting) from a surface that had nothing to give
117
+ * (a normal no-op).
118
+ */
119
+ export function isParentProviderSurface(value: unknown): boolean {
120
+ return isProviderSurface(value);
121
+ }
122
+
123
+ /**
124
+ * Find a provider-registration surface on the parent context, trying the
125
+ * documented public surfaces in order. Returns `undefined` when none exists —
126
+ * callers must treat that as "nothing to seed", never as an error.
127
+ *
128
+ * The list is the extension point for future Pi versions: add one entry and
129
+ * the new surface is picked up without touching the seeder.
130
+ */
131
+ const PARENT_SURFACE_KEYS = ["modelRegistry", "modelRuntime"] as const;
132
+
133
+ /**
134
+ * Find a provider-registration surface on the parent context, trying the
135
+ * documented public surfaces in order. Returns `undefined` when none exists —
136
+ * callers must treat that as "nothing to seed", never as an error.
137
+ */
138
+ export function findParentProviderSurface(
139
+ ctxLike: unknown,
140
+ ): ParentProviderSurface | undefined {
141
+ if (typeof ctxLike !== "object" || ctxLike === null) return undefined;
142
+ const holder = ctxLike as Record<string, unknown>;
143
+ for (const key of PARENT_SURFACE_KEYS) {
144
+ try {
145
+ const candidate = holder[key];
146
+ if (isProviderSurface(candidate)) return candidate;
147
+ } catch {
148
+ /* a throwing getter is just not a usable surface */
149
+ }
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ /**
155
+ * Compare the parent surface's registrations with what the child already
156
+ * composes. Never throws; a feature-detection failure on either side degrades
157
+ * to the safest scan (empty child set ⇒ everything in the parent counts as
158
+ * missing — re-registering is a merge, not a clobber).
159
+ */
160
+ export function collectMissingChildProviders(
161
+ childRuntimeLike: unknown,
162
+ parentSurfaceLike: unknown,
163
+ ): ProviderGapScan {
164
+ const childIds = new Set(registeredIdsOn(childRuntimeLike));
165
+ const parentIds = registeredIdsOn(parentSurfaceLike);
166
+ const missing: string[] = [];
167
+ const present: string[] = [];
168
+ for (const id of parentIds) {
169
+ if (childIds.has(id)) present.push(id);
170
+ else missing.push(id);
171
+ }
172
+ return { missing, present };
173
+ }
174
+
175
+ /**
176
+ * Seed the child runtime with the parent surface's provider registrations.
177
+ *
178
+ * Per missing id: register the provider object when the parent has one, else
179
+ * the provider config. Every id is individually fault-isolated — a malformed
180
+ * registration is recorded in `errors` and the remaining ids are still seeded.
181
+ * Idempotent: ids the child already composes are skipped, so a repeated call
182
+ * (or a shared runtime) is a no-op for them.
183
+ *
184
+ * Never throws. On the real Pi runtime each registration also triggers a
185
+ * network-off, fire-and-forget catalog refresh — local composition only.
186
+ */
187
+ export function seedChildRuntimeProviders(
188
+ modelRuntimeLike: unknown,
189
+ parentSurfaceLike: unknown,
190
+ ): ProviderSeedResult {
191
+ const result: ProviderSeedResult = {
192
+ seeded: [],
193
+ skippedPresent: [],
194
+ errors: [],
195
+ };
196
+ try {
197
+ if (!isProviderSurface(parentSurfaceLike)) return result;
198
+ const gap = collectMissingChildProviders(modelRuntimeLike, parentSurfaceLike);
199
+ result.skippedPresent = gap.present;
200
+ for (const id of gap.missing) {
201
+ try {
202
+ const getNative = parentSurfaceLike.getRegisteredNativeProvider as (
203
+ this: unknown,
204
+ providerId: string,
205
+ ) => unknown;
206
+ const native =
207
+ typeof getNative === "function" ? getNative.call(parentSurfaceLike, id) : undefined;
208
+ if (native !== undefined && native !== null) {
209
+ const registerNative = (modelRuntimeLike as {
210
+ registerNativeProvider?: unknown;
211
+ })?.registerNativeProvider;
212
+ if (typeof registerNative !== "function") {
213
+ throw new Error(
214
+ "the reviewer runtime does not expose registerNativeProvider",
215
+ );
216
+ }
217
+ (registerNative as (this: unknown, p: unknown) => void).call(
218
+ modelRuntimeLike,
219
+ native,
220
+ );
221
+ } else {
222
+ const getConfig = parentSurfaceLike.getRegisteredProviderConfig as (
223
+ this: unknown,
224
+ providerId: string,
225
+ ) => unknown;
226
+ const config =
227
+ typeof getConfig === "function"
228
+ ? getConfig.call(parentSurfaceLike, id)
229
+ : undefined;
230
+ if (config === undefined || config === null) {
231
+ throw new Error(
232
+ "the parent surface has no registration to copy for this id",
233
+ );
234
+ }
235
+ const registerProvider = (modelRuntimeLike as {
236
+ registerProvider?: unknown;
237
+ })?.registerProvider;
238
+ if (typeof registerProvider !== "function") {
239
+ throw new Error(
240
+ "the reviewer runtime does not expose registerProvider",
241
+ );
242
+ }
243
+ (registerProvider as (this: unknown, id: string, c: unknown) => void).call(
244
+ modelRuntimeLike,
245
+ id,
246
+ config,
247
+ );
248
+ }
249
+ result.seeded.push(id);
250
+ } catch (e: unknown) {
251
+ result.errors.push({
252
+ name: id,
253
+ error: e instanceof Error ? e.message : String(e),
254
+ });
255
+ }
256
+ }
257
+ } catch {
258
+ /* the probe itself misbehaved: report "did nothing" rather than throw */
259
+ }
260
+ return result;
261
+ }
262
+
263
+ /** Outcome of the reviewer's provider-auth preflight. */
264
+ export interface ReviewerAuthCheck {
265
+ configured: boolean;
266
+ /** `hasConfiguredAuth` said yes (snapshot). */
267
+ viaSnapshot: boolean;
268
+ /** `checkAuth` resolved to something (credential resolution). */
269
+ viaCheckAuth: boolean;
270
+ /** True when the runtime exposes neither accessor (cannot verify — trust). */
271
+ unverifiable: boolean;
272
+ }
273
+
274
+ /**
275
+ * Provider-agnostic reviewer auth preflight, mirroring the child session's own
276
+ * prompt gate (`hasConfiguredAuth(provider) || checkAuth(provider) !==
277
+ * undefined`) but tolerating a runtime that exposes neither accessor (old or
278
+ * stubbed SDKs): "cannot verify" proceeds rather than blocks. Feature-detected
279
+ * and never throws; a throwing `checkAuth` counts as "not configured".
280
+ */
281
+ export async function checkReviewerProviderAuth(
282
+ runtimeLike: unknown,
283
+ providerId: string,
284
+ ): Promise<ReviewerAuthCheck> {
285
+ let viaSnapshot = false;
286
+ let sawSnapshotAccessor = false;
287
+ try {
288
+ const has = (runtimeLike as { hasConfiguredAuth?: unknown })
289
+ ?.hasConfiguredAuth;
290
+ if (typeof has === "function") {
291
+ sawSnapshotAccessor = true;
292
+ viaSnapshot =
293
+ (has as (this: unknown, id: string) => unknown).call(
294
+ runtimeLike,
295
+ providerId,
296
+ ) === true;
297
+ }
298
+ } catch {
299
+ viaSnapshot = false;
300
+ }
301
+ if (viaSnapshot) {
302
+ return { configured: true, viaSnapshot: true, viaCheckAuth: false, unverifiable: false };
303
+ }
304
+ let viaCheckAuth = false;
305
+ let sawCheckAuthAccessor = false;
306
+ try {
307
+ const check = (runtimeLike as { checkAuth?: unknown })?.checkAuth;
308
+ if (typeof check === "function") {
309
+ sawCheckAuthAccessor = true;
310
+ const resolved = await (check as (this: unknown, id: string) => unknown).call(
311
+ runtimeLike,
312
+ providerId,
313
+ );
314
+ viaCheckAuth = resolved !== undefined && resolved !== null;
315
+ }
316
+ } catch {
317
+ viaCheckAuth = false;
318
+ }
319
+ const unverifiable = !sawSnapshotAccessor && !sawCheckAuthAccessor;
320
+ return {
321
+ configured: viaCheckAuth || unverifiable,
322
+ viaSnapshot: false,
323
+ viaCheckAuth,
324
+ unverifiable,
325
+ };
326
+ }
327
+
328
+ /** One-line summary of a seed result, for preflight/failure messages. */
329
+ export function describeSeedResult(result: ProviderSeedResult): string {
330
+ const parts: string[] = [];
331
+ parts.push(
332
+ result.seeded.length > 0
333
+ ? `seeded into the reviewer runtime: ${result.seeded.join(", ")}`
334
+ : "nothing needed seeding",
335
+ );
336
+ parts.push(
337
+ result.skippedPresent.length > 0
338
+ ? `already present: ${result.skippedPresent.join(", ")}`
339
+ : "none were already present",
340
+ );
341
+ parts.push(
342
+ result.errors.length > 0
343
+ ? `failed: ${result.errors
344
+ .map((e) => `${e.name} (${e.error})`)
345
+ .join("; ")}`
346
+ : "no failures",
347
+ );
348
+ return parts.join("; ");
349
+ }
350
+
351
+ /**
352
+ * Build the fail-fast error for a reviewer provider whose auth could not be
353
+ * resolved after seeding. Names the provider, reports what the seed found,
354
+ * and gives the generic escape hatch that works for ANY provider.
355
+ */
356
+ export function unconfiguredReviewerProviderMessage(input: {
357
+ providerId: string;
358
+ seed: ProviderSeedResult;
359
+ /** Parent surface was absent entirely (no probe hit). */
360
+ parentSurfaceFound: boolean;
361
+ modelsJsonPath?: string;
362
+ }): string {
363
+ const modelsJson =
364
+ input.modelsJsonPath ?? "~/.pi/agent/models.json";
365
+ const seedSummary = input.parentSurfaceFound
366
+ ? describeSeedResult(input.seed)
367
+ : "the parent session exposed no provider-registration surface, so nothing could be seeded";
368
+ const lines = [
369
+ `the reviewer session has no usable auth for provider "${input.providerId}".`,
370
+ `Provider seeding: ${seedSummary}.`,
371
+ `The reviewer runs in an isolated session without ambient extensions, so a provider registered only in the parent session must be seeded (above) AND its auth must be resolvable on its own.`,
372
+ `Fix any one of:`,
373
+ ` 1. run /login ${input.providerId} — the stored credential is read by every runtime, including the reviewer's;`,
374
+ ` 2. add a static provider entry to ${modelsJson} — that file is loaded by every runtime, including the reviewer's:`,
375
+ ` { "providers": { "${input.providerId}": { "baseUrl": "…", "apiKey": "$MY_API_KEY", "api": "openai-completions", "models": [ … ] } } }`,
376
+ ` 3. export MY_API_KEY in the environment when the provider config references "$MY_API_KEY".`,
377
+ ];
378
+ return lines.join("\n");
379
+ }
380
+
381
+ /**
382
+ * Defensive hint appended to a mid-run pass-1 failure that still carries the
383
+ * bare provider-auth text. After seeding + preflight this path should be rare;
384
+ * it is the safety net for any future provider-source gap.
385
+ *
386
+ * Only fires when the error text matches the provider-auth failure shape AND
387
+ * the failing provider (extracted from the text) matches the reviewer's own
388
+ * provider id — an unrelated error must never gain a misleading hint.
389
+ */
390
+ export function appendProviderAuthHint(
391
+ errorText: string,
392
+ providerId: string | undefined,
393
+ modelsJsonPath?: string,
394
+ ): string {
395
+ if (!providerId || !errorText) return errorText;
396
+ const match = errorText.match(/No API key found for\s+([^\s.]+)/);
397
+ if (!match) return errorText;
398
+ const failingProvider = match[1];
399
+ if (failingProvider !== providerId) return errorText;
400
+ const modelsJson = modelsJsonPath ?? "~/.pi/agent/models.json";
401
+ return [
402
+ errorText,
403
+ `Hint: provider "${providerId}" may only be registered in the parent session; the reviewer session starts without ambient extensions.`,
404
+ `Run /login ${providerId}, or add a static provider entry to ${modelsJson} (loaded by every runtime, including the reviewer's).`,
405
+ ].join("\n");
406
+ }
package/review-runtime.ts CHANGED
@@ -22,13 +22,17 @@
22
22
  * instructions — the reviewer only supplies judgement.
23
23
  */
24
24
 
25
+ import * as path from "node:path";
26
+
25
27
  import {
26
28
  DEFAULT_REVIEW_MODE_CONFIG,
27
29
  createReviewRoundGate,
28
30
  createReviewSession,
29
31
  createReviewState,
30
32
  planHash,
33
+ readProviderErrorEvent,
31
34
  reviewNeedsVerification,
35
+ truncateErrorText,
32
36
  type CreateReviewSessionOptions,
33
37
  type CreateReviewSessionResult,
34
38
  type ReviewChildSession,
@@ -44,6 +48,7 @@ import {
44
48
  type ReviewVerdict,
45
49
  ReviewTranscript as Transcript,
46
50
  } from "./review.ts";
51
+ import { appendProviderAuthHint } from "./review-providers.ts";
47
52
  import { createReviewTools } from "./review-tools.ts";
48
53
  import { renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
49
54
 
@@ -71,6 +76,15 @@ export interface ReviewRuntimeDeps {
71
76
  | undefined;
72
77
  /** Resolve a model from the parent's registry. */
73
78
  findModel: (provider: string, id: string) => unknown;
79
+ /**
80
+ * Parent-side provider-registration surface for the reviewer runtime, found
81
+ * with `findParentProviderSurface(ctx)` at session-creation time. Providers
82
+ * the parent knows that the isolated child does not compose yet are seeded
83
+ * onto it before `createAgentSession`. Optional: when absent (or when the
84
+ * probe finds no surface) nothing is seeded and the provider-auth preflight
85
+ * reports the generic escape hatch instead.
86
+ */
87
+ parentProviders?: () => unknown;
74
88
  /** Plan path Plan Mode chose (absolute or cwd-relative), if known. */
75
89
  planPath: () => string | undefined;
76
90
  /** Current plan text on disk, if it exists. */
@@ -279,6 +293,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
279
293
  let abortController: AbortController | undefined;
280
294
  /** True while `runRound` is in flight (gates prompt routing). */
281
295
  let roundActive = false;
296
+ /**
297
+ * The reviewer's most recent provider failure, captured live from child
298
+ * events (`auto_retry_start`, failed `auto_retry_end`, an `agent_end` that
299
+ * stopped on error). Latest wins. Cleared at the start of every pass so a
300
+ * stale error can never mislabel a later clean settle (see runRound).
301
+ */
302
+ let lastProviderError:
303
+ | { message: string; retries: number; at: number }
304
+ | undefined;
305
+
306
+ /** Record one provider failure. The message arrives pre-decoded, non-empty. */
307
+ const noteProviderError = (message: string, retries: number): void => {
308
+ lastProviderError = {
309
+ message,
310
+ retries: Math.max(0, Math.trunc(retries)),
311
+ at: Date.now(),
312
+ };
313
+ };
314
+
315
+ /** Drop the captured error when a new pass starts. */
316
+ const clearProviderError = (): void => {
317
+ lastProviderError = undefined;
318
+ };
282
319
 
283
320
  const config = (): ReviewModeConfig => {
284
321
  try {
@@ -594,6 +631,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
594
631
  // Reuse the already-resolved module instead of importing twice.
595
632
  loadSdk: async () => sdk,
596
633
  };
634
+ // Probed lazily, at creation time: the parent context is live here.
635
+ const parentSurface = deps.parentProviders?.();
636
+ if (parentSurface !== undefined) {
637
+ createOptions.parentProviders = parentSurface;
638
+ }
597
639
 
598
640
  const created: CreateReviewSessionResult =
599
641
  await createReviewSession(createOptions);
@@ -633,6 +675,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
633
675
  }
634
676
  }
635
677
  const type = readEventType(event);
678
+ // Capture provider failures as they happen, so a pass that ends
679
+ // without a submission can be diagnosed with the reviewer's real
680
+ // API error instead of a generic "no plan" message (see runRound).
681
+ try {
682
+ const failure = readProviderErrorEvent(event);
683
+ if (failure.kind === "retry") {
684
+ noteProviderError(failure.message, failure.retries);
685
+ } else if (failure.kind === "exhaust") {
686
+ noteProviderError(
687
+ failure.message ||
688
+ lastProviderError?.message ||
689
+ "provider error (no detail)",
690
+ failure.retries || lastProviderError?.retries || 0,
691
+ );
692
+ } else if (failure.kind === "stop") {
693
+ noteProviderError(
694
+ failure.message,
695
+ lastProviderError?.retries ?? 0,
696
+ );
697
+ }
698
+ } catch {
699
+ /* error capture is diagnostics only: never break the review */
700
+ }
636
701
  if (type === "agent_settled") {
637
702
  noteSettled();
638
703
  } else if (type === "agent_end") {
@@ -738,6 +803,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
738
803
  // ── Pass 1 ──────────────────────────────────────────────────────
739
804
  await settleInFlight(deadline, rc);
740
805
  gate.reset();
806
+ clearProviderError();
741
807
  const pass1Prompt = buildPass1Prompt({
742
808
  planPath: input.planPath,
743
809
  planText: originalPlan,
@@ -755,7 +821,16 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
755
821
 
756
822
  if (p1.stop === "error") {
757
823
  await abortChild();
758
- return fail(`could not prompt the reviewer: ${errorText(p1.error)}`);
824
+ // Safety net for any provider-source gap the preflight could not see:
825
+ // the bare "No API key found for <provider>." gains the actionable
826
+ // hint ONLY when the failing provider is the reviewer's own.
827
+ return fail(
828
+ `could not prompt the reviewer: ${appendProviderAuthHint(
829
+ errorText(p1.error),
830
+ deps.reviewerModel()?.provider,
831
+ path.join(deps.agentDir, "models.json"),
832
+ )}`,
833
+ );
759
834
  }
760
835
  if (p1.stop === "aborted" || rc.signal.aborted) {
761
836
  await abortChild();
@@ -773,10 +848,23 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
773
848
  // The run is over (or out of time) and nothing reached the plan file.
774
849
  // Fail now — waiting longer cannot produce a submission.
775
850
  await abortChild();
851
+ if (p1.stop === "timeout") {
852
+ return fail(
853
+ `the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`,
854
+ originalPlan,
855
+ );
856
+ }
857
+ // A provider failure settles the run without a submission — name the
858
+ // real cause (with the provider's own message) instead of blaming the
859
+ // write path. The slot only holds errors captured during THIS pass.
860
+ if (lastProviderError) {
861
+ return fail(
862
+ `the reviewer's provider failed after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`,
863
+ originalPlan,
864
+ );
865
+ }
776
866
  return fail(
777
- p1.stop === "timeout"
778
- ? `the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`
779
- : "the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
867
+ "the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
780
868
  originalPlan,
781
869
  );
782
870
  }
@@ -802,6 +890,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
802
890
  setPhase("verifying");
803
891
  await settleInFlight(deadline, rc);
804
892
  gate.reset();
893
+ clearProviderError();
805
894
  const pass2Prompt = buildPass2Prompt({
806
895
  planPath: input.planPath,
807
896
  originalPlan,
@@ -811,14 +900,28 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
811
900
  const p2 = await runPass(2, deadline, rc, () =>
812
901
  child.followUp(pass2Prompt),
813
902
  );
903
+ // Name the provider when the verification pass died on one; pass 2
904
+ // stays best-effort either way — pass 1's plan is never lost to it.
905
+ const p2Provider = lastProviderError
906
+ ? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
907
+ : undefined;
814
908
  if (p2.error !== undefined) {
815
909
  // Verification is an enhancement: never lose pass 1's plan over it.
816
910
  deps.log(
817
- `Review Mode verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
911
+ p2Provider
912
+ ? `Review Mode verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
913
+ : `Review Mode verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
818
914
  "warning",
819
915
  );
820
916
  } else if (p2.submitted) {
821
917
  finalPlan = p2.submitted.planText;
918
+ } else if (p2Provider) {
919
+ // The run settled without a submission AND a provider error was
920
+ // seen this pass: say why, then keep going with pass 1's rewrite.
921
+ deps.log(
922
+ `Review Mode verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
923
+ "warning",
924
+ );
822
925
  }
823
926
  if (p2.stop === "timeout") await abortChild();
824
927
  }
package/review-ui.ts CHANGED
@@ -253,6 +253,7 @@ const LINE_COLORS: Record<ReviewEntryLineKind, ThemeColor> = {
253
253
  finding: "warning",
254
254
  notice: "warning",
255
255
  status: "muted",
256
+ error: "error",
256
257
  user: "success",
257
258
  };
258
259
 
package/review.ts CHANGED
@@ -36,6 +36,13 @@ import {
36
36
  type ReviewFinding,
37
37
  type ReviewTier,
38
38
  } from "./utils.ts";
39
+ import {
40
+ checkReviewerProviderAuth,
41
+ isParentProviderSurface,
42
+ seedChildRuntimeProviders,
43
+ unconfiguredReviewerProviderMessage,
44
+ type ProviderSeedResult,
45
+ } from "./review-providers.ts";
39
46
 
40
47
  // Re-exported so `review.ts` is the single import surface for Review Mode.
41
48
  export type { ReviewDisposition, ReviewFinding, ReviewTier };
@@ -567,7 +574,8 @@ export type ReviewLineKind =
567
574
  | "tool"
568
575
  | "finding"
569
576
  | "notice"
570
- | "status";
577
+ | "status"
578
+ | "error";
571
579
 
572
580
  /** One line in the reviewer pane. Kept small: the pane renders these verbatim. */
573
581
  export interface ReviewTranscriptLine {
@@ -1099,6 +1107,103 @@ function truncateLabel(s: string, max: number): string {
1099
1107
  return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`;
1100
1108
  }
1101
1109
 
1110
+ /** Longest provider-error excerpt shown inline in a transcript line. */
1111
+ export const MAX_ERROR_TEXT = 160;
1112
+
1113
+ /**
1114
+ * One-line excerpt of a provider error message for `notify` and the reviewer
1115
+ * pane. Provider errors are often multi-line JSON blobs; they must become a
1116
+ * single line to survive a transcript entry and a `ui.notify` banner.
1117
+ * Pure and total: empty/undefined input yields "", never throws.
1118
+ */
1119
+ export function truncateErrorText(input: unknown, max: number = MAX_ERROR_TEXT): string {
1120
+ const raw = typeof input === "string" ? input : "";
1121
+ const flat = raw.replace(/\s+/g, " ").trim();
1122
+ if (!flat) return "";
1123
+ const cap = Math.max(1, Math.trunc(max));
1124
+ return flat.length <= cap ? flat : `${flat.slice(0, cap - 1).trimEnd()}…`;
1125
+ }
1126
+
1127
+ /**
1128
+ * Extract the provider error message when a run stopped on an error: scan an
1129
+ * `agent_end` event's messages from the end for the last assistant message
1130
+ * with `stopReason === "error"`. Structural on purpose — SDK message shapes
1131
+ * are not imported here. Returns undefined for clean runs and junk input.
1132
+ */
1133
+ export function lastAssistantErrorStop(messages: unknown): string | undefined {
1134
+ if (!Array.isArray(messages)) return undefined;
1135
+ for (let i = messages.length - 1; i >= 0; i--) {
1136
+ const rec = messages[i] as Record<string, unknown> | null | undefined;
1137
+ if (!rec || typeof rec !== "object") continue;
1138
+ if (rec.role !== "assistant") continue;
1139
+ // Last assistant message reached: a non-error stop means a clean run.
1140
+ if (rec.stopReason !== "error") return undefined;
1141
+ const err = typeof rec.errorMessage === "string" ? rec.errorMessage : "";
1142
+ return err || "unknown provider error";
1143
+ }
1144
+ return undefined;
1145
+ }
1146
+
1147
+ /**
1148
+ * What a child-session event says about provider health, decoded at the
1149
+ * boundary so callers branch on typed values instead of SDK event shapes:
1150
+ *
1151
+ * - `retry` → the SDK scheduled another attempt (`auto_retry_start`) and
1152
+ * reported the provider's error message.
1153
+ * - `exhaust` → the retry budget ran out (`auto_retry_end`, `success: false`);
1154
+ * the run is about to settle on an error. `message` may be empty when the
1155
+ * event carries no `finalError` — callers then keep their earlier message.
1156
+ * - `stop` → the run itself ended with `stopReason === "error"` on its last
1157
+ * assistant message (auto-retry disabled, or a non-retryable failure).
1158
+ * - `none` → anything else, including a retry that recovered.
1159
+ */
1160
+ export type ReviewProviderFailure =
1161
+ | { kind: "retry"; message: string; retries: number }
1162
+ | { kind: "exhaust"; message: string; retries: number }
1163
+ | { kind: "stop"; message: string }
1164
+ | { kind: "none" };
1165
+
1166
+ /** Structural decode of one provider-failure event. Never throws. */
1167
+ export function readProviderErrorEvent(
1168
+ event: unknown,
1169
+ ): ReviewProviderFailure {
1170
+ const e = eventRecord(event);
1171
+ if (!e) return { kind: "none" };
1172
+ const type = typeof e.type === "string" ? e.type : "";
1173
+ if (type === "auto_retry_start") {
1174
+ const message =
1175
+ typeof e.errorMessage === "string" ? e.errorMessage.trim() : "";
1176
+ if (!message) return { kind: "none" };
1177
+ return {
1178
+ kind: "retry",
1179
+ message,
1180
+ retries: readRetryAttempt(e.attempt),
1181
+ };
1182
+ }
1183
+ if (type === "auto_retry_end") {
1184
+ if (e.success !== false) return { kind: "none" };
1185
+ const message = typeof e.finalError === "string" ? e.finalError.trim() : "";
1186
+ return {
1187
+ kind: "exhaust",
1188
+ message,
1189
+ retries: readRetryAttempt(e.attempt),
1190
+ };
1191
+ }
1192
+ if (type === "agent_end") {
1193
+ const message = lastAssistantErrorStop(e.messages);
1194
+ if (message === undefined) return { kind: "none" };
1195
+ return { kind: "stop", message };
1196
+ }
1197
+ return { kind: "none" };
1198
+ }
1199
+
1200
+ /** Non-negative attempt count from a retry event; 0 when absent or junk. */
1201
+ function readRetryAttempt(value: unknown): number {
1202
+ return typeof value === "number" && Number.isFinite(value) && value > 0
1203
+ ? Math.trunc(value)
1204
+ : 0;
1205
+ }
1206
+
1102
1207
  /**
1103
1208
  * Human-readable header for a tool call: the tool name plus a short, safe
1104
1209
  * excerpt of its key argument (`▸ read src/app.ts`, `▸ review_bash: git log -5`).
@@ -1170,6 +1275,12 @@ export function formatActionLabel(toolName: unknown, args: unknown): string {
1170
1275
  * `toolLabels` is owned by the stateful caller (`ReviewTranscript`): the end
1171
1276
  * event carries no args, so the start event registers `toolCallId → label` and
1172
1277
  * the end event consumes it. Pure otherwise; junk input never throws.
1278
+ *
1279
+ * Provider failures are surfaced with their real (truncated) text: the
1280
+ * `auto_retry_start` notice carries the provider's message, and a failed
1281
+ * `auto_retry_end` or an `agent_end` that stopped on `stopReason === "error"`
1282
+ * appends an `error` line — a dead provider is then visible in the pane during
1283
+ * the review instead of resurfacing later as a mysterious "no plan" failure.
1173
1284
  */
1174
1285
  export function translateReviewEvent(
1175
1286
  event: unknown,
@@ -1207,15 +1318,70 @@ export function translateReviewEvent(
1207
1318
  if (type === "agent_start") {
1208
1319
  return [{ op: "new", kind: "status", text: "reviewer started" }];
1209
1320
  }
1321
+ return providerErrorOps(e, type);
1322
+ }
1323
+
1324
+ /**
1325
+ * Provider-error events → transcript lines, split out of
1326
+ * `translateReviewEvent` to keep the dispatcher flat:
1327
+ *
1328
+ * - `agent_end` → status line, plus an `error` line when the run
1329
+ * stopped on `stopReason === "error"` (auto-retry disabled, or the first
1330
+ * failure was not retryable).
1331
+ * - `auto_retry_start` → notice carrying the provider's message (truncated).
1332
+ * - `auto_retry_end` → `error` line when the retry budget is exhausted
1333
+ * (`success: false`); a recovered retry (`success: true`) stays silent.
1334
+ *
1335
+ * Unknown types and junk fields degrade to empty ops; never throws.
1336
+ */
1337
+ function providerErrorOps(
1338
+ e: Record<string, unknown>,
1339
+ type: string,
1340
+ ): ReviewTranscriptOp[] {
1210
1341
  if (type === "agent_end") {
1211
- return [{ op: "new", kind: "status", text: "reviewer finished this run" }];
1342
+ const ops: ReviewTranscriptOp[] = [
1343
+ { op: "new", kind: "status", text: "reviewer finished this run" },
1344
+ ];
1345
+ const failed = lastAssistantErrorStop(e.messages);
1346
+ if (failed) {
1347
+ ops.push({
1348
+ op: "new",
1349
+ kind: "error",
1350
+ text: `reviewer run ended on a provider error: ${truncateErrorText(failed)}`,
1351
+ });
1352
+ }
1353
+ return ops;
1212
1354
  }
1213
1355
  if (type === "auto_retry_start") {
1356
+ const msg = typeof e.errorMessage === "string" ? e.errorMessage : "";
1214
1357
  return [
1215
- { op: "new", kind: "notice", text: "retrying after a provider error" },
1358
+ {
1359
+ op: "new",
1360
+ kind: "notice",
1361
+ text: msg
1362
+ ? `retrying after a provider error: ${truncateErrorText(msg)}`
1363
+ : "retrying after a provider error",
1364
+ },
1216
1365
  ];
1217
1366
  }
1218
-
1367
+ if (type === "auto_retry_end") {
1368
+ // `success: false` ends the retry loop with the provider still failing —
1369
+ // the run is about to settle on an error stop. `success: true` means a
1370
+ // retry recovered; the earlier notice already covers that case.
1371
+ if (e.success === false) {
1372
+ const detail = truncateErrorText(e.finalError);
1373
+ return [
1374
+ {
1375
+ op: "new",
1376
+ kind: "error",
1377
+ text: detail
1378
+ ? `reviewer retry budget exhausted: ${detail}`
1379
+ : "reviewer retry budget exhausted",
1380
+ },
1381
+ ];
1382
+ }
1383
+ return [];
1384
+ }
1219
1385
  return [];
1220
1386
  }
1221
1387
 
@@ -1443,6 +1609,23 @@ export interface CreateReviewSessionOptions {
1443
1609
  reviewPrompt: string;
1444
1610
  /** Reviewer-only tools, built by the caller. */
1445
1611
  customTools: unknown[];
1612
+ /**
1613
+ * Parent-side provider-registration surface (structural, feature-detected),
1614
+ * found with `findParentProviderSurface(ctx)` — the session's model registry
1615
+ * facade or its underlying model runtime.
1616
+ *
1617
+ * The reviewer session is deliberately created without ambient extensions,
1618
+ * so a provider registered only in the parent session (e.g. by a provider
1619
+ * extension) does not exist in the reviewer's fresh runtime. When this
1620
+ * surface is given, every provider the parent knows that the child does not
1621
+ * yet compose is registered onto the child BEFORE `createAgentSession` —
1622
+ * natively when the parent has a provider object, otherwise by config.
1623
+ * Absent or surface-less ⇒ nothing is seeded and the provider-auth preflight
1624
+ * (below) reports the generic escape hatch instead. Providers the child
1625
+ * already composes are skipped, so this is a no-op for every provider the
1626
+ * runtime builds in by construction.
1627
+ */
1628
+ parentProviders?: unknown;
1446
1629
  /** Injectable for tests; defaults to a real dynamic import. */
1447
1630
  loadSdk?: ReviewSdkLoader;
1448
1631
  }
@@ -1453,6 +1636,8 @@ export interface CreateReviewSessionResult {
1453
1636
  modelLabel: string;
1454
1637
  /** Tool names the child actually has enabled, when the SDK exposes them. */
1455
1638
  activeTools?: string[];
1639
+ /** What provider seeding did before the session was created, when probed. */
1640
+ providerSeed?: ProviderSeedResult;
1456
1641
  error?: string;
1457
1642
  }
1458
1643
 
@@ -1601,6 +1786,36 @@ export async function createReviewSession(
1601
1786
  };
1602
1787
  }
1603
1788
 
1789
+ // Seed providers the child does not compose yet (e.g. providers registered
1790
+ // only in the parent session) BEFORE the session exists. Present-probe:
1791
+ // only the difference is registered, native-first, per-provider
1792
+ // fault-isolated — never fatal, never layer-aware.
1793
+ const providerSeed = seedChildRuntimeProviders(
1794
+ modelRuntime,
1795
+ options.parentProviders,
1796
+ );
1797
+
1798
+ // Provider-agnostic auth preflight, mirroring the child session's own
1799
+ // prompt gate: fail fast here — with the seed report and the generic
1800
+ // escape hatch — instead of mid-review with a bare "No API key found".
1801
+ const auth = await checkReviewerProviderAuth(
1802
+ modelRuntime,
1803
+ options.modelRef.provider,
1804
+ );
1805
+ if (!auth.configured) {
1806
+ return {
1807
+ ok: false,
1808
+ modelLabel,
1809
+ providerSeed,
1810
+ error: unconfiguredReviewerProviderMessage({
1811
+ providerId: options.modelRef.provider,
1812
+ seed: providerSeed,
1813
+ parentSurfaceFound: isParentProviderSurface(options.parentProviders),
1814
+ modelsJsonPath: path.join(options.agentDir, "models.json"),
1815
+ }),
1816
+ };
1817
+ }
1818
+
1604
1819
  // No ambient extensions, no skills/prompts/themes: the reviewer gets exactly
1605
1820
  // the system prompt below and nothing from the user's other packages.
1606
1821
  // Project context files (AGENTS.md etc.) DO load — they are the framework