mixdog 0.9.95 → 0.9.97

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 (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -0,0 +1,1134 @@
1
+ // Session client: spawn-or-attach + the remote session proxy that both
2
+ // the terminal TUI and the desktop host consume in place of a local session
3
+ // runtime. The proxy keeps the store contract intact —
4
+ // getState() stays synchronous by mirroring the daemon's snapshot frames, and
5
+ // every other method is forwarded over the transport.
6
+ import http from 'node:http';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fork } from 'node:child_process';
10
+ import { readFileSync } from 'node:fs';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { fileURLToPath } from 'node:url';
13
+ import {
14
+ compareRuntimeVersions,
15
+ SESSION_CAPABILITY_FINGERPRINT,
16
+ SESSION_PROTOCOL,
17
+ SESSION_REVISION,
18
+ runtimeVersion,
19
+ } from './session-wire.mjs';
20
+ import {
21
+ createSessionProtocolClient,
22
+ SESSION_CONFIGURE_ACTIONS,
23
+ SESSION_CONFIGURE_ACTION_SET,
24
+ SESSION_READ_ACTIONS,
25
+ SESSION_READ_ACTION_SET,
26
+ } from './session-protocol.mjs';
27
+
28
+ function runtimeRoot() {
29
+ return process.env.MIXDOG_RUNTIME_ROOT
30
+ ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
31
+ : path.join(os.tmpdir(), 'mixdog');
32
+ }
33
+
34
+ export function sessionDiscoveryPath() {
35
+ return path.join(runtimeRoot(), 'daemon.json');
36
+ }
37
+
38
+ function daemonEntry() {
39
+ // ONE daemon: the channels/memory host also owns the session pool, so both
40
+ // spawn paths converge on the same singleton process.
41
+ return fileURLToPath(new URL('./daemon.mjs', import.meta.url));
42
+ }
43
+
44
+ // Data calls and lifecycle control must never share a socket pool. A burst of
45
+ // long /call requests can occupy every data socket; health/register/deregister
46
+ // still need a reserved lane so a new terminal can always attach or recover.
47
+ const daemonCallAgent = new http.Agent({
48
+ keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 64, maxFreeSockets: 8,
49
+ });
50
+ const daemonUrgentAgent = new http.Agent({
51
+ keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 8, maxFreeSockets: 2,
52
+ });
53
+ const daemonControlAgent = new http.Agent({
54
+ keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 4, maxFreeSockets: 2,
55
+ });
56
+ const URGENT_CALLS = new Set([
57
+ 'session.submit',
58
+ 'session.abort',
59
+ 'session.approve',
60
+ 'session.unsubscribe',
61
+ 'desktop.control',
62
+ 'desktop.unsubscribe',
63
+ ]);
64
+ const EVENT_STREAM_RECONNECT_BASE_MS = 100;
65
+ const EVENT_STREAM_RECONNECT_MAX_MS = 1_000;
66
+ const EVENT_STREAM_HEALTH_GRACE_MS = 750;
67
+ // A daemon that restarted needs a moment to rebind its port, so one immediate
68
+ // re-attach often lands in the same gap the first call died in. Mirror the
69
+ // event-stream policy above with a short bounded ladder; `callId` keeps a
70
+ // retried submit idempotent on the daemon side, so replaying is safe.
71
+ const CALL_RECOVERY_BACKOFF_MS = Object.freeze([0, 150, 600]);
72
+
73
+ function request({
74
+ port,
75
+ token,
76
+ method = 'GET',
77
+ path: urlPath,
78
+ body = null,
79
+ timeoutMs = 120_000,
80
+ control = false,
81
+ urgent = false,
82
+ }) {
83
+ return new Promise((resolve, reject) => {
84
+ const payload = body ? JSON.stringify(body) : null;
85
+ const req = http.request({
86
+ hostname: '127.0.0.1',
87
+ port,
88
+ path: urlPath,
89
+ method,
90
+ agent: control ? daemonControlAgent : urgent ? daemonUrgentAgent : daemonCallAgent,
91
+ headers: {
92
+ 'X-Mixdog-Daemon-Token': token,
93
+ ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
94
+ },
95
+ timeout: timeoutMs,
96
+ }, (res) => {
97
+ let data = '';
98
+ res.setEncoding('utf8');
99
+ res.on('data', (chunk) => { data += chunk; });
100
+ res.on('end', () => {
101
+ let parsed = null;
102
+ try { parsed = data ? JSON.parse(data) : {}; } catch { parsed = null; }
103
+ if (res.statusCode && res.statusCode >= 400) {
104
+ const err = new Error(parsed?.error || data || `HTTP ${res.statusCode}`);
105
+ err.statusCode = res.statusCode;
106
+ reject(err);
107
+ return;
108
+ }
109
+ resolve(parsed ?? {});
110
+ });
111
+ });
112
+ req.on('timeout', () => { req.destroy(new Error(`session timeout: ${method} ${urlPath}`)); });
113
+ req.on('error', reject);
114
+ if (payload) req.write(payload);
115
+ req.end();
116
+ });
117
+ }
118
+
119
+ export async function probeSessionHealth({ port, token, timeoutMs = 800 } = {}) {
120
+ try {
121
+ const health = await request({ port, token, path: '/health', timeoutMs, control: true });
122
+ return health?.status === 'ok' ? health : null;
123
+ } catch { return null; }
124
+ }
125
+
126
+ export function readSessionDiscovery(discoveryPath = sessionDiscoveryPath()) {
127
+ const pidAlive = (pid) => {
128
+ try { process.kill(Number(pid), 0); return true; }
129
+ catch (error) { return error?.code === 'EPERM'; }
130
+ };
131
+ const readUnified = (candidate) => {
132
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
133
+ const endpoint = parsed?.endpoints?.session;
134
+ const channel = parsed?.endpoints?.channel;
135
+ const pid = parsed?.pid;
136
+ if (!endpoint?.port || !endpoint?.token || !pid || !pidAlive(pid)) return null;
137
+ return {
138
+ ...endpoint,
139
+ pid,
140
+ ...(channel?.port && channel?.token
141
+ ? { channel: { port: channel.port, token: channel.token } }
142
+ : {}),
143
+ };
144
+ };
145
+ try { return readUnified(discoveryPath); } catch {}
146
+ return null;
147
+ }
148
+
149
+ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
150
+
151
+ function positiveProtocol(value, fallback = 0) {
152
+ const protocol = Number(value);
153
+ return Number.isInteger(protocol) && protocol > 0 ? protocol : fallback;
154
+ }
155
+
156
+ /** Protocol generation, then API revision, then application build determines
157
+ * ownership. An older client may attach to a newer daemon; a newer client
158
+ * replaces an older daemon. Capability skew remains diagnostic only. */
159
+ export function sessionDaemonCompatibility(health, {
160
+ protocol = SESSION_PROTOCOL,
161
+ revision = SESSION_REVISION,
162
+ version = runtimeVersion(),
163
+ capabilityFingerprint = SESSION_CAPABILITY_FINGERPRINT,
164
+ } = {}) {
165
+ const daemonProtocol = positiveProtocol(health?.protocol);
166
+ if (!daemonProtocol) return { status: 'invalid', protocol: daemonProtocol };
167
+ const daemonVersion = String(health?.version || '0.0.0');
168
+ const daemonRevision = Math.max(0, Number(health?.revision) || 0);
169
+ const details = {
170
+ protocol: daemonProtocol,
171
+ revision: daemonRevision,
172
+ version: daemonVersion,
173
+ capabilityMismatch: String(health?.capabilityFingerprint || '') !== capabilityFingerprint,
174
+ };
175
+ if (daemonProtocol !== protocol) return { status: 'protocol-mismatch', ...details };
176
+ if (revision < daemonRevision) return { status: 'daemon-newer', ...details };
177
+ if (revision > daemonRevision) return { status: 'client-newer', ...details };
178
+ const versionOrder = compareRuntimeVersions(version, daemonVersion);
179
+ if (versionOrder < 0) return { status: 'daemon-newer', ...details };
180
+ if (versionOrder > 0) return { status: 'client-newer', ...details };
181
+ return { status: 'compatible', ...details };
182
+ }
183
+
184
+ async function replaceLowerDaemon(discovery, initialHealth, { log }) {
185
+ const configuredTimeoutMs = Number(process.env.MIXDOG_DAEMON_UPGRADE_TIMEOUT_MS);
186
+ const deadline = configuredTimeoutMs > 0
187
+ ? Date.now() + Math.max(10_000, configuredTimeoutMs)
188
+ : Number.POSITIVE_INFINITY;
189
+ const result = await request({
190
+ port: discovery.port,
191
+ token: discovery.token,
192
+ method: 'POST',
193
+ path: '/upgrade',
194
+ body: {
195
+ protocol: SESSION_PROTOCOL,
196
+ revision: SESSION_REVISION,
197
+ version: runtimeVersion(),
198
+ },
199
+ timeoutMs: 3_000,
200
+ control: true,
201
+ });
202
+ if (result?.accepted !== true) throw new Error('daemon replacement was not accepted');
203
+ log(
204
+ `waiting for daemon build ${initialHealth?.version || 'unknown'}`
205
+ + ` to yield to ${runtimeVersion()}`,
206
+ );
207
+ while (Date.now() < deadline) {
208
+ const current = readSessionDiscovery();
209
+ if (!current || Number(current.pid) !== Number(discovery.pid)) return true;
210
+ const health = await probeSessionHealth({
211
+ port: current.port,
212
+ token: current.token,
213
+ timeoutMs: 800,
214
+ });
215
+ if (!health || Number(health.pid) !== Number(discovery.pid)) return true;
216
+ const compatibility = sessionDaemonCompatibility(health);
217
+ if (compatibility.status === 'compatible' || compatibility.status === 'daemon-newer') return true;
218
+ await delay(100);
219
+ }
220
+ const error = new Error('newer daemon build is still waiting for active work to finish');
221
+ error.daemonUpgradePending = true;
222
+ throw error;
223
+ }
224
+
225
+ /** Fork one daemon candidate DETACHED (it outlives this client — machine
226
+ * global) and resolve when it reports ready OR exits (race loss/crash); the
227
+ * caller then re-reads discovery and attaches to whoever won. */
228
+ function spawnDaemonCandidate({ cwd, log }) {
229
+ return new Promise((resolve) => {
230
+ let settled = false;
231
+ const done = () => { if (!settled) { settled = true; resolve(); } };
232
+ let child;
233
+ try {
234
+ child = fork(daemonEntry(), [], {
235
+ cwd,
236
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
237
+ detached: process.platform !== 'win32',
238
+ windowsHide: true,
239
+ env: {
240
+ ...process.env,
241
+ MIXDOG_DAEMON_HOST: '1',
242
+ // Session-only spawn: the daemon stays dormant on the channels side
243
+ // until a channels client registers.
244
+ MIXDOG_DAEMON_SPAWNED_FOR: 'session',
245
+ MIXDOG_SUPERVISOR_PID: process.env.MIXDOG_SUPERVISOR_PID || String(process.pid),
246
+ },
247
+ });
248
+ } catch (err) {
249
+ log(`daemon spawn failed: ${err?.message || err}`);
250
+ done();
251
+ return;
252
+ }
253
+ const mirror = (chunk) => {
254
+ const text = String(chunk || '').trimEnd();
255
+ if (text) log(text);
256
+ };
257
+ child.stderr?.on('data', mirror);
258
+ child.once('message', (msg) => {
259
+ if (msg?.type !== 'ready') return;
260
+ try { child.stderr?.off?.('data', mirror); } catch {}
261
+ try { child.disconnect?.(); } catch {}
262
+ try { child.unref?.(); } catch {}
263
+ try { child.stderr?.unref?.(); } catch {}
264
+ done();
265
+ });
266
+ child.once('exit', done);
267
+ child.once('error', (err) => { log(`daemon spawn error: ${err?.message || err}`); done(); });
268
+ const timer = setTimeout(done, 30_000);
269
+ timer.unref?.();
270
+ });
271
+ }
272
+
273
+ /** Spawn-or-attach discovery for the machine-global daemon. */
274
+ export async function ensureDaemon({ cwd = process.cwd(), log = () => {}, attempts = 5 } = {}) {
275
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
276
+ const discovery = readSessionDiscovery();
277
+ if (discovery) {
278
+ const health = await probeSessionHealth({ port: discovery.port, token: discovery.token });
279
+ if (health && Number(health.pid) === Number(discovery.pid)) {
280
+ const compatibility = sessionDaemonCompatibility(health);
281
+ if (compatibility.status === 'compatible' || compatibility.status === 'daemon-newer') {
282
+ return discovery;
283
+ }
284
+ if (compatibility.status === 'client-newer') {
285
+ await replaceLowerDaemon(discovery, health, { log });
286
+ continue;
287
+ }
288
+ const err = new Error('daemon session protocol identity is invalid');
289
+ err.sessionProtocolMismatch = true;
290
+ throw err;
291
+ }
292
+ }
293
+ await spawnDaemonCandidate({ cwd, log });
294
+ await delay(100);
295
+ }
296
+ throw new Error('daemon session endpoint is unavailable');
297
+ }
298
+
299
+ /** Attach to a live daemon: register, open the frame stream, return a handle
300
+ * whose call() dispatches session operations. */
301
+ export async function attachSession({
302
+ discovery,
303
+ leadPid = process.pid,
304
+ cwd = process.cwd(),
305
+ lifecycle = true,
306
+ registrationId = randomUUID(),
307
+ onFrame = () => {},
308
+ onFatal = () => {},
309
+ onStreamDisconnect = () => {},
310
+ onStreamReconnect = () => {},
311
+ streamReconnectBaseMs = EVENT_STREAM_RECONNECT_BASE_MS,
312
+ streamReconnectMaxMs = EVENT_STREAM_RECONNECT_MAX_MS,
313
+ streamReconnectHealthGraceMs = EVENT_STREAM_HEALTH_GRACE_MS,
314
+ log = () => {},
315
+ } = {}) {
316
+ if (!discovery?.port || !discovery?.token) throw new Error('daemon discovery {port, token} required');
317
+ const { port, token: serverToken } = discovery;
318
+ const stableRegistrationId = String(registrationId || randomUUID());
319
+ let reg = null;
320
+ let registrationError = null;
321
+ for (let attempt = 0; attempt < 3; attempt += 1) {
322
+ try {
323
+ reg = await request({
324
+ port, token: serverToken, method: 'POST', path: '/client/register',
325
+ body: {
326
+ leadPid,
327
+ cwd,
328
+ lifecycle: lifecycle !== false,
329
+ registrationId: stableRegistrationId,
330
+ protocol: SESSION_PROTOCOL,
331
+ revision: SESSION_REVISION,
332
+ },
333
+ timeoutMs: 3000,
334
+ control: true,
335
+ });
336
+ break;
337
+ } catch (error) {
338
+ registrationError = error;
339
+ if (Number(error?.statusCode) >= 400 && Number(error?.statusCode) < 500) throw error;
340
+ if (attempt < 2) await delay(100 * (attempt + 1));
341
+ }
342
+ }
343
+ if (!reg) throw registrationError || new Error('session registration failed');
344
+ const clientToken = reg?.token;
345
+ if (!clientToken) throw new Error('session registration returned no client token');
346
+
347
+ let closed = false;
348
+ let streamRequest = null;
349
+ let reconnectTimer = null;
350
+ let reconnectAttempt = 0;
351
+ let disconnectedAt = 0;
352
+ let lastLossReason = '';
353
+ let streamWasReady = false;
354
+ let fatalSignalled = false;
355
+ const reconnectBaseMs = Math.max(1, Number(streamReconnectBaseMs) || EVENT_STREAM_RECONNECT_BASE_MS);
356
+ const reconnectMaxMs = Math.max(
357
+ reconnectBaseMs,
358
+ Number(streamReconnectMaxMs) || EVENT_STREAM_RECONNECT_MAX_MS,
359
+ );
360
+ const healthGraceMs = Math.max(
361
+ reconnectBaseMs,
362
+ Number(streamReconnectHealthGraceMs) || EVENT_STREAM_HEALTH_GRACE_MS,
363
+ );
364
+
365
+ function signalFatal(reason) {
366
+ if (closed || fatalSignalled) return;
367
+ fatalSignalled = true;
368
+ if (reconnectTimer) clearTimeout(reconnectTimer);
369
+ reconnectTimer = null;
370
+ try { onFatal(reason); } catch {}
371
+ }
372
+
373
+ function scheduleStreamReconnect(reason) {
374
+ if (closed || fatalSignalled || reconnectTimer) return;
375
+ const now = Date.now();
376
+ if (!disconnectedAt) {
377
+ disconnectedAt = now;
378
+ lastLossReason = String(reason || 'sse disconnected');
379
+ try { onStreamDisconnect({ reason: lastLossReason }); } catch {}
380
+ }
381
+ const attempt = reconnectAttempt + 1;
382
+ reconnectAttempt = attempt;
383
+ const delayMs = Math.min(reconnectMaxMs, reconnectBaseMs * (2 ** Math.min(attempt - 1, 8)));
384
+ log(`session event stream reconnecting attempt=${attempt} delayMs=${delayMs} reason=${lastLossReason}`);
385
+ reconnectTimer = setTimeout(() => {
386
+ reconnectTimer = null;
387
+ void (async () => {
388
+ if (closed || fatalSignalled) return;
389
+ const downtimeMs = Math.max(0, Date.now() - disconnectedAt);
390
+ if (downtimeMs >= healthGraceMs) {
391
+ const health = await probeSessionHealth({ port, token: serverToken, timeoutMs: 500 });
392
+ if (!health || Number(health.pid) !== Number(discovery.pid)) {
393
+ signalFatal(`${lastLossReason}; daemon unavailable or replaced`);
394
+ return;
395
+ }
396
+ }
397
+ openStream();
398
+ })().catch(() => signalFatal(`${lastLossReason}; daemon health check failed`));
399
+ }, delayMs);
400
+ reconnectTimer.unref?.();
401
+ }
402
+
403
+ function openStream() {
404
+ if (closed || fatalSignalled || streamRequest) return;
405
+ const req = http.request({
406
+ hostname: '127.0.0.1',
407
+ port,
408
+ path: `/events?token=${encodeURIComponent(clientToken)}&server_token=${encodeURIComponent(serverToken)}`,
409
+ method: 'GET',
410
+ headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
411
+ }, (res) => {
412
+ if (req !== streamRequest || closed) { res.resume(); return; }
413
+ if (res.statusCode !== 200) {
414
+ res.resume();
415
+ streamRequest = null;
416
+ if (res.statusCode === 401 || res.statusCode === 403 || res.statusCode === 404) {
417
+ signalFatal(`bad sse status ${res.statusCode}`);
418
+ } else {
419
+ scheduleStreamReconnect(`bad sse status ${res.statusCode}`);
420
+ }
421
+ return;
422
+ }
423
+ const reconnected = streamWasReady && disconnectedAt > 0;
424
+ const reconnectInfo = reconnected ? {
425
+ reason: lastLossReason,
426
+ attempt: reconnectAttempt,
427
+ downtimeMs: Math.max(0, Date.now() - disconnectedAt),
428
+ } : null;
429
+ streamWasReady = true;
430
+ reconnectAttempt = 0;
431
+ disconnectedAt = 0;
432
+ lastLossReason = '';
433
+ res.setEncoding('utf8');
434
+ let buffer = '';
435
+ res.on('data', (chunk) => {
436
+ if (req !== streamRequest || closed) return;
437
+ buffer += chunk;
438
+ let index;
439
+ while ((index = buffer.indexOf('\n\n')) >= 0) {
440
+ const raw = buffer.slice(0, index);
441
+ buffer = buffer.slice(index + 2);
442
+ for (const line of raw.split('\n')) {
443
+ if (!line.startsWith('data:')) continue; // ': ka' keepalives
444
+ const json = line.slice(5).trim();
445
+ if (!json) continue;
446
+ let frame = null;
447
+ try { frame = JSON.parse(json); } catch { continue; }
448
+ try { onFrame(frame); } catch (err) { log(`onFrame threw: ${err?.message || err}`); }
449
+ }
450
+ }
451
+ });
452
+ let lossHandled = false;
453
+ const lost = (reason) => {
454
+ if (req !== streamRequest || closed) return;
455
+ if (lossHandled) return;
456
+ lossHandled = true;
457
+ streamRequest = null;
458
+ scheduleStreamReconnect(reason);
459
+ };
460
+ res.on('end', () => lost('sse ended'));
461
+ res.on('error', () => lost('sse error'));
462
+ res.on('aborted', () => lost('sse aborted'));
463
+ res.on('close', () => lost('sse closed'));
464
+ if (reconnectInfo) {
465
+ log(
466
+ `session event stream reconnected attempt=${reconnectInfo.attempt}`
467
+ + ` downtimeMs=${reconnectInfo.downtimeMs}`,
468
+ );
469
+ try { onStreamReconnect(reconnectInfo); } catch {}
470
+ }
471
+ });
472
+ req.on('error', () => {
473
+ if (req !== streamRequest || closed) return;
474
+ streamRequest = null;
475
+ scheduleStreamReconnect('sse request error');
476
+ });
477
+ streamRequest = req;
478
+ req.end();
479
+ }
480
+ openStream();
481
+
482
+ async function call(name, args = {}, { timeoutMs = 300_000, callId = null } = {}) {
483
+ let out;
484
+ try {
485
+ out = await request({
486
+ port, token: serverToken, method: 'POST', path: '/call',
487
+ body: { token: clientToken, name, args: args || {}, ...(callId ? { callId } : {}) },
488
+ timeoutMs,
489
+ urgent: URGENT_CALLS.has(name),
490
+ });
491
+ } catch (err) {
492
+ // Transport death (daemon restarted/unreachable) is recoverable by
493
+ // re-attaching; a session error comes back as a 200 {error} envelope.
494
+ err.daemonTransportError = true;
495
+ throw err;
496
+ }
497
+ if (out && out.error) throw new Error(out.error);
498
+ return out?.result;
499
+ }
500
+
501
+ async function close(reason = 'client close') {
502
+ if (closed) return;
503
+ closed = true;
504
+ if (reconnectTimer) clearTimeout(reconnectTimer);
505
+ reconnectTimer = null;
506
+ try { streamRequest?.destroy?.(); } catch {}
507
+ try {
508
+ await request({
509
+ port, token: serverToken, method: 'POST', path: '/client/deregister',
510
+ body: { token: clientToken }, timeoutMs: 1500, control: true,
511
+ });
512
+ } catch { /* the daemon sweep reaps us anyway */ }
513
+ log(`detached (${reason})`);
514
+ }
515
+
516
+ return {
517
+ call,
518
+ close,
519
+ clientToken,
520
+ port,
521
+ pid: Number(discovery.pid),
522
+ protocol: Number(reg.protocol) || SESSION_PROTOCOL,
523
+ revision: Math.max(0, Number(reg.revision) || 0),
524
+ };
525
+ }
526
+
527
+ /** Ask a live daemon to exit. Used by dev/test teardown; normal clients just
528
+ * detach and let the client-grace shutdown fire. */
529
+ export async function shutdownDaemon(discovery = readSessionDiscovery()) {
530
+ if (!discovery?.port) return false;
531
+ try {
532
+ await request({
533
+ port: discovery.port, token: discovery.token, method: 'POST',
534
+ path: '/shutdown', body: {}, timeoutMs: 3000, control: true,
535
+ });
536
+ return true;
537
+ } catch { return false; }
538
+ }
539
+
540
+ // ── Shared per-process attachment ────────────────────────────────────────────
541
+ // Every session view in this process rides ONE attachment. Frames and calls are
542
+ // addressed only by durable sessionId; runtime handles never cross the wire.
543
+ let shared = null;
544
+
545
+ function addSessionView(views, sessionId, view) {
546
+ let bucket = views.get(sessionId);
547
+ if (!bucket) { bucket = new Set(); views.set(sessionId, bucket); }
548
+ bucket.add(view);
549
+ }
550
+
551
+ function removeSessionView(views, sessionId, view) {
552
+ const bucket = views.get(sessionId);
553
+ if (!bucket) return;
554
+ bucket.delete(view);
555
+ if (bucket.size === 0) views.delete(sessionId);
556
+ }
557
+
558
+ function sessionViewCount(views, sessionId) {
559
+ return views.get(sessionId)?.size ?? 0;
560
+ }
561
+
562
+ function applyStatePatch(state, patch) {
563
+ const next = { ...state, ...(patch.set || {}) };
564
+ if (patch.itemsAppend) {
565
+ const base = Array.isArray(state.items) ? state.items : [];
566
+ next.items = base.slice(0, patch.itemsAppend.from).concat(patch.itemsAppend.values || []);
567
+ }
568
+ for (const key of patch.remove || []) delete next[key];
569
+ return next;
570
+ }
571
+
572
+ const liveViews = new Set();
573
+ let reattachTimer = null;
574
+
575
+ function scheduleReattach({ cwd, log }) {
576
+ if (reattachTimer || liveViews.size === 0) return;
577
+ reattachTimer = setTimeout(() => {
578
+ reattachTimer = null;
579
+ if (liveViews.size === 0) return;
580
+ void ensureSharedAttachment({ cwd, log }).catch((err) => {
581
+ log(`daemon re-attach failed: ${err?.message || err}`);
582
+ scheduleReattach({ cwd, log });
583
+ });
584
+ }, 500);
585
+ reattachTimer.unref?.();
586
+ }
587
+
588
+ async function ensureSharedAttachment({ cwd, log }) {
589
+ if (shared?.client) return shared;
590
+ if (shared?.pending) return shared.pending;
591
+ const pending = (async () => {
592
+ const discovery = await ensureDaemon({ cwd, log });
593
+ const views = new Map();
594
+ const state = { client: null, views, discovery, refs: liveViews.size, log, cwd };
595
+ const attachmentOptions = {
596
+ cwd,
597
+ log,
598
+ onFrame: (frame) => {
599
+ const bucket = views.get(String(frame?.sessionId || ''));
600
+ if (!bucket) return;
601
+ for (const view of [...bucket]) {
602
+ if (frame.type === 'session-state') view.applyFrame(frame, state);
603
+ else if (frame.type === 'session-gone') view.applyGone(frame.reason || 'session unloaded');
604
+ }
605
+ },
606
+ onFatal: (reason) => {
607
+ log(`daemon attachment lost (${reason})`);
608
+ if (shared === state) shared = null;
609
+ for (const bucket of views.values()) {
610
+ for (const view of [...bucket]) view.applyDetached(reason);
611
+ }
612
+ scheduleReattach({ cwd: state.cwd, log });
613
+ },
614
+ };
615
+ const rawTransport = await attachSession({ discovery, ...attachmentOptions });
616
+ state.client = createSessionProtocolClient(rawTransport);
617
+ shared = state;
618
+ for (const view of liveViews) {
619
+ addSessionView(views, view.sessionId(), view);
620
+ void view.recover(state).catch((err) => {
621
+ log(`session ${view.sessionId()} recovery failed: ${err?.message || err}`);
622
+ });
623
+ }
624
+ return state;
625
+ })();
626
+ shared = { pending };
627
+ try {
628
+ return await pending;
629
+ } catch (err) {
630
+ shared = null;
631
+ throw err;
632
+ }
633
+ }
634
+
635
+ /** Remote session projection backed by the daemon,
636
+ * while execution and durable intake stay in the daemon. */
637
+ export async function createSession(options = {}) {
638
+ const log = typeof options.log === 'function' ? options.log : () => {};
639
+ const cwd = options.cwd || process.cwd();
640
+ const openParams = {
641
+ cwd,
642
+ provider: options.provider,
643
+ model: options.model,
644
+ toolMode: options.toolMode || 'full',
645
+ remote: options.remote === true,
646
+ desktopSession: options.desktopSession ?? null,
647
+ };
648
+ let attachment = await ensureSharedAttachment({ cwd, log });
649
+ const created = await attachment.client.create(openParams, {
650
+ callId: `session-create:${process.pid}:${randomUUID()}`,
651
+ });
652
+ let sessionId = String(created?.sessionId || '');
653
+ if (!sessionId) throw new Error('session.create returned no sessionId');
654
+ let state = created?.full ?? {};
655
+ let revision = Number(created?.revision) || 0;
656
+ // A revision belongs to one session projection on one daemon attachment. It
657
+ // detects a missing patch on that stream; it is not session history and
658
+ // never crosses a session rebind or daemon restart. This mirrors Codex
659
+ // thread/resume: seed a full thread snapshot, then consume notifications
660
+ // from the newly attached listener.
661
+ let revisionAttachment = attachment;
662
+ let revisionSessionId = sessionId;
663
+ let reservedOnly = created?.reservedOnly === true;
664
+ let disposed = false;
665
+ const listeners = new Set();
666
+ const resultAttachments = new WeakMap();
667
+
668
+ function emit() {
669
+ for (const listener of [...listeners]) {
670
+ try { listener(); } catch (err) { log(`session listener threw: ${err?.message || err}`); }
671
+ }
672
+ }
673
+
674
+ function applyBody(body, sourceAttachment = resultAttachments.get(body) || attachment) {
675
+ if (disposed || !body) return false;
676
+ // A response from the dead attachment may finish after recovery. It cannot
677
+ // repaint the session that is now subscribed to a replacement daemon.
678
+ if (sourceAttachment !== attachment) return true;
679
+ if (Object.hasOwn(body, 'reservedOnly')) reservedOnly = body.reservedOnly === true;
680
+ const incomingRevision = Number(body.revision);
681
+ if (revisionAttachment !== sourceAttachment || revisionSessionId !== sessionId) {
682
+ // A fresh attachment/session projection has no patch baseline. Only its
683
+ // authoritative full snapshot may establish the new epoch; numeric
684
+ // revisions from another projection are deliberately incomparable.
685
+ if (body.full === undefined || body.full === null) return false;
686
+ state = body.full;
687
+ revision = Number.isFinite(incomingRevision) ? incomingRevision : 0;
688
+ revisionAttachment = sourceAttachment;
689
+ revisionSessionId = sessionId;
690
+ emit();
691
+ return true;
692
+ }
693
+ if (Number.isFinite(incomingRevision) && incomingRevision < revision) return true;
694
+ if (body.full !== undefined && body.full !== null) {
695
+ state = body.full;
696
+ revision = Number.isFinite(incomingRevision) ? incomingRevision : revision;
697
+ emit();
698
+ return true;
699
+ }
700
+ if (body.patch) {
701
+ // The daemon answers a state-changing call AND broadcasts the same step,
702
+ // so every such call delivers one revision twice. Recognising the
703
+ // duplicate as a no-op keeps it from looking like a revision gap, which
704
+ // used to cost one extra full session.read per call.
705
+ if (Number(body.revision) === revision
706
+ && Number(body.baseRevision) === revision - 1) return true;
707
+ if (Number(body.baseRevision) !== revision) return false;
708
+ state = applyStatePatch(state, body.patch);
709
+ revision = Number(body.revision) || revision;
710
+ emit();
711
+ return true;
712
+ }
713
+ if (Number.isFinite(Number(body.revision))) {
714
+ revision = Number(body.revision);
715
+ return true;
716
+ }
717
+ return false;
718
+ }
719
+
720
+ function baseRevisionFor(
721
+ sourceAttachment = attachment,
722
+ targetSessionId = sessionId,
723
+ ) {
724
+ return revisionAttachment === sourceAttachment && revisionSessionId === targetSessionId
725
+ ? revision
726
+ : null;
727
+ }
728
+
729
+ let resyncing = false;
730
+ function resync(reason) {
731
+ if (disposed || resyncing) return;
732
+ resyncing = true;
733
+ const requestAttachment = attachment;
734
+ void requestAttachment.client.read({
735
+ sessionId,
736
+ open: openParams,
737
+ baseRevision: baseRevisionFor(requestAttachment),
738
+ }).then((result) => {
739
+ if (disposed || requestAttachment !== attachment) return;
740
+ if (result && typeof result === 'object') resultAttachments.set(result, requestAttachment);
741
+ if (!applyBody(result, requestAttachment) && result?.revision !== undefined) {
742
+ log(`session ${sessionId} resync returned an unusable body`);
743
+ }
744
+ }).catch((err) => {
745
+ log(`session ${sessionId} resync after ${reason} failed: ${err?.message || err}`);
746
+ }).finally(() => { resyncing = false; });
747
+ }
748
+
749
+ let recoveryPromise = null;
750
+ let view;
751
+ async function recover(nextAttachment) {
752
+ if (disposed) return;
753
+ if (recoveryPromise) return recoveryPromise;
754
+ recoveryPromise = (async () => {
755
+ const previousAttachment = attachment;
756
+ if (previousAttachment !== nextAttachment) {
757
+ removeSessionView(previousAttachment.views, sessionId, view);
758
+ attachment = nextAttachment;
759
+ }
760
+ const recoveryAttachment = attachment;
761
+ // Route notifications before asking the daemon to subscribe. The
762
+ // session.subscribe response is the full baseline; frames racing after
763
+ // server-side subscription are either full themselves or trigger a
764
+ // baseline read, never disappear between the response and local wiring.
765
+ addSessionView(recoveryAttachment.views, sessionId, view);
766
+ revisionAttachment = null;
767
+ revisionSessionId = '';
768
+ let result;
769
+ try {
770
+ result = await recoveryAttachment.client.subscribe({
771
+ sessionId, open: openParams, baseRevision: null,
772
+ });
773
+ } catch (error) {
774
+ // A reserved New task has no durable transcript until its first
775
+ // accepted submit. If the daemon dies before then, recreate the same
776
+ // durable address instead of silently switching the pane to another
777
+ // session. A submitted session always retries subscribe first so an
778
+ // accepted-but-unacknowledged prompt can never be shadowed.
779
+ if (!reservedOnly || !/session .* is not available/i.test(String(error?.message || error))) {
780
+ throw error;
781
+ }
782
+ result = await recoveryAttachment.client.create({
783
+ ...openParams,
784
+ sessionId,
785
+ }, {
786
+ callId: `session-recover-reservation:${sessionId}`,
787
+ });
788
+ }
789
+ if (disposed || recoveryAttachment !== attachment) return;
790
+ if (result && typeof result === 'object') resultAttachments.set(result, recoveryAttachment);
791
+ if (!applyBody(result, recoveryAttachment)) {
792
+ const baseline = await recoveryAttachment.client.read({
793
+ sessionId, open: openParams, baseRevision: null,
794
+ });
795
+ if (disposed || recoveryAttachment !== attachment) return;
796
+ if (baseline && typeof baseline === 'object') {
797
+ resultAttachments.set(baseline, recoveryAttachment);
798
+ }
799
+ if (!applyBody(baseline, recoveryAttachment)) {
800
+ throw new Error(`session ${sessionId} recovery returned no full snapshot`);
801
+ }
802
+ }
803
+ log(`session ${sessionId} projection recovered`);
804
+ })();
805
+ try {
806
+ return await recoveryPromise;
807
+ } finally {
808
+ recoveryPromise = null;
809
+ }
810
+ }
811
+
812
+ view = {
813
+ sessionId: () => sessionId,
814
+ applyFrame(frame, sourceAttachment = attachment) {
815
+ if (disposed) return;
816
+ if (!applyBody(frame, sourceAttachment)) resync('revision gap');
817
+ },
818
+ applyGone(reason) {
819
+ if (disposed) return;
820
+ log(`session ${sessionId} is no longer available (${reason})`);
821
+ },
822
+ applyDetached(reason) {
823
+ log(`session ${sessionId} projection detached (${reason})`);
824
+ },
825
+ recover,
826
+ };
827
+ addSessionView(attachment.views, sessionId, view);
828
+ attachment.refs += 1;
829
+ liveViews.add(view);
830
+
831
+ async function sendCall(route, payload, callId) {
832
+ const send = async () => {
833
+ const sourceAttachment = attachment;
834
+ let result;
835
+ if (route === 'session.create') result = await sourceAttachment.client.create(payload, { callId });
836
+ else if (route === 'session.read') result = await sourceAttachment.client.read(payload, { callId });
837
+ else if (route === 'session.subscribe') result = await sourceAttachment.client.subscribe(payload, { callId });
838
+ else if (route === 'session.submit') result = await sourceAttachment.client.submit(payload, { callId });
839
+ else if (route === 'session.abort') result = await sourceAttachment.client.abort(payload, { callId });
840
+ else if (route === 'session.approve') result = await sourceAttachment.client.approve(payload, { callId });
841
+ else if (route === 'session.configure') result = await sourceAttachment.client.configure(payload, { callId });
842
+ else if (route === 'project.list') result = await sourceAttachment.client.projectList(payload, { callId });
843
+ else if (route === 'project.inspect') result = await sourceAttachment.client.projectInspect(payload, { callId });
844
+ else if (route === 'project.add') result = await sourceAttachment.client.projectAdd(payload, { callId });
845
+ else if (route === 'project.touch') result = await sourceAttachment.client.projectTouch(payload, { callId });
846
+ else if (route === 'project.rename') result = await sourceAttachment.client.projectRename(payload, { callId });
847
+ else if (route === 'project.remove') result = await sourceAttachment.client.projectRemove(payload, { callId });
848
+ else if (route === 'project.ensureDirectory') {
849
+ result = await sourceAttachment.client.projectEnsureDirectory(payload, { callId });
850
+ }
851
+ else throw new TypeError(`session route ${route} is unavailable`);
852
+ if (result && typeof result === 'object') {
853
+ resultAttachments.set(result, sourceAttachment);
854
+ }
855
+ return result;
856
+ };
857
+ for (let attempt = 0; ; attempt += 1) {
858
+ try {
859
+ return await send();
860
+ } catch (err) {
861
+ const recoverable = err?.daemonTransportError
862
+ || /unknown client token/i.test(String(err?.message || ''));
863
+ if (!recoverable || disposed || attempt >= CALL_RECOVERY_BACKOFF_MS.length) throw err;
864
+ const waitMs = CALL_RECOVERY_BACKOFF_MS[attempt];
865
+ if (waitMs > 0) await new Promise((resolve) => { setTimeout(resolve, waitMs); });
866
+ if (disposed) throw err;
867
+ if (shared === attachment) shared = null;
868
+ const next = await ensureSharedAttachment({ cwd, log });
869
+ await recover(next);
870
+ if (attempt > 0) log(`session ${sessionId} call ${route} re-attached (attempt ${attempt + 1})`);
871
+ }
872
+ }
873
+ }
874
+
875
+ async function applyResult(result, reason) {
876
+ const sourceAttachment = resultAttachments.get(result) || attachment;
877
+ const nextSessionId = String(result?.sessionId || sessionId);
878
+ if (nextSessionId && nextSessionId !== sessionId) {
879
+ const previousSessionId = sessionId;
880
+ addSessionView(attachment.views, nextSessionId, view);
881
+ removeSessionView(attachment.views, previousSessionId, view);
882
+ sessionId = nextSessionId;
883
+ }
884
+ if (!applyBody(result, sourceAttachment) && result?.revision !== undefined) {
885
+ resync(`${reason} body gap`);
886
+ }
887
+ return result;
888
+ }
889
+
890
+ let transitionChain = Promise.resolve();
891
+ function serializeTransition(task) {
892
+ const run = transitionChain.then(task);
893
+ transitionChain = run.then(() => {}, () => {});
894
+ return run;
895
+ }
896
+
897
+ function remoteCall(method, args, callOptions = {}) {
898
+ if (disposed) return Promise.reject(new Error('This session view is disposed.'));
899
+ const route = SESSION_READ_ACTION_SET.has(method)
900
+ ? 'session.read'
901
+ : SESSION_CONFIGURE_ACTION_SET.has(method)
902
+ ? 'session.configure'
903
+ : null;
904
+ if (!route) return Promise.reject(new TypeError(`session action ${method} is unavailable`));
905
+ const targetSessionId = sessionId;
906
+ const baseRevision = baseRevisionFor(attachment, targetSessionId);
907
+ const stableCallId = typeof callOptions.callId === 'string' && callOptions.callId.trim()
908
+ ? callOptions.callId.trim()
909
+ : randomUUID();
910
+ return sendCall(route, {
911
+ sessionId: targetSessionId,
912
+ action: method,
913
+ args,
914
+ open: openParams,
915
+ baseRevision,
916
+ }, stableCallId).then(async (result) => {
917
+ if (!disposed && sessionId === targetSessionId) await applyResult(result, method);
918
+ return result?.value ?? null;
919
+ });
920
+ }
921
+
922
+ async function rebindTo(result, previousSessionId) {
923
+ const nextSessionId = String(result?.sessionId || '');
924
+ if (!nextSessionId) throw new Error('session route returned no sessionId');
925
+ const lastLocalView = sessionViewCount(attachment.views, previousSessionId) <= 1;
926
+ addSessionView(attachment.views, nextSessionId, view);
927
+ removeSessionView(attachment.views, previousSessionId, view);
928
+ sessionId = nextSessionId;
929
+ if (!applyBody(result) && result?.revision !== undefined) resync('session rebind body gap');
930
+ if (previousSessionId && previousSessionId !== nextSessionId && lastLocalView) {
931
+ try {
932
+ await attachment.client.unsubscribe({ sessionId: previousSessionId });
933
+ } catch (err) {
934
+ log(`session ${previousSessionId} unsubscribe failed: ${err?.message || err}`);
935
+ }
936
+ }
937
+ return nextSessionId;
938
+ }
939
+
940
+ async function createReservedSession() {
941
+ return serializeTransition(async () => {
942
+ if (disposed) throw new Error('This session view is disposed.');
943
+ const previousSessionId = sessionId;
944
+ const result = await sendCall('session.create', openParams,
945
+ `session-create:${process.pid}:${randomUUID()}`);
946
+ return await rebindTo(result, previousSessionId);
947
+ });
948
+ }
949
+
950
+ async function resumeSession(targetSessionId, resumeOptions) {
951
+ const target = String(targetSessionId || '');
952
+ if (!target) return false;
953
+ return serializeTransition(async () => {
954
+ if (disposed) throw new Error('This session view is disposed.');
955
+ if (target === sessionId) {
956
+ const result = await sendCall('session.read', {
957
+ sessionId,
958
+ open: openParams,
959
+ baseRevision: baseRevisionFor(),
960
+ }, randomUUID());
961
+ await applyResult(result, 'resume');
962
+ return true;
963
+ }
964
+ const previousSessionId = sessionId;
965
+ const result = await sendCall('session.subscribe', {
966
+ sessionId: target,
967
+ open: { ...openParams, resumeOptions: resumeOptions || undefined },
968
+ baseRevision: null,
969
+ }, randomUUID());
970
+ await rebindTo(result, previousSessionId);
971
+ return true;
972
+ });
973
+ }
974
+
975
+ let submitSeq = 0;
976
+ async function submitAsync(prompt, options = {}) {
977
+ if (disposed) throw new Error('This session view is disposed.');
978
+ const submissionId = String(options?.id || '').trim()
979
+ || `session-submit-${process.pid}-${Date.now()}-${(submitSeq += 1)}`;
980
+ const targetSessionId = sessionId;
981
+ const baseRevision = baseRevisionFor(attachment, targetSessionId);
982
+ const result = await sendCall('session.submit', {
983
+ sessionId: targetSessionId,
984
+ prompt,
985
+ options: { ...(options || {}), id: submissionId },
986
+ open: openParams,
987
+ baseRevision,
988
+ }, `session-submit:${targetSessionId}:${submissionId}`);
989
+ if (!disposed && sessionId === targetSessionId) await applyResult(result, 'submit');
990
+ return result?.accepted === true;
991
+ }
992
+
993
+ async function abortAsync(options = {}) {
994
+ if (disposed) return { aborted: false };
995
+ const result = await sendCall('session.abort', {
996
+ sessionId, open: openParams, options,
997
+ baseRevision: baseRevisionFor(),
998
+ }, randomUUID());
999
+ return await applyResult(result, 'abort');
1000
+ }
1001
+
1002
+ const base = {
1003
+ isRemoteSession: true,
1004
+ get disposedView() { return disposed; },
1005
+ getState: () => state,
1006
+ subscribe(listener) {
1007
+ listeners.add(listener);
1008
+ return () => listeners.delete(listener);
1009
+ },
1010
+ async listSessions(options = {}) {
1011
+ const result = await attachment.client.list(options, { callId: randomUUID() });
1012
+ return Array.isArray(result?.sessions) ? result.sessions : [];
1013
+ },
1014
+ async listProjects() {
1015
+ const result = await sendCall('project.list', {}, randomUUID());
1016
+ return Array.isArray(result?.projects) ? result.projects : [];
1017
+ },
1018
+ async inspectProjectPath(projectPath) {
1019
+ return await sendCall('project.inspect', { path: projectPath }, randomUUID());
1020
+ },
1021
+ async addProject(projectPath) {
1022
+ const result = await sendCall('project.add', { path: projectPath }, randomUUID());
1023
+ return result?.project ?? null;
1024
+ },
1025
+ async touchProjectSelected(projectPath) {
1026
+ const result = await sendCall('project.touch', { path: projectPath }, randomUUID());
1027
+ return result?.project ?? null;
1028
+ },
1029
+ async renameProject(projectPath, name) {
1030
+ const result = await sendCall('project.rename', {
1031
+ path: projectPath,
1032
+ name,
1033
+ }, randomUUID());
1034
+ return result?.project ?? null;
1035
+ },
1036
+ async removeProject(projectPath) {
1037
+ const result = await sendCall('project.remove', { path: projectPath }, randomUUID());
1038
+ return result?.removed === true;
1039
+ },
1040
+ async ensureProjectDirectory(projectPath) {
1041
+ const result = await sendCall('project.ensureDirectory', {
1042
+ path: projectPath,
1043
+ }, randomUUID());
1044
+ return String(result?.path || '');
1045
+ },
1046
+ async newSession(options = {}) {
1047
+ // A newly-created daemon view already owns a durable reserved address.
1048
+ // New-task setup may ask for a fresh session before the first submit;
1049
+ // reusing that reservation avoids a second session.create round trip and
1050
+ // duplicate provider/session prewarm. Explicit route/workflow changes
1051
+ // omit this flag and retain the ordinary new-session boundary.
1052
+ if (options?.reuseReservation === true && reservedOnly) return true;
1053
+ await createReservedSession();
1054
+ return true;
1055
+ },
1056
+ resume: resumeSession,
1057
+ async prefetchSession(targetSessionId) {
1058
+ const target = String(targetSessionId || '');
1059
+ if (!target) return false;
1060
+ await attachment.client.read({
1061
+ sessionId: target,
1062
+ open: openParams,
1063
+ baseRevision: null,
1064
+ });
1065
+ return true;
1066
+ },
1067
+ submitAsync,
1068
+ submit(prompt, options) {
1069
+ if (disposed) return false;
1070
+ void submitAsync(prompt, options).catch((err) => {
1071
+ log(`session submit failed: ${err?.message || err}`);
1072
+ });
1073
+ return true;
1074
+ },
1075
+ abortAsync,
1076
+ abort(options = {}) {
1077
+ // Cancellation must bypass this view's ordered mutation chain. A slow
1078
+ // capability/read call ahead of it must never delay the user's ESC.
1079
+ void abortAsync(options).catch((err) => log(`session abort failed: ${err?.message || err}`));
1080
+ return true;
1081
+ },
1082
+ resolveToolApproval(id, decision) {
1083
+ // Approval unblocks a live turn and has the same priority requirement as
1084
+ // abort. Revision gaps caused by an overlapping ordinary call self-heal
1085
+ // through applyResult()/resync().
1086
+ void (async () => {
1087
+ if (disposed) return;
1088
+ const result = await sendCall('session.approve', {
1089
+ sessionId,
1090
+ approvalId: id,
1091
+ decision,
1092
+ open: openParams,
1093
+ baseRevision: baseRevisionFor(),
1094
+ }, randomUUID());
1095
+ await applyResult(result, 'approval');
1096
+ })().catch((err) => log(`session approval failed: ${err?.message || err}`));
1097
+ return true;
1098
+ },
1099
+ async dispose(reason = 'view dispose') {
1100
+ if (disposed) return;
1101
+ disposed = true;
1102
+ liveViews.delete(view);
1103
+ const releasedSessionId = sessionId;
1104
+ const lastLocalView = sessionViewCount(attachment.views, releasedSessionId) <= 1;
1105
+ if (lastLocalView) {
1106
+ try {
1107
+ await attachment.client.unsubscribe({ sessionId: releasedSessionId });
1108
+ } catch (err) {
1109
+ log(`session ${releasedSessionId} unsubscribe failed: ${err?.message || err}`);
1110
+ }
1111
+ }
1112
+ removeSessionView(attachment.views, releasedSessionId, view);
1113
+ listeners.clear();
1114
+ attachment.refs = Math.max(0, attachment.refs - 1);
1115
+ if (attachment.refs === 0 && liveViews.size === 0) {
1116
+ try { await attachment.client.close(reason); } catch {}
1117
+ // Last view gone: drop pooled keep-alive sockets so an idle connection
1118
+ // cannot keep the process (or a test runner) alive.
1119
+ try { daemonCallAgent.destroy(); } catch {}
1120
+ try { daemonUrgentAgent.destroy(); } catch {}
1121
+ try { daemonControlAgent.destroy(); } catch {}
1122
+ if (shared === attachment) shared = null;
1123
+ }
1124
+ },
1125
+ };
1126
+
1127
+ for (const action of SESSION_READ_ACTIONS) {
1128
+ if (!Object.hasOwn(base, action)) base[action] = (...args) => remoteCall(action, args);
1129
+ }
1130
+ for (const action of SESSION_CONFIGURE_ACTIONS) {
1131
+ if (!Object.hasOwn(base, action)) base[action] = (...args) => remoteCall(action, args);
1132
+ }
1133
+ return base;
1134
+ }