gentle-pi 3.5.1 → 3.7.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 (35) hide show
  1. package/README.md +8 -1
  2. package/assets/orchestrator-delegation.md +2 -0
  3. package/bin/gentle-shell.mjs +900 -23
  4. package/docs/gentle-shell.md +5 -4
  5. package/docs/readme-reference.md +53 -39
  6. package/extensions/gentle-agents.ts +89 -22
  7. package/extensions/gentle-ai.ts +27 -48
  8. package/lib/agents-runner.ts +9 -0
  9. package/lib/foreign-target-grants.ts +32 -0
  10. package/lib/gentle-shell-launcher.ts +314 -2
  11. package/lib/inprocess-reviewer.ts +54 -6
  12. package/lib/native-review-cli.ts +16 -0
  13. package/lib/session-change-capture.ts +10 -1
  14. package/package.json +1 -1
  15. package/runtime/gentle-shell-launcher.mjs +313 -1
  16. package/runtime/native-review-cli.mjs +16 -0
  17. package/scripts/gentle-ai-installer.mjs +10 -10
  18. package/scripts/install-tui-mode-setting.mjs +21 -2
  19. package/scripts/verify-package-files.mjs +2 -2
  20. package/tests/agents-runner.test.ts +22 -0
  21. package/tests/foreign-target-grants.test.ts +58 -0
  22. package/tests/gentle-agents.test.ts +362 -2
  23. package/tests/gentle-ai-binary.test.ts +1 -1
  24. package/tests/gentle-ai-installer.test.ts +54 -49
  25. package/tests/gentle-ai.test.ts +147 -2
  26. package/tests/gentle-shell-bin.test.ts +1739 -4
  27. package/tests/gentle-shell-launcher.test.ts +394 -0
  28. package/tests/inprocess-reviewer.test.ts +179 -0
  29. package/tests/install-tui-mode-setting.test.ts +22 -4
  30. package/tests/native-review-capability-contract.test.ts +34 -1
  31. package/tests/odd-runtime-delegation-gate.test.ts +18 -197
  32. package/tests/package-manifest.test.ts +6 -6
  33. package/tests/runtime-harness.mjs +1 -2
  34. package/tests/session-change-capture.test.ts +12 -1
  35. package/lib/odd-runtime-delegation-gate.ts +0 -88
@@ -10,6 +10,7 @@ import test from "node:test";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import {
12
12
  GENTLE_AI_PENDING_DIGEST,
13
+ INSTALLER_VERSION,
13
14
  GENTLE_AI_RELEASE_ASSETS,
14
15
  GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
15
16
  downloadGentleAiAsset,
@@ -20,16 +21,20 @@ import {
20
21
  trustedSystemExtractor,
21
22
  } from "../scripts/gentle-ai-installer.mjs";
22
23
 
23
- // v3.5.0 digests pinned from the published release: archive digests verified
24
- // against the minisign-signed checksums.txt and freshly computed hashes; binary
25
- // digests computed from the extracted executables.
24
+ // v3.7.0 archive digests independently match the minisign-signed release
25
+ // checksums; binary digests were computed from the extracted executables.
26
26
  const EXPECTED_ASSETS = {
27
- "darwin/amd64": { name: "gentle-ai_3.5.0_darwin_amd64.tar.gz", sha256: "483b8363cc2b717d224ee7a15c834ed518854c31a1d79d84b6ea0551fd7905cf", binarySha256: "06e5ce2936f0b0579fa382b5cd59cf42c23b07404b162d9ff9a036dd107093a4" },
28
- "darwin/arm64": { name: "gentle-ai_3.5.0_darwin_arm64.tar.gz", sha256: "d7a5c233ce23e8fc2ce917ed3e6a2e643655e6e8322fddb3c522324d4cf8ae52", binarySha256: "ec7757fe13f6cd5c9dffa6466d75c5cd68992eac44e684d330c838282c5e2e7e" },
29
- "linux/amd64": { name: "gentle-ai_3.5.0_linux_amd64.tar.gz", sha256: "3ea016622981f5e4f4a80632fb9c4673149c98f4e5cecc4b979b41bda758ba4a", binarySha256: "7a4202f75c8b90056f0ecd8fceaaef9efe407abc6f98bb42a3ee1e5ad69eb197" },
30
- "linux/arm64": { name: "gentle-ai_3.5.0_linux_arm64.tar.gz", sha256: "aca1997b3ae8439c17883cde39c3ba577ae38352ad6be1d1c4b2c6bc428c4505", binarySha256: "4d81f356f6e221bc5730a280bae049df8315232a2470089993200577e36e97cc" },
27
+ "darwin/amd64": { name: "gentle-ai_3.7.0_darwin_amd64.tar.gz", sha256: "e55ff3ee06258a90e9195c0e3a593b85f6d79cc439e9e6c04b2596725120a610", binarySha256: "aa30a940e3b75ac218249b3f92d17afd2b972f833258e8212175a73d8e76d5bf" },
28
+ "darwin/arm64": { name: "gentle-ai_3.7.0_darwin_arm64.tar.gz", sha256: "66eb5740c4506cb2cfd1cc5207439c61cfe07678854f4ac7c83027a95a0e666a", binarySha256: "ca726cf3f9a523dc0112aa113beb634cda389e8b47edc5851254c32979bee89e" },
29
+ "linux/amd64": { name: "gentle-ai_3.7.0_linux_amd64.tar.gz", sha256: "a730a61a43758f04cc9a4ac644945cc0e8652a1e33d6997a0a3d3f0044d2fff5", binarySha256: "002d09fd2b9628a29986a660c1f51f8a5042ff7fd54c8ab15b7b27de96c6cccc" },
30
+ "linux/arm64": { name: "gentle-ai_3.7.0_linux_arm64.tar.gz", sha256: "a3a3d3a974f3d9b67d935fe9e306ae83c305da4ec1baed4a5319c10b044cd0eb", binarySha256: "e29546c51d8d65528bf565239ed3fc174da73c5fa3e861e64f20f84b16f28f26" },
31
31
  } as const;
32
32
 
33
+ test("published Gentle AI v3.7.0 is the installer pin", () => {
34
+ assert.equal(INSTALLER_VERSION, "3.7.0");
35
+ assert.equal(GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM, "h1:MQbzHlLdPklUQn0rVE9Mz94UygHsN2OPe7xMfPn9aGw=");
36
+ });
37
+
33
38
  test("default installer package root is the package containing scripts, not its parent", () => {
34
39
  const installerPath = fileURLToPath(new URL("../scripts/gentle-ai-installer.mjs", import.meta.url));
35
40
  const expectedPackageRoot = dirname(dirname(installerPath));
@@ -38,15 +43,15 @@ test("default installer package root is the package containing scripts, not its
38
43
  assert.notEqual(resolveGentleAiInstallerPackageRoot(), dirname(expectedPackageRoot));
39
44
  });
40
45
 
41
- test("release mapping selects only the supported official v3.5.0 assets and pinned digests", () => {
46
+ test("release mapping selects only the supported official v3.7.0 assets and pinned digests", () => {
42
47
  assert.deepEqual(
43
48
  Object.fromEntries(Object.entries(GENTLE_AI_RELEASE_ASSETS).map(([key, asset]) => [key, { name: asset.name, sha256: asset.sha256, binarySha256: asset.binarySha256 }])),
44
49
  EXPECTED_ASSETS,
45
50
  );
46
- assert.equal(resolveGentleAiReleaseAsset("linux", "x64").name, "gentle-ai_3.5.0_linux_amd64.tar.gz");
47
- assert.equal(resolveGentleAiReleaseAsset("darwin", "arm64").name, "gentle-ai_3.5.0_darwin_arm64.tar.gz");
51
+ assert.equal(resolveGentleAiReleaseAsset("linux", "x64").name, "gentle-ai_3.7.0_linux_amd64.tar.gz");
52
+ assert.equal(resolveGentleAiReleaseAsset("darwin", "arm64").name, "gentle-ai_3.7.0_darwin_arm64.tar.gz");
48
53
  for (const asset of Object.values(GENTLE_AI_RELEASE_ASSETS)) {
49
- assert.match(asset.url, /^https:\/\/github\.com\/Gentleman-Programming\/gentle-ai\/releases\/download\/v3\.5\.0\//);
54
+ assert.match(asset.url, /^https:\/\/github\.com\/Gentleman-Programming\/gentle-ai\/releases\/download\/v3\.7\.0\//);
50
55
  }
51
56
  });
52
57
 
@@ -56,7 +61,7 @@ test("raw release assets are admitted only under a prerelease pin", () => {
56
61
  assert.equal(gentleAiAssetForm("gentle-ai_2.5.0-rc.3_windows_amd64.exe", "2.5.0-rc.3"), "raw-binary");
57
62
  // A raw binary under a stable pin means the pin itself is wrong: stable
58
63
  // releases publish signed archives only, so this fails closed pre-download.
59
- assert.throws(() => gentleAiAssetForm("gentle-ai_3.5.0_linux_amd64", "3.5.0"), /only admitted for a prerelease pin/);
64
+ assert.throws(() => gentleAiAssetForm("gentle-ai_3.7.0_linux_amd64", "3.7.0"), /only admitted for a prerelease pin/);
60
65
  assert.throws(() => gentleAiAssetForm("gentle-ai.dmg", "2.5.0-rc.3"), /unsupported Gentle AI release asset form/);
61
66
  // The current stable pin admits every pinned asset row through the same
62
67
  // gate the installer uses at download time (default installerVersion
@@ -83,7 +88,7 @@ test("release digests are all-or-none and install fails closed while any digest
83
88
  }),
84
89
  /checksum mismatch/,
85
90
  );
86
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai")), false);
91
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
87
92
  }
88
93
  });
89
94
 
@@ -112,7 +117,7 @@ function windowsGoFixture(fixtureOptions: WindowsGoFixtureOptions = {}) {
112
117
  const metadata = [
113
118
  "gentle-ai.exe: go1.25.10",
114
119
  "\tpath\tgithub.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
115
- `\tmod\tgithub.com/gentleman-programming/gentle-ai/v3\tv3.5.0\t${GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM}`,
120
+ `\tmod\tgithub.com/gentleman-programming/gentle-ai/v3\tv3.7.0\t${GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM}`,
116
121
  "\tbuild\t-buildmode=exe", "\tbuild\t-compiler=gc", "\tbuild\tCGO_ENABLED=0", `\tbuild\tGOARCH=${fixtureOptions.goArchitecture ?? "amd64"}`, "\tbuild\tGOOS=windows",
117
122
  ].join("\n");
118
123
  const run = async (file: string, arguments_: string[], options: WindowsGoCall["options"]) => {
@@ -130,7 +135,7 @@ function windowsGoFixture(fixtureOptions: WindowsGoFixtureOptions = {}) {
130
135
  return { stdout: "", stderr: "" };
131
136
  }
132
137
  if (file === goExecutable && arguments_[0] === "version" && arguments_[1] === "-m") return { stdout: metadata, stderr: "" };
133
- if (arguments_.length === 1 && arguments_[0] === "version") return { stdout: fixtureOptions.reportedVersion ?? "gentle-ai 3.5.0\n", stderr: "" };
138
+ if (arguments_.length === 1 && arguments_[0] === "version") return { stdout: fixtureOptions.reportedVersion ?? "gentle-ai 3.7.0\n", stderr: "" };
134
139
  throw new Error(`unexpected command: ${file} ${arguments_.join(" ")}`);
135
140
  };
136
141
  return { calls, run, setGoExecutable: (path: string) => { goExecutable = path; } };
@@ -146,7 +151,7 @@ test("win32 x64 and arm64 install the exact Go SumDB source tag without archive
146
151
  const result = await installGentleAi({ packageRoot, platform: "win32", arch, execFile: fixture.run, resolveGoExecutable: async () => goPath });
147
152
  assert.equal(result.installed, true);
148
153
  assert.deepEqual(fixture.calls.filter((call) => call.file === goPath).map((call) => call.arguments_.slice(0, 2)), [
149
- ["version"], ["install", "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai@v3.5.0"], ["version", "-m"],
154
+ ["version"], ["install", "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai@v3.7.0"], ["version", "-m"],
150
155
  ]);
151
156
  }
152
157
  });
@@ -165,7 +170,7 @@ test("Windows source install reports missing or too-old Go without publishing a
165
170
  () => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: async () => goPath }),
166
171
  (error: unknown) => error instanceof Error && "code" in error && error.code === fixtureOptions.expectedCode,
167
172
  );
168
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai.exe")), false);
173
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai.exe")), false);
169
174
  assert.deepEqual((await readdir(packageRoot)).filter((entry) => entry.includes("install-")), []);
170
175
  }
171
176
  });
@@ -184,7 +189,7 @@ test("Windows source install cleans staging after Go failure or wrong built vers
184
189
  () => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: async () => goPath }),
185
190
  (error: unknown) => error instanceof Error && "code" in error && error.code === fixtureOptions.expectedCode,
186
191
  );
187
- const runtimeDirectory = join(packageRoot, ".gentle-ai", "v3.5.0");
192
+ const runtimeDirectory = join(packageRoot, ".gentle-ai", "v3.7.0");
188
193
  assert.ok(fixture.calls.some((call) => call.arguments_[0] === "install"));
189
194
  assert.equal(existsSync(join(runtimeDirectory, "gentle-ai.exe")), false);
190
195
  assert.equal(existsSync(runtimeDirectory) && (await readdir(runtimeDirectory)).some((entry) => entry.startsWith(".go-install-") || entry.endsWith(".tmp")), false);
@@ -205,7 +210,7 @@ test("Windows source installs reuse only a fully verified package-local binary",
205
210
  assert.equal(reused.installed, false);
206
211
  assert.ok(reuse.calls.every((call) => call.file !== "gentle-ai"), "the installer must never fall back to ambient gentle-ai on PATH");
207
212
 
208
- await writeFile(join(packageRoot, ".gentle-ai", "v3.5.0", "integrity.json"), "{}\n");
213
+ await writeFile(join(packageRoot, ".gentle-ai", "v3.7.0", "integrity.json"), "{}\n");
209
214
  const repaired = windowsGoFixture();
210
215
  repaired.setGoExecutable(goPath);
211
216
  assert.equal((await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: repaired.run, resolveGoExecutable: async () => goPath })).installed, true);
@@ -247,7 +252,7 @@ async function hardenedWindowsGoFixture(packageRoot: string, fixtureOptions: Har
247
252
  const metadata = fixtureOptions.metadataOverride ?? [
248
253
  "gentle-ai.exe: go1.25.10",
249
254
  "\tpath\tgithub.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
250
- `\tmod\tgithub.com/gentleman-programming/gentle-ai/v3\tv3.5.0\t${GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM}`,
255
+ `\tmod\tgithub.com/gentleman-programming/gentle-ai/v3\tv3.7.0\t${GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM}`,
251
256
  "\tbuild\t-buildmode=exe",
252
257
  "\tbuild\t-compiler=gc",
253
258
  "\tbuild\tCGO_ENABLED=0",
@@ -267,7 +272,7 @@ async function hardenedWindowsGoFixture(packageRoot: string, fixtureOptions: Har
267
272
  return { stdout: "", stderr: "" };
268
273
  }
269
274
  if (file === goPath && arguments_[0] === "version" && arguments_[1] === "-m") return { stdout: metadata, stderr: "" };
270
- if (arguments_.length === 1 && arguments_[0] === "version") return { stdout: "gentle-ai 3.5.0\n", stderr: "" };
275
+ if (arguments_.length === 1 && arguments_[0] === "version") return { stdout: "gentle-ai 3.7.0\n", stderr: "" };
271
276
  throw new Error(`unexpected command: ${file} ${arguments_.join(" ")}`);
272
277
  };
273
278
  return {
@@ -309,7 +314,7 @@ test("Windows source installation resolves one validated Go executable and seals
309
314
  const goCalls = fixture.calls.filter((call) => call.file === fixture.goPath);
310
315
  assert.deepEqual(goCalls.map((call) => call.arguments_.slice(0, 2)), [
311
316
  ["version"],
312
- ["install", "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai@v3.5.0"],
317
+ ["install", "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai@v3.7.0"],
313
318
  ["version", "-m"],
314
319
  ]);
315
320
  for (const call of goCalls) {
@@ -335,14 +340,14 @@ test("Windows source manifest binds verified Go metadata and architecture", asyn
335
340
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-provenance-"));
336
341
  const fixture = await hardenedWindowsGoFixture(packageRoot, { architecture: "arm64" });
337
342
  const result = await installGentleAi({ packageRoot, platform: "win32", arch: "arm64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable });
338
- const manifest = JSON.parse(await readFile(join(packageRoot, ".gentle-ai", "v3.5.0", "integrity.json"), "utf8")) as Record<string, string>;
343
+ const manifest = JSON.parse(await readFile(join(packageRoot, ".gentle-ai", "v3.7.0", "integrity.json"), "utf8")) as Record<string, string>;
339
344
  assert.equal(result.installed, true);
340
345
  assert.deepEqual(manifest, {
341
- version: "3.5.0",
346
+ version: "3.7.0",
342
347
  method: "go-sumdb-source-build",
343
348
  package: "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
344
349
  module: "github.com/gentleman-programming/gentle-ai/v3",
345
- tag: "v3.5.0",
350
+ tag: "v3.7.0",
346
351
  architecture: "arm64",
347
352
  binarySha256: createHash("sha256").update("trusted Windows source build").digest("hex"),
348
353
  moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
@@ -362,12 +367,12 @@ test("Windows source installation rejects Go metadata for a different architectu
362
367
  () => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable }),
363
368
  (error: unknown) => error instanceof Error && "code" in error && error.code === "GENTLE_AI_GO_INSTALL_FAILED",
364
369
  );
365
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai.exe")), false);
370
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai.exe")), false);
366
371
  });
367
372
 
368
373
  test("Windows source installation treats a fresh ownerless lock as active", async () => {
369
374
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-ownerless-lock-"));
370
- const lockPath = join(packageRoot, ".gentle-ai", ".v3.5.0.install.lock");
375
+ const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
371
376
  await mkdir(lockPath, { recursive: true });
372
377
  const fixture = await hardenedWindowsGoFixture(packageRoot);
373
378
  await assertManualLockRecoveryRequired(packageRoot, fixture);
@@ -375,7 +380,7 @@ test("Windows source installation treats a fresh ownerless lock as active", asyn
375
380
 
376
381
  test("Windows source installation preserves a lock whose owner nonce changed before release", async () => {
377
382
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-owner-lock-"));
378
- const lockOwnerPath = join(packageRoot, ".gentle-ai", ".v3.5.0.install.lock", "owner.json");
383
+ const lockOwnerPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock", "owner.json");
379
384
  const fixture = await hardenedWindowsGoFixture(packageRoot);
380
385
  let replacedOwner = false;
381
386
  const run = async (...arguments_: Parameters<typeof fixture.run>) => {
@@ -392,7 +397,7 @@ test("Windows source installation preserves a lock whose owner nonce changed bef
392
397
  });
393
398
 
394
399
  async function assertManualLockRecoveryRequired(packageRoot: string, fixture: Awaited<ReturnType<typeof hardenedWindowsGoFixture>>, options: Record<string, unknown> = {}) {
395
- const lockPath = join(packageRoot, ".gentle-ai", ".v3.5.0.install.lock");
400
+ const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
396
401
  await assert.rejects(
397
402
  () => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable, ...options }),
398
403
  (error: unknown) => error instanceof Error && error.message.includes(lockPath) && /confirm no installer is active.*remove.*manually/i.test(error.message),
@@ -402,18 +407,18 @@ async function assertManualLockRecoveryRequired(packageRoot: string, fixture: Aw
402
407
  }
403
408
 
404
409
  function tombstonePath(packageRoot: string, nonce: string): string {
405
- return join(packageRoot, ".gentle-ai", `.v3.5.0.install.tombstone-${nonce}`);
410
+ return join(packageRoot, ".gentle-ai", `.v3.7.0.install.tombstone-${nonce}`);
406
411
  }
407
412
 
408
413
  async function tombstones(packageRoot: string): Promise<string[]> {
409
414
  const runtimeRoot = join(packageRoot, ".gentle-ai");
410
415
  return existsSync(runtimeRoot)
411
- ? (await readdir(runtimeRoot)).filter((entry) => entry.startsWith(".v3.5.0.install.tombstone-"))
416
+ ? (await readdir(runtimeRoot)).filter((entry) => entry.startsWith(".v3.7.0.install.tombstone-"))
412
417
  : [];
413
418
  }
414
419
 
415
420
  function backupBundlePath(packageRoot: string, nonce: string): string {
416
- return join(packageRoot, ".gentle-ai", `.v3.5.0.backup-${nonce}`);
421
+ return join(packageRoot, ".gentle-ai", `.v3.7.0.backup-${nonce}`);
417
422
  }
418
423
 
419
424
  async function copyWindowsBundle(source: string, destination: string): Promise<void> {
@@ -424,7 +429,7 @@ async function copyWindowsBundle(source: string, destination: string): Promise<v
424
429
 
425
430
  test("Windows source installation fails closed for an ownerless lock even after the stale threshold", async () => {
426
431
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-ownerless-stale-lock-"));
427
- const lockPath = join(packageRoot, ".gentle-ai", ".v3.5.0.install.lock");
432
+ const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
428
433
  await mkdir(lockPath, { recursive: true });
429
434
  const fixture = await hardenedWindowsGoFixture(packageRoot);
430
435
  await assertManualLockRecoveryRequired(packageRoot, fixture, { now: () => Date.now() + 10 * 60_000 });
@@ -432,7 +437,7 @@ test("Windows source installation fails closed for an ownerless lock even after
432
437
 
433
438
  test("Windows source installation fails closed for a stale owner lock", async () => {
434
439
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-stale-lock-"));
435
- const lockPath = join(packageRoot, ".gentle-ai", ".v3.5.0.install.lock");
440
+ const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
436
441
  await mkdir(lockPath, { recursive: true });
437
442
  await writeFile(join(lockPath, "owner.json"), `${JSON.stringify({ createdAt: 0, nonce: "0".repeat(64) })}\n`);
438
443
  const fixture = await hardenedWindowsGoFixture(packageRoot);
@@ -484,7 +489,7 @@ test("Windows source acquisition fails closed when a tombstone appears after its
484
489
  (error: unknown) => error instanceof Error && error.message.includes(foreignTombstone),
485
490
  );
486
491
  assert.equal(existsSync(foreignTombstone), true);
487
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", ".v3.5.0.install.lock")), false);
492
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock")), false);
488
493
  assert.equal(fixture.calls.some((call) => call.arguments_[0] === "install"), false);
489
494
  });
490
495
 
@@ -492,7 +497,7 @@ test("Windows source release deletes its matching tombstone and allows reuse", a
492
497
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-owned-lock-release-"));
493
498
  const fixture = await hardenedWindowsGoFixture(packageRoot);
494
499
  await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable });
495
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", ".v3.5.0.install.lock")), false);
500
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock")), false);
496
501
  assert.deepEqual(await tombstones(packageRoot), []);
497
502
  const reuse = await hardenedWindowsGoFixture(packageRoot);
498
503
  assert.equal((await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: reuse.run, resolveGoExecutable: reuse.resolveGoExecutable })).installed, false);
@@ -502,7 +507,7 @@ test("Windows source recovers a valid backup after a crash between publication r
502
507
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-backup-crash-"));
503
508
  const initial = await hardenedWindowsGoFixture(packageRoot);
504
509
  await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: initial.run, resolveGoExecutable: initial.resolveGoExecutable });
505
- const live = join(packageRoot, ".gentle-ai", "v3.5.0");
510
+ const live = join(packageRoot, ".gentle-ai", "v3.7.0");
506
511
  const backup = backupBundlePath(packageRoot, "crash");
507
512
  await rename(live, backup);
508
513
  const recovery = await hardenedWindowsGoFixture(packageRoot);
@@ -538,7 +543,7 @@ test("Windows source cleans one validated backup only when the live bundle is va
538
543
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-backup-valid-live-"));
539
544
  const initial = await hardenedWindowsGoFixture(packageRoot);
540
545
  await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: initial.run, resolveGoExecutable: initial.resolveGoExecutable });
541
- const live = join(packageRoot, ".gentle-ai", "v3.5.0");
546
+ const live = join(packageRoot, ".gentle-ai", "v3.7.0");
542
547
  const backup = backupBundlePath(packageRoot, "valid");
543
548
  await copyWindowsBundle(live, backup);
544
549
  const reuse = await hardenedWindowsGoFixture(packageRoot);
@@ -551,7 +556,7 @@ test("Windows source preserves a valid backup when the live bundle is invalid",
551
556
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-backup-invalid-live-"));
552
557
  const initial = await hardenedWindowsGoFixture(packageRoot);
553
558
  await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: initial.run, resolveGoExecutable: initial.resolveGoExecutable });
554
- const live = join(packageRoot, ".gentle-ai", "v3.5.0");
559
+ const live = join(packageRoot, ".gentle-ai", "v3.7.0");
555
560
  const backup = backupBundlePath(packageRoot, "valid");
556
561
  await copyWindowsBundle(live, backup);
557
562
  await writeFile(join(live, "integrity.json"), "{}\n");
@@ -569,7 +574,7 @@ test("Windows concurrent installs fail closed until normal release, then reuse t
569
574
  const fixture = await hardenedWindowsGoFixture(packageRoot, { blockInstall: true });
570
575
  const first = installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable });
571
576
  await fixture.waitForInstall();
572
- const lockPath = join(packageRoot, ".gentle-ai", ".v3.5.0.install.lock");
577
+ const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
573
578
  await assert.rejects(
574
579
  () => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable }),
575
580
  (error: unknown) => error instanceof Error && error.message.includes(lockPath),
@@ -583,7 +588,7 @@ test("Windows concurrent installs fail closed until normal release, then reuse t
583
588
 
584
589
  test("Windows source publication rolls back a prior bundle when final directory swap fails", async () => {
585
590
  const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-rollback-"));
586
- const versionDirectory = join(packageRoot, ".gentle-ai", "v3.5.0");
591
+ const versionDirectory = join(packageRoot, ".gentle-ai", "v3.7.0");
587
592
  await mkdir(versionDirectory, { recursive: true });
588
593
  await writeFile(join(versionDirectory, "old.txt"), "previous bundle");
589
594
  const fixture = await hardenedWindowsGoFixture(packageRoot);
@@ -620,7 +625,7 @@ test("Darwin/Linux signed bundles retain their four-field manifest and reusable
620
625
  },
621
626
  };
622
627
  await installGentleAi(options);
623
- const manifestPath = join(packageRoot, ".gentle-ai", "v3.5.0", "integrity.json");
628
+ const manifestPath = join(packageRoot, ".gentle-ai", "v3.7.0", "integrity.json");
624
629
  const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record<string, string>;
625
630
  assert.deepEqual(Object.keys(manifest), ["version", "asset", "assetSha256", "binarySha256"]);
626
631
  assert.equal((await installGentleAi({ ...options, download: async () => { throw new Error("signed bundle must be reused"); } })).installed, false);
@@ -633,7 +638,7 @@ test("a pinned asset falls back to its gentle-pi mirror only on download failure
633
638
  // under the current stable pin, whose form gate refuses raw binaries.
634
639
  const payload = Buffer.from("signed archive fixture");
635
640
  const binary = "mirrored binary";
636
- const baseAsset = { name: "gentle-ai_3.5.0_linux_amd64.tar.gz", sha256: createHash("sha256").update(payload).digest("hex"), binarySha256: createHash("sha256").update(binary).digest("hex"), url: "https://example.invalid/upstream", mirrorUrl: "https://example.invalid/mirror", executable: "gentle-ai" };
641
+ const baseAsset = { name: "gentle-ai_3.7.0_linux_amd64.tar.gz", sha256: createHash("sha256").update(payload).digest("hex"), binarySha256: createHash("sha256").update(binary).digest("hex"), url: "https://example.invalid/upstream", mirrorUrl: "https://example.invalid/mirror", executable: "gentle-ai" };
637
642
  const extractArchive = async (_archive: string, destination: string) => {
638
643
  await mkdir(destination, { recursive: true });
639
644
  await writeFile(join(destination, "gentle-ai"), binary);
@@ -751,7 +756,7 @@ test("checksum mismatch cleans temporary state without promoting a binary", asyn
751
756
  }),
752
757
  /checksum mismatch/,
753
758
  );
754
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai")), false);
759
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
755
760
  assert.deepEqual((await readdir(packageRoot)).filter((entry) => entry.startsWith(".gentle-ai-install-")), []);
756
761
  });
757
762
 
@@ -773,7 +778,7 @@ test("installer promotes only the expected regular executable with executable PO
773
778
  await chmod(extracted, 0o700);
774
779
  },
775
780
  });
776
- const binary = join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai");
781
+ const binary = join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai");
777
782
  assert.equal(existsSync(binary), true);
778
783
  assert.equal(await readFile(binary, "utf8"), "native executable");
779
784
  assert.ok(((await stat(binary)).mode & 0o111) !== 0);
@@ -804,7 +809,7 @@ test("installer rejects an extracted binary that differs from its pinned digest"
804
809
  }),
805
810
  /binary checksum mismatch/,
806
811
  );
807
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai")), false);
812
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
808
813
  });
809
814
 
810
815
  test("installer repairs a valid non-executable POSIX binary instead of reusing it", async (t) => {
@@ -827,7 +832,7 @@ test("installer repairs a valid non-executable POSIX binary instead of reusing i
827
832
  },
828
833
  };
829
834
  await installGentleAi(options);
830
- const binary = join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai");
835
+ const binary = join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai");
831
836
  await chmod(binary, 0o600);
832
837
  const repaired = await installGentleAi(options);
833
838
  assert.equal(repaired.installed, true);
@@ -864,7 +869,7 @@ test("installer rejects archives with multiple expected executable entries", asy
864
869
  }),
865
870
  /exactly one regular gentle-ai/,
866
871
  );
867
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai")), false);
872
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
868
873
  });
869
874
 
870
875
  test("installer rejects an archive without the expected regular executable", async () => {
@@ -882,7 +887,7 @@ test("installer rejects an archive without the expected regular executable", asy
882
887
  }),
883
888
  /non-regular gentle-ai/,
884
889
  );
885
- assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.5.0", "gentle-ai")), false);
890
+ assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
886
891
  });
887
892
 
888
893
  // #400: a clean Windows source build measured 232 s on a cold module cache,
@@ -15,13 +15,15 @@ import type {
15
15
  } from "@earendil-works/pi-coding-agent";
16
16
  import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
17
17
  import { PROFILES_KIND, PROFILES_VERSION } from "../lib/agent-profiles.ts";
18
+ import type { AgentRoutingEntry } from "../lib/model-routing-authority.ts";
19
+ type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel">;
18
20
  import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
19
21
  import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
20
22
  import { CandidateViewError, type CandidateViewRegistry } from "../lib/review-candidate-view.ts";
21
23
  import { installPackageAssets } from "../lib/sdd-preflight.ts";
22
24
  import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
23
25
  import { stripAnsi } from "../lib/terminal-theme.ts";
24
- import { cardBody, cardHint, cardTitle, cardTone } from "./gentle-card-text.ts";
26
+ import { cardBody, cardTitle, cardTone } from "./gentle-card-text.ts";
25
27
 
26
28
  initTheme("dark");
27
29
 
@@ -274,7 +276,7 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
274
276
  const scope = tools.get("gentle_review_scope");
275
277
  const manifest = { version: 1, scopeByMode: { "100644": ["src/file.ts"] }, gitlinks: {} };
276
278
  const bytes = Buffer.from(JSON.stringify(manifest), "utf8");
277
- const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url");
279
+ const encoded = gzipSync(bytes).toString("base64url");
278
280
  const sha256 = createHash("sha256").update(bytes).digest("hex");
279
281
 
280
282
  const result = await scope.execute(
@@ -2627,3 +2629,146 @@ test("a rename follows the clone pin and leaves the committed declaration naming
2627
2629
  rmSync(base, { recursive: true, force: true });
2628
2630
  }
2629
2631
  });
2632
+
2633
+ // getPiModelOptions guard branches (gentle-pi regression coverage)
2634
+
2635
+ function makeContext(registry?: ExtensionContext["modelRegistry"]): ExtensionContext {
2636
+ return {
2637
+ cwd: process.cwd(),
2638
+ hasUI: true,
2639
+ modelRegistry: registry,
2640
+ ui: { notify() {} },
2641
+ } as unknown as ExtensionContext;
2642
+ }
2643
+
2644
+ test("getPiModelOptions returns MODEL_CONTROL_OPTIONS when modelRegistry is absent", async () => {
2645
+ const options = await __testing.getPiModelOptions(makeContext());
2646
+ assert.deepEqual(options, ["Keep current", "Inherit active/default model", "Custom model id"]);
2647
+ });
2648
+
2649
+ test("getPiModelOptions returns MODEL_CONTROL_OPTIONS when getAvailable throws", async () => {
2650
+ const registry = {
2651
+ getAvailable: async () => { throw new Error("registry unavailable"); },
2652
+ } as unknown as ExtensionContext["modelRegistry"];
2653
+ const options = await __testing.getPiModelOptions(makeContext(registry));
2654
+ assert.deepEqual(options, ["Keep current", "Inherit active/default model", "Custom model id"]);
2655
+ });
2656
+
2657
+ test("getPiModelOptions returns MODEL_CONTROL_OPTIONS when getAvailable returns non-array", async () => {
2658
+ const registry = {
2659
+ getAvailable: async () => ({ provider: "openai", id: "gpt-5" }),
2660
+ } as unknown as ExtensionContext["modelRegistry"];
2661
+ const options = await __testing.getPiModelOptions(makeContext(registry));
2662
+ assert.deepEqual(options, ["Keep current", "Inherit active/default model", "Custom model id"]);
2663
+ });
2664
+
2665
+ test("getPiModelOptions merges MODEL_CONTROL_OPTIONS with normalized sorted model list", async () => {
2666
+ const registry = {
2667
+ getAvailable: async () => [
2668
+ { provider: "openai", id: "gpt-5.5" },
2669
+ { provider: "anthropic", id: "opus-4" },
2670
+ { provider: "openai", id: "gpt-5" },
2671
+ ],
2672
+ } as unknown as ExtensionContext["modelRegistry"];
2673
+ const options = await __testing.getPiModelOptions(makeContext(registry));
2674
+
2675
+ assert.equal(options[0], "Keep current");
2676
+ assert.equal(options[1], "Inherit active/default model");
2677
+ assert.equal(options[2], "Custom model id");
2678
+
2679
+ const modelPart = options.slice(3);
2680
+ assert.deepEqual(
2681
+ modelPart,
2682
+ [
2683
+ "anthropic/opus-4",
2684
+ "openai/gpt-5",
2685
+ "openai/gpt-5.5",
2686
+ ],
2687
+ );
2688
+ });
2689
+
2690
+ test("getPiModelOptions drops models that normalize to undefined", async () => {
2691
+ const registry = {
2692
+ getAvailable: async () => [
2693
+ { provider: "openai", id: "gpt-5" },
2694
+ { provider: "anthropic", id: "claude 4" }, // space fails SAFE_MODEL_ID_PATTERN
2695
+ { provider: "o|penai", id: "gpt-5" }, // pipe fails SAFE_MODEL_ID_PATTERN
2696
+ ],
2697
+ } as unknown as ExtensionContext["modelRegistry"];
2698
+ const options = await __testing.getPiModelOptions(makeContext(registry));
2699
+
2700
+ const modelPart = options.slice(3);
2701
+ assert.deepEqual(
2702
+ modelPart,
2703
+ [
2704
+ "openai/gpt-5",
2705
+ ],
2706
+ );
2707
+ });
2708
+
2709
+ // switchLiveOrchestrator regression coverage (gentle-pi)
2710
+
2711
+ test("switchLiveOrchestrator returns fallback note when modelRegistry is absent", async () => {
2712
+ const live = {
2713
+ setModel: async () => true,
2714
+ setThinkingLevel: () => {},
2715
+ } as unknown as LiveSession;
2716
+ const ctx = {
2717
+ cwd: process.cwd(),
2718
+ hasUI: true,
2719
+ ui: { notify() {} },
2720
+ } as unknown as ExtensionContext;
2721
+ const entry: AgentRoutingEntry = {
2722
+ model: "openai/gpt-5",
2723
+ };
2724
+ const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
2725
+ assert.equal(
2726
+ result,
2727
+ "\nModel registry unavailable; this session keeps its current model.",
2728
+ );
2729
+ });
2730
+
2731
+ test("switchLiveOrchestrator returns fallback note when model not found in catalog", async () => {
2732
+ const registry = {
2733
+ find: () => undefined,
2734
+ } as unknown as ExtensionContext["modelRegistry"];
2735
+ const live = {
2736
+ setModel: async () => true,
2737
+ setThinkingLevel: () => {},
2738
+ } as unknown as LiveSession;
2739
+ const ctx = {
2740
+ cwd: process.cwd(),
2741
+ hasUI: true,
2742
+ modelRegistry: registry,
2743
+ ui: { notify() {} },
2744
+ } as unknown as ExtensionContext;
2745
+ const entry: AgentRoutingEntry = {
2746
+ model: "openai/gpt-99",
2747
+ };
2748
+ const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
2749
+ assert.equal(
2750
+ result,
2751
+ "\nopenai/gpt-99 is not in the model catalog; this session keeps its current model.",
2752
+ );
2753
+ });
2754
+
2755
+ test("switchLiveOrchestrator returns note when setModel fails", async () => {
2756
+ const registry = {
2757
+ find: () => ({ provider: "openai", id: "gpt-5" }),
2758
+ } as unknown as ExtensionContext["modelRegistry"];
2759
+ const live = {
2760
+ setModel: async () => false,
2761
+ setThinkingLevel: () => {},
2762
+ } as unknown as LiveSession;
2763
+ const ctx = {
2764
+ cwd: process.cwd(),
2765
+ hasUI: true,
2766
+ modelRegistry: registry,
2767
+ ui: { notify() {} },
2768
+ } as unknown as ExtensionContext;
2769
+ const entry: AgentRoutingEntry = {
2770
+ model: "openai/gpt-5",
2771
+ };
2772
+ const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
2773
+ assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model.");
2774
+ });