@oh-my-pi/pi-coding-agent 17.0.8 → 17.1.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 (340) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +1 -1
  3. package/dist/cli.js +5495 -5021
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +30 -1
  11. package/dist/types/config/models-config-schema.d.ts +18 -6
  12. package/dist/types/config/models-config.d.ts +14 -5
  13. package/dist/types/config/provider-globals.d.ts +2 -2
  14. package/dist/types/config/service-tier.d.ts +5 -1
  15. package/dist/types/config/settings-schema.d.ts +350 -50
  16. package/dist/types/config/settings.d.ts +7 -0
  17. package/dist/types/dap/client.d.ts +20 -0
  18. package/dist/types/exec/bash-executor.d.ts +38 -0
  19. package/dist/types/exec/direnv.d.ts +33 -0
  20. package/dist/types/export/html/args.d.ts +15 -0
  21. package/dist/types/export/html/index.d.ts +11 -26
  22. package/dist/types/export/html/web-palette.d.ts +55 -116
  23. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  25. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  26. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  27. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
  28. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  29. package/dist/types/live/controller.d.ts +40 -0
  30. package/dist/types/live/protocol.d.ts +85 -0
  31. package/dist/types/live/protocol.test.d.ts +1 -0
  32. package/dist/types/live/transport.d.ts +48 -0
  33. package/dist/types/live/visualizer.d.ts +33 -0
  34. package/dist/types/lsp/client.d.ts +18 -1
  35. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  36. package/dist/types/mcp/manager.d.ts +6 -2
  37. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  38. package/dist/types/mcp/render.d.ts +2 -2
  39. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  40. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  41. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  42. package/dist/types/mcp/types.d.ts +6 -0
  43. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  44. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  45. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  46. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  47. package/dist/types/modes/components/settings-defs.d.ts +8 -1
  48. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  51. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  52. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  53. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  54. package/dist/types/modes/interactive-mode.d.ts +5 -0
  55. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  56. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  57. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  58. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  59. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  60. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  61. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  62. package/dist/types/modes/theme/theme.d.ts +1 -1
  63. package/dist/types/modes/types.d.ts +3 -1
  64. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  65. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  66. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  67. package/dist/types/registry/agent-registry.d.ts +11 -4
  68. package/dist/types/sdk.d.ts +15 -1
  69. package/dist/types/secrets/index.d.ts +15 -2
  70. package/dist/types/secrets/obfuscator.d.ts +113 -15
  71. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  72. package/dist/types/session/agent-session-events.d.ts +84 -0
  73. package/dist/types/session/agent-session-types.d.ts +314 -0
  74. package/dist/types/session/agent-session.d.ts +120 -750
  75. package/dist/types/session/async-job-delivery.d.ts +28 -0
  76. package/dist/types/session/bash-runner.d.ts +75 -0
  77. package/dist/types/session/blob-store.d.ts +11 -1
  78. package/dist/types/session/blob-store.test.d.ts +1 -0
  79. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  80. package/dist/types/session/eval-runner.d.ts +49 -0
  81. package/dist/types/session/irc-bridge.d.ts +46 -0
  82. package/dist/types/session/messages.d.ts +22 -0
  83. package/dist/types/session/model-controls.d.ts +180 -0
  84. package/dist/types/session/prewalk.d.ts +46 -0
  85. package/dist/types/session/queued-messages.d.ts +22 -0
  86. package/dist/types/session/redis-session-storage.d.ts +1 -0
  87. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  88. package/dist/types/session/role-models.d.ts +15 -0
  89. package/dist/types/session/session-advisors.d.ts +228 -0
  90. package/dist/types/session/session-entries.d.ts +8 -0
  91. package/dist/types/session/session-handoff.d.ts +70 -0
  92. package/dist/types/session/session-history-format.d.ts +6 -1
  93. package/dist/types/session/session-loader.d.ts +6 -4
  94. package/dist/types/session/session-maintenance.d.ts +246 -0
  95. package/dist/types/session/session-manager.d.ts +41 -0
  96. package/dist/types/session/session-memory.d.ts +56 -0
  97. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  98. package/dist/types/session/session-stats.d.ts +48 -0
  99. package/dist/types/session/session-tools.d.ts +143 -0
  100. package/dist/types/session/session-workspace.d.ts +32 -0
  101. package/dist/types/session/stream-guards.d.ts +44 -0
  102. package/dist/types/session/todo-tracker.d.ts +56 -0
  103. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  104. package/dist/types/session/turn-recovery.d.ts +225 -0
  105. package/dist/types/system-prompt.d.ts +2 -0
  106. package/dist/types/task/executor.d.ts +4 -1
  107. package/dist/types/task/output-manager.d.ts +3 -1
  108. package/dist/types/task/types.d.ts +14 -0
  109. package/dist/types/tools/approval.d.ts +1 -0
  110. package/dist/types/tools/browser/launch.d.ts +5 -0
  111. package/dist/types/tools/hub/jobs.d.ts +1 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -4
  113. package/dist/types/tools/image-providers.d.ts +38 -0
  114. package/dist/types/tools/index.d.ts +8 -0
  115. package/dist/types/tools/path-utils.d.ts +8 -0
  116. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  117. package/dist/types/tools/todo.d.ts +10 -4
  118. package/dist/types/tools/xdev.d.ts +5 -1
  119. package/dist/types/tts/streaming-player.d.ts +14 -0
  120. package/dist/types/tts/vocalizer.d.ts +5 -0
  121. package/dist/types/utils/jj.d.ts +29 -0
  122. package/dist/types/utils/lang-from-path.d.ts +1 -0
  123. package/dist/types/utils/title-generator.d.ts +30 -0
  124. package/dist/types/vibe/runtime.d.ts +36 -8
  125. package/dist/types/web/search/index.d.ts +1 -1
  126. package/dist/types/web/search/provider.d.ts +15 -5
  127. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  128. package/dist/types/web/search/types.d.ts +95 -1
  129. package/package.json +13 -12
  130. package/scripts/generate-share-viewer.ts +4 -6
  131. package/scripts/legacy-pi-virtual-module.ts +1 -1
  132. package/src/advisor/__tests__/advisor.test.ts +643 -0
  133. package/src/advisor/runtime.ts +144 -43
  134. package/src/async/job-manager.ts +90 -2
  135. package/src/cli/args.ts +2 -0
  136. package/src/cli/auth-gateway-cli.ts +18 -3
  137. package/src/cli/bench-cli.ts +395 -29
  138. package/src/cli/flag-tables.ts +3 -0
  139. package/src/cli/gc-cli.ts +2 -2
  140. package/src/cli/grievances-cli.ts +2 -2
  141. package/src/cli/usage-cli.ts +11 -2
  142. package/src/collab/host.ts +3 -2
  143. package/src/commands/bench.ts +18 -4
  144. package/src/commands/launch.ts +4 -0
  145. package/src/config/__tests__/model-registry.test.ts +147 -0
  146. package/src/config/model-registry.ts +50 -2
  147. package/src/config/model-resolver.ts +0 -1
  148. package/src/config/models-config-schema.ts +15 -4
  149. package/src/config/provider-globals.ts +9 -9
  150. package/src/config/service-tier.ts +26 -1
  151. package/src/config/settings-schema.ts +250 -54
  152. package/src/config/settings.ts +81 -2
  153. package/src/dap/client.ts +37 -4
  154. package/src/edit/index.ts +49 -12
  155. package/src/eval/py/executor.ts +103 -33
  156. package/src/exec/bash-executor.ts +89 -6
  157. package/src/exec/direnv.ts +145 -0
  158. package/src/export/html/args.ts +20 -0
  159. package/src/export/html/index.ts +45 -47
  160. package/src/export/html/template.css +19 -1
  161. package/src/export/html/template.html +6 -0
  162. package/src/export/html/template.js +21 -0
  163. package/src/export/html/tool-views.generated.js +31 -31
  164. package/src/export/html/web-palette.ts +116 -132
  165. package/src/export/share.ts +249 -49
  166. package/src/extensibility/extensions/loader.ts +29 -1
  167. package/src/extensibility/extensions/runner.ts +6 -0
  168. package/src/extensibility/extensions/types.ts +33 -2
  169. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  170. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  171. package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
  172. package/src/hindsight/state.ts +64 -6
  173. package/src/internal-urls/local-protocol.ts +2 -1
  174. package/src/internal-urls/memory-protocol.ts +2 -2
  175. package/src/internal-urls/registry-helpers.ts +40 -0
  176. package/src/internal-urls/skill-protocol.ts +2 -2
  177. package/src/internal-urls/ssh-protocol.ts +2 -1
  178. package/src/internal-urls/vault-protocol.ts +2 -1
  179. package/src/live/audio-worklet.txt +59 -0
  180. package/src/live/browser-runtime.txt +221 -0
  181. package/src/live/controller.ts +464 -0
  182. package/src/live/prompts/agent-final-message.md +3 -0
  183. package/src/live/prompts/live-instructions.md +23 -0
  184. package/src/live/protocol.test.ts +140 -0
  185. package/src/live/protocol.ts +233 -0
  186. package/src/live/transport.ts +490 -0
  187. package/src/live/visualizer.ts +234 -0
  188. package/src/lsp/client.ts +52 -9
  189. package/src/lsp/clients/biome-client.ts +3 -4
  190. package/src/lsp/index.ts +44 -11
  191. package/src/main.ts +9 -1
  192. package/src/mcp/config-writer.test.ts +43 -0
  193. package/src/mcp/config-writer.ts +109 -82
  194. package/src/mcp/manager.ts +39 -8
  195. package/src/mcp/oauth-discovery.ts +10 -2
  196. package/src/mcp/render.ts +94 -35
  197. package/src/mcp/smithery-auth.test.ts +29 -0
  198. package/src/mcp/smithery-auth.ts +3 -2
  199. package/src/mcp/tool-bridge.ts +107 -11
  200. package/src/mcp/types.ts +7 -0
  201. package/src/memories/index.ts +40 -20
  202. package/src/memory-backend/tool-names.ts +2 -0
  203. package/src/mnemopi/backend.ts +24 -7
  204. package/src/modes/acp/acp-agent.ts +56 -10
  205. package/src/modes/acp/acp-event-mapper.ts +8 -1
  206. package/src/modes/components/agent-hub.ts +27 -10
  207. package/src/modes/components/login-dialog.ts +14 -4
  208. package/src/modes/components/oauth-selector.ts +24 -7
  209. package/src/modes/components/plan-review-overlay.ts +350 -35
  210. package/src/modes/components/settings-defs.ts +28 -3
  211. package/src/modes/components/settings-selector.ts +178 -6
  212. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  213. package/src/modes/components/status-line/component.ts +123 -2
  214. package/src/modes/components/tool-execution.test.ts +63 -2
  215. package/src/modes/components/tool-execution.ts +11 -2
  216. package/src/modes/controllers/command-controller.ts +70 -22
  217. package/src/modes/controllers/event-controller.ts +111 -16
  218. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  219. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  220. package/src/modes/controllers/live-command-controller.ts +178 -0
  221. package/src/modes/controllers/mcp-command-controller.ts +80 -41
  222. package/src/modes/controllers/omfg-controller.ts +44 -40
  223. package/src/modes/controllers/selector-controller.ts +18 -10
  224. package/src/modes/controllers/session-focus-controller.ts +6 -1
  225. package/src/modes/interactive-mode.ts +160 -38
  226. package/src/modes/rpc/rpc-client.ts +94 -3
  227. package/src/modes/rpc/rpc-frame.ts +164 -4
  228. package/src/modes/rpc/rpc-messages.ts +127 -0
  229. package/src/modes/rpc/rpc-mode.ts +79 -7
  230. package/src/modes/rpc/rpc-types.ts +34 -2
  231. package/src/modes/runtime-init.ts +2 -0
  232. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  233. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  234. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  235. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  236. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  237. package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
  238. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  239. package/src/modes/theme/theme.ts +1 -1
  240. package/src/modes/types.ts +3 -1
  241. package/src/modes/utils/context-usage.ts +23 -7
  242. package/src/modes/utils/ui-helpers.ts +10 -5
  243. package/src/plan-mode/plan-files.ts +40 -0
  244. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  245. package/src/prompts/bench/cache-prefix.md +3 -0
  246. package/src/prompts/bench/cache-suffix.md +1 -0
  247. package/src/prompts/goals/guided-goal-system.md +24 -3
  248. package/src/prompts/system/custom-system-prompt.md +1 -1
  249. package/src/prompts/system/project-prompt.md +8 -1
  250. package/src/prompts/system/subagent-async-pending.md +6 -0
  251. package/src/prompts/system/system-prompt.md +1 -1
  252. package/src/prompts/system/workflow-notice.md +48 -40
  253. package/src/prompts/system/xdev-mount-notice.md +4 -0
  254. package/src/prompts/tools/task.md +12 -2
  255. package/src/prompts/tools/todo.md +3 -1
  256. package/src/registry/agent-lifecycle.ts +116 -46
  257. package/src/registry/agent-registry.ts +35 -8
  258. package/src/registry/persisted-agents.ts +26 -2
  259. package/src/sdk.ts +255 -139
  260. package/src/secrets/index.ts +127 -4
  261. package/src/secrets/obfuscator.ts +2328 -105
  262. package/src/session/acp-permission-gate.ts +165 -0
  263. package/src/session/agent-session-error-log.test.ts +1 -1
  264. package/src/session/agent-session-events.ts +66 -0
  265. package/src/session/agent-session-types.ts +334 -0
  266. package/src/session/agent-session.ts +1910 -12213
  267. package/src/session/artifacts.ts +6 -5
  268. package/src/session/async-job-delivery.ts +74 -0
  269. package/src/session/bash-runner.ts +326 -0
  270. package/src/session/blob-store.test.ts +56 -0
  271. package/src/session/blob-store.ts +18 -2
  272. package/src/session/checkpoint-entries.ts +81 -0
  273. package/src/session/eval-runner.ts +212 -0
  274. package/src/session/indexed-session-storage.ts +9 -2
  275. package/src/session/irc-bridge.ts +203 -0
  276. package/src/session/messages.ts +263 -1
  277. package/src/session/model-controls.ts +714 -0
  278. package/src/session/prewalk.ts +252 -0
  279. package/src/session/queued-messages.ts +93 -0
  280. package/src/session/redis-session-storage.ts +51 -11
  281. package/src/session/retry-fallback-chains.ts +455 -0
  282. package/src/session/role-models.ts +85 -0
  283. package/src/session/session-advisors.ts +1679 -0
  284. package/src/session/session-context.test.ts +224 -1
  285. package/src/session/session-context.ts +47 -5
  286. package/src/session/session-entries.ts +8 -0
  287. package/src/session/session-handoff.ts +308 -0
  288. package/src/session/session-history-format.ts +20 -9
  289. package/src/session/session-loader.ts +10 -51
  290. package/src/session/session-maintenance.ts +2983 -0
  291. package/src/session/session-manager.ts +390 -30
  292. package/src/session/session-memory.ts +222 -0
  293. package/src/session/session-provider-boundary.ts +307 -0
  294. package/src/session/session-stats.ts +293 -0
  295. package/src/session/session-tools.ts +943 -0
  296. package/src/session/session-workspace.ts +53 -0
  297. package/src/session/stream-guards.ts +417 -0
  298. package/src/session/todo-tracker.ts +380 -0
  299. package/src/session/ttsr-coordinator.ts +496 -0
  300. package/src/session/turn-recovery.ts +1629 -0
  301. package/src/slash-commands/builtin-registry.ts +92 -11
  302. package/src/slash-commands/helpers/usage-report.ts +25 -4
  303. package/src/system-prompt.ts +41 -17
  304. package/src/task/executor.ts +288 -70
  305. package/src/task/index.ts +148 -111
  306. package/src/task/output-manager.ts +25 -3
  307. package/src/task/persisted-revive.ts +4 -3
  308. package/src/task/structured-subagent.ts +4 -1
  309. package/src/task/types.ts +16 -0
  310. package/src/tools/approval.ts +55 -10
  311. package/src/tools/bash-interactive.ts +26 -0
  312. package/src/tools/bash-skill-urls.ts +28 -2
  313. package/src/tools/bash.ts +339 -123
  314. package/src/tools/browser/launch.ts +9 -1
  315. package/src/tools/browser/tab-supervisor.ts +8 -5
  316. package/src/tools/browser.ts +94 -47
  317. package/src/tools/hub/index.ts +1 -1
  318. package/src/tools/hub/jobs.ts +67 -8
  319. package/src/tools/image-gen.ts +20 -28
  320. package/src/tools/image-providers.ts +50 -0
  321. package/src/tools/index.ts +12 -0
  322. package/src/tools/path-utils.ts +49 -2
  323. package/src/tools/read.ts +156 -88
  324. package/src/tools/renderers.ts +7 -1
  325. package/src/tools/report-tool-issue.ts +79 -28
  326. package/src/tools/todo.ts +101 -11
  327. package/src/tools/vibe.ts +1 -2
  328. package/src/tools/write.ts +51 -1
  329. package/src/tools/xdev.ts +67 -3
  330. package/src/tts/streaming-player.ts +62 -4
  331. package/src/tts/vocalizer.ts +18 -1
  332. package/src/utils/jj.ts +125 -4
  333. package/src/utils/lang-from-path.ts +7 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/vibe/runtime.ts +852 -72
  336. package/src/web/search/index.ts +3 -6
  337. package/src/web/search/provider.ts +31 -16
  338. package/src/web/search/providers/firecrawl.ts +46 -13
  339. package/src/web/search/providers/xai.ts +7 -1
  340. package/src/web/search/types.ts +8 -1
package/src/tools/todo.ts CHANGED
@@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
2
2
  import type { ToolExample } from "@oh-my-pi/pi-ai";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Text } from "@oh-my-pi/pi-tui";
5
- import { prompt } from "@oh-my-pi/pi-utils";
5
+ import { isRecord, prompt } from "@oh-my-pi/pi-utils";
6
6
  import { type } from "arktype";
7
7
  import chalk from "chalk";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -18,13 +18,15 @@ import { formatErrorDetail, formatMoreItems, PREVIEW_LIMITS, pluralize } from ".
18
18
  // Types
19
19
  // =============================================================================
20
20
 
21
- export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
21
+ export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned" | "blocked";
22
22
  /** Operation names accepted by the todo tool and echoed in successful result details. */
23
- export type TodoOperation = "init" | "start" | "done" | "rm" | "drop" | "append" | "view";
23
+ export type TodoOperation = "init" | "start" | "done" | "rm" | "drop" | "block" | "unblock" | "append" | "view";
24
24
 
25
25
  export interface TodoItem {
26
26
  content: string;
27
27
  status: TodoStatus;
28
+ /** When `status === "blocked"`, an optional note on what the task is waiting for. */
29
+ blocker?: string;
28
30
  }
29
31
 
30
32
  export interface TodoPhase {
@@ -32,6 +34,21 @@ export interface TodoPhase {
32
34
  tasks: TodoItem[];
33
35
  }
34
36
 
37
+ /** Whether an unknown value is a persisted todo phase. */
38
+ export function isTodoPhase(value: unknown): value is TodoPhase {
39
+ if (!isRecord(value) || typeof value.name !== "string" || !Array.isArray(value.tasks)) return false;
40
+ return value.tasks.every(
41
+ task =>
42
+ isRecord(task) &&
43
+ typeof task.content === "string" &&
44
+ (task.status === "pending" ||
45
+ task.status === "in_progress" ||
46
+ task.status === "completed" ||
47
+ task.status === "abandoned" ||
48
+ task.status === "blocked"),
49
+ );
50
+ }
51
+
35
52
  export interface TodoCompletionTransition {
36
53
  phase: string;
37
54
  content: string;
@@ -49,7 +66,9 @@ export interface TodoToolDetails {
49
66
  // Schema
50
67
  // =============================================================================
51
68
 
52
- const TodoOp = type('"init" | "start" | "done" | "rm" | "drop" | "append" | "view"').describe("operation to apply");
69
+ const TodoOp = type('"init" | "start" | "done" | "rm" | "drop" | "block" | "unblock" | "append" | "view"').describe(
70
+ "operation to apply",
71
+ );
53
72
 
54
73
  const InitListEntry = type({
55
74
  phase: type("string").describe("phase name"),
@@ -65,6 +84,7 @@ const todoSchema = type({
65
84
  // and both enforce non-empty with op-specific errors. A stray `items: []` on
66
85
  // an op that ignores it (e.g. `view`) must not be a hard schema rejection.
67
86
  "items?": type("string").describe("task content").array().describe("tasks to append"),
87
+ "reason?": type("string").describe("blocker note (block op)"),
68
88
  }).describe("apply a single todo operation");
69
89
 
70
90
  type TodoParams = TodoSchema;
@@ -89,7 +109,9 @@ function findPhaseByName(phases: TodoPhase[], name: string): TodoPhase | undefin
89
109
  }
90
110
 
91
111
  function cloneTask(task: TodoItem): TodoItem {
92
- return { content: task.content, status: task.status };
112
+ return task.blocker !== undefined
113
+ ? { content: task.content, status: task.status, blocker: task.blocker }
114
+ : { content: task.content, status: task.status };
93
115
  }
94
116
 
95
117
  function clonePhases(phases: TodoPhase[]): TodoPhase[] {
@@ -256,7 +278,9 @@ export function selectCollapsedTodos<T extends { status: TodoStatus }>(
256
278
  isMatched: (task: T) => boolean,
257
279
  cap: number,
258
280
  ): CollapsedTodoSelection<T> {
259
- const open = tasks.filter(task => task.status === "pending" || task.status === "in_progress");
281
+ const open = tasks.filter(
282
+ task => task.status === "pending" || task.status === "in_progress" || task.status === "blocked",
283
+ );
260
284
  // No open work: fall back to the closed tasks so a settled phase still
261
285
  // renders (HUD closed-todo persistence). Closed tasks are never active.
262
286
  const base = open.length > 0 ? open : tasks;
@@ -456,6 +480,41 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string
456
480
  }
457
481
  return phases;
458
482
  }
483
+ case "block": {
484
+ if (!entry.task && !entry.phase) {
485
+ errors.push("block requires a task or phase target");
486
+ return phases;
487
+ }
488
+ // Collapse whitespace runs (incl. newlines) to single spaces: a blocker
489
+ // note rides on one Markdown checklist line (as a trailing HTML comment)
490
+ // and one HUD/summary line, so an embedded newline from a multi-line
491
+ // external error or user question would corrupt the round-trip parse and
492
+ // the rendered line. Normalizing here keeps every consumer one-line-safe.
493
+ const reason = entry.reason?.replace(/\s+/g, " ").trim() || undefined;
494
+ for (const task of getTaskTargets(phases, entry, errors)) {
495
+ // Only actionable open work can be blocked: blocking a phase must not
496
+ // reopen completed/abandoned tasks or erase finished progress. An
497
+ // already-blocked task stays eligible so a later block can refine its
498
+ // blocker note (e.g. first blocked without a reason, then with one).
499
+ if (task.status !== "pending" && task.status !== "in_progress" && task.status !== "blocked") continue;
500
+ task.status = "blocked";
501
+ task.blocker = reason;
502
+ }
503
+ return phases;
504
+ }
505
+ case "unblock": {
506
+ if (!entry.task && !entry.phase) {
507
+ errors.push("unblock requires a task or phase target");
508
+ return phases;
509
+ }
510
+ for (const task of getTaskTargets(phases, entry, errors)) {
511
+ if (task.status === "blocked") {
512
+ task.status = "pending";
513
+ task.blocker = undefined;
514
+ }
515
+ }
516
+ return phases;
517
+ }
459
518
  case "rm":
460
519
  return removeTasks(phases, entry, errors);
461
520
  case "append":
@@ -495,6 +554,7 @@ const STATUS_TO_MARKER: Record<TodoStatus, string> = {
495
554
  in_progress: "/",
496
555
  completed: "x",
497
556
  abandoned: "-",
557
+ blocked: "!",
498
558
  };
499
559
 
500
560
  export function resolveTodoMarkdownPath(input: string, cwd: string): string {
@@ -510,7 +570,12 @@ export function phasesToMarkdown(phases: TodoPhase[]): string {
510
570
  if (i > 0) out.push("");
511
571
  out.push(`# ${phases[i].name}`);
512
572
  for (const task of phases[i].tasks) {
513
- out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
573
+ // A blocked task's reason rides in a trailing HTML comment: invisible in
574
+ // rendered markdown, unambiguous to parse back (task content can't
575
+ // contain the comment delimiters), so the note survives `/todo edit` and
576
+ // export/import round-trips.
577
+ const blockerNote = task.status === "blocked" && task.blocker ? ` <!-- blocker: ${task.blocker} -->` : "";
578
+ out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}${blockerNote}`);
514
579
  }
515
580
  }
516
581
  return `${out.join("\n")}\n`;
@@ -525,6 +590,7 @@ const MARKER_TO_STATUS: Record<string, TodoStatus> = {
525
590
  ">": "in_progress",
526
591
  "-": "abandoned",
527
592
  "~": "abandoned",
593
+ "!": "blocked",
528
594
  };
529
595
 
530
596
  /** Parse a Markdown checklist back into todo phases. */
@@ -556,10 +622,18 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
556
622
  const marker = taskMatch[1];
557
623
  const status = MARKER_TO_STATUS[marker];
558
624
  if (!status) {
559
- errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
625
+ errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-], [!])`);
560
626
  continue;
561
627
  }
562
- currentPhase.tasks.push({ content: taskMatch[2].trim(), status });
628
+ // Recover a blocked task's reason from its trailing HTML comment (see
629
+ // phasesToMarkdown), then strip the comment from the visible content.
630
+ const rawContent = taskMatch[2].trim();
631
+ const blockerMatch = /^(.*?)\s*<!--\s*blocker:\s*(.*?)\s*-->$/.exec(rawContent);
632
+ if (status === "blocked" && blockerMatch) {
633
+ currentPhase.tasks.push({ content: blockerMatch[1].trim(), status, blocker: blockerMatch[2].trim() });
634
+ } else {
635
+ currentPhase.tasks.push({ content: rawContent, status });
636
+ }
563
637
  continue;
564
638
  }
565
639
 
@@ -604,6 +678,7 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
604
678
  }
605
679
  // Closed = completed + abandoned, mirroring the per-phase `done` count.
606
680
  const closedAll = tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
681
+ const blockedAll = tasks.filter(task => task.status === "blocked").length;
607
682
  // The active phase is the EARLIEST one still holding open work, so the
608
683
  // in-progress pointer can sit in a phase whose successors already have
609
684
  // completed tasks. Detect that "worked ahead" case to explain the
@@ -613,7 +688,9 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
613
688
  (phase, idx) =>
614
689
  idx > currentIdx && phase.tasks.some(task => task.status === "completed" || task.status === "abandoned"),
615
690
  );
616
- lines.push(`Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open.`);
691
+ lines.push(
692
+ `Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open${blockedAll > 0 ? `, ${blockedAll} blocked` : ""}.`,
693
+ );
617
694
  lines.push(
618
695
  `Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${
619
696
  workedAhead
@@ -625,7 +702,16 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
625
702
  lines.push(` ${phase.name}:`);
626
703
  for (const task of phase.tasks) {
627
704
  const checkbox = task.status === "completed" ? "[X]" : "[ ]";
628
- const tag = task.status === "in_progress" ? " (in progress)" : task.status === "abandoned" ? " (dropped)" : "";
705
+ const tag =
706
+ task.status === "in_progress"
707
+ ? " (in progress)"
708
+ : task.status === "abandoned"
709
+ ? " (dropped)"
710
+ : task.status === "blocked"
711
+ ? task.blocker
712
+ ? ` (blocked: ${task.blocker})`
713
+ : " (blocked)"
714
+ : "";
629
715
  lines.push(` - ${checkbox} ${task.content}${tag}`);
630
716
  }
631
717
  }
@@ -845,6 +931,10 @@ function formatTodoLine(
845
931
  return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
846
932
  case "abandoned":
847
933
  return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`);
934
+ case "blocked": {
935
+ const note = item.blocker ? `blocked: ${item.blocker}` : "blocked";
936
+ return uiTheme.fg("warning", `${prefix}${checkbox.unchecked} ${item.content} (${note})`);
937
+ }
848
938
  default:
849
939
  // A pending todo lit by a live subagent match renders accent, matching
850
940
  // the sticky HUD's convention (#5873).
package/src/tools/vibe.ts CHANGED
@@ -23,7 +23,6 @@ import vibeListDescription from "../prompts/tools/vibe-list.md" with { type: "te
23
23
  import vibeSendDescription from "../prompts/tools/vibe-send.md" with { type: "text" };
24
24
  import vibeSpawnDescription from "../prompts/tools/vibe-spawn.md" with { type: "text" };
25
25
  import vibeWaitDescription from "../prompts/tools/vibe-wait.md" with { type: "text" };
26
- import { MAIN_AGENT_ID } from "../registry/agent-registry";
27
26
  import { oneLineLabel } from "../task/types";
28
27
  import { renderStatusLine } from "../tui";
29
28
  import {
@@ -93,7 +92,7 @@ export interface VibeToolDetails {
93
92
  }
94
93
 
95
94
  function screensOf(session: ToolSession, ids?: string[]): VibeScreenSnapshot[] {
96
- return VibeSessionRegistry.global().screens(session.getAgentId?.() ?? MAIN_AGENT_ID, ids);
95
+ return VibeSessionRegistry.global().screens(session, ids);
97
96
  }
98
97
 
99
98
  function textResult(text: string, details: VibeToolDetails): AgentToolResult<VibeToolDetails> {
@@ -49,7 +49,14 @@ import {
49
49
  } from "./conflict-detect";
50
50
  import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
51
51
  import { type OutputMeta, outputMeta } from "./output-meta";
52
- import { formatPathRelativeToCwd, isInternalUrlPath, pathTargetsSsh, peelWriteUrlSelector } from "./path-utils";
52
+ import {
53
+ formatPathRelativeToCwd,
54
+ isInternalUrlPath,
55
+ pathTargetsSsh,
56
+ peelWriteUrlSelector,
57
+ probeLiteralPathExists,
58
+ splitPathAndSel,
59
+ } from "./path-utils";
53
60
  import { enforcePlanModeWrite, resolvePlanPath, unwrapHashlineHeaderPath } from "./plan-mode-guard";
54
61
  import {
55
62
  cachedRenderedString,
@@ -116,6 +123,43 @@ function assertWriteTargetAddressable(target: string, router: InternalUrlRouter)
116
123
  );
117
124
  }
118
125
 
126
+ /**
127
+ * Fail closed when a local write target looks like a mis-dispatched read.
128
+ *
129
+ * A read-only step that selects `write` instead of `read` passes the full read
130
+ * expression (`src/foo.tsx:1-260:raw`) as the target. Because a literal colon
131
+ * filename is legal on POSIX (issue #4618), that request otherwise resolves to
132
+ * filesystem creation and reports success, leaving a stray zero-byte file the
133
+ * model cannot recover from — the local analogue of the `xd://` near-miss guard
134
+ * ({@link assertWriteTargetAddressable}, issue #6123).
135
+ *
136
+ * Fires only on the high-confidence combination the report identifies: the tail
137
+ * parses as a read-tool selector, the literal target is missing, and no content
138
+ * was supplied. Non-empty content is the escape hatch — it is never blocked, so
139
+ * a deliberate write to a selector-shaped filename still succeeds. An existing
140
+ * literal path or an ambiguous stat (`"unknown"`: EACCES, transient I/O) also
141
+ * passes through so a real file is never shadowed by the guard.
142
+ */
143
+ function readSelectorForEmptyWrite(target: string, content: string): string | undefined {
144
+ if (content.length > 0) return undefined;
145
+ return splitPathAndSel(target).sel;
146
+ }
147
+
148
+ function throwReadSelectorMisfire(target: string, sel: string): never {
149
+ throw new ToolError(
150
+ `write target '${target}' ends with a read-tool selector ':${sel}' and no such file exists — refusing to create a literal file by that name. ` +
151
+ `If you meant to read it, use read({ path: "${target}" }). ` +
152
+ `If you truly intend to create this file, pass its contents in \`content\` (a non-empty write is never blocked).`,
153
+ );
154
+ }
155
+
156
+ async function assertNotReadSelectorMisfire(target: string, content: string, cwd: string): Promise<void> {
157
+ const sel = readSelectorForEmptyWrite(target, content);
158
+ if (sel === undefined) return;
159
+ if ((await probeLiteralPathExists(target, cwd)) !== "missing") return;
160
+ throwReadSelectorMisfire(target, sel);
161
+ }
162
+
119
163
  const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
120
164
  /**
121
165
  * The head of a per-id directive line — `<id>:` / `<id>=` (optionally `#`-prefixed),
@@ -576,6 +620,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
576
620
  throw new ToolError(error instanceof Error ? error.message : String(error));
577
621
  }
578
622
  }
623
+ const writeTarget = `${resolvedArchivePath.archivePath}:${resolvedArchivePath.archiveSubPath}`;
624
+ const sel = readSelectorForEmptyWrite(writeTarget, content);
625
+ if (sel !== undefined && !entries.has(resolvedArchivePath.archiveSubPath)) {
626
+ throwReadSelectorMisfire(writeTarget, sel);
627
+ }
579
628
  entries.set(resolvedArchivePath.archiveSubPath, content);
580
629
 
581
630
  try {
@@ -1159,6 +1208,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
1159
1208
  return sqliteResult;
1160
1209
  }
1161
1210
 
1211
+ await assertNotReadSelectorMisfire(path, cleanContent, this.session.cwd);
1162
1212
  enforcePlanModeWrite(this.session, path, { op: "create" });
1163
1213
  const absolutePath = resolvePlanPath(this.session, path);
1164
1214
  const batchRequest = getLspBatchRequest(context?.toolCall);
package/src/tools/xdev.ts CHANGED
@@ -60,6 +60,9 @@ export const XDEV_KEEP_TOP_LEVEL: Record<string, true> = {
60
60
  */
61
61
  export const XDEV_TRANSPORT_TOOLS: Record<string, true> = { read: true, write: true };
62
62
 
63
+ /** Controls which mounted-device docs are inlined into the system prompt. */
64
+ export type XdevDocsMode = "inline" | "builtins" | "catalog";
65
+
63
66
  /**
64
67
  * Whether an enabled tool is presented under `xd://` (rather than top-level)
65
68
  * while the `xd://` transport is active. Discoverable tools mount unless they
@@ -170,6 +173,28 @@ function toolSummary(inst: Tool): string {
170
173
  return firstLine?.trim() ?? inst.label ?? inst.name;
171
174
  }
172
175
 
176
+ function promptCatalogSummary(inst: Tool, maxLength?: number): string {
177
+ const summary =
178
+ toolSummary(inst)
179
+ .split("\n")
180
+ .find(line => line.trim().length > 0)
181
+ ?.trim() ?? inst.name;
182
+ if (maxLength === undefined || summary.length <= maxLength) return summary;
183
+ return `${summary.slice(0, maxLength).trimEnd()}…`;
184
+ }
185
+
186
+ /** Compile the `tools.xdevInlineDevices` allowlist once per render, dropping
187
+ * non-string entries so malformed user config cannot break prompt builds. */
188
+ function compileInlineGlobs(patterns: readonly string[]): Bun.Glob[] {
189
+ if (!Array.isArray(patterns)) return [];
190
+ const globs: Bun.Glob[] = [];
191
+ for (const pattern of patterns) {
192
+ if (typeof pattern !== "string" || pattern.length === 0) continue;
193
+ globs.push(new Bun.Glob(pattern));
194
+ }
195
+ return globs;
196
+ }
197
+
173
198
  /** Decode the (possibly partially streamed) inner args JSON string into display args. */
174
199
  function decodeInnerArgs(raw: unknown): Record<string, unknown> {
175
200
  if (typeof raw !== "string" || raw.length === 0) return {};
@@ -230,7 +255,13 @@ export class XdevRegistry {
230
255
 
231
256
  /** `{name, summary}` pairs for prompt templates and /tools display. */
232
257
  entries(): Array<{ name: string; summary: string }> {
233
- return this.list().map(tool => ({ name: tool.name, summary: toolSummary(tool) }));
258
+ return this.list().map(tool => ({
259
+ name: tool.name,
260
+ summary: promptCatalogSummary(
261
+ tool,
262
+ this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined,
263
+ ),
264
+ }));
234
265
  }
235
266
 
236
267
  /** `read xd://` listing with one device per line. */
@@ -273,11 +304,16 @@ export class XdevRegistry {
273
304
  * Dynamic mounts embed at most {@link EXTERNAL_DESCRIPTION_CAP} description
274
305
  * chars (schema always intact); `read xd://<tool>` returns the full text.
275
306
  */
276
- docsAll(): string {
307
+ docsAll(mode: XdevDocsMode = "inline", inlinePatterns: readonly string[] = []): string {
277
308
  const sections: string[] = [];
278
309
  const overflow: Tool[] = [];
310
+ const inlineGlobs = compileInlineGlobs(inlinePatterns);
279
311
  let used = 0;
280
312
  for (const tool of this.list()) {
313
+ if (!this.#shouldInline(tool, mode, inlineGlobs)) {
314
+ overflow.push(tool);
315
+ continue;
316
+ }
281
317
  const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
282
318
  const docs = renderDocs(tool, "##", descriptionCap);
283
319
  if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET) {
@@ -291,7 +327,10 @@ export class XdevRegistry {
291
327
  sections.push(
292
328
  [
293
329
  "## Additional devices (docs on demand)",
294
- ...overflow.map(tool => `- ${XD_URL_PREFIX}${tool.name} — ${toolSummary(tool)}`),
330
+ ...overflow.map(tool => {
331
+ const maxLength = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
332
+ return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxLength)}`;
333
+ }),
295
334
  "",
296
335
  `Read ${XD_URL_PREFIX}<tool> for full docs + JSON schema before first use.`,
297
336
  ].join("\n"),
@@ -300,6 +339,31 @@ export class XdevRegistry {
300
339
  return sections.join("\n\n");
301
340
  }
302
341
 
342
+ /** Docs for selected mounted devices under the configured prompt-doc policy. */
343
+ docsFor(names: Iterable<string>, mode: XdevDocsMode, inlinePatterns: readonly string[] = []): string {
344
+ const sections: string[] = [];
345
+ const inlineGlobs = compileInlineGlobs(inlinePatterns);
346
+ let used = 0;
347
+ for (const name of names) {
348
+ const tool = this.get(name);
349
+ if (!tool || !this.#shouldInline(tool, mode, inlineGlobs)) continue;
350
+ const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
351
+ const docs = renderDocs(tool, "##", descriptionCap);
352
+ if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET)
353
+ continue;
354
+ used += docs.length;
355
+ sections.push(docs);
356
+ }
357
+ return sections.join("\n\n");
358
+ }
359
+
360
+ #shouldInline(tool: Tool, mode: XdevDocsMode, inlineGlobs: readonly Bun.Glob[]): boolean {
361
+ return (
362
+ mode !== "catalog" &&
363
+ (mode === "inline" || this.#builtins.has(tool.name) || inlineGlobs.some(glob => glob.match(tool.name)))
364
+ );
365
+ }
366
+
303
367
  #resolve(name: string): Tool {
304
368
  const inst = this.get(name);
305
369
  if (!inst) {
@@ -31,6 +31,15 @@ const DEFAULT_SAMPLE_RATE = 24_000;
31
31
  const LEAD_SECONDS = 0.6;
32
32
  /** Output gain applied while ducked (the user is speaking over the assistant). */
33
33
  export const DUCK_GAIN = 0.25;
34
+ /**
35
+ * Cap on streamed PCM retained for the nonzero-exit replay. Past this the
36
+ * buffer is dropped: the failure being recovered is a short clip that fits the
37
+ * OS pipe buffer before a broken backend dies, while a backend that consumed
38
+ * minutes of realtime-paced audio was playing it — replaying a whole long
39
+ * utterance would duplicate audio, and unbounded retention (~5.8 MB/min at
40
+ * 24 kHz mono f32) would defeat streaming for long input.
41
+ */
42
+ const REPLAY_RETENTION_SECONDS = 60;
34
43
 
35
44
  /** Injection seam for {@link streamingPlayerCommandsFor} — defaults to real PATH/tools lookups. */
36
45
  export interface StreamingPlayerLookup {
@@ -89,6 +98,20 @@ export function streamingPlayerCommandsFor(
89
98
  return commands;
90
99
  }
91
100
 
101
+ /**
102
+ * Test seams for {@link StreamingAudioPlayer}: override backend discovery and
103
+ * the per-file fallback so playback logic can be exercised without a real audio
104
+ * device. Both default to the platform lookup and {@link playAudioFile}.
105
+ */
106
+ export interface StreamingPlayerOptions {
107
+ /** Ordered backend commands for a sample rate; defaults to {@link streamingPlayerCommandsFor}. */
108
+ commandsFor?: (sampleRate: number) => PlayerCommand[];
109
+ /** Per-file fallback playback; defaults to {@link playAudioFile}. */
110
+ playAudio?: (wavPath: string, signal: AbortSignal) => Promise<void>;
111
+ /** Max seconds of streamed PCM retained for the nonzero-exit replay; defaults to {@link REPLAY_RETENTION_SECONDS}. */
112
+ replayRetentionSeconds?: number;
113
+ }
114
+
92
115
  /**
93
116
  * Single-session gapless player. Lifecycle: {@link start} once, {@link write}
94
117
  * chunks in order, then {@link end} to drain or {@link stop} to abort. Not
@@ -111,6 +134,18 @@ export class StreamingAudioPlayer {
111
134
  #abortController = new AbortController();
112
135
  #wake: (() => void) | null = null;
113
136
  #drain: Promise<void> = Promise.resolve();
137
+ readonly #commandsFor: (sampleRate: number) => PlayerCommand[];
138
+ readonly #playAudio: (wavPath: string, signal: AbortSignal) => Promise<void>;
139
+ readonly #replayRetentionSec: number;
140
+ /** Streamed PCM retained for this utterance so a failed backend can be replayed via file playback. */
141
+ #played: Float32Array[] = [];
142
+ #playedSec = 0;
143
+
144
+ constructor(options: StreamingPlayerOptions = {}) {
145
+ this.#commandsFor = options.commandsFor ?? (rate => streamingPlayerCommandsFor(process.platform, rate));
146
+ this.#playAudio = options.playAudio ?? ((wavPath, signal) => playAudioFile(wavPath, { signal }));
147
+ this.#replayRetentionSec = options.replayRetentionSeconds ?? REPLAY_RETENTION_SECONDS;
148
+ }
114
149
 
115
150
  /** Pick a backend and begin draining. Idempotent; the first call's rate wins. */
116
151
  start(sampleRate: number): void {
@@ -146,6 +181,7 @@ export class StreamingAudioPlayer {
146
181
  if (this.#stopped) return;
147
182
  this.#stopped = true;
148
183
  this.#queue.length = 0;
184
+ this.#played.length = 0;
149
185
  this.#abortController.abort();
150
186
  this.#signal();
151
187
  try {
@@ -168,7 +204,7 @@ export class StreamingAudioPlayer {
168
204
  * in-flight chunk.
169
205
  */
170
206
  #spawnStream(): boolean {
171
- this.#candidates ??= streamingPlayerCommandsFor(process.platform, this.#sampleRate);
207
+ this.#candidates ??= this.#commandsFor(this.#sampleRate);
172
208
  for (let command = this.#candidates.shift(); command; command = this.#candidates.shift()) {
173
209
  const { cmd, args } = command;
174
210
  try {
@@ -213,6 +249,12 @@ export class StreamingAudioPlayer {
213
249
  continue;
214
250
  }
215
251
  if (this.#mode === "stream") {
252
+ if (this.#playedSec <= this.#replayRetentionSec) {
253
+ this.#played.push(chunk);
254
+ this.#playedSec += chunk.length / this.#sampleRate;
255
+ // Over the cap: drop retention for the rest of the utterance.
256
+ if (this.#playedSec > this.#replayRetentionSec) this.#played.length = 0;
257
+ }
216
258
  // Pace writes so the player buffers ~LEAD_SECONDS, no more, keeping
217
259
  // ducking and stop responsive instead of locked behind buffered audio.
218
260
  const ahead = this.#writtenSec - (performance.now() - this.#startedAt) / 1000;
@@ -242,11 +284,27 @@ export class StreamingAudioPlayer {
242
284
  try {
243
285
  await this.#sink?.end();
244
286
  } catch {}
245
- if (this.#proc) {
287
+ const proc = this.#proc;
288
+ let exitCode: number | null = null;
289
+ if (proc) {
246
290
  try {
247
- await this.#proc.exited;
291
+ exitCode = await proc.exited;
248
292
  } catch {}
249
293
  }
294
+ // A streaming backend that exits nonzero never opened its audio
295
+ // device (e.g. the bundled ffmpeg built without pulse/alsa output).
296
+ // For a short single-segment clip the pipe write succeeds before
297
+ // that death and #inputClosed is already set, so neither the
298
+ // broken-pipe replay nor the early-exit handler advances backends.
299
+ // Replay the buffered utterance through per-file playback so it
300
+ // still reaches the speakers.
301
+ if (!this.#stopped && proc && exitCode !== 0) {
302
+ this.#mode = "file";
303
+ for (const chunk of this.#played) {
304
+ if (this.#stopped) break;
305
+ await this.#playFile(chunk);
306
+ }
307
+ }
250
308
  }
251
309
  } catch (error) {
252
310
  logger.debug("tts: streaming player drain failed", {
@@ -291,7 +349,7 @@ export class StreamingAudioPlayer {
291
349
  const wavPath = path.join(os.tmpdir(), `omp-speech-${Snowflake.next()}.wav`);
292
350
  try {
293
351
  await fs.writeFile(wavPath, encodeWav(this.#scaled(pcm), this.#sampleRate));
294
- if (!this.#stopped) await playAudioFile(wavPath, { signal: this.#abortController.signal });
352
+ if (!this.#stopped) await this.#playAudio(wavPath, this.#abortController.signal);
295
353
  } catch (error) {
296
354
  logger.debug("tts: file playback failed", {
297
355
  error: error instanceof Error ? error.message : String(error),
@@ -100,6 +100,8 @@ export class Vocalizer {
100
100
  #chain: Promise<void> = Promise.resolve();
101
101
  /** Whether the user is currently speaking; new sessions open ducked. */
102
102
  #ducked = false;
103
+ /** Active scopes that suppress local TTS while another speech surface owns audio. */
104
+ #suspensions = 0;
103
105
  /** Available rewrite slots; blocks queue when exhausted. */
104
106
  #rewriteSlots = MAX_REWRITES_IN_FLIGHT;
105
107
  #slotWaiters: Array<() => void> = [];
@@ -114,6 +116,21 @@ export class Vocalizer {
114
116
  this.#enhancer = enhancer;
115
117
  }
116
118
 
119
+ /**
120
+ * Suppress new vocalization until the returned idempotent release function runs.
121
+ * Existing synthesis and playback stop immediately; nested scopes release independently.
122
+ */
123
+ suspend(): () => void {
124
+ this.#suspensions += 1;
125
+ this.clear();
126
+ let released = false;
127
+ return () => {
128
+ if (released) return;
129
+ released = true;
130
+ this.#suspensions = Math.max(0, this.#suspensions - 1);
131
+ };
132
+ }
133
+
117
134
  /**
118
135
  * Stream a delta of assistant text into the pipeline. No-op when
119
136
  * vocalization is disabled. The synthesis session (worker, player) is only
@@ -123,7 +140,7 @@ export class Vocalizer {
123
140
  * pipeline (enhanced vs mechanical) is latched per utterance.
124
141
  */
125
142
  pushDelta(text: string): void {
126
- if (!settings.get("speech.enabled")) return;
143
+ if (this.#suspensions > 0 || !settings.get("speech.enabled")) return;
127
144
  if (!text) return;
128
145
  if (this.#enhanced || (!this.#speakable && this.#enhancer && settings.get("speech.enhanced"))) {
129
146
  this.#pushEnhanced(text);