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
@@ -5,21 +5,86 @@ import { dirname, join } from "node:path";
5
5
  import test from "node:test";
6
6
  import { __testing } from "../extensions/gentle-ai.ts";
7
7
 
8
- const { classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle, isOrdinaryYoloPush, yoloPushConfiguredRestriction } = __testing;
8
+ const { classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle, isReversibleYoloCommand, yoloConfiguredRestriction } = __testing;
9
9
 
10
- test("YOLO waiver only selects a single ordinary default-confirm push after full evaluation", () => {
10
+ // gentle-shell#2172: YOLO waives every reversible push and non-interactive
11
+ // rebase segment; anything that deletes, rewrites or publishes keeps asking.
12
+ test("YOLO waives reversible push and rebase segments after full evaluation (#2172)", () => {
11
13
  const config = { autonomousMode: false, guardedCommands: {} };
12
- for (const command of ["git push", "git push -u origin feature/test", "git push --set-upstream origin main"]) {
13
- assert.equal(isOrdinaryYoloPush(command, evaluateGuardedCommand(command, config)), true, command);
14
+ for (const command of [
15
+ "git push", "git push -u origin feature/test", "git push --set-upstream origin main", "git push origin HEAD",
16
+ "git push -q origin main", "git push --quiet --tags", "git push --no-verify origin main", "git push --follow-tags -v origin main",
17
+ "git push origin HEAD:refs/heads/feature/x", "git push origin main:main",
18
+ "git -C /other push origin main", "env X=1 git push origin main",
19
+ "git add -A && git commit -m 'fix: x && y' && git push -u origin feature/test",
20
+ "git push origin main && gh pr create --fill", "cd sub && git push", "git push origin main; git status",
21
+ "git rebase main", "git rebase origin/main", "git rebase --onto main feature~3 feature", "git rebase --continue", "git rebase --abort", "git rebase --autostash main",
22
+ "git fetch origin && git rebase origin/main && git push",
23
+ "git\tpush\torigin\tmain", "git -C dir rebase main", "command git push origin main", "env -i git push origin main", "GIT_AUTHOR_NAME=x git push origin main",
24
+ ]) {
25
+ assert.equal(isReversibleYoloCommand(command, evaluateGuardedCommand(command, config)), true, command);
14
26
  }
15
27
  for (const command of [
16
- "git push --delete origin main", "git push origin :main", "git push --mirror origin",
17
- "git push --prune origin", "git push -f origin main", "git -C /other push origin main",
18
- "git push origin main; git status", "env X=1 git push origin main", "sudo git push origin main",
19
- "git push origin main && git rebase main", "git push origin main && npm publish",
28
+ "git push --delete origin main", "git push -d origin main", "git push origin :main", "git push origin +main", "git push --mirror origin",
29
+ "git push --prune origin", "git push --all origin", "git push -f origin main", "git push --force-with-lease origin main",
30
+ "git push --receive-pack=evil origin main", "git push --exec=evil origin main", "git push --repo=/other origin main", "git push -o ci.skip origin main",
31
+ "sudo git push origin main", "git push origin main && npm publish", "git push origin main && pi remove foo", "git push origin main && git branch -D old",
20
32
  `sh -c 'git push origin main'`, `git push origin main && sh -c "psql -c 'DROP TABLE users'"`,
21
- "git push origin main && find data -delete", "git push origin main && rm -r data",
22
- ]) assert.equal(isOrdinaryYoloPush(command, evaluateGuardedCommand(command, config)), false, command);
33
+ "git push origin main && find data -delete", "git push origin main && rm -r data", "git push origin main && git stash drop", "git push origin main && git reset --hard",
34
+ "git rebase -i HEAD~3", "git rebase --interactive main", "git rebase -x 'make test' main", "git rebase --exec 'make test' main",
35
+ "git commit -m x", "git status",
36
+ // Global git options that run or redirect code are not inspected by the segment grammar, so they never qualify.
37
+ "git -c core.sshCommand=evil push origin main", "git --exec-path=/evil push origin main", "git --git-dir=/x push origin main", "git -C dir rebase -i main",
38
+ "git push origin $(echo main)", "git push origin `echo main`", "GIT_SSH=\"evil\" git push origin main",
39
+ "sh -c 'git push; echo done'", "bash -c 'git push && echo done'", "sh -c 'git rebase main; echo done'", "eval 'git push origin main'", "sudo -u x git push origin main",
40
+ ]) assert.equal(isReversibleYoloCommand(command, evaluateGuardedCommand(command, config)), false, command);
41
+ });
42
+
43
+ // gentle-shell#2174: a guarded word inside an argument of a known data command
44
+ // (search, print, view, non-executing edit) is text, not an operation. Every
45
+ // opaque form keeps today's lexical match.
46
+ test("guarded words inside data-command arguments are not guarded operations (#2174)", () => {
47
+ const config = { autonomousMode: false, guardedCommands: {} };
48
+ for (const command of [
49
+ "grep -n 'git push origin main' probe.mts",
50
+ "grep -o '\"command\":\"[^\"]*git push[^\"]*\"' session.jsonl | sort | uniq -c | sort -rn | head -8",
51
+ "rg 'npm publish' docs",
52
+ "echo 'remember to git push later'",
53
+ "cat > probe.mts <<'EOF'\nconst x = \"git push --force origin main\";\nEOF",
54
+ "grep -rn 'git reset --hard' docs",
55
+ "env LC_ALL=C grep 'pi remove' notes.md",
56
+ ]) assert.equal(classifyGuardedCommand(command, config), "not-guarded", command);
57
+ for (const [command, expected] of [
58
+ ["git push origin main", "confirm"],
59
+ ["grep x file && git push origin main", "confirm"],
60
+ ["sh -c 'git push origin main'", "confirm"],
61
+ ["bash <<'EOF'\ngit push origin main\nEOF", "confirm"],
62
+ ["python3 -c \"import os; os.system('git push')\"", "confirm"],
63
+ ["echo $(git push origin main)", "confirm"],
64
+ ["grep 'git push origin main' f | sh", "confirm"],
65
+ ["time git push origin main", "confirm"],
66
+ ["sed 's/x/git push/e' file", "confirm"],
67
+ ["grep \"git push origin main file", "confirm"],
68
+ ["ssh host git push origin main", "confirm"],
69
+ ["cat <<'EOF' | bash\ngit push -f origin main\nEOF", "block"],
70
+ ["git reset --hard", "block"],
71
+ ["echo ok; git push --force origin main", "block"],
72
+ // Verifier counterexamples: every executor or unproven heredoc keeps the lexical guard.
73
+ ["cat <<EOF; git push origin main\nhello\nEOF", "confirm"],
74
+ ["cat <<EOF; chmod -R 777 /tmp/guard-target\nhello\nEOF", "block"],
75
+ ["echo '<<true'\ngit push --force origin main\ntrue", "block"],
76
+ ["printf '%s\\n' 'git push --force origin main' | xargs env", "block"],
77
+ ["printf '%s\\n' 'git push origin main' | sudo -s", "confirm"],
78
+ ["printf '%s\\n' 'git push origin main' | env -S 'sh -s'", "confirm"],
79
+ ["printf '%s\\n' 'git push origin main' | exec -a cat sh", "confirm"],
80
+ ["printf '%s\\n' 'git push origin main' | builtin source /dev/stdin", "confirm"],
81
+ ["printf '%s\\n' 'git push origin main' | $'sh'", "confirm"],
82
+ ["printf x | sed 's|.*|git push --force origin main|e'", "block"],
83
+ ["printf x | sed '1e git push origin main'", "confirm"],
84
+ ["echo 'git push origin main' | at now", "confirm"],
85
+ ["echo 'git push origin main' > x.sh && ./x.sh", "confirm"],
86
+ ["sed -i '' 's/a/git push/' file", "confirm"],
87
+ ] as const) assert.equal(classifyGuardedCommand(command, config), expected, command);
23
88
  });
24
89
 
25
90
  test("YOLO preserves explicit push restrictions in either config layer independently of persistent autonomy", () => {
@@ -27,18 +92,23 @@ test("YOLO preserves explicit push restrictions in either config layer independe
27
92
  const cwd = join(root, "repo");
28
93
  const home = join(root, "home");
29
94
  try {
30
- assert.equal(yoloPushConfiguredRestriction(cwd, { gentlePiConfigHome: home }), undefined);
95
+ const pushOnly = (): unknown => yoloConfiguredRestriction(cwd, ["gitPush"], { gentlePiConfigHome: home });
96
+ assert.equal(pushOnly(), undefined);
31
97
  writeConfig(home, "runtime-guardrails.json", { autonomousMode: false, guardedCommands: { gitPush: "confirm" } });
32
98
  writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { autonomousMode: true, guardedCommands: { gitPush: "allow" } });
33
- assert.equal(yoloPushConfiguredRestriction(cwd, { gentlePiConfigHome: home }), "confirm");
99
+ assert.equal(pushOnly(), "confirm");
34
100
  writeConfig(home, "runtime-guardrails.json", { autonomousMode: true, guardedCommands: { gitPush: "allow" } });
35
- assert.equal(yoloPushConfiguredRestriction(cwd, { gentlePiConfigHome: home }), undefined);
101
+ assert.equal(pushOnly(), undefined);
36
102
  writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitPush: "block" } });
37
- assert.equal(yoloPushConfiguredRestriction(cwd, { gentlePiConfigHome: home }), "block");
103
+ assert.equal(pushOnly(), "block");
38
104
  writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitPush: "confirm" } });
39
- assert.equal(yoloPushConfiguredRestriction(cwd, { gentlePiConfigHome: home }), "confirm");
105
+ assert.equal(pushOnly(), "confirm");
40
106
  writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", null);
41
- assert.equal(yoloPushConfiguredRestriction(cwd, { gentlePiConfigHome: home }), "confirm");
107
+ assert.equal(pushOnly(), "confirm");
108
+ // gentle-shell#2172: a rebase rule restricts only commands that rebase.
109
+ writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitRebase: "block" } });
110
+ assert.equal(pushOnly(), undefined, "a rebase rule does not restrict a push-only command");
111
+ assert.equal(yoloConfiguredRestriction(cwd, ["gitPush", "gitRebase"], { gentlePiConfigHome: home }), "block");
42
112
  } finally { rmSync(root, { recursive: true, force: true }); }
43
113
  });
44
114
 
@@ -17,7 +17,7 @@ const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) =
17
17
  const NODE = "24.21.0";
18
18
  const PNPM = "11.1.1";
19
19
  const NPM = "11.19.0";
20
- const ENGRAM = "3.3.2";
20
+ const ENGRAM = "3.4.0";
21
21
 
22
22
  // Every temporary home this file creates is removed when the file's tests end.
23
23
  const roots: string[] = [];
@@ -373,8 +373,8 @@ posixTest("ensureEngram downloads nothing when the prefix is missing", async ()
373
373
  });
374
374
 
375
375
  test("engramExecutable takes only the top-level engram from the tarball and engram.exe from the zip, regular files only", () => {
376
- const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.3.2/engram_3.3.2_linux_amd64.tar.gz" };
377
- const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.3.2/engram_3.3.2_windows_amd64.zip" };
376
+ const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_linux_amd64.tar.gz" };
377
+ const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_windows_amd64.zip" };
378
378
  assert.deepEqual(engramExecutable(engramArchive, tarball), engramBytes);
379
379
  const exe = Buffer.from("MZ engram");
380
380
  assert.deepEqual(engramExecutable(zip([["tools/cloud-sync-projects.ps1", Buffer.from("x")], ["engram.exe", exe]]), zipped), exe);
@@ -16,7 +16,7 @@ const OTHER_ID = "fedcba9876543210fedcba9876543210";
16
16
  const READY = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
17
17
  instance_id: ID, runtime_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", capability: "startup-verified-store-v1" };
18
18
  const REFUSED: shell.EngramProbe = { kind: "refused" };
19
- const CORE = "3.3.2";
19
+ const CORE = "3.4.0";
20
20
  const listening = (compatible: boolean, healthInstanceId?: string, healthVersion = CORE): shell.EngramProbe =>
21
21
  ({ kind: "listening", compatible, healthInstanceId, healthVersion: healthInstanceId === undefined ? undefined : healthVersion });
22
22
 
@@ -44,11 +44,36 @@ test("existing, unreadable or foreign stores without matching provenance are dis
44
44
  for (const store of ["present", "unknown"] as const) {
45
45
  const result = classify({ store });
46
46
  assert.equal(result.policy, "disabled", store);
47
- assert.match(result.notice!, /Back up.*update.*restart.*recheck/is);
47
+ assert.match(result.notice!, /could not verify.*compatibility.*Back up/is);
48
+ assert.doesNotMatch(result.notice!, /update Engram and restart/i);
48
49
  }
49
50
  assert.equal(classify({ store: "present", endpoint: listening(false, ID) }).policy, "disabled");
50
51
  });
51
52
 
53
+ test("a running server older than the first compatibility-capable core is named in the notice, without changing the decision", () => {
54
+ for (const version of ["3.3.2", "v3.3.2", "2.9.0"]) {
55
+ const result = classify({ store: "present", endpoint: listening(false, OTHER_ID, version) });
56
+ assert.equal(result.policy, "disabled", version);
57
+ assert.equal(result.url, undefined, version);
58
+ assert.match(result.notice!, new RegExp(`reports version ${version.replace(/^v/, "").replaceAll(".", "\\.")}`), version);
59
+ assert.match(result.notice!, /update that server to 3\.4\.0 or later.*restart it/s, version);
60
+ assert.match(result.notice!, /Back up/, version);
61
+ }
62
+ // Shell's own store behind an older server gets the same guidance and stays disabled.
63
+ const own = classify({ store: "present", provenance: { instanceId: ID }, endpoint: listening(false, ID, "3.3.2") });
64
+ assert.equal(own.policy, "disabled");
65
+ assert.match(own.notice!, /reports version 3\.3\.2/);
66
+ });
67
+
68
+ test("a current, unparsable or hostile server version keeps the generic notice and is never echoed", () => {
69
+ for (const version of ["3.4.0", "3.10.0", "4.0.0", "dev", "", "3.3.2\u001b[31m", "3.3.2 update now", "03.3.2", "3.03.2", "3.3.02", "v03.3.2"]) {
70
+ const result = classify({ store: "present", endpoint: listening(false, OTHER_ID, version) });
71
+ assert.equal(result.policy, "disabled", JSON.stringify(version));
72
+ assert.equal(result.notice, shell.ENGRAM_RECOVERY_NOTICE, JSON.stringify(version));
73
+ }
74
+ assert.equal(classify({ store: "present", endpoint: listening(false) }).notice, shell.ENGRAM_RECOVERY_NOTICE);
75
+ });
76
+
52
77
  test("explicit selections and invalid policies are disabled without depending on probes", () => {
53
78
  for (const key of ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_DATABASE_URL"]) {
54
79
  assert.equal(classify({ env: { [key]: "chosen" }, endpoint: listening(true) }).policy, "disabled", key);
@@ -74,7 +99,7 @@ test("Shell provenance allows its own store only for the same instance and the s
74
99
  assert.deepEqual(classify({ store: "present", provenance }), { policy: "allow" });
75
100
  assert.deepEqual(classify({ store: "present", provenance, endpoint: listening(false, ID) }), { policy: "allow" });
76
101
  assert.equal(classify({ store: "present", provenance, endpoint: listening(false, OTHER_ID) }).policy, "disabled");
77
- for (const version of ["3.3.3", "v3.3.2", "dev", ""]) {
102
+ for (const version of ["3.3.3", "v3.4.0", "dev", ""]) {
78
103
  assert.equal(classify({ store: "present", provenance, endpoint: listening(false, ID, version) }).policy, "disabled", version);
79
104
  }
80
105
  assert.equal(classify({ store: "present", provenance, endpoint: listening(false) }).policy, "disabled");
@@ -149,6 +174,7 @@ test("provenance matches only the same directory, instance and core digest", ()
149
174
  test("the header is a newer-schema tripwire against the verified bundled schema, never a compatibility proof", () => {
150
175
  // v3.3.2:internal/store/store.go declares schemaVersion = 1.
151
176
  assert.equal(shell.bundledEngramSchema("3.3.2"), 1);
177
+ assert.equal(shell.bundledEngramSchema("3.4.0"), 1);
152
178
  assert.equal(shell.bundledEngramSchema("3.3.3"), undefined);
153
179
  for (const userVersion of [0, 1]) assert.equal(shell.engramHeaderAllows(userVersion, 1), true, String(userVersion));
154
180
  assert.equal(shell.engramHeaderAllows(2, 1), false);
@@ -215,7 +241,7 @@ test("probe reports refused for a closed port and unknown for a silent listener"
215
241
  assert.ok(Date.now() - started < 2000);
216
242
  });
217
243
 
218
- const base = { node: "/node", cli: "/pi", prefix: "/prefix", engram: "/prefix/runtime/engram-3.3.2/engram", guard: GUARD };
244
+ const base = { node: "/node", cli: "/pi", prefix: "/prefix", engram: "/prefix/runtime/engram-3.4.0/engram", guard: GUARD };
219
245
 
220
246
  test("disabled memory without explicit selections points ENGRAM_BIN at the guard and keeps the policy", () => {
221
247
  const memory = classify({ store: "present" });
@@ -274,8 +300,10 @@ test("the guard fails non-zero, reports the notice and leaves Engram data byte-i
274
300
  assert.match(result.stderr, /Back up first\. \$\(touch injected\)/);
275
301
  }
276
302
  const fallback = spawnSync(GUARD, ["instance-id"], { env: clean({ HOME: root }), encoding: "utf8" });
277
- assert.notEqual(fallback.status, 0);
278
- assert.match(fallback.stderr, /Engram memory is disabled/);
303
+ assert.equal(fallback.status, 78);
304
+ assert.equal(fallback.stdout, "");
305
+ assert.match(fallback.stderr, /could not verify.*compatibility/i);
306
+ assert.doesNotMatch(fallback.stderr, /update Engram, restart/i);
279
307
  assert.equal(dataSnapshot(data), before);
280
308
  assert.deepEqual(readdirSync(root).sort(), [".engram"]);
281
309
  });
@@ -30,6 +30,7 @@ import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
30
30
  import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts";
31
31
  import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
32
32
  import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
33
+ import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
33
34
  import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
34
35
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
35
36
  // The card style defaults to float; these assertions pin the outlined (neon)
@@ -92,6 +93,9 @@ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-")));
92
93
  const activeSessionTeardowns = new Set<() => Promise<void>>();
93
94
  const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown()));
94
95
  afterEach(stopActiveSessions);
96
+ // Every fixture session is "s1": the inherited-profile freeze is process state
97
+ // keyed by session id, so each test starts from an unfrozen session.
98
+ afterEach(() => resetFrozenInheritedProfilesForTesting());
95
99
  // subagent_run's default mode now reads the background-subagents policy
96
100
  // in-process (gentle-pi#background-subagents-default-mode), which falls
97
101
  // back to the real ~/.pi/gentle-ai/background-subagents.json when
@@ -353,7 +357,9 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
353
357
  pi: { command: "pi", args: [] },
354
358
  home,
355
359
  resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
356
- env: { PATH: "/bin" },
360
+ // The launch reads the global active profile from the config home; keep it
361
+ // on the scratch directory instead of the developer's own profiles.json.
362
+ env: { PATH: "/bin", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME },
357
363
  sessionTransport: inertSessionTransport,
358
364
  },
359
365
  };
@@ -1223,7 +1229,7 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
1223
1229
  return { stdout: JSON.stringify({ schema: "gentle-ai.telemetry-policy/v1", operation: "policy", enabled: true,
1224
1230
  source: "state", reason: "enabled" }), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1225
1231
  } };
1226
- const env: NodeJS.ProcessEnv = {};
1232
+ const env: NodeJS.ProcessEnv = { GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
1227
1233
  const profile = join(root, `metrics-${boundary}`);
1228
1234
  mkdirSync(join(profile, "agents"), { recursive: true });
1229
1235
  writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
@@ -2227,6 +2233,8 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2227
2233
  try {
2228
2234
  const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
2229
2235
  const h = fakePi();
2236
+ // The launch reads the global active profile; keep it on the scratch config home.
2237
+ env = { ...env, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
2230
2238
  gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
2231
2239
  const { ctx } = fakeContext();
2232
2240
  (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
@@ -2258,7 +2266,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2258
2266
  assert.equal(captured[index]?.command, "/fixture/pi");
2259
2267
  assert.deepEqual(captured[index]?.args, args);
2260
2268
  assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
2261
- assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
2269
+ assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
2262
2270
  assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
2263
2271
  assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
2264
2272
  assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
@@ -2387,6 +2395,9 @@ test("foreign clone tool requires consent before queueing and never enters paren
2387
2395
  await tick();
2388
2396
  assert.deepEqual(spawned, [foreign]);
2389
2397
  assert.equal((runnerRun.mock.calls[0]?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, undefined, "foreign child must not receive parent review permission channel");
2398
+ // The parent session froze "no profile" (no pin, no global active), so the
2399
+ // foreign launch keeps the foreign repository's own pin (gentle-shell#1064
2400
+ // 3b-ii). Dropping `foreignRepository` from the launch wiring breaks this.
2390
2401
  assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/foreign-model:minimal");
2391
2402
  assert.equal((result.details.gentleAgents as { cwd: string }).cwd, foreign);
2392
2403
  assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
@@ -2437,6 +2448,34 @@ test("foreign clone tool requires consent before queueing and never enters paren
2437
2448
  } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
2438
2449
  });
2439
2450
 
2451
+ test("a frozen session profile routes a foreign repository launch over that repository's pin", async (t) => {
2452
+ const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-frozen-")));
2453
+ t.after(() => rmSync(fixture, TEST_DIR_REMOVAL));
2454
+ const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
2455
+ mkdirSync(template);
2456
+ for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
2457
+ const configHome = join(fixture, "config");
2458
+ mkdirSync(configHome);
2459
+ writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "session", profiles: {
2460
+ session: { explore: { model: "openai/session-model", thinking: "low" } },
2461
+ pinned: { explore: { model: "openai/foreign-model", thinking: "minimal" } },
2462
+ } }));
2463
+ mkdirSync(join(foreign, ".git", "gentle-ai"));
2464
+ writeFileSync(join(foreign, ".git", "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "pinned" }));
2465
+ const h = fakePi(), runtime = deps();
2466
+ runtime.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: configHome };
2467
+ runtime.deps.resolveWorktree = resolveSessionWorktree;
2468
+ gentleAgents(h.pi, {}, runtime.deps);
2469
+ const { ctx } = fakeContext();
2470
+ ctx.sessionManager.getCwd = () => parent;
2471
+ await h.fire("session_start", ctx);
2472
+ t.after(async () => { await h.fire("session_shutdown", ctx); await tick(); });
2473
+ assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "session", "the parent froze the global active profile");
2474
+ await h.tools.get("subagent_run")!.execute("foreign-frozen", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
2475
+ await tick();
2476
+ assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/session-model:low", "the session profile wins over the foreign pin");
2477
+ });
2478
+
2440
2479
  test("foreign child Changes require successful target-bound tool evidence, never model claims or sibling writes", async () => {
2441
2480
  const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-changes-")));
2442
2481
  const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), sibling = join(fixture, "sibling"), template = join(fixture, "template");
@@ -2826,7 +2865,8 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
2826
2865
  Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => scenario === "model" || model === "bad" ? undefined : { provider: "offline", id: model } } });
2827
2866
  ctx.sessionManager.getCwd = () => project;
2828
2867
  ctx.sessionManager.getEntries = () => h.entries as never;
2829
- await h.fire("session_start", ctx);
2868
+ // The repository declaration exists before the session starts: the session
2869
+ // freezes it at session_start (gentle-shell#1064 slice 3b-ii).
2830
2870
  if (scenario === "profile-model" || scenario === "profile-valid") {
2831
2871
  mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
2832
2872
  writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "invalid" }));
@@ -2834,6 +2874,7 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
2834
2874
  mkdirSync(config, { recursive: true });
2835
2875
  writeFileSync(join(config, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { invalid: { worker: { model: scenario === "profile-valid" ? "offline/pinned-good" : "offline/bad" } } } }));
2836
2876
  }
2877
+ await h.fire("session_start", ctx);
2837
2878
  let calls = 0;
2838
2879
  const abort = new AbortController();
2839
2880
  const unbind = scenario === "missing" ? () => {} : bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
@@ -3095,8 +3136,8 @@ function pinFixture(name: string) {
3095
3136
  declarationPath,
3096
3137
  writePin: (profile: string) => writePinText(localPinPath, profile),
3097
3138
  writeDeclaration: (profile: string) => writePinText(declarationPath, profile),
3098
- writeStore: (profiles: Record<string, unknown>) => {
3099
- writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles }, null, 2)}\n`);
3139
+ writeStore: (profiles: Record<string, unknown>, active?: string) => {
3140
+ writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, ...(active === undefined ? {} : { active }), profiles }, null, 2)}\n`);
3100
3141
  },
3101
3142
  };
3102
3143
  }
@@ -3222,6 +3263,121 @@ test("a queued launch keeps the session routing frozen across a rebind", async t
3222
3263
  }
3223
3264
  });
3224
3265
 
3266
+ // gentle-shell#1064 slice 3b-ii: a parent session without an explicit Enter
3267
+ // binding freezes p → P → G at session_start; later default changes only show
3268
+ // a drift notice once per change. GENTLE_PI_PROFILE_FOLLOW=1 keeps the live
3269
+ // resolution.
3270
+ async function launchSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
3271
+ const harness = deps();
3272
+ harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
3273
+ harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
3274
+ const { pi, tools, fire } = fakePi();
3275
+ gentleAgents(pi, {}, harness.deps);
3276
+ const { ctx, dialogs } = fakeContext();
3277
+ await fire("session_start", ctx);
3278
+ let count = 0;
3279
+ return {
3280
+ dialogs,
3281
+ driftNotices: () => dialogs.filter((dialog) => dialog.includes("the default profile changed")),
3282
+ launch: async () => {
3283
+ await tools.get("subagent_run")!.execute(`drift-${count}`, { agent: "explore", task: `Map ${count}`, mode: "background" }, undefined, undefined, ctx);
3284
+ await tick();
3285
+ const args = harness.spawned[count];
3286
+ // Settle the child so the next launch is not held by the concurrency cap.
3287
+ const child = harness.children[count++]!;
3288
+ child.emit({ type: "agent_end", messages: [] });
3289
+ child.emit({ type: "agent_settled" });
3290
+ child.exit(0);
3291
+ await tick();
3292
+ return args[args.indexOf("--model") + 1];
3293
+ },
3294
+ shutdown: async () => {
3295
+ await fire("session_shutdown", ctx);
3296
+ await tick();
3297
+ },
3298
+ };
3299
+ }
3300
+
3301
+ const DRIFT_PROFILES = {
3302
+ frontier: { explore: { model: "openai/alpha", thinking: "minimal" } },
3303
+ local: { explore: { model: "openai/beta" } },
3304
+ };
3305
+
3306
+ test("session_start freezes the inherited pin: a later pin change does not move the open session", async t => {
3307
+ const base = pinFixture("freeze-pin");
3308
+ base.writeStore(DRIFT_PROFILES);
3309
+ base.writePin("frontier");
3310
+ const session = await launchSession(base);
3311
+ t.after(session.shutdown);
3312
+ assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "frontier", "frozen at session_start, before any launch");
3313
+ base.writePin("local");
3314
+ assert.equal(await session.launch(), "openai/alpha:minimal");
3315
+ assert.equal(await session.launch(), "openai/alpha:minimal");
3316
+ assert.deepEqual(session.driftNotices(), [
3317
+ 'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
3318
+ ], "one notice for one change, across two launches");
3319
+ base.writeStore(DRIFT_PROFILES, "local");
3320
+ base.writeDeclaration("local");
3321
+ rmSync(base.localPinPath);
3322
+ assert.equal(await session.launch(), "openai/alpha:minimal");
3323
+ assert.equal(session.driftNotices().length, 1, "the same default reached through another layer is not a new change");
3324
+ });
3325
+
3326
+ test("an unpinned session routes through the global active profile and freezes it", async t => {
3327
+ const base = pinFixture("freeze-global");
3328
+ base.writeStore(DRIFT_PROFILES, "frontier");
3329
+ const session = await launchSession(base);
3330
+ t.after(session.shutdown);
3331
+ base.writeStore(DRIFT_PROFILES, "local");
3332
+ assert.equal(await session.launch(), "openai/alpha:minimal", "the global active profile replaces the materialized routing");
3333
+ assert.equal(session.driftNotices().length, 1);
3334
+ assert.match(session.driftNotices()[0]!, /changed to "local" \(global\), this session keeps "frontier" \(global\)/);
3335
+ });
3336
+
3337
+ test("a session with no profile anywhere keeps today's routing even after a pin appears", async t => {
3338
+ const base = pinFixture("freeze-none");
3339
+ base.writeStore(DRIFT_PROFILES);
3340
+ const session = await launchSession(base);
3341
+ t.after(session.shutdown);
3342
+ assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low", "today's materialized routing");
3343
+ base.writePin("frontier");
3344
+ assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low");
3345
+ assert.deepEqual(session.driftNotices(), [
3346
+ 'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.',
3347
+ ]);
3348
+ });
3349
+
3350
+ test("an explicit Enter binding wins over the frozen profile and never shows the drift notice", async t => {
3351
+ t.after(() => resetSessionProfileBindingsForTesting());
3352
+ const base = pinFixture("freeze-explicit");
3353
+ base.writeStore(DRIFT_PROFILES);
3354
+ base.writePin("frontier");
3355
+ const session = await launchSession(base);
3356
+ t.after(session.shutdown);
3357
+ bindSessionProfile("s1", "local", { explore: { model: "openai/beta" } });
3358
+ base.writePin("local");
3359
+ base.writeStore(DRIFT_PROFILES, "frontier");
3360
+ rmSync(base.localPinPath);
3361
+ assert.equal(await session.launch(), "openai/beta:high");
3362
+ assert.deepEqual(session.driftNotices(), []);
3363
+ });
3364
+
3365
+ test("follow mode re-resolves the defaults on every launch without a notice; Enter makes it explicit", async t => {
3366
+ t.after(() => resetSessionProfileBindingsForTesting());
3367
+ const base = pinFixture("follow");
3368
+ base.writeStore(DRIFT_PROFILES);
3369
+ base.writePin("frontier");
3370
+ const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3371
+ t.after(session.shutdown);
3372
+ assert.equal(readFrozenInheritedProfile("s1"), undefined, "follow never freezes");
3373
+ assert.equal(await session.launch(), "openai/alpha:minimal");
3374
+ base.writePin("local");
3375
+ assert.equal(await session.launch(), "openai/beta:high");
3376
+ bindSessionProfile("s1", "frontier", { explore: { model: "openai/alpha", thinking: "minimal" } });
3377
+ assert.equal(await session.launch(), "openai/alpha:minimal", "the explicit binding stops following");
3378
+ assert.deepEqual(session.driftNotices(), []);
3379
+ });
3380
+
3225
3381
  test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
3226
3382
  assert.equal(agentsEnabled({}), true);
3227
3383
  assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
@@ -80,10 +80,10 @@ function standaloneLauncher(t: test.TestContext) {
80
80
 
81
81
  test("a genuinely absent adjacent peer falls back to PATH", (t) => {
82
82
  const f = standaloneLauncher(t);
83
- writePathPiScript(f.root, "0.99.2");
83
+ writePathPiScript(f.root, "1.2.1");
84
84
  const result = spawnSync(process.execPath, [f.launcher, "--version"], { env: f.env, encoding: "utf8" });
85
85
  assert.equal(result.status, 0, result.stderr);
86
- assert.match(result.stdout, /pi 0\.99\.2/);
86
+ assert.match(result.stdout, /pi 1\.2\.1/);
87
87
  });
88
88
 
89
89
  test("malformed adjacent metadata never silently falls back to PATH; env override still wins", (t) => {
@@ -91,8 +91,8 @@ test("malformed adjacent metadata never silently falls back to PATH; env overrid
91
91
  const peer = join(f.root, "node_modules", "@earendil-works", "pi-coding-agent");
92
92
  mkdirSync(join(peer, "dist", "bundle"), { recursive: true });
93
93
  writeFileSync(join(peer, "dist", "index.js"), "");
94
- writePiScript(join(peer, "dist", "bundle", "cli.js"), "0.99.1");
95
- const fallback = writePathPiScript(f.root, "0.99.2");
94
+ writePiScript(join(peer, "dist", "bundle", "cli.js"), "1.2.0");
95
+ const fallback = writePathPiScript(f.root, "1.2.1");
96
96
  const base = { name: "@earendil-works/pi-coding-agent", type: "module", exports: { ".": { import: "./dist/index.js" } }, bin: { pi: "dist/bundle/cli.js" } };
97
97
  for (const metadata of [{ ...base, name: "impostor" }, { ...base, bin: { pi: "../outside.js" } }, { ...base, bin: {} }, "malformed"]) {
98
98
  writeFileSync(join(peer, "package.json"), metadata === "malformed" ? "{" : JSON.stringify(metadata));
@@ -103,7 +103,7 @@ test("malformed adjacent metadata never silently falls back to PATH; env overrid
103
103
  writeFileSync(join(peer, "package.json"), JSON.stringify({ ...base, name: "impostor" }));
104
104
  const override = spawnSync(process.execPath, [f.launcher, "--version"], { env: { ...f.env, GENTLE_SHELL_PI: fallback }, encoding: "utf8" });
105
105
  assert.equal(override.status, 0, override.stderr);
106
- assert.match(override.stdout, /pi 0\.99\.2/);
106
+ assert.match(override.stdout, /pi 1\.2\.1/);
107
107
  });
108
108
 
109
109
  // The launcher's own gentle-pi version, exactly as `ownPackageVersion()` in
@@ -127,7 +127,7 @@ function fixture(t: test.TestContext) {
127
127
  const home = join(root, "home");
128
128
  mkdirSync(home, { recursive: true });
129
129
  const piScript = join(root, "fake-pi.mjs");
130
- writePiScript(piScript, "0.99.1");
130
+ writePiScript(piScript, "1.2.0");
131
131
  const gentleShellHome = join(root, "gentle-shell-home");
132
132
  const env: NodeJS.ProcessEnv = {
133
133
  ...process.env,
@@ -496,7 +496,7 @@ function writeGentleAiScriptDeclaringGentlePi(path: string, extraSources: string
496
496
  // the invocation), this models what a real `pi remove` does to settings.json
497
497
  // closely enough to prove a later plain launch sees the declaration gone and
498
498
  // resumes the launcher's own injection.
499
- function writePiScriptEditingSettingsOnRemove(path: string, version = "0.99.1") {
499
+ function writePiScriptEditingSettingsOnRemove(path: string, version = "1.2.0") {
500
500
  writeFileSync(
501
501
  path,
502
502
  [
@@ -821,7 +821,7 @@ test("a too-old pi exits 1 naming both versions", (t) => {
821
821
  const result = run(f.env, []);
822
822
  assert.equal(result.status, 1);
823
823
  assert.match(result.stderr, /0\.80\.0/);
824
- assert.match(result.stderr, /0\.99\.1/);
824
+ assert.match(result.stderr, /1\.2\.0/);
825
825
  });
826
826
 
827
827
  test("--version prints three lines", (t) => {
@@ -831,7 +831,7 @@ test("--version prints three lines", (t) => {
831
831
  const lines = result.stdout.trim().split("\n");
832
832
  assert.equal(lines.length, 3);
833
833
  assert.match(lines[0], /^gentle-shell /);
834
- assert.match(lines[1], /^pi 0\.99\.1$/);
834
+ assert.match(lines[1], /^pi 1\.2\.0$/);
835
835
  assert.match(lines[2], /^home isolated /);
836
836
  });
837
837
 
@@ -1278,7 +1278,7 @@ test("gentle-shell setup propagates a non-zero pi remove exit code with an actio
1278
1278
  const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1279
1279
  writeGentleAiScriptDeclaringConflict(gentleAiScript);
1280
1280
  const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
1281
- writePiScript(failingPiScript, "0.99.1", 7);
1281
+ writePiScript(failingPiScript, "1.2.0", 7);
1282
1282
  const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
1283
1283
 
1284
1284
  const result = run(env, ["setup"]);
@@ -1299,7 +1299,7 @@ test("gentle-shell setup --home <dir> includes --home <dir> in the failing-remov
1299
1299
  const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1300
1300
  writeGentleAiScriptDeclaringConflict(gentleAiScript);
1301
1301
  const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
1302
- writePiScript(failingPiScript, "0.99.1", 7);
1302
+ writePiScript(failingPiScript, "1.2.0", 7);
1303
1303
  const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
1304
1304
 
1305
1305
  const result = run(env, ["--home", target, "setup"]);
@@ -1323,7 +1323,7 @@ test("gentle-shell setup shell-quotes a --home path containing a space in the fa
1323
1323
  const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1324
1324
  writeGentleAiScriptDeclaringConflict(gentleAiScript);
1325
1325
  const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
1326
- writePiScript(failingPiScript, "0.99.1", 7);
1326
+ writePiScript(failingPiScript, "1.2.0", 7);
1327
1327
  const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
1328
1328
 
1329
1329
  const result = run(env, ["--home", target, "setup"]);
@@ -1865,7 +1865,7 @@ test("setup gives Gentle AI a `pi` on its PATH that runs the same Pi gentle-shel
1865
1865
 
1866
1866
  const result = run(env, ["setup"]);
1867
1867
  assert.equal(result.status, 0, result.stderr);
1868
- assert.deepEqual(JSON.parse(readFileSync(probe, "utf8")), { status: 0, error: null, stdout: "0.99.1\n" });
1868
+ assert.deepEqual(JSON.parse(readFileSync(probe, "utf8")), { status: 0, error: null, stdout: "1.2.0\n" });
1869
1869
  // The launcher is temporary: nothing is left behind in the temp folder it used.
1870
1870
  assert.deepEqual(readdirSync(temp), []);
1871
1871
  });
@@ -2171,7 +2171,7 @@ test("a hung 'pi remove' during post-install cleanup is killed after the timeout
2171
2171
  [
2172
2172
  "#!/usr/bin/env node",
2173
2173
  "const args = process.argv.slice(2);",
2174
- 'if (args.includes("--version")) { console.log("0.99.1"); process.exit(0); }',
2174
+ 'if (args.includes("--version")) { console.log("1.2.0"); process.exit(0); }',
2175
2175
  "if (args[0] === 'remove') { await new Promise((resolve) => setTimeout(resolve, 60000)); process.exit(0); }",
2176
2176
  "console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
2177
2177
  "process.exit(0);",
@@ -3186,7 +3186,7 @@ function writeHandoffPiScript(path: string, exitCode = 0) {
3186
3186
  "#!/usr/bin/env node",
3187
3187
  "const { writeFileSync } = require('node:fs');",
3188
3188
  "const args = process.argv.slice(2);",
3189
- "if (args.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
3189
+ "if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
3190
3190
  "const handoff = process.env.GENTLE_SHELL_RESUME_HANDOFF;",
3191
3191
  "if (handoff) writeFileSync(handoff, JSON.stringify({ sessionId: 'abc' }));",
3192
3192
  "if (process.env.PI_STUB_PRINT_ENV) console.log(JSON.stringify({ args, handoff }));",
@@ -3287,7 +3287,7 @@ function writeWaitingPiScript(path: string) {
3287
3287
  [
3288
3288
  "#!/usr/bin/env node",
3289
3289
  "const { writeFileSync } = require('node:fs');",
3290
- "if (process.argv.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
3290
+ "if (process.argv.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
3291
3291
  "writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc' }));",
3292
3292
  "const quit = () => { process.stdout.write('To resume this session: pi --session abc\\n'); process.exit(0); };",
3293
3293
  "process.on('SIGHUP', quit);",
@@ -3329,7 +3329,7 @@ test("on a TTY a cross-project session resumes by its session file", { skip: !ha
3329
3329
  [
3330
3330
  "#!/usr/bin/env node",
3331
3331
  "const { writeFileSync } = require('node:fs');",
3332
- "if (process.argv.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
3332
+ "if (process.argv.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
3333
3333
  `writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc', sessionFile: ${JSON.stringify(sessionFile)} }));`,
3334
3334
  "process.stdout.write('To resume this session: pi --session abc\\n');",
3335
3335
  "",