gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  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/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { createRequire, syncBuiltinESMExports } from "node:module";
@@ -13,11 +13,11 @@ import test, { after, afterEach, before, mock } from "node:test";
13
13
  import type { TestContext } from "node:test";
14
14
  import { generateUnifiedPatch, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
15
15
  import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
16
- import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
16
+ import { visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
17
17
  import { sidebarState } from "../lib/shell-sidebar.ts";
18
18
  import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection } from "../lib/child-package-injection.ts";
19
19
  import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, agentResultPreview, answerThroughUi, childContextExtensionPaths, completionText, createDefaultSessionTransport, legacySubagentsInstalled, PARENT_WAKE_GRACE_MS, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
20
- import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
20
+ import { ActiveSessionClient, ActiveSessionListener, SessionPresenceError, SessionPresenceRegistry, type PresenceRecord } from "../lib/agents-session-transport.ts";
21
21
  import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
22
22
  import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
23
23
  import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
@@ -25,11 +25,13 @@ import { applyTaskEvent, emptyThread, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TAS
25
25
  import { NativePointerScope } from "../lib/native-pointer-region.ts";
26
26
  import { PresenceCursor, PresencePublisher, listPresence, readActivity, readDiscovery } from "../lib/orchestrator-presence.ts";
27
27
  import { OrchestratorScopeCache } from "../lib/orchestrator-scope.ts";
28
+ import { TODO_PROGRESS_EVENT } from "../lib/shell-todo.ts";
28
29
  import { stripAnsi } from "../lib/terminal-theme.ts";
29
30
  import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
30
- import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts";
31
+ import { AgentRunner, REQUESTED_TOOLS_ENV, type TaskRequest } from "../lib/agents-runner.ts";
31
32
  import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
32
33
  import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
34
+ import { taskHeader } from "../lib/agents-view.ts";
33
35
  import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
34
36
  import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
35
37
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
@@ -241,7 +243,8 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
241
243
  const factory = widgets.get("gentle-agents");
242
244
  return factory ? factory(tui, plainTheme).render(72).map(stripAnsi) : undefined;
243
245
  };
244
- return { ctx, widget, dialogs, overlays, customCompletions, customOptions };
246
+ const widgetComponent = () => widgets.get("gentle-agents")?.(tui, plainTheme) as Component | undefined;
247
+ return { ctx, widget, widgetComponent, dialogs, overlays, customCompletions, customOptions };
245
248
  }
246
249
 
247
250
  for (const scenario of ["background", "task", "append-failure", "overflow", "bytes", "replacement", "task-replacement", "unsafe-id", "no-transport"] as const) {
@@ -578,7 +581,9 @@ test("gentle-agents notifies once, deduplicated, when the RPC activity publisher
578
581
  const runtime = deps();
579
582
  const scheduler = fakeScheduler();
580
583
  runtime.deps.schedule = scheduler.schedule;
581
- runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" };
584
+ // The launch reads the session profile through this env, so it must keep the
585
+ // scratch config home instead of reaching the runner's real one.
586
+ runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
582
587
  gentleAgents(h.pi, {}, runtime.deps);
583
588
  const { ctx } = fakeContext();
584
589
  Object.assign(ctx, { mode: "rpc", hasUI: true });
@@ -1413,6 +1418,152 @@ test("live-only extension instances discover same-profile peers across cwd bound
1413
1418
  await Promise.all([panel.opened, peerPanel.opened]);
1414
1419
  });
1415
1420
 
1421
+ test("rapid child deltas coalesce presence publication to a leading publish plus one trailing publish", async (t) => {
1422
+ const profile = liveProfile("live-coalesce-profile");
1423
+ const h = fakePi();
1424
+ const runtime = deps();
1425
+ const timers = recordTimers(runtime.deps);
1426
+ const { ctx } = fakeContext();
1427
+ Object.assign(ctx.sessionManager, {
1428
+ getSessionId: () => "coalesce-live",
1429
+ getSessionName: () => "coalesce-live",
1430
+ getCwd: () => join(realpathSync(root), "coalesce-live"),
1431
+ });
1432
+ gentleAgents(h.pi, {}, { ...runtime.deps, agentHome: profile });
1433
+ const update = t.mock.method(PresencePublisher.prototype, "update");
1434
+ await h.fire("session_start", ctx);
1435
+ timers.run(400); // expire session_start's own publication window
1436
+ update.mock.resetCalls(); // session_start's own publication is out of scope
1437
+
1438
+ const launch = await h.tools.get("subagent_run")!.execute("coalesce", { agent: "local", task: "Stream", mode: "background" }, undefined, undefined, ctx);
1439
+ await tick();
1440
+ const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
1441
+ assert.ok(taskId);
1442
+ assert.equal(runtime.children.length, 1);
1443
+ // The admission burst publishes its discrete semantic events immediately:
1444
+ // the admission itself plus the child's worktree registration. Per-delta
1445
+ // events below still collapse into one trailing publish.
1446
+ assert.equal(update.mock.callCount(), 2, "the admission burst publishes its semantic events on the leading edge");
1447
+ update.mock.resetCalls();
1448
+
1449
+ for (let delta = 0; delta < 20; delta++) {
1450
+ runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `delta ${delta}` } });
1451
+ }
1452
+ assert.equal(update.mock.callCount(), 0, "per-delta events must not publish while the coalescing window is open");
1453
+
1454
+ timers.run(400);
1455
+ assert.equal(update.mock.callCount(), 1, "exactly one trailing publish after the burst");
1456
+ update.mock.resetCalls();
1457
+
1458
+ // After the window closes, a new burst publishes on the leading edge again.
1459
+ runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "after window" } });
1460
+ assert.equal(update.mock.callCount(), 1, "a call after the window publishes immediately again");
1461
+ });
1462
+
1463
+ test("the trailing publication reuses cached scope facts: a served metadata query sees no Git probes", async (t) => {
1464
+ // gentle-shell#2522 regression: the coalesced trailing publish ran while a
1465
+ // peer metadata query (orchestrator_consult/orchestrator_list) was being
1466
+ // served, and its scope projection cache-missed into a synchronous Git
1467
+ // resolve. A metadata query adds no caller Git probes, so the trailing
1468
+ // publication reuses the snapshot the last event-time publish projected —
1469
+ // never probing, never dropping — until an event-time publish refreshes.
1470
+ const profile = liveProfile("live-coalesce-scope-profile");
1471
+ const h = fakePi();
1472
+ const runtime = deps();
1473
+ const timers = recordTimers(runtime.deps);
1474
+ let releaseActivations: () => void = () => {};
1475
+ // A recorded listener makes publishActivityNow project the discovery scope;
1476
+ // the registry holds the consult's activation read until the test releases it.
1477
+ runtime.deps.sessionTransport = {
1478
+ ...inertSessionTransport,
1479
+ createRegistry: async () => ({ list: async () => [], listActivations: async () => await new Promise<readonly PresenceRecord[]>(resolve => { releaseActivations = () => resolve([]); }) }),
1480
+ createListener: registry => ({ registry, record: { version: 1, sessionId: "coalesce-scope", endpoint: "/fixture/coalesce-scope", createdAt: 1 }, start: async () => {}, close: async () => {} }),
1481
+ };
1482
+ const probes: string[] = [];
1483
+ // Count only scope-projection probes: admission and launch also resolve
1484
+ // worktrees outside the projection, and only the projection can leak into a
1485
+ // peer metadata query.
1486
+ const realProject = OrchestratorScopeCache.prototype.project;
1487
+ let projecting = false;
1488
+ OrchestratorScopeCache.prototype.project = function (...args: Parameters<typeof realProject>) {
1489
+ projecting = true;
1490
+ try { return realProject.apply(this, args); } finally { projecting = false; }
1491
+ };
1492
+ t.after(() => { OrchestratorScopeCache.prototype.project = realProject; });
1493
+ runtime.deps.resolveWorktree = (path, base) => {
1494
+ if (projecting) probes.push(resolve(base, path));
1495
+ return { root: resolve(base, path), commonDir: "/fixture/common" };
1496
+ };
1497
+ const { ctx } = fakeContext();
1498
+ Object.assign(ctx.sessionManager, {
1499
+ getSessionId: () => "coalesce-scope",
1500
+ getSessionName: () => "coalesce-scope",
1501
+ getCwd: () => join(realpathSync(root), "coalesce-scope"),
1502
+ });
1503
+ gentleAgents(h.pi, {}, { ...runtime.deps, agentHome: profile });
1504
+ const update = t.mock.method(PresencePublisher.prototype, "update");
1505
+ await h.fire("session_start", ctx);
1506
+ assert.ok(probes.length > 0, "the semantic startup publish resolves its facts eagerly");
1507
+ // Wait for the asynchronous session transport to bind its record without
1508
+ // publishing; the metadata tool answers "not ready" until it has.
1509
+ for (let i = 0; i < 100; i++) {
1510
+ const result = await h.tools.get("orchestrator_session_id")!.execute("id", {}, undefined, undefined, ctx);
1511
+ if ((result.details?.gentleAgents as { senderSessionId?: string } | undefined)?.senderSessionId) break;
1512
+ assert.ok(i < 99, "session transport binds within bounded ticks");
1513
+ await tick();
1514
+ }
1515
+ timers.run(400); // expire startup's own publication window
1516
+ update.mock.resetCalls();
1517
+ probes.length = 0;
1518
+
1519
+ const launch = await h.tools.get("subagent_run")!.execute("coalesce", { agent: "local", task: "Stream", mode: "background" }, undefined, undefined, ctx);
1520
+ await tick();
1521
+ const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
1522
+ assert.ok(taskId);
1523
+ assert.ok(probes.length > 0, "the semantic admission publish resolves new membership facts eagerly");
1524
+ // Let a settle publish absorb any late membership change before the measured window.
1525
+ timers.run(400);
1526
+ runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "settle" } });
1527
+ await tick();
1528
+ timers.run(400);
1529
+ update.mock.resetCalls();
1530
+ probes.length = 0;
1531
+
1532
+ runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "delta" } });
1533
+ assert.equal(update.mock.callCount(), 1, "the delta publishes on the leading edge");
1534
+ assert.equal(probes.length, 0, "an unchanged membership never re-resolves facts");
1535
+
1536
+ // Membership changes mid-window (the child settles, leaving the coalesced
1537
+ // path to queue the publish) while a peer metadata query starts being
1538
+ // served — the exact collision of the regression.
1539
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
1540
+ runtime.children[0].emit({ type: "agent_settled" });
1541
+ for (let i = 0; i < 100; i++) {
1542
+ const status = await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx);
1543
+ if (/completed/.test(status.content[0]?.text ?? "")) break;
1544
+ assert.ok(i < 99, "child completes within bounded ticks");
1545
+ await tick();
1546
+ }
1547
+ assert.equal(update.mock.callCount(), 1, "the membership change stays behind the trailing edge");
1548
+ assert.equal(probes.length, 0, "queued events must not publish");
1549
+ const pendingConsult = h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "peer123" }, undefined, undefined, ctx);
1550
+ await tick(); // the consult suspends on its activation read, holding the window open
1551
+
1552
+ timers.run(400); // the trailing publish comes due while the query is being served
1553
+ assert.equal(update.mock.callCount(), 2, "the trailing publication still runs, never dropped");
1554
+ assert.equal(probes.length, 0, "a served metadata query adds no caller Git probes");
1555
+
1556
+ releaseActivations();
1557
+ await pendingConsult;
1558
+ assert.equal(probes.length, 0, "the whole served query stayed probe-free");
1559
+
1560
+ // After the window, an event-time publish refreshes the facts the trailing
1561
+ // reuse deferred: the changed membership resolves eagerly again.
1562
+ h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "coalesce-scope" });
1563
+ assert.ok(probes.length > 0, "an event-time publish refreshes the deferred facts");
1564
+ assert.equal(update.mock.callCount(), 3, "the refresh publishes immediately");
1565
+ });
1566
+
1416
1567
  test("manual agents command warns once in RPC mode and stays quiet without UI", async (t) => {
1417
1568
  const local = liveInstance(t, liveProfile("live-non-tui"), "non-tui");
1418
1569
  Object.assign(local.ctx, { mode: "rpc" });
@@ -2266,7 +2417,10 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2266
2417
  assert.equal(captured[index]?.command, "/fixture/pi");
2267
2418
  assert.deepEqual(captured[index]?.args, args);
2268
2419
  assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
2269
- assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
2420
+ const scratch = captured[index]?.options.env.TMPDIR;
2421
+ assert.match(scratch ?? "", /[\\/]ga[A-Za-z0-9]{6}$/, "each child receives its own TMPDIR");
2422
+ const scratchEnv = process.platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
2423
+ assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv });
2270
2424
  assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
2271
2425
  assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
2272
2426
  assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
@@ -2713,11 +2867,16 @@ test("foreign clone rejects aliases, absent UI, decline and changed session befo
2713
2867
  const declined = run.execute("decline", args(foreign), undefined, undefined, ctx);
2714
2868
  await tick(); decision(false);
2715
2869
  await assert.rejects(declined, /interactive/);
2870
+ // gentle-shell#2314: Git prints Windows roots with forward slashes; that spelling
2871
+ // must pass the canonical-root guard and reach consent like the native one.
2872
+ const forwardSlashes = run.execute("forward-slashes", args(foreign.replaceAll("\\", "/")), undefined, undefined, ctx);
2873
+ await tick(); decision(false);
2874
+ await assert.rejects(forwardSlashes, /interactive/);
2716
2875
  const drift = run.execute("drift", args(foreign), undefined, undefined, ctx);
2717
2876
  await tick(); id = "replacement"; decision(true);
2718
2877
  await assert.rejects(drift, /identity changed/);
2719
2878
  assert.equal(runtime.spawned.length, 0);
2720
- assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 2);
2879
+ assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 3);
2721
2880
  assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
2722
2881
  await h.fire("session_shutdown", ctx);
2723
2882
  } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
@@ -3378,6 +3537,167 @@ test("follow mode re-resolves the defaults on every launch without a notice; Ent
3378
3537
  assert.deepEqual(session.driftNotices(), []);
3379
3538
  });
3380
3539
 
3540
+ // gentle-shell#2180: the launch seam records which profile layer supplied the
3541
+ // model, next to the model itself, so every task card shows the winning layer
3542
+ // and stale disk edits below a frozen session become visible.
3543
+ interface LaunchCapture {
3544
+ request: TaskRequest;
3545
+ record: TaskRecord;
3546
+ }
3547
+
3548
+ function captureLaunches(t: TestContext): LaunchCapture[] {
3549
+ const captures: LaunchCapture[] = [];
3550
+ const original = AgentRunner.prototype.run;
3551
+ t.mock.method(AgentRunner.prototype, "run", function (this: AgentRunner, request: TaskRequest) {
3552
+ const record = original.call(this, request);
3553
+ captures.push({ request, record });
3554
+ return record;
3555
+ });
3556
+ return captures;
3557
+ }
3558
+
3559
+ // A non-Git variant of launchSession: the resolver returns nothing, so the
3560
+ // declaration is the only pin layer (as non-Git writer admission reads it).
3561
+ async function launchNonGitSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
3562
+ const harness = deps();
3563
+ harness.deps.resolveWorktree = () => undefined;
3564
+ harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
3565
+ const { pi, tools, fire } = fakePi();
3566
+ gentleAgents(pi, {}, harness.deps);
3567
+ const { ctx } = fakeContext();
3568
+ // Outside Git the declaration is read at the session's own cwd, so the
3569
+ // fixture binds a dedicated session directory instead of leaking the
3570
+ // declaration into the shared fixture cwd other tests launch from.
3571
+ const sessionCwd = join(base.root, "session");
3572
+ mkdirSync(join(sessionCwd, ".pi", "gentle-ai"), { recursive: true });
3573
+ copyFileSync(base.declarationPath, join(sessionCwd, ".pi", "gentle-ai", "profile.json"));
3574
+ ctx.sessionManager.getCwd = () => sessionCwd;
3575
+ await fire("session_start", ctx);
3576
+ return {
3577
+ launch: async () => {
3578
+ await tools.get("subagent_run")!.execute("source", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
3579
+ await tick();
3580
+ },
3581
+ shutdown: async () => {
3582
+ await fire("session_shutdown", ctx);
3583
+ await tick();
3584
+ },
3585
+ };
3586
+ }
3587
+
3588
+ test("a bound session launch records the binding as the model source on the task record", async t => {
3589
+ t.after(() => resetSessionProfileBindingsForTesting());
3590
+ const captures = captureLaunches(t);
3591
+ const base = pinFixture("source-binding");
3592
+ base.writeStore({ pinned: { explore: { model: "openai/alpha", thinking: "minimal" } } });
3593
+ base.writePin("pinned");
3594
+ bindSessionProfile("s1", "bound", { explore: { model: "openai/beta" } });
3595
+ const session = await launchSession(base);
3596
+ t.after(session.shutdown);
3597
+ await session.launch();
3598
+ assert.equal(captures[0]!.request.modelSource, "binding", "the explicit Enter binding wins per the chain precedence");
3599
+ assert.equal(captures[0]!.record.modelSource, "binding", "the record copies the request's layer token");
3600
+ });
3601
+
3602
+ test("a frozen global-active launch records frozen:global as the model source", async t => {
3603
+ const captures = captureLaunches(t);
3604
+ const base = pinFixture("source-freeze-global");
3605
+ base.writeStore(DRIFT_PROFILES, "frontier");
3606
+ const session = await launchSession(base);
3607
+ t.after(session.shutdown);
3608
+ await session.launch();
3609
+ assert.equal(captures[0]!.request.modelSource, "frozen:global");
3610
+ });
3611
+
3612
+ test("a frozen local-pin launch records frozen:local as the model source", async t => {
3613
+ const captures = captureLaunches(t);
3614
+ const base = pinFixture("source-freeze-local");
3615
+ base.writeStore(DRIFT_PROFILES);
3616
+ base.writePin("frontier");
3617
+ const session = await launchSession(base);
3618
+ t.after(session.shutdown);
3619
+ await session.launch();
3620
+ assert.equal(captures[0]!.request.modelSource, "frozen:local");
3621
+ });
3622
+
3623
+ test("a session frozen on a non-Git declaration records frozen:repo as the model source", async t => {
3624
+ const captures = captureLaunches(t);
3625
+ const base = pinFixture("source-freeze-declaration");
3626
+ base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
3627
+ base.writeDeclaration("declared");
3628
+ const session = await launchNonGitSession(base);
3629
+ t.after(session.shutdown);
3630
+ await session.launch();
3631
+ assert.equal(captures[0]!.request.modelSource, "frozen:repo");
3632
+ });
3633
+
3634
+ test("an agent outside every profile records its frontmatter as the model source", async t => {
3635
+ const captures = captureLaunches(t);
3636
+ const base = pinFixture("source-definition");
3637
+ // The pinned profile replaces routing wholesale and does not name explore,
3638
+ // so explore's model comes from its own frontmatter.
3639
+ base.writeStore({ helper: { model: "openai/beta" } });
3640
+ base.writePin("helper");
3641
+ const session = await launchSession(base);
3642
+ t.after(session.shutdown);
3643
+ await session.launch();
3644
+ assert.equal(captures[0]!.request.modelSource, "definition");
3645
+ });
3646
+
3647
+ test("a follow launch through the globally active profile records profile as the model source", async t => {
3648
+ const captures = captureLaunches(t);
3649
+ const base = pinFixture("source-follow-global");
3650
+ base.writeStore(DRIFT_PROFILES, "frontier");
3651
+ const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3652
+ t.after(session.shutdown);
3653
+ await session.launch();
3654
+ assert.equal(captures[0]!.request.modelSource, "profile");
3655
+ });
3656
+
3657
+ test("a follow launch through the local pin records pin:local as the model source", async t => {
3658
+ const captures = captureLaunches(t);
3659
+ const base = pinFixture("source-follow-pin");
3660
+ base.writeStore(DRIFT_PROFILES);
3661
+ base.writePin("frontier");
3662
+ const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3663
+ t.after(session.shutdown);
3664
+ await session.launch();
3665
+ assert.equal(captures[0]!.request.modelSource, "pin:local");
3666
+ });
3667
+
3668
+ test("a follow launch through a committed repository declaration records pin:repo as the model source", async t => {
3669
+ const captures = captureLaunches(t);
3670
+ const base = pinFixture("source-follow-declaration");
3671
+ base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
3672
+ base.writeDeclaration("declared");
3673
+ const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3674
+ t.after(session.shutdown);
3675
+ await session.launch();
3676
+ assert.equal(captures[0]!.request.modelSource, "pin:repo");
3677
+ });
3678
+
3679
+ test("a follow launch through a non-Git repository declaration records declaration as the model source", async t => {
3680
+ const captures = captureLaunches(t);
3681
+ const base = pinFixture("source-follow-nongit");
3682
+ base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
3683
+ base.writeDeclaration("declared");
3684
+ const session = await launchNonGitSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3685
+ t.after(session.shutdown);
3686
+ await session.launch();
3687
+ assert.equal(captures[0]!.request.modelSource, "declaration");
3688
+ });
3689
+
3690
+ test("the agents task card shows the winning layer next to the model, and renders unchanged without one (#2180)", () => {
3691
+ const record = {
3692
+ id: "task", agent: "explore", mode: "task", prompt: "Map the repo", label: "Map", cwd: "/repo", parentSessionId: "s1",
3693
+ status: TASK_STATUS.RUNNING, createdAt: 60_000, startedAt: 60_000, endedAt: null, model: "nan/glm5.3",
3694
+ thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "", lastActivityAt: 60_000,
3695
+ turns: 0, toolCalls: 0, tokens: 0, cost: 0,
3696
+ } as TaskRecord;
3697
+ assert.equal(taskHeader({ ...record, modelSource: "binding" }, 61_000), "explore · running · nan/glm5.3 · binding · 1s");
3698
+ assert.equal(taskHeader(record, 61_000), "explore · running · nan/glm5.3 · 1s", "absent modelSource renders exactly as today");
3699
+ });
3700
+
3381
3701
  test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
3382
3702
  assert.equal(agentsEnabled({}), true);
3383
3703
  assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
@@ -3456,7 +3776,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
3456
3776
  assert.equal(args[args.indexOf("--tools") + 1], "read,grep,subagent_parent_message");
3457
3777
  await tick();
3458
3778
  assert.match(String(harness.children[0].written[1].message), /Map lib\/ and report every module\.\n\n## Context\nFocus on agents-\*\.ts/);
3459
- assert.match(widget()![0], /^╭─ ❀ Agents · 1 active ─+╮$/);
3779
+ assert.match(widget()![0], /^╭─ ❀ Agents ▾ Collapse · 1 active ─+╮$/);
3460
3780
  assert.match(widget()![1], /^│ ◐ explore map lib modules +gpt-5\.6-terra · low · \d+s │$/);
3461
3781
  harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
3462
3782
  harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
@@ -3506,7 +3826,7 @@ test("running subagent_result polls hide only their tool chrome, not the model r
3506
3826
  assert.match(renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(80).join("\n"), /Agent result.*All done\./s, "background completion card remains visible");
3507
3827
  });
3508
3828
 
3509
- test("the Agents widget never registers a sidebar rail part and stays visible even while the fullscreen sidebar owns the host", async () => {
3829
+ test("the Agents card moves to the fullscreen rail like Todos and its header toggles collapse on left click", async () => {
3510
3830
  const { pi, tools, fire } = fakePi();
3511
3831
  const harness = deps();
3512
3832
  gentleAgents(pi, {}, harness.deps);
@@ -3514,22 +3834,54 @@ test("the Agents widget never registers a sidebar rail part and stays visible ev
3514
3834
  // (a host with no .terminal is already a passthrough); this is the host
3515
3835
  // shape the fullscreen layout actually uses.
3516
3836
  const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
3517
- const { ctx, widget } = fakeContext(terminalTui);
3837
+ const { ctx, widgetComponent } = fakeContext(terminalTui);
3518
3838
  await fire("session_start", ctx);
3519
3839
  await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Map lib/", label: "map lib modules", mode: "background" }, undefined, undefined, ctx);
3840
+ await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Map tests/", label: "map test suites", mode: "background" }, undefined, undefined, ctx);
3520
3841
  await tick();
3521
3842
 
3522
- // The factory only runs (and only then could register a rail part) once
3523
- // something actually renders the widget, exactly like the real host.
3524
- assert.match(widget()![1]!, /explore map lib modules/);
3843
+ const component = widgetComponent()!;
3844
+ const bottom = component.render(72).map(stripAnsi);
3845
+ assert.match(bottom[0]!, /Agents ▾ Collapse/, "the header carries the same clickable control as Todos");
3846
+ assert.match(bottom.join("\n"), /explore map lib modules/);
3847
+ assert.match(bottom.join("\n"), /explore map test suites/);
3848
+
3525
3849
  const state = sidebarState(terminalTui as unknown as TUI);
3526
- assert.equal(state.parts.has("agents"), false, "Agents never claims a rail slot; the above-editor widget is its only surface");
3850
+ const rail = state.parts.get("agents");
3851
+ assert.ok(rail, "Agents claims the rail slot, so a wide fullscreen terminal shows it at the right");
3852
+ assert.equal(rail.render(46).filter((line) => /explore/.test(stripAnsi(line))).length, 2, "the rail lists every task");
3853
+ assert.ok(rail.digest?.(), "the rail declares a digest so its clock and counters repaint");
3527
3854
 
3528
- // Simulate the fullscreen sidebar actively owning the host, the same
3529
- // condition sidebarPart used to suppress a registered bottom widget under.
3855
+ // While the fullscreen sidebar owns the host, the bottom copy steps aside.
3530
3856
  state.active = true;
3531
3857
  state.ownsHost = () => true;
3532
- assert.match(widget()![1]!, /explore map lib modules/, "the widget keeps rendering regardless of sidebar ownership");
3858
+ assert.deepEqual(component.render(72), [], "the bottom card paints nothing while the rail shows it");
3859
+ state.active = false;
3860
+
3861
+ const event = (type: "press" | "click", button: "left" | "right", y = 0) => ({
3862
+ type, button, x: 1, y, screenX: 1, screenY: y, width: 72, height: bottom.length, shift: false, alt: false, ctrl: false,
3863
+ });
3864
+ assert.equal(component.handleMouse?.(event("click", "right")), undefined);
3865
+ assert.equal(component.handleMouse?.(event("click", "left", 1)), undefined);
3866
+ assert.match(stripAnsi(component.render(72)[0]!), /Agents ▾ Collapse/, "only a left click on the header toggles");
3867
+ assert.equal(component.handleMouse?.(event("click", "left"))?.handled, true);
3868
+ const collapsed = component.render(72).map(stripAnsi);
3869
+ assert.match(collapsed[0]!, /Agents ▸ Expand/);
3870
+ assert.doesNotMatch(collapsed.join("\n"), /map test suites/, "the collapsed card keeps one row");
3871
+ assert.equal(rail.render(46).filter((line) => /explore/.test(stripAnsi(line))).length, 1, "both surfaces share the collapsed state");
3872
+ });
3873
+
3874
+ test("the Agents rail digest stays stable while no task is running, so an idle card never rebuilds the rail every second", async () => {
3875
+ const { pi, fire } = fakePi();
3876
+ gentleAgents(pi, {}, deps().deps);
3877
+ const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
3878
+ const { ctx, widgetComponent } = fakeContext(terminalTui);
3879
+ await fire("session_start", ctx);
3880
+ widgetComponent();
3881
+ const rail = sidebarState(terminalTui as unknown as TUI).parts.get("agents")!;
3882
+ // The harness clock advances on every read, so three digests span more than a second.
3883
+ const digests = [rail.digest!(), rail.digest!(), rail.digest!()];
3884
+ assert.deepEqual(new Set(digests).size, 1, "the clock only enters the digest while a task runs");
3533
3885
  });
3534
3886
 
3535
3887
  test("background runs return at once; status, result, send_message, cancel, and continue follow the task", async () => {
@@ -4272,6 +4624,7 @@ test("session transport startup failure cleans the constructed Windows-capable t
4272
4624
 
4273
4625
  test("registered task aliases persist independently of human names and curated state", async () => {
4274
4626
  const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
4627
+ const timers = recordTimers(runtime.deps);
4275
4628
  const profile = realpathSync(mkdtempSync(join(root, "aliases-runtime-")));
4276
4629
  runtime.deps.agentHome = profile;
4277
4630
  const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/aliases.sock", createdAt: 1 };
@@ -4288,8 +4641,15 @@ test("registered task aliases persist independently of human names and curated s
4288
4641
  gentleAgents(h.pi, {}, runtime.deps);
4289
4642
  await h.fire("session_start", ctx);
4290
4643
  const tool = h.tools.get("orchestrator_session_id")!;
4291
- const declare = (params: unknown = {}, context = ctx) => tool.execute("aliases", params, undefined, undefined, context);
4292
- const aliases = () => readDiscovery(profile, listPresence(profile).entries[0])?.aliases;
4644
+ const declare = async (params: unknown = {}, context = ctx) => {
4645
+ const result = await tool.execute("aliases", params, undefined, undefined, context);
4646
+ timers.run(400); // the activity publication is coalesced; close the window
4647
+ return result;
4648
+ };
4649
+ const aliases = () => {
4650
+ timers.run(400);
4651
+ return readDiscovery(profile, listPresence(profile).entries[0])?.aliases;
4652
+ };
4293
4653
  const first = await declare({ subject: "Task A", state: { progress: "Starting", work: { area: "Auth" } } });
4294
4654
  const initial = h.entries.at(-1)!;
4295
4655
  assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task A" });
@@ -4339,6 +4699,7 @@ test("registered task aliases persist independently of human names and curated s
4339
4699
  test("registered session identity declares subjects and refreshes canonical idle renames", async (t) => {
4340
4700
  const h = fakePi();
4341
4701
  const runtime = deps();
4702
+ const timers = recordTimers(runtime.deps);
4342
4703
  const profile = realpathSync(mkdtempSync(join(root, "subject-runtime-")));
4343
4704
  runtime.deps.agentHome = profile;
4344
4705
  let ready = false;
@@ -4366,7 +4727,11 @@ test("registered session identity declares subjects and refreshes canonical idle
4366
4727
  await h.fire("session_start", ctx);
4367
4728
  await eventually(() => ready, "subject transport ready");
4368
4729
  const tool = h.tools.get("orchestrator_session_id")!;
4369
- const declare = (subject?: unknown, context = ctx) => tool.execute("id", { subject }, undefined, undefined, context);
4730
+ const declare = async (subject?: unknown, context = ctx) => {
4731
+ const result = await tool.execute("id", { subject }, undefined, undefined, context);
4732
+ timers.run(400); // the activity publication is coalesced; close the window
4733
+ return result;
4734
+ };
4370
4735
  const initialWrites = h.entries.length;
4371
4736
  await assert.rejects(() => tool.execute("invalid-work", { subject: "No effect", state: { work: { topic: "Login" } } }, undefined, undefined, ctx), /invalid/);
4372
4737
  assert.equal(name, "");
@@ -4384,6 +4749,7 @@ test("registered session identity declares subjects and refreshes canonical idle
4384
4749
  { kind: "issue", repository: "github.com/Owner/Repo", id: "12" },
4385
4750
  ] };
4386
4751
  await tool.execute("publish", { state: { objective: "Verify auth", decisions: "Advisory only", work } }, undefined, undefined, ctx);
4752
+ timers.run(400);
4387
4753
  assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
4388
4754
  assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.work, work);
4389
4755
  const published = h.entries.at(-1)!;
@@ -4405,6 +4771,7 @@ test("registered session identity declares subjects and refreshes canonical idle
4405
4771
  assert.equal(readDiscovery(profile, renamed)?.scope?.host.resolvedAt, readDiscovery(profile, before)?.scope?.host.resolvedAt);
4406
4772
  registrations.push({ ...registrations[0], data: { ...registrations[0].data, root: join(cwd, "registered") } });
4407
4773
  h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "s1" });
4774
+ timers.run(400);
4408
4775
  assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.registered.map(fact => fact.root), [cwd, join(cwd, "registered")]);
4409
4776
  await declare("Do not overwrite");
4410
4777
  assert.deepEqual(names, ["Fix auth"]);
@@ -4415,10 +4782,12 @@ test("registered session identity declares subjects and refreshes canonical idle
4415
4782
  await declare("😀".repeat(130));
4416
4783
  assert.equal(Array.from(name).length, 120);
4417
4784
  await tool.execute("withdraw", { state: null }, undefined, undefined, ctx);
4785
+ timers.run(400);
4418
4786
  await h.fire("session_start", ctx, { reason: "reload" });
4419
4787
  await eventually(() => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state === null, "withdrawal reload");
4420
4788
  Object.assign(ctx.sessionManager, { getBranch: () => [published] });
4421
4789
  await h.fire("session_tree", ctx);
4790
+ timers.run(400);
4422
4791
  assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
4423
4792
  const sameId = fakeContext().ctx;
4424
4793
  Object.assign(sameId.sessionManager, { getSessionName: () => name, getBranch: () => [] });
@@ -4427,6 +4796,7 @@ test("registered session identity declares subjects and refreshes canonical idle
4427
4796
  const replacement = fakeContext().ctx;
4428
4797
  Object.assign(replacement.sessionManager, { getSessionId: () => "s2", getSessionName: () => "Replacement" });
4429
4798
  await h.fire("session_start", replacement, { reason: "new" });
4799
+ timers.run(400); // the post-transport-start publication is coalesced; close the window
4430
4800
  heartbeats[0]();
4431
4801
  assert.equal(listPresence(profile).entries.length, 1);
4432
4802
  assert.equal(listPresence(profile).entries[0].label, "Replacement");
@@ -4562,6 +4932,66 @@ test("registered orchestrator_list joins peer metadata without child launches or
4562
4932
  } finally { publisher.dispose(); }
4563
4933
  });
4564
4934
 
4935
+ test("orchestrator_list surfaces the real session-presence failure code", async () => {
4936
+ const h = fakePi();
4937
+ const runtime = deps();
4938
+ const profile = realpathSync(mkdtempSync(join(root, "discovery-failure-")));
4939
+ let ready = false;
4940
+ const registry = { list: async () => [], listActivations: async () => { throw new SessionPresenceError("busy", "presence registry is busy"); } };
4941
+ runtime.deps.agentHome = profile;
4942
+ runtime.deps.sessionTransport = {
4943
+ createRegistry: async () => registry,
4944
+ createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
4945
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
4946
+ };
4947
+ gentleAgents(h.pi, {}, runtime.deps);
4948
+ const { ctx } = fakeContext();
4949
+ try {
4950
+ await h.fire("session_start", ctx);
4951
+ await eventually(() => ready, "discovery transport ready");
4952
+ const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
4953
+ assert.match(result.content[0].text, /session discovery is unavailable \(busy\)/);
4954
+ assert.equal((result.details as { error?: string }).error, "unavailable");
4955
+ assert.equal((result.details as { reason?: string }).reason, "busy");
4956
+ } finally {
4957
+ await h.fire("session_shutdown", ctx);
4958
+ rmSync(profile, { recursive: true, force: true });
4959
+ }
4960
+ });
4961
+
4962
+ test("orchestrator_list labels a live peer that predates discovery publication (#2305)", async () => {
4963
+ const h = fakePi();
4964
+ const runtime = deps();
4965
+ const profile = realpathSync(mkdtempSync(join(root, "discovery-legacy-")));
4966
+ const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 };
4967
+ // A peer running pre-publication code heartbeats its schema-1 header only.
4968
+ const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "gentle-shell", activity: [] });
4969
+ try {
4970
+ let ready = false;
4971
+ const registry = { list: async () => [], listActivations: async () => [peer] };
4972
+ runtime.deps.agentHome = profile;
4973
+ runtime.deps.sessionTransport = {
4974
+ createRegistry: async () => registry,
4975
+ createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
4976
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
4977
+ };
4978
+ gentleAgents(h.pi, {}, runtime.deps);
4979
+ const { ctx } = fakeContext();
4980
+ await h.fire("session_start", ctx);
4981
+ await eventually(() => ready, "discovery transport ready");
4982
+ const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
4983
+ assert.equal(result.content[0].text, "Advertised sessions (reachability is unknown):\n"
4984
+ + "- peer · gentle-shell · context: legacy (this session predates discovery publication; restart it to publish its workspace and repository)"
4985
+ + " · repository: not published · metadata: legacy\n recorded catalog: unknown");
4986
+ // Consultation stays fail-closed: a legacy label is display-only.
4987
+ const consult = await h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "peer" }, undefined, undefined, ctx);
4988
+ assert.equal(JSON.parse(consult.content[0].text).status, "unavailable");
4989
+ assert.equal(runtime.spawned.length, 0);
4990
+ assert.equal(h.sent.length, 0);
4991
+ await h.fire("session_shutdown", ctx);
4992
+ } finally { publisher.dispose(); }
4993
+ });
4994
+
4565
4995
  test("registered reasoning and revocation use live SDK host and published source only", async () => {
4566
4996
  const h = fakePi(), runtime = deps();
4567
4997
  const profile = realpathSync(mkdtempSync(join(root, "reasoning-runtime-")));
@@ -6500,3 +6930,197 @@ test("a human stop of a monitor from /gentle:jobs delivers its pending lines, th
6500
6930
  jobs.cleanup();
6501
6931
  }
6502
6932
  });
6933
+
6934
+ // Harness roadmap T6: todo progress reaches peers without a model turn spent on
6935
+ // orchestrator_session_id, and never replaces the owner's curated fields.
6936
+ test("todo progress publishes merged into the owner's curated state, only for this session", async () => {
6937
+ const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
6938
+ const timers = recordTimers(runtime.deps);
6939
+ const profile = realpathSync(mkdtempSync(join(root, "todo-progress-")));
6940
+ runtime.deps.agentHome = profile;
6941
+ const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress.sock", createdAt: 1 };
6942
+ const registry = { list: async () => [], listActivations: async () => [peer] };
6943
+ runtime.deps.sessionTransport = {
6944
+ createRegistry: async () => registry,
6945
+ createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
6946
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
6947
+ };
6948
+ Object.assign(h.pi, { setSessionName() {} });
6949
+ Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
6950
+ gentleAgents(h.pi, {}, runtime.deps);
6951
+ await h.fire("session_start", ctx);
6952
+ const published = () => {
6953
+ timers.run(400); // the activity publication is coalesced; close the window
6954
+ return readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
6955
+ };
6956
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 2, current: "Write parser" });
6957
+ // PR #2290 review: a todo title is private work text; peers see counts only.
6958
+ assert.deepEqual(published(), { progress: "0/2 done" }, "progress publishes before any curated state, without the todo title");
6959
+ assert.doesNotMatch(JSON.stringify(readDiscovery(profile, listPresence(profile).entries[0])), /Write parser/, "the todo title never reaches peers");
6960
+ await h.tools.get("orchestrator_session_id")!.execute("own", { subject: "Ledger", state: { objective: "Ship the ledger", decisions: "inline" } }, undefined, undefined, ctx);
6961
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 2, current: null });
6962
+ assert.deepEqual(published(), { objective: "Ship the ledger", decisions: "inline", progress: "1/2 done" });
6963
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.aliases?.currentAlias, "Ledger", "the subject survives");
6964
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "other", done: 2, total: 2, current: null });
6965
+ assert.equal(published()?.progress, "1/2 done", "another session's todo is ignored");
6966
+ // R1 review advisory: a cleared list published "0/0 done" as if it were progress.
6967
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 0, current: null });
6968
+ assert.deepEqual(published(), { objective: "Ship the ledger", decisions: "inline" }, "a cleared list removes the progress line and keeps curated fields");
6969
+ // R1 review advisory: a withdrawn state must stay withdrawn until the owner publishes again.
6970
+ await h.tools.get("orchestrator_session_id")!.execute("withdraw", { state: null }, undefined, undefined, ctx);
6971
+ assert.equal(published(), null);
6972
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Docs" });
6973
+ assert.equal(published(), null, "automatic progress does not republish a withdrawn state");
6974
+ await h.tools.get("orchestrator_session_id")!.execute("own", { state: { objective: "Ship v2" } }, undefined, undefined, ctx);
6975
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 2, total: 3, current: null });
6976
+ assert.deepEqual(published(), { objective: "Ship v2", progress: "2/3 done" }, "progress resumes once the owner publishes again");
6977
+ });
6978
+
6979
+ // PR #2290 review: an owner publication without `progress` replaced the automatic
6980
+ // progress line, so peers lost it until the next todo write.
6981
+ test("an owner publication without progress keeps the automatic progress; explicit progress and null still win", async () => {
6982
+ const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
6983
+ const timers = recordTimers(runtime.deps);
6984
+ const profile = realpathSync(mkdtempSync(join(root, "todo-progress-merge-")));
6985
+ runtime.deps.agentHome = profile;
6986
+ const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress-merge.sock", createdAt: 1 };
6987
+ const registry = { list: async () => [], listActivations: async () => [peer] };
6988
+ runtime.deps.sessionTransport = {
6989
+ createRegistry: async () => registry,
6990
+ createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
6991
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
6992
+ };
6993
+ Object.assign(h.pi, { setSessionName() {} });
6994
+ Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
6995
+ gentleAgents(h.pi, {}, runtime.deps);
6996
+ await h.fire("session_start", ctx);
6997
+ const published = () => {
6998
+ timers.run(400); // the activity publication is coalesced; close the window
6999
+ return readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
7000
+ };
7001
+ const own = (state: unknown) => h.tools.get("orchestrator_session_id")!.execute("own", { state }, undefined, undefined, ctx);
7002
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Parser" });
7003
+ await own({ objective: "Ship the ledger" });
7004
+ assert.deepEqual(published(), { objective: "Ship the ledger", progress: "1/3 done" }, "omitted progress keeps the automatic line");
7005
+ await own({ objective: "Ship the ledger", progress: "Reviewing the parser" });
7006
+ assert.deepEqual(published(), { objective: "Ship the ledger", progress: "Reviewing the parser" }, "explicit owner progress wins");
7007
+ await own({ objective: "Ship the ledger" });
7008
+ assert.deepEqual(published(), { objective: "Ship the ledger" }, "owner progress is replaced like any curated field");
7009
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 2, total: 3, current: null });
7010
+ await own(null);
7011
+ assert.equal(published(), null, "null withdraws everything");
7012
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 3, total: 3, current: null });
7013
+ assert.equal(published(), null, "the withdrawal holds against automatic progress");
7014
+ await own({ objective: "Ship v2" });
7015
+ assert.deepEqual(published(), { objective: "Ship v2" }, "a withdrawn progress line is not merged back");
7016
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 4, current: null });
7017
+ const full = "x".repeat(2048);
7018
+ await own({ objective: full });
7019
+ assert.deepEqual(published(), { objective: full }, "a merge over the state budget publishes the owner's state as given");
7020
+ });
7021
+
7022
+ // Issue #2196 (PR #2233 maintainer decision): at launch, a model_profiles key
7023
+ // that names no discovered agent but suggests the launched one is reported
7024
+ // instead of being silently ignored while the child inherits the orchestrator
7025
+ // model. An omitted or matched role keeps today's inheritance untouched.
7026
+ test("subagent_run warns when a model profile key suggests the launched agent", async () => {
7027
+ const child = fakePi();
7028
+ const runtime = deps();
7029
+ gentleAgents(child.pi, {}, runtime.deps);
7030
+ const { ctx, dialogs } = fakeContext();
7031
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
7032
+ writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { "gentle-ai-explore": { model: "openai/alpha" } } })}\n`);
7033
+ await child.fire("session_start", ctx);
7034
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "warn", mode: "background" }, undefined, undefined, ctx);
7035
+ await tick();
7036
+ assert.equal(runtime.children.length, 1, "the launch itself still proceeds");
7037
+ assert.equal(dialogs.filter((message) => /"gentle-ai-explore" did not match any agent, did you mean explore\?/.test(message)).length, 1);
7038
+
7039
+ // A key that matches the agent exactly never warns, and the fixture's own
7040
+ // global effort profile (explore) stays matched.
7041
+ dialogs.length = 0;
7042
+ writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explore: { model: "openai/alpha" } } })}\n`);
7043
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "quiet", mode: "background" }, undefined, undefined, ctx);
7044
+ await tick();
7045
+ assert.equal(dialogs.length, 0, "a matched key is silent");
7046
+ });
7047
+
7048
+ // CodeRabbit follow-up on PR #2397: a key that names another discovered agent
7049
+ // is valid, so launching a different agent whose name it merely resembles
7050
+ // (explore vs gentle-ai-explore) must not label it unmatched.
7051
+ test("subagent_run does not warn about a key that matches another discovered agent", async (t) => {
7052
+ const child = fakePi();
7053
+ const runtime = deps();
7054
+ gentleAgents(child.pi, {}, runtime.deps);
7055
+ const { ctx, dialogs } = fakeContext();
7056
+ mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
7057
+ const projectAgent = join(cwd, ".pi", "agents", "gentle-ai-explore.md");
7058
+ const subagents = join(cwd, ".pi", "subagents.json");
7059
+ t.after(() => rmSync(projectAgent, { force: true }));
7060
+ t.after(() => rmSync(subagents, { force: true }));
7061
+ writeFileSync(projectAgent, "---\ndescription: packaged explore\ntools: [read]\n---\nYou map things.");
7062
+ writeFileSync(subagents, `${JSON.stringify({ model_profiles: { "gentle-ai-explore": { model: "openai/alpha" } } })}\n`);
7063
+ await child.fire("session_start", ctx);
7064
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "quiet", mode: "background" }, undefined, undefined, ctx);
7065
+ await tick();
7066
+ assert.equal(runtime.children.length, 1, "the launch proceeds");
7067
+ assert.equal(dialogs.filter((message) => /did not match any agent/.test(message)).length, 0, "a key naming a discovered agent is never unmatched");
7068
+ });
7069
+
7070
+ // Issue #2400: a model_profiles key that matches no discovered agent and has no
7071
+ // did-you-mean suggestion (a typo such as `explor`) was still silent, because
7072
+ // the launch warning only named keys suggesting the launched agent. The first
7073
+ // launch of a session now lists every such key once; later launches in the
7074
+ // same session stay quiet, and the key is never routed.
7075
+ test("subagent_run warns once per session about model profile keys with no suggestion", async (t) => {
7076
+ const child = fakePi();
7077
+ const runtime = deps();
7078
+ const captures = captureLaunches(t);
7079
+ gentleAgents(child.pi, {}, runtime.deps);
7080
+ const { ctx, dialogs } = fakeContext();
7081
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
7082
+ const subagents = join(cwd, ".pi", "subagents.json");
7083
+ t.after(() => rmSync(subagents, { force: true }));
7084
+ writeFileSync(subagents, `${JSON.stringify({ model_profiles: { explor: { model: "openai/alpha" }, "sdd-desing": { model: "openai/beta" } } })}\n`);
7085
+ await child.fire("session_start", ctx);
7086
+ const unmatched = () => dialogs.filter((message) => /did not match any agent and was ignored/.test(message));
7087
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "first", mode: "background" }, undefined, undefined, ctx);
7088
+ await tick();
7089
+ assert.equal(unmatched().length, 1, "the first launch names the unmatched keys");
7090
+ assert.match(unmatched()[0]!, /"explor" did not match any agent and was ignored\./);
7091
+ assert.match(unmatched()[0]!, /"sdd-desing" did not match any agent and was ignored\./);
7092
+ assert.equal(dialogs.filter((message) => /did you mean/.test(message)).length, 0, "a key without a suggestion never claims one");
7093
+ assert.equal(captures[0]!.request.modelSource === "profile", false, "an unmatched key is never routed");
7094
+
7095
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "second", mode: "background" }, undefined, undefined, ctx);
7096
+ await tick();
7097
+ assert.equal(unmatched().length, 1, "later launches in the same session do not repeat it");
7098
+
7099
+ // A different parent session is told again.
7100
+ const other = fakeContext();
7101
+ other.ctx.sessionManager.getSessionId = () => "s2";
7102
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "other", mode: "background" }, undefined, undefined, other.ctx);
7103
+ await tick();
7104
+ assert.equal(other.dialogs.filter((message) => /"explor" did not match any agent and was ignored\./.test(message)).length, 1);
7105
+ });
7106
+
7107
+ // Cortex QA adversarial follow-up (end-to-end pin for the #2196 fix): a key
7108
+ // that matches a discovered agent must not only stay silent, it must actually
7109
+ // route: the task request carries the profile model and thinking, so the child
7110
+ // launches with --model exactly as the issue's expected behavior describes.
7111
+ test("a matched model_profiles key routes the task request model end to end", async (t) => {
7112
+ const h = fakePi();
7113
+ const runtime = deps();
7114
+ const captures = captureLaunches(t);
7115
+ gentleAgents(h.pi, {}, runtime.deps);
7116
+ const { ctx } = fakeContext();
7117
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
7118
+ writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explore: { model: "openai/alpha", effort: "high" } } })}\n`);
7119
+ await h.fire("session_start", ctx);
7120
+ await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "route", mode: "background" }, undefined, undefined, ctx);
7121
+ await tick();
7122
+ assert.equal(captures.length, 1);
7123
+ assert.deepEqual(captures[0]!.request.model, { provider: "openai", id: "alpha" }, "the matched key routes the configured model into the launch");
7124
+ assert.equal(captures[0]!.request.thinking, "high");
7125
+ assert.equal(captures[0]!.request.modelSource, "profile");
7126
+ });