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
@@ -32,6 +32,8 @@ import {
32
32
  quoteForCmdExe,
33
33
  recordProvisioned,
34
34
  resolveHome,
35
+ USER_PI_HOME_ENV,
36
+ userPiHome,
35
37
  resolvePiRuntime,
36
38
  restoreJsonField,
37
39
  settingsDeclareGentlePi,
@@ -620,20 +622,37 @@ test("checkPeerVersionPin passes for a matching pin", () => {
620
622
  assert.deepEqual(result, { ok: true, pinned: ">=0.85.1" });
621
623
  });
622
624
 
625
+ test("Pi 0.99.1 baseline and host peers follow the open development range policy", () => {
626
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
627
+ assert.equal(MIN_PI_VERSION, "0.99.1");
628
+ assert.equal(pkg.engines.node, ">=22.19.0");
629
+ for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
630
+ assert.equal(pkg.peerDependencies[name], "*");
631
+ assert.equal(pkg.peerDependenciesMeta[name].optional, true);
632
+ assert.equal(pkg.dependencies[name], undefined);
633
+ }
634
+ // Development ranges are policy specifiers; their floor never drops below
635
+ // the runtime minimum the launcher enforces.
636
+ for (const name of ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
637
+ assert.equal(pkg.devDependencies[name], ">=1.0.0", name);
638
+ assert.equal(checkPiVersion(pkg.devDependencies[name].slice(2)).ok, true, name);
639
+ }
640
+ });
641
+
623
642
  test("checkPiVersion accepts a version equal to the minimum", () => {
624
- assert.deepEqual(checkPiVersion("0.85.1"), { ok: true, version: "0.85.1" });
643
+ assert.deepEqual(checkPiVersion("0.99.1"), { ok: true, version: "0.99.1" });
625
644
  });
626
645
 
627
646
  test("checkPiVersion accepts a version above the minimum", () => {
628
- assert.deepEqual(checkPiVersion("0.86.0"), { ok: true, version: "0.86.0" });
647
+ assert.deepEqual(checkPiVersion("0.100.0"), { ok: true, version: "0.100.0" });
629
648
  });
630
649
 
631
650
  test("checkPiVersion accepts a v-prefixed version", () => {
632
- assert.deepEqual(checkPiVersion("v0.85.1"), { ok: true, version: "0.85.1" });
651
+ assert.deepEqual(checkPiVersion("v0.99.1"), { ok: true, version: "0.99.1" });
633
652
  });
634
653
 
635
654
  test("checkPiVersion accepts a prerelease suffix at the minimum", () => {
636
- assert.deepEqual(checkPiVersion("pi version 0.85.1-rc.2"), { ok: true, version: "0.85.1" });
655
+ assert.deepEqual(checkPiVersion("pi version 0.99.1-rc.2"), { ok: true, version: "0.99.1" });
637
656
  });
638
657
 
639
658
  test("checkPiVersion rejects a version below the minimum and names both versions", () => {
@@ -642,7 +661,7 @@ test("checkPiVersion rejects a version below the minimum and names both versions
642
661
  if (result.ok) throw new Error("expected a failing result");
643
662
  assert.equal(result.version, "0.85.0");
644
663
  assert.match(result.message, /0\.85\.0/);
645
- assert.match(result.message, /0\.85\.1/);
664
+ assert.match(result.message, /0\.99\.1/);
646
665
  });
647
666
 
648
667
  test("checkPiVersion rejects a prerelease below the minimum", () => {
@@ -656,7 +675,7 @@ test("checkPiVersion reports unparsable output with the raw text and the minimum
656
675
  if (result.ok) throw new Error("expected a failing result");
657
676
  assert.equal(result.version, undefined);
658
677
  assert.match(result.message, /not a version/);
659
- assert.match(result.message, /0\.85\.1/);
678
+ assert.match(result.message, /0\.99\.1/);
660
679
  });
661
680
 
662
681
  test("checkPiVersion accepts a custom minimum", () => {
@@ -1103,6 +1122,23 @@ test("otherPackageInjections defaults to including every declared package when i
1103
1122
 
1104
1123
  // --- buildPiInvocation -------------------------------------------------------
1105
1124
 
1125
+ test("package injection relies on Pi's -e resource discovery in isolated and takeover modes", () => {
1126
+ for (const takeOver of [false, true]) {
1127
+ const built = buildPiInvocation({
1128
+ runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
1129
+ home: { mode: "isolated", dir: "/gentle-shell/agent", source: "default" },
1130
+ packageRoot: "/pkg",
1131
+ declaration: undefined,
1132
+ takeOver,
1133
+ otherPackagePaths: [],
1134
+ passthrough: [],
1135
+ baseEnv: {},
1136
+ homedir: "/home/u",
1137
+ });
1138
+ assert.deepEqual(built.args, takeOver ? ["--no-extensions", "-e", "/pkg"] : ["-e", "/pkg"]);
1139
+ }
1140
+ });
1141
+
1106
1142
  const linkHome: ResolvedHome = { mode: "link", dir: "/pi/agent", source: "flag" };
1107
1143
  const isolatedHomeResolved: ResolvedHome = { mode: "isolated", dir: "/gentle-shell/agent", source: "default" };
1108
1144
 
@@ -1116,8 +1152,9 @@ test("buildPiInvocation injects the launcher env into baseEnv", () => {
1116
1152
  otherPackagePaths: [],
1117
1153
  passthrough: [],
1118
1154
  baseEnv: { PATH: "/usr/bin" },
1155
+ homedir: "/home/u",
1119
1156
  });
1120
- assert.deepEqual(built.env, { PATH: "/usr/bin", PI_CODING_AGENT_DIR: "/pi/agent", GENTLE_PI_AGENT_HOME: "/pi/agent" });
1157
+ assert.deepEqual(built.env, { PATH: "/usr/bin", PI_CODING_AGENT_DIR: "/pi/agent", GENTLE_PI_AGENT_HOME: "/pi/agent", GENTLE_SHELL_USER_PI_HOME: join("/home/u", ".pi", "agent") });
1121
1158
  });
1122
1159
 
1123
1160
  test("buildPiInvocation skips injection when there is a declaration and no takeover (npm matches the launcher's own install)", () => {
@@ -1130,6 +1167,7 @@ test("buildPiInvocation skips injection when there is a declaration and no takeo
1130
1167
  otherPackagePaths: [],
1131
1168
  passthrough: ["--mode", "rpc"],
1132
1169
  baseEnv: {},
1170
+ homedir: "/home/u",
1133
1171
  });
1134
1172
  assert.deepEqual(built.command, "/usr/bin/pi");
1135
1173
  assert.deepEqual(built.args, ["--mode", "rpc"]);
@@ -1145,16 +1183,11 @@ test("buildPiInvocation adds the gentle-pi injection flags when there is no decl
1145
1183
  otherPackagePaths: [],
1146
1184
  passthrough: ["--mode", "rpc"],
1147
1185
  baseEnv: {},
1186
+ homedir: "/home/u",
1148
1187
  });
1149
1188
  assert.deepEqual(built.args, [
1150
1189
  "-e",
1151
1190
  "/pkg",
1152
- "--theme",
1153
- join("/pkg", "themes"),
1154
- "--skill",
1155
- join("/pkg", "skills"),
1156
- "--prompt-template",
1157
- join("/pkg", "prompts"),
1158
1191
  "--mode",
1159
1192
  "rpc",
1160
1193
  ]);
@@ -1171,6 +1204,7 @@ test("buildPiInvocation emits only runtime args and passthrough when a pi subcom
1171
1204
  passthrough: ["install", "npm:x"],
1172
1205
  piSubcommand: "install",
1173
1206
  baseEnv: {},
1207
+ homedir: "/home/u",
1174
1208
  });
1175
1209
  assert.deepEqual(built.args, ["install", "npm:x"]);
1176
1210
  assert.equal(built.command, "/usr/bin/pi");
@@ -1187,11 +1221,13 @@ test("buildPiInvocation still injects the launcher env for a pi subcommand", ()
1187
1221
  passthrough: ["list"],
1188
1222
  piSubcommand: "list",
1189
1223
  baseEnv: { PATH: "/usr/bin" },
1224
+ homedir: "/home/u",
1190
1225
  });
1191
1226
  assert.deepEqual(built.env, {
1192
1227
  PATH: "/usr/bin",
1193
1228
  PI_CODING_AGENT_DIR: "/gentle-shell/agent",
1194
1229
  GENTLE_PI_AGENT_HOME: "/gentle-shell/agent",
1230
+ GENTLE_SHELL_USER_PI_HOME: join("/home/u", ".pi", "agent"),
1195
1231
  });
1196
1232
  });
1197
1233
 
@@ -1205,11 +1241,49 @@ test("buildPiInvocation in link mode with a pi subcommand is exactly pi <subcomm
1205
1241
  otherPackagePaths: [join("/agent", "npm", "node_modules", "some-other")],
1206
1242
  passthrough: ["auth", "status"],
1207
1243
  piSubcommand: "auth",
1208
- baseEnv: {},
1244
+ baseEnv: { PI_CODING_AGENT_DIR: "/pi/agent" },
1245
+ homedir: "/home/u",
1209
1246
  });
1210
1247
  assert.equal(built.command, "/usr/bin/pi");
1211
1248
  assert.deepEqual(built.args, ["auth", "status"]);
1212
- assert.deepEqual(built.env, { PI_CODING_AGENT_DIR: "/pi/agent", GENTLE_PI_AGENT_HOME: "/pi/agent" });
1249
+ assert.deepEqual(built.env, { PI_CODING_AGENT_DIR: "/pi/agent", GENTLE_PI_AGENT_HOME: "/pi/agent", GENTLE_SHELL_USER_PI_HOME: "/pi/agent" });
1250
+ });
1251
+
1252
+ // The isolated home overrides PI_CODING_AGENT_DIR for the session, so the
1253
+ // user's own Pi home travels separately for read-only features such as
1254
+ // /gentle:stats. Isolation itself is unchanged.
1255
+ test("buildPiInvocation carries the user's original Pi home without weakening isolation", () => {
1256
+ const build = (baseEnv: Record<string, string | undefined>, home: ResolvedHome = isolatedHomeResolved) => buildPiInvocation({
1257
+ runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
1258
+ home,
1259
+ packageRoot: "/pkg",
1260
+ declaration: undefined,
1261
+ takeOver: false,
1262
+ otherPackagePaths: [],
1263
+ passthrough: [],
1264
+ baseEnv,
1265
+ homedir: "/home/u",
1266
+ }).env;
1267
+ const conventional = build({});
1268
+ assert.equal(conventional.PI_CODING_AGENT_DIR, "/gentle-shell/agent");
1269
+ assert.equal(conventional.GENTLE_PI_AGENT_HOME, "/gentle-shell/agent");
1270
+ assert.equal(conventional.GENTLE_SHELL_USER_PI_HOME, join("/home/u", ".pi", "agent"));
1271
+ // A custom Pi home is preserved; an empty value falls through like Pi itself.
1272
+ const custom = build({ PI_CODING_AGENT_DIR: "/custom/pi" });
1273
+ assert.equal(custom.PI_CODING_AGENT_DIR, "/gentle-shell/agent");
1274
+ assert.equal(custom.GENTLE_SHELL_USER_PI_HOME, "/custom/pi");
1275
+ assert.equal(build({ PI_CODING_AGENT_DIR: "" }).GENTLE_SHELL_USER_PI_HOME, join("/home/u", ".pi", "agent"));
1276
+ // A nested launch inherits the outer isolated PI_CODING_AGENT_DIR; the
1277
+ // original home the outer launcher recorded must win over it.
1278
+ const nested = build({ PI_CODING_AGENT_DIR: "/gentle-shell/agent", GENTLE_SHELL_USER_PI_HOME: "/custom/pi" });
1279
+ assert.equal(nested.PI_CODING_AGENT_DIR, "/gentle-shell/agent");
1280
+ assert.equal(nested.GENTLE_SHELL_USER_PI_HOME, "/custom/pi");
1281
+ // --link: the active and original homes are the same directory.
1282
+ const linked = build({ PI_CODING_AGENT_DIR: "/pi/agent" }, linkHome);
1283
+ assert.equal(linked.PI_CODING_AGENT_DIR, "/pi/agent");
1284
+ assert.equal(linked.GENTLE_SHELL_USER_PI_HOME, "/pi/agent");
1285
+ assert.equal(USER_PI_HOME_ENV, "GENTLE_SHELL_USER_PI_HOME");
1286
+ assert.equal(userPiHome({ GENTLE_SHELL_USER_PI_HOME: "/kept" }, "/home/u"), "/kept");
1213
1287
  });
1214
1288
 
1215
1289
  test("buildPiInvocation keeps the runtime's own args ahead of the injection and passthrough", () => {
@@ -1222,6 +1296,7 @@ test("buildPiInvocation keeps the runtime's own args ahead of the injection and
1222
1296
  otherPackagePaths: [],
1223
1297
  passthrough: [],
1224
1298
  baseEnv: {},
1299
+ homedir: "/home/u",
1225
1300
  });
1226
1301
  assert.equal(built.args[0], "/bundled/cli.js");
1227
1302
  assert.equal(built.command, "/usr/bin/node");
@@ -1237,6 +1312,7 @@ test("buildPiInvocation takes over a conflicting path declaration: --no-extensio
1237
1312
  otherPackagePaths: [join("/agent", "npm", "node_modules", "some-other")],
1238
1313
  passthrough: ["--mode", "rpc"],
1239
1314
  baseEnv: {},
1315
+ homedir: "/home/u",
1240
1316
  });
1241
1317
  assert.deepEqual(built.args, [
1242
1318
  "--no-extensions",
@@ -1244,12 +1320,6 @@ test("buildPiInvocation takes over a conflicting path declaration: --no-extensio
1244
1320
  join("/agent", "npm", "node_modules", "some-other"),
1245
1321
  "-e",
1246
1322
  "/pkg",
1247
- "--theme",
1248
- join("/pkg", "themes"),
1249
- "--skill",
1250
- join("/pkg", "skills"),
1251
- "--prompt-template",
1252
- join("/pkg", "prompts"),
1253
1323
  "--mode",
1254
1324
  "rpc",
1255
1325
  ]);
@@ -1265,17 +1335,12 @@ test("buildPiInvocation takes over with --package-root even for a matching npm d
1265
1335
  otherPackagePaths: [],
1266
1336
  passthrough: [],
1267
1337
  baseEnv: {},
1338
+ homedir: "/home/u",
1268
1339
  });
1269
1340
  assert.deepEqual(built.args, [
1270
1341
  "--no-extensions",
1271
1342
  "-e",
1272
1343
  "/forced/root",
1273
- "--theme",
1274
- join("/forced/root", "themes"),
1275
- "--skill",
1276
- join("/forced/root", "skills"),
1277
- "--prompt-template",
1278
- join("/forced/root", "prompts"),
1279
1344
  ]);
1280
1345
  });
1281
1346
 
@@ -1289,6 +1354,7 @@ test("buildPiInvocation takes over with --package-root even when there is no dec
1289
1354
  otherPackagePaths: [join("/agent", "npm", "node_modules", "some-other")],
1290
1355
  passthrough: [],
1291
1356
  baseEnv: {},
1357
+ homedir: "/home/u",
1292
1358
  });
1293
1359
  assert.deepEqual(built.args, [
1294
1360
  "--no-extensions",
@@ -1296,12 +1362,6 @@ test("buildPiInvocation takes over with --package-root even when there is no dec
1296
1362
  join("/agent", "npm", "node_modules", "some-other"),
1297
1363
  "-e",
1298
1364
  "/forced/root",
1299
- "--theme",
1300
- join("/forced/root", "themes"),
1301
- "--skill",
1302
- join("/forced/root", "skills"),
1303
- "--prompt-template",
1304
- join("/forced/root", "prompts"),
1305
1365
  ]);
1306
1366
  });
1307
1367
 
@@ -1316,6 +1376,7 @@ test("buildPiInvocation injects loose extension entries during a takeover, after
1316
1376
  looseExtensionEntries: [join("/agent", "extensions", "a.ts"), join("/project", ".pi", "extensions", "b.js")],
1317
1377
  passthrough: ["--mode", "rpc"],
1318
1378
  baseEnv: {},
1379
+ homedir: "/home/u",
1319
1380
  });
1320
1381
  assert.deepEqual(built.args, [
1321
1382
  "--no-extensions",
@@ -1327,12 +1388,6 @@ test("buildPiInvocation injects loose extension entries during a takeover, after
1327
1388
  join("/project", ".pi", "extensions", "b.js"),
1328
1389
  "-e",
1329
1390
  "/pkg",
1330
- "--theme",
1331
- join("/pkg", "themes"),
1332
- "--skill",
1333
- join("/pkg", "skills"),
1334
- "--prompt-template",
1335
- join("/pkg", "prompts"),
1336
1391
  "--mode",
1337
1392
  "rpc",
1338
1393
  ]);
@@ -1348,8 +1403,9 @@ test("buildPiInvocation omits loose extension entry flags when the list is empty
1348
1403
  otherPackagePaths: [],
1349
1404
  passthrough: [],
1350
1405
  baseEnv: {},
1406
+ homedir: "/home/u",
1351
1407
  });
1352
- assert.deepEqual(withoutField.args, ["--no-extensions", "-e", "/pkg", "--theme", join("/pkg", "themes"), "--skill", join("/pkg", "skills"), "--prompt-template", join("/pkg", "prompts")]);
1408
+ assert.deepEqual(withoutField.args, ["--no-extensions", "-e", "/pkg"]);
1353
1409
 
1354
1410
  const withEmptyField = buildPiInvocation({
1355
1411
  runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
@@ -1361,6 +1417,7 @@ test("buildPiInvocation omits loose extension entry flags when the list is empty
1361
1417
  looseExtensionEntries: [],
1362
1418
  passthrough: [],
1363
1419
  baseEnv: {},
1420
+ homedir: "/home/u",
1364
1421
  });
1365
1422
  assert.deepEqual(withEmptyField.args, withoutField.args);
1366
1423
  });
@@ -1381,6 +1438,7 @@ test("buildPiInvocation dedupes loose extension entries against other-package pa
1381
1438
  looseExtensionEntries: ["/shared/dup.ts", "/agent/extensions/a.ts", "/agent/extensions/a.ts"],
1382
1439
  passthrough: [],
1383
1440
  baseEnv: {},
1441
+ homedir: "/home/u",
1384
1442
  });
1385
1443
  assert.deepEqual(built.args, [
1386
1444
  "--no-extensions",
@@ -1392,12 +1450,6 @@ test("buildPiInvocation dedupes loose extension entries against other-package pa
1392
1450
  "/agent/extensions/a.ts",
1393
1451
  "-e",
1394
1452
  "/pkg",
1395
- "--theme",
1396
- join("/pkg", "themes"),
1397
- "--skill",
1398
- join("/pkg", "skills"),
1399
- "--prompt-template",
1400
- join("/pkg", "prompts"),
1401
1453
  ]);
1402
1454
  });
1403
1455
 
@@ -1415,6 +1467,7 @@ test("buildPiInvocation dedupes the launcher's own package root against an other
1415
1467
  otherPackagePaths: [join("/agent", "npm", "node_modules", "some-other"), "/pkg"],
1416
1468
  passthrough: [],
1417
1469
  baseEnv: {},
1470
+ homedir: "/home/u",
1418
1471
  });
1419
1472
  const eFlags = built.args.filter((arg, index) => built.args[index - 1] === "-e");
1420
1473
  assert.deepEqual(eFlags, [join("/agent", "npm", "node_modules", "some-other"), "/pkg"]);
@@ -1424,12 +1477,6 @@ test("buildPiInvocation dedupes the launcher's own package root against an other
1424
1477
  join("/agent", "npm", "node_modules", "some-other"),
1425
1478
  "-e",
1426
1479
  "/pkg",
1427
- "--theme",
1428
- join("/pkg", "themes"),
1429
- "--skill",
1430
- join("/pkg", "skills"),
1431
- "--prompt-template",
1432
- join("/pkg", "prompts"),
1433
1480
  ]);
1434
1481
  });
1435
1482
 
@@ -0,0 +1,270 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import test from "node:test";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ RESUME_HANDOFF_ENV,
10
+ gentleShellResumeCommand,
11
+ isResumeHandoffPath,
12
+ parseResumeHandoff,
13
+ piDefaultSessionDir,
14
+ planResumeHint,
15
+ resumeHandoffFromSession,
16
+ serializeResumeHandoff,
17
+ } from "../lib/gentle-shell-resume-hint.ts";
18
+
19
+ const ID = "01a0e0a0-6d7b-7314-89c1-537d47bbf4f3";
20
+ const AGENT_DIR = resolve("/home/u/.gentle-shell/agent");
21
+ const CWD = resolve("/home/u/project");
22
+ const DEFAULT_DIR = join(AGENT_DIR, "sessions", `--${CWD.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`);
23
+
24
+ test("handoff env name is stable", () => {
25
+ assert.equal(RESUME_HANDOFF_ENV, "GENTLE_SHELL_RESUME_HANDOFF");
26
+ });
27
+
28
+ test("piDefaultSessionDir mirrors pi's encoded per-cwd session dir", () => {
29
+ assert.equal(piDefaultSessionDir(CWD, AGENT_DIR), DEFAULT_DIR);
30
+ if (process.platform !== "win32") assert.equal(piDefaultSessionDir("/home/u/project", "/a"), "/a/sessions/--home-u-project--");
31
+ });
32
+
33
+ test("piDefaultSessionDir matches pi's own SessionManager", () => {
34
+ const agentDir = mkdtempSync(join(tmpdir(), "resume-hint-agent-"));
35
+ const previous = process.env.PI_CODING_AGENT_DIR;
36
+ process.env.PI_CODING_AGENT_DIR = agentDir;
37
+ try {
38
+ const byDefault = SessionManager.create(CWD);
39
+ assert.equal(byDefault.usesDefaultSessionDir(), true);
40
+ assert.equal(byDefault.getSessionDir(), piDefaultSessionDir(CWD, getAgentDir()));
41
+ const custom = SessionManager.create(CWD, join(agentDir, "elsewhere"));
42
+ assert.equal(custom.usesDefaultSessionDir(), false);
43
+ assert.notEqual(custom.getSessionDir(), piDefaultSessionDir(CWD, getAgentDir()));
44
+ } finally {
45
+ if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
46
+ else process.env.PI_CODING_AGENT_DIR = previous;
47
+ rmSync(agentDir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test("resumeHandoffFromSession omits the session dir when it is pi's default", () => {
52
+ const handoff = resumeHandoffFromSession({
53
+ sessionId: ID,
54
+ sessionDir: DEFAULT_DIR,
55
+ sessionFile: join(DEFAULT_DIR, "x.jsonl"),
56
+ cwd: CWD,
57
+ launchCwd: CWD,
58
+ agentDir: AGENT_DIR,
59
+ fileExists: () => true,
60
+ });
61
+ assert.deepEqual(handoff, { sessionId: ID });
62
+ });
63
+
64
+ test("resumeHandoffFromSession keeps a custom session dir", () => {
65
+ const handoff = resumeHandoffFromSession({
66
+ sessionId: ID,
67
+ sessionDir: "/tmp/my sessions",
68
+ sessionFile: "/tmp/my sessions/x.jsonl",
69
+ cwd: CWD,
70
+ launchCwd: CWD,
71
+ agentDir: AGENT_DIR,
72
+ fileExists: () => true,
73
+ });
74
+ assert.deepEqual(handoff, { sessionId: ID, sessionDir: "/tmp/my sessions" });
75
+ });
76
+
77
+ test("resumeHandoffFromSession carries the session file for a session from another project", () => {
78
+ const other = resolve("/home/u/other");
79
+ const handoff = resumeHandoffFromSession({
80
+ sessionId: ID,
81
+ sessionDir: DEFAULT_DIR,
82
+ sessionFile: join(DEFAULT_DIR, "x.jsonl"),
83
+ cwd: CWD,
84
+ launchCwd: other,
85
+ agentDir: AGENT_DIR,
86
+ fileExists: () => true,
87
+ });
88
+ assert.deepEqual(handoff, { sessionId: ID, sessionFile: join(DEFAULT_DIR, "x.jsonl") });
89
+ assert.equal(gentleShellResumeCommand(handoff!, ["--link"], "linux"), `gentle-shell --link --session ${join(DEFAULT_DIR, "x.jsonl")}`);
90
+ });
91
+
92
+ test("resumeHandoffFromSession returns undefined when pi would not print a hint", () => {
93
+ const base = { sessionId: ID, sessionDir: DEFAULT_DIR, cwd: CWD, launchCwd: CWD, agentDir: AGENT_DIR };
94
+ assert.equal(resumeHandoffFromSession({ ...base, sessionFile: undefined, fileExists: () => true }), undefined);
95
+ assert.equal(resumeHandoffFromSession({ ...base, sessionFile: join(DEFAULT_DIR, "x.jsonl"), fileExists: () => false }), undefined);
96
+ });
97
+
98
+ test("handoff round-trips and rejects malformed input", () => {
99
+ const handoff = { sessionId: ID, sessionDir: "/tmp/s" };
100
+ assert.deepEqual(parseResumeHandoff(serializeResumeHandoff(handoff)), handoff);
101
+ assert.deepEqual(parseResumeHandoff(serializeResumeHandoff({ sessionId: ID })), { sessionId: ID });
102
+ for (const text of ["", "not json", "null", "[]", '{"sessionId":""}', '{"sessionId":1}', '{"sessionId":"a","sessionDir":3}', '{"sessionId":"a","sessionDir":""}']) {
103
+ assert.equal(parseResumeHandoff(text), undefined, text);
104
+ }
105
+ });
106
+
107
+ test("handoff accepts only an absolute, control-free session file on its own", () => {
108
+ const file = resolve("/tmp/s/x.jsonl");
109
+ assert.deepEqual(parseResumeHandoff(JSON.stringify({ sessionId: ID, sessionFile: file })), { sessionId: ID, sessionFile: file });
110
+ for (const value of [
111
+ { sessionId: ID, sessionFile: "relative/x.jsonl" },
112
+ { sessionId: ID, sessionFile: resolve("/tmp/s/x.txt") },
113
+ { sessionId: ID, sessionFile: resolve("/tmp/s/\u001b[2J.jsonl") },
114
+ { sessionId: ID, sessionFile: 3 },
115
+ { sessionId: ID, sessionFile: file, sessionDir: "/tmp/s" },
116
+ ]) {
117
+ assert.equal(parseResumeHandoff(JSON.stringify(value)), undefined, JSON.stringify(value));
118
+ }
119
+ });
120
+
121
+ test("isResumeHandoffPath accepts only the launcher's private handoff shape", () => {
122
+ const tmp = tmpdir();
123
+ assert.equal(isResumeHandoffPath(join(tmp, "gentle-shell-resume-Ab12Cd", "handoff.json")), true);
124
+ assert.equal(isResumeHandoffPath(join(tmp, "gentle-shell-resume-Ab12Cd", "other.json")), false);
125
+ assert.equal(isResumeHandoffPath(join(tmp, "elsewhere", "handoff.json")), false);
126
+ assert.equal(isResumeHandoffPath(join(resolve("/home/u"), ".bashrc")), false);
127
+ assert.equal(isResumeHandoffPath("gentle-shell-resume-Ab12Cd/handoff.json"), false);
128
+ });
129
+
130
+ test("handoff rejects values that could inject terminal control sequences", () => {
131
+ const reject = (value: object) => assert.equal(parseResumeHandoff(JSON.stringify(value)), undefined, JSON.stringify(value));
132
+ // Session ids follow pi's assertValidSessionId charset.
133
+ reject({ sessionId: "abc\u001b[2J" });
134
+ reject({ sessionId: "abc def" });
135
+ reject({ sessionId: "-abc" });
136
+ reject({ sessionId: "abc$(id)" });
137
+ reject({ sessionId: ID, sessionDir: "/tmp/\u001b]0;pwned\u0007" });
138
+ reject({ sessionId: ID, sessionDir: "/tmp/a\u009bb" });
139
+ assert.deepEqual(parseResumeHandoff(JSON.stringify({ sessionId: "a.b_c-1" })), { sessionId: "a.b_c-1" });
140
+ });
141
+
142
+ test("gentleShellResumeCommand keeps home selectors and a custom session dir", () => {
143
+ assert.equal(gentleShellResumeCommand({ sessionId: ID }, [], "linux"), `gentle-shell --session ${ID}`);
144
+ assert.equal(gentleShellResumeCommand({ sessionId: ID }, ["--link"], "linux"), `gentle-shell --link --session ${ID}`);
145
+ assert.equal(
146
+ gentleShellResumeCommand({ sessionId: ID, sessionDir: "/tmp/my sessions" }, ["--home", "/x y"], "linux"),
147
+ `gentle-shell --home '/x y' --session-dir '/tmp/my sessions' --session ${ID}`,
148
+ );
149
+ });
150
+
151
+ test("the gentle-shell command matches pi's real exit hint with the binary swapped", async () => {
152
+ // formatResumeCommand is not in pi's exports map; load the module file.
153
+ const piDist = dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")));
154
+ const modulePath = join(piDist, "modes", "interactive", "interactive-mode.js");
155
+ const { formatResumeCommand } = await import(pathToFileURL(modulePath).href);
156
+ // The appended line is only needed while pi prints this hint.
157
+ assert.ok(readFileSync(modulePath, "utf8").includes('chalk.dim("To resume this session:")'), "pi's hint label changed");
158
+
159
+ const agentDir = mkdtempSync(join(tmpdir(), "resume-hint-agent-"));
160
+ const previousDir = process.env.PI_CODING_AGENT_DIR;
161
+ const previousTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
162
+ process.env.PI_CODING_AGENT_DIR = agentDir;
163
+ Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
164
+ try {
165
+ for (const sessionDir of [undefined, join(agentDir, "my sessions")]) {
166
+ const manager = SessionManager.create(CWD, sessionDir);
167
+ manager.appendMessage({ role: "user", content: "hi", timestamp: Date.now() } as never);
168
+ manager.appendMessage({ role: "assistant", content: [{ type: "text", text: "ok" }], timestamp: Date.now() } as never);
169
+ const handoff = resumeHandoffFromSession({
170
+ sessionId: manager.getSessionId(),
171
+ sessionDir: manager.getSessionDir(),
172
+ sessionFile: manager.getSessionFile(),
173
+ cwd: manager.getCwd(),
174
+ launchCwd: manager.getCwd(),
175
+ agentDir: getAgentDir(),
176
+ fileExists: existsSync,
177
+ });
178
+ assert.ok(handoff);
179
+ const piCommand: string = formatResumeCommand(manager);
180
+ assert.ok(piCommand.startsWith("pi "), piCommand);
181
+ // pi quotes the POSIX way on every platform; compare with the same quoting.
182
+ assert.equal(gentleShellResumeCommand(handoff, [], "linux"), `gentle-shell ${piCommand.slice("pi ".length)}`);
183
+ }
184
+ } finally {
185
+ if (previousTTY) Object.defineProperty(process.stdout, "isTTY", previousTTY);
186
+ else delete (process.stdout as { isTTY?: boolean }).isTTY;
187
+ if (previousDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
188
+ else process.env.PI_CODING_AGENT_DIR = previousDir;
189
+ rmSync(agentDir, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ test("on win32 the command uses double quotes that cmd.exe and PowerShell honor", () => {
194
+ assert.equal(gentleShellResumeCommand({ sessionId: ID }, ["--link"], "win32"), `gentle-shell --link --session ${ID}`);
195
+ assert.equal(
196
+ gentleShellResumeCommand({ sessionId: ID, sessionDir: "C:\\Users\\Name With Space\\sessions" }, ["--home", "C:\\Users\\Name With Space\\home"], "win32"),
197
+ `gentle-shell --home "C:\\Users\\Name With Space\\home" --session-dir "C:\\Users\\Name With Space\\sessions" --session ${ID}`,
198
+ );
199
+ // Plain paths need no quotes at all.
200
+ assert.equal(
201
+ gentleShellResumeCommand({ sessionId: ID, sessionFile: "C:\\s\\x.jsonl" }, [], "win32"),
202
+ "gentle-shell --session C:\\s\\x.jsonl",
203
+ );
204
+ // Spaces alone are safe inside double quotes.
205
+ assert.equal(
206
+ gentleShellResumeCommand({ sessionId: ID, sessionFile: "C:\\My Sessions\\x.jsonl" }, [], "win32"),
207
+ 'gentle-shell --session "C:\\My Sessions\\x.jsonl"',
208
+ );
209
+ });
210
+
211
+ test("on win32 no command is produced when a value cannot be quoted safely", () => {
212
+ // PowerShell passes a space-free argument to the .cmd shim unquoted, so
213
+ // cmd.exe operators are refused even though double quotes would cover them.
214
+ for (const home of ["C:\\%USERPROFILE%\\h", "C:\\a!b\\h", "C:\\$env\\h", "C:\\a`b\\h", 'C:\\a"b\\h', "C:\\a b\\", "C:\\R&D\\h", "C:\\a|b\\h", "C:\\a<b\\h", "C:\\a>b\\h", "C:\\a^b\\h", "C:\\a(b)\\h"]) {
215
+ assert.equal(gentleShellResumeCommand({ sessionId: ID }, ["--home", home], "win32"), undefined, home);
216
+ }
217
+ assert.equal(
218
+ planResumeHint({ handoff: { sessionId: ID }, homeFlags: ["--home", "C:\\%TEMP%\\h"], stdoutIsTTY: true, terminalHungUp: false, platform: "win32", color: true }),
219
+ undefined,
220
+ );
221
+ // The same characters are harmless inside POSIX single quotes elsewhere.
222
+ assert.equal(gentleShellResumeCommand({ sessionId: ID }, ["--home", "/tmp/$h"], "linux"), `gentle-shell --home '/tmp/$h' --session ${ID}`);
223
+ });
224
+
225
+ test("a cross-project session file reopens the original session in pi", () => {
226
+ const agentDir = mkdtempSync(join(tmpdir(), "resume-hint-agent-"));
227
+ const previousDir = process.env.PI_CODING_AGENT_DIR;
228
+ process.env.PI_CODING_AGENT_DIR = agentDir;
229
+ try {
230
+ const manager = SessionManager.create(CWD);
231
+ manager.appendMessage({ role: "user", content: "hi", timestamp: Date.now() } as never);
232
+ manager.appendMessage({ role: "assistant", content: [{ type: "text", text: "ok" }], timestamp: Date.now() } as never);
233
+ const handoff = resumeHandoffFromSession({
234
+ sessionId: manager.getSessionId(),
235
+ sessionDir: manager.getSessionDir(),
236
+ sessionFile: manager.getSessionFile(),
237
+ cwd: manager.getCwd(),
238
+ launchCwd: resolve("/home/u/other"),
239
+ agentDir: getAgentDir(),
240
+ fileExists: existsSync,
241
+ });
242
+ assert.ok(handoff?.sessionFile);
243
+ // pi's --session takes any value with a path separator as a file path.
244
+ assert.ok(/[/\\]/.test(handoff.sessionFile));
245
+ const reopened = SessionManager.open(handoff.sessionFile);
246
+ assert.equal(reopened.getSessionId(), manager.getSessionId());
247
+ assert.equal(reopened.getCwd(), manager.getCwd());
248
+ } finally {
249
+ if (previousDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
250
+ else process.env.PI_CODING_AGENT_DIR = previousDir;
251
+ rmSync(agentDir, { recursive: true, force: true });
252
+ }
253
+ });
254
+
255
+ test("planResumeHint prints the gentle-shell line with pi's dim label style", () => {
256
+ const hint = planResumeHint({ handoff: { sessionId: ID }, homeFlags: ["--link"], stdoutIsTTY: true, terminalHungUp: false, platform: "linux", color: true });
257
+ assert.equal(hint, `\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session ${ID}\n`);
258
+ });
259
+
260
+ test("planResumeHint drops the ANSI style when stdout has no colors", () => {
261
+ const hint = planResumeHint({ handoff: { sessionId: ID }, homeFlags: [], stdoutIsTTY: true, terminalHungUp: false, platform: "linux", color: false });
262
+ assert.equal(hint, `To resume in gentle-shell: gentle-shell --session ${ID}\n`);
263
+ });
264
+
265
+ test("planResumeHint prints nothing without a handoff, a TTY, or after a hang-up", () => {
266
+ const base = { handoff: { sessionId: ID } as { sessionId: string } | undefined, homeFlags: [], stdoutIsTTY: true, terminalHungUp: false, platform: "linux" as NodeJS.Platform, color: true };
267
+ assert.equal(planResumeHint({ ...base, handoff: undefined }), undefined);
268
+ assert.equal(planResumeHint({ ...base, stdoutIsTTY: false }), undefined);
269
+ assert.equal(planResumeHint({ ...base, terminalHungUp: true }), undefined);
270
+ });