gentle-pi 4.1.0 → 4.2.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 (81) hide show
  1. package/README.md +39 -14
  2. package/bin/engram-guard +1 -1
  3. package/bin/gentle-shell.mjs +16 -11
  4. package/docs/auto-permission-answers.md +25 -0
  5. package/docs/auto-permission-evidence.md +39 -0
  6. package/docs/bundled-install.md +8 -6
  7. package/docs/gentle-shell.md +17 -10
  8. package/docs/install-wizard.md +9 -9
  9. package/docs/permission-modes.md +50 -5
  10. package/docs/readme-reference.md +48 -12
  11. package/docs/session-profile-format.md +39 -0
  12. package/docs/yolo-mode.md +2 -1
  13. package/extensions/ask-user-choice.ts +4 -0
  14. package/extensions/ask-user-question.ts +6 -1
  15. package/extensions/gentle-agents.ts +54 -23
  16. package/extensions/gentle-ai.ts +105 -25
  17. package/extensions/gentle-shell.ts +26 -31
  18. package/extensions/gentle-todo.ts +26 -16
  19. package/lib/auto-permission-answers.ts +133 -0
  20. package/lib/auto-permission-classifier.ts +215 -36
  21. package/lib/auto-permission-evidence.ts +88 -0
  22. package/lib/auto-permission-memory.ts +69 -0
  23. package/lib/auto-permission-runtime.ts +251 -35
  24. package/lib/auto-permission-tool-context.ts +61 -0
  25. package/lib/destructive-command-guard.ts +104 -28
  26. package/lib/gentle-shell-bundled.ts +23 -5
  27. package/lib/gentle-shell-launcher.ts +1 -1
  28. package/lib/model-routing-authority.ts +6 -1
  29. package/lib/selection-engine.ts +4 -1
  30. package/lib/session-profile-freeze.ts +10 -15
  31. package/lib/session-profile-persistence.ts +187 -0
  32. package/lib/shell-changes.ts +2 -0
  33. package/lib/shell-prompt.ts +6 -4
  34. package/lib/shell-todo.ts +20 -8
  35. package/lib/vim-editor-adapter.ts +22 -4
  36. package/package.json +5 -5
  37. package/runtime/gentle-shell-bundled.mjs +23 -5
  38. package/runtime/gentle-shell-launcher.mjs +1 -1
  39. package/scripts/install-wizard-preview.mjs +2 -2
  40. package/scripts/installer-downloads.mjs +7 -7
  41. package/scripts/installer-preflight.mjs +1 -1
  42. package/scripts/pi-update.mjs +223 -0
  43. package/tests/append-system-prompt-route.test.ts +18 -1
  44. package/tests/auto-mode-human-answers.test.ts +68 -0
  45. package/tests/auto-mode-invariants.test.ts +23 -5
  46. package/tests/auto-mode-sdk.test.ts +29 -2
  47. package/tests/auto-permission-answers.test.ts +170 -0
  48. package/tests/auto-permission-classifier.test.ts +353 -2
  49. package/tests/auto-permission-compound.test.ts +24 -0
  50. package/tests/auto-permission-evidence.test.ts +88 -0
  51. package/tests/auto-permission-memory.test.ts +46 -0
  52. package/tests/auto-permission-public-regressions.test.ts +111 -0
  53. package/tests/auto-permission-runtime.test.ts +270 -12
  54. package/tests/autonomous-guard.test.ts +86 -16
  55. package/tests/bundled-install.test.ts +3 -3
  56. package/tests/engram-safety.test.ts +34 -6
  57. package/tests/gentle-agents.test.ts +162 -6
  58. package/tests/gentle-shell-bin.test.ts +17 -17
  59. package/tests/gentle-shell-bundled.test.ts +67 -33
  60. package/tests/gentle-shell-launcher.test.ts +18 -13
  61. package/tests/gentle-shell.test.ts +71 -5
  62. package/tests/gentle-todo.test.ts +79 -27
  63. package/tests/install-wizard.test.ts +2 -2
  64. package/tests/installer-posix-bootstrap.test.ts +8 -8
  65. package/tests/installer-preflight.test.ts +3 -3
  66. package/tests/installer-probes.test.ts +8 -8
  67. package/tests/installer-runner.test.ts +14 -13
  68. package/tests/installer-server.test.ts +5 -5
  69. package/tests/main-channel-upgrade.test.ts +7 -1
  70. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  71. package/tests/package-manifest.test.ts +6 -6
  72. package/tests/pi-update-launcher.test.ts +87 -0
  73. package/tests/pi-update.test.ts +186 -0
  74. package/tests/rdd-status-line.test.ts +52 -0
  75. package/tests/selection-engine.test.ts +35 -5
  76. package/tests/session-profile-freeze.test.ts +14 -1
  77. package/tests/session-profile-persistence.test.ts +334 -0
  78. package/tests/shell-changes.test.ts +18 -0
  79. package/tests/shell-todo.test.ts +15 -12
  80. package/tests/vim-editor-adapter.test.ts +41 -15
  81. package/tests/yolo-mode.test.ts +6 -1
@@ -39,7 +39,7 @@ function fakeFs({ real = {}, files = {}, links = {} }: { real?: Record<string, s
39
39
  };
40
40
  }
41
41
 
42
- const ID = "4.1.0-1.1.0";
42
+ const ID = "4.1.0-1.2.0";
43
43
  const POSIX_ROOT = "/home/ana/.gentle-shell";
44
44
  const POSIX_VERSION = posix.join(POSIX_ROOT, "versions", ID);
45
45
  const POSIX_PACKAGE = posix.join(POSIX_VERSION, "node_modules", ".pnpm", "gentle-pi@4.1.0", "node_modules", "gentle-pi");
@@ -106,29 +106,29 @@ const install = { root: POSIX_ROOT, id: ID, versionDir: POSIX_VERSION };
106
106
  const messageOf = (pi: BundledPi) => ("message" in pi ? pi.message : "");
107
107
 
108
108
  test("bundledPi returns only the Pi the version pins, inside the version folder", () => {
109
- assert.deepEqual(bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.1.0", "1.1.0") }) }),
110
- { ok: true, version: "1.1.0", cli: posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js") });
109
+ assert.deepEqual(bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.2.0", "1.2.0") }) }),
110
+ { ok: true, version: "1.2.0", cli: posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js") });
111
111
  const windows = { root: "C:\\G", id: ID, versionDir: `C:\\G\\versions\\${ID}` };
112
- assert.deepEqual(bundledPi(windows, { platform: "win32", fs: fakeFs({ files: piFiles("1.1.0", "1.1.0", win32, windows.versionDir) }) }),
113
- { ok: true, version: "1.1.0", cli: win32.join(PI_PACKAGE(windows.versionDir, win32), "dist", "bundle", "cli.js") });
112
+ assert.deepEqual(bundledPi(windows, { platform: "win32", fs: fakeFs({ files: piFiles("1.2.0", "1.2.0", win32, windows.versionDir) }) }),
113
+ { ok: true, version: "1.2.0", cli: win32.join(PI_PACKAGE(windows.versionDir, win32), "dist", "bundle", "cli.js") });
114
114
  });
115
115
 
116
116
  test("bundledPi fails with the version, the pinned Pi and the way to repair it", () => {
117
- const repair = /Gentle Shell 4\.1\.0-1\.1\.0 .*Pi 1\.1\.0.*gentle-shell upgrade.*installer/s;
118
- const mismatch = bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.1.0", "1.0.0") }) });
117
+ const repair = /Gentle Shell 4\.1\.0-1\.2\.0 .*Pi 1\.2\.0.*gentle-shell upgrade.*installer/s;
118
+ const mismatch = bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.2.0", "1.0.0") }) });
119
119
  assert.equal(mismatch.ok, false);
120
120
  assert.match(messageOf(mismatch), repair);
121
121
  assert.match(messageOf(mismatch), /found Pi 1\.0\.0/);
122
- const files = piFiles("1.1.0", "1.1.0");
122
+ const files = piFiles("1.2.0", "1.2.0");
123
123
  delete files[posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js")];
124
124
  const missing = bundledPi(install, { platform: "linux", fs: fakeFs({ files }) });
125
125
  assert.match(messageOf(missing), repair);
126
126
  // A Pi linked from outside the version folder is not the bundled one.
127
- const outside = fakeFs({ real: { [PI_PACKAGE(POSIX_VERSION)]: "/usr/lib/node_modules/@earendil-works/pi-coding-agent" }, files: piFiles("1.1.0", "1.1.0") });
127
+ const outside = fakeFs({ real: { [PI_PACKAGE(POSIX_VERSION)]: "/usr/lib/node_modules/@earendil-works/pi-coding-agent" }, files: piFiles("1.2.0", "1.2.0") });
128
128
  assert.equal(bundledPi(install, { platform: "linux", fs: outside }).ok, false);
129
129
  });
130
130
 
131
- const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.3.2/engram" };
131
+ const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.4.0/engram" };
132
132
 
133
133
  test("bundledChildEnv keeps the user's environment and adds only the bundled Pi, the prefix and Engram, no Pi override and no Pi update check", () => {
134
134
  const user = { PATH: "/home/ana/.nvm/bin:/usr/bin", NODE_OPTIONS: "--max-old-space-size=4096", npm_config_registry: "https://npm.example", npm_config_prefix: "/home/ana/.npm-global",
@@ -140,7 +140,7 @@ test("bundledChildEnv keeps the user's environment and adds only the bundled Pi,
140
140
  // folder from the prefix, and Gentle AI 4.0.0 reads that variable as a parent.
141
141
  assert.deepEqual(env, { PATH: user.PATH, NODE_OPTIONS: user.NODE_OPTIONS, npm_config_registry: user.npm_config_registry, npm_config_prefix: user.npm_config_prefix,
142
142
  GENTLE_PI_CONFIG_HOME: user.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_PI: "/g/node /g/cli.js", PI_SKIP_VERSION_CHECK: "1", GENTLE_SHELL_PREFIX: "/g",
143
- ENGRAM_BIN: "/g/runtime/engram-3.3.2/engram" });
143
+ ENGRAM_BIN: "/g/runtime/engram-3.4.0/engram" });
144
144
  // The subagent runner splits that variable on whitespace: a spaced path falls
145
145
  // back to its own default, the running node and cli.js.
146
146
  const spaced = bundledChildEnv({ GENTLE_PI_AGENTS_PI: "pi" }, { node: "C:\\Users\\Ana Li\\node.exe", cli: "C:\\cli.js", ...ISOLATION });
@@ -205,10 +205,10 @@ test("bundledUpdateNext still updates extensions when the upgrade did not comple
205
205
  });
206
206
 
207
207
  test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => {
208
- const interactive = bundledUpdateNotice({ piVersion: "1.1.0", interactive: true }).join("\n");
209
- assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.1\.0\)/);
208
+ const interactive = bundledUpdateNotice({ piVersion: "1.2.0", interactive: true }).join("\n");
209
+ assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
210
210
  assert.match(interactive, /Running 'gentle-shell upgrade'/);
211
- assert.match(bundledUpdateNotice({ piVersion: "1.1.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/);
211
+ assert.match(bundledUpdateNotice({ piVersion: "1.2.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/);
212
212
  });
213
213
 
214
214
  test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => {
@@ -233,7 +233,7 @@ test("parseBundledUpgradeArgs accepts the release channel and --rollback and ref
233
233
  const repository = dirname(dirname(fileURLToPath(import.meta.url)));
234
234
  const posixHost = process.platform === "win32" ? "the bundled prefix fixture uses a POSIX current symlink" : false;
235
235
  const SHELL = JSON.parse(readFileSync(join(repository, "package.json"), "utf8")).version;
236
- const FIXTURE_ID = `${SHELL}-1.1.0`;
236
+ const FIXTURE_ID = `${SHELL}-1.2.0`;
237
237
  const FAKE_PI = [
238
238
  "import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
239
239
  "const args = process.argv.slice(2);",
@@ -244,7 +244,7 @@ const FAKE_PI = [
244
244
  " if (process.env.FIXTURE_INSTALL_CAPABILITY !== 'metadata-only') { const packages = settings.packages ?? []; const index = packages.findIndex(p => /^npm:gentle-engram(?:@|$)/.test(typeof p === 'string' ? p : p.source));",
245
245
  " if (index < 0) packages.push(args[1]); else packages[index] = typeof packages[index] === 'string' ? args[1] : {...packages[index], source:args[1]}; settings.packages = packages; writeFileSync(path, JSON.stringify(settings)); }",
246
246
  "}",
247
- "if (args.includes('--version')) { console.log('1.1.0'); process.exit(0); }",
247
+ "if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
248
248
  // Stands in for gentle-engram creating the store through the bundled core during the session.
249
249
  "if (process.env.FIXTURE_CREATE_STORE && args[0] !== 'install' && args[0] !== 'list') {",
250
250
  " const data = join(process.env.HOME, '.engram');",
@@ -273,7 +273,7 @@ const FIXTURE_CORE = [
273
273
  "",
274
274
  ].join("\n");
275
275
 
276
- function bundledPrefix(t: TestContext, { pi = "1.1.0", core = true } = {}) {
276
+ function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
277
277
  const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-shell-bundled-")));
278
278
  t.after(() => rmSync(base, { recursive: true, force: true }));
279
279
  const home = join(base, "home");
@@ -295,7 +295,7 @@ function bundledPrefix(t: TestContext, { pi = "1.1.0", core = true } = {}) {
295
295
  mkdirSync(join(piDir, "dist", "bundle"), { recursive: true });
296
296
  writeFileSync(join(piDir, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: pi, bin: { pi: "dist/bundle/cli.js" } }));
297
297
  writeFileSync(join(piDir, "dist", "bundle", "cli.js"), FAKE_PI);
298
- writeFileSync(join(version, "package.json"), JSON.stringify({ dependencies: { "gentle-pi": SHELL, "@earendil-works/pi-coding-agent": "1.1.0" } }));
298
+ writeFileSync(join(version, "package.json"), JSON.stringify({ dependencies: { "gentle-pi": SHELL, "@earendil-works/pi-coding-agent": "1.2.0" } }));
299
299
  writeFileSync(join(version, ".gentle-shell-version"), `${FIXTURE_ID}\n`);
300
300
  symlinkSync(`versions/${FIXTURE_ID}`, join(root, "current"));
301
301
  // The bundled npm the npmCommand entry loads.
@@ -457,10 +457,20 @@ test("provenance mismatches disable memory behind the guard: instance, core dige
457
457
  assert.equal(result.status, 0, `${name}: ${result.stderr}`);
458
458
  const seen = lastLaunch(result);
459
459
  assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard], name);
460
- assert.match(result.stderr, /memory disabled.*Back up.*update.*restart.*recheck/s, name);
460
+ assert.match(result.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s, name);
461
461
  }
462
462
  });
463
463
 
464
+ test("public help explains compatibility evidence without promising update recovery", { skip: posixHost }, (t) => {
465
+ const prefix = bundledPrefix(t);
466
+ const result = prefix.run(["--help"]);
467
+ assert.equal(result.status, 0, result.stderr);
468
+ assert.equal(result.stderr, "");
469
+ assert.match(result.stdout, /Matching versions or updating alone do not prove compatibility/);
470
+ assert.match(result.stdout, /\/compatibility/);
471
+ assert.doesNotMatch(result.stdout, /update Engram, restart/);
472
+ });
473
+
464
474
  test("a foreign pre-existing store stays disabled and never receives provenance", { skip: posixHost }, (t) => {
465
475
  const prefix = bundledPrefix(t);
466
476
  const data = join(prefix.home, ".engram");
@@ -510,6 +520,23 @@ test("a verified /compatibility server is reused through an explicit URL and the
510
520
  assert.equal(lastLaunch(unready.run([], { FIXTURE_ENGRAM_PORT: notReady })).env.ENGRAM_STARTUP_POLICY, "disabled");
511
521
  });
512
522
 
523
+ test("a foreign store behind a 3.3.2 server stays guarded and the launch names the server version to update", { skip: posixHost }, async (t) => {
524
+ const prefix = bundledPrefix(t);
525
+ const data = join(prefix.home, ".engram");
526
+ mkdirSync(data);
527
+ writeHeader(join(data, "engram.db"), 1);
528
+ const before = readFileSync(join(data, "engram.db"));
529
+ // Engram 3.3.2 has no /compatibility route (the fixture answers 404).
530
+ const port = await engramServer(t, { "/health": [200, { status: "ok", service: "engram", version: "3.3.2", instance_id: OTHER_STORE_ID }] });
531
+ const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
532
+ assert.equal(result.status, 0, result.stderr);
533
+ const seen = lastLaunch(result);
534
+ assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
535
+ assert.match(result.stderr, /memory disabled.*reports version 3\.3\.2.*update that server to 3\.4\.0 or later/s);
536
+ assert.deepEqual(readFileSync(join(data, "engram.db")), before);
537
+ assert.equal(existsSync(provenancePath(prefix)), false);
538
+ });
539
+
513
540
  test("a fresh launch whose launcher is killed during Pi already has finalized provenance, and the next launch allows memory", { skip: posixHost }, (t) => {
514
541
  const prefix = bundledPrefix(t);
515
542
  const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID, FIXTURE_KILL_LAUNCHER: "1" });
@@ -634,7 +661,8 @@ test("setup with a foreign existing store runs the installer like the baseline,
634
661
  assert.equal(setup.status, 0, setup.stderr);
635
662
  assert.equal(installer.runs(), 1);
636
663
  assert.equal(existsSync(prefix.engram), true);
637
- assert.match(setup.stderr, /memory disabled.*Back up.*update.*restart.*recheck/s);
664
+ assert.match(setup.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s);
665
+ assert.doesNotMatch(setup.stderr, /update Engram and restart/);
638
666
  assert.doesNotMatch(setup.stderr, /deferred/);
639
667
  const guarded = prefix.run([]);
640
668
  assert.equal(guarded.status, 0, guarded.stderr);
@@ -768,7 +796,7 @@ test("the bundled launcher runs only its version's Pi, with the user's environme
768
796
  const prefix = bundledPrefix(t);
769
797
  const version = prefix.run(["--version"]);
770
798
  assert.equal(version.status, 0, version.stderr);
771
- assert.match(version.stdout, new RegExp(`^gentle-shell ${SHELL.replaceAll(".", "\\.")}\npi 1\\.1\\.0\n`));
799
+ assert.match(version.stdout, new RegExp(`^gentle-shell ${SHELL.replaceAll(".", "\\.")}\npi 1\\.2\\.0\n`));
772
800
  const launched = prefix.run(["list"]);
773
801
  assert.equal(launched.status, 0, launched.stderr);
774
802
  const seen = JSON.parse(launched.stdout.trim().split("\n").at(-1) ?? "{}");
@@ -894,19 +922,19 @@ test("the version check before activation runs that version in bundled mode, nev
894
922
  encoding: "utf8", input: "" });
895
923
  const good = verify({ [BUNDLED_VERIFY_ENV]: FIXTURE_ID });
896
924
  assert.equal(good.status, 0, good.stderr);
897
- assert.match(good.stdout, /\npi 1\.1\.0\n/);
925
+ assert.match(good.stdout, /\npi 1\.2\.0\n/);
898
926
  // The version's own Pi is broken, and the user's Pi prints the expected version.
899
- writeFileSync(join(prefix.userBin, "pi"), "#!/bin/sh\necho 1.1.0\n", { mode: 0o755 });
927
+ writeFileSync(join(prefix.userBin, "pi"), "#!/bin/sh\necho 1.2.0\n", { mode: 0o755 });
900
928
  rmSync(join(prefix.piDir, "dist", "bundle", "cli.js"));
901
929
  const broken = verify({ [BUNDLED_VERIFY_ENV]: FIXTURE_ID });
902
930
  assert.equal(broken.status, 1);
903
- assert.match(broken.stderr, /bundled Pi 1\.1\.0/);
931
+ assert.match(broken.stderr, /bundled Pi 1\.2\.0/);
904
932
  // Without the verification input, the same entry is an ordinary launcher that would pass.
905
- assert.match(verify({}).stdout, /\npi 1\.1\.0\n/);
933
+ assert.match(verify({}).stdout, /\npi 1\.2\.0\n/);
906
934
  // Only --version honors it.
907
935
  const list = spawnSync(process.execPath, [prefix.entry, "list"], { env: { HOME: prefix.home, PATH: `${prefix.userBin}:/usr/bin:/bin`, [BUNDLED_VERIFY_ENV]: FIXTURE_ID,
908
936
  GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8", input: "" });
909
- assert.equal(list.stdout, "1.1.0\n");
937
+ assert.equal(list.stdout, "1.2.0\n");
910
938
  });
911
939
 
912
940
  test("the bundled launcher refuses a Pi that is not the pinned one", { skip: posixHost }, (t) => {
@@ -919,7 +947,7 @@ test("the bundled launcher refuses a Pi that is not the pinned one", { skip: pos
919
947
  test("gentle-shell update in a bundled install explains the pinned Pi instead of updating it", { skip: posixHost }, (t) => {
920
948
  const result = bundledPrefix(t).run(["update"]);
921
949
  assert.equal(result.status, 0, result.stderr);
922
- assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.1\.0\)/);
950
+ assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
923
951
  assert.match(result.stdout, /Run 'gentle-shell upgrade'/);
924
952
  assert.doesNotMatch(result.stdout, /"args"/);
925
953
  });
@@ -928,23 +956,29 @@ test("gentle-shell update pi --extensions in a bundled install redirects Pi's pa
928
956
  const prefix = bundledPrefix(t);
929
957
  const result = prefix.run(["update", "pi", "--extensions"]);
930
958
  assert.equal(result.status, 0, result.stderr);
931
- assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.1\.0\)/);
959
+ assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
932
960
  const seen = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}");
933
961
  assert.deepEqual(seen.args, ["update", "--extensions"]);
934
962
  assert.ok(JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8")).npmCommand);
935
963
  });
936
964
 
937
- test("a kept version that current does not name keeps the ordinary launcher, Pi override and Pi update included", { skip: posixHost }, (t) => {
965
+ test("a kept version that current does not name keeps the ordinary Pi override and refuses an unowned update", { skip: posixHost }, (t) => {
938
966
  const prefix = bundledPrefix(t);
939
967
  const other = join(prefix.root, "versions", "0.0.1-1.0.0");
940
968
  mkdirSync(other);
941
969
  writeFileSync(join(other, ".gentle-shell-version"), "0.0.1-1.0.0\n");
942
970
  rmSync(join(prefix.root, "current"));
943
971
  symlinkSync("versions/0.0.1-1.0.0", join(prefix.root, "current"));
944
- const update = spawnSync(process.execPath, [prefix.entry, "update"], { env: { HOME: dirname(prefix.root), PATH: "/usr/bin:/bin",
945
- GENTLE_SHELL_PI: join(dirname(dirname(prefix.root)), "user-bin", "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8", input: "" });
946
- assert.equal(update.status, 0, update.stderr);
947
- assert.equal(update.stdout, "9.9.9\n");
972
+ const options = { env: { HOME: dirname(prefix.root), PATH: "/usr/bin:/bin",
973
+ GENTLE_SHELL_PI: join(dirname(dirname(prefix.root)), "user-bin", "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8" as const, input: "" };
974
+ const version = spawnSync(process.execPath, [prefix.entry, "--version"], options);
975
+ assert.equal(version.status, 0, version.stderr);
976
+ assert.match(version.stdout, /pi 9\.9\.9/);
977
+ const update = spawnSync(process.execPath, [prefix.entry, "update"], options);
978
+ assert.equal(update.status, 1);
979
+ assert.equal(update.stdout, "");
980
+ // The fake override is a plain script: neither a Pi entry nor a package-manager shim.
981
+ assert.match(update.stderr, /ownership selected executable is not a Pi entry or shim.*No installation changed\./);
948
982
  });
949
983
 
950
984
  test("gentle-shell upgrade in a bundled install refuses the main channel and rolls back to the kept version", { skip: posixHost }, (t) => {
@@ -683,9 +683,10 @@ test("checkPeerVersionPin passes for a matching pin", () => {
683
683
  assert.deepEqual(result, { ok: true, pinned: ">=0.85.1" });
684
684
  });
685
685
 
686
- test("Pi 0.99.1 baseline and host peers follow the open development range policy", () => {
686
+ test("Pi 1.2.0 baseline and host peers follow the open development range policy", () => {
687
687
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
688
- assert.equal(MIN_PI_VERSION, "0.99.1");
688
+ assert.equal(MIN_PI_VERSION, "1.2.0");
689
+ assert.equal(pkg.peerDependencies["@earendil-works/pi-coding-agent"], ">=1.2.0");
689
690
  assert.equal(pkg.engines.node, ">=22.19.0");
690
691
  for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
691
692
  assert.equal(pkg.peerDependencies[name], "*");
@@ -695,34 +696,38 @@ test("Pi 0.99.1 baseline and host peers follow the open development range policy
695
696
  // Development ranges are policy specifiers; their floor never drops below
696
697
  // the runtime minimum the launcher enforces.
697
698
  for (const name of ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
698
- assert.equal(pkg.devDependencies[name], ">=1.0.0", name);
699
+ assert.equal(pkg.devDependencies[name], ">=1.2.0", name);
699
700
  assert.equal(checkPiVersion(pkg.devDependencies[name].slice(2)).ok, true, name);
700
701
  }
701
702
  });
702
703
 
703
704
  test("checkPiVersion accepts a version equal to the minimum", () => {
704
- assert.deepEqual(checkPiVersion("0.99.1"), { ok: true, version: "0.99.1" });
705
+ assert.deepEqual(checkPiVersion("1.2.0"), { ok: true, version: "1.2.0" });
705
706
  });
706
707
 
707
708
  test("checkPiVersion accepts a version above the minimum", () => {
708
- assert.deepEqual(checkPiVersion("0.100.0"), { ok: true, version: "0.100.0" });
709
+ for (const version of ["1.2.1", "1.3.0", "2.0.0"]) {
710
+ assert.deepEqual(checkPiVersion(version), { ok: true, version });
711
+ }
709
712
  });
710
713
 
711
714
  test("checkPiVersion accepts a v-prefixed version", () => {
712
- assert.deepEqual(checkPiVersion("v0.99.1"), { ok: true, version: "0.99.1" });
715
+ assert.deepEqual(checkPiVersion("v1.2.0"), { ok: true, version: "1.2.0" });
713
716
  });
714
717
 
715
718
  test("checkPiVersion accepts a prerelease suffix at the minimum", () => {
716
- assert.deepEqual(checkPiVersion("pi version 0.99.1-rc.2"), { ok: true, version: "0.99.1" });
719
+ assert.deepEqual(checkPiVersion("pi version 1.2.0-rc.2"), { ok: true, version: "1.2.0" });
717
720
  });
718
721
 
719
722
  test("checkPiVersion rejects a version below the minimum and names both versions", () => {
720
- const result = checkPiVersion("0.85.0");
723
+ const result = checkPiVersion("1.1.0");
721
724
  assert.equal(result.ok, false);
722
725
  if (result.ok) throw new Error("expected a failing result");
723
- assert.equal(result.version, "0.85.0");
724
- assert.match(result.message, /0\.85\.0/);
725
- assert.match(result.message, /0\.99\.1/);
726
+ assert.equal(result.version, "1.1.0");
727
+ assert.match(result.message, /1\.1\.0/);
728
+ assert.match(result.message, /1\.2\.0/);
729
+ assert.equal(checkPiVersion("1.0.0").ok, false);
730
+ assert.equal(checkPiVersion("0.99.1").ok, false);
726
731
  });
727
732
 
728
733
  test("checkPiVersion rejects a prerelease below the minimum", () => {
@@ -736,7 +741,7 @@ test("checkPiVersion reports unparsable output with the raw text and the minimum
736
741
  if (result.ok) throw new Error("expected a failing result");
737
742
  assert.equal(result.version, undefined);
738
743
  assert.match(result.message, /not a version/);
739
- assert.match(result.message, /0\.99\.1/);
744
+ assert.match(result.message, /1\.2\.0/);
740
745
  });
741
746
 
742
747
  test("checkPiVersion accepts a custom minimum", () => {
@@ -2015,7 +2020,7 @@ test("the isolated-home bootstrap creates ~/.gentle-shell as 0700 under umask 00
2015
2020
  const root = mkdtempSync(join(tmpdir(), "gentle-shell-umask-"));
2016
2021
  try {
2017
2022
  const piScript = join(root, "fake-pi.mjs");
2018
- writeFileSync(piScript, "#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('0.99.1');\n");
2023
+ writeFileSync(piScript, "#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('1.2.0');\n");
2019
2024
  chmodSync(piScript, 0o755);
2020
2025
  const binPath = fileURLToPath(new URL("../bin/gentle-shell.mjs", import.meta.url));
2021
2026
  const env = { HOME: root, USERPROFILE: root, PATH: process.env.PATH ?? "/usr/bin:/bin", GENTLE_SHELL_PI: piScript, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
@@ -4,19 +4,23 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
4
4
  import { createRequire } from "node:module";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import test, { after } from "node:test";
7
+ import test, { after, afterEach } from "node:test";
8
8
  import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
9
- import { CURSOR_MARKER, matchesKey, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
9
+ import { CURSOR_MARKER, matchesKey, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
10
10
  import installGentleShell, { backgroundChangeRefreshesSettled, buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
11
11
  import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
12
12
  import { bindSessionProfile, clearSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
13
13
  import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
14
+ import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
15
+
16
+ // Fixture sessions share ids across tests; the inherited-profile freeze is
17
+ // process state keyed by session id (gentle-shell#1064 slice 3b-ii).
18
+ afterEach(() => resetFrozenInheritedProfilesForTesting());
14
19
  import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
15
20
  import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE } from "../lib/shell-changes.ts";
16
21
  import { sidebarPart, sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
17
22
  import { renderTodoCard } from "../lib/shell-todo.ts";
18
23
  import type { ShellBarTheme } from "../lib/shell-bar.ts";
19
- import { stripAnsi } from "../lib/terminal-theme.ts";
20
24
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
21
25
  import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
22
26
  import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
@@ -751,7 +755,9 @@ test("profile polling refreshes both fullscreen surfaces only on change and stop
751
755
  t.mock.method(globalThis, "clearInterval", (handle: { timer: (typeof intervals)[number] }) => { handle.timer.stopped = true; });
752
756
  const { pi, handlers } = fakePi();
753
757
  let resolutions = 0;
754
- gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, {
758
+ // follow mode keeps the live p → P → G resolution, so a pin edit reaches the
759
+ // open session through the poll (gentle-shell#1064 slice 3b-ii).
760
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_PROFILE_FOLLOW: "1" }, {
755
761
  resolveWorktree: () => { resolutions++; return { root: repo, commonDir }; },
756
762
  });
757
763
  const first = fakeContext();
@@ -1800,6 +1806,8 @@ function reverseColumns(row: string): number[] {
1800
1806
  let inverse = false;
1801
1807
  let column = 0;
1802
1808
  const selected: number[] = [];
1809
+ const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
1810
+ row = host.resolveFakeCursors.call({ showHardwareCursor: false }, [row])[0]!;
1803
1811
  const tokens = row.match(/\x1b\[[0-9;]*m|\x1b_pi:c\x07|[^\x1b]/g) ?? [];
1804
1812
  assert.equal(tokens.join(""), row);
1805
1813
  for (const token of tokens) {
@@ -5945,7 +5953,10 @@ test("after a profile switch the panel stops presenting the old profile's provid
5945
5953
  ui.closeOverlay?.();
5946
5954
  await opened;
5947
5955
 
5948
- writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: {} }, "solo");
5956
+ // An open session changes profile only through its own Enter (gentle-shell#1064
5957
+ // slice 3b-ii): a new global default would leave its frozen routing in place.
5958
+ bindSessionProfile("shell-session", "solo", {});
5959
+ t.after(() => resetSessionProfileBindingsForTesting());
5949
5960
  const reopened = commands.get("gentle:usage")!.handler("", ctx);
5950
5961
  await settle();
5951
5962
  assert.equal(ui.overlayView!.render(90).map(stripAnsi).some((line) => line.includes("nan")), false, "a provider recorded under the previous profile's routing is not current scope");
@@ -5953,6 +5964,61 @@ test("after a profile switch the panel stops presenting the old profile's provid
5953
5964
  await reopened;
5954
5965
  });
5955
5966
 
5967
+ test("an open session keeps its frozen profile in the status and Usage scope when the defaults change", async (t) => {
5968
+ const home = mkdtempSync(join(tmpdir(), "shell-frozen-"));
5969
+ const commonDir = mkdtempSync(join(tmpdir(), "shell-frozen-git-"));
5970
+ t.after(() => {
5971
+ rmSync(home, { recursive: true, force: true });
5972
+ rmSync(commonDir, { recursive: true, force: true });
5973
+ });
5974
+ mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
5975
+ writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "team");
5976
+ const { pi, handlers, commands } = fakePi();
5977
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) });
5978
+ const { ctx, ui } = fakeContext({ token: JWT });
5979
+ await fire(handlers, "session_start", ctx);
5980
+ assert.equal(readFrozenInheritedProfile("shell-session")?.profile?.name, "team", "the session froze the global active profile at start");
5981
+ writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "solo");
5982
+ writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" }));
5983
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
5984
+ await settle();
5985
+ assert.ok(openPanelLines(ui).some((line) => /^│ nan ·/.test(line)), "Usage keeps the frozen profile's nan route");
5986
+ ui.closeOverlay?.();
5987
+ await opened;
5988
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, data: unknown) => { dispose(): void };
5989
+ const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
5990
+ const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} });
5991
+ try {
5992
+ const status = (sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail).render(60).join("\n");
5993
+ assert.match(status, /Profile.*team/, "the status keeps the bare global name of the frozen profile");
5994
+ assert.doesNotMatch(status, /solo/);
5995
+ } finally {
5996
+ component.dispose();
5997
+ }
5998
+ });
5999
+
6000
+ test("active profile reader: a bound session shows its frozen profile, and follow shows the live default", (t) => {
6001
+ const root = mkdtempSync(join(tmpdir(), "shell-profile-frozen-"));
6002
+ t.after(() => rmSync(root, { recursive: true, force: true }));
6003
+ const commonDir = join(root, "git");
6004
+ mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
6005
+ writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} } }));
6006
+ const pin = (profile: string) => writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile }));
6007
+ pin("other");
6008
+ const frozen = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
6009
+ frozen.bind(root, () => ({ root, commonDir }), "session-frozen");
6010
+ assert.equal(frozen(), "other (local)");
6011
+ const follow = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_PROFILE_FOLLOW: "1" });
6012
+ follow.bind(root, () => ({ root, commonDir }), "session-follow");
6013
+ assert.equal(follow(), "other (local)");
6014
+ rmSync(join(commonDir, "gentle-ai", "profile-pin.json"));
6015
+ assert.equal(frozen.refresh(), false, "the frozen label does not move");
6016
+ assert.equal(frozen(), "other (local)");
6017
+ assert.equal(follow.refresh(), true);
6018
+ assert.equal(follow(), "team", "follow falls to the global active profile");
6019
+ assert.equal(readFrozenInheritedProfile("session-follow"), undefined);
6020
+ });
6021
+
5956
6022
  function openPanelLines(ui: FakeUi): string[] {
5957
6023
  return ui.overlayView!.render(90).map(stripAnsi);
5958
6024
  }
@@ -114,12 +114,27 @@ test("long Todo widgets stay bounded, scroll, resize and keep their collapse con
114
114
  assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].title, title.trim());
115
115
  assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].note, note.trim());
116
116
  await shortcuts.get("ctrl+shift+t")!.handler(ctx);
117
- await shortcuts.get("ctrl+shift+down")!.handler(ctx);
117
+ await shortcuts.get("alt+shift+down")!.handler(ctx);
118
118
  assert.notDeepEqual(bottom.render(40), collapsed, "keyboard scrolling is available without mouse");
119
119
  }
120
120
  t.after(() => setCardStyle(CARD_STYLE.NEON));
121
121
  });
122
122
 
123
+ test("Todo shortcuts never take a key Pi binds by default, so startup reports no shortcut conflict", async () => {
124
+ // Pi keeps its default table in dist/core/keybindings.js, next to the package entry.
125
+ const { KEYBINDINGS } = await import(new URL("core/keybindings.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href) as
126
+ { KEYBINDINGS: Record<string, { defaultKeys?: string | string[] }> };
127
+ assert.ok("tui.altScreen.previousPrompt" in KEYBINDINGS, "the table includes Pi's built-in prompt navigation");
128
+ const owners = new Map<string, string[]>();
129
+ for (const [id, binding] of Object.entries(KEYBINDINGS)) {
130
+ for (const key of ([] as string[]).concat(binding.defaultKeys ?? [])) owners.set(key, [...(owners.get(key) ?? []), id]);
131
+ }
132
+ const { pi, shortcuts } = fakePi();
133
+ gentleTodo(pi, {});
134
+ assert.deepEqual([...shortcuts.keys()].sort(), ["alt+shift+down", "alt+shift+up", "ctrl+shift+t"]);
135
+ for (const key of shortcuts.keys()) assert.equal(owners.get(key), undefined, `${key} is a Pi default for ${owners.get(key)?.join(", ")}`);
136
+ });
137
+
123
138
  test("Todo keyboard scrolling targets the visible card after a cached sidebar breakpoint round-trip", async (t) => {
124
139
  const node = Symbol.for("@earendil-works/pi-tui/layout-node");
125
140
  for (const style of [CARD_STYLE.NEON, CARD_STYLE.FLOAT]) {
@@ -146,11 +161,11 @@ test("Todo keyboard scrolling targets the visible card after a cached sidebar br
146
161
  bottom.render(40);
147
162
  tui.terminal.columns = 160;
148
163
  const before = railLines();
149
- await shortcuts.get("ctrl+shift+down")!.handler(ctx);
164
+ await shortcuts.get("alt+shift+down")!.handler(ctx);
150
165
  const after = railLines();
151
166
  assert.notDeepEqual(after, before, "DOWN changes the visible cached rail, not the hidden bottom widget");
152
167
  assert.match(after.join("\n"), /↑↓ 4–/);
153
- await shortcuts.get("ctrl+shift+up")!.handler(ctx);
168
+ await shortcuts.get("alt+shift+up")!.handler(ctx);
154
169
  assert.deepEqual(railLines(), before, "UP restores the expanded viewport");
155
170
  } finally {
156
171
  dispose();
@@ -288,11 +303,25 @@ function toolTurn(toolName = "read") {
288
303
  return { message: { role: "assistant", content: [{ type: "toolCall", id: "call", name: toolName, arguments: {} }] }, toolResults: [] };
289
304
  }
290
305
 
291
- async function contextReminder(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext) {
306
+ type ContextMessage = { role: string; content: unknown; customType?: string; display?: boolean; timestamp?: number };
307
+
308
+ async function contextMessages(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage[] | undefined> {
292
309
  const messages = [{ role: "user", content: "Keep working", timestamp: 1 }];
293
- const result = await fire("context", ctx, { messages }) as { messages: Array<{ role: string; content: unknown }> } | undefined;
310
+ const result = await fire("context", ctx, { messages }) as { messages: ContextMessage[] } | undefined;
294
311
  assert.deepEqual(messages, [{ role: "user", content: "Keep working", timestamp: 1 }], "request-local injection must not mutate the input transcript");
295
- return result;
312
+ if (result === undefined) return undefined;
313
+ assert.deepEqual(result.messages[0], { role: "user", content: "Keep working", timestamp: 1 }, "the transcript stays first; todo messages trail it");
314
+ return result.messages;
315
+ }
316
+
317
+ /** The request-local reminder, if the next model request carries one. */
318
+ async function contextReminder(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
319
+ return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-reminder");
320
+ }
321
+
322
+ /** The request-local current-list message, if the next model request carries one (#1662). */
323
+ async function contextState(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
324
+ return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-state");
296
325
  }
297
326
 
298
327
  async function workTurns(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext, count: number) {
@@ -312,13 +341,14 @@ test("runtime reminder arrives after four tool-use turns, only once, without cha
312
341
  await workTurns(fire, ctx, 3);
313
342
  assert.equal(await contextReminder(fire, ctx), undefined);
314
343
  await workTurns(fire, ctx, 1);
315
- const result = await contextReminder(fire, ctx);
316
- assert.ok(result, "the next model request must carry a reminder within the same run");
317
- assert.equal(result.messages.length, 2);
318
- assert.deepEqual(result.messages[0], { role: "user", content: "Keep working", timestamp: 1 });
319
- assert.deepEqual(result.messages[1], { role: "custom", customType: "gentle-todo-reminder", content: REMINDER, display: false, timestamp: (result.messages[1] as { timestamp?: number }).timestamp });
320
- const converted = convertToLlm(result.messages as Parameters<typeof convertToLlm>[0]);
321
- assert.deepEqual(converted[1], { role: "user", content: [{ type: "text", text: REMINDER }], timestamp: (result.messages[1] as { timestamp?: number }).timestamp }, "Pi forwards the reminder as provider-compatible text");
344
+ const messages = await contextMessages(fire, ctx);
345
+ assert.ok(messages, "the next model request must carry a reminder within the same run");
346
+ assert.equal(messages.length, 3, "transcript, current list, then the reminder");
347
+ assert.equal(messages[1]!.customType, "gentle-todo-state");
348
+ const reminder = messages[2]!;
349
+ assert.deepEqual(reminder, { role: "custom", customType: "gentle-todo-reminder", content: REMINDER, display: false, timestamp: reminder.timestamp });
350
+ const converted = convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
351
+ assert.deepEqual(converted[2], { role: "user", content: [{ type: "text", text: REMINDER }], timestamp: reminder.timestamp }, "Pi forwards the reminder as provider-compatible text");
322
352
  await workTurns(fire, ctx, 4);
323
353
  assert.equal(await contextReminder(fire, ctx), undefined, "no repeated nagging without a todo write");
324
354
  const listed = await tool.execute("list", { action: "list" }, undefined, undefined, ctx);
@@ -418,31 +448,53 @@ function promptEvent(): { systemPrompt: string; systemPromptOptions: { appendSys
418
448
  return { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
419
449
  }
420
450
 
421
- test("every turn carries the open tasks in appendSystemPrompt (never a returned systemPrompt) and the card goes stale after two silent turns", async () => {
451
+ test("the system prompt carries only a byte-stable todo block; the current list travels as a request-local message (#1662)", async () => {
422
452
  const { pi, tools, fire } = fakePi();
423
453
  gentleTodo(pi, {});
424
454
  const { ctx, widget } = fakeContext();
425
455
  await fire("session_start", ctx);
426
- await fire("before_agent_start", ctx, promptEvent());
456
+ const emptyEvent = promptEvent();
457
+ assert.equal(await fire("before_agent_start", ctx, emptyEvent), undefined, "the handler must not return a replacement systemPrompt");
458
+ assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /^## Todo list/);
459
+ assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /mark a task in_progress before starting/);
460
+ assert.equal(await contextState(fire, ctx), undefined, "an empty list sends no state message");
461
+
427
462
  await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
428
463
  await fire("tool_execution_end", ctx, { toolName: "todo" });
429
-
430
464
  const withTasksEvent = promptEvent();
431
- const withTasksResult = await fire("before_agent_start", ctx, withTasksEvent);
432
- assert.equal(withTasksResult, undefined, "the handler must not return a replacement systemPrompt");
433
- assert.match(withTasksEvent.systemPromptOptions.appendSystemPrompt, /^## Todo list/);
434
- assert.match(withTasksEvent.systemPromptOptions.appendSystemPrompt, /1\. \[pending\] Fix the bug/);
465
+ await fire("before_agent_start", ctx, withTasksEvent);
466
+ assert.equal(withTasksEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "writing a list must not change the system prompt");
467
+ assert.doesNotMatch(withTasksEvent.systemPromptOptions.appendSystemPrompt, /Fix the bug/, "task content never enters the system prompt");
468
+ const state = await contextState(fire, ctx);
469
+ assert.ok(state, "open tasks arrive as a trailing request-local message");
470
+ assert.equal(state.role, "custom");
471
+ assert.equal(state.display, false);
472
+ assert.match(String(state.content), /1\. \[pending\] Fix the bug/);
435
473
  assert.doesNotMatch(widget()![0], /stale/);
436
474
 
437
- const staleEvent = promptEvent();
438
- await fire("before_agent_start", ctx, staleEvent);
439
- assert.match(staleEvent.systemPromptOptions.appendSystemPrompt, /stale: 2 turns without an update/);
440
- assert.match(widget()![0], /ctrl\+shift\+t collapse/);
441
- assert.match(widget()![1], /stale · 2 turns/);
475
+ // Silent turns: the UI card goes stale, the system prompt stays frozen (#1367).
476
+ for (const expected of ["stale · 2 turns", "stale · 3 turns"]) {
477
+ const silent = promptEvent();
478
+ await fire("before_agent_start", ctx, silent);
479
+ assert.equal(silent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt);
480
+ assert.match(widget()![1], new RegExp(expected));
481
+ }
442
482
 
483
+ // Status, note, title and ordering changes reach the model through the message only.
443
484
  await tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "on it" }, undefined, undefined, ctx);
444
485
  await fire("tool_execution_end", ctx, { toolName: "todo" });
486
+ const updatedEvent = promptEvent();
487
+ await fire("before_agent_start", ctx, updatedEvent);
488
+ assert.equal(updatedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "a status change must not change the system prompt");
489
+ assert.match(String((await contextState(fire, ctx))!.content), /1\. \[in_progress\] Fix the bug — on it/);
445
490
  assert.doesNotMatch(widget()![0], /stale/);
491
+
492
+ await tools.get("todo")!.execute("c3", { action: "update", id: 1, status: "done" }, undefined, undefined, ctx);
493
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
494
+ assert.equal(await contextState(fire, ctx), undefined, "a finished list sends no state message");
495
+ const finishedEvent = promptEvent();
496
+ await fire("before_agent_start", ctx, finishedEvent);
497
+ assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
446
498
  });
447
499
 
448
500
  test("before_agent_start is idempotent: a todo block already present in appendSystemPrompt is not duplicated", async () => {
@@ -555,8 +607,8 @@ test("a list left with only dropped tasks open finishes and clears; a blocked-on
555
607
  await fire("agent_end", ctx);
556
608
  const event = promptEvent();
557
609
  await fire("before_agent_start", ctx, event);
558
- assert.match(event.systemPromptOptions.appendSystemPrompt, /2\. \[blocked\] Deploy — waiting for an admin/);
559
- assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /stale/);
610
+ assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Deploy/);
611
+ assert.match(String((await contextState(fire, ctx))!.content), /2\. \[blocked\] Deploy — waiting for an admin/);
560
612
  }
561
613
  assert.match(widget()![2], /⊘ Deploy · waiting for an admin/);
562
614
  });