gentle-pi 3.3.0 → 3.5.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 (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. package/skills/release/SKILL.md +0 -137
@@ -0,0 +1,482 @@
1
+ import { join } from "node:path";
2
+
3
+ // The gentle-shell launcher: pure, side-effect-free functions over injected
4
+ // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
5
+ // filesystem and child process so this module stays fully unit-testable.
6
+
7
+ export type LauncherCommand = "home";
8
+
9
+ // pi's own package-management subcommands (see pi's cli/args.ts printHelp
10
+ // "Commands" list): each is dispatched by pi itself, before pi's own flag
11
+ // parsing, purely on argv[0]. `uninstall` is pi's alias for `remove`.
12
+ export const PI_SUBCOMMANDS = ["install", "remove", "uninstall", "update", "list", "config", "auth"] as const;
13
+
14
+ export type PiSubcommand = (typeof PI_SUBCOMMANDS)[number];
15
+
16
+ function isPiSubcommand(token: string): token is PiSubcommand {
17
+ return (PI_SUBCOMMANDS as readonly string[]).includes(token);
18
+ }
19
+
20
+ export interface ParsedLauncherArgs {
21
+ link: boolean;
22
+ isolated: boolean;
23
+ home?: string;
24
+ help: boolean;
25
+ version: boolean;
26
+ command?: LauncherCommand;
27
+ commandArgs: string[];
28
+ passthrough: string[];
29
+ // Set when the first passthrough token is one of PI_SUBCOMMANDS (e.g.
30
+ // `gentle-shell install npm:x`). It stays part of `passthrough` — this
31
+ // field only tells buildPiInvocation to skip its extension injection, so
32
+ // pi sees the bare subcommand it expects as argv[0].
33
+ piSubcommand?: PiSubcommand;
34
+ error?: string;
35
+ }
36
+
37
+ // Home-subcommand parsing is deliberately shallow: `home` only counts as the
38
+ // subcommand when it is argv[0], and everything after it is handed over
39
+ // untouched as commandArgs — T2 owns interpreting `home link|isolated|<path>`.
40
+ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
41
+ if (argv[0] === "home") {
42
+ return {
43
+ link: false,
44
+ isolated: false,
45
+ home: undefined,
46
+ help: false,
47
+ version: false,
48
+ command: "home",
49
+ commandArgs: argv.slice(1),
50
+ passthrough: [],
51
+ piSubcommand: undefined,
52
+ error: undefined,
53
+ };
54
+ }
55
+
56
+ let link = false;
57
+ let isolated = false;
58
+ let home: string | undefined;
59
+ let help = false;
60
+ let version = false;
61
+ let error: string | undefined;
62
+ let piSubcommand: PiSubcommand | undefined;
63
+ const passthrough: string[] = [];
64
+
65
+ for (let i = 0; i < argv.length; i += 1) {
66
+ const arg = argv[i];
67
+ if (arg === "--") {
68
+ const rest = argv.slice(i + 1);
69
+ if (passthrough.length === 0 && rest.length > 0 && isPiSubcommand(rest[0])) {
70
+ piSubcommand = rest[0];
71
+ }
72
+ passthrough.push(...rest);
73
+ break;
74
+ }
75
+ if (arg === "--link") {
76
+ link = true;
77
+ continue;
78
+ }
79
+ if (arg === "--isolated") {
80
+ isolated = true;
81
+ continue;
82
+ }
83
+ if (arg === "--help" || arg === "-h") {
84
+ help = true;
85
+ continue;
86
+ }
87
+ if (arg === "--version") {
88
+ version = true;
89
+ continue;
90
+ }
91
+ if (arg.startsWith("--home=")) {
92
+ const value = arg.slice("--home=".length);
93
+ if (value.length === 0) {
94
+ error = "--home requires a non-empty path argument";
95
+ continue;
96
+ }
97
+ home = value;
98
+ continue;
99
+ }
100
+ if (arg === "--home") {
101
+ const value = argv[i + 1];
102
+ if (value === undefined || value.length === 0) {
103
+ error = "--home requires a non-empty path argument";
104
+ if (value !== undefined) i += 1;
105
+ continue;
106
+ }
107
+ home = value;
108
+ i += 1;
109
+ continue;
110
+ }
111
+ if (passthrough.length === 0 && isPiSubcommand(arg)) {
112
+ piSubcommand = arg;
113
+ }
114
+ passthrough.push(arg);
115
+ }
116
+
117
+ if (error === undefined) {
118
+ if (link && isolated) {
119
+ error = "--link cannot be combined with --isolated";
120
+ } else if (link && home !== undefined) {
121
+ error = "--link cannot be combined with --home";
122
+ } else if (isolated && home !== undefined) {
123
+ error = "--isolated cannot be combined with --home";
124
+ }
125
+ }
126
+
127
+ return { link, isolated, home, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
128
+ }
129
+
130
+ // --- home resolution -------------------------------------------------------
131
+
132
+ export type HomeMode = "link" | "isolated" | "path";
133
+ export type HomeSource = "flag" | "config" | "default";
134
+
135
+ export interface ResolvedHome {
136
+ mode: HomeMode;
137
+ dir: string;
138
+ source: HomeSource;
139
+ }
140
+
141
+ // A discriminated union instead of a plain `home: string` field: `resolveHome`
142
+ // switches on `mode` rather than re-parsing the raw on-disk string, and the
143
+ // `path` case carries its `dir` explicitly so a "link"/"isolated" string can
144
+ // never be mistaken for a filesystem path at the call site.
145
+ export type LauncherConfig = { mode: "link" } | { mode: "isolated" } | { mode: "path"; dir: string };
146
+
147
+ export interface ResolveHomeInput {
148
+ args: ParsedLauncherArgs;
149
+ env: Record<string, string | undefined>;
150
+ homedir: string;
151
+ config: LauncherConfig | undefined;
152
+ }
153
+
154
+ // Pi Subagents resolves `PI_CODING_AGENT_DIR || ~/.pi/agent`; `--link` reuses
155
+ // that exact home so gentle-shell never diverges from the user's own pi.
156
+ function linkDir(env: Record<string, string | undefined>, homedir: string): string {
157
+ return env.PI_CODING_AGENT_DIR || join(homedir, ".pi", "agent");
158
+ }
159
+
160
+ function isolatedDir(env: Record<string, string | undefined>, homedir: string): string {
161
+ return env.GENTLE_SHELL_HOME || join(homedir, ".gentle-shell", "agent");
162
+ }
163
+
164
+ export function resolveHome(input: ResolveHomeInput): ResolvedHome {
165
+ const { args, env, homedir, config } = input;
166
+
167
+ if (args.link) return { mode: "link", dir: linkDir(env, homedir), source: "flag" };
168
+ if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" };
169
+ if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" };
170
+
171
+ if (config !== undefined) {
172
+ if (config.mode === "link") return { mode: "link", dir: linkDir(env, homedir), source: "config" };
173
+ if (config.mode === "isolated") return { mode: "isolated", dir: isolatedDir(env, homedir), source: "config" };
174
+ return { mode: "path", dir: config.dir, source: "config" };
175
+ }
176
+
177
+ return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" };
178
+ }
179
+
180
+ export function launcherConfigPath(homedir: string): string {
181
+ return join(homedir, ".gentle-shell", "config.json");
182
+ }
183
+
184
+ // Tolerant on purpose: a malformed or foreign config.json must never crash
185
+ // the launcher, it just falls through to the default isolated home.
186
+ //
187
+ // The on-disk shape stays the flat `{ "home": "link" | "isolated" | "<path>" }`
188
+ // documented in the feature scope; only the parsed, in-memory `LauncherConfig`
189
+ // is a discriminated union. Any non-empty string other than the exact literals
190
+ // "link" or "isolated" is treated as a path, including a near-miss like
191
+ // "linked" — this is deliberate: there is no separate "unrecognised mode"
192
+ // error, a typo just resolves to a (probably nonexistent) path instead.
193
+ export function parseLauncherConfig(text: string): LauncherConfig | undefined {
194
+ let parsed: unknown;
195
+ try {
196
+ parsed = JSON.parse(text);
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
201
+ const home = (parsed as Record<string, unknown>).home;
202
+ if (typeof home !== "string" || home.length === 0) return undefined;
203
+ if (home === "link") return { mode: "link" };
204
+ if (home === "isolated") return { mode: "isolated" };
205
+ return { mode: "path", dir: home };
206
+ }
207
+
208
+ // --- pi runtime resolution ---------------------------------------------------
209
+
210
+ export type PiRuntimeKind = "env" | "bundled" | "path";
211
+
212
+ export interface PiRuntime {
213
+ kind: PiRuntimeKind;
214
+ command: string;
215
+ args: string[];
216
+ }
217
+
218
+ export interface PiRuntimeDeps {
219
+ env: Record<string, string | undefined>;
220
+ resolveBundledCli: () => string | undefined;
221
+ findOnPath: (name: string) => string | undefined;
222
+ nodeExecPath: string;
223
+ }
224
+
225
+ export function resolvePiRuntime(deps: PiRuntimeDeps): PiRuntime | undefined {
226
+ const envOverride = deps.env.GENTLE_SHELL_PI;
227
+ if (envOverride !== undefined && envOverride.length > 0) return { kind: "env", command: envOverride, args: [] };
228
+
229
+ const bundledCliPath = deps.resolveBundledCli();
230
+ if (bundledCliPath !== undefined) return { kind: "bundled", command: deps.nodeExecPath, args: [bundledCliPath] };
231
+
232
+ const onPath = deps.findOnPath("pi");
233
+ if (onPath !== undefined) return { kind: "path", command: onPath, args: [] };
234
+
235
+ return undefined;
236
+ }
237
+
238
+ export function missingPiMessage(): string {
239
+ return [
240
+ "No pi runtime could be found. Pick one of:",
241
+ " - Set GENTLE_SHELL_PI to the path of a pi executable.",
242
+ " - Install @earendil-works/pi-coding-agent next to gentle-pi (it ships as an optional peer dependency).",
243
+ " - Install pi and make sure it is on your PATH.",
244
+ ].join("\n");
245
+ }
246
+
247
+ // --- pi version gate ---------------------------------------------------------
248
+
249
+ export const MIN_PI_VERSION = "0.85.1";
250
+
251
+ export type PiVersionCheck = { ok: true; version: string } | { ok: false; message: string; version?: string };
252
+
253
+ const VERSION_PATTERN = /v?(\d+)\.(\d+)\.(\d+)/;
254
+
255
+ function compareVersions(a: readonly [number, number, number], b: readonly [number, number, number]): number {
256
+ for (let i = 0; i < 3; i += 1) {
257
+ if (a[i] !== b[i]) return a[i] - b[i];
258
+ }
259
+ return 0;
260
+ }
261
+
262
+ export function checkPiVersion(output: string, minimum: string = MIN_PI_VERSION): PiVersionCheck {
263
+ const match = VERSION_PATTERN.exec(output);
264
+ if (!match) {
265
+ return { ok: false, message: `Could not determine the pi version from "${output.trim()}" (need at least ${minimum}).` };
266
+ }
267
+ const version = `${match[1]}.${match[2]}.${match[3]}`;
268
+ const minimumMatch = VERSION_PATTERN.exec(minimum);
269
+ if (!minimumMatch) throw new Error(`invalid minimum version "${minimum}"`);
270
+ const found: [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3])];
271
+ const wanted: [number, number, number] = [Number(minimumMatch[1]), Number(minimumMatch[2]), Number(minimumMatch[3])];
272
+ if (compareVersions(found, wanted) < 0) {
273
+ return { ok: false, version, message: `pi version ${version} is older than the required minimum ${minimum}.` };
274
+ }
275
+ return { ok: true, version };
276
+ }
277
+
278
+ // --- packaging drift guard -----------------------------------------------------
279
+
280
+ export interface PackageJsonPeerShape {
281
+ peerDependencies?: Record<string, string>;
282
+ }
283
+
284
+ export type PeerVersionPinCheck = { ok: true; pinned: string } | { ok: false; message: string };
285
+
286
+ // Keeps the MIN_PI_VERSION drift-guard test's failure readable: a missing
287
+ // peerDependencies block, a missing peer entry, or a malformed range must
288
+ // fail with a clear assertion message, not a raw TypeError from indexing an
289
+ // undefined value the way a direct `packageJson.peerDependencies[peerName]`
290
+ // lookup would.
291
+ export function checkPeerVersionPin(packageJson: PackageJsonPeerShape, peerName: string, minVersion: string): PeerVersionPinCheck {
292
+ const peerDependencies = packageJson.peerDependencies;
293
+ if (peerDependencies === undefined) {
294
+ return { ok: false, message: "package.json is missing a peerDependencies block" };
295
+ }
296
+ const pinned = peerDependencies[peerName];
297
+ if (typeof pinned !== "string") {
298
+ return { ok: false, message: `package.json peerDependencies is missing "${peerName}"` };
299
+ }
300
+ if (!/^>=\d+\.\d+\.\d+$/.test(pinned)) {
301
+ return { ok: false, message: `package.json peerDependencies["${peerName}"] ("${pinned}") is not a simple >=x.y.z range` };
302
+ }
303
+ const version = pinned.replace(/^>=/, "");
304
+ if (version !== minVersion) {
305
+ return { ok: false, message: `MIN_PI_VERSION ("${minVersion}") does not match the pinned peer range ("${pinned}")` };
306
+ }
307
+ return { ok: true, pinned };
308
+ }
309
+
310
+ // --- settings.json detection ---------------------------------------------------
311
+
312
+ function packageEntryDeclaresGentlePi(entry: unknown): boolean {
313
+ const declares = (value: unknown): boolean => typeof value === "string" && (value === "npm:gentle-pi" || value.startsWith("npm:gentle-pi@"));
314
+ if (declares(entry)) return true;
315
+ if (entry !== null && typeof entry === "object") return declares((entry as Record<string, unknown>).source);
316
+ return false;
317
+ }
318
+
319
+ export function settingsDeclareGentlePi(settingsText: string | undefined): boolean {
320
+ if (settingsText === undefined) return false;
321
+ let parsed: unknown;
322
+ try {
323
+ parsed = JSON.parse(settingsText);
324
+ } catch {
325
+ return false;
326
+ }
327
+ if (typeof parsed !== "object" || parsed === null) return false;
328
+ const packages = (parsed as Record<string, unknown>).packages;
329
+ if (!Array.isArray(packages)) return false;
330
+ return packages.some(packageEntryDeclaresGentlePi);
331
+ }
332
+
333
+ // --- pi invocation builder ---------------------------------------------------
334
+
335
+ export interface BuildPiInvocationInput {
336
+ runtime: PiRuntime;
337
+ home: ResolvedHome;
338
+ packageRoot: string;
339
+ settingsDeclareGentlePi: boolean;
340
+ passthrough: string[];
341
+ // Set when parseLauncherArgs recognised passthrough[0] as one of
342
+ // PI_SUBCOMMANDS. pi dispatches install/remove/uninstall/update/list/
343
+ // config/auth on argv[0] before its own flag parsing, so none of the
344
+ // gentle-pi extension injection below may precede it.
345
+ piSubcommand?: PiSubcommand;
346
+ baseEnv: Record<string, string | undefined>;
347
+ }
348
+
349
+ export interface PiInvocation {
350
+ command: string;
351
+ args: string[];
352
+ env: Record<string, string | undefined>;
353
+ }
354
+
355
+ // The `-e/--theme/--skill/--prompt-template` injection is skipped when the
356
+ // caller already confirmed the target settings.json declares the package
357
+ // (the `--link` case with a pi-managed install), or when passthrough[0] is
358
+ // one of pi's own subcommands: pi dispatches install/remove/uninstall/
359
+ // update/list/config/auth on argv[0] before it even parses flags, so any
360
+ // injected flag ahead of it stops pi from recognising its subcommand at
361
+ // all — this is exactly the observed 2026-09-22 bug where `gentle-shell
362
+ // install npm:x` opened an interactive pi session instead of running the
363
+ // package manager. Isolated and path homes never declare the package, so
364
+ // callers pass `settingsDeclareGentlePi: false` for those and the
365
+ // injection always happens there, unless a pi subcommand is set.
366
+ export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation {
367
+ const args = [...input.runtime.args];
368
+ if (input.piSubcommand === undefined && !input.settingsDeclareGentlePi) {
369
+ args.push(
370
+ "-e",
371
+ input.packageRoot,
372
+ "--theme",
373
+ join(input.packageRoot, "themes"),
374
+ "--skill",
375
+ join(input.packageRoot, "skills"),
376
+ "--prompt-template",
377
+ join(input.packageRoot, "prompts"),
378
+ );
379
+ }
380
+ args.push(...input.passthrough);
381
+
382
+ return {
383
+ command: input.runtime.command,
384
+ args,
385
+ env: { ...input.baseEnv, PI_CODING_AGENT_DIR: input.home.dir, GENTLE_PI_AGENT_HOME: input.home.dir },
386
+ };
387
+ }
388
+
389
+ // --- spawn planning ------------------------------------------------------------
390
+
391
+ // R3-001: `findOnPath` can resolve a PATHEXT candidate such as a .CMD or .BAT
392
+ // shim on win32 (exactly how an npm-installed `pi` lands on PATH), and a
393
+ // GENTLE_SHELL_PI override can point at one too. Current Node releases refuse
394
+ // to spawn a batch file directly without `shell: true` (EINVAL), so both the
395
+ // version probe and the real launch route a batch shim through cmd.exe as one
396
+ // quoted command line instead of spawning it directly.
397
+ const CMD_EXE_SPECIAL_CHARS = /[\s"&|<>^%()]/;
398
+
399
+ // cmd.exe quoting is deliberately simple, not a full cmd.exe parser: wrap a
400
+ // token in double quotes when it is empty or contains whitespace or any of
401
+ // `"&|<>^%()`, and escape an inner `"` as `\"` — doubling inner quotes is not
402
+ // reliable in cmd.exe, unlike the `\"` convention Node's own Windows spawn
403
+ // helpers use.
404
+ export function quoteForCmdExe(token: string): string {
405
+ if (token.length > 0 && !CMD_EXE_SPECIAL_CHARS.test(token)) return token;
406
+ return `"${token.replace(/"/g, '\\"')}"`;
407
+ }
408
+
409
+ export interface PlanSpawnInput {
410
+ command: string;
411
+ args: string[];
412
+ platform: NodeJS.Platform;
413
+ }
414
+
415
+ export interface SpawnPlan {
416
+ command: string;
417
+ args: string[];
418
+ shell: boolean;
419
+ }
420
+
421
+ export function planSpawn(input: PlanSpawnInput): SpawnPlan {
422
+ const { command, args, platform } = input;
423
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
424
+ return { command: [command, ...args].map(quoteForCmdExe).join(" "), args: [], shell: true };
425
+ }
426
+ return { command, args, shell: false };
427
+ }
428
+
429
+ // --- reporting ---------------------------------------------------------------
430
+
431
+ export interface DescribeVersionInput {
432
+ gentlePiVersion: string;
433
+ piVersion: string | undefined;
434
+ home: ResolvedHome;
435
+ }
436
+
437
+ export function describeVersion(input: DescribeVersionInput): string {
438
+ return [
439
+ `gentle-shell ${input.gentlePiVersion}`,
440
+ `pi ${input.piVersion ?? "not found"}`,
441
+ `home ${input.home.mode} ${input.home.dir}`,
442
+ ].join("\n");
443
+ }
444
+
445
+ export function helpText(): string {
446
+ return [
447
+ "Usage: gentle-shell [options] [-- pi-args...]",
448
+ " gentle-shell home [link|isolated|<path>]",
449
+ "",
450
+ "Opens pi with the Gentle Shell package loaded, without touching your",
451
+ "vanilla pi installation.",
452
+ "",
453
+ "Options:",
454
+ " --link Use your existing pi agent home (never edits its settings.json).",
455
+ " --isolated Use the dedicated ~/.gentle-shell/agent home (default).",
456
+ " --home <path> Use a custom agent home directory.",
457
+ " --help, -h Show this help text.",
458
+ " --version Show gentle-shell, pi, and home version information.",
459
+ "",
460
+ "Commands:",
461
+ " home Print or persist the effective home mode (link, isolated, or a path).",
462
+ "",
463
+ "Managing packages:",
464
+ " gentle-shell install npm:<pkg> Run pi's own 'install' against the resolved home.",
465
+ " gentle-shell remove <source> Run pi's own 'remove' against the resolved home.",
466
+ " gentle-shell list Run pi's own 'list' against the resolved home.",
467
+ " gentle-shell update [target] Run pi's own 'update' against the resolved home.",
468
+ " gentle-shell config Run pi's own 'config' against the resolved home.",
469
+ " gentle-shell auth <command> Run pi's own 'auth' against the resolved home.",
470
+ " These run pi's own commands, forwarded verbatim, against the --isolated home",
471
+ " (or your own pi home with --link). Running 'gentle-shell install npm:gentle-pi'",
472
+ " inside the isolated home is unnecessary: gentle-shell already loads the",
473
+ " package itself.",
474
+ "",
475
+ "Environment variables:",
476
+ " GENTLE_SHELL_PI Path to the pi executable to run.",
477
+ " GENTLE_SHELL_HOME Directory for the isolated home (default: ~/.gentle-shell/agent).",
478
+ " PI_CODING_AGENT_DIR Directory for the --link home, shared with pi itself.",
479
+ "",
480
+ "Every other argument is forwarded to pi unchanged.",
481
+ ].join("\n");
482
+ }
@@ -59,6 +59,13 @@ export interface InProcessReviewerRequest {
59
59
  readonly prompt: Buffer;
60
60
  readonly timeoutMs: number;
61
61
  readonly signal?: AbortSignal;
62
+ /**
63
+ * The live pi session id, threaded from the extension context. Pi's main
64
+ * agent loop adds OpenCode attribution headers itself; this side-call must
65
+ * carry them itself instead. Absent (or empty) means no attribution header,
66
+ * never an invented one and never an error.
67
+ */
68
+ readonly sessionId?: string;
62
69
  /** e.g. "review-risk" — only used to name the routing config key in refusal messages. */
63
70
  readonly routingKey: string;
64
71
  }
@@ -126,6 +133,30 @@ function isTextContent(part: { type?: unknown }): part is TextContent {
126
133
  return part.type === "text";
127
134
  }
128
135
 
136
+ /**
137
+ * Mirrors pi's main-loop OpenCode attribution condition exactly
138
+ * (core/provider-attribution.js#getSessionHeaders): the model's provider is
139
+ * `opencode` or `opencode-go`, or its baseUrl host is `opencode.ai`. Returns
140
+ * the `{ x-opencode-session, x-opencode-client }` attribution pair, or
141
+ * undefined when the model is not OpenCode-routed or there is no live session
142
+ * id — a missing session id is never an error and never invents a header.
143
+ * The URL parse is guarded: an unparseable baseUrl follows the provider
144
+ * condition alone.
145
+ */
146
+ export function openCodeSessionAttributionHeaders(model: Model<Api>, sessionId: string | undefined): ProviderHeaders | undefined {
147
+ const isOpenCode = model.provider === "opencode"
148
+ || model.provider === "opencode-go"
149
+ || (() => {
150
+ try {
151
+ return new URL(String(model.baseUrl ?? "")).hostname === "opencode.ai";
152
+ } catch {
153
+ return false;
154
+ }
155
+ })();
156
+ if (!isOpenCode || typeof sessionId !== "string" || sessionId.length === 0) return undefined;
157
+ return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
158
+ }
159
+
129
160
  /**
130
161
  * Runs one reviewer completion in-process: resolve the model, authenticate,
131
162
  * map the routing thinking label, and complete exactly one frozen prompt as
@@ -168,6 +199,12 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
168
199
  );
169
200
  }
170
201
 
202
+ // Extension side-calls bypass pi's main agent loop, which is where OpenCode
203
+ // attribution headers are otherwise added, so this completion carries them
204
+ // itself — as a default beneath the registry's own auth headers, the same
205
+ // merge order pi's core uses for explicit header sources.
206
+ const attributionHeaders = openCodeSessionAttributionHeaders(model, request.sessionId);
207
+
171
208
  // The caller's own signal (if any) and a floor timeout race together:
172
209
  // whichever fires first aborts the completion. The catch branch below
173
210
  // tells them apart by which underlying signal actually fired, never by
@@ -188,7 +225,7 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
188
225
  signal: combinedSignal,
189
226
  timeoutMs: request.timeoutMs,
190
227
  ...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
191
- ...(auth.headers === undefined ? {} : { headers: auth.headers }),
228
+ ...(auth.headers === undefined && attributionHeaders === undefined ? {} : { headers: attributionHeaders === undefined ? auth.headers : { ...attributionHeaders, ...auth.headers } }),
192
229
  ...(reasoning.reasoning === undefined ? {} : { reasoning: reasoning.reasoning }),
193
230
  };
194
231
 
@@ -201,7 +201,7 @@ export interface NativeReviewModeRequest {
201
201
  // explicit `committedOnly` acknowledgement, exactly like Native START's
202
202
  // baseRef/committedOnly pairing, because both select a committed range
203
203
  // instead of the ambient working tree.
204
- export interface NativeReviewAssessRequest {
204
+ export interface NativeReviewAssessRequest extends NativeUntrackedSelection {
205
205
  cwd: string;
206
206
  baseRef?: string;
207
207
  committedOnly?: boolean;
@@ -705,7 +705,11 @@ function isNativeUntrackedPath(value: unknown): value is string {
705
705
  && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
706
706
  }
707
707
 
708
- function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): NativeUntrackedSelection {
708
+ export function nativeUntrackedSelection(request: {
709
+ untrackedScope?: unknown;
710
+ expectedUntrackedInventory?: unknown;
711
+ intendedUntracked?: unknown;
712
+ }): NativeUntrackedSelection {
709
713
  const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = request;
710
714
  const declared = untrackedScope !== undefined || expectedUntrackedInventory !== undefined || intendedUntracked !== undefined;
711
715
  if (!declared) return {};
@@ -716,16 +720,19 @@ function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): Nat
716
720
  ) {
717
721
  throw new TypeError("Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths");
718
722
  }
719
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) {
723
+ // The guard above establishes the array and element types for both typed
724
+ // native requests and untyped facade input.
725
+ const paths = intendedUntracked as readonly string[] | undefined;
726
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (paths?.length ?? 0) > 0) {
720
727
  throw new TypeError("Native exclude untracked selection cannot include paths");
721
728
  }
722
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) {
729
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (paths?.length ?? 0) === 0) {
723
730
  throw new TypeError("Native select untracked selection requires at least one path");
724
731
  }
725
732
  return {
726
733
  untrackedScope,
727
734
  expectedUntrackedInventory,
728
- intendedUntracked: intendedUntracked === undefined ? undefined : [...intendedUntracked],
735
+ intendedUntracked: paths === undefined ? undefined : [...paths],
729
736
  };
730
737
  }
731
738
 
@@ -1018,6 +1025,13 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1018
1025
  // and hint remain dark because neither is proven to reach the negotiated
1019
1026
  // START path Pi consumes.
1020
1027
  "3.4.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 }),
1028
+ // v3.5.0 repeats 3.4.0: the published provider contract bundle is
1029
+ // byte-identical at 1.2.0, both binaries advertise capabilities/v2.6
1030
+ // with only build-identity differences, and no review-integration schema
1031
+ // changed. The v2 preflight failure identity fix does not change the
1032
+ // closed START/STATUS fields this row negotiates. riskEvidence and hint
1033
+ // remain dark; neither is proven to reach Pi's negotiated START path.
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 }),
1021
1035
  });
1022
1036
 
1023
1037
  export interface NativeReviewProcessDiagnostics {
@@ -1102,8 +1116,19 @@ function decodeSelectedLenses(value: unknown, riskLevel: string, lensesRequired:
1102
1116
  function enumString(value: unknown, allowed: readonly string[]): string { const parsed = stringValue(value); if (!allowed.includes(parsed)) throw new Error("unsupported enum"); return parsed; }
1103
1117
  const NATIVE_DIAGNOSTIC_TEXT_LIMIT = 4_096;
1104
1118
 
1105
- function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT): string {
1106
- const normalized = value
1119
+ function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation?: NativeReviewOperation): string {
1120
+ // ASSESS diagnostics are projected into a public verification plan. Retain
1121
+ // native guidance, not local paths or environment assignment values.
1122
+ const input = operation === NATIVE_REVIEW_OPERATION.ASSESS
1123
+ ? value
1124
+ .replace(/(?<![\w-])[a-z_][a-z0-9_]*=(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED ENV]")
1125
+ .replace(/--(?:password|token|secret|authorization|cookie|private[_-]key|access[_-]token|[a-z0-9_-]+[_-]token|api[_-]?key)[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED CREDENTIAL]")
1126
+ // Quoted paths have a clear boundary. For an unquoted path, the
1127
+ // remaining line is ambiguous (spaces may belong to the filename).
1128
+ // Redact that suffix rather than leak trailing path components.
1129
+ .replace(/"(?:[A-Za-z]:[\\/]|\/)[^"\r\n]*"|'(?:[A-Za-z]:[\\/]|\/)[^'\r\n]*'|(?:[A-Za-z]:[\\/]|\/)[^\r\n]*/g, "[REDACTED PATH]")
1130
+ : value;
1131
+ const normalized = input
1107
1132
  .replace(/\x1b](?:[^\x07\x1b]|\x1b(?!\\))*?(?:\x07|\x1b\\)/g, "[REDACTED CONTROL]")
1108
1133
  .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "[REDACTED CONTROL]")
1109
1134
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "[REDACTED CONTROL]")
@@ -1137,7 +1162,7 @@ export function sanitizeForeignNativeReviewDiagnostics(value: unknown): NativeRe
1137
1162
  timed_out: booleanValue(raw.timed_out),
1138
1163
  output_limit_exceeded: booleanValue(raw.output_limit_exceeded),
1139
1164
  ...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint! }),
1140
- ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }),
1165
+ ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr), NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) }),
1141
1166
  };
1142
1167
  } catch { return undefined; }
1143
1168
  }
@@ -1154,7 +1179,7 @@ function nativeProcessDiagnostics(operation: NativeReviewOperation, code: Native
1154
1179
  ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined
1155
1180
  ? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT }
1156
1181
  : {}),
1157
- ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}),
1182
+ ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr, NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) } : {}),
1158
1183
  };
1159
1184
  }
1160
1185
 
@@ -2535,6 +2560,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2535
2560
  // rejects -- callers (the `gentle_review` tool's `assess` operation) fail
2536
2561
  // closed to `high`.
2537
2562
  async assess(request: NativeReviewAssessRequest): Promise<ReviewAssessmentV1> {
2563
+ const selection = nativeUntrackedSelection(request);
2538
2564
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string");
2539
2565
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement");
2540
2566
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef");
@@ -2542,7 +2568,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2542
2568
  const execution = await this.invoke(
2543
2569
  NATIVE_REVIEW_OPERATION.ASSESS,
2544
2570
  cwd,
2545
- ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"],
2571
+ ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...nativeUntrackedSelectionArguments(selection), "--json"],
2546
2572
  false,
2547
2573
  request.signal,
2548
2574
  this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false),