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
@@ -1,19 +1,40 @@
1
- // Engine pool hosted by the machine-global engine daemon.
1
+ // Session runtime pool hosted by the machine-global daemon.
2
2
  //
3
- // One process owns every live session engine; the terminal TUI and the desktop
3
+ // One process owns every live session runtime; the terminal TUI and the desktop
4
4
  // app attach as VIEWS over the transport. That inverts today's model (each
5
- // client boots its own engine and the session store arbitrates ownership with
5
+ // client boots its own session runtime and the session store arbitrates ownership with
6
6
  // generation counters + heartbeat vetoes): with a single writer, cross-client
7
7
  // editing is just fan-out, and the split-brain guards can never trip against
8
8
  // our own second client.
9
9
  //
10
- // The engine factory is injected (`createEngine`) so the daemon entry supplies
11
- // the real createEngineSession while tests supply a stub.
10
+ // The session runtime factory is injected by the daemon entry.
12
11
  import { randomUUID } from 'node:crypto';
12
+ import { createRequire } from 'node:module';
13
+ import { fileURLToPath } from 'node:url';
14
+ import {
15
+ SESSION_CONFIGURE_ACTION_SET,
16
+ SESSION_READ_ACTION_SET,
17
+ } from './session-protocol.mjs';
13
18
 
14
19
  const MAX_CLONE_DEPTH = 24;
20
+ const requireDesktopService = createRequire(import.meta.url);
15
21
 
16
- /** JSON-safe projection of an engine snapshot. Functions, symbols, and
22
+ async function loadDesktopServiceModule(moduleUrl) {
23
+ const parsed = new URL(moduleUrl);
24
+ if (!parsed.pathname.toLowerCase().endsWith('.cjs')) {
25
+ return import(moduleUrl);
26
+ }
27
+ // Node's CJS bridge ignores URL search parameters and otherwise returns the
28
+ // previous install's cached exports after an in-place desktop update. Keep
29
+ // already-instantiated adapters alive, but load this newly keyed artifact
30
+ // from disk for the new desktop build.
31
+ const modulePath = fileURLToPath(parsed);
32
+ const resolved = requireDesktopService.resolve(modulePath);
33
+ delete requireDesktopService.cache[resolved];
34
+ return requireDesktopService(resolved);
35
+ }
36
+
37
+ /** JSON-safe projection of a session snapshot. Functions, symbols, and
17
38
  * undefined never survive a transport hop; dropping them here (instead of at
18
39
  * JSON.stringify time) keeps object identity stable for the receiver and
19
40
  * makes cycles impossible rather than fatal. */
@@ -66,8 +87,10 @@ export function sanitizeForWire(value, depth = 0, seen = new WeakSet()) {
66
87
  }
67
88
  }
68
89
 
69
- export function createEngineDaemonService({
70
- createEngine,
90
+ export function createSessionService({
91
+ createSessionRuntime = null,
92
+ sessionExists = null,
93
+ listSessions = null,
71
94
  desktopRuntime = null,
72
95
  publishIntervalMs = 16,
73
96
  onFrame = () => {},
@@ -76,30 +99,46 @@ export function createEngineDaemonService({
76
99
  idleEvictMs = null,
77
100
  evictSweepMs = null,
78
101
  } = {}) {
79
- if (typeof createEngine !== 'function') throw new Error('createEngine is required');
102
+ const createRuntime = createSessionRuntime;
103
+ if (typeof createRuntime !== 'function') throw new Error('createSessionRuntime is required');
104
+ // Revisions optimize deltas inside one daemon lifetime; sessionId + full
105
+ // snapshots remain the durable contract. Start each daemon far above the
106
+ // prior wall-clock epoch so a view reconnecting after replacement accepts
107
+ // the new daemon's first full snapshot.
108
+ const configuredRevisionEpoch = Number(process.env.MIXDOG_SESSION_REVISION_EPOCH);
109
+ const revisionEpoch = Number.isSafeInteger(configuredRevisionEpoch)
110
+ && configuredRevisionEpoch >= 0
111
+ ? configuredRevisionEpoch
112
+ : Math.floor(Date.now() * 1_000);
80
113
 
81
114
  // One daemon-owned execution entry per live session. Entries are never
82
115
  // addressed by clients; sessionId is the only identity outside this module.
83
116
  const sessions = new Set();
84
- let desktopBackend = null;
85
- let desktopBackendPromise = null;
117
+ const sessionsById = new Map();
118
+ let busyEntries = 0;
119
+ // Desktop adapters are keyed by their exact module URL so a dev preview and
120
+ // installed build never share module state accidentally.
121
+ const desktopServicesById = new Map();
122
+ const desktopServicesByModule = new Map();
123
+ const desktopServicePromises = new Map();
124
+ let projectStorePromise = null;
86
125
  let closed = false;
87
126
  // A turn belongs to the DAEMON, not to whoever is watching it: closing the
88
- // desktop window or restarting the TUI must never interrupt work. An engine
127
+ // desktop window or restarting the TUI must never interrupt work. An session runtime
89
128
  // whose last view left is RETAINED while it is busy and evicted only after it
90
129
  // has been idle and unwatched for this long. With a view release no longer
91
130
  // destroying anything, this sweep is the ONLY reclaim path besides shutdown.
92
131
  const IDLE_EVICT_MS = Number(idleEvictMs) > 0
93
132
  ? Number(idleEvictMs)
94
- : Math.max(60_000, Number(process.env.MIXDOG_ENGINE_IDLE_EVICT_MS) || 15 * 60_000);
133
+ : Math.max(60_000, Number(process.env.MIXDOG_SESSION_IDLE_EVICT_MS) || 2 * 60_000);
95
134
  const EVICT_SWEEP_MS = Number(evictSweepMs) > 0 ? Number(evictSweepMs) : 30_000;
96
135
  let evictTimer = null;
97
136
 
98
137
  // ── Cross-client subscriptions ──────────────────────────────────────────────
99
- // An engine is shared by construction (terminal + desktop converge on one
100
- // engine per session), but each client process only refcounts the mirrors it
138
+ // An session runtime is shared by construction (terminal + desktop converge on one
139
+ // session runtime per session), but each client process only refcounts the mirrors it
101
140
  // holds ITSELF. Without a daemon-side viewer set, the first client to quit
102
- // destroyed an engine the other one was still streaming — the turn cut out
141
+ // destroyed an session runtime the other one was still streaming — the turn cut out
103
142
  // mid-answer and the surviving view stalled. Viewers are keyed by daemon
104
143
  // CLIENT token, so "the last view left" is a machine-wide fact.
105
144
  function subscriberToken(ctx) {
@@ -116,15 +155,15 @@ export function createEngineDaemonService({
116
155
  }
117
156
 
118
157
  /** A client that deregistered (or whose process died) stops being a viewer.
119
- * Its engines are never destroyed here: work outlives the client that
120
- * walked away, so an engine nobody watches goes back on the idle clock. */
158
+ * Its session runtimes are never destroyed here: work outlives the client that
159
+ * walked away, so an session runtime nobody watches goes back on the idle clock. */
121
160
  function releaseClient(clientToken) {
122
161
  const token = String(clientToken || '');
123
162
  if (!token) return { ok: true };
124
163
  for (const entry of sessions) {
125
164
  if (!entry.subscribers?.delete(token)) continue;
126
165
  if (entry.subscribers.size > 0) continue;
127
- if (entry.reservedOnly && !engineBusy(entry)) {
166
+ if (entry.reservedOnly && !sessionBusy(entry)) {
128
167
  void destroy(entry, 'unclaimed session reservation', {
129
168
  keepBackgroundWork: true,
130
169
  });
@@ -134,17 +173,32 @@ export function createEngineDaemonService({
134
173
  startEvictionSweep();
135
174
  log(`session ${currentSessionId(entry) || '(creating)'} unwatched (client ${token} gone) — retained`);
136
175
  }
137
- if (desktopBackend) desktopBackend.subscribers.delete(token);
176
+ for (const service of desktopServicesById.values()) {
177
+ service.subscribers.delete(token);
178
+ }
138
179
  return { ok: true };
139
180
  }
140
181
 
141
- function engineBusy(entry) {
182
+ function stateBusy(state) {
183
+ return state?.busy === true || state?.commandBusy === true
184
+ || (Array.isArray(state?.queued) && state.queued.length > 0);
185
+ }
186
+
187
+ function updateEntryBusy(entry, state) {
188
+ const next = stateBusy(state);
189
+ if (entry.busy === next) return next;
190
+ if (entry.busy === true) busyEntries = Math.max(0, busyEntries - 1);
191
+ if (next) busyEntries += 1;
192
+ entry.busy = next;
193
+ return next;
194
+ }
195
+
196
+ function sessionBusy(entry) {
197
+ if (typeof entry?.busy === 'boolean') return entry.busy;
142
198
  try {
143
- const state = entry.engine.getState?.() || {};
144
- return state.busy === true || state.commandBusy === true
145
- || (Array.isArray(state.queued) && state.queued.length > 0);
199
+ return updateEntryBusy(entry, entry.runtime.getState?.() || {});
146
200
  } catch {
147
- // An engine we cannot read is never assumed idle — losing a live turn is
201
+ // An session runtime we cannot read is never assumed idle — losing a live turn is
148
202
  // far worse than holding an extra process for one sweep.
149
203
  return true;
150
204
  }
@@ -156,18 +210,48 @@ export function createEngineDaemonService({
156
210
  const now = Date.now();
157
211
  for (const entry of [...sessions]) {
158
212
  if (!entry.retainedAt) continue;
159
- // A client came back to it: watched engines are never reclaimed.
213
+ // A client came back to it: watched session runtimes are never reclaimed.
160
214
  if (entry.subscribers?.size > 0) { entry.retainedAt = null; continue; }
161
- if (engineBusy(entry)) { entry.retainedAt = now; continue; }
215
+ if (sessionBusy(entry)) { entry.retainedAt = now; continue; }
162
216
  if (now - entry.retainedAt < IDLE_EVICT_MS) continue;
163
217
  void destroy(entry, 'idle and unwatched');
164
218
  }
219
+ stopEvictionSweepIfIdle();
165
220
  }, EVICT_SWEEP_MS);
166
221
  evictTimer.unref?.();
167
222
  }
168
223
 
224
+ function stopEvictionSweepIfIdle() {
225
+ if (!evictTimer) return;
226
+ for (const entry of sessions) {
227
+ if (!entry.disposed && entry.retainedAt && (entry.subscribers?.size || 0) === 0) return;
228
+ }
229
+ clearInterval(evictTimer);
230
+ evictTimer = null;
231
+ }
232
+
233
+ function retainUnwatched(entry, reason = 'headless session budget') {
234
+ if (!entry || entry.disposed || (entry.subscribers?.size || 0) > 0) return;
235
+ entry.headless = true;
236
+ entry.retainedAt = Date.now();
237
+ releaseProjection(entry);
238
+ startEvictionSweep();
239
+ }
240
+
241
+ function releaseProjection(entry) {
242
+ if (!entry) return;
243
+ entry.snapshotSource = null;
244
+ entry.snapshotCache = null;
245
+ entry.fieldCache?.clear?.();
246
+ entry.itemCache?.clear?.();
247
+ entry.fieldCache = null;
248
+ entry.itemCache = null;
249
+ entry.publishedSnapshot = null;
250
+ entry.publishedSessionId = '';
251
+ }
252
+
169
253
  function snapshotOf(entry) {
170
- const raw = entry.engine.getState?.() ?? null;
254
+ const raw = entry.runtime.getState?.() ?? null;
171
255
  // Store states are immutable snapshots (every mutation makes a new object),
172
256
  // so identity is a sound cache key. Without this the whole transcript was
173
257
  // re-sanitized on every call AND every published frame.
@@ -236,9 +320,11 @@ export function createEngineDaemonService({
236
320
  return { set, remove, itemsAppend };
237
321
  }
238
322
 
239
- /** Advance the engine's published revision one step. */
323
+ /** Advance the session runtime's published revision one step. */
240
324
  function advance(entry) {
241
325
  const snapshot = snapshotOf(entry);
326
+ indexSessionEntry(entry, snapshot?.sessionId);
327
+ updateEntryBusy(entry, snapshot);
242
328
  const previous = entry.publishedSnapshot;
243
329
  const previousRevision = entry.revision || 0;
244
330
  if (snapshot === previous) {
@@ -264,18 +350,38 @@ export function createEngineDaemonService({
264
350
  }
265
351
 
266
352
  function currentSessionId(entry) {
267
- return String(entry?.engine?.getState?.()?.sessionId || '');
353
+ return String(entry?.runtime?.getState?.()?.sessionId || '');
354
+ }
355
+
356
+ function indexSessionEntry(entry, sessionId = currentSessionId(entry)) {
357
+ const nextId = String(sessionId || '');
358
+ const previousId = String(entry?.indexedSessionId || '');
359
+ if (previousId && previousId !== nextId && sessionsById.get(previousId) === entry) {
360
+ sessionsById.delete(previousId);
361
+ }
362
+ if (!entry || entry.disposed || !nextId) {
363
+ if (entry) entry.indexedSessionId = '';
364
+ return '';
365
+ }
366
+ const existing = sessionsById.get(nextId);
367
+ if (existing && existing !== entry && !existing.disposed) {
368
+ // Do not redirect an established address to a second session runtime. The load
369
+ throw new Error(`duplicate session address: ${nextId}`);
370
+ }
371
+ sessionsById.set(nextId, entry);
372
+ entry.indexedSessionId = nextId;
373
+ return nextId;
268
374
  }
269
375
 
270
- /** Publish one durable session-addressed frame. The engine pool is a daemon
376
+ /** Publish one durable session-addressed frame. The runtime pool is a daemon
271
377
  * implementation detail and never enters the client contract. */
272
378
  function publishStep(entry, step) {
273
379
  const sessionId = currentSessionId(entry);
274
380
  if (!sessionId) return;
275
- // Engine revisions may predate the session address (a reservation becomes
381
+ // Session runtime revisions may predate the session address (a reservation becomes
276
382
  // a materialized session during newSession/resume). A session subscriber
277
- // has no copy of that engine-only base, so the first frame for each session
278
- // address must be FULL; only later frames may use engine revision deltas.
383
+ // has no copy of that session runtime-only base, so the first frame for each session
384
+ // address must be FULL; only later frames may use session runtime revision deltas.
279
385
  const body = entry.publishedSessionId === sessionId
280
386
  ? frameBody(step)
281
387
  : { revision: step.revision, full: step.snapshot };
@@ -296,71 +402,31 @@ export function createEngineDaemonService({
296
402
  return { revision: step.revision, full: step.snapshot };
297
403
  }
298
404
 
299
- /** Values the store returns SYNCHRONOUSLY in-process. A view cannot await
300
- * them (EngineHost does `engine.listSessions().flatMap(...)` inline), so
301
- * they ride every open/call response and the view answers from its mirror. */
302
- let sharedSyncCache = null;
303
- let sharedSyncEpoch = -1;
304
-
305
- function syncSurfaceOf(entry, { refresh = false, refreshFromStorage = false } = {}) {
306
- // listSessions() reads the session STORE — a scan measured in hundreds of
307
- // milliseconds on a large store. Recomputing it on every call made each
308
- // round trip pay for it; only session-shaping calls need a fresh surface.
309
- if (!refresh && !refreshFromStorage && sharedSyncCache && sharedSyncEpoch === syncEpoch) {
310
- return sharedSyncCache;
311
- }
312
- const surface = {};
313
- try {
314
- const sessions = entry.engine.listSessions?.(
315
- refreshFromStorage ? { refreshFromStorage: true } : undefined,
316
- );
317
- if (Array.isArray(sessions)) surface.sessions = sanitizeForWire(sessions);
318
- } catch { /* a store read must never fail a call */ }
319
- try {
320
- const dir = entry.engine.sessionStoreDir?.();
321
- if (typeof dir === 'string') surface.sessionStoreDir = dir;
322
- } catch { /* optional */ }
323
- sharedSyncCache = surface;
324
- sharedSyncEpoch = syncEpoch;
325
- return surface;
326
- }
327
-
328
- // The session LIST is process-global: every entry reads the same store. A
329
- // shape change made through ONE entry must therefore expire the cached
330
- // surface of every OTHER entry, or a pane keeps serving a list that predates
331
- // the session another view just created, renamed, or deleted.
332
- let syncEpoch = 0;
333
- function invalidateSyncSurfaces() {
334
- syncEpoch += 1;
335
- sharedSyncCache = null;
336
- }
337
-
338
405
  /** Entry that currently holds a session live. */
339
406
  function sessionOwner(sessionId) {
340
407
  const id = String(sessionId || '');
341
408
  if (!id) return null;
342
- for (const entry of sessions) {
343
- if (entry.disposed) continue;
344
- if (currentSessionId(entry) === id) return entry;
409
+ const entry = sessionsById.get(id) || null;
410
+ if (!entry || entry.disposed) {
411
+ if (entry) sessionsById.delete(id);
412
+ return null;
345
413
  }
346
- return null;
414
+ return entry;
347
415
  }
348
416
 
349
- // Calls that change WHICH sessions exist or which one is loaded. Only these
350
- // invalidate the cached sync surface (a store scan).
351
- const SESSION_SHAPING_METHODS = new Set([
352
- 'resume', 'resumeSession', 'newSession', 'switchContext', 'deleteSession',
353
- 'renameSessionTitle', 'setSessionArchived', 'prefetchSession', 'submit',
354
- ]);
355
-
356
- // Engine LIFETIME belongs to the daemon: a view drives a session, it never
357
- // ends one. `subscribe` would also hand a wire client a callback it cannot
358
- // receive, and reservation is the daemon's own creation step.
359
- const FORBIDDEN_SESSION_METHODS = new Set(['dispose', 'subscribe', 'reserveSession']);
360
-
361
417
  function publish(entry) {
362
418
  if (closed || entry.disposed) return;
363
419
  try {
420
+ if ((entry.subscribers?.size || 0) === 0) {
421
+ // A headless turn still needs busy/index liveness, but no client can
422
+ // consume a wire projection. Avoid cloning the growing transcript on
423
+ // every token; the next subscriber receives a fresh full snapshot.
424
+ const raw = entry.runtime.getState?.() || {};
425
+ indexSessionEntry(entry, raw.sessionId);
426
+ updateEntryBusy(entry, raw);
427
+ releaseProjection(entry);
428
+ return;
429
+ }
364
430
  // Identical state produces no frame at all; a changed one travels as a
365
431
  // DELTA against the revision every attached view already holds.
366
432
  const step = advance(entry);
@@ -371,7 +437,7 @@ export function createEngineDaemonService({
371
437
  }
372
438
  }
373
439
 
374
- /** Engine events fire per streamed token. Publish immediately after an idle
440
+ /** Session runtime events fire per streamed token. Publish immediately after an idle
375
441
  * interval, then coalesce the rest of the burst to one display-frame clock.
376
442
  * This avoids charging every first token a fixed delay. */
377
443
  function schedulePublish(entry) {
@@ -385,8 +451,9 @@ export function createEngineDaemonService({
385
451
  }
386
452
 
387
453
  async function createEntry(params = {}, ctx = null) {
388
- if (closed) throw new Error('engine daemon service is closed');
389
- const engine = await createEngine({
454
+ if (closed) throw new Error('session service is closed');
455
+ const runtime = await createRuntime({
456
+ sessionId: params.sessionId,
390
457
  cwd: params.cwd || process.cwd(),
391
458
  provider: params.provider,
392
459
  model: params.model,
@@ -395,13 +462,22 @@ export function createEngineDaemonService({
395
462
  desktopSession: params.desktopSession ?? null,
396
463
  });
397
464
  const entry = {
398
- engine, cwd: params.cwd || process.cwd(), timer: null, disposed: false,
465
+ runtime, cwd: params.cwd || process.cwd(), timer: null, disposed: false,
399
466
  unsubscribe: null, subscribers: new Set(), reservedOnly: false, lastPublishedAt: 0,
467
+ indexedSessionId: '', busy: null, headless: !subscriberToken(ctx), retainedAt: null,
468
+ revision: revisionEpoch,
400
469
  };
401
470
  sessions.add(entry);
471
+ try {
472
+ const initialState = runtime.getState?.() || {};
473
+ indexSessionEntry(entry, initialState.sessionId);
474
+ updateEntryBusy(entry, initialState);
475
+ } catch {
476
+ updateEntryBusy(entry, { busy: true });
477
+ }
402
478
  addSubscriber(entry, ctx);
403
479
  try {
404
- entry.unsubscribe = engine.subscribe?.(() => schedulePublish(entry)) ?? null;
480
+ entry.unsubscribe = runtime.subscribe?.(() => schedulePublish(entry)) ?? null;
405
481
  } catch (err) {
406
482
  log(`session subscribe failed: ${err?.message || err}`);
407
483
  }
@@ -410,31 +486,32 @@ export function createEngineDaemonService({
410
486
 
411
487
  // ── Session-addressed calls ─────────────────────────────────────────────────
412
488
  // Views are RENDERERS: a desktop pane (or a TUI tab) must be able to hand the
413
- // backend a prompt for any session it can see, without owning an engine for
414
- // it first. The daemon resolves the session to its engine — LOADING one when
489
+ // service a prompt for any session it can see, without owning an session runtime for
490
+ // it first. The daemon resolves the session to its session runtime — LOADING one when
415
491
  // nothing hosts it — so "that session is not live here" can never reject user
416
492
  // input (user: 채팅이 안 쳐짐).
417
493
  const sessionLoads = new Map(); // sessionId -> Promise<entry>
418
494
 
419
- async function loadSessionEngine(sessionId, hints) {
495
+ async function loadSessionRuntime(sessionId, hints) {
420
496
  const entry = await createEntry({
497
+ sessionId,
421
498
  cwd: hints.cwd,
422
499
  provider: hints.provider,
423
500
  model: hints.model,
424
501
  toolMode: hints.toolMode,
425
502
  desktopSession: hints.desktopSession ?? null,
426
503
  });
427
- // Nothing is watching this engine yet: it is the daemon's own load, so the
504
+ // Nothing is watching this session runtime yet: it is the daemon's own load, so the
428
505
  // idle sweep must be able to reclaim it if no view ever attaches.
429
506
  entry.headless = true;
430
507
  let resumed = false;
431
508
  try {
432
- resumed = await entry.engine.resume?.(sessionId, hints.resumeOptions || undefined) === true;
509
+ resumed = await entry.runtime.resume?.(sessionId, hints.resumeOptions || undefined) === true;
433
510
  } catch (err) {
434
511
  await destroy(entry, 'session load failed');
435
512
  throw err;
436
513
  }
437
- const state = entry.engine.getState?.() || {};
514
+ const state = entry.runtime.getState?.() || {};
438
515
  const loaded = String(state.sessionId || '');
439
516
  // Fork-on-resume names its origin; any other id is a failed load.
440
517
  const forkedFrom = String(state.sessionForkedFrom || '');
@@ -442,48 +519,62 @@ export function createEngineDaemonService({
442
519
  await destroy(entry, 'session load mismatch');
443
520
  throw new Error(`session ${sessionId} could not be resumed`);
444
521
  }
522
+ // Publish/index the resumed identity before sessionLoads releases its
523
+ // single-flight promise. A second pane arriving in the next microtask must
524
+ // find this owner in O(1), not create a duplicate session runtime.
525
+ advance(entry);
526
+ retainUnwatched(entry, 'headless session load');
445
527
  log(`session ${sessionId} loaded on demand`);
446
528
  return entry;
447
529
  }
448
530
 
449
- /** The engine hosting sessionId: the existing owner, or a fresh load. One
450
- * load per session at a time — concurrent panes converge on one engine. */
531
+ /** The runtime hosting sessionId: the existing owner, or a fresh load. One
532
+ * load per session at a time — concurrent panes converge on one session runtime. */
451
533
  async function entryForSession(sessionId, hints = {}) {
452
534
  const owner = sessionOwner(sessionId);
453
535
  if (owner) return owner;
454
536
  const inFlight = sessionLoads.get(sessionId);
455
537
  if (inFlight) return inFlight;
456
538
  let loading;
457
- loading = loadSessionEngine(sessionId, hints).finally(() => {
539
+ loading = (async () => {
540
+ if (typeof sessionExists === 'function'
541
+ && await sessionExists(sessionId) !== true) {
542
+ // A session may have been created while the durable check was in
543
+ // flight. Reuse that owner, but never materialize an unknown address
544
+ // merely because a stale pane subscribed to it.
545
+ const lateOwner = sessionOwner(sessionId);
546
+ if (lateOwner) return lateOwner;
547
+ throw new Error(`session ${sessionId} is not available`);
548
+ }
549
+ return loadSessionRuntime(sessionId, hints);
550
+ })().finally(() => {
458
551
  if (sessionLoads.get(sessionId) === loading) sessionLoads.delete(sessionId);
459
552
  });
460
553
  sessionLoads.set(sessionId, loading);
461
554
  return loading;
462
555
  }
463
556
 
464
- /** Apply one compatibility store method to whatever engine owns a SESSION. */
465
- async function sessionCall({
466
- sessionId, method, args = [], open: openHints = {}, baseRevision = null,
467
- } = {}) {
468
- if (closed) throw new Error('engine daemon service is closed');
557
+ async function runSessionAction({
558
+ sessionId, action, args = [], open: openHints = {}, baseRevision = null,
559
+ } = {}, allowedActions) {
560
+ if (closed) throw new Error('session service is closed');
469
561
  const id = String(sessionId || '');
470
562
  if (!id) throw new TypeError('sessionId is required');
471
- const name = String(method || '');
472
- // `name in Object.prototype` blocks toString/valueOf/hasOwnProperty: they
473
- // are functions on every object but were never part of the engine contract.
474
- if (!name || name === 'constructor' || name.startsWith('__')
475
- || FORBIDDEN_SESSION_METHODS.has(name) || name in Object.prototype) {
476
- throw new TypeError(`engine method ${name} is unavailable`);
477
- }
563
+ const name = requireSessionAction(action, allowedActions);
478
564
  const entry = await entryForSession(id, openHints || {});
479
- const target = entry.engine[name];
480
- if (typeof target !== 'function') throw new TypeError(`engine method ${name} is unavailable`);
481
- const value = await target.apply(entry.engine, Array.isArray(args) ? args : []);
482
- const shaping = SESSION_SHAPING_METHODS.has(name);
483
- if (shaping) invalidateSyncSurfaces();
484
- // Keep one compact record that the call reached the backend without
485
- // serializing transcripts/catalogs into the daemon log. A cold pane peek
486
- // previously wrote hundreds of KB synchronously on the request path.
565
+ const target = entry.runtime[name];
566
+ if (typeof target !== 'function') throw new TypeError(`session action ${name} is unavailable`);
567
+ const value = await target.apply(entry.runtime, Array.isArray(args) ? args : []);
568
+ if (name === 'setCwd' && value) {
569
+ try {
570
+ const projects = await loadProjectStore();
571
+ projects.touchProjectSelected?.(value);
572
+ } catch (error) {
573
+ log(`project recency update failed (non-fatal): ${error?.message || error}`);
574
+ }
575
+ }
576
+ // Keep one compact record that the action reached the service without
577
+ // serializing transcripts/catalogs into the daemon log.
487
578
  const valueSummary = value === null || value === undefined
488
579
  ? String(value)
489
580
  : typeof value === 'object'
@@ -491,42 +582,140 @@ export function createEngineDaemonService({
491
582
  ? `array(${value.length})`
492
583
  : `object${Array.isArray(value.items) ? ` items=${value.items.length}` : ''}`
493
584
  : String(value).replace(/\s+/g, ' ').slice(0, 160);
494
- log(`session call ${name} session=${id} result=${valueSummary}`);
585
+ log(`session action ${name} session=${id} result=${valueSummary}`);
495
586
  const step = advance(entry);
496
587
  if (step.changed) {
497
588
  publishStep(entry, step);
498
589
  }
499
- // Still unwatched: keep it on the retention clock exactly like an engine a
500
- // view released, so an untouched load cannot leak past the idle window.
501
- if (entry.headless) {
502
- entry.retainedAt = Date.now();
503
- startEvictionSweep();
504
- }
590
+ // Still unwatched: keep it on the retention clock exactly like a session runtime
591
+ // released by its view, so an untouched load cannot leak past the idle window.
592
+ retainUnwatched(entry);
505
593
  return {
506
594
  value: sanitizeForWire(value) ?? null,
507
- sessionId: String(entry.engine.getState?.()?.sessionId || id),
595
+ sessionId: String(entry.runtime.getState?.()?.sessionId || id),
508
596
  ...bodyForClient(step, Number.isInteger(baseRevision) ? baseRevision : null),
509
- sync: syncSurfaceOf(entry, { refresh: shaping }),
510
597
  };
511
598
  }
512
599
 
600
+ async function listSessionCatalog(options = {}) {
601
+ if (closed) throw new Error('session service is closed');
602
+ if (typeof listSessions !== 'function') {
603
+ throw new Error('session catalog is unavailable');
604
+ }
605
+ const sessions = await listSessions(options || {});
606
+ return { sessions: sanitizeForWire(Array.isArray(sessions) ? sessions : []) };
607
+ }
608
+
609
+ async function loadProjectStore() {
610
+ if (typeof desktopRuntime?.loadProjects !== 'function') {
611
+ throw new Error('project service is unavailable');
612
+ }
613
+ projectStorePromise ??= Promise.resolve(desktopRuntime.loadProjects()).catch((error) => {
614
+ projectStorePromise = null;
615
+ throw error;
616
+ });
617
+ return projectStorePromise;
618
+ }
619
+
620
+ function requiredProjectPath(path) {
621
+ const value = String(path || '').trim();
622
+ if (!value) throw new TypeError('project path is required');
623
+ return value;
624
+ }
625
+
626
+ async function listProjectCatalog() {
627
+ const projects = await loadProjectStore();
628
+ return {
629
+ projects: sanitizeForWire(projects.listProjects?.() || []),
630
+ };
631
+ }
632
+
633
+ async function inspectProjectPath({ path } = {}) {
634
+ const projects = await loadProjectStore();
635
+ const resolved = projects.resolveProjectPath?.(requiredProjectPath(path)) || '';
636
+ if (!resolved) throw new TypeError('project path is required');
637
+ return {
638
+ path: resolved,
639
+ exists: projects.pathExists?.(resolved) === true,
640
+ directory: projects.isDirectory?.(resolved) === true,
641
+ };
642
+ }
643
+
644
+ async function addProjectEntry({ path } = {}) {
645
+ const projects = await loadProjectStore();
646
+ const project = projects.addProject?.(requiredProjectPath(path)) || null;
647
+ if (!project) throw new Error('project could not be registered');
648
+ return { project: sanitizeForWire(project) };
649
+ }
650
+
651
+ async function touchProjectEntry({ path } = {}) {
652
+ const projects = await loadProjectStore();
653
+ return {
654
+ project: sanitizeForWire(projects.touchProjectSelected?.(requiredProjectPath(path)) || null),
655
+ };
656
+ }
657
+
658
+ async function renameProjectEntry({ path, name = '' } = {}) {
659
+ const projects = await loadProjectStore();
660
+ const project = projects.renameProject?.(requiredProjectPath(path), String(name || '')) || null;
661
+ if (!project) throw new Error('project is not registered');
662
+ return { project: sanitizeForWire(project) };
663
+ }
664
+
665
+ async function removeProjectEntry({ path } = {}) {
666
+ const projects = await loadProjectStore();
667
+ return { removed: projects.removeProject?.(requiredProjectPath(path)) === true };
668
+ }
669
+
670
+ async function ensureProjectDirectory({ path } = {}) {
671
+ const projects = await loadProjectStore();
672
+ const resolved = projects.ensureDir?.(requiredProjectPath(path)) || '';
673
+ if (!resolved) throw new Error('project directory could not be created');
674
+ return { path: resolved };
675
+ }
676
+
677
+ function requireSessionAction(action, allowed) {
678
+ const name = String(action || '');
679
+ if (!allowed.has(name)) throw new TypeError(`session action ${name || '(empty)'} is unavailable`);
680
+ return name;
681
+ }
682
+
683
+ async function configureSession(params = {}, ctx = null) {
684
+ const revision = Math.max(0, Number(ctx?.revision) || 0);
685
+ const action = revision < 1 && params?.action === 'setBackend'
686
+ ? 'setChannelProvider'
687
+ : params?.action;
688
+ // Revision 0 desktop adapters routed some reads through configure because
689
+ // their local read list lagged the session surface. A newer daemon accepts
690
+ // those reads without weakening the current revision's finite lanes.
691
+ if (revision < 1 && SESSION_READ_ACTION_SET.has(String(action || ''))) {
692
+ return runSessionAction({ ...params, action }, SESSION_READ_ACTION_SET);
693
+ }
694
+ return runSessionAction({ ...params, action }, SESSION_CONFIGURE_ACTION_SET);
695
+ }
696
+
513
697
  // ── Durable session protocol ───────────────────────────────────────────────
514
698
  // A connection is only a subscription. Session execution is accepted,
515
699
  // queued, and owned here; unsubscribe/client death never calls abort or
516
700
  // dispose. The client addresses a durable session id instead of a
517
- // client-owned engine handle.
701
+ // client-owned session runtime handle.
518
702
 
519
- function sessionResult(entry, step, baseRevision = null, extra = {}) {
703
+ function sessionResult(
704
+ entry,
705
+ step,
706
+ baseRevision = null,
707
+ extra = {},
708
+ ) {
520
709
  return {
521
710
  sessionId: currentSessionId(entry),
711
+ reservedOnly: entry.reservedOnly === true,
522
712
  ...extra,
523
713
  ...bodyForClient(step, Number.isInteger(baseRevision) ? baseRevision : null),
524
- sync: syncSurfaceOf(entry),
525
714
  };
526
715
  }
527
716
 
528
717
  async function createSession(params = {}, ctx = null) {
529
- if (closed) throw new Error('engine daemon service is closed');
718
+ if (closed) throw new Error('session service is closed');
530
719
  const requestedId = String(params.sessionId || '').trim();
531
720
  if (requestedId) {
532
721
  if (!/^[A-Za-z0-9_-]+$/.test(requestedId)) throw new TypeError('sessionId is invalid');
@@ -537,26 +726,27 @@ export function createEngineDaemonService({
537
726
  return sessionResult(owner, step);
538
727
  }
539
728
  }
540
- const entry = await createEntry(params, ctx);
729
+ const reservedSessionId = requestedId
730
+ || `sess_daemon_${Date.now()}_${randomUUID().replaceAll('-', '')}`;
731
+ const entry = await createEntry({ ...params, sessionId: reservedSessionId }, ctx);
541
732
  try {
542
733
  let sessionId = currentSessionId(entry);
543
734
  if (!sessionId) {
544
- sessionId = requestedId
545
- || `sess_daemon_${Date.now()}_${randomUUID().replaceAll('-', '')}`;
735
+ sessionId = reservedSessionId;
546
736
  if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new TypeError('sessionId is invalid');
547
- const target = entry.engine.reserveSession;
737
+ const target = entry.runtime.reserveSession;
548
738
  if (typeof target !== 'function') {
549
- throw new TypeError('engine method reserveSession is unavailable');
739
+ throw new TypeError('session action reserveSession is unavailable');
550
740
  }
551
- await target.call(entry.engine, sessionId);
741
+ await target.call(entry.runtime, sessionId);
552
742
  entry.reservedOnly = true;
553
743
  sessionId = currentSessionId(entry);
554
744
  }
555
745
  if (!sessionId) throw new Error('session creation returned no sessionId');
556
746
  const step = advance(entry);
557
747
  if (step.changed) publishStep(entry, step);
558
- invalidateSyncSurfaces();
559
748
  log(`session created session=${sessionId}`);
749
+ retainUnwatched(entry, 'headless session create');
560
750
  return sessionResult(entry, step);
561
751
  } catch (error) {
562
752
  await destroy(entry, 'session creation failed', { keepBackgroundWork: true });
@@ -564,16 +754,26 @@ export function createEngineDaemonService({
564
754
  }
565
755
  }
566
756
 
567
- async function readSession({ sessionId, open: openHints = {}, baseRevision = null } = {}) {
757
+ async function readSession(params = {}, ctx = null) {
758
+ const {
759
+ sessionId, open: openHints = {}, baseRevision = null, baseSyncRevision = null,
760
+ } = params;
761
+ if (params.action != null) {
762
+ void ctx;
763
+ return runSessionAction(params, SESSION_READ_ACTION_SET);
764
+ }
568
765
  const id = String(sessionId || '');
569
766
  if (!id) throw new TypeError('sessionId is required');
570
767
  const entry = await entryForSession(id, openHints || {});
571
768
  const step = advance(entry);
769
+ retainUnwatched(entry, 'headless session read');
572
770
  return sessionResult(entry, step, baseRevision);
573
771
  }
574
772
 
575
773
  async function subscribeSession(
576
- { sessionId, open: openHints = {}, baseRevision = null } = {},
774
+ {
775
+ sessionId, open: openHints = {}, baseRevision = null, baseSyncRevision = null,
776
+ } = {},
577
777
  ctx = null,
578
778
  ) {
579
779
  const id = String(sessionId || '');
@@ -593,13 +793,14 @@ export function createEngineDaemonService({
593
793
  const token = subscriberToken(ctx);
594
794
  if (token) entry.subscribers?.delete(token);
595
795
  if ((entry.subscribers?.size || 0) === 0) {
596
- if (entry.reservedOnly && !engineBusy(entry)) {
796
+ if (entry.reservedOnly && !sessionBusy(entry)) {
597
797
  await destroy(entry, 'unclaimed session reservation', {
598
798
  keepBackgroundWork: true,
599
799
  });
600
800
  return { sessionId: id, unsubscribed: true };
601
801
  }
602
802
  entry.retainedAt = Date.now();
803
+ releaseProjection(entry);
603
804
  startEvictionSweep();
604
805
  }
605
806
  log(`session unsubscribed session=${id}${token ? ` client=${token}` : ''}`);
@@ -612,33 +813,45 @@ export function createEngineDaemonService({
612
813
  const id = String(sessionId || '');
613
814
  if (!id) throw new TypeError('sessionId is required');
614
815
  const entry = await entryForSession(id, openHints || {});
615
- const target = entry.engine.submit;
616
- if (typeof target !== 'function') throw new TypeError('engine method submit is unavailable');
617
- // Engine submit is an intake operation: it records/queues synchronously and
618
- // returns before provider execution. Awaiting Promise.resolve only
619
- // normalizes embedders; it never waits for the turn.
620
- const accepted = await Promise.resolve(target.call(entry.engine, prompt, options || {}));
816
+ const target = entry.runtime.submitAsync;
817
+ if (typeof target !== 'function') throw new TypeError('session runtime must implement submitAsync');
818
+ // Await intake only: submitAsync resolves once the prompt is represented by
819
+ // the queue/user row, while provider execution remains daemon-owned and
820
+ // detached.
821
+ const accepted = await Promise.resolve(target.call(entry.runtime, prompt, options || {}));
822
+ const firstSubmit = accepted === true && entry.reservedOnly;
621
823
  if (accepted === true) {
622
824
  entry.reservedOnly = false;
623
- // First submit materializes a reserved session in the store.
624
- invalidateSyncSurfaces();
625
825
  }
626
826
  const step = advance(entry);
627
827
  if (step.changed) publishStep(entry, step);
828
+ retainUnwatched(entry, 'headless session submit');
628
829
  log(`session submit session=${id} accepted=${accepted === true}`);
629
- return sessionResult(entry, step, baseRevision, { accepted: accepted === true });
830
+ return sessionResult(
831
+ entry,
832
+ step,
833
+ baseRevision,
834
+ { accepted: accepted === true },
835
+ );
630
836
  }
631
837
 
632
- async function abortSession({ sessionId, open: openHints = {}, baseRevision = null } = {}) {
838
+ async function abortSession({
839
+ sessionId, open: openHints = {}, options = {},
840
+ baseRevision = null,
841
+ } = {}) {
633
842
  const id = String(sessionId || '');
634
843
  if (!id) throw new TypeError('sessionId is required');
635
844
  const entry = await entryForSession(id, openHints || {});
636
- const target = entry.engine.abort;
637
- if (typeof target !== 'function') throw new TypeError('engine method abort is unavailable');
638
- const aborted = await target.call(entry.engine);
845
+ const target = entry.runtime.abort;
846
+ if (typeof target !== 'function') throw new TypeError('session action abort is unavailable');
847
+ const rawResult = await target.call(entry.runtime, options || {});
848
+ const abortResult = rawResult && typeof rawResult === 'object'
849
+ ? rawResult
850
+ : { aborted: rawResult === true };
639
851
  const step = advance(entry);
640
852
  if (step.changed) publishStep(entry, step);
641
- return sessionResult(entry, step, baseRevision, { aborted: aborted === true });
853
+ retainUnwatched(entry, 'headless session abort');
854
+ return sessionResult(entry, step, baseRevision, abortResult);
642
855
  }
643
856
 
644
857
  async function approveSession({
@@ -647,13 +860,14 @@ export function createEngineDaemonService({
647
860
  const id = String(sessionId || '');
648
861
  if (!id) throw new TypeError('sessionId is required');
649
862
  const entry = await entryForSession(id, openHints || {});
650
- const target = entry.engine.resolveToolApproval;
863
+ const target = entry.runtime.resolveToolApproval;
651
864
  if (typeof target !== 'function') {
652
- throw new TypeError('engine method resolveToolApproval is unavailable');
865
+ throw new TypeError('session action resolveToolApproval is unavailable');
653
866
  }
654
- const approved = await target.call(entry.engine, approvalId, decision);
867
+ const approved = await target.call(entry.runtime, approvalId, decision);
655
868
  const step = advance(entry);
656
869
  if (step.changed) publishStep(entry, step);
870
+ retainUnwatched(entry, 'headless session approval');
657
871
  return sessionResult(entry, step, baseRevision, { approved: approved === true });
658
872
  }
659
873
 
@@ -665,11 +879,17 @@ export function createEngineDaemonService({
665
879
  if (!entry || entry.disposed) return { ok: true };
666
880
  const sessionId = currentSessionId(entry);
667
881
  entry.disposed = true;
882
+ releaseProjection(entry);
668
883
  if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
669
884
  try { entry.unsubscribe?.(); } catch {}
670
885
  sessions.delete(entry);
671
- invalidateSyncSurfaces();
672
- try { await entry.engine.dispose?.(reason, { keepBackgroundWork }); }
886
+ stopEvictionSweepIfIdle();
887
+ if (entry.indexedSessionId && sessionsById.get(entry.indexedSessionId) === entry) {
888
+ sessionsById.delete(entry.indexedSessionId);
889
+ }
890
+ if (entry.busy === true) busyEntries = Math.max(0, busyEntries - 1);
891
+ entry.busy = false;
892
+ try { await entry.runtime.dispose?.(reason, { keepBackgroundWork }); }
673
893
  catch (err) { log(`session dispose failed session=${sessionId}: ${err?.message || err}`); }
674
894
  if (announce && sessionId) {
675
895
  onFrame({
@@ -683,7 +903,7 @@ export function createEngineDaemonService({
683
903
  return { ok: true };
684
904
  }
685
905
 
686
- // ── Desktop backend adapter ────────────────────────────────────────────────
906
+ // ── Desktop service adapter ────────────────────────────────────────────────
687
907
  // The adapter is a BUILD artifact supplied by the desktop install, but it is
688
908
  // instantiated here. Electron and its renderer only keep a transport view;
689
909
  // project/session/capability execution therefore shares this daemon process
@@ -693,11 +913,11 @@ export function createEngineDaemonService({
693
913
  if (kind === 'session-state') {
694
914
  return `desktop-event:${desktopId}:${kind}:${String(message?.sessionId || '')}`;
695
915
  }
696
- // Backend events are NOT one lane. Under one shared `backend-event` key a
916
+ // Service events are NOT one lane. Under one shared `desktop-event` key a
697
917
  // terminal flood clobbered LSP/folder events — and every other terminal —
698
918
  // whenever the stream backed up, because the backlog is latest-wins per
699
919
  // key. Name (and terminal id) keep each producer on its own key.
700
- if (kind === 'backend-event') {
920
+ if (kind === 'desktop-event') {
701
921
  const name = String(message?.name || '');
702
922
  const terminalId = name === 'terminal-data' ? String(message?.value?.id || '') : '';
703
923
  return `desktop-event:${desktopId}:${kind}:${name}${terminalId ? `:${terminalId}` : ''}`;
@@ -707,17 +927,18 @@ export function createEngineDaemonService({
707
927
 
708
928
  function publishDesktopEvent(desktopId, message) {
709
929
  const wire = sanitizeForWire(message);
710
- if (!wire || !desktopBackend || desktopBackend.desktopId !== desktopId) return;
930
+ const service = desktopServicesById.get(desktopId);
931
+ if (!wire || !service) return;
711
932
  onFrame({
712
933
  type: 'desktop-event',
713
934
  key: desktopEventKey(desktopId, wire),
714
935
  desktopId,
715
936
  message: wire,
716
- }, desktopBackend.subscribers);
937
+ }, service.subscribers);
717
938
  }
718
939
 
719
- async function initializeDesktopBackend({ desktopId, moduleUrl, options = {} } = {}) {
720
- if (closed) throw new Error('engine daemon service is closed');
940
+ async function initializeDesktopService({ desktopId, moduleUrl, options = {} } = {}) {
941
+ if (closed) throw new Error('session service is closed');
721
942
  const requestedId = String(desktopId || '').trim();
722
943
  if (!requestedId || !/^[A-Za-z0-9_-]+$/.test(requestedId)) {
723
944
  throw new TypeError('desktopId is invalid');
@@ -725,27 +946,27 @@ export function createEngineDaemonService({
725
946
  const requestedModule = String(moduleUrl || '').trim();
726
947
  let parsed;
727
948
  try { parsed = new URL(requestedModule); }
728
- catch { throw new TypeError('desktop backend moduleUrl is invalid'); }
949
+ catch { throw new TypeError('desktop service moduleUrl is invalid'); }
729
950
  if (parsed.protocol !== 'file:') {
730
- throw new TypeError('desktop backend moduleUrl must be a file URL');
731
- }
732
- if (desktopBackend) {
733
- if (desktopBackend.moduleUrl !== requestedModule) {
734
- // The daemon owns one canonical backend for its whole lifetime. A dev
735
- // and installed Electron can attach concurrently without racing to
736
- // replace it; protocol negotiation restarts genuinely incompatible
737
- // daemons before desktop.init reaches this point.
738
- log(`desktop backend already active; ignoring alternate module ${requestedModule}`);
951
+ throw new TypeError('desktop service moduleUrl must be a file URL');
952
+ }
953
+ const existingByModule = desktopServicesByModule.get(requestedModule);
954
+ if (existingByModule) return existingByModule;
955
+ const existingById = desktopServicesById.get(requestedId);
956
+ if (existingById) {
957
+ if (existingById.moduleUrl !== requestedModule) {
958
+ throw new Error(`desktopId ${requestedId} is already bound to another service module`);
739
959
  }
740
- return desktopBackend;
960
+ return existingById;
741
961
  }
742
- if (desktopBackendPromise) return desktopBackendPromise;
743
- desktopBackendPromise = (async () => {
744
- const loaded = await import(requestedModule);
745
- if (typeof loaded.createDesktopBackend !== 'function') {
746
- throw new TypeError('desktop backend module has no createDesktopBackend export');
962
+ const pending = desktopServicePromises.get(requestedModule);
963
+ if (pending) return pending;
964
+ const loading = (async () => {
965
+ const loaded = await loadDesktopServiceModule(requestedModule);
966
+ if (typeof loaded.createDesktopService !== 'function') {
967
+ throw new TypeError('desktop service module has no createDesktopService export');
747
968
  }
748
- const instance = await loaded.createDesktopBackend({
969
+ const instance = await loaded.createDesktopService({
749
970
  options: sanitizeForWire(options) || {},
750
971
  runtime: desktopRuntime,
751
972
  emit: (message) => publishDesktopEvent(requestedId, message),
@@ -755,7 +976,7 @@ export function createEngineDaemonService({
755
976
  });
756
977
  if (!instance || typeof instance.invoke !== 'function'
757
978
  || typeof instance.control !== 'function') {
758
- throw new TypeError('desktop backend adapter is invalid');
979
+ throw new TypeError('desktop service adapter is invalid');
759
980
  }
760
981
  const record = {
761
982
  desktopId: requestedId,
@@ -763,53 +984,56 @@ export function createEngineDaemonService({
763
984
  instance,
764
985
  subscribers: new Set(),
765
986
  };
766
- desktopBackend = record;
767
- log(`desktop backend loaded id=${requestedId}`);
987
+ desktopServicesById.set(requestedId, record);
988
+ desktopServicesByModule.set(requestedModule, record);
989
+ log(`desktop service loaded id=${requestedId} module=${requestedModule}`);
768
990
  return record;
769
991
  })();
992
+ desktopServicePromises.set(requestedModule, loading);
770
993
  try {
771
- return await desktopBackendPromise;
994
+ return await loading;
772
995
  } finally {
773
- desktopBackendPromise = null;
996
+ if (desktopServicePromises.get(requestedModule) === loading) {
997
+ desktopServicePromises.delete(requestedModule);
998
+ }
774
999
  }
775
1000
  }
776
1001
 
777
1002
  async function desktopInit(params = {}, ctx = null) {
778
- const backend = await initializeDesktopBackend(params);
1003
+ const service = await initializeDesktopService(params);
779
1004
  const token = subscriberToken(ctx);
780
- if (token) backend.subscribers.add(token);
781
- return { desktopId: backend.desktopId };
1005
+ if (token) service.subscribers.add(token);
1006
+ return { desktopId: service.desktopId };
782
1007
  }
783
1008
 
784
- function requireDesktopBackend(desktopId) {
1009
+ function requireDesktopService(desktopId) {
785
1010
  const id = String(desktopId || '');
786
- if (!desktopBackend || desktopBackend.desktopId !== id) {
787
- throw new Error('desktop backend is not initialized');
788
- }
789
- return desktopBackend;
1011
+ const service = desktopServicesById.get(id);
1012
+ if (!service) throw new Error('desktop service is not initialized');
1013
+ return service;
790
1014
  }
791
1015
 
792
1016
  async function desktopInvoke({ desktopId, method, args = [] } = {}, ctx = null) {
793
- const backend = requireDesktopBackend(desktopId);
1017
+ const service = requireDesktopService(desktopId);
794
1018
  const token = subscriberToken(ctx);
795
- if (token) backend.subscribers.add(token);
1019
+ if (token) service.subscribers.add(token);
796
1020
  const name = String(method || '');
797
- if (!name) throw new TypeError('desktop backend method is required');
798
- return sanitizeForWire(await backend.instance.invoke(name, Array.isArray(args) ? args : [])) ?? null;
1021
+ if (!name) throw new TypeError('desktop service method is required');
1022
+ return sanitizeForWire(await service.instance.invoke(name, Array.isArray(args) ? args : [])) ?? null;
799
1023
  }
800
1024
 
801
1025
  async function desktopControl({ desktopId, message } = {}, ctx = null) {
802
- const backend = requireDesktopBackend(desktopId);
1026
+ const service = requireDesktopService(desktopId);
803
1027
  const token = subscriberToken(ctx);
804
- if (token) backend.subscribers.add(token);
805
- await backend.instance.control(sanitizeForWire(message) || {});
1028
+ if (token) service.subscribers.add(token);
1029
+ await service.instance.control(sanitizeForWire(message) || {});
806
1030
  return { ok: true };
807
1031
  }
808
1032
 
809
1033
  async function desktopUnsubscribe({ desktopId } = {}, ctx = null) {
810
- const backend = requireDesktopBackend(desktopId);
1034
+ const service = requireDesktopService(desktopId);
811
1035
  const token = subscriberToken(ctx);
812
- if (token) backend.subscribers.delete(token);
1036
+ if (token) service.subscribers.delete(token);
813
1037
  return { ok: true, unsubscribed: true };
814
1038
  }
815
1039
 
@@ -818,6 +1042,14 @@ export function createEngineDaemonService({
818
1042
  'desktop.invoke': desktopInvoke,
819
1043
  'desktop.control': desktopControl,
820
1044
  'desktop.unsubscribe': desktopUnsubscribe,
1045
+ 'project.list': listProjectCatalog,
1046
+ 'project.inspect': inspectProjectPath,
1047
+ 'project.add': addProjectEntry,
1048
+ 'project.touch': touchProjectEntry,
1049
+ 'project.rename': renameProjectEntry,
1050
+ 'project.remove': removeProjectEntry,
1051
+ 'project.ensureDirectory': ensureProjectDirectory,
1052
+ 'session.list': listSessionCatalog,
821
1053
  'session.create': createSession,
822
1054
  'session.read': readSession,
823
1055
  'session.subscribe': subscribeSession,
@@ -825,23 +1057,26 @@ export function createEngineDaemonService({
825
1057
  'session.submit': submitSession,
826
1058
  'session.abort': abortSession,
827
1059
  'session.approve': approveSession,
828
- 'session.invoke': sessionCall,
1060
+ 'session.configure': configureSession,
829
1061
  };
830
1062
 
831
1063
  async function handleCall(name, args = {}, ctx = null) {
832
1064
  const route = routes[String(name || '')];
833
- if (!route) throw new Error(`unknown engine daemon call ${name}`);
1065
+ if (!route) throw new Error(`unknown session service call ${name}`);
834
1066
  return route(args || {}, ctx);
835
1067
  }
836
1068
 
837
1069
  async function stop(reason = 'service stop') {
838
1070
  closed = true;
839
1071
  if (evictTimer) { clearInterval(evictTimer); evictTimer = null; }
840
- const backend = desktopBackend;
841
- desktopBackend = null;
842
- if (backend?.instance?.dispose) {
843
- try { await backend.instance.dispose(reason); }
844
- catch (error) { log(`desktop backend dispose failed: ${error?.message || error}`); }
1072
+ const services = [...new Set(desktopServicesById.values())];
1073
+ desktopServicesById.clear();
1074
+ desktopServicesByModule.clear();
1075
+ desktopServicePromises.clear();
1076
+ for (const service of services) {
1077
+ if (!service?.instance?.dispose) continue;
1078
+ try { await service.instance.dispose(reason); }
1079
+ catch (error) { log(`desktop service dispose failed: ${error?.message || error}`); }
845
1080
  }
846
1081
  for (const entry of [...sessions]) {
847
1082
  await destroy(entry, reason);
@@ -849,16 +1084,38 @@ export function createEngineDaemonService({
849
1084
  }
850
1085
 
851
1086
  return {
852
- handleCall, createSession, readSession, subscribeSession, unsubscribeSession,
1087
+ handleCall, listSessionCatalog, createSession, readSession, subscribeSession, unsubscribeSession,
853
1088
  submitSession, abortSession, approveSession,
854
- sessionCall, stop, releaseClient,
1089
+ configureSession, stop, releaseClient,
855
1090
  get size() { return sessions.size; },
856
1091
  /** Live work the daemon must not abandon (self-shutdown guard). */
857
- get busyCount() { return [...sessions].filter((entry) => engineBusy(entry)).length; },
858
- /** Phone clients hosted by the daemon-owned desktop adapter. */
1092
+ get busyCount() { return busyEntries; },
1093
+ get status() {
1094
+ let watched = 0;
1095
+ let retained = 0;
1096
+ let projected = 0;
1097
+ for (const entry of sessions) {
1098
+ if ((entry.subscribers?.size || 0) > 0) watched += 1;
1099
+ else if (entry.retainedAt) retained += 1;
1100
+ if (entry.snapshotCache || entry.publishedSnapshot) projected += 1;
1101
+ }
1102
+ return {
1103
+ live: sessions.size,
1104
+ busy: busyEntries,
1105
+ watched,
1106
+ retained,
1107
+ projected,
1108
+ evictionSweepActive: evictTimer !== null,
1109
+ };
1110
+ },
1111
+ /** Phone clients hosted by daemon-owned desktop adapters. */
859
1112
  get externalClientCount() {
860
- const count = Number(desktopBackend?.instance?.clientCount ?? 0);
861
- return Number.isSafeInteger(count) && count > 0 ? count : 0;
1113
+ let total = 0;
1114
+ for (const service of desktopServicesById.values()) {
1115
+ const count = Number(service?.instance?.clientCount ?? 0);
1116
+ if (Number.isSafeInteger(count) && count > 0) total += count;
1117
+ }
1118
+ return total;
862
1119
  },
863
1120
  };
864
1121
  }