@oh-my-pi/pi-coding-agent 17.0.9 → 17.1.1

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 (349) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4685 -4614
  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/model-registry.d.ts +3 -1
  10. package/dist/types/config/models-config-schema.d.ts +18 -6
  11. package/dist/types/config/models-config.d.ts +14 -5
  12. package/dist/types/config/provider-globals.d.ts +2 -2
  13. package/dist/types/config/service-tier.d.ts +5 -1
  14. package/dist/types/config/settings-schema.d.ts +374 -47
  15. package/dist/types/config/settings.d.ts +7 -0
  16. package/dist/types/dap/client.d.ts +20 -0
  17. package/dist/types/exec/bash-executor.d.ts +38 -0
  18. package/dist/types/exec/direnv.d.ts +33 -0
  19. package/dist/types/export/html/args.d.ts +15 -0
  20. package/dist/types/export/html/index.d.ts +11 -26
  21. package/dist/types/export/html/web-palette.d.ts +55 -116
  22. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  24. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +3 -0
  25. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
  26. package/dist/types/live/attestation.d.ts +2 -0
  27. package/dist/types/live/controller.d.ts +48 -0
  28. package/dist/types/live/protocol.d.ts +85 -0
  29. package/dist/types/live/protocol.test.d.ts +1 -0
  30. package/dist/types/live/transport.d.ts +35 -0
  31. package/dist/types/live/visualizer.d.ts +30 -0
  32. package/dist/types/lsp/client.d.ts +16 -1
  33. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  34. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  35. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  36. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  37. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  38. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  39. package/dist/types/modes/components/custom-message.d.ts +1 -1
  40. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  41. package/dist/types/modes/components/message-frame.d.ts +8 -4
  42. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  43. package/dist/types/modes/components/session-account-selector.d.ts +11 -0
  44. package/dist/types/modes/components/settings-defs.d.ts +7 -1
  45. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  46. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  48. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  49. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  50. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  51. package/dist/types/modes/interactive-mode.d.ts +6 -0
  52. package/dist/types/modes/theme/theme.d.ts +1 -1
  53. package/dist/types/modes/types.d.ts +4 -1
  54. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  55. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  56. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  57. package/dist/types/registry/agent-registry.d.ts +11 -4
  58. package/dist/types/sdk.d.ts +15 -1
  59. package/dist/types/secrets/index.d.ts +15 -2
  60. package/dist/types/secrets/obfuscator.d.ts +113 -15
  61. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  62. package/dist/types/session/agent-session-events.d.ts +84 -0
  63. package/dist/types/session/agent-session-types.d.ts +321 -0
  64. package/dist/types/session/agent-session.d.ts +135 -753
  65. package/dist/types/session/async-job-delivery.d.ts +28 -0
  66. package/dist/types/session/auth-storage.d.ts +1 -1
  67. package/dist/types/session/bash-runner.d.ts +75 -0
  68. package/dist/types/session/blob-store.d.ts +11 -1
  69. package/dist/types/session/blob-store.test.d.ts +1 -0
  70. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  71. package/dist/types/session/eval-runner.d.ts +51 -0
  72. package/dist/types/session/irc-bridge.d.ts +46 -0
  73. package/dist/types/session/messages.d.ts +24 -0
  74. package/dist/types/session/model-controls.d.ts +180 -0
  75. package/dist/types/session/prewalk.d.ts +46 -0
  76. package/dist/types/session/queued-messages.d.ts +22 -0
  77. package/dist/types/session/redis-session-storage.d.ts +1 -0
  78. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  79. package/dist/types/session/role-models.d.ts +15 -0
  80. package/dist/types/session/session-advisors.d.ts +228 -0
  81. package/dist/types/session/session-entries.d.ts +8 -0
  82. package/dist/types/session/session-handoff.d.ts +70 -0
  83. package/dist/types/session/session-history-format.d.ts +6 -1
  84. package/dist/types/session/session-maintenance.d.ts +246 -0
  85. package/dist/types/session/session-manager.d.ts +41 -0
  86. package/dist/types/session/session-memory.d.ts +56 -0
  87. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  88. package/dist/types/session/session-stats.d.ts +48 -0
  89. package/dist/types/session/session-tools.d.ts +158 -0
  90. package/dist/types/session/session-workspace.d.ts +32 -0
  91. package/dist/types/session/stream-guards.d.ts +44 -0
  92. package/dist/types/session/streaming-output.d.ts +8 -0
  93. package/dist/types/session/todo-tracker.d.ts +56 -0
  94. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  95. package/dist/types/session/turn-recovery.d.ts +225 -0
  96. package/dist/types/slash-commands/helpers/session-pin.d.ts +9 -0
  97. package/dist/types/stt/index.d.ts +0 -2
  98. package/dist/types/stt/stt-controller.d.ts +7 -0
  99. package/dist/types/system-prompt.d.ts +2 -0
  100. package/dist/types/task/executor.d.ts +4 -1
  101. package/dist/types/task/output-manager.d.ts +3 -1
  102. package/dist/types/tiny/title-client.d.ts +10 -0
  103. package/dist/types/tools/approval.d.ts +1 -0
  104. package/dist/types/tools/browser/launch.d.ts +5 -0
  105. package/dist/types/tools/builtin-names.d.ts +1 -1
  106. package/dist/types/tools/computer/protocol.d.ts +43 -0
  107. package/dist/types/tools/computer/supervisor.d.ts +32 -0
  108. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  109. package/dist/types/tools/computer/worker.d.ts +15 -0
  110. package/dist/types/tools/computer-renderer.d.ts +22 -0
  111. package/dist/types/tools/computer.d.ts +71 -0
  112. package/dist/types/tools/context.d.ts +2 -0
  113. package/dist/types/tools/default-renderer.d.ts +21 -0
  114. package/dist/types/tools/image-gen.d.ts +4 -4
  115. package/dist/types/tools/image-providers.d.ts +38 -0
  116. package/dist/types/tools/index.d.ts +7 -0
  117. package/dist/types/tools/path-utils.d.ts +8 -0
  118. package/dist/types/tools/todo.d.ts +10 -4
  119. package/dist/types/tools/xdev.d.ts +5 -1
  120. package/dist/types/tts/streaming-player.d.ts +10 -29
  121. package/dist/types/tts/vocalizer.d.ts +5 -0
  122. package/dist/types/utils/jj.d.ts +29 -0
  123. package/dist/types/utils/lang-from-path.d.ts +1 -0
  124. package/dist/types/utils/title-generator.d.ts +30 -0
  125. package/dist/types/utils/tools-manager.d.ts +1 -2
  126. package/dist/types/vibe/runtime.d.ts +36 -8
  127. package/dist/types/web/search/index.d.ts +1 -1
  128. package/dist/types/web/search/provider.d.ts +15 -5
  129. package/dist/types/web/search/types.d.ts +94 -0
  130. package/package.json +12 -12
  131. package/scripts/generate-share-viewer.ts +4 -6
  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 +3 -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/setup-cli.ts +2 -14
  141. package/src/cli/usage-cli.ts +10 -1
  142. package/src/cli.ts +8 -0
  143. package/src/collab/host.ts +3 -2
  144. package/src/commands/bench.ts +18 -4
  145. package/src/commands/launch.ts +4 -0
  146. package/src/config/model-registry.ts +16 -2
  147. package/src/config/models-config-schema.ts +15 -4
  148. package/src/config/provider-globals.ts +9 -9
  149. package/src/config/service-tier.ts +26 -1
  150. package/src/config/settings-schema.ts +270 -51
  151. package/src/config/settings.ts +81 -2
  152. package/src/dap/client.ts +37 -4
  153. package/src/edit/index.ts +49 -12
  154. package/src/eval/executor-base.ts +1 -0
  155. package/src/eval/js/executor.ts +2 -0
  156. package/src/eval/py/executor.ts +103 -33
  157. package/src/exec/bash-executor.ts +90 -6
  158. package/src/exec/direnv.ts +145 -0
  159. package/src/export/html/args.ts +20 -0
  160. package/src/export/html/index.ts +45 -47
  161. package/src/export/html/template.css +19 -1
  162. package/src/export/html/template.html +6 -0
  163. package/src/export/html/template.js +21 -0
  164. package/src/export/html/tool-views.generated.js +31 -31
  165. package/src/export/html/web-palette.ts +116 -132
  166. package/src/export/share.ts +249 -49
  167. package/src/extensibility/extensions/loader.ts +29 -1
  168. package/src/extensibility/extensions/runner.ts +6 -0
  169. package/src/extensibility/extensions/types.ts +33 -2
  170. package/src/extensibility/extensions/wrapper.ts +68 -12
  171. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -1
  172. package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
  173. package/src/hindsight/state.ts +64 -6
  174. package/src/internal-urls/local-protocol.ts +2 -1
  175. package/src/internal-urls/memory-protocol.ts +2 -2
  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/attestation.ts +91 -0
  180. package/src/live/controller.ts +517 -0
  181. package/src/live/prompts/agent-final-message.md +3 -0
  182. package/src/live/prompts/live-instructions.md +23 -0
  183. package/src/live/protocol.test.ts +140 -0
  184. package/src/live/protocol.ts +233 -0
  185. package/src/live/transport.ts +422 -0
  186. package/src/live/visualizer.ts +214 -0
  187. package/src/lsp/client.ts +29 -9
  188. package/src/lsp/index.ts +19 -4
  189. package/src/main.ts +8 -1
  190. package/src/mcp/config-writer.test.ts +43 -0
  191. package/src/mcp/config-writer.ts +109 -82
  192. package/src/mcp/oauth-discovery.ts +10 -2
  193. package/src/mcp/smithery-auth.test.ts +29 -0
  194. package/src/mcp/smithery-auth.ts +3 -2
  195. package/src/memories/index.ts +40 -20
  196. package/src/memory-backend/tool-names.ts +2 -0
  197. package/src/mnemopi/backend.ts +24 -7
  198. package/src/modes/acp/acp-agent.ts +56 -10
  199. package/src/modes/acp/acp-event-mapper.ts +8 -1
  200. package/src/modes/components/agent-hub.ts +1 -1
  201. package/src/modes/components/assistant-message.ts +7 -2
  202. package/src/modes/components/custom-message.ts +4 -1
  203. package/src/modes/components/login-dialog.ts +14 -4
  204. package/src/modes/components/message-frame.ts +14 -8
  205. package/src/modes/components/plan-review-overlay.ts +350 -35
  206. package/src/modes/components/session-account-selector.ts +62 -0
  207. package/src/modes/components/settings-defs.ts +23 -1
  208. package/src/modes/components/settings-selector.ts +169 -1
  209. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  210. package/src/modes/components/status-line/component.ts +123 -2
  211. package/src/modes/components/tool-execution.ts +28 -112
  212. package/src/modes/controllers/command-controller.ts +56 -15
  213. package/src/modes/controllers/event-controller.ts +111 -16
  214. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  215. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  216. package/src/modes/controllers/input-controller.ts +47 -39
  217. package/src/modes/controllers/live-command-controller.ts +255 -0
  218. package/src/modes/controllers/mcp-command-controller.ts +10 -1
  219. package/src/modes/controllers/omfg-controller.ts +44 -40
  220. package/src/modes/controllers/selector-controller.ts +80 -10
  221. package/src/modes/controllers/session-focus-controller.ts +6 -1
  222. package/src/modes/interactive-mode.ts +176 -38
  223. package/src/modes/runtime-init.ts +2 -0
  224. package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
  225. package/src/modes/theme/theme.ts +1 -1
  226. package/src/modes/types.ts +4 -1
  227. package/src/modes/utils/context-usage.ts +23 -7
  228. package/src/modes/utils/ui-helpers.ts +10 -5
  229. package/src/plan-mode/plan-files.ts +40 -0
  230. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  231. package/src/prompts/bench/cache-prefix.md +3 -0
  232. package/src/prompts/bench/cache-suffix.md +1 -0
  233. package/src/prompts/system/computer-safety.md +14 -0
  234. package/src/prompts/system/custom-system-prompt.md +1 -1
  235. package/src/prompts/system/project-prompt.md +8 -1
  236. package/src/prompts/system/subagent-async-pending.md +6 -0
  237. package/src/prompts/system/system-prompt.md +1 -1
  238. package/src/prompts/system/workflow-notice.md +48 -40
  239. package/src/prompts/system/xdev-mount-notice.md +4 -0
  240. package/src/prompts/tools/computer.md +26 -0
  241. package/src/prompts/tools/todo.md +3 -1
  242. package/src/registry/agent-lifecycle.ts +116 -46
  243. package/src/registry/agent-registry.ts +35 -8
  244. package/src/registry/persisted-agents.ts +26 -2
  245. package/src/sdk.ts +215 -136
  246. package/src/secrets/index.ts +127 -4
  247. package/src/secrets/obfuscator.ts +2328 -105
  248. package/src/session/acp-permission-gate.ts +165 -0
  249. package/src/session/agent-session-error-log.test.ts +1 -1
  250. package/src/session/agent-session-events.ts +66 -0
  251. package/src/session/agent-session-types.ts +343 -0
  252. package/src/session/agent-session.ts +1940 -12218
  253. package/src/session/artifacts.ts +6 -5
  254. package/src/session/async-job-delivery.ts +74 -0
  255. package/src/session/auth-storage.ts +1 -0
  256. package/src/session/bash-runner.ts +326 -0
  257. package/src/session/blob-store.test.ts +56 -0
  258. package/src/session/blob-store.ts +18 -2
  259. package/src/session/checkpoint-entries.ts +81 -0
  260. package/src/session/eval-runner.ts +217 -0
  261. package/src/session/indexed-session-storage.ts +9 -2
  262. package/src/session/irc-bridge.ts +203 -0
  263. package/src/session/messages.ts +266 -1
  264. package/src/session/model-controls.ts +714 -0
  265. package/src/session/prewalk.ts +252 -0
  266. package/src/session/queued-messages.ts +93 -0
  267. package/src/session/redis-session-storage.ts +51 -11
  268. package/src/session/retry-fallback-chains.ts +455 -0
  269. package/src/session/role-models.ts +85 -0
  270. package/src/session/session-advisors.ts +1679 -0
  271. package/src/session/session-context.ts +6 -3
  272. package/src/session/session-entries.ts +8 -0
  273. package/src/session/session-handoff.ts +308 -0
  274. package/src/session/session-history-format.ts +20 -9
  275. package/src/session/session-loader.ts +0 -46
  276. package/src/session/session-maintenance.ts +2983 -0
  277. package/src/session/session-manager.ts +390 -30
  278. package/src/session/session-memory.ts +222 -0
  279. package/src/session/session-provider-boundary.ts +307 -0
  280. package/src/session/session-stats.ts +293 -0
  281. package/src/session/session-tools.ts +980 -0
  282. package/src/session/session-workspace.ts +53 -0
  283. package/src/session/stream-guards.ts +417 -0
  284. package/src/session/streaming-output.ts +52 -5
  285. package/src/session/todo-tracker.ts +380 -0
  286. package/src/session/ttsr-coordinator.ts +496 -0
  287. package/src/session/turn-recovery.ts +1629 -0
  288. package/src/slash-commands/builtin-registry.ts +257 -20
  289. package/src/slash-commands/helpers/session-pin.ts +44 -0
  290. package/src/slash-commands/helpers/usage-report.ts +22 -3
  291. package/src/stt/downloader.ts +0 -2
  292. package/src/stt/index.ts +0 -2
  293. package/src/stt/stt-controller.ts +57 -146
  294. package/src/system-prompt.ts +45 -17
  295. package/src/task/executor.ts +285 -70
  296. package/src/task/index.ts +98 -105
  297. package/src/task/output-manager.ts +25 -3
  298. package/src/task/persisted-revive.ts +4 -3
  299. package/src/task/structured-subagent.ts +1 -0
  300. package/src/tiny/title-client.ts +22 -0
  301. package/src/tools/approval.ts +55 -10
  302. package/src/tools/bash-interactive.ts +116 -86
  303. package/src/tools/bash-skill-urls.ts +28 -2
  304. package/src/tools/bash.ts +339 -123
  305. package/src/tools/browser/launch.ts +9 -1
  306. package/src/tools/browser/tab-supervisor.ts +8 -5
  307. package/src/tools/browser.ts +94 -47
  308. package/src/tools/builtin-names.ts +1 -0
  309. package/src/tools/computer/protocol.ts +28 -0
  310. package/src/tools/computer/supervisor.ts +258 -0
  311. package/src/tools/computer/worker-entry.ts +25 -0
  312. package/src/tools/computer/worker.ts +135 -0
  313. package/src/tools/computer-renderer.ts +108 -0
  314. package/src/tools/computer.ts +433 -0
  315. package/src/tools/context.ts +2 -0
  316. package/src/tools/default-renderer.ts +139 -0
  317. package/src/tools/essential-tools.ts +1 -0
  318. package/src/tools/image-gen.ts +20 -28
  319. package/src/tools/image-providers.ts +50 -0
  320. package/src/tools/index.ts +14 -0
  321. package/src/tools/path-utils.ts +49 -2
  322. package/src/tools/read.ts +156 -88
  323. package/src/tools/renderers.ts +9 -1
  324. package/src/tools/todo.ts +101 -11
  325. package/src/tools/vibe.ts +1 -2
  326. package/src/tools/write.ts +51 -1
  327. package/src/tools/xdev.ts +121 -29
  328. package/src/tts/streaming-player.ts +82 -283
  329. package/src/tts/vocalizer.ts +18 -1
  330. package/src/utils/clipboard.ts +1 -30
  331. package/src/utils/jj.ts +125 -4
  332. package/src/utils/lang-from-path.ts +7 -0
  333. package/src/utils/mac-file-urls.applescript +37 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/utils/tool-choice.ts +14 -0
  336. package/src/utils/tools-manager.ts +1 -19
  337. package/src/vibe/runtime.ts +852 -72
  338. package/src/web/search/index.ts +3 -6
  339. package/src/web/search/provider.ts +31 -16
  340. package/src/web/search/providers/xai.ts +7 -1
  341. package/src/web/search/types.ts +3 -0
  342. package/dist/types/stt/recorder.d.ts +0 -30
  343. package/dist/types/stt/transcriber.d.ts +0 -14
  344. package/dist/types/stt/wav.d.ts +0 -29
  345. package/dist/types/tts/player.d.ts +0 -32
  346. package/src/stt/recorder.ts +0 -551
  347. package/src/stt/transcriber.ts +0 -60
  348. package/src/stt/wav.ts +0 -173
  349. package/src/tts/player.ts +0 -137
@@ -1,321 +1,120 @@
1
- /**
2
- * Gapless streaming audio output for assistant speech.
3
- *
4
- * Replaces the spawn-`afplay`-per-sentence approach (a fresh process per chunk
5
- * meant audible gaps, per-spawn latency, and no way to interrupt a clip mid-play)
6
- * with a single persistent player process fed raw 32-bit-float mono PCM over
7
- * stdin. Chunks are queued and drained by one writer so segments play back to
8
- * back; writes are paced to stay only {@link LEAD_SECONDS} ahead of realtime so
9
- * ducking and stop take effect promptly instead of after seconds of buffered
10
- * audio. {@link StreamingAudioPlayer.stop} kills the process for instant silence.
11
- *
12
- * Where no streaming backend exists (Windows, or a host without ffmpeg/sox), it
13
- * degrades to the per-file {@link playAudioFile} path so speech still works —
14
- * just without gapless playback or mid-clip interruption. A backend that spawns
15
- * but dies early (e.g. an ffmpeg built without its platform audio device) is
16
- * detected via its exit and the session downgrades to per-file playback without
17
- * dropping the chunk being played.
18
- */
19
- import * as fs from "node:fs/promises";
20
- import * as os from "node:os";
21
- import * as path from "node:path";
22
- import { $which, logger, Snowflake } from "@oh-my-pi/pi-utils";
23
- import type { FileSink, Subprocess } from "bun";
24
- import { getToolPath } from "../utils/tools-manager";
25
- import { type PlayerCommand, playAudioFile } from "./player";
26
- import { encodeWav } from "./wav";
1
+ import { AudioPlayback } from "@oh-my-pi/pi-natives";
27
2
 
28
- /** Kokoro emits 24 kHz mono; used when a chunk does not declare a rate. */
3
+ /** Kokoro emits 24 kHz mono PCM when a chunk does not declare a rate. */
29
4
  const DEFAULT_SAMPLE_RATE = 24_000;
30
- /** Cap how far ahead of realtime we buffer into the player so duck/stop are responsive. */
31
- const LEAD_SECONDS = 0.6;
32
- /** Output gain applied while ducked (the user is speaking over the assistant). */
5
+
6
+ /** Output gain applied while the user speaks over assistant audio. */
33
7
  export const DUCK_GAIN = 0.25;
34
8
 
35
- /** Injection seam for {@link streamingPlayerCommandsFor} — defaults to real PATH/tools lookups. */
36
- export interface StreamingPlayerLookup {
37
- which?: (bin: string) => string | null;
38
- ffmpeg?: () => string | null;
9
+ function errorFrom(cause: unknown): Error {
10
+ return cause instanceof Error ? cause : new Error(String(cause));
39
11
  }
40
12
 
41
13
  /**
42
- * Ordered candidate commands for a persistent raw-PCM player on `platform`: each
43
- * reads 32-bit-float little-endian mono PCM at `sampleRate` from stdin (`pipe:0`)
44
- * and plays it to the default output device. An empty list means no streaming
45
- * backend is available and the caller should fall back to per-file playback.
46
- *
47
- * - darwin: `ffmpeg` (AudioToolbox output device) → sox's `play` (coreaudio).
48
- * - linux/other POSIX: `ffmpeg` (`-f pulse` then `-f alsa`) → `paplay`/`aplay`
49
- * raw fallbacks.
50
- * - win32: none (PowerShell `SoundPlayer` is file-only).
51
- */
52
- export function streamingPlayerCommandsFor(
53
- platform: NodeJS.Platform,
54
- sampleRate: number,
55
- lookup: StreamingPlayerLookup = {},
56
- ): PlayerCommand[] {
57
- const which = lookup.which ?? $which;
58
- const ffmpeg = lookup.ffmpeg ?? ((): string | null => getToolPath("ffmpeg"));
59
- const rate = String(sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE);
60
- const input = ["-loglevel", "error", "-nostdin", "-f", "f32le", "-ar", rate, "-ac", "1", "-i", "pipe:0"];
61
-
62
- if (platform === "darwin") {
63
- const commands: PlayerCommand[] = [];
64
- const ffmpegBin = ffmpeg();
65
- if (ffmpegBin) commands.push({ cmd: ffmpegBin, args: [...input, "-f", "audiotoolbox", "default"] });
66
- const play = which("play");
67
- if (play) {
68
- commands.push({
69
- cmd: play,
70
- args: ["-q", "-t", "raw", "-e", "floating-point", "-b", "32", "-r", rate, "-c", "1", "-"],
71
- });
72
- }
73
- return commands;
74
- }
75
- if (platform === "win32") {
76
- return [];
77
- }
78
-
79
- const commands: PlayerCommand[] = [];
80
- const ffmpegBin = ffmpeg();
81
- if (ffmpegBin) {
82
- commands.push({ cmd: ffmpegBin, args: [...input, "-f", "pulse", "default"] });
83
- commands.push({ cmd: ffmpegBin, args: [...input, "-f", "alsa", "default"] });
84
- }
85
- const paplay = which("paplay");
86
- if (paplay) commands.push({ cmd: paplay, args: ["--raw", `--rate=${rate}`, "--format=float32le", "--channels=1"] });
87
- const aplay = which("aplay");
88
- if (aplay) commands.push({ cmd: aplay, args: ["-q", "-f", "FLOAT_LE", "-r", rate, "-c", "1", "-"] });
89
- return commands;
90
- }
91
-
92
- /**
93
- * Single-session gapless player. Lifecycle: {@link start} once, {@link write}
94
- * chunks in order, then {@link end} to drain or {@link stop} to abort. Not
95
- * reusable after stop/end — create a new instance per utterance.
14
+ * One native gapless playback session. Call {@link start}, enqueue mono `f32`
15
+ * chunks with {@link write}, then {@link end} to drain or {@link stop} to abort.
96
16
  */
97
17
  export class StreamingAudioPlayer {
98
- #queue: Float32Array[] = [];
18
+ #native: AudioPlayback | null = null;
99
19
  #sampleRate = DEFAULT_SAMPLE_RATE;
100
20
  #gain = 1;
101
- #mode: "stream" | "file" = "file";
102
- #proc: Subprocess<"pipe", "ignore", "ignore"> | null = null;
103
- #sink: FileSink | null = null;
104
- /** Streaming backends not yet tried; consumed head-first by {@link #spawnStream}. */
105
- #candidates: PlayerCommand[] | null = null;
106
- #writtenSec = 0;
107
- #startedAt = 0;
108
- #started = false;
21
+ #error: Error | null = null;
22
+ #ending: Promise<void> | null = null;
109
23
  #inputClosed = false;
110
24
  #stopped = false;
111
- #abortController = new AbortController();
112
- #wake: (() => void) | null = null;
113
- #drain: Promise<void> = Promise.resolve();
25
+ #failNative(native: AudioPlayback, cause: unknown): void {
26
+ this.#error = errorFrom(cause);
27
+ if (this.#native === native) this.#native = null;
28
+ try {
29
+ native.stop();
30
+ } catch {
31
+ // Preserve the original playback failure.
32
+ }
33
+ }
114
34
 
115
- /** Pick a backend and begin draining. Idempotent; the first call's rate wins. */
116
- start(sampleRate: number): void {
117
- if (this.#started || this.#stopped) return;
118
- this.#started = true;
35
+ /** Opens the default speaker at the stream's logical sample rate. */
36
+ start(sampleRate = DEFAULT_SAMPLE_RATE): void {
37
+ if (this.#native || this.#error || this.#inputClosed || this.#stopped) return;
119
38
  this.#sampleRate = sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE;
120
- this.#mode = this.#spawnStream() ? "stream" : "file";
121
- this.#startedAt = performance.now();
122
- this.#drain = this.#drainLoop();
39
+ let native: AudioPlayback | undefined;
40
+ try {
41
+ native = new AudioPlayback(this.#sampleRate);
42
+ native.setGain(this.#gain);
43
+ this.#native = native;
44
+ } catch (cause) {
45
+ if (native) this.#failNative(native, cause);
46
+ else this.#error = errorFrom(cause);
47
+ }
123
48
  }
124
49
 
125
- /** Queue a mono float32 PCM chunk for playback in arrival order. */
50
+ /** Queues one mono `f32` PCM chunk without copying it in TypeScript. */
126
51
  write(pcm: Float32Array): void {
127
- if (this.#stopped) return;
128
- this.#queue.push(pcm);
129
- this.#signal();
130
- }
131
-
132
- /** Scale subsequent output (1 = normal, <1 = ducked). Applies within {@link LEAD_SECONDS}. */
133
- setGain(gain: number): void {
134
- this.#gain = gain < 0 ? 0 : gain;
135
- }
136
-
137
- /** Close the input; resolves once all queued audio has finished playing. */
138
- async end(): Promise<void> {
139
- this.#inputClosed = true;
140
- this.#signal();
141
- await this.#drain;
142
- }
143
-
144
- /** Stop immediately: kill the player, drop everything still queued. */
145
- stop(): void {
146
- if (this.#stopped) return;
147
- this.#stopped = true;
148
- this.#queue.length = 0;
149
- this.#abortController.abort();
150
- this.#signal();
52
+ if (this.#inputClosed || this.#stopped || pcm.length === 0) return;
53
+ if (!this.#native && !this.#error) this.start(this.#sampleRate);
54
+ const native = this.#native;
55
+ if (!native) return;
151
56
  try {
152
- // end() flushes asynchronously; the SIGKILL below races it, so a broken
153
- // pipe here is expected — swallow the rejection (it otherwise surfaces
154
- // as an unhandled EPIPE right as speech ends).
155
- void Promise.resolve(this.#sink?.end()).catch(() => {});
156
- } catch {}
157
- try {
158
- this.#proc?.kill("SIGKILL");
159
- } catch {}
160
- }
161
-
162
- /**
163
- * Spawn the next untried streaming backend; false once the list is
164
- * exhausted. A backend that spawns but dies early (e.g. an ffmpeg built
165
- * without this platform's audio output device) would otherwise swallow PCM
166
- * into a dead pipe, so its exit advances to the next candidate — or to
167
- * per-file playback — and #writeStream's failure path replays the
168
- * in-flight chunk.
169
- */
170
- #spawnStream(): boolean {
171
- this.#candidates ??= streamingPlayerCommandsFor(process.platform, this.#sampleRate);
172
- for (let command = this.#candidates.shift(); command; command = this.#candidates.shift()) {
173
- const { cmd, args } = command;
174
- try {
175
- const proc = Bun.spawn([cmd, ...args], {
176
- stdin: "pipe",
177
- stdout: "ignore",
178
- stderr: "ignore",
179
- });
180
- this.#proc = proc;
181
- this.#sink = proc.stdin;
182
- void proc.exited.then(code => {
183
- if (this.#proc !== proc || this.#stopped || this.#inputClosed) return;
184
- logger.debug("tts: streaming backend exited early; trying next backend", { cmd, code });
185
- this.#proc = null;
186
- this.#sink = null;
187
- this.#mode = this.#spawnStream() ? "stream" : "file";
188
- });
189
- return true;
190
- } catch (error) {
191
- logger.debug("tts: streaming player spawn failed", {
192
- cmd,
193
- error: error instanceof Error ? error.message : String(error),
194
- });
195
- }
57
+ native.write(pcm);
58
+ } catch (cause) {
59
+ this.#failNative(native, cause);
196
60
  }
197
- return false;
198
61
  }
199
62
 
200
- #signal(): void {
201
- const wake = this.#wake;
202
- this.#wake = null;
203
- wake?.();
204
- }
205
-
206
- async #drainLoop(): Promise<void> {
63
+ /** Applies gain at render time, including to samples already queued natively. */
64
+ setGain(gain: number): void {
65
+ if (!Number.isFinite(gain) || gain < 0) throw new RangeError("Audio gain must be a finite non-negative number");
66
+ this.#gain = gain;
67
+ const native = this.#native;
68
+ if (!native) return;
207
69
  try {
208
- while (!this.#stopped) {
209
- const chunk = this.#queue.shift();
210
- if (!chunk) {
211
- if (this.#inputClosed) break;
212
- await this.#waitForWork();
213
- continue;
214
- }
215
- if (this.#mode === "stream") {
216
- // Pace writes so the player buffers ~LEAD_SECONDS, no more, keeping
217
- // ducking and stop responsive instead of locked behind buffered audio.
218
- const ahead = this.#writtenSec - (performance.now() - this.#startedAt) / 1000;
219
- if (ahead > LEAD_SECONDS) {
220
- await Bun.sleep((ahead - LEAD_SECONDS) * 1000);
221
- if (this.#stopped) return;
222
- }
223
- if (await this.#writeStream(chunk)) {
224
- this.#writtenSec += chunk.length / this.#sampleRate;
225
- continue;
226
- }
227
- // Backend died mid-write: move to the next streaming candidate
228
- // (or the file path) and replay this exact chunk so nothing is
229
- // dropped.
230
- this.#mode = this.#spawnStream() ? "stream" : "file";
231
- if (this.#mode === "stream" && (await this.#writeStream(chunk))) {
232
- this.#writtenSec += chunk.length / this.#sampleRate;
233
- } else {
234
- this.#mode = "file";
235
- await this.#playFile(chunk);
236
- }
237
- } else {
238
- await this.#playFile(chunk);
239
- }
240
- }
241
- if (!this.#stopped && this.#mode === "stream") {
242
- try {
243
- await this.#sink?.end();
244
- } catch {}
245
- if (this.#proc) {
246
- try {
247
- await this.#proc.exited;
248
- } catch {}
249
- }
250
- }
251
- } catch (error) {
252
- logger.debug("tts: streaming player drain failed", {
253
- error: error instanceof Error ? error.message : String(error),
254
- });
70
+ native.setGain(gain);
71
+ } catch (cause) {
72
+ this.#failNative(native, cause);
255
73
  }
256
74
  }
257
75
 
258
- /** Block until a chunk is queued, the input closes, or stop is called. */
259
- #waitForWork(): Promise<void> {
260
- const { promise, resolve } = Promise.withResolvers<void>();
261
- this.#wake = resolve;
262
- // Re-check after arming to close the gap between the empty shift and here.
263
- if (this.#queue.length > 0 || this.#inputClosed || this.#stopped) {
264
- this.#wake = null;
265
- resolve();
76
+ /** Closes input and resolves after every queued sample reaches the speaker. */
77
+ end(): Promise<void> {
78
+ if (this.#ending) return this.#ending;
79
+ if (this.#stopped) return Promise.resolve();
80
+ this.#inputClosed = true;
81
+ if (this.#error) {
82
+ this.#stopped = true;
83
+ return Promise.reject(this.#error);
266
84
  }
267
- return promise;
268
- }
269
-
270
- /**
271
- * Write one chunk into the backend's stdin and await the flush — a broken
272
- * pipe rejects here (not as an unhandled rejection later), so the caller
273
- * can replay the exact chunk on the next backend.
274
- */
275
- async #writeStream(pcm: Float32Array): Promise<boolean> {
276
- const sink = this.#sink;
277
- if (!sink) return false;
278
- try {
279
- sink.write(this.#bytes(pcm));
280
- await sink.flush();
281
- return true;
282
- } catch (error) {
283
- logger.debug("tts: streaming write failed", {
284
- error: error instanceof Error ? error.message : String(error),
285
- });
286
- return false;
85
+ const native = this.#native;
86
+ if (!native) {
87
+ this.#stopped = true;
88
+ return Promise.resolve();
287
89
  }
90
+ this.#ending = native
91
+ .end()
92
+ .catch(cause => {
93
+ throw errorFrom(cause);
94
+ })
95
+ .finally(() => {
96
+ if (this.#native === native) this.#native = null;
97
+ this.#stopped = true;
98
+ });
99
+ return this.#ending;
288
100
  }
289
101
 
290
- async #playFile(pcm: Float32Array): Promise<void> {
291
- const wavPath = path.join(os.tmpdir(), `omp-speech-${Snowflake.next()}.wav`);
102
+ /** Stops immediately and discards queued audio. Safe to call repeatedly. */
103
+ stop(): void {
104
+ this.#inputClosed = true;
105
+ this.#stopped = true;
106
+ const native = this.#native;
107
+ this.#native = null;
108
+ if (!native) return;
292
109
  try {
293
- await fs.writeFile(wavPath, encodeWav(this.#scaled(pcm), this.#sampleRate));
294
- if (!this.#stopped) await playAudioFile(wavPath, { signal: this.#abortController.signal });
295
- } catch (error) {
296
- logger.debug("tts: file playback failed", {
297
- error: error instanceof Error ? error.message : String(error),
298
- });
299
- } finally {
300
- await fs.unlink(wavPath).catch(() => {});
110
+ native.stop();
111
+ } catch {
112
+ // Best-effort abort during session teardown.
301
113
  }
302
114
  }
303
-
304
- /** Raw f32le bytes for the stream sink, applying gain only when ducked (avoids a copy at unity). */
305
- #bytes(pcm: Float32Array): Uint8Array {
306
- if (this.#gain === 1) return new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
307
- return new Uint8Array(this.#scaled(pcm).buffer);
308
- }
309
-
310
- #scaled(pcm: Float32Array): Float32Array {
311
- if (this.#gain === 1) return pcm;
312
- const out = new Float32Array(pcm.length);
313
- for (let i = 0; i < pcm.length; i++) out[i] = (pcm[i] ?? 0) * this.#gain;
314
- return out;
315
- }
316
115
  }
317
116
 
318
- /** Factory the vocalizer calls; a function so tests can stub it without spawning a player. */
117
+ /** Creates the single-use player used by the speech vocalizer. */
319
118
  export function createStreamingPlayer(): StreamingAudioPlayer {
320
119
  return new StreamingAudioPlayer();
321
120
  }
@@ -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);
@@ -1,6 +1,7 @@
1
1
  import type { ClipboardImage } from "@oh-my-pi/pi-natives";
2
2
  import * as native from "@oh-my-pi/pi-natives";
3
3
  import { logger } from "@oh-my-pi/pi-utils";
4
+ import MAC_FILE_URL_SCRIPT from "./mac-file-urls.applescript" with { type: "text" };
4
5
 
5
6
  /**
6
7
  * Run a subprocess and capture its stdout without blocking the event loop.
@@ -52,36 +53,6 @@ function isWsl(): boolean {
52
53
  return process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
53
54
  }
54
55
 
55
- // AppleScript that returns the POSIX paths of every file URL currently on the
56
- // macOS pasteboard, one path per line. `pbpaste(1)` only surfaces plain text,
57
- // EPS, or RTF, so a Finder `Cmd+C` (which puts only a `public.file-url`
58
- // representation on the pasteboard) makes `pbpaste` empty. AppleScript's
59
- // `«class furl»` coercion reaches the file-URL representation directly and
60
- // works for both single-file and multi-file selections. The `try` blocks
61
- // suppress the `-1700` "can't make … into type" error AppleScript raises when
62
- // the clipboard holds no file URLs, so the script's exit status only reflects
63
- // `osascript` itself.
64
- const MAC_FILE_URL_SCRIPT = [
65
- "on run",
66
- '\tset output to ""',
67
- "\ttry",
68
- "\t\tset theClip to the clipboard as «class furl»",
69
- "\t\tif class of theClip is list then",
70
- "\t\t\trepeat with anItem in theClip",
71
- "\t\t\t\ttry",
72
- "\t\t\t\t\tset output to output & POSIX path of anItem & linefeed",
73
- "\t\t\t\tend try",
74
- "\t\t\tend repeat",
75
- "\t\telse",
76
- "\t\t\ttry",
77
- "\t\t\t\tset output to POSIX path of theClip & linefeed",
78
- "\t\t\tend try",
79
- "\t\tend if",
80
- "\tend try",
81
- "\treturn output",
82
- "end run",
83
- ].join("\n");
84
-
85
56
  /**
86
57
  * Read file paths from the macOS pasteboard's `public.file-url` representation.
87
58
  *
package/src/utils/jj.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as fs from "node:fs/promises";
1
+ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { $which } from "@oh-my-pi/pi-utils";
4
4
  import { LRUCache } from "lru-cache/raw";
@@ -63,6 +63,8 @@ export class JjCommandError extends Error {
63
63
  // ════════════════════════════════════════════════════════════════════════════
64
64
  // Internal: Core execution
65
65
  // ════════════════════════════════════════════════════════════════════════════
66
+ const WORKING_COPY_LABEL_REVSET = "@ | heads(::@ & bookmarks())";
67
+ const WORKING_COPY_LABEL_TEMPLATE = 'change_id.shortest(8) ++ "|" ++ local_bookmarks ++ "\\n"';
66
68
 
67
69
  function ensureAvailable(): void {
68
70
  if (!$which("jj")) {
@@ -121,6 +123,19 @@ async function runText(cwd: string, args: readonly string[], options: CommandOpt
121
123
  return (await runChecked(cwd, args, options)).stdout;
122
124
  }
123
125
 
126
+ async function runOptionalText(
127
+ cwd: string,
128
+ args: readonly string[],
129
+ options: CommandOptions = {},
130
+ ): Promise<string | null> {
131
+ try {
132
+ const result = await jj(cwd, args, options);
133
+ return result.exitCode === 0 ? result.stdout : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
124
139
  function splitLines(text: string): string[] {
125
140
  return text
126
141
  .split("\n")
@@ -135,6 +150,30 @@ function buildDiffArgs(options: DiffOptions): string[] {
135
150
  return args;
136
151
  }
137
152
 
153
+ function parseWorkingCopyLabel(raw: string): string | null {
154
+ let changeId: string | null = null;
155
+ for (const line of raw.split("\n")) {
156
+ const sep = line.indexOf("|");
157
+ const change = (sep === -1 ? line : line.slice(0, sep)).trim();
158
+ const bookmarks = sep === -1 ? "" : line.slice(sep + 1).trim();
159
+ if (changeId === null && change) changeId = change;
160
+ if (bookmarks) return bookmarks.replace(/\s+/g, " ");
161
+ }
162
+ return changeId;
163
+ }
164
+
165
+ function parseStatusSummary(raw: string): git.GitStatusSummary {
166
+ let unstaged = 0;
167
+ let untracked = 0;
168
+ for (const line of raw.split("\n")) {
169
+ const type = line.trim()[0];
170
+ if (!type) continue;
171
+ if (type === "A") untracked++;
172
+ else unstaged++;
173
+ }
174
+ return { staged: 0, unstaged, untracked };
175
+ }
176
+
138
177
  // ════════════════════════════════════════════════════════════════════════════
139
178
  // Internal: Repository resolution
140
179
  // ════════════════════════════════════════════════════════════════════════════
@@ -153,7 +192,16 @@ async function hasJjWorkspaceMetadata(dir: string): Promise<boolean> {
153
192
  // of the default workspace. Either form is a real workspace, so match on
154
193
  // `.jj/repo` presence rather than the inner `store/` directory.
155
194
  try {
156
- await fs.stat(path.join(dir, ".jj", "repo"));
195
+ await fs.promises.stat(path.join(dir, ".jj", "repo"));
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ function hasJjWorkspaceMetadataSync(dir: string): boolean {
203
+ try {
204
+ fs.statSync(path.join(dir, ".jj", "repo"));
157
205
  return true;
158
206
  } catch {
159
207
  return false;
@@ -180,6 +228,21 @@ async function findWorkspaceRoot(cwd: string): Promise<string | undefined> {
180
228
  return undefined;
181
229
  }
182
230
 
231
+ function findWorkspaceRootSync(cwd: string): string | undefined {
232
+ const key = path.resolve(cwd);
233
+ if (workspaceRootCache.has(key)) return workspaceRootCache.get(key)?.root;
234
+
235
+ for (let dir: string | undefined = key; dir; dir = parentOf(dir)) {
236
+ if (hasJjWorkspaceMetadataSync(dir)) {
237
+ workspaceRootCache.set(key, { root: dir });
238
+ return dir;
239
+ }
240
+ }
241
+
242
+ workspaceRootCache.set(key, {});
243
+ return undefined;
244
+ }
245
+
183
246
  /**
184
247
  * Resolve the `.jj/repo` directory backing a workspace root, following the file
185
248
  * indirection used by non-default workspaces. `jj workspace add` writes a FILE at
@@ -190,8 +253,8 @@ async function findWorkspaceRoot(cwd: string): Promise<string | undefined> {
190
253
  async function resolveRepoDir(root: string): Promise<string> {
191
254
  const jjDir = path.join(root, ".jj");
192
255
  const repoPath = path.join(jjDir, "repo");
193
- if ((await fs.stat(repoPath)).isFile()) {
194
- const target = (await fs.readFile(repoPath, "utf8")).trim();
256
+ if ((await fs.promises.stat(repoPath)).isFile()) {
257
+ const target = (await fs.promises.readFile(repoPath, "utf8")).trim();
195
258
  return path.resolve(jjDir, target);
196
259
  }
197
260
  return repoPath;
@@ -221,6 +284,56 @@ export const diff = Object.assign(
221
284
  },
222
285
  );
223
286
 
287
+ // ════════════════════════════════════════════════════════════════════════════
288
+ // API: working copy
289
+ // ════════════════════════════════════════════════════════════════════════════
290
+
291
+ /** Jujutsu working-copy metadata used by status displays. */
292
+ export const workingCopy = {
293
+ /**
294
+ * Label `@` with its nearest bookmark, falling back to its short change ID.
295
+ * Returns `null` when `jj` is unavailable or the query fails.
296
+ */
297
+ async label(cwd: string, signal?: AbortSignal): Promise<string | null> {
298
+ const raw = await runOptionalText(
299
+ cwd,
300
+ [
301
+ "log",
302
+ "--no-graph",
303
+ "--ignore-working-copy",
304
+ "-r",
305
+ WORKING_COPY_LABEL_REVSET,
306
+ "-T",
307
+ WORKING_COPY_LABEL_TEMPLATE,
308
+ ],
309
+ { signal },
310
+ );
311
+ return raw === null ? null : parseWorkingCopyLabel(raw);
312
+ },
313
+
314
+ /** Parse working-copy label query output. */
315
+ parseLabel: parseWorkingCopyLabel,
316
+ };
317
+
318
+ // ════════════════════════════════════════════════════════════════════════════
319
+ // API: status
320
+ // ════════════════════════════════════════════════════════════════════════════
321
+
322
+ /** Jujutsu working-copy status derived from the changes in `@`. */
323
+ export const status = {
324
+ /**
325
+ * Count changes in `@` relative to its parent using the Git status shape.
326
+ * Jujutsu has no index, so `staged` is always zero.
327
+ */
328
+ async summary(cwd: string, signal?: AbortSignal): Promise<git.GitStatusSummary | null> {
329
+ const raw = await runOptionalText(cwd, ["diff", "-r", "@", "--summary", "--ignore-working-copy"], { signal });
330
+ return raw === null ? null : parseStatusSummary(raw);
331
+ },
332
+
333
+ /** Parse `jj diff --summary` output into status counts. */
334
+ parse: parseStatusSummary,
335
+ };
336
+
224
337
  // ════════════════════════════════════════════════════════════════════════════
225
338
  // API: repo
226
339
  // ════════════════════════════════════════════════════════════════════════════
@@ -231,6 +344,14 @@ export const repo = {
231
344
  workspaceRootCache.clear();
232
345
  },
233
346
 
347
+ /**
348
+ * Resolve the current workspace root synchronously from on-disk metadata.
349
+ * Intended for render paths that cannot await filesystem I/O.
350
+ */
351
+ rootSync(cwd: string): string | null {
352
+ return findWorkspaceRootSync(cwd) ?? null;
353
+ },
354
+
234
355
  /** Resolve the current Jujutsu workspace root, or `null` when `cwd` is not in a JJ repository. */
235
356
  async root(cwd: string): Promise<string | null> {
236
357
  return (await findWorkspaceRoot(cwd)) ?? null;