@oh-my-pi/pi-coding-agent 17.2.4 → 17.2.6

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 (348) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/{CHANGELOG-bpmhv26t.md → CHANGELOG-gs76k6wc.md} +69 -0
  3. package/dist/cli.js +4438 -3962
  4. package/dist/{template-8vdv6xb4.js → template-c2hyaytt.js} +8 -26
  5. package/dist/types/cli/args.d.ts +4 -1
  6. package/dist/types/cli/browser-relay-cli.d.ts +15 -0
  7. package/dist/types/cli/command-help.d.ts +99 -0
  8. package/dist/types/cli/gc-cli.d.ts +1 -0
  9. package/dist/types/cli/help-extra.d.ts +2 -0
  10. package/dist/types/cli/thinking-levels.d.ts +5 -0
  11. package/dist/types/cli/update-cli.d.ts +13 -0
  12. package/dist/types/commands/browser-relay.d.ts +38 -0
  13. package/dist/types/commands/commit.d.ts +3 -0
  14. package/dist/types/commands/complete.d.ts +1 -1
  15. package/dist/types/commands/completions.d.ts +6 -0
  16. package/dist/types/commands/grep.d.ts +3 -0
  17. package/dist/types/commands/join.d.ts +4 -0
  18. package/dist/types/commands/launch-help.d.ts +171 -0
  19. package/dist/types/commands/launch.d.ts +5 -1
  20. package/dist/types/commands/models.d.ts +3 -0
  21. package/dist/types/commands/say.d.ts +10 -0
  22. package/dist/types/commands/worktree.d.ts +3 -0
  23. package/dist/types/config/keybindings.d.ts +5 -0
  24. package/dist/types/config/model-discovery.d.ts +2 -2
  25. package/dist/types/config/models-config-schema-bundle.d.ts +3 -0
  26. package/dist/types/config/models-config-schema.d.ts +2 -0
  27. package/dist/types/config/models-config.d.ts +1 -0
  28. package/dist/types/config/service-tier.d.ts +2 -0
  29. package/dist/types/config/settings-schema.d.ts +69 -21
  30. package/dist/types/cursor-bridge-tools.d.ts +1 -1
  31. package/dist/types/cursor.d.ts +7 -4
  32. package/dist/types/edit/index.d.ts +3 -3
  33. package/dist/types/edit/modes/replace.d.ts +22 -15
  34. package/dist/types/edit/renderer.d.ts +1 -1
  35. package/dist/types/eval/py/kernel.d.ts +3 -1
  36. package/dist/types/eval/py/spawn-options.d.ts +12 -16
  37. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +8 -1
  38. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  39. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  40. package/dist/types/launch/broker.d.ts +1 -1
  41. package/dist/types/launch/client.d.ts +15 -4
  42. package/dist/types/launch/ensure.d.ts +8 -0
  43. package/dist/types/launch/paths.d.ts +1 -1
  44. package/dist/types/launch/protocol.d.ts +19 -2
  45. package/dist/types/lsp/client.d.ts +2 -0
  46. package/dist/types/lsp/mux/daemon.d.ts +15 -0
  47. package/dist/types/lsp/mux/protocol.d.ts +57 -0
  48. package/dist/types/lsp/mux/server.d.ts +18 -0
  49. package/dist/types/lsp/types.d.ts +22 -2
  50. package/dist/types/main.d.ts +2 -0
  51. package/dist/types/mnemopi/embed-client.d.ts +1 -1
  52. package/dist/types/mnemopi/state.d.ts +0 -23
  53. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  54. package/dist/types/modes/components/btw-panel.d.ts +3 -0
  55. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  56. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  57. package/dist/types/modes/components/stripped-tool-calls-placeholder.d.ts +12 -0
  58. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  59. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  60. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  61. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  62. package/dist/types/modes/interactive-mode.d.ts +5 -1
  63. package/dist/types/modes/types.d.ts +4 -1
  64. package/dist/types/modes/workflow.d.ts +2 -1
  65. package/dist/types/sdk.d.ts +3 -1
  66. package/dist/types/secrets/index.d.ts +1 -1
  67. package/dist/types/security/contracts/schemas.d.ts +405 -403
  68. package/dist/types/session/agent-session-types.d.ts +8 -1
  69. package/dist/types/session/agent-session.d.ts +26 -2
  70. package/dist/types/session/indexed-session-storage.d.ts +1 -0
  71. package/dist/types/session/launch-completion.d.ts +10 -0
  72. package/dist/types/session/session-advisors.d.ts +1 -1
  73. package/dist/types/session/session-context.d.ts +2 -1
  74. package/dist/types/session/session-entries.d.ts +15 -1
  75. package/dist/types/session/session-maintenance.d.ts +1 -0
  76. package/dist/types/session/session-manager.d.ts +23 -11
  77. package/dist/types/session/session-stats.d.ts +10 -0
  78. package/dist/types/session/session-storage.d.ts +15 -3
  79. package/dist/types/session/yield-queue.d.ts +6 -1
  80. package/dist/types/system-prompt.d.ts +4 -1
  81. package/dist/types/task/index.d.ts +2 -1
  82. package/dist/types/task/spawn-policy.d.ts +5 -0
  83. package/dist/types/thinking.d.ts +1 -7
  84. package/dist/types/tools/ast-grep.d.ts +1 -1
  85. package/dist/types/tools/browser/registry.d.ts +2 -1
  86. package/dist/types/tools/browser/relay/bridge.d.ts +35 -0
  87. package/dist/types/tools/browser/relay/daemon.d.ts +16 -0
  88. package/dist/types/tools/browser/relay/kind.d.ts +19 -0
  89. package/dist/types/tools/browser/relay/protocol.d.ts +97 -0
  90. package/dist/types/tools/browser/relay/server.d.ts +35 -0
  91. package/dist/types/tools/browser/render.d.ts +1 -0
  92. package/dist/types/tools/browser.d.ts +3 -0
  93. package/dist/types/tools/computer/exposure.d.ts +9 -7
  94. package/dist/types/tools/computer/protocol.d.ts +70 -17
  95. package/dist/types/tools/computer/supervisor.d.ts +23 -10
  96. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  97. package/dist/types/tools/computer/worker.d.ts +42 -7
  98. package/dist/types/tools/computer-renderer.d.ts +7 -5
  99. package/dist/types/tools/computer.d.ts +25 -44
  100. package/dist/types/tools/context.d.ts +3 -0
  101. package/dist/types/tools/glob.d.ts +1 -1
  102. package/dist/types/tools/grep.d.ts +1 -1
  103. package/dist/types/tools/index.d.ts +9 -0
  104. package/dist/types/tools/render-utils.d.ts +2 -0
  105. package/dist/types/tools/{browser/run-cancellation.d.ts → run-scope.d.ts} +8 -8
  106. package/dist/types/tools/tool-timeouts.d.ts +5 -0
  107. package/dist/types/tools/xdev.d.ts +8 -0
  108. package/dist/types/utils/clipboard.d.ts +2 -2
  109. package/dist/types/web/kagi.d.ts +1 -0
  110. package/dist/types/web/search/index.d.ts +1 -1
  111. package/dist/types/web/search/providers/anthropic.d.ts +1 -0
  112. package/dist/types/web/search/providers/base.d.ts +2 -0
  113. package/dist/types/web/search/providers/brave.d.ts +1 -0
  114. package/dist/types/web/search/providers/browser-page.d.ts +1 -0
  115. package/dist/types/web/search/providers/codex.d.ts +1 -0
  116. package/dist/types/web/search/providers/exa.d.ts +1 -0
  117. package/dist/types/web/search/providers/firecrawl.d.ts +1 -0
  118. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  119. package/dist/types/web/search/providers/jina.d.ts +1 -0
  120. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  121. package/dist/types/web/search/providers/kimi.d.ts +1 -0
  122. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  123. package/dist/types/web/search/providers/perplexity.d.ts +1 -0
  124. package/dist/types/web/search/providers/searxng.d.ts +1 -0
  125. package/dist/types/web/search/providers/tavily.d.ts +1 -0
  126. package/dist/types/web/search/providers/tinyfish.d.ts +1 -0
  127. package/dist/types/web/search/providers/utils.d.ts +6 -9
  128. package/dist/types/web/search/providers/zai.d.ts +1 -0
  129. package/dist/types/web/search/types.d.ts +4 -0
  130. package/package.json +12 -12
  131. package/src/cli/args.ts +7 -87
  132. package/src/cli/browser-relay-cli.ts +119 -0
  133. package/src/cli/command-help.ts +103 -0
  134. package/src/cli/completion-gen.ts +5 -5
  135. package/src/cli/flag-tables.ts +9 -0
  136. package/src/cli/gc-cli.ts +641 -21
  137. package/src/cli/help-extra.ts +89 -0
  138. package/src/cli/setup-cli.ts +13 -21
  139. package/src/cli/thinking-levels.ts +7 -0
  140. package/src/cli/update-cli.ts +26 -7
  141. package/src/cli-commands.ts +170 -33
  142. package/src/cli.ts +17 -5
  143. package/src/commands/acp.ts +3 -1
  144. package/src/commands/agents.ts +3 -2
  145. package/src/commands/auth-broker.ts +3 -2
  146. package/src/commands/auth-gateway.ts +3 -2
  147. package/src/commands/bench.ts +2 -3
  148. package/src/commands/browser-relay.ts +53 -0
  149. package/src/commands/cleanse.ts +2 -2
  150. package/src/commands/commit.ts +3 -2
  151. package/src/commands/complete.ts +2 -1
  152. package/src/commands/completions.ts +3 -2
  153. package/src/commands/config.ts +3 -2
  154. package/src/commands/dry-balance.ts +2 -2
  155. package/src/commands/gallery.ts +3 -2
  156. package/src/commands/gc.ts +3 -2
  157. package/src/commands/grep.ts +3 -2
  158. package/src/commands/grievances.ts +3 -2
  159. package/src/commands/install.ts +2 -2
  160. package/src/commands/join.ts +3 -2
  161. package/src/commands/launch-help.ts +116 -0
  162. package/src/commands/launch.ts +7 -191
  163. package/src/commands/models.ts +3 -2
  164. package/src/commands/plugin.ts +3 -2
  165. package/src/commands/read.ts +3 -2
  166. package/src/commands/say.ts +3 -2
  167. package/src/commands/setup.ts +3 -2
  168. package/src/commands/shell.ts +3 -2
  169. package/src/commands/ssh.ts +3 -2
  170. package/src/commands/stats.ts +3 -2
  171. package/src/commands/tiny-models.ts +2 -2
  172. package/src/commands/token.ts +2 -2
  173. package/src/commands/ttsr.ts +2 -2
  174. package/src/commands/update.ts +3 -2
  175. package/src/commands/usage.ts +3 -2
  176. package/src/commands/web-search.ts +3 -2
  177. package/src/commands/worktree.ts +3 -2
  178. package/src/config/keybindings.ts +5 -0
  179. package/src/config/model-discovery.ts +44 -27
  180. package/src/config/model-registry.ts +19 -13
  181. package/src/config/models-config-schema-bundle.ts +9 -0
  182. package/src/config/resolve-config-value.ts +2 -1
  183. package/src/config/service-tier.ts +5 -0
  184. package/src/config/settings-schema.ts +72 -20
  185. package/src/config/settings.ts +1 -1
  186. package/src/cursor-bridge-tools.ts +1 -1
  187. package/src/cursor.ts +10 -14
  188. package/src/edit/diff.ts +17 -8
  189. package/src/edit/index.ts +20 -7
  190. package/src/edit/modes/replace.ts +93 -40
  191. package/src/edit/renderer.ts +1 -1
  192. package/src/edit/streaming.ts +14 -17
  193. package/src/eval/py/kernel.ts +2 -1
  194. package/src/eval/py/spawn-options.ts +24 -29
  195. package/src/export/html/index.ts +10 -3
  196. package/src/export/html/template.js +8 -26
  197. package/src/export/share.ts +4 -0
  198. package/src/extensibility/extensions/wrapper.ts +1 -0
  199. package/src/extensibility/legacy-pi-ai-shim.ts +22 -1
  200. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -6
  201. package/src/extensibility/plugins/legacy-pi-compat.ts +124 -57
  202. package/src/extensibility/skills.ts +11 -0
  203. package/src/extensibility/utils.ts +10 -2
  204. package/src/launch/broker.ts +223 -7
  205. package/src/launch/client.ts +192 -21
  206. package/src/launch/ensure.ts +73 -0
  207. package/src/launch/paths.ts +1 -1
  208. package/src/launch/protocol.ts +54 -2
  209. package/src/lsp/client.ts +23 -2
  210. package/src/lsp/index.ts +8 -0
  211. package/src/lsp/mux/daemon.ts +348 -0
  212. package/src/lsp/mux/protocol.ts +96 -0
  213. package/src/lsp/mux/server.ts +797 -0
  214. package/src/lsp/types.ts +28 -2
  215. package/src/main.ts +20 -8
  216. package/src/mcp/config-writer.ts +1 -1
  217. package/src/mcp/oauth-flow.ts +11 -1
  218. package/src/mnemopi/embed-client.ts +49 -2
  219. package/src/mnemopi/state.ts +57 -23
  220. package/src/modes/components/assistant-message.ts +11 -0
  221. package/src/modes/components/btw-panel.ts +41 -4
  222. package/src/modes/components/chat-transcript-builder.ts +4 -0
  223. package/src/modes/components/custom-editor.ts +9 -0
  224. package/src/modes/components/read-tool-group.ts +10 -0
  225. package/src/modes/components/status-line/component.ts +1 -1
  226. package/src/modes/components/stripped-tool-calls-placeholder.ts +35 -0
  227. package/src/modes/components/tool-execution.ts +8 -0
  228. package/src/modes/components/tree-selector.ts +46 -48
  229. package/src/modes/controllers/btw-controller.ts +55 -7
  230. package/src/modes/controllers/command-controller.ts +31 -0
  231. package/src/modes/controllers/event-controller.ts +17 -0
  232. package/src/modes/controllers/input-controller.ts +63 -8
  233. package/src/modes/controllers/mcp-command-controller.ts +11 -5
  234. package/src/modes/controllers/selector-controller.ts +20 -0
  235. package/src/modes/interactive-mode.ts +18 -2
  236. package/src/modes/types.ts +9 -1
  237. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  238. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  239. package/src/modes/utils/ui-helpers.ts +6 -10
  240. package/src/modes/workflow.ts +8 -2
  241. package/src/prompts/agents/init.md +1 -1
  242. package/src/prompts/session/launch-completion.md +1 -0
  243. package/src/prompts/system/plan-mode-active.md +2 -2
  244. package/src/prompts/system/system-prompt.md +26 -52
  245. package/src/prompts/system/workflow-notice.md +3 -3
  246. package/src/prompts/tools/ast-grep.md +1 -1
  247. package/src/prompts/tools/bash.md +1 -1
  248. package/src/prompts/tools/browser.md +1 -0
  249. package/src/prompts/tools/computer.md +21 -21
  250. package/src/prompts/tools/glob.md +1 -1
  251. package/src/prompts/tools/grep.md +1 -1
  252. package/src/prompts/tools/replace.md +5 -4
  253. package/src/prompts/tools/task.md +4 -4
  254. package/src/sdk.ts +49 -6
  255. package/src/secrets/index.ts +1 -1
  256. package/src/security/contracts/schemas.ts +205 -183
  257. package/src/security/contracts/validation.ts +5 -1
  258. package/src/security/store.ts +2 -2
  259. package/src/session/agent-session-types.ts +9 -1
  260. package/src/session/agent-session.ts +204 -15
  261. package/src/session/indexed-session-storage.ts +9 -0
  262. package/src/session/launch-completion.ts +37 -0
  263. package/src/session/prewalk.ts +24 -2
  264. package/src/session/session-advisors.ts +2 -2
  265. package/src/session/session-context.ts +42 -12
  266. package/src/session/session-dump-format.ts +1 -1
  267. package/src/session/session-entries.ts +17 -1
  268. package/src/session/session-maintenance.ts +38 -4
  269. package/src/session/session-manager.ts +123 -67
  270. package/src/session/session-paths.ts +118 -55
  271. package/src/session/session-stats.ts +48 -3
  272. package/src/session/session-storage.ts +30 -22
  273. package/src/session/stream-guards.ts +1 -1
  274. package/src/session/yield-queue.ts +121 -16
  275. package/src/slash-commands/builtin-registry.ts +37 -24
  276. package/src/system-prompt.ts +8 -3
  277. package/src/task/index.ts +23 -8
  278. package/src/task/spawn-policy.ts +14 -0
  279. package/src/thinking.ts +2 -8
  280. package/src/tools/ast-grep.ts +10 -4
  281. package/src/tools/browser/cmux/cmux-tab.ts +97 -14
  282. package/src/tools/browser/cmux/rpc.ts +2 -8
  283. package/src/tools/browser/registry.ts +53 -2
  284. package/src/tools/browser/relay/bridge.ts +945 -0
  285. package/src/tools/browser/relay/daemon.ts +117 -0
  286. package/src/tools/browser/relay/extension-assets/background.js.txt +242 -0
  287. package/src/tools/browser/relay/extension-assets/manifest.json.txt +14 -0
  288. package/src/tools/browser/relay/extension-assets/options.html.txt +53 -0
  289. package/src/tools/browser/relay/extension-assets/options.js.txt +23 -0
  290. package/src/tools/browser/relay/kind.ts +41 -0
  291. package/src/tools/browser/relay/protocol.ts +54 -0
  292. package/src/tools/browser/relay/server.ts +141 -0
  293. package/src/tools/browser/render.ts +4 -1
  294. package/src/tools/browser/shared-daemon.ts +7 -58
  295. package/src/tools/browser/tab-supervisor.ts +5 -4
  296. package/src/tools/browser/tab-worker.ts +33 -12
  297. package/src/tools/browser.ts +26 -1
  298. package/src/tools/computer/exposure.ts +11 -35
  299. package/src/tools/computer/protocol.ts +51 -10
  300. package/src/tools/computer/supervisor.ts +227 -124
  301. package/src/tools/computer/worker-entry.ts +11 -6
  302. package/src/tools/computer/worker.ts +695 -81
  303. package/src/tools/computer-renderer.ts +104 -65
  304. package/src/tools/computer.ts +134 -428
  305. package/src/tools/context.ts +3 -0
  306. package/src/tools/glob.ts +9 -2
  307. package/src/tools/grep.ts +12 -6
  308. package/src/tools/hub/launch.ts +122 -6
  309. package/src/tools/index.ts +9 -0
  310. package/src/tools/inspect-image.ts +32 -6
  311. package/src/tools/render-utils.ts +2 -0
  312. package/src/tools/{browser/run-cancellation.ts → run-scope.ts} +11 -11
  313. package/src/tools/tool-timeouts.ts +1 -0
  314. package/src/tools/xdev.ts +29 -2
  315. package/src/utils/clipboard.ts +40 -14
  316. package/src/utils/command-args.ts +26 -28
  317. package/src/utils/tool-choice.ts +0 -13
  318. package/src/web/kagi.ts +2 -1
  319. package/src/web/search/index.ts +18 -2
  320. package/src/web/search/providers/anthropic.ts +4 -1
  321. package/src/web/search/providers/base.ts +2 -0
  322. package/src/web/search/providers/brave.ts +3 -1
  323. package/src/web/search/providers/browser-page.ts +6 -4
  324. package/src/web/search/providers/codex.ts +4 -1
  325. package/src/web/search/providers/duckduckgo.ts +2 -1
  326. package/src/web/search/providers/ecosia.ts +2 -1
  327. package/src/web/search/providers/exa.ts +4 -2
  328. package/src/web/search/providers/firecrawl.ts +3 -1
  329. package/src/web/search/providers/gemini.ts +8 -2
  330. package/src/web/search/providers/google.ts +2 -1
  331. package/src/web/search/providers/jina.ts +12 -2
  332. package/src/web/search/providers/kagi.ts +3 -0
  333. package/src/web/search/providers/kimi.ts +5 -1
  334. package/src/web/search/providers/mojeek.ts +2 -1
  335. package/src/web/search/providers/parallel.ts +5 -1
  336. package/src/web/search/providers/perplexity.ts +6 -3
  337. package/src/web/search/providers/public.ts +1 -1
  338. package/src/web/search/providers/searxng.ts +11 -5
  339. package/src/web/search/providers/startpage.ts +10 -4
  340. package/src/web/search/providers/synthetic.ts +11 -5
  341. package/src/web/search/providers/tavily.ts +3 -1
  342. package/src/web/search/providers/tinyfish.ts +3 -1
  343. package/src/web/search/providers/utils.ts +12 -10
  344. package/src/web/search/providers/xai.ts +2 -2
  345. package/src/web/search/providers/zai.ts +18 -3
  346. package/src/web/search/types.ts +6 -0
  347. package/dist/types/config/file-lock.d.ts +0 -29
  348. package/src/config/file-lock.ts +0 -164
@@ -15,14 +15,17 @@ import {
15
15
  DAEMON_PTY_COLUMNS,
16
16
  DAEMON_PTY_ROWS,
17
17
  DAEMON_RUNTIME_DIR_ENV,
18
+ type DaemonCompletionNotification,
18
19
  type DaemonOperation,
19
20
  type DaemonReadySpec,
20
21
  type DaemonRpcResult,
21
22
  type DaemonSignal,
22
23
  type DaemonSnapshot,
23
24
  type DaemonSpec,
25
+ type DaemonWireRequest,
24
26
  parseDaemonSnapshot,
25
27
  parseDaemonSpec,
28
+ parseDaemonWireMessage,
26
29
  parseDaemonWireRequest,
27
30
  } from "./protocol";
28
31
  import { resolveDaemonSpawnOptions } from "./spawn-options";
@@ -81,6 +84,9 @@ interface ManagedDaemon {
81
84
  readyPattern?: RegExp;
82
85
  restartTimer?: NodeJS.Timeout;
83
86
  consecutiveFailures: number;
87
+ completionCapable: boolean;
88
+ pendingCompletions: DaemonCompletionNotification[];
89
+ completionSubscriptionId?: string;
84
90
  persistQueue: Promise<void>;
85
91
  }
86
92
 
@@ -107,6 +113,10 @@ function settledState(state: DaemonSnapshot["state"]): boolean {
107
113
  return terminalState(state) || state === "restarting";
108
114
  }
109
115
 
116
+ function publishesCompletionOwners(request: DaemonWireRequest): boolean {
117
+ return request.completionEvents === true && (request.completionAcks?.length ?? 0) === 0;
118
+ }
119
+
110
120
  /**
111
121
  * Order daemons for the `list` response: non-terminal (active) daemons first,
112
122
  * oldest to newest, so the process the user is acting on is immediately visible
@@ -352,6 +362,9 @@ class DaemonBroker {
352
362
  */
353
363
  readonly #startingNames = new Set<string>();
354
364
  readonly #clients = new Set<net.Socket>();
365
+ readonly #ownerSockets = new Map<string, { socket: net.Socket; subscriptionId: string | undefined }>();
366
+ readonly #completionSubscriptions = new Map<string, string | undefined>();
367
+ readonly #pendingCompletions = new Map<string, Map<string, DaemonCompletionNotification>>();
355
368
  readonly #finished = Promise.withResolvers<void>();
356
369
  readonly #sockets = new Set<net.Socket>();
357
370
  #server: net.Server | undefined;
@@ -393,6 +406,7 @@ class DaemonBroker {
393
406
  await record.log?.close();
394
407
  await record.persistQueue;
395
408
  }
409
+ this.#ownerSockets.clear();
396
410
  for (const socket of this.#sockets) socket.destroy();
397
411
  this.#sockets.clear();
398
412
  this.#clients.clear();
@@ -439,6 +453,9 @@ class DaemonBroker {
439
453
  if (!authenticated) return;
440
454
  this.#clients.delete(socket);
441
455
  this.#scheduleIdleShutdown();
456
+ for (const [owner, registration] of this.#ownerSockets) {
457
+ if (registration.socket === socket) this.#ownerSockets.delete(owner);
458
+ }
442
459
  });
443
460
  }
444
461
 
@@ -450,6 +467,78 @@ class DaemonBroker {
450
467
  id = request.id;
451
468
  if (request.token !== this.#token) throw new Error("Daemon broker authentication failed");
452
469
  onAuthenticated();
470
+ for (const owner of request.completionUnsubscribes ?? []) {
471
+ const subscriptionId = this.#completionSubscriptions.get(owner);
472
+ if (
473
+ !this.#completionSubscriptions.has(owner) ||
474
+ (subscriptionId !== undefined && subscriptionId !== request.completionSubscriptionId)
475
+ ) {
476
+ continue;
477
+ }
478
+ this.#ownerSockets.delete(owner);
479
+ this.#completionSubscriptions.delete(owner);
480
+ await this.#setRecordCompletionCapability(owner, false);
481
+ this.#pendingCompletions.delete(owner);
482
+ }
483
+ for (const completionId of request.completionAcks ?? []) {
484
+ for (const [owner, pending] of this.#pendingCompletions) {
485
+ const registration = this.#ownerSockets.get(owner);
486
+ if (
487
+ !registration ||
488
+ registration.socket !== socket ||
489
+ registration.subscriptionId !== request.completionSubscriptionId
490
+ ) {
491
+ continue;
492
+ }
493
+ const completion = pending.get(completionId);
494
+ if (!completion) continue;
495
+ pending.delete(completionId);
496
+ if (pending.size === 0) this.#pendingCompletions.delete(owner);
497
+ const record = this.#records.get(completion.daemon.name);
498
+ const index = record?.pendingCompletions.findIndex(item => item.completionId === completionId) ?? -1;
499
+ if (record && index >= 0) {
500
+ record.pendingCompletions.splice(index, 1);
501
+ this.#persist(record);
502
+ await record.persistQueue;
503
+ }
504
+ }
505
+ }
506
+ if (publishesCompletionOwners(request)) {
507
+ const replayOwners = new Set(request.completionReplays ?? []);
508
+ const activeOwners = new Set(request.owners ?? []);
509
+ const detachedOwners = new Set(request.detachedOwners ?? []);
510
+ const advertisedOwners = new Set([...activeOwners, ...detachedOwners]);
511
+ for (const [owner, subscriptionId] of this.#completionSubscriptions) {
512
+ if (subscriptionId !== request.completionSubscriptionId || advertisedOwners.has(owner)) continue;
513
+ this.#ownerSockets.delete(owner);
514
+ this.#completionSubscriptions.delete(owner);
515
+ await this.#setRecordCompletionCapability(owner, false);
516
+ this.#pendingCompletions.delete(owner);
517
+ }
518
+ for (const owner of activeOwners) {
519
+ this.#completionSubscriptions.set(owner, request.completionSubscriptionId);
520
+ await this.#setRecordCompletionCapability(owner, true);
521
+ const previous = this.#ownerSockets.get(owner);
522
+ this.#ownerSockets.set(owner, {
523
+ socket,
524
+ subscriptionId: request.completionSubscriptionId,
525
+ });
526
+ if (previous?.socket === socket && !replayOwners.has(owner)) continue;
527
+ for (const completion of this.#pendingCompletions.get(owner)?.values() ?? []) {
528
+ socket.write(`${JSON.stringify(completion)}\n`);
529
+ }
530
+ }
531
+ for (const owner of detachedOwners) {
532
+ const subscriptionId = this.#completionSubscriptions.get(owner);
533
+ if (this.#completionSubscriptions.has(owner) && subscriptionId !== request.completionSubscriptionId) {
534
+ continue;
535
+ }
536
+ this.#completionSubscriptions.set(owner, request.completionSubscriptionId);
537
+ await this.#setRecordCompletionCapability(owner, true);
538
+ const registration = this.#ownerSockets.get(owner);
539
+ if (registration?.subscriptionId === request.completionSubscriptionId) this.#ownerSockets.delete(owner);
540
+ }
541
+ }
453
542
  const result = await this.#dispatch(request.operation);
454
543
  socket.write(`${JSON.stringify({ id, ok: true, result })}\n`);
455
544
  if (request.operation.op === "shutdown") setTimeout(() => void this.shutdown(), 10);
@@ -520,6 +609,9 @@ class DaemonBroker {
520
609
  if (existing && !terminalState(existing.snapshot.state)) {
521
610
  throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
522
611
  }
612
+ if (existing && existing.pendingCompletions.length > 0) {
613
+ throw new Error(`Daemon ${spec.name} has unacknowledged completion notifications`);
614
+ }
523
615
  if (spec.ready?.log) {
524
616
  try {
525
617
  new RegExp(spec.ready.log, "u");
@@ -556,6 +648,9 @@ class DaemonBroker {
556
648
  readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
557
649
  consecutiveFailures: 0,
558
650
  persistQueue: Promise.resolve(),
651
+ completionCapable: owner !== undefined && this.#completionSubscriptions.has(owner),
652
+ completionSubscriptionId: owner === undefined ? undefined : this.#completionSubscriptions.get(owner),
653
+ pendingCompletions: [],
559
654
  };
560
655
  syncReadyPending(record);
561
656
  this.#records.set(spec.name, record);
@@ -785,6 +880,14 @@ class DaemonBroker {
785
880
  await this.#settle(record, generation);
786
881
  }
787
882
 
883
+ async #monitorRecoveredDetached(record: ManagedDaemon, generation: number): Promise<void> {
884
+ while (!this.#shuttingDown && generation === record.generation && !settledState(record.snapshot.state)) {
885
+ await Bun.sleep(100);
886
+ if (this.#shuttingDown || generation !== record.generation) return;
887
+ await this.#refreshDetached(record);
888
+ }
889
+ }
890
+
788
891
  async #pollPort(record: ManagedDaemon, generation: number, ready: DaemonReadySpec): Promise<void> {
789
892
  const host = ready.host ?? "127.0.0.1";
790
893
  const port = ready.port;
@@ -812,6 +915,15 @@ class DaemonBroker {
812
915
  return this.#settle(record, generation, result.exitCode, result.timedOut ? "timed out" : undefined);
813
916
  }
814
917
 
918
+ #notifyCompletion(completion: DaemonCompletionNotification): void {
919
+ const pending = this.#pendingCompletions.get(completion.owner) ?? new Map<string, DaemonCompletionNotification>();
920
+ pending.set(completion.completionId, completion);
921
+ this.#pendingCompletions.set(completion.owner, pending);
922
+ const registration = this.#ownerSockets.get(completion.owner);
923
+ if (!registration || registration.socket.destroyed) return;
924
+ registration.socket.write(`${JSON.stringify(completion)}\n`);
925
+ }
926
+
815
927
  async #settle(record: ManagedDaemon, generation: number, exitCode?: number, error?: string): Promise<void> {
816
928
  // `restarting` is a settled state (child exited, relaunch timer armed). Any op that
817
929
  // runs #refreshDetached on such a record must not re-settle it: re-entry double-counts
@@ -855,9 +967,29 @@ class DaemonBroker {
855
967
  return;
856
968
  }
857
969
  record.snapshot.state = failed && !record.stopRequested ? "failed" : "exited";
970
+ const completion =
971
+ record.snapshot.owner !== undefined &&
972
+ !record.stopRequested &&
973
+ this.#completionSubscriptions.has(record.snapshot.owner)
974
+ ? ({
975
+ event: "daemon-completed",
976
+ completionId: crypto.randomUUID(),
977
+ owner: record.snapshot.owner,
978
+ daemon: { ...record.snapshot },
979
+ } satisfies DaemonCompletionNotification)
980
+ : undefined;
981
+ if (completion) record.pendingCompletions.push(completion);
858
982
  this.#persist(record);
859
983
  await record.log?.close();
860
984
  record.log = undefined;
985
+ await record.persistQueue;
986
+ if (
987
+ completion &&
988
+ this.#completionSubscriptions.has(completion.owner) &&
989
+ record.pendingCompletions.some(pending => pending.completionId === completion.completionId)
990
+ ) {
991
+ this.#notifyCompletion(completion);
992
+ }
861
993
  }
862
994
 
863
995
  async #logs(operation: Extract<DaemonOperation, { op: "logs" }>): Promise<DaemonRpcResult> {
@@ -1027,9 +1159,21 @@ class DaemonBroker {
1027
1159
  #persist(record: ManagedDaemon): void {
1028
1160
  const metaPath = path.join(record.dir, META_FILE);
1029
1161
  const tempPath = `${metaPath}.${process.pid}.tmp`;
1162
+ const metadata = {
1163
+ daemon: { ...record.snapshot },
1164
+ spec: record.spec,
1165
+ completionEvents: record.completionCapable,
1166
+ completionSubscriptionId: record.completionSubscriptionId,
1167
+ completionPending: record.pendingCompletions.length > 0,
1168
+ pendingCompletion: record.pendingCompletions.at(-1)?.daemon,
1169
+ pendingCompletions: record.pendingCompletions.map(completion => ({
1170
+ ...completion,
1171
+ daemon: { ...completion.daemon },
1172
+ })),
1173
+ };
1030
1174
  record.persistQueue = record.persistQueue
1031
1175
  .then(async () => {
1032
- await Bun.write(tempPath, JSON.stringify({ daemon: record.snapshot, spec: record.spec }));
1176
+ await Bun.write(tempPath, JSON.stringify(metadata));
1033
1177
  await fs.rename(tempPath, metaPath);
1034
1178
  })
1035
1179
  .catch(error => {
@@ -1040,6 +1184,28 @@ class DaemonBroker {
1040
1184
  });
1041
1185
  }
1042
1186
 
1187
+ async #setRecordCompletionCapability(owner: string, capable: boolean): Promise<void> {
1188
+ const subscriptionId = capable ? this.#completionSubscriptions.get(owner) : undefined;
1189
+ const persistence: Promise<void>[] = [];
1190
+ for (const record of this.#records.values()) {
1191
+ const clearPendingCompletions = !capable && record.pendingCompletions.length > 0;
1192
+ if (
1193
+ record.snapshot.owner !== owner ||
1194
+ (record.completionCapable === capable &&
1195
+ record.completionSubscriptionId === subscriptionId &&
1196
+ !clearPendingCompletions)
1197
+ ) {
1198
+ continue;
1199
+ }
1200
+ record.completionCapable = capable;
1201
+ record.completionSubscriptionId = subscriptionId;
1202
+ if (!capable) record.pendingCompletions = [];
1203
+ this.#persist(record);
1204
+ persistence.push(record.persistQueue);
1205
+ }
1206
+ await Promise.all(persistence);
1207
+ }
1208
+
1043
1209
  async #recoverRecords(): Promise<void> {
1044
1210
  const root = path.join(this.#runtimeDir, "daemons");
1045
1211
  const entries = await fs.readdir(root, { withFileTypes: true }).catch(error => {
@@ -1057,11 +1223,9 @@ class DaemonBroker {
1057
1223
  const snapshot = parseDaemonSnapshot(decoded.daemon);
1058
1224
  const spec = parseDaemonSpec(decoded.spec);
1059
1225
  const processRef = snapshot.pid === undefined ? null : Process.fromPid(snapshot.pid);
1060
- const detached =
1061
- spec.detached &&
1062
- !terminalState(snapshot.state) &&
1063
- snapshot.state !== "stopping" &&
1064
- processRef?.status() === "running";
1226
+ const recoverableExit = !terminalState(snapshot.state) && snapshot.state !== "stopping";
1227
+ const detached = spec.detached && recoverableExit && processRef?.status() === "running";
1228
+ const recoveredDead = recoverableExit && !detached;
1065
1229
  if (!detached) {
1066
1230
  // Reap only records that were still alive when the previous broker
1067
1231
  // exited; already-terminal records keep their real exit time so
@@ -1088,12 +1252,64 @@ class DaemonBroker {
1088
1252
  readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
1089
1253
  consecutiveFailures: 0,
1090
1254
  persistQueue: Promise.resolve(),
1255
+ completionCapable: "completionEvents" in decoded && decoded.completionEvents === true,
1256
+ completionSubscriptionId:
1257
+ "completionSubscriptionId" in decoded && typeof decoded.completionSubscriptionId === "string"
1258
+ ? decoded.completionSubscriptionId
1259
+ : undefined,
1260
+ pendingCompletions: (() => {
1261
+ if ("pendingCompletions" in decoded && Array.isArray(decoded.pendingCompletions)) {
1262
+ return decoded.pendingCompletions.map(value => {
1263
+ const message = parseDaemonWireMessage(value);
1264
+ if (!("event" in message)) throw new Error("Pending daemon completion is not an event");
1265
+ return message;
1266
+ });
1267
+ }
1268
+ const pendingSnapshot =
1269
+ "pendingCompletion" in decoded
1270
+ ? parseDaemonSnapshot(decoded.pendingCompletion)
1271
+ : "completionPending" in decoded && decoded.completionPending === true
1272
+ ? { ...snapshot }
1273
+ : undefined;
1274
+ return pendingSnapshot
1275
+ ? [
1276
+ {
1277
+ event: "daemon-completed",
1278
+ completionId: crypto.randomUUID(),
1279
+ owner: pendingSnapshot.owner ?? snapshot.owner ?? "",
1280
+ daemon: pendingSnapshot,
1281
+ },
1282
+ ]
1283
+ : [];
1284
+ })(),
1091
1285
  };
1286
+ if (recoveredDead && record.completionCapable && snapshot.owner && record.pendingCompletions.length === 0) {
1287
+ record.pendingCompletions.push({
1288
+ event: "daemon-completed",
1289
+ completionId: crypto.randomUUID(),
1290
+ owner: snapshot.owner,
1291
+ daemon: { ...snapshot },
1292
+ });
1293
+ }
1092
1294
  syncReadyPending(record);
1093
1295
  this.#records.set(snapshot.name, record);
1296
+ if (snapshot.owner && record.completionCapable && (detached || record.pendingCompletions.length > 0)) {
1297
+ this.#completionSubscriptions.set(snapshot.owner, record.completionSubscriptionId);
1298
+ }
1299
+ if (record.completionCapable) {
1300
+ for (const completion of record.pendingCompletions) this.#notifyCompletion(completion);
1301
+ }
1094
1302
  if (detached && spec.ready?.port !== undefined && snapshot.state !== "ready") {
1095
1303
  void this.#pollPort(record, record.generation, spec.ready);
1096
1304
  }
1305
+ if (detached) {
1306
+ void this.#monitorRecoveredDetached(record, record.generation).catch(error => {
1307
+ logger.warn("Failed to monitor recovered detached daemon", {
1308
+ name: record.snapshot.name,
1309
+ error: error instanceof Error ? error.message : String(error),
1310
+ });
1311
+ });
1312
+ }
1097
1313
  this.#persist(record);
1098
1314
  } catch (error) {
1099
1315
  logger.warn("Failed to recover daemon record", {
@@ -1124,7 +1340,7 @@ class DaemonBroker {
1124
1340
  }
1125
1341
  }
1126
1342
 
1127
- /** Start the detached per-project daemon broker selected by the CLI worker host. */
1343
+ /** Start the detached project or global daemon broker selected by the CLI worker host. */
1128
1344
  export async function startDaemonBrokerFromEnvironment(): Promise<void> {
1129
1345
  const projectDir = process.env[DAEMON_PROJECT_DIR_ENV];
1130
1346
  const runtimeDir = process.env[DAEMON_RUNTIME_DIR_ENV];
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as net from "node:net";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
5
+ import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
6
6
  import { hostHasInheritableConsole } from "../eval/py/spawn-options";
7
7
  import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
8
8
  import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
@@ -11,11 +11,12 @@ import {
11
11
  DAEMON_IDLE_GRACE_ENV,
12
12
  DAEMON_PROJECT_DIR_ENV,
13
13
  DAEMON_RUNTIME_DIR_ENV,
14
+ type DaemonCompletionNotification,
14
15
  type DaemonOperation,
15
16
  type DaemonRpcResult,
16
- type DaemonWireResponse,
17
+ type DaemonWireMessage,
17
18
  parseDaemonRpcResult,
18
- parseDaemonWireResponse,
19
+ parseDaemonWireMessage,
19
20
  } from "./protocol";
20
21
  import { resolveDaemonSpawnOptions } from "./spawn-options";
21
22
 
@@ -43,13 +44,26 @@ export interface DaemonBrokerClientOptions {
43
44
  idleGraceMs?: number;
44
45
  }
45
46
 
46
- /** Persistent per-process connection to one project's daemon broker. */
47
+ export interface DaemonCompletionUnregisterOptions {
48
+ /** Detach this process without deleting broker-persisted pending notifications. */
49
+ preservePending?: boolean;
50
+ }
51
+
52
+ /** Persistent per-process connection to one project or global daemon broker. */
47
53
  export interface DaemonBrokerClient {
54
+ onCompletion(
55
+ owner: string,
56
+ sink: (notification: DaemonCompletionNotification) => Promise<void> | void,
57
+ ): (options?: DaemonCompletionUnregisterOptions) => void;
58
+ /** Canonical project directory or synthetic directory identifying a global scope. */
48
59
  readonly projectDir: string;
49
60
  request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult>;
50
61
  close(): void;
51
62
  }
52
63
 
64
+ /** A request reached the broker and the broker rejected the operation. */
65
+ export class DaemonBrokerRejectedError extends Error {}
66
+
53
67
  async function canonicalProjectDir(projectDir: string): Promise<string> {
54
68
  const resolved = path.resolve(projectDir);
55
69
  try {
@@ -133,12 +147,20 @@ class SocketDaemonClient implements DaemonBrokerClient {
133
147
  readonly #runtimeDir: string;
134
148
  readonly #endpoint: string;
135
149
  readonly #token: string;
150
+ readonly #seenCompletionIds = new Set<string>();
136
151
  readonly #idleGraceMs: number | undefined;
137
152
  readonly #pending = new Map<string, PendingRequest>();
153
+ readonly #completionSinks = new Map<string, (notification: DaemonCompletionNotification) => Promise<void> | void>();
154
+ readonly #completionUnsubscribes = new Set<string>();
155
+ readonly #preservedCompletionOwners = new Set<string>();
156
+ readonly #completionReplays = new Set<string>();
157
+ readonly #inFlightCompletionIds = new Set<string>();
158
+ readonly #completionSubscriptionId = crypto.randomUUID();
138
159
  #socket: net.Socket | undefined;
139
160
  #connectPromise: Promise<void> | undefined;
140
161
  #buffer = "";
141
162
  #closed = false;
163
+ #completionReconnectTimer: NodeJS.Timeout | undefined;
142
164
 
143
165
  constructor(projectDir: string, runtimeDir: string, token: string, options: DaemonBrokerClientOptions) {
144
166
  this.projectDir = projectDir;
@@ -155,6 +177,8 @@ class SocketDaemonClient implements DaemonBrokerClient {
155
177
  const socket = this.#socket;
156
178
  if (!socket || socket.destroyed) throw new Error("Daemon broker socket is unavailable");
157
179
 
180
+ const completionUnsubscribes = [...this.#completionUnsubscribes];
181
+ const completionReplays = [...this.#completionReplays];
158
182
  const id = crypto.randomUUID();
159
183
  const { promise, resolve, reject } = Promise.withResolvers<DaemonRpcResult>();
160
184
  const timer = setTimeout(() => {
@@ -175,18 +199,88 @@ class SocketDaemonClient implements DaemonBrokerClient {
175
199
  pending.removeAbort = () => signal.removeEventListener("abort", abort);
176
200
  }
177
201
  this.#pending.set(id, pending);
178
- socket.write(`${JSON.stringify({ id, token: this.#token, operation })}\n`);
179
- return promise;
202
+ socket.write(
203
+ `${JSON.stringify({
204
+ id,
205
+ token: this.#token,
206
+ owners: [...this.#completionSinks.keys()],
207
+ detachedOwners: [...this.#preservedCompletionOwners],
208
+ completionEvents: true,
209
+ completionUnsubscribes,
210
+ completionReplays,
211
+ completionSubscriptionId: this.#completionSubscriptionId,
212
+ operation,
213
+ })}\n`,
214
+ );
215
+ const result = await promise;
216
+ for (const owner of completionUnsubscribes) {
217
+ if (!this.#completionSinks.has(owner)) this.#completionUnsubscribes.delete(owner);
218
+ }
219
+ for (const owner of completionReplays) {
220
+ if (this.#completionSinks.has(owner)) this.#completionReplays.delete(owner);
221
+ }
222
+ return result;
180
223
  }
181
224
 
182
225
  close(): void {
183
226
  if (this.#closed) return;
184
227
  this.#closed = true;
228
+ clearTimeout(this.#completionReconnectTimer);
229
+ this.#completionReconnectTimer = undefined;
185
230
  this.#socket?.destroy();
231
+ this.#completionSinks.clear();
232
+ this.#preservedCompletionOwners.clear();
233
+ this.#completionReplays.clear();
186
234
  this.#socket = undefined;
187
235
  this.#rejectPending(new Error("Daemon broker client closed"));
188
236
  }
189
237
 
238
+ onCompletion(
239
+ owner: string,
240
+ sink: (notification: DaemonCompletionNotification) => Promise<void> | void,
241
+ ): (options?: DaemonCompletionUnregisterOptions) => void {
242
+ this.#completionUnsubscribes.delete(owner);
243
+ if (this.#preservedCompletionOwners.delete(owner)) this.#completionReplays.add(owner);
244
+ this.#completionSinks.set(owner, sink);
245
+ this.#publishCompletionOwners();
246
+ return options => {
247
+ if (this.#completionSinks.get(owner) !== sink) return;
248
+ this.#completionSinks.delete(owner);
249
+ if (options?.preservePending) {
250
+ this.#preservedCompletionOwners.add(owner);
251
+ } else {
252
+ this.#preservedCompletionOwners.delete(owner);
253
+ this.#completionUnsubscribes.add(owner);
254
+ }
255
+ if (this.#completionSinks.size === 0 && this.#completionReconnectTimer) {
256
+ clearTimeout(this.#completionReconnectTimer);
257
+ this.#completionReconnectTimer = undefined;
258
+ }
259
+ this.#publishCompletionOwners();
260
+ };
261
+ }
262
+
263
+ #publishCompletionOwners(): void {
264
+ if (this.#closed) return;
265
+ void this.request({ op: "ping" }).catch(() => this.#scheduleCompletionReconnect());
266
+ }
267
+
268
+ #scheduleCompletionReconnect(): void {
269
+ if (
270
+ this.#closed ||
271
+ this.#completionSinks.size === 0 ||
272
+ this.#completionReconnectTimer !== undefined ||
273
+ (this.#socket !== undefined && !this.#socket.destroyed)
274
+ ) {
275
+ return;
276
+ }
277
+ this.#completionReconnectTimer = setTimeout(() => {
278
+ this.#completionReconnectTimer = undefined;
279
+ this.#publishCompletionOwners();
280
+ }, CONNECT_RETRY_MS);
281
+ this.#completionReconnectTimer.unref();
282
+ }
283
+
190
284
  async #connect(): Promise<void> {
191
285
  if (this.#socket && !this.#socket.destroyed) return;
192
286
  if (this.#connectPromise) return this.#connectPromise;
@@ -250,6 +344,7 @@ class SocketDaemonClient implements DaemonBrokerClient {
250
344
  socket.on("close", () => {
251
345
  if (this.#socket === socket) this.#socket = undefined;
252
346
  this.#rejectPending(new Error("Daemon broker connection closed"));
347
+ this.#scheduleCompletionReconnect();
253
348
  });
254
349
  }
255
350
 
@@ -261,21 +356,32 @@ class SocketDaemonClient implements DaemonBrokerClient {
261
356
  const line = this.#buffer.slice(0, newline);
262
357
  this.#buffer = this.#buffer.slice(newline + 1);
263
358
  if (line.length === 0) continue;
264
- let response: DaemonWireResponse;
359
+ let decoded: unknown;
265
360
  try {
266
- const decoded: unknown = JSON.parse(line);
267
- response = parseDaemonWireResponse(decoded);
361
+ decoded = JSON.parse(line);
268
362
  } catch (error) {
269
363
  this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
270
364
  continue;
271
365
  }
366
+ let message: DaemonWireMessage;
367
+ try {
368
+ message = parseDaemonWireMessage(decoded);
369
+ } catch (error) {
370
+ this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
371
+ continue;
372
+ }
373
+ if ("event" in message) {
374
+ void this.#deliverCompletion(message);
375
+ continue;
376
+ }
377
+ const response = message;
272
378
  const pending = this.#pending.get(response.id);
273
379
  if (!pending) continue;
274
380
  this.#pending.delete(response.id);
275
381
  clearTimeout(pending.timer);
276
382
  pending.removeAbort?.();
277
383
  if (!response.ok) {
278
- pending.reject(new Error(response.error));
384
+ pending.reject(new DaemonBrokerRejectedError(response.error));
279
385
  continue;
280
386
  }
281
387
  try {
@@ -286,6 +392,53 @@ class SocketDaemonClient implements DaemonBrokerClient {
286
392
  }
287
393
  }
288
394
 
395
+ async #deliverCompletion(message: DaemonCompletionNotification): Promise<void> {
396
+ if (this.#seenCompletionIds.has(message.completionId)) {
397
+ this.#ackCompletion(message.completionId);
398
+ return;
399
+ }
400
+ if (this.#inFlightCompletionIds.has(message.completionId)) return;
401
+ const sink = this.#completionSinks.get(message.owner);
402
+ if (!sink) return;
403
+ this.#inFlightCompletionIds.add(message.completionId);
404
+ try {
405
+ await sink(message);
406
+ if (this.#seenCompletionIds.size >= 512) {
407
+ const oldest = this.#seenCompletionIds.values().next().value;
408
+ if (oldest !== undefined) this.#seenCompletionIds.delete(oldest);
409
+ }
410
+ this.#seenCompletionIds.add(message.completionId);
411
+ this.#ackCompletion(message.completionId);
412
+ } catch (error) {
413
+ logger.warn("Daemon completion sink failed", {
414
+ owner: message.owner,
415
+ completionId: message.completionId,
416
+ error: error instanceof Error ? error.message : String(error),
417
+ });
418
+ this.#socket?.destroy();
419
+ } finally {
420
+ this.#inFlightCompletionIds.delete(message.completionId);
421
+ }
422
+ }
423
+
424
+ #ackCompletion(completionId: string): void {
425
+ const socket = this.#socket;
426
+ if (!socket || socket.destroyed) return;
427
+ socket.write(
428
+ `${JSON.stringify({
429
+ id: crypto.randomUUID(),
430
+ token: this.#token,
431
+ owners: [...this.#completionSinks.keys()],
432
+ detachedOwners: [...this.#preservedCompletionOwners],
433
+ completionEvents: true,
434
+ completionAcks: [completionId],
435
+ completionUnsubscribes: [...this.#completionUnsubscribes],
436
+ completionSubscriptionId: this.#completionSubscriptionId,
437
+ operation: { op: "ping" },
438
+ })}\n`,
439
+ );
440
+ }
441
+
289
442
  #rejectPending(error: Error): void {
290
443
  for (const pending of this.#pending.values()) {
291
444
  clearTimeout(pending.timer);
@@ -299,7 +452,19 @@ class SocketDaemonClient implements DaemonBrokerClient {
299
452
  const sharedClients = new Map<string, Promise<DaemonBrokerClient>>();
300
453
  let cancelExitCleanup: (() => void) | undefined;
301
454
 
302
- /** Create an independent socket connection to one project's shared daemon broker. */
455
+ function sharedDaemonClient(key: string, create: () => Promise<DaemonBrokerClient>): Promise<DaemonBrokerClient> {
456
+ let pending = sharedClients.get(key);
457
+ if (!pending) {
458
+ pending = create();
459
+ sharedClients.set(key, pending);
460
+ if (!cancelExitCleanup) {
461
+ cancelExitCleanup = postmortem.register("daemon-broker-clients", () => closeDaemonClients());
462
+ }
463
+ }
464
+ return pending;
465
+ }
466
+
467
+ /** Create an independent socket connection to one daemon broker scope. */
303
468
  export async function createDaemonBrokerClient(
304
469
  projectDir: string,
305
470
  options: DaemonBrokerClientOptions = {},
@@ -313,18 +478,24 @@ export async function createDaemonBrokerClient(
313
478
  /** Get the process-shared daemon broker client for one canonical project directory. */
314
479
  export async function daemonClientForProject(projectDir: string): Promise<DaemonBrokerClient> {
315
480
  const canonical = await canonicalProjectDir(projectDir);
316
- let pending = sharedClients.get(canonical);
317
- if (!pending) {
318
- pending = createDaemonBrokerClient(canonical);
319
- sharedClients.set(canonical, pending);
320
- if (!cancelExitCleanup) {
321
- cancelExitCleanup = postmortem.register("daemon-broker-clients", () => closeDaemonClients());
322
- }
323
- }
324
- return pending;
481
+ return sharedDaemonClient(`project:${canonical}`, () => createDaemonBrokerClient(canonical));
482
+ }
483
+
484
+ /** Get the process-shared client that leases one profile-independent, machine-global daemon broker. */
485
+ export async function daemonClientForGlobal(service: string): Promise<DaemonBrokerClient> {
486
+ const runtimeDir = getGlobalDaemonRuntimeDir(service);
487
+ // Canonicalize only after creation so the first caller and later callers
488
+ // derive the same Windows pipe key even when an ancestor is a symlink.
489
+ await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
490
+ const canonical = await fs.realpath(runtimeDir);
491
+ return sharedDaemonClient(`global:${canonical}`, () =>
492
+ createDaemonBrokerClient(canonical, {
493
+ runtimeDir: canonical,
494
+ }),
495
+ );
325
496
  }
326
497
 
327
- /** Close every project broker connection held by this omp process. */
498
+ /** Close every project and machine-global broker connection held by this omp process. */
328
499
  export async function closeDaemonClients(): Promise<void> {
329
500
  const pending = [...sharedClients.values()];
330
501
  sharedClients.clear();