gentle-pi 4.1.0 → 4.3.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 (249) hide show
  1. package/README.md +352 -128
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/engram-guard +1 -1
  12. package/bin/gentle-shell.mjs +188 -35
  13. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  15. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  16. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  19. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  20. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  21. package/docs/assets/diagrams/measured-routing.svg +49 -0
  22. package/docs/assets/diagrams/native-review.svg +52 -12
  23. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  24. package/docs/assets/diagrams/permission-modes.svg +51 -0
  25. package/docs/assets/features/audio-notifications.png +0 -0
  26. package/docs/assets/features/command-palette.png +0 -0
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +40 -8
  30. package/docs/delegated-verification.md +10 -8
  31. package/docs/gentle-agents-activity.md +11 -5
  32. package/docs/gentle-shell-uninstall.md +8 -1
  33. package/docs/gentle-shell.md +20 -12
  34. package/docs/install-wizard.md +17 -10
  35. package/docs/permission-modes.md +51 -6
  36. package/docs/readme-reference.md +155 -31
  37. package/docs/session-profile-format.md +39 -0
  38. package/docs/yolo-mode.md +21 -7
  39. package/extensions/ask-user-choice.ts +4 -0
  40. package/extensions/ask-user-question.ts +6 -1
  41. package/extensions/codegraph-tools.ts +68 -6
  42. package/extensions/gentle-agents.ts +146 -28
  43. package/extensions/gentle-ai.ts +380 -68
  44. package/extensions/gentle-herdr-activity.ts +2 -3
  45. package/extensions/gentle-shell.ts +53 -43
  46. package/extensions/gentle-todo.ts +62 -18
  47. package/extensions/quiet-tools.ts +1 -1
  48. package/extensions/session-scratch.ts +365 -0
  49. package/extensions/startup-banner.ts +2 -3
  50. package/lib/agent-home.ts +8 -3
  51. package/lib/agents-protocol.ts +3 -0
  52. package/lib/agents-runner.ts +55 -6
  53. package/lib/agents-view.ts +3 -1
  54. package/lib/auto-permission-answers.ts +133 -0
  55. package/lib/auto-permission-classifier.ts +215 -36
  56. package/lib/auto-permission-evidence.ts +88 -0
  57. package/lib/auto-permission-memory.ts +69 -0
  58. package/lib/auto-permission-runtime.ts +251 -35
  59. package/lib/auto-permission-tool-context.ts +61 -0
  60. package/lib/bounded-writer-admission.ts +51 -0
  61. package/lib/command-palette-catalog.ts +0 -4
  62. package/lib/commit-slice-signal.ts +100 -0
  63. package/lib/context-backstop.ts +84 -0
  64. package/lib/delivered-test-lint.ts +80 -0
  65. package/lib/destructive-command-guard.ts +215 -28
  66. package/lib/gauge-theme.ts +146 -0
  67. package/lib/gentle-ai-binary.ts +28 -7
  68. package/lib/gentle-shell-bundled.ts +62 -8
  69. package/lib/gentle-shell-import-pi.ts +233 -0
  70. package/lib/gentle-shell-launcher.ts +146 -12
  71. package/lib/gentle-shell-uninstall.ts +18 -1
  72. package/lib/herdr-activity.ts +24 -0
  73. package/lib/model-routing-authority.ts +6 -1
  74. package/lib/native-review-cli.ts +9 -1
  75. package/lib/orchestrator-discovery.ts +5 -1
  76. package/lib/orchestrator-presence.ts +9 -0
  77. package/lib/orchestrator-scope.ts +21 -3
  78. package/lib/provider-contract-bundle.ts +99 -53
  79. package/lib/review-candidate-view-owner.ts +29 -19
  80. package/lib/review-candidate-view.ts +36 -2
  81. package/lib/review-risk-assessment.ts +43 -38
  82. package/lib/review-session-standing-permission.ts +3 -1
  83. package/lib/selection-engine.ts +4 -1
  84. package/lib/session-profile-freeze.ts +40 -16
  85. package/lib/session-profile-persistence.ts +187 -0
  86. package/lib/session-scratch.ts +474 -0
  87. package/lib/shell-bar.ts +25 -25
  88. package/lib/shell-changes-view.ts +80 -13
  89. package/lib/shell-changes.ts +2 -0
  90. package/lib/shell-gauge.ts +49 -6
  91. package/lib/shell-prompt.ts +6 -4
  92. package/lib/shell-todo.ts +39 -8
  93. package/lib/shell-usage-view.ts +5 -1
  94. package/lib/shell-usage.ts +7 -7
  95. package/lib/verifier-admission.ts +29 -0
  96. package/lib/vim-editor-adapter.ts +22 -4
  97. package/lib/yolo-temp-scratch.ts +69 -0
  98. package/package.json +5 -6
  99. package/runtime/gentle-ai-binary.mjs +28 -7
  100. package/runtime/gentle-shell-bundled.mjs +61 -7
  101. package/runtime/gentle-shell-import-pi.mjs +234 -0
  102. package/runtime/gentle-shell-launcher.mjs +144 -10
  103. package/runtime/gentle-shell-uninstall.mjs +18 -1
  104. package/runtime/native-review-cli.mjs +8 -0
  105. package/runtime/review-risk-assessment.mjs +43 -38
  106. package/scripts/build-runtime-modules.mjs +1 -0
  107. package/scripts/bundled-engram-migration.mjs +125 -0
  108. package/scripts/gentle-ai-installer.mjs +10 -10
  109. package/scripts/install-wizard-preview.mjs +2 -2
  110. package/scripts/installer-downloads.mjs +7 -7
  111. package/scripts/installer-preflight.mjs +1 -1
  112. package/scripts/installer-server.mjs +14 -4
  113. package/scripts/main-channel.mjs +3 -2
  114. package/scripts/pi-update.mjs +223 -0
  115. package/scripts/run-test-suite.mjs +5 -0
  116. package/scripts/verify-package-files.mjs +19 -13
  117. package/skills/gentle-ai/SKILL.md +3 -3
  118. package/skills/work-unit-commits/SKILL.md +5 -3
  119. package/tests/agent-risk-escalation.test.ts +37 -10
  120. package/tests/agents-runner.test.ts +260 -3
  121. package/tests/append-system-prompt-route.test.ts +18 -1
  122. package/tests/auto-mode-human-answers.test.ts +68 -0
  123. package/tests/auto-mode-invariants.test.ts +23 -5
  124. package/tests/auto-mode-sdk.test.ts +29 -2
  125. package/tests/auto-permission-answers.test.ts +170 -0
  126. package/tests/auto-permission-classifier.test.ts +353 -2
  127. package/tests/auto-permission-compound.test.ts +24 -0
  128. package/tests/auto-permission-evidence.test.ts +88 -0
  129. package/tests/auto-permission-memory.test.ts +46 -0
  130. package/tests/auto-permission-public-regressions.test.ts +111 -0
  131. package/tests/auto-permission-runtime.test.ts +270 -12
  132. package/tests/autonomous-guard.test.ts +208 -16
  133. package/tests/bounded-writer-admission.test.ts +11 -3
  134. package/tests/bundled-install.test.ts +3 -3
  135. package/tests/card-content-policy.test.ts +19 -9
  136. package/tests/child-safety.test.ts +39 -0
  137. package/tests/codegraph-tools.test.ts +123 -2
  138. package/tests/command-palette.test.ts +14 -4
  139. package/tests/commit-slice-signal.test.ts +162 -0
  140. package/tests/context-backstop.test.ts +159 -0
  141. package/tests/delegate-for-reason-contract.test.ts +91 -24
  142. package/tests/delivered-test-lint.test.ts +105 -0
  143. package/tests/destructive-command-guard.test.ts +85 -1
  144. package/tests/engram-safety.test.ts +35 -7
  145. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  148. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  149. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  150. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  151. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  152. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  153. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  154. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  155. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  156. package/tests/gauge-theme.test.ts +120 -0
  157. package/tests/gentle-agents.test.ts +443 -8
  158. package/tests/gentle-ai-binary.test.ts +1 -1
  159. package/tests/gentle-ai-child-guards.test.ts +61 -0
  160. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  161. package/tests/gentle-ai-installer.test.ts +51 -51
  162. package/tests/gentle-shell-bin.test.ts +180 -17
  163. package/tests/gentle-shell-bundled.test.ts +235 -38
  164. package/tests/gentle-shell-import-pi.test.ts +262 -0
  165. package/tests/gentle-shell-launcher.test.ts +97 -13
  166. package/tests/gentle-shell-uninstall.test.ts +30 -0
  167. package/tests/gentle-shell.test.ts +113 -14
  168. package/tests/gentle-todo.test.ts +258 -27
  169. package/tests/headless-delivery-question.test.ts +58 -0
  170. package/tests/herdr-activity.test.ts +34 -1
  171. package/tests/install-wizard.test.ts +2 -2
  172. package/tests/installer-posix-bootstrap.test.ts +8 -8
  173. package/tests/installer-preflight.test.ts +3 -3
  174. package/tests/installer-probes.test.ts +8 -8
  175. package/tests/installer-runner.test.ts +14 -13
  176. package/tests/installer-server.test.ts +13 -5
  177. package/tests/installer-windows-bootstrap.test.ts +33 -4
  178. package/tests/main-channel-upgrade.test.ts +7 -1
  179. package/tests/native-review-capability-contract.test.ts +7 -1
  180. package/tests/native-review-parity.test.ts +22 -1
  181. package/tests/no-self-pause-contract.test.ts +5 -2
  182. package/tests/odd-integration.test.ts +5 -1
  183. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  184. package/tests/odd-routing-contract.test.ts +115 -30
  185. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  186. package/tests/orchestrator-budget.test.ts +19 -8
  187. package/tests/orchestrator-discovery.test.ts +29 -0
  188. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  189. package/tests/orchestrator-scope.test.ts +66 -0
  190. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  191. package/tests/package-manifest.test.ts +28 -24
  192. package/tests/pi-update-launcher.test.ts +106 -0
  193. package/tests/pi-update.test.ts +186 -0
  194. package/tests/primary-session-prompt.test.ts +101 -0
  195. package/tests/provider-contract-bundle.test.ts +118 -0
  196. package/tests/provider-contract-mirror.test.ts +24 -0
  197. package/tests/quiet-tool-rendering.test.ts +23 -0
  198. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  199. package/tests/rdd-status-line.test.ts +131 -0
  200. package/tests/review-agent-end-preflight.test.ts +6 -0
  201. package/tests/review-candidate-view.test.ts +49 -0
  202. package/tests/review-contract-prompt.test.ts +5 -2
  203. package/tests/review-controller-native-routing.test.ts +14 -1
  204. package/tests/review-controller.test.ts +1 -1
  205. package/tests/review-host-relay-routing.test.ts +59 -2
  206. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  207. package/tests/review-owner-identity.test.ts +132 -0
  208. package/tests/review-risk-assessment.test.ts +163 -76
  209. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  210. package/tests/review-session-standing-permission.test.ts +27 -1
  211. package/tests/run-test-suite.test.ts +15 -0
  212. package/tests/runtime-harness.mjs +16 -6
  213. package/tests/selection-engine.test.ts +35 -5
  214. package/tests/session-changes-shell.test.ts +1 -1
  215. package/tests/session-profile-freeze.test.ts +37 -1
  216. package/tests/session-profile-persistence.test.ts +334 -0
  217. package/tests/session-scratch.test.ts +945 -0
  218. package/tests/session-subject-routing-contract.test.ts +2 -1
  219. package/tests/shell-bar.test.ts +7 -0
  220. package/tests/shell-changes-view.test.ts +137 -1
  221. package/tests/shell-changes.test.ts +18 -0
  222. package/tests/shell-gauge.test.ts +42 -0
  223. package/tests/shell-todo.test.ts +15 -12
  224. package/tests/small-task-inline-contract.test.ts +37 -0
  225. package/tests/standalone-config-home.test.ts +324 -0
  226. package/tests/startup-banner.test.ts +5 -4
  227. package/tests/startup-home-repo.test.ts +34 -0
  228. package/tests/support/orchestrator-modules.ts +1 -0
  229. package/tests/task-size-routing-contract.test.ts +69 -9
  230. package/tests/test-scratch-contract.test.ts +102 -0
  231. package/tests/test-strength-contract.test.ts +36 -1
  232. package/tests/tool-batching-contract.test.ts +64 -0
  233. package/tests/verification-proportionality.test.ts +46 -0
  234. package/tests/verifier-admission.test.ts +137 -0
  235. package/tests/verify-agent-contract.test.ts +8 -8
  236. package/tests/vim-editor-adapter.test.ts +41 -15
  237. package/tests/windows-hidden-processes.test.ts +5 -0
  238. package/tests/writer-edit-surface-scope.test.ts +3 -0
  239. package/tests/writer-feature-document-admission.test.ts +156 -0
  240. package/tests/yolo-mode.test.ts +24 -1
  241. package/tests/yolo-temp-scratch.test.ts +71 -0
  242. package/scripts/mirror-odd-routing.mjs +0 -242
  243. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  244. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  245. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  246. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  247. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  248. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  249. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -4,19 +4,23 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
4
4
  import { createRequire } from "node:module";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import test, { after } from "node:test";
7
+ import test, { after, afterEach } from "node:test";
8
8
  import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
9
- import { CURSOR_MARKER, matchesKey, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
9
+ import { CURSOR_MARKER, matchesKey, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
10
10
  import installGentleShell, { backgroundChangeRefreshesSettled, buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
11
11
  import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
12
12
  import { bindSessionProfile, clearSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
13
13
  import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
14
+ import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
15
+
16
+ // Fixture sessions share ids across tests; the inherited-profile freeze is
17
+ // process state keyed by session id (gentle-shell#1064 slice 3b-ii).
18
+ afterEach(() => resetFrozenInheritedProfilesForTesting());
14
19
  import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
15
20
  import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE } from "../lib/shell-changes.ts";
16
21
  import { sidebarPart, sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
17
22
  import { renderTodoCard } from "../lib/shell-todo.ts";
18
23
  import type { ShellBarTheme } from "../lib/shell-bar.ts";
19
- import { stripAnsi } from "../lib/terminal-theme.ts";
20
24
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
21
25
  import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
22
26
  import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
@@ -84,6 +88,7 @@ interface FakeUi {
84
88
  overlay: unknown;
85
89
  overlayView: { render(width: number): string[]; handleInput(data: string): void } | undefined;
86
90
  closeOverlay: (() => void) | undefined;
91
+ customOptions?: unknown[];
87
92
  }
88
93
 
89
94
  interface GitScript {
@@ -171,7 +176,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
171
176
  }
172
177
 
173
178
  function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): string }; hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
174
- const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
179
+ const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined, customOptions: [] };
175
180
  let resolveOverlay: () => void;
176
181
  const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
177
182
  const entries = options.entries ?? [];
@@ -222,8 +227,9 @@ function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): stri
222
227
  // whatever their `done` callback is given (see ExtensionUIContext.custom
223
228
  // in pi-coding-agent), not always null. `closeOverlay` stays a
224
229
  // null-resolving escape hatch for tests that only need to end the wait.
225
- custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
230
+ custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }, options?: unknown) {
226
231
  ui.overlay = factory;
232
+ ui.customOptions?.push(options);
227
233
  return new Promise<T | null>((resolve) => {
228
234
  ui.closeOverlay = () => resolve(null);
229
235
  // An identity bg mock hides focused rows; omit it so overlay navigation
@@ -751,7 +757,9 @@ test("profile polling refreshes both fullscreen surfaces only on change and stop
751
757
  t.mock.method(globalThis, "clearInterval", (handle: { timer: (typeof intervals)[number] }) => { handle.timer.stopped = true; });
752
758
  const { pi, handlers } = fakePi();
753
759
  let resolutions = 0;
754
- gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, {
760
+ // follow mode keeps the live p → P → G resolution, so a pin edit reaches the
761
+ // open session through the poll (gentle-shell#1064 slice 3b-ii).
762
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_PROFILE_FOLLOW: "1" }, {
755
763
  resolveWorktree: () => { resolutions++; return { root: repo, commonDir }; },
756
764
  });
757
765
  const first = fakeContext();
@@ -1800,6 +1808,8 @@ function reverseColumns(row: string): number[] {
1800
1808
  let inverse = false;
1801
1809
  let column = 0;
1802
1810
  const selected: number[] = [];
1811
+ const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
1812
+ row = host.resolveFakeCursors.call({ showHardwareCursor: false }, [row])[0]!;
1803
1813
  const tokens = row.match(/\x1b\[[0-9;]*m|\x1b_pi:c\x07|[^\x1b]/g) ?? [];
1804
1814
  assert.equal(tokens.join(""), row);
1805
1815
  for (const token of tokens) {
@@ -4767,6 +4777,35 @@ test("a registered worktree opens Changes as its own group", async () => {
4767
4777
  } finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
4768
4778
  });
4769
4779
 
4780
+ // /gentle:changes opens like Gentle Agents: a full-terminal overlay whose
4781
+ // native fullscreen wrapper still routes mouse events to the changes view.
4782
+ test("Changes opens full screen and the wrapper keeps mouse hit-testing aligned", async () => {
4783
+ const {pi,handlers,commands}=fakePi([{numstat:"",porcelain:" M file.ts\0?? dirty.ts\0"}]);
4784
+ gentleShell(pi,{});
4785
+ const {ctx,ui,overlayReady}=fakeContext();
4786
+ await fire(handlers,"session_start",ctx);
4787
+ const opened=commands.get("gentle:changes")!.handler("",ctx);
4788
+ await overlayReady;
4789
+ const overlay=ui.overlayView as unknown as { render(width: number): string[]; handleInput(data: string): void; handleMouse(event: unknown): unknown };
4790
+ const at=(type: string, x: number, y: number) => ({ type, button: type === "move" ? "none" : "left", x, y, screenX: x, screenY: y, width: 140, height: 40, shift: false, alt: false, ctrl: false });
4791
+ try {
4792
+ assert.deepEqual(ui.customOptions?.at(-1), { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } });
4793
+ assert.equal(overlay.render(140).length, 40, "the overlay uses every terminal row");
4794
+ overlay.handleInput("\r");
4795
+ overlay.render(140);
4796
+ const { handled, render } = overlay.handleMouse(at("click", 3, 3)) as { handled?: boolean; render?: boolean };
4797
+ assert.deepEqual({ handled, render }, { handled: true, render: true });
4798
+ assert.match(overlay.render(140)[3]!, /▸ M file\.ts/, "a click through the wrapper selects the file");
4799
+ overlay.handleInput("F");
4800
+ assert.match(overlay.render(140)[1]!, /^│ M file\.ts +│$/, "F switches to diff-only inside the fullscreen overlay");
4801
+ } finally { ui.closeOverlay?.(); await opened; }
4802
+ const reopened=commands.get("gentle:changes")!.handler("",ctx);
4803
+ for (let attempt=0; attempt<100 && ui.overlayView===overlay; attempt++) await new Promise((resolve)=>setTimeout(resolve,5));
4804
+ try {
4805
+ assert.match(ui.overlayView!.render(140)[1]!, /main · repo/, "diff-only mode is not persisted across openings");
4806
+ } finally { ui.closeOverlay?.(); await reopened; await fire(handlers,"session_shutdown",ctx); }
4807
+ });
4808
+
4770
4809
  test("new sessions do not inherit another session's worktrees", async () => {
4771
4810
  const h=fakePi(); gentleShell(h.pi,{});
4772
4811
  const first=fakeContext();
@@ -4841,7 +4880,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
4841
4880
  assert.ok(await loadFileDiff(bounded, { path: "tracked.txt", added: 70_001, deleted: 1, status: CHANGE_STATUS.MODIFIED }) === DIFF_TOO_LARGE);
4842
4881
  const opened = h.commands.get("gentle:changes")!.handler("", ctx);
4843
4882
  await overlayReady;
4844
- const trees = (ui.overlayView as unknown as { trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }).trees;
4883
+ const trees = (ui.overlayView as unknown as { children: Array<{ trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }> }).children[0]!.trees;
4845
4884
  assert.deepEqual(trees.map((tree) => [tree.root, tree.model.files.map((file) => file.path)]), [[selected, ["selected-only.txt", "tracked.txt"]]], "inherited routing never lists the foreign repository");
4846
4885
  ui.closeOverlay?.();
4847
4886
  await opened;
@@ -5945,7 +5984,10 @@ test("after a profile switch the panel stops presenting the old profile's provid
5945
5984
  ui.closeOverlay?.();
5946
5985
  await opened;
5947
5986
 
5948
- writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: {} }, "solo");
5987
+ // An open session changes profile only through its own Enter (gentle-shell#1064
5988
+ // slice 3b-ii): a new global default would leave its frozen routing in place.
5989
+ bindSessionProfile("shell-session", "solo", {});
5990
+ t.after(() => resetSessionProfileBindingsForTesting());
5949
5991
  const reopened = commands.get("gentle:usage")!.handler("", ctx);
5950
5992
  await settle();
5951
5993
  assert.equal(ui.overlayView!.render(90).map(stripAnsi).some((line) => line.includes("nan")), false, "a provider recorded under the previous profile's routing is not current scope");
@@ -5953,6 +5995,61 @@ test("after a profile switch the panel stops presenting the old profile's provid
5953
5995
  await reopened;
5954
5996
  });
5955
5997
 
5998
+ test("an open session keeps its frozen profile in the status and Usage scope when the defaults change", async (t) => {
5999
+ const home = mkdtempSync(join(tmpdir(), "shell-frozen-"));
6000
+ const commonDir = mkdtempSync(join(tmpdir(), "shell-frozen-git-"));
6001
+ t.after(() => {
6002
+ rmSync(home, { recursive: true, force: true });
6003
+ rmSync(commonDir, { recursive: true, force: true });
6004
+ });
6005
+ mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
6006
+ writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "team");
6007
+ const { pi, handlers, commands } = fakePi();
6008
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) });
6009
+ const { ctx, ui } = fakeContext({ token: JWT });
6010
+ await fire(handlers, "session_start", ctx);
6011
+ assert.equal(readFrozenInheritedProfile("shell-session")?.profile?.name, "team", "the session froze the global active profile at start");
6012
+ writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "solo");
6013
+ writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" }));
6014
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
6015
+ await settle();
6016
+ assert.ok(openPanelLines(ui).some((line) => /^│ nan ·/.test(line)), "Usage keeps the frozen profile's nan route");
6017
+ ui.closeOverlay?.();
6018
+ await opened;
6019
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, data: unknown) => { dispose(): void };
6020
+ const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
6021
+ const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} });
6022
+ try {
6023
+ const status = (sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail).render(60).join("\n");
6024
+ assert.match(status, /Profile.*team/, "the status keeps the bare global name of the frozen profile");
6025
+ assert.doesNotMatch(status, /solo/);
6026
+ } finally {
6027
+ component.dispose();
6028
+ }
6029
+ });
6030
+
6031
+ test("active profile reader: a bound session shows its frozen profile, and follow shows the live default", (t) => {
6032
+ const root = mkdtempSync(join(tmpdir(), "shell-profile-frozen-"));
6033
+ t.after(() => rmSync(root, { recursive: true, force: true }));
6034
+ const commonDir = join(root, "git");
6035
+ mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
6036
+ writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} } }));
6037
+ const pin = (profile: string) => writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile }));
6038
+ pin("other");
6039
+ const frozen = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
6040
+ frozen.bind(root, () => ({ root, commonDir }), "session-frozen");
6041
+ assert.equal(frozen(), "other (local)");
6042
+ const follow = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_PROFILE_FOLLOW: "1" });
6043
+ follow.bind(root, () => ({ root, commonDir }), "session-follow");
6044
+ assert.equal(follow(), "other (local)");
6045
+ rmSync(join(commonDir, "gentle-ai", "profile-pin.json"));
6046
+ assert.equal(frozen.refresh(), false, "the frozen label does not move");
6047
+ assert.equal(frozen(), "other (local)");
6048
+ assert.equal(follow.refresh(), true);
6049
+ assert.equal(follow(), "team", "follow falls to the global active profile");
6050
+ assert.equal(readFrozenInheritedProfile("session-follow"), undefined);
6051
+ });
6052
+
5956
6053
  function openPanelLines(ui: FakeUi): string[] {
5957
6054
  return ui.overlayView!.render(90).map(stripAnsi);
5958
6055
  }
@@ -6215,19 +6312,21 @@ test("an explicit session binding routes the Usage scope ahead of the pin and th
6215
6312
 
6216
6313
  // S9: the config home each of these reads resolves, per case. The OS home is
6217
6314
  // a temporary folder in every case, so no case can reach the real one.
6218
- // "unset" and "empty" are exactly as before: an empty GENTLE_PI_CONFIG_HOME
6219
- // names the current folder. "bundled" stands in for this package running from
6220
- // a bundled install (testing overlay) whose prefix the launcher passed; the
6221
- // user's own GENTLE_PI_CONFIG_HOME is then not this install's.
6315
+ // "unset" and "empty" both name ~/.pi/gentle-ai: an empty GENTLE_PI_CONFIG_HOME
6316
+ // counts as unset (#2201; it used to name the current folder). "bundled" stands
6317
+ // in for this package running from a bundled install (testing overlay) whose
6318
+ // prefix the launcher passed; the user's own GENTLE_PI_CONFIG_HOME is then not
6319
+ // this install's.
6222
6320
  type ConfigHomeCase = "unset" | "empty" | "bundled";
6223
6321
  function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHomeCase): { env: NodeJS.ProcessEnv; dir: string; userConfig: string } {
6224
6322
  const base = realpathSync(mkdtempSync(join(tmpdir(), `config-home-${kind}-`)));
6225
- const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX };
6323
+ const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX, GENTLE_SHELL_CONFIG_HOME: process.env.GENTLE_SHELL_CONFIG_HOME };
6226
6324
  const cwd = process.cwd();
6227
6325
  process.env.HOME = join(base, "home");
6228
6326
  process.env.USERPROFILE = join(base, "home");
6229
6327
  delete process.env.GENTLE_PI_CONFIG_HOME;
6230
6328
  delete process.env.GENTLE_SHELL_PREFIX;
6329
+ delete process.env.GENTLE_SHELL_CONFIG_HOME;
6231
6330
  t.after(() => {
6232
6331
  process.chdir(cwd);
6233
6332
  setBundledInstallForTesting(undefined);
@@ -6242,7 +6341,7 @@ function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHome
6242
6341
  if (kind === "empty") {
6243
6342
  mkdirSync(join(base, "cwd"));
6244
6343
  process.chdir(join(base, "cwd"));
6245
- return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "cwd"), userConfig };
6344
+ return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "home", ".pi", "gentle-ai"), userConfig };
6246
6345
  }
6247
6346
  const prefix = join(base, "prefix");
6248
6347
  mkdirSync(prefix);
@@ -3,6 +3,7 @@ import test, { after, before } from "node:test";
3
3
  import { convertToLlm, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { Component, TUI } from "@earendil-works/pi-tui";
5
5
  import gentleTodo, { todoCollapseKey, todoEnabled } from "../extensions/gentle-todo.ts";
6
+ import { TODO_PROGRESS_EVENT } from "../lib/shell-todo.ts";
6
7
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
7
8
  import { stripAnsi } from "../lib/terminal-theme.ts";
8
9
  import { sidebarState } from "../lib/shell-sidebar.ts";
@@ -114,12 +115,27 @@ test("long Todo widgets stay bounded, scroll, resize and keep their collapse con
114
115
  assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].title, title.trim());
115
116
  assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].note, note.trim());
116
117
  await shortcuts.get("ctrl+shift+t")!.handler(ctx);
117
- await shortcuts.get("ctrl+shift+down")!.handler(ctx);
118
+ await shortcuts.get("alt+shift+down")!.handler(ctx);
118
119
  assert.notDeepEqual(bottom.render(40), collapsed, "keyboard scrolling is available without mouse");
119
120
  }
120
121
  t.after(() => setCardStyle(CARD_STYLE.NEON));
121
122
  });
122
123
 
124
+ test("Todo shortcuts never take a key Pi binds by default, so startup reports no shortcut conflict", async () => {
125
+ // Pi keeps its default table in dist/core/keybindings.js, next to the package entry.
126
+ const { KEYBINDINGS } = await import(new URL("core/keybindings.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href) as
127
+ { KEYBINDINGS: Record<string, { defaultKeys?: string | string[] }> };
128
+ assert.ok("tui.altScreen.previousPrompt" in KEYBINDINGS, "the table includes Pi's built-in prompt navigation");
129
+ const owners = new Map<string, string[]>();
130
+ for (const [id, binding] of Object.entries(KEYBINDINGS)) {
131
+ for (const key of ([] as string[]).concat(binding.defaultKeys ?? [])) owners.set(key, [...(owners.get(key) ?? []), id]);
132
+ }
133
+ const { pi, shortcuts } = fakePi();
134
+ gentleTodo(pi, {});
135
+ assert.deepEqual([...shortcuts.keys()].sort(), ["alt+shift+down", "alt+shift+up", "ctrl+shift+t"]);
136
+ for (const key of shortcuts.keys()) assert.equal(owners.get(key), undefined, `${key} is a Pi default for ${owners.get(key)?.join(", ")}`);
137
+ });
138
+
123
139
  test("Todo keyboard scrolling targets the visible card after a cached sidebar breakpoint round-trip", async (t) => {
124
140
  const node = Symbol.for("@earendil-works/pi-tui/layout-node");
125
141
  for (const style of [CARD_STYLE.NEON, CARD_STYLE.FLOAT]) {
@@ -146,11 +162,11 @@ test("Todo keyboard scrolling targets the visible card after a cached sidebar br
146
162
  bottom.render(40);
147
163
  tui.terminal.columns = 160;
148
164
  const before = railLines();
149
- await shortcuts.get("ctrl+shift+down")!.handler(ctx);
165
+ await shortcuts.get("alt+shift+down")!.handler(ctx);
150
166
  const after = railLines();
151
167
  assert.notDeepEqual(after, before, "DOWN changes the visible cached rail, not the hidden bottom widget");
152
168
  assert.match(after.join("\n"), /↑↓ 4–/);
153
- await shortcuts.get("ctrl+shift+up")!.handler(ctx);
169
+ await shortcuts.get("alt+shift+up")!.handler(ctx);
154
170
  assert.deepEqual(railLines(), before, "UP restores the expanded viewport");
155
171
  } finally {
156
172
  dispose();
@@ -288,11 +304,25 @@ function toolTurn(toolName = "read") {
288
304
  return { message: { role: "assistant", content: [{ type: "toolCall", id: "call", name: toolName, arguments: {} }] }, toolResults: [] };
289
305
  }
290
306
 
291
- async function contextReminder(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext) {
307
+ type ContextMessage = { role: string; content: unknown; customType?: string; display?: boolean; timestamp?: number };
308
+
309
+ async function contextMessages(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage[] | undefined> {
292
310
  const messages = [{ role: "user", content: "Keep working", timestamp: 1 }];
293
- const result = await fire("context", ctx, { messages }) as { messages: Array<{ role: string; content: unknown }> } | undefined;
311
+ const result = await fire("context", ctx, { messages }) as { messages: ContextMessage[] } | undefined;
294
312
  assert.deepEqual(messages, [{ role: "user", content: "Keep working", timestamp: 1 }], "request-local injection must not mutate the input transcript");
295
- return result;
313
+ if (result === undefined) return undefined;
314
+ assert.deepEqual(result.messages[0], { role: "user", content: "Keep working", timestamp: 1 }, "the transcript stays first; todo messages trail it");
315
+ return result.messages;
316
+ }
317
+
318
+ /** The request-local reminder, if the next model request carries one. */
319
+ async function contextReminder(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
320
+ return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-reminder");
321
+ }
322
+
323
+ /** The request-local current-list message, if the next model request carries one (#1662). */
324
+ async function contextState(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
325
+ return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-state");
296
326
  }
297
327
 
298
328
  async function workTurns(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext, count: number) {
@@ -312,13 +342,14 @@ test("runtime reminder arrives after four tool-use turns, only once, without cha
312
342
  await workTurns(fire, ctx, 3);
313
343
  assert.equal(await contextReminder(fire, ctx), undefined);
314
344
  await workTurns(fire, ctx, 1);
315
- const result = await contextReminder(fire, ctx);
316
- assert.ok(result, "the next model request must carry a reminder within the same run");
317
- assert.equal(result.messages.length, 2);
318
- assert.deepEqual(result.messages[0], { role: "user", content: "Keep working", timestamp: 1 });
319
- assert.deepEqual(result.messages[1], { role: "custom", customType: "gentle-todo-reminder", content: REMINDER, display: false, timestamp: (result.messages[1] as { timestamp?: number }).timestamp });
320
- const converted = convertToLlm(result.messages as Parameters<typeof convertToLlm>[0]);
321
- assert.deepEqual(converted[1], { role: "user", content: [{ type: "text", text: REMINDER }], timestamp: (result.messages[1] as { timestamp?: number }).timestamp }, "Pi forwards the reminder as provider-compatible text");
345
+ const messages = await contextMessages(fire, ctx);
346
+ assert.ok(messages, "the next model request must carry a reminder within the same run");
347
+ assert.equal(messages.length, 3, "transcript, current list, then the reminder");
348
+ assert.equal(messages[1]!.customType, "gentle-todo-state");
349
+ const reminder = messages[2]!;
350
+ assert.deepEqual(reminder, { role: "custom", customType: "gentle-todo-reminder", content: REMINDER, display: false, timestamp: reminder.timestamp });
351
+ const converted = convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
352
+ assert.deepEqual(converted[2], { role: "user", content: [{ type: "text", text: REMINDER }], timestamp: reminder.timestamp }, "Pi forwards the reminder as provider-compatible text");
322
353
  await workTurns(fire, ctx, 4);
323
354
  assert.equal(await contextReminder(fire, ctx), undefined, "no repeated nagging without a todo write");
324
355
  const listed = await tool.execute("list", { action: "list" }, undefined, undefined, ctx);
@@ -418,31 +449,213 @@ function promptEvent(): { systemPrompt: string; systemPromptOptions: { appendSys
418
449
  return { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
419
450
  }
420
451
 
421
- test("every turn carries the open tasks in appendSystemPrompt (never a returned systemPrompt) and the card goes stale after two silent turns", async () => {
452
+ test("the system prompt carries only a byte-stable todo block; the current list travels as a request-local message (#1662)", async () => {
422
453
  const { pi, tools, fire } = fakePi();
423
454
  gentleTodo(pi, {});
424
455
  const { ctx, widget } = fakeContext();
425
456
  await fire("session_start", ctx);
426
- await fire("before_agent_start", ctx, promptEvent());
457
+ const emptyEvent = promptEvent();
458
+ assert.equal(await fire("before_agent_start", ctx, emptyEvent), undefined, "the handler must not return a replacement systemPrompt");
459
+ assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /^## Todo list/);
460
+ assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /mark a task in_progress before starting/);
461
+ assert.equal(await contextState(fire, ctx), undefined, "an empty list sends no state message");
462
+
427
463
  await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
428
464
  await fire("tool_execution_end", ctx, { toolName: "todo" });
429
-
430
465
  const withTasksEvent = promptEvent();
431
- const withTasksResult = await fire("before_agent_start", ctx, withTasksEvent);
432
- assert.equal(withTasksResult, undefined, "the handler must not return a replacement systemPrompt");
433
- assert.match(withTasksEvent.systemPromptOptions.appendSystemPrompt, /^## Todo list/);
434
- assert.match(withTasksEvent.systemPromptOptions.appendSystemPrompt, /1\. \[pending\] Fix the bug/);
466
+ await fire("before_agent_start", ctx, withTasksEvent);
467
+ assert.equal(withTasksEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "writing a list must not change the system prompt");
468
+ assert.doesNotMatch(withTasksEvent.systemPromptOptions.appendSystemPrompt, /Fix the bug/, "task content never enters the system prompt");
469
+ const state = await contextState(fire, ctx);
470
+ assert.ok(state, "open tasks arrive as a trailing request-local message");
471
+ assert.equal(state.role, "custom");
472
+ assert.equal(state.display, false);
473
+ assert.match(String(state.content), /1\. \[pending\] Fix the bug/);
435
474
  assert.doesNotMatch(widget()![0], /stale/);
436
475
 
437
- const staleEvent = promptEvent();
438
- await fire("before_agent_start", ctx, staleEvent);
439
- assert.match(staleEvent.systemPromptOptions.appendSystemPrompt, /stale: 2 turns without an update/);
440
- assert.match(widget()![0], /ctrl\+shift\+t collapse/);
441
- assert.match(widget()![1], /stale · 2 turns/);
476
+ // Silent turns: the UI card goes stale, the system prompt stays frozen (#1367).
477
+ for (const expected of ["stale · 2 turns", "stale · 3 turns"]) {
478
+ const silent = promptEvent();
479
+ await fire("before_agent_start", ctx, silent);
480
+ assert.equal(silent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt);
481
+ assert.match(widget()![1], new RegExp(expected));
482
+ }
442
483
 
484
+ // Status, note, title and ordering changes reach the model through the message only.
443
485
  await tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "on it" }, undefined, undefined, ctx);
444
486
  await fire("tool_execution_end", ctx, { toolName: "todo" });
487
+ const updatedEvent = promptEvent();
488
+ await fire("before_agent_start", ctx, updatedEvent);
489
+ assert.equal(updatedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "a status change must not change the system prompt");
490
+ assert.match(String((await contextState(fire, ctx))!.content), /1\. \[in_progress\] Fix the bug — on it/);
445
491
  assert.doesNotMatch(widget()![0], /stale/);
492
+
493
+ await tools.get("todo")!.execute("c3", { action: "update", id: 1, status: "done" }, undefined, undefined, ctx);
494
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
495
+ assert.equal(await contextState(fire, ctx), undefined, "a finished list sends no state message");
496
+ const finishedEvent = promptEvent();
497
+ await fire("before_agent_start", ctx, finishedEvent);
498
+ assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
499
+ });
500
+
501
+ /**
502
+ * Drives the extension the way Pi's agent loop does (#2195): every model request
503
+ * runs the context handlers over the persisted transcript, and a custom message
504
+ * sent with triggerTurn false while streaming is appended to the transcript at
505
+ * turn_end, after the tool results (agent-session `_flushPendingCustomMessages`).
506
+ */
507
+ function liveSession() {
508
+ const base = fakePi();
509
+ const queued: ContextMessage[] = [];
510
+ const pi = {
511
+ ...base.pi,
512
+ sendMessage(message: { customType: string; content: string; display: boolean }, options: { triggerTurn?: boolean }) {
513
+ assert.equal(options.triggerTurn, false, "todo state must never start a turn");
514
+ queued.push({ role: "custom", customType: message.customType, content: message.content, display: message.display, timestamp: 0 });
515
+ },
516
+ } as unknown as ExtensionAPI;
517
+ gentleTodo(pi, {});
518
+ const { ctx } = fakeContext();
519
+ const transcript: ContextMessage[] = [{ role: "user", content: "Work on it", timestamp: 1 }];
520
+ const requests: ContextMessage[][] = [];
521
+ let call = 0;
522
+ const request = async () => {
523
+ const result = await base.fire("context", ctx, { messages: [...transcript] }) as { messages: ContextMessage[] } | undefined;
524
+ const messages = result?.messages ?? [...transcript];
525
+ requests.push(messages);
526
+ return messages;
527
+ };
528
+ /** One model request whose assistant reply calls `todo` (when params are given) or `read`. */
529
+ const turn = async (todo?: Record<string, unknown>) => {
530
+ await request();
531
+ call += 1;
532
+ const id = `call-${call}`;
533
+ const name = todo ? "todo" : "read";
534
+ const assistant = { role: "assistant", content: [{ type: "toolCall", id, name, arguments: todo ?? {} }], timestamp: 0 };
535
+ transcript.push(assistant);
536
+ if (todo) await base.tools.get("todo")!.execute(id, todo, undefined, undefined, ctx);
537
+ transcript.push({ role: "toolResult", content: [{ type: "text", text: "ok" }], timestamp: 0 });
538
+ await base.fire("turn_end", ctx, { message: assistant, toolResults: [] });
539
+ transcript.push(...queued.splice(0));
540
+ };
541
+ const assertAppendOnly = (from: number) => {
542
+ for (let index = from + 1; index < requests.length; index++) {
543
+ const previous = requests[index - 1]!;
544
+ assert.deepEqual(requests[index]!.slice(0, previous.length), previous, `request ${index} must extend request ${index - 1} byte for byte`);
545
+ }
546
+ };
547
+ const visibleState = (messages: ContextMessage[]) => [...messages].reverse().find((message) => message.customType === "gentle-todo-state")?.content;
548
+ return { ctx, fire: base.fire, transcript, requests, request, turn, assertAppendOnly, visibleState };
549
+ }
550
+
551
+ test("a todo write in a running tool loop reaches the transcript before the next request, which stays append-only (#2195)", async () => {
552
+ const live = liveSession();
553
+ await live.fire("session_start", live.ctx);
554
+ await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }, { title: "Ship it" }] });
555
+ await live.turn();
556
+ await live.turn();
557
+ await live.turn({ action: "update", id: 1, status: "done" });
558
+ await live.turn();
559
+ await live.request();
560
+ assert.match(live.visibleState(live.requests.at(-1)!) as string, /1\. \[done\] Fix the bug\n2\. \[pending\] Ship it/);
561
+ live.assertAppendOnly(0);
562
+ });
563
+
564
+ test("closing every task does not leave a stale open list in the transcript (#2195)", async () => {
565
+ for (const close of [{ action: "update", id: 1, status: "done" }, { action: "update", id: 1, status: "dropped" }, { action: "clear" }]) {
566
+ const live = liveSession();
567
+ await live.fire("session_start", live.ctx);
568
+ await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }] });
569
+ await live.turn(close);
570
+ await live.turn();
571
+ const messages = await live.request();
572
+ assert.equal(live.visibleState(messages), "Current todo list: no open tasks.", `${JSON.stringify(close)}: the model must not see the closed task as still open`);
573
+ live.assertAppendOnly(0);
574
+ }
575
+ });
576
+
577
+ test("an open list missing from the transcript (compaction, branch switch) is persisted again once, then requests stay append-only (#2195)", async () => {
578
+ const live = liveSession();
579
+ await live.fire("session_start", live.ctx);
580
+ await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }] });
581
+ // Compaction summarises the head of the conversation, including the persisted state.
582
+ live.transcript.splice(0, live.transcript.length, { role: "user", content: "Summary of earlier work", timestamp: 2 });
583
+ const healedFrom = live.requests.length;
584
+ await live.turn();
585
+ await live.turn();
586
+ await live.turn();
587
+ await live.request();
588
+ for (const messages of live.requests.slice(healedFrom)) assert.match(String(live.visibleState(messages)), /\[pending\] Fix the bug/);
589
+ assert.equal(live.transcript.filter((message) => message.customType === "gentle-todo-state").length, 1, "the missing state is persisted exactly once");
590
+ live.assertAppendOnly(healedFrom + 1);
591
+ });
592
+
593
+ test("consecutive context requests with an unchanged Todo list preserve prefix stability (#2195)", async () => {
594
+ const { pi, tools, fire } = fakePi();
595
+ gentleTodo(pi, {});
596
+ const { ctx } = fakeContext();
597
+ await fire("session_start", ctx);
598
+ await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
599
+
600
+ // Request 1: first model request
601
+ const userMsg = { role: "user", content: "Work on it", timestamp: 100 };
602
+ const req1 = await fire("context", ctx, { messages: [userMsg] }) as { messages: Array<{ role: string; customType?: string }> };
603
+ assert.ok(req1, "req1 must return context messages");
604
+ assert.ok(req1.messages.some((m) => (m as { customType?: string }).customType === "gentle-todo-state"), "req1 must include gentle-todo-state");
605
+
606
+ // Model executes a tool (read), producing assistant message and tool result
607
+ const assistantMsg = { role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: {} }], timestamp: 200 };
608
+ const toolResultMsg = { role: "toolResult", toolCallId: "read-1", content: [{ type: "text", text: "file content" }], timestamp: 300 };
609
+
610
+ // Request 2: next model request with unchanged todo list.
611
+ // The input messages to context include the transcript with the already-delivered state message
612
+ const inputReq2 = [...req1.messages, assistantMsg, toolResultMsg];
613
+ const req2 = await fire("context", ctx, { messages: inputReq2 }) as { messages: Array<{ role: string; customType?: string }> } | undefined;
614
+
615
+ // req2 must not append a redundant trailing gentle-todo-state that would invalidate prefix caching
616
+ const effectiveReq2 = req2?.messages ?? inputReq2;
617
+ assert.deepEqual(
618
+ effectiveReq2.slice(0, req1.messages.length),
619
+ req1.messages,
620
+ "req1 must be an exact prefix of req2 to preserve prompt caching",
621
+ );
622
+ // No duplicate trailing state message at the end of req2
623
+ const stateMessages = effectiveReq2.filter((m) => (m as { customType?: string }).customType === "gentle-todo-state");
624
+ assert.equal(stateMessages.length, 1, "must not append duplicate gentle-todo-state when unchanged");
625
+ });
626
+
627
+ test("todo write and update persist gentle-todo-state custom message to transcript (#2195)", async () => {
628
+ const sent: Array<{ message: unknown; options: unknown }> = [];
629
+ const base = fakePi();
630
+ const pi = {
631
+ ...base.pi,
632
+ sendMessage(message: unknown, options: unknown) {
633
+ sent.push({ message, options });
634
+ },
635
+ } as unknown as ExtensionAPI;
636
+ gentleTodo(pi, {});
637
+ const { ctx } = fakeContext();
638
+ await base.fire("session_start", ctx);
639
+
640
+ // 1. todo write persists state
641
+ await base.tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Task 1" }] }, undefined, undefined, ctx);
642
+ assert.equal(sent.length, 1);
643
+ assert.deepEqual(sent[0]!.options, { triggerTurn: false });
644
+ const firstMsg = sent[0]!.message as { role: string; customType: string; content: string; display: boolean };
645
+ assert.equal(firstMsg.customType, "gentle-todo-state");
646
+ assert.equal(firstMsg.display, false);
647
+ assert.match(firstMsg.content, /1\. \[pending\] Task 1/);
648
+
649
+ // 2. todo update persists new state
650
+ await base.tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "doing" }, undefined, undefined, ctx);
651
+ assert.equal(sent.length, 2);
652
+ assert.deepEqual(sent[1]!.options, { triggerTurn: false });
653
+ const secondMsg = sent[1]!.message as { content: string };
654
+ assert.match(secondMsg.content, /1\. \[in_progress\] Task 1 — doing/);
655
+
656
+ // 3. todo list does NOT persist extra messages
657
+ await base.tools.get("todo")!.execute("c3", { action: "list" }, undefined, undefined, ctx);
658
+ assert.equal(sent.length, 2, "read actions must not emit state messages");
446
659
  });
447
660
 
448
661
  test("before_agent_start is idempotent: a todo block already present in appendSystemPrompt is not duplicated", async () => {
@@ -555,8 +768,26 @@ test("a list left with only dropped tasks open finishes and clears; a blocked-on
555
768
  await fire("agent_end", ctx);
556
769
  const event = promptEvent();
557
770
  await fire("before_agent_start", ctx, event);
558
- assert.match(event.systemPromptOptions.appendSystemPrompt, /2\. \[blocked\] Deploy — waiting for an admin/);
559
- assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /stale/);
771
+ assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Deploy/);
772
+ assert.match(String((await contextState(fire, ctx))!.content), /2\. \[blocked\] Deploy — waiting for an admin/);
560
773
  }
561
774
  assert.match(widget()![2], /⊘ Deploy · waiting for an admin/);
562
775
  });
776
+
777
+ // Harness roadmap T6: the todo list is the session's progress, so the runtime
778
+ // publishes it for peers instead of asking the model to call orchestrator_session_id.
779
+ test("a todo write emits the session's progress; list and errors emit nothing", async () => {
780
+ const { pi, tools } = fakePi();
781
+ const emitted: Array<{ name: string; data: unknown }> = [];
782
+ Object.assign(pi, { events: { emit: (name: string, data: unknown) => { emitted.push({ name, data }); }, on: () => () => {} } });
783
+ gentleTodo(pi, {});
784
+ const { ctx } = fakeContext();
785
+ const todo = (params: unknown) => tools.get("todo")!.execute("call", params, undefined, undefined, ctx);
786
+ await todo({ action: "write", tasks: [{ title: "Write parser", status: "done" }, { title: "Wire CLI", status: "in_progress" }, { title: "Docs" }] });
787
+ assert.deepEqual(emitted, [{ name: TODO_PROGRESS_EVENT, data: { sessionId: "s1", done: 1, total: 3, current: "Wire CLI" } }]);
788
+ await todo({ action: "list" });
789
+ await todo({ action: "update", id: 99, status: "done" });
790
+ assert.equal(emitted.length, 1, "list and a failed update publish nothing");
791
+ await todo({ action: "update", id: 2, status: "done" });
792
+ assert.deepEqual(emitted.at(-1)?.data, { sessionId: "s1", done: 2, total: 3, current: null });
793
+ });