gentle-pi 3.5.1 → 3.6.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.
@@ -4,7 +4,7 @@ import { join, resolve as resolvePath } from "node:path";
4
4
  // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
5
5
  // filesystem and child process so this module stays fully unit-testable.
6
6
 
7
- export type LauncherCommand = "home";
7
+ export type LauncherCommand = "home" | "setup";
8
8
 
9
9
  // pi's own package-management subcommands (see pi's cli/args.ts printHelp
10
10
  // "Commands" list): each is dispatched by pi itself, before pi's own flag
@@ -62,6 +62,8 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
62
62
  let help = false;
63
63
  let version = false;
64
64
  let error: string | undefined;
65
+ let command: LauncherCommand | undefined;
66
+ let commandArgs: string[] = [];
65
67
  let piSubcommand: PiSubcommand | undefined;
66
68
  const passthrough: string[] = [];
67
69
 
@@ -131,6 +133,18 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
131
133
  i += 1;
132
134
  continue;
133
135
  }
136
+ // Unlike `home`, `setup` is not restricted to argv[0]: it accepts the
137
+ // home selectors (--link, --isolated, --home <dir>) ahead of it, same
138
+ // as a pi subcommand would, so it provisions whichever home those
139
+ // selectors resolve to. It is only recognised as the FIRST non-flag
140
+ // token — once a pi subcommand (or any other passthrough token) has
141
+ // already started, a later "setup" is just an ordinary passthrough
142
+ // argument, same as "home" is.
143
+ if (arg === "setup" && command === undefined && passthrough.length === 0) {
144
+ command = "setup";
145
+ commandArgs = argv.slice(i + 1);
146
+ break;
147
+ }
134
148
  if (passthrough.length === 0 && isPiSubcommand(arg)) {
135
149
  piSubcommand = arg;
136
150
  }
@@ -147,7 +161,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
147
161
  }
148
162
  }
149
163
 
150
- return { link, isolated, home, packageRoot, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
164
+ return { link, isolated, home, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error };
151
165
  }
152
166
 
153
167
  // --- home resolution -------------------------------------------------------
@@ -200,6 +214,20 @@ export function resolveHome(input: ResolveHomeInput): ResolvedHome {
200
214
  return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" };
201
215
  }
202
216
 
217
+ // The flags that reproduce `home`'s resolved mode on a later `gentle-shell
218
+ // <flags> ...` invocation — used by remediation messages (e.g. "run
219
+ // `gentle-shell <flags> remove <source>`") so they point at the exact home
220
+ // setup provisioned instead of silently defaulting to the isolated home.
221
+ // Mirrors the three ResolvedHome modes one-to-one: "link" needs --link
222
+ // (PI_CODING_AGENT_DIR-derived dirs aren't reproducible as a literal path),
223
+ // "path" needs its --home <dir>, and "isolated" needs nothing since it's
224
+ // gentle-shell's own default when no selector is given.
225
+ export function homeSelectorFlags(home: ResolvedHome): string[] {
226
+ if (home.mode === "link") return ["--link"];
227
+ if (home.mode === "path") return ["--home", home.dir];
228
+ return [];
229
+ }
230
+
203
231
  export function launcherConfigPath(homedir: string): string {
204
232
  return join(homedir, ".gentle-shell", "config.json");
205
233
  }
@@ -228,6 +256,88 @@ export function parseLauncherConfig(text: string): LauncherConfig | undefined {
228
256
  return { mode: "path", dir: home };
229
257
  }
230
258
 
259
+ // --- provisioning marker (S7 auto-provision) --------------------------------
260
+
261
+ // Raw config.json shape as actually stored on disk: a plain object that may
262
+ // carry `home` (see LauncherConfig above), `provisioned`, and any other key
263
+ // a future feature adds. Unlike parseLauncherConfig's discriminated
264
+ // LauncherConfig, these helpers operate on (and return) the whole object so
265
+ // a write never drops a field it does not itself understand — notably
266
+ // another home's provisioned marker when `gentle-shell home ...` persists a
267
+ // mode change.
268
+ export type RawLauncherConfig = Record<string, unknown>;
269
+
270
+ // Tolerant like parseLauncherConfig: a missing, malformed, or foreign
271
+ // config.json resolves to an empty object rather than throwing, so a caller
272
+ // can always merge into (and write back) whatever it finds.
273
+ export function parseRawLauncherConfig(text: string | undefined): RawLauncherConfig {
274
+ if (text === undefined) return {};
275
+ let parsed: unknown;
276
+ try {
277
+ parsed = JSON.parse(text);
278
+ } catch {
279
+ return {};
280
+ }
281
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
282
+ return parsed as RawLauncherConfig;
283
+ }
284
+
285
+ export interface ProvisionedEntry {
286
+ gentleAi: string;
287
+ // Optional: a marker written before gentle-pi version tracking (S8) has
288
+ // no `gentlePi` field at all. needsProvisioning below treats that
289
+ // omission as "needs provisioning" rather than trusting or crashing on it.
290
+ gentlePi?: string;
291
+ at: string;
292
+ }
293
+
294
+ function isProvisionedEntry(value: unknown): value is ProvisionedEntry {
295
+ if (typeof value !== "object" || value === null) return false;
296
+ const record = value as Record<string, unknown>;
297
+ if (typeof record.gentleAi !== "string" || typeof record.at !== "string") return false;
298
+ return record.gentlePi === undefined || typeof record.gentlePi === "string";
299
+ }
300
+
301
+ // Tolerant read of config.provisioned: a missing, non-object, or malformed
302
+ // map (or a malformed individual entry) is dropped rather than thrown, same
303
+ // tolerance policy as parseLauncherConfig above.
304
+ function provisionedMap(config: RawLauncherConfig): Record<string, ProvisionedEntry> {
305
+ const value = config.provisioned;
306
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
307
+ const map: Record<string, ProvisionedEntry> = {};
308
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
309
+ if (isProvisionedEntry(entry)) map[key] = entry;
310
+ }
311
+ return map;
312
+ }
313
+
314
+ // The provisioning record for `homeDir` (the caller passes a realpath, so
315
+ // two different-looking paths to the same home never diverge), or undefined
316
+ // when that home has never been auto- or manually provisioned.
317
+ export function provisionedEntry(config: RawLauncherConfig, homeDir: string): ProvisionedEntry | undefined {
318
+ return provisionedMap(config)[homeDir];
319
+ }
320
+
321
+ // True when `homeDir` has never been provisioned, was provisioned with a
322
+ // gentle-ai pin other than `pin`, or was provisioned against a gentle-pi
323
+ // other than `gentlePiVersion` (the running launcher's own version, from its
324
+ // package.json) — the signal bin/gentle-shell.mjs uses to decide whether a
325
+ // plain launch should run the setup flow automatically before starting pi.
326
+ // A marker written before gentle-pi version tracking existed has no
327
+ // `gentlePi` field, which never strictly-equals a real version string, so it
328
+ // always counts as needing provisioning too — see ProvisionedEntry above.
329
+ export function needsProvisioning(config: RawLauncherConfig, homeDir: string, pin: string, gentlePiVersion: string): boolean {
330
+ const entry = provisionedEntry(config, homeDir);
331
+ return entry === undefined || entry.gentleAi !== pin || entry.gentlePi !== gentlePiVersion;
332
+ }
333
+
334
+ // Returns a new config object recording `homeDir` as provisioned at `pin`
335
+ // and `gentlePiVersion`, preserving every other key — including every other
336
+ // home's provisioned entry — unchanged. Never mutates `config`.
337
+ export function recordProvisioned(config: RawLauncherConfig, homeDir: string, pin: string, gentlePiVersion: string, now: string): RawLauncherConfig {
338
+ return { ...config, provisioned: { ...provisionedMap(config), [homeDir]: { gentleAi: pin, gentlePi: gentlePiVersion, at: now } } };
339
+ }
340
+
231
341
  // --- pi runtime resolution ---------------------------------------------------
232
342
 
233
343
  export type PiRuntimeKind = "env" | "bundled" | "path";
@@ -298,6 +408,25 @@ export function checkPiVersion(output: string, minimum: string = MIN_PI_VERSION)
298
408
  return { ok: true, version };
299
409
  }
300
410
 
411
+ // --- setup subcommand's gentle-ai pin gate -----------------------------------
412
+
413
+ // The first gentle-ai release that honors PI_CODING_AGENT_DIR in its own
414
+ // `install --agent pi` provisioning. `gentle-shell setup` spawns the
415
+ // package-local pinned gentle-ai with PI_CODING_AGENT_DIR set to the
416
+ // resolved home; an older pin ignores that variable and silently provisions
417
+ // the caller's real ~/.pi/agent instead, so setup must refuse to run it.
418
+ export const MIN_SETUP_GENTLE_AI_VERSION = "3.6.0";
419
+
420
+ export function isSetupCapablePin(version: string, minimum: string = MIN_SETUP_GENTLE_AI_VERSION): boolean {
421
+ const match = VERSION_PATTERN.exec(version);
422
+ if (!match) return false;
423
+ const minimumMatch = VERSION_PATTERN.exec(minimum);
424
+ if (!minimumMatch) throw new Error(`invalid minimum version "${minimum}"`);
425
+ const found: [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3])];
426
+ const wanted: [number, number, number] = [Number(minimumMatch[1]), Number(minimumMatch[2]), Number(minimumMatch[3])];
427
+ return compareVersions(found, wanted) >= 0;
428
+ }
429
+
301
430
  // --- packaging drift guard -----------------------------------------------------
302
431
 
303
432
  export interface PackageJsonPeerShape {
@@ -400,6 +529,61 @@ export function settingsDeclareGentlePi(settingsText: string | undefined): boole
400
529
  return packages.some(packageEntryDeclaresGentlePi);
401
530
  }
402
531
 
532
+ // gentle-ai's own managed Pi stack still installs
533
+ // npm:@juicesharp/rpiv-ask-user-question, which conflicts with gentle-pi's
534
+ // first-party ask_user_question tool: Pi tool names are exclusive, so a
535
+ // second provider for the same name fails the whole load (see
536
+ // extensions/ask-user-question.ts). Tracked upstream as gentle-ai #4820 and
537
+ // gentle-shell #1277; the gentle-ai fix lands separately, so `gentle-shell
538
+ // setup` (bin/gentle-shell.mjs) must remove it from the provisioned home
539
+ // itself.
540
+ //
541
+ // gentle-ai's managed Pi stack also always declares npm:gentle-pi itself.
542
+ // That declaration must never survive setup either, for an unrelated reason:
543
+ // this launcher always loads its own gentle-pi (its own package root, or a
544
+ // take-over), never the one gentle-ai's stack installs, so leaving the
545
+ // declaration in place would silently let the home drift onto whatever
546
+ // gentle-pi npm last installed — or, for a developer running from a source
547
+ // checkout, onto the published npm package — instead of the running
548
+ // launcher's own copy. See docs/readme-reference.md's "setup" section.
549
+ //
550
+ // Table of every package `setup` removes after gentle-ai finishes, so a
551
+ // future addition only needs a new row here.
552
+ const POST_INSTALL_REMOVAL_PACKAGES: readonly { readonly name: string; readonly source: string }[] = [
553
+ { name: "@juicesharp/rpiv-ask-user-question", source: "npm:@juicesharp/rpiv-ask-user-question" },
554
+ { name: "gentle-pi", source: "npm:gentle-pi" },
555
+ ];
556
+
557
+ // The known removal sources, exposed so a `--dry-run` caller can report what
558
+ // setup would remove *if* gentle-ai's install declares it, without reading
559
+ // settings.json itself: a dry run writes nothing, so settings.json
560
+ // afterwards would only reflect whatever pre-existed the run, not what the
561
+ // (skipped) install would have declared. See runPostInstallCleanup in
562
+ // bin/gentle-shell.mjs.
563
+ export const POST_INSTALL_REMOVAL_SOURCES: readonly string[] = POST_INSTALL_REMOVAL_PACKAGES.map((entry) => entry.source);
564
+
565
+ // Scans a settings.json `packages` list (same string/object-source parsing
566
+ // as settingsDeclareGentlePi/findGentlePiDeclaration above) for any entry
567
+ // whose npm package name matches POST_INSTALL_REMOVAL_PACKAGES, at any
568
+ // version spec. Returns each match's canonical unversioned source, deduped,
569
+ // in the order those packages first appear in `packages` — never the
570
+ // declared (possibly versioned) source text, since the caller always removes
571
+ // the bare package.
572
+ export function postInstallRemovals(settingsText: string | undefined): string[] {
573
+ const packages = parseSettingsPackages(settingsText);
574
+ if (packages === undefined) return [];
575
+
576
+ const found: string[] = [];
577
+ for (const entry of packages) {
578
+ const source = entrySource(entry);
579
+ if (source === undefined || packageSourceKind(source) !== "npm") continue;
580
+ const name = npmPackageName(source);
581
+ const match = POST_INSTALL_REMOVAL_PACKAGES.find((candidate) => candidate.name === name);
582
+ if (match !== undefined && !found.includes(match.source)) found.push(match.source);
583
+ }
584
+ return found;
585
+ }
586
+
403
587
  export type GentlePiDeclaration = { kind: "npm" } | { kind: "path"; dir: string };
404
588
 
405
589
  export interface FindGentlePiDeclarationOptions {
@@ -789,6 +973,20 @@ export function quoteForCmdExe(token: string): string {
789
973
  return `"${token.replace(/"/g, '\\"')}"`;
790
974
  }
791
975
 
976
+ const POSIX_SHELL_SPECIAL_CHARS = /[\s"'`\\$&|;<>(){}*?[\]!#~]/;
977
+
978
+ // POSIX/bash single-quote shell quoting for a copy-pasteable command
979
+ // bin/gentle-shell.mjs prints to stderr (e.g. the setup remediation
980
+ // command): wraps a token in single quotes when it is empty or contains
981
+ // whitespace or a shell metacharacter, escaping an embedded single quote as
982
+ // `'\''` (close quote, escaped literal quote, reopen quote) — inside single
983
+ // quotes nothing else needs escaping, unlike cmd.exe's `"`-based quoting
984
+ // (quoteForCmdExe above).
985
+ export function shellQuote(value: string): string {
986
+ if (value.length > 0 && !POSIX_SHELL_SPECIAL_CHARS.test(value)) return value;
987
+ return `'${value.replace(/'/g, "'\\''")}'`;
988
+ }
989
+
792
990
  export interface PlanSpawnInput {
793
991
  command: string;
794
992
  args: string[];
@@ -809,6 +1007,115 @@ export function planSpawn(input: PlanSpawnInput): SpawnPlan {
809
1007
  return { command, args, shell: false };
810
1008
  }
811
1009
 
1010
+ // --- JSON field restore --------------------------------------------------
1011
+
1012
+ // Detects the indentation unit and trailing-newline presence of a JSON text,
1013
+ // so restoreJsonField below can re-serialize as close to the original
1014
+ // formatting as practical instead of imposing its own. `indent` is
1015
+ // `undefined` for compact (no-whitespace) JSON, matching what
1016
+ // `JSON.stringify(value)` (no third argument) produces.
1017
+ function detectJsonFormatting(text: string): { indent: string | undefined; trailingNewline: boolean } {
1018
+ const match = text.match(/\{\r?\n([ \t]+)/);
1019
+ return { indent: match ? match[1] : undefined, trailingNewline: text.endsWith("\n") };
1020
+ }
1021
+
1022
+ function jsonValuesEqual(a: unknown, b: unknown): boolean {
1023
+ return JSON.stringify(a) === JSON.stringify(b);
1024
+ }
1025
+
1026
+ // Pure JSON merge: restores `field` in `currentText` back to whatever it was
1027
+ // in `originalText`, keeping every other field exactly as `currentText` left
1028
+ // it, and formatting the result to match `originalText`'s indentation and
1029
+ // trailing newline. Used by bin/gentle-shell.mjs's setup flow to restore
1030
+ // `managed_asset_digest` in the user's shared `~/.gentle-ai/state.json` after
1031
+ // the pinned gentle-ai spawn rewrites it (the same shared-file problem
1032
+ // persona.json has — see sharedPersonaPath/snapshotFile/restoreFile in
1033
+ // bin/gentle-shell.mjs — but state.json also carries fields the pinned
1034
+ // gentle-ai is supposed to update, like installed_agents, so this restores
1035
+ // only the one field instead of the whole file).
1036
+ //
1037
+ // Returns the new text, or `undefined` when either text fails to parse as a
1038
+ // JSON object, or the field's presence and value are already identical on
1039
+ // both sides (nothing to restore). Never called by the caller when
1040
+ // `originalText` comes from a file that did not exist before the spawn —
1041
+ // there is nothing to restore a nonexistent file back to.
1042
+ export function restoreJsonField(originalText: string, currentText: string, field: string): string | undefined {
1043
+ let originalValue: unknown;
1044
+ let currentValue: unknown;
1045
+ try {
1046
+ originalValue = JSON.parse(originalText);
1047
+ currentValue = JSON.parse(currentText);
1048
+ } catch {
1049
+ return undefined;
1050
+ }
1051
+ if (
1052
+ typeof originalValue !== "object" ||
1053
+ originalValue === null ||
1054
+ Array.isArray(originalValue) ||
1055
+ typeof currentValue !== "object" ||
1056
+ currentValue === null ||
1057
+ Array.isArray(currentValue)
1058
+ ) {
1059
+ return undefined;
1060
+ }
1061
+ const originalObj = originalValue as Record<string, unknown>;
1062
+ const currentObj = currentValue as Record<string, unknown>;
1063
+ const hadField = Object.prototype.hasOwnProperty.call(originalObj, field);
1064
+ const hasFieldNow = Object.prototype.hasOwnProperty.call(currentObj, field);
1065
+ const unchanged = hadField === hasFieldNow && (!hadField || jsonValuesEqual(originalObj[field], currentObj[field]));
1066
+ if (unchanged) return undefined;
1067
+
1068
+ let restored: Record<string, unknown>;
1069
+ if (hadField) {
1070
+ restored = { ...currentObj, [field]: originalObj[field] };
1071
+ } else {
1072
+ restored = { ...currentObj };
1073
+ delete restored[field];
1074
+ }
1075
+
1076
+ const { indent, trailingNewline } = detectJsonFormatting(originalText);
1077
+ const serialized = JSON.stringify(restored, null, indent);
1078
+ return trailingNewline ? `${serialized}\n` : serialized;
1079
+ }
1080
+
1081
+ // Pure JSON merge: forces `field` in `currentText` to `value`, but only when
1082
+ // `originalText` (the state from before whatever wrote `currentText`) did not
1083
+ // declare that field at all — never overriding a value the original already
1084
+ // had, in either direction. Keeps every other field exactly as `currentText`
1085
+ // left it, and formats the result to match `currentText`'s own indentation
1086
+ // and trailing newline (unlike restoreJsonField above, which matches the
1087
+ // *original*'s formatting — here `currentText` is what the other writer just
1088
+ // produced, so its own convention is respected instead of imposed on).
1089
+ // Used by bin/gentle-shell.mjs's setup flow so a home gentle-shell provisions
1090
+ // ends up with the maintainer's default theme unless the home (or the user)
1091
+ // already had an opinion about it, even when gentle-ai's own managed install
1092
+ // writes a *different* default theme into settings.json.
1093
+ //
1094
+ // Returns the new text, or `undefined` when either text fails to parse as a
1095
+ // JSON object, the original text already declared `field` (nothing to
1096
+ // force), or the current value already equals `value` (nothing to change).
1097
+ export function forceJsonFieldIfAbsentInOriginal(originalText: string, currentText: string, field: string, value: unknown): string | undefined {
1098
+ let originalValue: unknown;
1099
+ let currentValue: unknown;
1100
+ try {
1101
+ originalValue = JSON.parse(originalText);
1102
+ currentValue = JSON.parse(currentText);
1103
+ } catch {
1104
+ return undefined;
1105
+ }
1106
+ if (typeof originalValue !== "object" || originalValue === null || Array.isArray(originalValue)) return undefined;
1107
+ if (typeof currentValue !== "object" || currentValue === null || Array.isArray(currentValue)) return undefined;
1108
+ const originalObj = originalValue as Record<string, unknown>;
1109
+ const currentObj = currentValue as Record<string, unknown>;
1110
+ if (Object.prototype.hasOwnProperty.call(originalObj, field)) return undefined;
1111
+ if (jsonValuesEqual(currentObj[field], value)) return undefined;
1112
+
1113
+ const forced = { ...currentObj, [field]: value };
1114
+ const { indent, trailingNewline } = detectJsonFormatting(currentText);
1115
+ const serialized = JSON.stringify(forced, null, indent);
1116
+ return trailingNewline ? `${serialized}\n` : serialized;
1117
+ }
1118
+
812
1119
  // --- reporting ---------------------------------------------------------------
813
1120
 
814
1121
  export interface DescribeVersionInput {
@@ -829,6 +1136,7 @@ export function helpText(): string {
829
1136
  return [
830
1137
  "Usage: gentle-shell [options] [-- pi-args...]",
831
1138
  " gentle-shell home [link|isolated|<path>]",
1139
+ " gentle-shell [home selectors] setup [--dry-run]",
832
1140
  "",
833
1141
  "Opens pi with the Gentle Shell package loaded, without touching your",
834
1142
  "vanilla pi installation.",
@@ -844,6 +1152,10 @@ export function helpText(): string {
844
1152
  "",
845
1153
  "Commands:",
846
1154
  " home Print or persist the effective home mode (link, isolated, or a path).",
1155
+ " setup Provision the resolved home with the gentle-ai companion packages",
1156
+ " (runs the package-local gentle-ai 'install --agent pi --scope global').",
1157
+ " Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector",
1158
+ " (--link, --isolated, --home <dir>) before it.",
847
1159
  "",
848
1160
  "Managing packages:",
849
1161
  " gentle-shell install npm:<pkg> Run pi's own 'install' against the resolved home.",
@@ -9,7 +9,11 @@
9
9
  // the caller's "provider/id" selection through pi's live model registry,
10
10
  // authenticates through the registry's own resolver, and completes exactly
11
11
  // one frozen prompt as a single user message — no systemPrompt, no tools, no
12
- // session, no extension hooks.
12
+ // session, and none of pi's tool/skill/prompt extension hooks. Extension
13
+ // *providers* are the opposite: they are explicitly in scope. Reading the
14
+ // earlier "no extension hooks" wording as "no extension providers" is what
15
+ // produced gentle-shell#1304, where every extension-registered model was
16
+ // unreachable, so the distinction is stated here rather than implied.
13
17
  //
14
18
  // Every I/O seam is injected (`registry`, `complete`, `now`), so this module
15
19
  // runs under tests with no network and no pi process. gentle-pi#311 P2 wires
@@ -22,18 +26,32 @@ import { SAFE_MODEL_ID_PATTERN } from "./model-routing-authority.ts";
22
26
 
23
27
  // ---------------------------------------------------------------------------
24
28
  // Registry seam — a structural subset of pi's live ModelRegistry
25
- // (@earendil-works/pi-coding-agent core/model-registry.ts). Only `find` and
26
- // `getApiKeyAndHeaders` are needed here; the real registry's resolved auth
27
- // carries extra optional fields (`baseUrl`, `env`) that this narrower shape
28
- // simply ignores.
29
+ // (@earendil-works/pi-coding-agent core/model-registry.ts). `find` and
30
+ // `getApiKeyAndHeaders` are required. `getProvider` is optional and selects
31
+ // the dispatch path: when present, the completion goes through the composed
32
+ // provider it returns (which is what reaches extension-registered providers);
33
+ // when absent, it falls back to `deps.complete`, so a test double with no
34
+ // composition layer still satisfies the seam. The real registry's resolved
35
+ // auth carries extra optional fields (`baseUrl`, `env`) that this narrower
36
+ // shape simply ignores.
29
37
  // ---------------------------------------------------------------------------
30
38
 
39
+ /**
40
+ * The single method this module needs from a resolved provider. pi's
41
+ * `Provider.streamSimple` returns an `AssistantMessageEventStream`; only its
42
+ * `result()` is consumed here, so the seam asks for nothing more.
43
+ */
44
+ export interface InProcessReviewerProvider {
45
+ streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): { result(): Promise<AssistantMessage> };
46
+ }
47
+
31
48
  export interface InProcessReviewerRegistry {
32
49
  find(provider: string, modelId: string): Model<Api> | undefined;
33
50
  getApiKeyAndHeaders(model: Model<Api>): Promise<
34
51
  | { readonly ok: true; readonly apiKey?: string; readonly headers?: ProviderHeaders }
35
52
  | { readonly ok: false; readonly error: string }
36
53
  >;
54
+ getProvider?(provider: string): InProcessReviewerProvider | undefined;
37
55
  }
38
56
 
39
57
  export const INPROCESS_REVIEWER_FAILURE = {
@@ -180,6 +198,33 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
180
198
  );
181
199
  }
182
200
 
201
+ // pi's composed provider is the only layer that honors extension-registered
202
+ // providers (core/provider-composer.ts: `extension.streamSimple` when
203
+ // `model.api === extension.api`). pi-ai's `completeSimple` resolves against
204
+ // its own builtin-only registry and throws for any extension api, so a seam
205
+ // that carries `getProvider` is always preferred; a seam without it at all
206
+ // (a test double with no composition layer) still uses `deps.complete`.
207
+ // A registry that has `getProvider` yet owns no provider for a model its
208
+ // own `find` just resolved is incoherent: refuse, never route past it.
209
+ //
210
+ // The lookup deliberately uses `parsed.provider` — the key `find` was
211
+ // called with — and not `model.provider`, which is a field of the object
212
+ // `find` returned. In pi's registry both reads hit one provider map
213
+ // (core/model-runtime.ts `getProvider`/`getModel` -> pi-ai models.ts, where
214
+ // `getModels(provider)` returns [] for an unknown id), so a resolved model
215
+ // always has a provider under its own key and this branch is unreachable by
216
+ // construction. Keying on the returned field instead would make that
217
+ // guarantee depend on every provider's `getModels()` echoing its own id,
218
+ // which a native or OAuth-modified extension provider is free not to do.
219
+ const provider = deps.registry.getProvider?.(parsed.provider);
220
+ if (deps.registry.getProvider !== undefined && provider === undefined) {
221
+ return refuse(
222
+ INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND,
223
+ `The model registry resolved ${JSON.stringify(request.selection)} for ${request.routingKey} but owns no provider for ${JSON.stringify(parsed.provider)}; reassign ${request.routingKey} to a model whose provider the interactive pi can actually dispatch.`,
224
+ { provider: parsed.provider, api: model.api },
225
+ );
226
+ }
227
+
183
228
  const auth = await deps.registry.getApiKeyAndHeaders(model);
184
229
  // Negation narrowing (`!auth.ok`) does not eliminate the `ok: true` arm of
185
230
  // this discriminated union under this project's `strict: false` tsconfig;
@@ -245,9 +290,12 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
245
290
  return undefined;
246
291
  };
247
292
 
293
+ // `SimpleStreamOptions` is identical on both paths; only the return shape
294
+ // differs (an event stream versus a promise), hence `.result()` — which is
295
+ // exactly what pi-ai's own compat layer does with the same stream.
248
296
  let assistant: AssistantMessage;
249
297
  try {
250
- assistant = await deps.complete(model, context, options);
298
+ assistant = provider === undefined ? await deps.complete(model, context, options) : await provider.streamSimple(model, context, options).result();
251
299
  } catch (error) {
252
300
  return abortRefusal() ?? refuse(INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED, `Reviewer completion failed for ${request.routingKey}: ${sanitizeErrorExcerpt(error)}`);
253
301
  }
@@ -1032,6 +1032,18 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1032
1032
  // closed START/STATUS fields this row negotiates. riskEvidence and hint
1033
1033
  // remain dark; neither is proven to reach Pi's negotiated START path.
1034
1034
  "3.5.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1035
+ // v3.6.0 repeats 3.5.0: the published provider contract bundle is
1036
+ // byte-identical at 1.2.0, both binaries advertise capabilities/v2.6
1037
+ // with only build-identity differences, and no review-integration schema
1038
+ // changed between the v3.5.0 and v3.6.0 tags. riskEvidence and hint
1039
+ // remain dark; neither is proven to reach Pi's negotiated START path.
1040
+ "3.6.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1041
+ // v3.6.1 repeats 3.6.0: published provider-contract archives are byte-identical
1042
+ // (SHA-256 547b68e172cc87aa297309d61624e5fc2c24d407a494b53eeb5a2b053904352c).
1043
+ // The published v3.6.1 binary advertises capabilities/v2.6, and the tag diff
1044
+ // changes no review-integration schema or capability source. riskEvidence and
1045
+ // hint remain dark because neither is proven in Pi's negotiated START path.
1046
+ "3.6.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1035
1047
  });
1036
1048
 
1037
1049
  export interface NativeReviewProcessDiagnostics {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "3.5.1",
3
+ "version": "3.6.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",