gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -3,6 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import {
4
4
  chmodSync,
5
5
  existsSync,
6
+ lstatSync,
6
7
  mkdirSync,
7
8
  mkdtempSync,
8
9
  readdirSync,
@@ -29,6 +30,62 @@ const binUrl = new URL("../bin/gentle-shell.mjs", import.meta.url);
29
30
  const binPath = fileURLToPath(binUrl);
30
31
  const packageRoot = dirname(dirname(binPath));
31
32
 
33
+ test("real adjacent Pi resolves through its public entry without PATH or a runtime override", (t) => {
34
+ const f = fixture(t);
35
+ const result = spawnSync(process.execPath, [binPath, "--version"], {
36
+ env: { HOME: f.home, USERPROFILE: f.home, PATH: "", GENTLE_SHELL_NO_AUTO_SETUP: "1" },
37
+ encoding: "utf8",
38
+ });
39
+ assert.equal(result.status, 0, result.stderr);
40
+ // The adjacent peer is the release the open development range resolved,
41
+ // not the manifest specifier.
42
+ const installed: string = JSON.parse(readFileSync(join(packageRoot, "node_modules", "@earendil-works", "pi-coding-agent", "package.json"), "utf8")).version;
43
+ assert.match(result.stdout, new RegExp(`pi ${installed.replace(/\./g, "\\.")}\\b`));
44
+ assert.equal(existsSync(join(f.home, ".gentle-shell", "agent")), false);
45
+ });
46
+
47
+ // A private copy places the launcher's resolution root outside this checkout,
48
+ // so absent-peer fallback is exercised without moving any real dependency.
49
+ function standaloneLauncher(t: test.TestContext) {
50
+ const f = fixture(t);
51
+ const root = join(f.root, "standalone");
52
+ mkdirSync(join(root, "bin"), { recursive: true });
53
+ writeFileSync(join(root, "bin", "gentle-shell.mjs"), readFileSync(binPath));
54
+ writeFileSync(join(root, "package.json"), readFileSync(join(packageRoot, "package.json")));
55
+ for (const dir of ["runtime", "scripts"]) symlinkSync(join(packageRoot, dir), join(root, dir), "junction");
56
+ const env = { HOME: f.home, USERPROFILE: f.home, PATH: `${root}${delimiter}${dirname(process.execPath)}`, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
57
+ return { ...f, root, env, launcher: join(root, "bin", "gentle-shell.mjs") };
58
+ }
59
+
60
+ test("a genuinely absent adjacent peer falls back to PATH", (t) => {
61
+ const f = standaloneLauncher(t);
62
+ writePiScript(join(f.root, "pi"), "0.99.2");
63
+ const result = spawnSync(process.execPath, [f.launcher, "--version"], { env: f.env, encoding: "utf8" });
64
+ assert.equal(result.status, 0, result.stderr);
65
+ assert.match(result.stdout, /pi 0\.99\.2/);
66
+ });
67
+
68
+ test("malformed adjacent metadata never silently falls back to PATH; env override still wins", (t) => {
69
+ const f = standaloneLauncher(t);
70
+ const peer = join(f.root, "node_modules", "@earendil-works", "pi-coding-agent");
71
+ mkdirSync(join(peer, "dist", "bundle"), { recursive: true });
72
+ writeFileSync(join(peer, "dist", "index.js"), "");
73
+ writePiScript(join(peer, "dist", "bundle", "cli.js"), "0.99.1");
74
+ const fallback = join(f.root, "pi");
75
+ writePiScript(fallback, "0.99.2");
76
+ const base = { name: "@earendil-works/pi-coding-agent", type: "module", exports: { ".": { import: "./dist/index.js" } }, bin: { pi: "dist/bundle/cli.js" } };
77
+ for (const metadata of [{ ...base, name: "impostor" }, { ...base, bin: { pi: "../outside.js" } }, { ...base, bin: {} }, "malformed"]) {
78
+ writeFileSync(join(peer, "package.json"), metadata === "malformed" ? "{" : JSON.stringify(metadata));
79
+ const result = spawnSync(process.execPath, [f.launcher, "--version"], { env: f.env, encoding: "utf8" });
80
+ assert.equal(result.status, 1);
81
+ assert.equal(result.stdout, "");
82
+ }
83
+ writeFileSync(join(peer, "package.json"), JSON.stringify({ ...base, name: "impostor" }));
84
+ const override = spawnSync(process.execPath, [f.launcher, "--version"], { env: { ...f.env, GENTLE_SHELL_PI: fallback }, encoding: "utf8" });
85
+ assert.equal(override.status, 0, override.stderr);
86
+ assert.match(override.stdout, /pi 0\.99\.2/);
87
+ });
88
+
32
89
  // The launcher's own gentle-pi version, exactly as `ownPackageVersion()` in
33
90
  // bin/gentle-shell.mjs reads it (package.json at packageRoot) — used to
34
91
  // assert the post-install gentle-pi removal message and the provisioning
@@ -50,7 +107,7 @@ function fixture(t: test.TestContext) {
50
107
  const home = join(root, "home");
51
108
  mkdirSync(home, { recursive: true });
52
109
  const piScript = join(root, "fake-pi.mjs");
53
- writePiScript(piScript, "0.85.1");
110
+ writePiScript(piScript, "0.99.1");
54
111
  const gentleShellHome = join(root, "gentle-shell-home");
55
112
  const env: NodeJS.ProcessEnv = {
56
113
  ...process.env,
@@ -94,6 +151,7 @@ function writePiScript(path: string, version: string, removeExitCode = 0) {
94
151
  " args,",
95
152
  " PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
96
153
  " GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
154
+ " GENTLE_SHELL_USER_PI_HOME: process.env.GENTLE_SHELL_USER_PI_HOME,",
97
155
  "}));",
98
156
  "process.exit(0);",
99
157
  "",
@@ -106,6 +164,133 @@ function run(env: NodeJS.ProcessEnv, args: string[], options: { cwd?: string } =
106
164
  return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env, ...options });
107
165
  }
108
166
 
167
+ // Herdr tests model terminal/socket metadata only: no socket is opened and the
168
+ // real managed bridge is never imported. The fake pi records launch resources.
169
+ function herdrFixture(t: test.TestContext) {
170
+ const f = fixture(t);
171
+ const bridge = join(f.home, ".pi", "agent", "extensions", "herdr-agent-state.ts");
172
+ mkdirSync(dirname(bridge), { recursive: true });
173
+ writeFileSync(bridge, "export default function () {}\n");
174
+ const socket = join(f.root, "mock-herdr.sock");
175
+ const preload = join(f.root, "terminal-and-socket.cjs");
176
+ writeFileSync(preload, [
177
+ "const fs = require('node:fs');",
178
+ "const { syncBuiltinESMExports } = require('node:module');",
179
+ "const originalStat = fs.statSync;",
180
+ "fs.statSync = (path, ...args) => path === process.env.HERDR_SOCKET_PATH",
181
+ " ? { isSocket: () => process.env.TEST_SOCKET_VALID === '1' } : originalStat(path, ...args);",
182
+ "process.stdin.isTTY = process.env.TEST_STDIN_TTY === '1';",
183
+ "process.stdout.isTTY = process.env.TEST_STDOUT_TTY === '1';",
184
+ "require('node:net').createConnection = () => { throw new Error('Live socket forbidden'); };",
185
+ "syncBuiltinESMExports();",
186
+ ].join("\n"));
187
+ const env: NodeJS.ProcessEnv = {
188
+ ...f.env,
189
+ HERDR_ENV: "1", HERDR_SOCKET_PATH: socket, HERDR_PANE_ID: "mock-pane",
190
+ TEST_SOCKET_VALID: "1", TEST_STDIN_TTY: "1", TEST_STDOUT_TTY: "1",
191
+ };
192
+ delete env.GENTLE_PI_AGENTS_CHILD;
193
+ // Keep parent agent-home inheritance out of every portable test fixture.
194
+ delete env.PI_CODING_AGENT_DIR;
195
+ const launch = (args: string[] = [], overrides: NodeJS.ProcessEnv = {}) => {
196
+ const result = spawnSync(process.execPath, ["--require", preload, binPath, ...args], {
197
+ encoding: "utf8", env: { ...env, ...overrides }, cwd: f.root,
198
+ });
199
+ assert.equal(result.status, 0, result.stderr);
200
+ return JSON.parse(result.stdout).args as string[];
201
+ };
202
+ return { ...f, bridge, env, launch };
203
+ }
204
+
205
+ function extensionPaths(args: string[]): string[] {
206
+ return args.flatMap((arg, i) => arg === "-e" || arg === "--extension" ? [args[i + 1]] : []);
207
+ }
208
+
209
+ test("Herdr isolated TUI launch explicitly loads the canonical managed bridge", (t) => {
210
+ const f = herdrFixture(t);
211
+ assert.ok(extensionPaths(f.launch()).includes(realpathSync(f.bridge)));
212
+ assert.equal(readFileSync(f.bridge, "utf8"), "export default function () {}\n");
213
+ });
214
+
215
+ test("Herdr prefers the incoming agent-home bridge and falls back to the conventional home", (t) => {
216
+ const f = herdrFixture(t);
217
+ const previousHome = join(f.root, "previous-agent");
218
+ const previousBridge = join(previousHome, "extensions", "herdr-agent-state.ts");
219
+ mkdirSync(dirname(previousBridge), { recursive: true });
220
+ writeFileSync(previousBridge, "export default function () {}\n");
221
+ const previousPaths = extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: previousHome }));
222
+ assert.ok(previousPaths.includes(realpathSync(previousBridge)));
223
+ assert.ok(!previousPaths.includes(realpathSync(f.bridge)));
224
+ assert.ok(extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: f.gentleShellHome })).includes(realpathSync(f.bridge)));
225
+ const selectedBridge = join(f.gentleShellHome, "extensions", "herdr-agent-state.ts");
226
+ mkdirSync(dirname(selectedBridge), { recursive: true });
227
+ writeFileSync(selectedBridge, "export default function () {}\n");
228
+ const selectedPaths = extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: previousHome }));
229
+ assert.ok(selectedPaths.includes(realpathSync(selectedBridge)));
230
+ assert.ok(!selectedPaths.includes(realpathSync(previousBridge)));
231
+ assert.ok(!selectedPaths.includes(realpathSync(f.bridge)));
232
+ });
233
+
234
+ test("Herdr bridge injection respects disabled extensions, headless modes and child launches", (t) => {
235
+ const f = herdrFixture(t);
236
+ for (const args of [["--no-extensions"], ["-ne"], ["-p", "prompt"], ["--print", "prompt"],
237
+ ["--mode", "rpc"], ["--mode", "json"], ["--export", "session.jsonl"], ["--list-models"],
238
+ ["--link"], ["--home", f.gentleShellHome], ["list"]]) {
239
+ assert.ok(!extensionPaths(f.launch(args)).includes(realpathSync(f.bridge)), JSON.stringify(args));
240
+ }
241
+ for (const overrides of [{ HERDR_ENV: "0" }, { HERDR_ENV: undefined }, { HERDR_PANE_ID: "" },
242
+ { HERDR_SOCKET_PATH: "" }, { TEST_SOCKET_VALID: "0" }, { TEST_STDIN_TTY: "0" },
243
+ { TEST_STDOUT_TTY: "0" }, { GENTLE_PI_AGENTS_CHILD: "1" }]) {
244
+ assert.ok(!extensionPaths(f.launch([], overrides)).includes(realpathSync(f.bridge)), JSON.stringify(overrides));
245
+ }
246
+ assert.ok(extensionPaths(f.launch(["--mode", "text"])).includes(realpathSync(f.bridge)));
247
+ });
248
+
249
+ test("Herdr absent bridge is nonfatal and explicit resources are preserved", (t) => {
250
+ const f = herdrFixture(t);
251
+ const absentHome = join(f.root, "empty-home");
252
+ mkdirSync(absentHome);
253
+ assert.ok(!extensionPaths(f.launch([], { HOME: absentHome, USERPROFILE: absentHome })).includes(realpathSync(f.bridge)));
254
+ const alias = join(f.root, "bridge-alias.ts");
255
+ symlinkSync(f.bridge, alias);
256
+ const args = f.launch(["-e", alias]);
257
+ assert.ok(extensionPaths(args).includes(alias));
258
+ assert.ok(extensionPaths(args).includes(realpathSync(f.bridge)));
259
+ const disabled = f.launch(["--no-extensions", "-e", alias]);
260
+ assert.ok(extensionPaths(disabled).includes(alias));
261
+ assert.ok(!extensionPaths(disabled).includes(realpathSync(f.bridge)));
262
+ });
263
+
264
+ test("Pi real resource loader deduplicates the bridge across discovery, explicit files and package directories", async (t) => {
265
+ const { DefaultResourceLoader, SettingsManager, createEventBus } = await import("@earendil-works/pi-coding-agent");
266
+ for (const scenario of ["discovered", "explicit", "directory"] as const) {
267
+ const f = fixture(t);
268
+ const agentDir = join(f.home, ".pi", "agent");
269
+ const bridge = join(agentDir, "extensions", "herdr-agent-state.ts");
270
+ mkdirSync(dirname(bridge), { recursive: true });
271
+ writeFileSync(bridge, "export default function (pi) { pi.events.emit('mock:bridge-loaded', {}); }\n");
272
+ const canonical = realpathSync(bridge);
273
+ const alias = join(f.root, "alias.ts");
274
+ symlinkSync(bridge, alias);
275
+ writeFileSync(join(agentDir, "package.json"), JSON.stringify({ pi: { extensions: ["./extensions/herdr-agent-state.ts"] } }));
276
+ const eventBus = createEventBus();
277
+ let factoryCalls = 0;
278
+ eventBus.on("mock:bridge-loaded", () => { factoryCalls++; });
279
+ const additionalExtensionPaths = scenario === "explicit" ? [alias, canonical, canonical]
280
+ : scenario === "directory" ? [agentDir, canonical] : [canonical];
281
+ const loader = new DefaultResourceLoader({ cwd: f.root, agentDir, eventBus,
282
+ settingsManager: SettingsManager.inMemory({ extensions: ["-builtin:mcp", "-builtin:llama.cpp", "-builtin:codemode", "-builtin:tool-search"] }),
283
+ additionalExtensionPaths, noExtensions: scenario !== "discovered", noSkills: true,
284
+ noPromptTemplates: true, noThemes: true, noContextFiles: true,
285
+ });
286
+ await loader.reload();
287
+ const loaded = loader.getExtensions();
288
+ assert.deepEqual(loaded.errors, [], scenario);
289
+ assert.equal(loaded.extensions.length, 1, scenario);
290
+ assert.equal(factoryCalls, 1, scenario);
291
+ }
292
+ });
293
+
109
294
  // Test/development-only stub for the setup subcommand's gentle-ai binary:
110
295
  // records its argv and the environment gentle-shell sets around it, instead
111
296
  // of running the real package-local gentle-ai (whose supply-chain integrity
@@ -276,7 +461,7 @@ function writeGentleAiScriptDeclaringGentlePi(path: string, extraSources: string
276
461
  // the invocation), this models what a real `pi remove` does to settings.json
277
462
  // closely enough to prove a later plain launch sees the declaration gone and
278
463
  // resumes the launcher's own injection.
279
- function writePiScriptEditingSettingsOnRemove(path: string, version = "0.85.1") {
464
+ function writePiScriptEditingSettingsOnRemove(path: string, version = "0.99.1") {
280
465
  writeFileSync(
281
466
  path,
282
467
  [
@@ -449,12 +634,6 @@ test("forwarded args reach pi after the injected extension flags, in order", (t)
449
634
  assert.deepEqual(payload.args, [
450
635
  "-e",
451
636
  packageRoot,
452
- "--theme",
453
- join(packageRoot, "themes"),
454
- "--skill",
455
- join(packageRoot, "skills"),
456
- "--prompt-template",
457
- join(packageRoot, "prompts"),
458
637
  "--mode",
459
638
  "rpc",
460
639
  "-p",
@@ -463,6 +642,34 @@ test("forwarded args reach pi after the injected extension flags, in order", (t)
463
642
  assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
464
643
  });
465
644
 
645
+ test("an isolated launch keeps its own agent home and carries the user's original Pi home, even when nested", (t) => {
646
+ const f = fixture(t);
647
+ const base = { ...f.env };
648
+ delete base.PI_CODING_AGENT_DIR;
649
+ delete base.GENTLE_SHELL_USER_PI_HOME;
650
+ const launch = (overrides: NodeJS.ProcessEnv, args: string[] = []) => {
651
+ const result = run({ ...base, ...overrides }, [...args, "--mode", "rpc"]);
652
+ assert.equal(result.status, 0, result.stderr);
653
+ return JSON.parse(result.stdout);
654
+ };
655
+ const conventional = launch({});
656
+ assert.equal(conventional.PI_CODING_AGENT_DIR, f.gentleShellHome);
657
+ assert.equal(conventional.GENTLE_SHELL_USER_PI_HOME, join(f.home, ".pi", "agent"));
658
+ const customHome = join(f.root, "custom-pi");
659
+ const custom = launch({ PI_CODING_AGENT_DIR: customHome });
660
+ assert.equal(custom.PI_CODING_AGENT_DIR, f.gentleShellHome);
661
+ assert.equal(custom.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
662
+ assert.equal(custom.GENTLE_SHELL_USER_PI_HOME, customHome);
663
+ // A gentle-shell started from inside a Gentle Shell session inherits both variables.
664
+ const nested = launch({ PI_CODING_AGENT_DIR: custom.PI_CODING_AGENT_DIR, GENTLE_SHELL_USER_PI_HOME: custom.GENTLE_SHELL_USER_PI_HOME });
665
+ assert.equal(nested.PI_CODING_AGENT_DIR, f.gentleShellHome);
666
+ assert.equal(nested.GENTLE_SHELL_USER_PI_HOME, customHome);
667
+ const linked = launch({ PI_CODING_AGENT_DIR: customHome }, ["--link"]);
668
+ assert.equal(linked.PI_CODING_AGENT_DIR, customHome);
669
+ assert.equal(linked.GENTLE_SHELL_USER_PI_HOME, customHome);
670
+ assert.equal(existsSync(join(customHome, "sessions")), false);
671
+ });
672
+
466
673
  test("--link skips injection and leaves settings.json byte-identical when it already declares gentle-pi", (t) => {
467
674
  const f = fixture(t);
468
675
  const piAgentDir = join(f.root, "pi-agent");
@@ -511,12 +718,6 @@ test("an isolated home whose settings.json does not declare gentle-pi keeps the
511
718
  assert.deepEqual(payload.args, [
512
719
  "-e",
513
720
  packageRoot,
514
- "--theme",
515
- join(packageRoot, "themes"),
516
- "--skill",
517
- join(packageRoot, "skills"),
518
- "--prompt-template",
519
- join(packageRoot, "prompts"),
520
721
  "--mode",
521
722
  "rpc",
522
723
  ]);
@@ -538,12 +739,6 @@ test("an isolated home whose settings.json declares a path-based gentle-pi takes
538
739
  "--no-extensions",
539
740
  "-e",
540
741
  packageRoot,
541
- "--theme",
542
- join(packageRoot, "themes"),
543
- "--skill",
544
- join(packageRoot, "skills"),
545
- "--prompt-template",
546
- join(packageRoot, "prompts"),
547
742
  "--mode",
548
743
  "rpc",
549
744
  ]);
@@ -573,7 +768,7 @@ test("a too-old pi exits 1 naming both versions", (t) => {
573
768
  const result = run(f.env, []);
574
769
  assert.equal(result.status, 1);
575
770
  assert.match(result.stderr, /0\.80\.0/);
576
- assert.match(result.stderr, /0\.85\.1/);
771
+ assert.match(result.stderr, /0\.99\.1/);
577
772
  });
578
773
 
579
774
  test("--version prints three lines", (t) => {
@@ -583,7 +778,7 @@ test("--version prints three lines", (t) => {
583
778
  const lines = result.stdout.trim().split("\n");
584
779
  assert.equal(lines.length, 3);
585
780
  assert.match(lines[0], /^gentle-shell /);
586
- assert.match(lines[1], /^pi 0\.85\.1$/);
781
+ assert.match(lines[1], /^pi 0\.99\.1$/);
587
782
  assert.match(lines[2], /^home isolated /);
588
783
  });
589
784
 
@@ -904,12 +1099,6 @@ test("a later normal launch injects the launcher's own package root once gentle-
904
1099
  assert.deepEqual(payload.args, [
905
1100
  "-e",
906
1101
  packageRoot,
907
- "--theme",
908
- join(packageRoot, "themes"),
909
- "--skill",
910
- join(packageRoot, "skills"),
911
- "--prompt-template",
912
- join(packageRoot, "prompts"),
913
1102
  "--mode",
914
1103
  "rpc",
915
1104
  ]);
@@ -920,7 +1109,7 @@ test("gentle-shell setup propagates a non-zero pi remove exit code with an actio
920
1109
  const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
921
1110
  writeGentleAiScriptDeclaringConflict(gentleAiScript);
922
1111
  const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
923
- writePiScript(failingPiScript, "0.85.1", 7);
1112
+ writePiScript(failingPiScript, "0.99.1", 7);
924
1113
  const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
925
1114
 
926
1115
  const result = run(env, ["setup"]);
@@ -941,7 +1130,7 @@ test("gentle-shell setup --home <dir> includes --home <dir> in the failing-remov
941
1130
  const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
942
1131
  writeGentleAiScriptDeclaringConflict(gentleAiScript);
943
1132
  const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
944
- writePiScript(failingPiScript, "0.85.1", 7);
1133
+ writePiScript(failingPiScript, "0.99.1", 7);
945
1134
  const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
946
1135
 
947
1136
  const result = run(env, ["--home", target, "setup"]);
@@ -965,7 +1154,7 @@ test("gentle-shell setup shell-quotes a --home path containing a space in the fa
965
1154
  const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
966
1155
  writeGentleAiScriptDeclaringConflict(gentleAiScript);
967
1156
  const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
968
- writePiScript(failingPiScript, "0.85.1", 7);
1157
+ writePiScript(failingPiScript, "0.99.1", 7);
969
1158
  const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
970
1159
 
971
1160
  const result = run(env, ["--home", target, "setup"]);
@@ -1493,7 +1682,8 @@ test("a --home equal to pi's own default agent home is never auto-provisioned, e
1493
1682
  const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1494
1683
  const counterPath = join(f.root, "gentle-ai-runs.log");
1495
1684
  writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1496
- const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1685
+ // Exercise the default Pi home, not an ambient PI_CODING_AGENT_DIR override.
1686
+ const env = enableAutoProvision({ ...f.env, PI_CODING_AGENT_DIR: undefined, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1497
1687
 
1498
1688
  const result = run(env, ["--home", defaultPiHome, "--mode", "rpc"]);
1499
1689
  assert.equal(result.status, 0, result.stderr);
@@ -1737,7 +1927,7 @@ test("a hung 'pi remove' during post-install cleanup is killed after the timeout
1737
1927
  [
1738
1928
  "#!/usr/bin/env node",
1739
1929
  "const args = process.argv.slice(2);",
1740
- 'if (args.includes("--version")) { console.log("0.85.1"); process.exit(0); }',
1930
+ 'if (args.includes("--version")) { console.log("0.99.1"); process.exit(0); }',
1741
1931
  "if (args[0] === 'remove') { await new Promise((resolve) => setTimeout(resolve, 60000)); process.exit(0); }",
1742
1932
  "console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
1743
1933
  "process.exit(0);",
@@ -1882,6 +2072,253 @@ test("automatic setup restores the home's own theme when gentle-ai overwrites it
1882
2072
  assert.equal(settings.theme, "dracula");
1883
2073
  });
1884
2074
 
2075
+ // --- builtin codemode exclusion ----------------------------------------------
2076
+ //
2077
+ // gentle-pi registers its own decorated codemode tool, so Pi's replaceable
2078
+ // builtin codemode always loses to it and Pi prints a startup warning. The
2079
+ // only per-builtin opt-out Pi supports is a `-builtin:codemode` entry in the
2080
+ // settings `extensions` array, which every normal launch ensures in a home
2081
+ // gentle-shell owns — never in --link, a foreign --home, pi's own default
2082
+ // agent home, a pi subcommand, or `setup --dry-run`.
2083
+
2084
+ const CODEMODE_EXCLUSION = "-builtin:codemode";
2085
+
2086
+ function settingsText(dir: string): string {
2087
+ return readFileSync(join(dir, "settings.json"), "utf8");
2088
+ }
2089
+
2090
+ test("a fresh isolated home excludes Pi's builtin codemode, and a second launch changes nothing", (t) => {
2091
+ const f = fixture(t);
2092
+ const first = run(f.env, ["--mode", "rpc"]);
2093
+ assert.equal(first.status, 0, first.stderr);
2094
+ const settings = JSON.parse(settingsText(f.gentleShellHome));
2095
+ assert.deepEqual(settings.extensions, [CODEMODE_EXCLUSION]);
2096
+ assert.equal(settings.tuiMode, "fullscreen");
2097
+ assert.equal(settings.theme, "Gentleman-Cute");
2098
+
2099
+ const before = settingsText(f.gentleShellHome);
2100
+ const second = run(f.env, ["--mode", "rpc"]);
2101
+ assert.equal(second.status, 0, second.stderr);
2102
+ assert.equal(settingsText(f.gentleShellHome), before);
2103
+ assert.doesNotMatch(second.stderr, /builtin codemode/);
2104
+ });
2105
+
2106
+ test("an existing isolated home gains the exclusion after its own extension entries, keeping every other key and its formatting", (t) => {
2107
+ for (const [indent, newline] of [[4, "\n"], [undefined, ""]] as const) {
2108
+ const f = fixture(t);
2109
+ mkdirSync(f.gentleShellHome, { recursive: true });
2110
+ const original = { tuiMode: "fullscreen", theme: "rose", extensions: ["./ext/a.ts", "!./ext/b.ts"], packages: ["npm:pi-btw"] };
2111
+ writeFileSync(join(f.gentleShellHome, "settings.json"), `${JSON.stringify(original, null, indent)}${newline}`);
2112
+
2113
+ const result = run(f.env, ["--mode", "rpc"]);
2114
+ assert.equal(result.status, 0, result.stderr);
2115
+ const expected = { ...original, extensions: [...original.extensions, CODEMODE_EXCLUSION] };
2116
+ assert.equal(settingsText(f.gentleShellHome), `${JSON.stringify(expected, null, indent)}${newline}`);
2117
+ assert.match(result.stderr, /builtin codemode/);
2118
+ }
2119
+ });
2120
+
2121
+ test("an explicit user entry for builtin:codemode is left byte-identical", (t) => {
2122
+ for (const entry of ["+builtin:codemode", "!builtin:codemode", "builtin:codemode", CODEMODE_EXCLUSION]) {
2123
+ const f = fixture(t);
2124
+ mkdirSync(f.gentleShellHome, { recursive: true });
2125
+ const text = JSON.stringify({ tuiMode: "fullscreen", extensions: ["./ext/a.ts", entry] });
2126
+ writeFileSync(join(f.gentleShellHome, "settings.json"), text);
2127
+
2128
+ const result = run(f.env, ["--mode", "rpc"]);
2129
+ assert.equal(result.status, 0, result.stderr);
2130
+ assert.equal(settingsText(f.gentleShellHome), text, entry);
2131
+ }
2132
+ });
2133
+
2134
+ test("a malformed or unexpected settings.json is never overwritten and pi still starts", (t) => {
2135
+ for (const text of ["{ not json", "[]", '{"extensions":"oops"}']) {
2136
+ const f = fixture(t);
2137
+ mkdirSync(f.gentleShellHome, { recursive: true });
2138
+ writeFileSync(join(f.gentleShellHome, "settings.json"), text);
2139
+
2140
+ const result = run(f.env, ["--mode", "rpc"]);
2141
+ assert.equal(result.status, 0, result.stderr);
2142
+ assert.deepEqual(JSON.parse(result.stdout).args.slice(-2), ["--mode", "rpc"]);
2143
+ assert.equal(settingsText(f.gentleShellHome), text);
2144
+ }
2145
+ });
2146
+
2147
+ test("an owned home without settings.json is not given one", (t) => {
2148
+ const f = fixture(t);
2149
+ mkdirSync(f.gentleShellHome, { recursive: true });
2150
+ const result = run(f.env, ["--mode", "rpc"]);
2151
+ assert.equal(result.status, 0, result.stderr);
2152
+ assert.equal(existsSync(join(f.gentleShellHome, "settings.json")), false);
2153
+ });
2154
+
2155
+ test("--link never gains the exclusion", (t) => {
2156
+ const f = fixture(t);
2157
+ const piAgentDir = join(f.root, "pi-agent-link");
2158
+ mkdirSync(piAgentDir, { recursive: true });
2159
+ const text = JSON.stringify({ tuiMode: "fullscreen" });
2160
+ writeFileSync(join(piAgentDir, "settings.json"), text);
2161
+
2162
+ const result = run({ ...f.env, PI_CODING_AGENT_DIR: piAgentDir }, ["--link", "--mode", "rpc"]);
2163
+ assert.equal(result.status, 0, result.stderr);
2164
+ assert.equal(settingsText(piAgentDir), text);
2165
+ });
2166
+
2167
+ test("a foreign --home and pi's own default agent home are never edited, while a bootstrapped --home is", (t) => {
2168
+ const f = fixture(t);
2169
+ const foreign = join(f.root, "existing-pi-home");
2170
+ const defaultPiHome = join(f.home, ".pi", "agent");
2171
+ const text = JSON.stringify({ tuiMode: "fullscreen" });
2172
+ for (const dir of [foreign, defaultPiHome]) {
2173
+ mkdirSync(dir, { recursive: true });
2174
+ writeFileSync(join(dir, "settings.json"), text);
2175
+ const result = run({ ...f.env, PI_CODING_AGENT_DIR: undefined }, ["--home", dir, "--mode", "rpc"]);
2176
+ assert.equal(result.status, 0, result.stderr);
2177
+ assert.equal(settingsText(dir), text, dir);
2178
+ }
2179
+
2180
+ const bootstrapped = join(f.root, "brand-new-home");
2181
+ const result = run(f.env, ["--home", bootstrapped, "--mode", "rpc"]);
2182
+ assert.equal(result.status, 0, result.stderr);
2183
+ assert.deepEqual(JSON.parse(settingsText(bootstrapped)).extensions, [CODEMODE_EXCLUSION]);
2184
+ });
2185
+
2186
+ test("a pi subcommand and setup --dry-run never edit the home's settings", (t) => {
2187
+ const f = fixture(t);
2188
+ mkdirSync(f.gentleShellHome, { recursive: true });
2189
+ const text = JSON.stringify({ tuiMode: "fullscreen" });
2190
+ writeFileSync(join(f.gentleShellHome, "settings.json"), text);
2191
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
2192
+ writeGentleAiScript(gentleAiScript);
2193
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
2194
+
2195
+ for (const args of [["list"], ["setup", "--dry-run"]]) {
2196
+ const result = run(env, args);
2197
+ assert.equal(result.status, 0, result.stderr);
2198
+ assert.equal(settingsText(f.gentleShellHome), text, args.join(" "));
2199
+ }
2200
+ });
2201
+
2202
+ test("the exclusion is ensured after automatic setup rewrites settings.json", (t) => {
2203
+ const f = fixture(t);
2204
+ const gentleAiScript = join(f.root, "fake-gentle-ai-drops-extensions.mjs");
2205
+ writeFileSync(
2206
+ gentleAiScript,
2207
+ [
2208
+ "#!/usr/bin/env node",
2209
+ "import { readFileSync, writeFileSync } from 'node:fs';",
2210
+ "import { join } from 'node:path';",
2211
+ "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
2212
+ "const { extensions, ...settings } = JSON.parse(readFileSync(settingsPath, 'utf8'));",
2213
+ "writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\\n');",
2214
+ "process.exit(0);",
2215
+ "",
2216
+ ].join("\n"),
2217
+ );
2218
+ chmodSync(gentleAiScript, 0o755);
2219
+ mkdirSync(f.gentleShellHome, { recursive: true });
2220
+ writeFileSync(join(f.gentleShellHome, "settings.json"), `${JSON.stringify({ tuiMode: "fullscreen", extensions: [CODEMODE_EXCLUSION] }, null, 2)}\n`);
2221
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
2222
+
2223
+ const result = run(env, ["--mode", "rpc"]);
2224
+ assert.equal(result.status, 0, result.stderr);
2225
+ assert.deepEqual(JSON.parse(settingsText(f.gentleShellHome)).extensions, [CODEMODE_EXCLUSION]);
2226
+ });
2227
+
2228
+ // --- atomic writes keep symlinked config files intact ------------------------
2229
+ //
2230
+ // Dotfile managers (Nix home-manager, stow, ...) ship settings.json and
2231
+ // config.json as symlinks. Every atomic launcher write must rename onto the
2232
+ // link's real target, so the link survives and the target gets the new
2233
+ // bytes with its own permission bits.
2234
+
2235
+ // File symlinks need extra privileges on Windows; skip where unavailable.
2236
+ const fileSymlinkSkip = (() => {
2237
+ const dir = mkdtempSync(join(tmpdir(), "gentle-shell-symlink-probe-"));
2238
+ try {
2239
+ writeFileSync(join(dir, "target"), "");
2240
+ symlinkSync(join(dir, "target"), join(dir, "link"), "file");
2241
+ return false;
2242
+ } catch {
2243
+ return "file symlinks are unavailable here";
2244
+ } finally {
2245
+ rmSync(dir, { recursive: true, force: true });
2246
+ }
2247
+ })();
2248
+
2249
+ function symlinkedFile(linkPath: string, targetPath: string, text: string, mode: number) {
2250
+ mkdirSync(dirname(linkPath), { recursive: true });
2251
+ mkdirSync(dirname(targetPath), { recursive: true });
2252
+ writeFileSync(targetPath, text);
2253
+ chmodSync(targetPath, mode);
2254
+ symlinkSync(targetPath, linkPath, "file");
2255
+ }
2256
+
2257
+ function assertStillLinkedTo(linkPath: string, targetPath: string, mode: number) {
2258
+ assert.ok(lstatSync(linkPath).isSymbolicLink(), `${linkPath} is still a symlink`);
2259
+ assert.equal(realpathSync(linkPath), realpathSync(targetPath));
2260
+ if (process.platform !== "win32") assert.equal(statSync(targetPath).mode & 0o777, mode);
2261
+ for (const dir of [dirname(linkPath), dirname(targetPath)]) {
2262
+ assert.deepEqual(readdirSync(dir).filter((name) => name.endsWith(".tmp")), [], `no temp file left in ${dir}`);
2263
+ }
2264
+ }
2265
+
2266
+ test("the builtin codemode exclusion writes through a symlinked settings.json", { skip: fileSymlinkSkip }, (t) => {
2267
+ const f = fixture(t);
2268
+ const linkPath = join(f.gentleShellHome, "settings.json");
2269
+ const targetPath = join(f.root, "dotfiles", "settings.json");
2270
+ symlinkedFile(linkPath, targetPath, JSON.stringify({ tuiMode: "fullscreen", theme: "rose" }), 0o640);
2271
+
2272
+ const result = run(f.env, ["--mode", "rpc"]);
2273
+ assert.equal(result.status, 0, result.stderr);
2274
+ assertStillLinkedTo(linkPath, targetPath, 0o640);
2275
+ assert.deepEqual(JSON.parse(readFileSync(targetPath, "utf8")), { tuiMode: "fullscreen", theme: "rose", extensions: [CODEMODE_EXCLUSION] });
2276
+ });
2277
+
2278
+ test("the automatic setup theme restore writes through a symlinked settings.json", { skip: fileSymlinkSkip }, (t) => {
2279
+ const f = fixture(t);
2280
+ const target = join(f.root, "linked-themed-home");
2281
+ const linkPath = join(target, "settings.json");
2282
+ const targetPath = join(f.root, "dotfiles", "themed-settings.json");
2283
+ symlinkedFile(linkPath, targetPath, JSON.stringify({ tuiMode: "fullscreen", theme: "dracula" }), 0o600);
2284
+ writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() }));
2285
+
2286
+ const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs");
2287
+ writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa");
2288
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
2289
+
2290
+ const result = run(env, ["--home", target, "--mode", "rpc"]);
2291
+ assert.equal(result.status, 0, result.stderr);
2292
+ assertStillLinkedTo(linkPath, targetPath, 0o600);
2293
+ assert.equal(JSON.parse(readFileSync(targetPath, "utf8")).theme, "dracula");
2294
+ });
2295
+
2296
+ test("'home' persistence writes through a symlinked config.json", { skip: fileSymlinkSkip }, (t) => {
2297
+ const f = fixture(t);
2298
+ const linkPath = join(f.root, "config", "config.json");
2299
+ const targetPath = join(f.root, "dotfiles", "config.json");
2300
+ symlinkedFile(linkPath, targetPath, "{}\n", 0o600);
2301
+
2302
+ const result = run({ ...f.env, GENTLE_SHELL_CONFIG: linkPath }, ["home", "link"]);
2303
+ assert.equal(result.status, 0, result.stderr);
2304
+ assertStillLinkedTo(linkPath, targetPath, 0o600);
2305
+ assert.deepEqual(JSON.parse(readFileSync(targetPath, "utf8")), { home: "link" });
2306
+ });
2307
+
2308
+ test("a dangling config.json symlink is replaced in place, never followed to create its target", { skip: fileSymlinkSkip }, (t) => {
2309
+ const f = fixture(t);
2310
+ const linkPath = join(f.root, "config", "config.json");
2311
+ const missingTarget = join(f.root, "missing", "config.json");
2312
+ mkdirSync(dirname(linkPath), { recursive: true });
2313
+ symlinkSync(missingTarget, linkPath, "file");
2314
+
2315
+ const result = run({ ...f.env, GENTLE_SHELL_CONFIG: linkPath }, ["home", "link"]);
2316
+ assert.equal(result.status, 0, result.stderr);
2317
+ assert.equal(lstatSync(linkPath).isSymbolicLink(), false);
2318
+ assert.deepEqual(JSON.parse(readFileSync(linkPath, "utf8")), { home: "link" });
2319
+ assert.equal(existsSync(dirname(missingTarget)), false);
2320
+ });
2321
+
1885
2322
  // --- --package-root silently ignored in a declared non-link home ----------
1886
2323
 
1887
2324
  test("--package-root warns and is ignored when an isolated home already declares gentle-pi", (t) => {
@@ -1963,12 +2400,6 @@ test("--link takes over a path-declared conflicting gentle-pi: --no-extensions,
1963
2400
  join(piAgentDir, "npm", "node_modules", "some-other"),
1964
2401
  "-e",
1965
2402
  packageRoot,
1966
- "--theme",
1967
- join(packageRoot, "themes"),
1968
- "--skill",
1969
- join(packageRoot, "skills"),
1970
- "--prompt-template",
1971
- join(packageRoot, "prompts"),
1972
2403
  "--mode",
1973
2404
  "rpc",
1974
2405
  ]);
@@ -2019,7 +2450,7 @@ test("--link does not take over a git-sourced other package: it is skipped with
2019
2450
  assert.match(result.stderr, /skipping git-sourced package/);
2020
2451
 
2021
2452
  const payload = JSON.parse(result.stdout);
2022
- assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot, "--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")]);
2453
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]);
2023
2454
  });
2024
2455
 
2025
2456
  test("--package-root forces a takeover even when settings already declare a matching npm:gentle-pi", (t) => {
@@ -2039,17 +2470,7 @@ test("--package-root forces a takeover even when settings already declare a matc
2039
2470
  assert.match(result.stderr, /taking over gentle-pi from npm:gentle-pi/);
2040
2471
 
2041
2472
  const payload = JSON.parse(result.stdout);
2042
- assert.deepEqual(payload.args, [
2043
- "--no-extensions",
2044
- "-e",
2045
- forcedRoot,
2046
- "--theme",
2047
- join(forcedRoot, "themes"),
2048
- "--skill",
2049
- join(forcedRoot, "skills"),
2050
- "--prompt-template",
2051
- join(forcedRoot, "prompts"),
2052
- ]);
2473
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", forcedRoot]);
2053
2474
  assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
2054
2475
  });
2055
2476
 
@@ -2079,12 +2500,6 @@ test("--package-root forces a takeover even with no gentle-pi declaration at all
2079
2500
  join(piAgentDir, "npm", "node_modules", "some-other"),
2080
2501
  "-e",
2081
2502
  forcedRoot,
2082
- "--theme",
2083
- join(forcedRoot, "themes"),
2084
- "--skill",
2085
- join(forcedRoot, "skills"),
2086
- "--prompt-template",
2087
- join(forcedRoot, "prompts"),
2088
2503
  ]);
2089
2504
  assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
2090
2505
  });
@@ -2117,12 +2532,6 @@ test("--link take-over with --package-root injects exactly one -e when a setting
2117
2532
  "--no-extensions",
2118
2533
  "-e",
2119
2534
  realOtherDir,
2120
- "--theme",
2121
- join(realOtherDir, "themes"),
2122
- "--skill",
2123
- join(realOtherDir, "skills"),
2124
- "--prompt-template",
2125
- join(realOtherDir, "prompts"),
2126
2535
  ]);
2127
2536
  });
2128
2537
 
@@ -2189,12 +2598,6 @@ test("--link take-over re-injects loose extension files, one -e per discovered f
2189
2598
  join(resolvedLooseProjectExtensions, "project-ext.mjs"),
2190
2599
  "-e",
2191
2600
  packageRoot,
2192
- "--theme",
2193
- join(packageRoot, "themes"),
2194
- "--skill",
2195
- join(packageRoot, "skills"),
2196
- "--prompt-template",
2197
- join(packageRoot, "prompts"),
2198
2601
  "--mode",
2199
2602
  "rpc",
2200
2603
  ]);
@@ -2234,12 +2637,6 @@ test("--link take-over injects a root-level index.ts as its own loose file entry
2234
2637
  join(looseAgentExtensions, "index.ts"),
2235
2638
  "-e",
2236
2639
  packageRoot,
2237
- "--theme",
2238
- join(packageRoot, "themes"),
2239
- "--skill",
2240
- join(packageRoot, "skills"),
2241
- "--prompt-template",
2242
- join(packageRoot, "prompts"),
2243
2640
  ]);
2244
2641
  });
2245
2642
 
@@ -2270,12 +2667,6 @@ test("--link take-over injects a subdirectory's own index.ts entry point even wh
2270
2667
  join(subDir, "index.ts"),
2271
2668
  "-e",
2272
2669
  packageRoot,
2273
- "--theme",
2274
- join(packageRoot, "themes"),
2275
- "--skill",
2276
- join(packageRoot, "skills"),
2277
- "--prompt-template",
2278
- join(packageRoot, "prompts"),
2279
2670
  ]);
2280
2671
  });
2281
2672
 
@@ -2295,7 +2686,7 @@ test("--link take-over omits -e flags for loose extension dirs that do not exist
2295
2686
  assert.equal(result.status, 0, result.stderr);
2296
2687
 
2297
2688
  const payload = JSON.parse(result.stdout);
2298
- assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot, "--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")]);
2689
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]);
2299
2690
  });
2300
2691
 
2301
2692
  // --- R3-001/R4-takeover-injects-unverified-package-dirs -------------------
@@ -2342,12 +2733,6 @@ test("--link take-over skips a declared package directory that is not installed,
2342
2733
  installedOtherDir,
2343
2734
  "-e",
2344
2735
  packageRoot,
2345
- "--theme",
2346
- join(packageRoot, "themes"),
2347
- "--skill",
2348
- join(packageRoot, "skills"),
2349
- "--prompt-template",
2350
- join(packageRoot, "prompts"),
2351
2736
  "--mode",
2352
2737
  "rpc",
2353
2738
  ]);
@@ -2380,12 +2765,6 @@ test("--link take-over injects the launcher's own package root once when --packa
2380
2765
  "--no-extensions",
2381
2766
  "-e",
2382
2767
  forcedRoot,
2383
- "--theme",
2384
- join(forcedRoot, "themes"),
2385
- "--skill",
2386
- join(forcedRoot, "skills"),
2387
- "--prompt-template",
2388
- join(forcedRoot, "prompts"),
2389
2768
  ]);
2390
2769
  });
2391
2770
 
@@ -2402,12 +2781,6 @@ test("--isolated --package-root produces the plain injection (no --no-extensions
2402
2781
  assert.deepEqual(payload.args, [
2403
2782
  "-e",
2404
2783
  forcedRoot,
2405
- "--theme",
2406
- join(forcedRoot, "themes"),
2407
- "--skill",
2408
- join(forcedRoot, "skills"),
2409
- "--prompt-template",
2410
- join(forcedRoot, "prompts"),
2411
2784
  "--mode",
2412
2785
  "rpc",
2413
2786
  ]);
@@ -2453,7 +2826,7 @@ test("--link take-over warns once when a loose extensions directory cannot be re
2453
2826
  assert.match(result.stderr, /extensions/);
2454
2827
 
2455
2828
  const payload = JSON.parse(result.stdout);
2456
- assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot, "--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")]);
2829
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]);
2457
2830
  });
2458
2831
 
2459
2832
  // --- R3-004: loose-extensions manifest branch coverage ----------------------
@@ -2485,12 +2858,6 @@ test("--link take-over falls through to per-file discovery when a loose dir's pa
2485
2858
  join(looseAgentExtensions, "a.ts"),
2486
2859
  "-e",
2487
2860
  packageRoot,
2488
- "--theme",
2489
- join(packageRoot, "themes"),
2490
- "--skill",
2491
- join(packageRoot, "skills"),
2492
- "--prompt-template",
2493
- join(packageRoot, "prompts"),
2494
2861
  ]);
2495
2862
  });
2496
2863
 
@@ -2521,12 +2888,6 @@ test("--link take-over treats a malformed package.json as no manifest and falls
2521
2888
  join(looseAgentExtensions, "a.ts"),
2522
2889
  "-e",
2523
2890
  packageRoot,
2524
- "--theme",
2525
- join(packageRoot, "themes"),
2526
- "--skill",
2527
- join(packageRoot, "skills"),
2528
- "--prompt-template",
2529
- join(packageRoot, "prompts"),
2530
2891
  ]);
2531
2892
  });
2532
2893
 
@@ -2567,5 +2928,181 @@ test("--link take-over treats a file named `extensions` as not a loose extension
2567
2928
  assert.equal(result.status, 0, result.stderr);
2568
2929
 
2569
2930
  const payload = JSON.parse(result.stdout);
2570
- assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot, "--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")]);
2931
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]);
2932
+ });
2933
+
2934
+ // --- resume-hint handoff (lib/gentle-shell-resume-hint.ts) ----------------------
2935
+
2936
+ // A stand-in pi that writes a resume handoff like extensions/resume-hint.ts
2937
+ // does, prints pi's own exit hint, and exits with the given code.
2938
+ function writeHandoffPiScript(path: string, exitCode = 0) {
2939
+ writeFileSync(
2940
+ path,
2941
+ [
2942
+ "#!/usr/bin/env node",
2943
+ "const { writeFileSync } = require('node:fs');",
2944
+ "const args = process.argv.slice(2);",
2945
+ "if (args.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
2946
+ "const handoff = process.env.GENTLE_SHELL_RESUME_HANDOFF;",
2947
+ "if (handoff) writeFileSync(handoff, JSON.stringify({ sessionId: 'abc' }));",
2948
+ "if (process.env.PI_STUB_PRINT_ENV) console.log(JSON.stringify({ args, handoff }));",
2949
+ "process.stdout.write('To resume this session: pi --session abc\\n');",
2950
+ `process.exit(${exitCode});`,
2951
+ "",
2952
+ ].join("\n"),
2953
+ );
2954
+ chmodSync(path, 0o755);
2955
+ }
2956
+
2957
+ test("interactive launch hands pi a private resume handoff and cleans it up", (t) => {
2958
+ const f = fixture(t);
2959
+ const piScript = join(f.root, "handoff-pi.cjs");
2960
+ writeHandoffPiScript(piScript);
2961
+ const result = run({ ...f.env, GENTLE_SHELL_PI: piScript, PI_STUB_PRINT_ENV: "1" }, []);
2962
+ assert.equal(result.status, 0, result.stderr);
2963
+ const lines = result.stdout.trim().split("\n");
2964
+ const { handoff } = JSON.parse(lines[0]);
2965
+ assert.equal(typeof handoff, "string");
2966
+ assert.match(handoff, /gentle-shell-resume-/);
2967
+ assert.equal(existsSync(dirname(handoff)), false, "handoff dir must be removed after pi exits");
2968
+ // Not a TTY: like pi's own hint, the gentle-shell line is not printed.
2969
+ assert.deepEqual(lines.slice(1), ["To resume this session: pi --session abc"]);
2970
+ });
2971
+
2972
+ test("pi subcommands get no resume handoff", (t) => {
2973
+ const f = fixture(t);
2974
+ const piScript = join(f.root, "handoff-pi.cjs");
2975
+ writeHandoffPiScript(piScript);
2976
+ const result = run({ ...f.env, GENTLE_SHELL_PI: piScript, PI_STUB_PRINT_ENV: "1" }, ["list"]);
2977
+ assert.equal(result.status, 0, result.stderr);
2978
+ assert.equal(JSON.parse(result.stdout.trim().split("\n")[0]).handoff, undefined);
2979
+ });
2980
+
2981
+ // The hint is only printed to a real TTY, so drive the launcher through a
2982
+ // pseudo-terminal. Python's pty module is the portable POSIX way to get one
2983
+ // without a native dependency; skipped where it is unavailable.
2984
+ const hasPythonPty = process.platform !== "win32" && spawnSync("python3", ["-c", "import pty"], { stdio: "ignore" }).status === 0;
2985
+ const PTY_RUNNER = [
2986
+ "import fcntl, os, pty, struct, subprocess, sys, termios",
2987
+ "m, s = pty.openpty()",
2988
+ "fcntl.ioctl(s, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 120, 0, 0))",
2989
+ "p = subprocess.Popen(sys.argv[1:], stdin=s, stdout=s, stderr=s)",
2990
+ "os.close(s)",
2991
+ "out = b''",
2992
+ // PTY_SIGNAL_AFTER: once this text appears, send PTY_SIGNAL to the launcher.
2993
+ "after = os.environ.get('PTY_SIGNAL_AFTER', '').encode()",
2994
+ "while True:",
2995
+ " try: d = os.read(m, 4096)",
2996
+ " except OSError: break",
2997
+ " if not d: break",
2998
+ " out += d",
2999
+ " if after and after in out:",
3000
+ " after = b''",
3001
+ " os.kill(p.pid, int(os.environ['PTY_SIGNAL']))",
3002
+ "sys.stdout.write(out.decode())",
3003
+ "sys.exit(p.wait())",
3004
+ ].join("\n");
3005
+
3006
+ function runInPty(env: NodeJS.ProcessEnv, args: string[], expectedStatus = 0): string {
3007
+ // Pin the color environment so the launcher's hasColors() check does not
3008
+ // depend on the machine running the tests; a test can still override it.
3009
+ const { NO_COLOR, FORCE_COLOR, NODE_DISABLE_COLORS, ...rest } = env;
3010
+ const colorEnv = { ...rest, TERM: "xterm-256color", ...(env.PTY_NO_COLOR ? { NO_COLOR: "1" } : {}) };
3011
+ // Bounded so a stand-in pi that never exits fails the test instead of hanging CI.
3012
+ const result = spawnSync("python3", ["-c", PTY_RUNNER, process.execPath, binPath, ...args], { encoding: "utf8", env: colorEnv, timeout: 30_000 });
3013
+ assert.equal(result.error, undefined, String(result.error));
3014
+ assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
3015
+ return result.stdout;
3016
+ }
3017
+
3018
+ const PI_HINT = "To resume this session: pi --session abc\r\n";
3019
+ const GENTLE_HINT = "\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session abc\r\n";
3020
+
3021
+ test("on a TTY the launcher appends a gentle-shell resume line below pi's hint", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
3022
+ const f = fixture(t);
3023
+ const piScript = join(f.root, "tty-pi.cjs");
3024
+ writeHandoffPiScript(piScript);
3025
+ const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"]);
3026
+ assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out));
3027
+ });
3028
+
3029
+ test("on a TTY the gentle-shell line still follows a non-zero pi exit, keeping the code", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
3030
+ const f = fixture(t);
3031
+ const piScript = join(f.root, "tty-pi.cjs");
3032
+ writeHandoffPiScript(piScript, 3);
3033
+ const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"], 3);
3034
+ assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out));
3035
+ });
3036
+
3037
+ // A stand-in pi that writes the handoff and then waits: it quits with its
3038
+ // own hint on SIGHUP/SIGTERM, but survives SIGINT (like an interrupted turn)
3039
+ // and quits only on the next line of input.
3040
+ function writeWaitingPiScript(path: string) {
3041
+ writeFileSync(
3042
+ path,
3043
+ [
3044
+ "#!/usr/bin/env node",
3045
+ "const { writeFileSync } = require('node:fs');",
3046
+ "if (process.argv.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
3047
+ "writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc' }));",
3048
+ "const quit = () => { process.stdout.write('To resume this session: pi --session abc\\n'); process.exit(0); };",
3049
+ "process.on('SIGHUP', quit);",
3050
+ "process.on('SIGTERM', quit);",
3051
+ "process.on('SIGINT', () => { process.stdout.write('interrupted\\n'); setTimeout(quit, 50); });",
3052
+ "process.stdout.write('ready\\n');",
3053
+ "setInterval(() => {}, 1000);",
3054
+ "",
3055
+ ].join("\n"),
3056
+ );
3057
+ chmodSync(path, 0o755);
3058
+ }
3059
+
3060
+ test("on a TTY the launcher prints nothing extra after the terminal hangs up", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
3061
+ const f = fixture(t);
3062
+ const piScript = join(f.root, "tty-pi.cjs");
3063
+ writeWaitingPiScript(piScript);
3064
+ const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_SIGNAL_AFTER: "ready", PTY_SIGNAL: "1" }, ["--link"]);
3065
+ // pi quit cleanly with its own hint; only the gentle-shell line is withheld.
3066
+ assert.ok(out.endsWith(PI_HINT), JSON.stringify(out));
3067
+ assert.equal(out.includes("gentle-shell --"), false, JSON.stringify(out));
3068
+ });
3069
+
3070
+ test("on a TTY a forwarded SIGINT that pi survives does not silence the gentle-shell line", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
3071
+ const f = fixture(t);
3072
+ const piScript = join(f.root, "tty-pi.cjs");
3073
+ writeWaitingPiScript(piScript);
3074
+ const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_SIGNAL_AFTER: "ready", PTY_SIGNAL: "2" }, ["--link"]);
3075
+ assert.ok(out.includes("interrupted"), JSON.stringify(out));
3076
+ assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out));
3077
+ });
3078
+
3079
+ test("on a TTY a cross-project session resumes by its session file", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
3080
+ const f = fixture(t);
3081
+ const sessionFile = join(f.root, "other project", "session.jsonl");
3082
+ const piScript = join(f.root, "tty-pi.cjs");
3083
+ writeFileSync(
3084
+ piScript,
3085
+ [
3086
+ "#!/usr/bin/env node",
3087
+ "const { writeFileSync } = require('node:fs');",
3088
+ "if (process.argv.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
3089
+ `writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc', sessionFile: ${JSON.stringify(sessionFile)} }));`,
3090
+ "process.stdout.write('To resume this session: pi --session abc\\n');",
3091
+ "",
3092
+ ].join("\n"),
3093
+ );
3094
+ chmodSync(piScript, 0o755);
3095
+ const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"]);
3096
+ assert.ok(
3097
+ out.endsWith(`${PI_HINT}\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session '${sessionFile}'\r\n`),
3098
+ JSON.stringify(out),
3099
+ );
3100
+ });
3101
+
3102
+ test("on a TTY without colors the gentle-shell line has no ANSI styling", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
3103
+ const f = fixture(t);
3104
+ const piScript = join(f.root, "tty-pi.cjs");
3105
+ writeHandoffPiScript(piScript);
3106
+ const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_NO_COLOR: "1" }, ["--link"]);
3107
+ assert.ok(out.endsWith(`${PI_HINT}To resume in gentle-shell: gentle-shell --link --session abc\r\n`), JSON.stringify(out));
2571
3108
  });