mixdog 0.9.95 → 0.9.96

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 (178) 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/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -10,8 +10,30 @@
10
10
  // The engine factory is injected (`createEngine`) so the daemon entry supplies
11
11
  // the real createEngineSession while tests supply a stub.
12
12
  import { randomUUID } from 'node:crypto';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+ import {
16
+ SESSION_CONFIGURE_ACTION_SET,
17
+ SESSION_READ_ACTION_SET,
18
+ } from './session-protocol.mjs';
13
19
 
14
20
  const MAX_CLONE_DEPTH = 24;
21
+ const requireDesktopBackend = createRequire(import.meta.url);
22
+
23
+ async function loadDesktopBackendModule(moduleUrl) {
24
+ const parsed = new URL(moduleUrl);
25
+ if (!parsed.pathname.toLowerCase().endsWith('.cjs')) {
26
+ return import(moduleUrl);
27
+ }
28
+ // Node's CJS bridge ignores URL search parameters and otherwise returns the
29
+ // previous install's cached exports after an in-place desktop update. Keep
30
+ // already-instantiated adapters alive, but load this newly keyed artifact
31
+ // from disk for the new desktop build.
32
+ const modulePath = fileURLToPath(parsed);
33
+ const resolved = requireDesktopBackend.resolve(modulePath);
34
+ delete requireDesktopBackend.cache[resolved];
35
+ return requireDesktopBackend(resolved);
36
+ }
15
37
 
16
38
  /** JSON-safe projection of an engine snapshot. Functions, symbols, and
17
39
  * undefined never survive a transport hop; dropping them here (instead of at
@@ -68,6 +90,8 @@ export function sanitizeForWire(value, depth = 0, seen = new WeakSet()) {
68
90
 
69
91
  export function createEngineDaemonService({
70
92
  createEngine,
93
+ sessionExists = null,
94
+ listSessions = null,
71
95
  desktopRuntime = null,
72
96
  publishIntervalMs = 16,
73
97
  onFrame = () => {},
@@ -77,12 +101,32 @@ export function createEngineDaemonService({
77
101
  evictSweepMs = null,
78
102
  } = {}) {
79
103
  if (typeof createEngine !== 'function') throw new Error('createEngine 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 an already-running legacy view (which compared
107
+ // revisions across reconnects) still accepts the replacement daemon's first
108
+ // full snapshot. New views explicitly reset their revision baseline on
109
+ // attachment replacement and do not depend on this compatibility seed.
110
+ const configuredRevisionEpoch = Number(process.env.MIXDOG_ENGINE_REVISION_EPOCH);
111
+ const revisionEpoch = Number.isSafeInteger(configuredRevisionEpoch)
112
+ && configuredRevisionEpoch >= 0
113
+ ? configuredRevisionEpoch
114
+ : Math.floor(Date.now() * 1_000);
80
115
 
81
116
  // One daemon-owned execution entry per live session. Entries are never
82
117
  // addressed by clients; sessionId is the only identity outside this module.
83
118
  const sessions = new Set();
84
- let desktopBackend = null;
85
- let desktopBackendPromise = null;
119
+ const sessionsById = new Map();
120
+ let busyEntries = 0;
121
+ // Desktop builds can overlap during an update (or while a dev preview is
122
+ // open). Their adapters are build artifacts and therefore must not be
123
+ // mistaken for one process-global implementation. Reuse an adapter only for
124
+ // the same module URL; compatible builds otherwise coexist on the stable
125
+ // daemon wire protocol.
126
+ const desktopBackendsById = new Map();
127
+ const desktopBackendsByModule = new Map();
128
+ const desktopBackendPromises = new Map();
129
+ let projectStorePromise = null;
86
130
  let closed = false;
87
131
  // A turn belongs to the DAEMON, not to whoever is watching it: closing the
88
132
  // desktop window or restarting the TUI must never interrupt work. An engine
@@ -91,7 +135,7 @@ export function createEngineDaemonService({
91
135
  // destroying anything, this sweep is the ONLY reclaim path besides shutdown.
92
136
  const IDLE_EVICT_MS = Number(idleEvictMs) > 0
93
137
  ? Number(idleEvictMs)
94
- : Math.max(60_000, Number(process.env.MIXDOG_ENGINE_IDLE_EVICT_MS) || 15 * 60_000);
138
+ : Math.max(60_000, Number(process.env.MIXDOG_ENGINE_IDLE_EVICT_MS) || 2 * 60_000);
95
139
  const EVICT_SWEEP_MS = Number(evictSweepMs) > 0 ? Number(evictSweepMs) : 30_000;
96
140
  let evictTimer = null;
97
141
 
@@ -134,15 +178,30 @@ export function createEngineDaemonService({
134
178
  startEvictionSweep();
135
179
  log(`session ${currentSessionId(entry) || '(creating)'} unwatched (client ${token} gone) — retained`);
136
180
  }
137
- if (desktopBackend) desktopBackend.subscribers.delete(token);
181
+ for (const backend of desktopBackendsById.values()) {
182
+ backend.subscribers.delete(token);
183
+ }
138
184
  return { ok: true };
139
185
  }
140
186
 
187
+ function stateBusy(state) {
188
+ return state?.busy === true || state?.commandBusy === true
189
+ || (Array.isArray(state?.queued) && state.queued.length > 0);
190
+ }
191
+
192
+ function updateEntryBusy(entry, state) {
193
+ const next = stateBusy(state);
194
+ if (entry.busy === next) return next;
195
+ if (entry.busy === true) busyEntries = Math.max(0, busyEntries - 1);
196
+ if (next) busyEntries += 1;
197
+ entry.busy = next;
198
+ return next;
199
+ }
200
+
141
201
  function engineBusy(entry) {
202
+ if (typeof entry?.busy === 'boolean') return entry.busy;
142
203
  try {
143
- const state = entry.engine.getState?.() || {};
144
- return state.busy === true || state.commandBusy === true
145
- || (Array.isArray(state.queued) && state.queued.length > 0);
204
+ return updateEntryBusy(entry, entry.engine.getState?.() || {});
146
205
  } catch {
147
206
  // An engine we cannot read is never assumed idle — losing a live turn is
148
207
  // far worse than holding an extra process for one sweep.
@@ -166,6 +225,26 @@ export function createEngineDaemonService({
166
225
  evictTimer.unref?.();
167
226
  }
168
227
 
228
+ function retainUnwatched(entry, reason = 'headless session budget') {
229
+ if (!entry || entry.disposed || (entry.subscribers?.size || 0) > 0) return;
230
+ entry.headless = true;
231
+ entry.retainedAt = Date.now();
232
+ releaseProjection(entry);
233
+ startEvictionSweep();
234
+ }
235
+
236
+ function releaseProjection(entry) {
237
+ if (!entry) return;
238
+ entry.snapshotSource = null;
239
+ entry.snapshotCache = null;
240
+ entry.fieldCache?.clear?.();
241
+ entry.itemCache?.clear?.();
242
+ entry.fieldCache = null;
243
+ entry.itemCache = null;
244
+ entry.publishedSnapshot = null;
245
+ entry.publishedSessionId = '';
246
+ }
247
+
169
248
  function snapshotOf(entry) {
170
249
  const raw = entry.engine.getState?.() ?? null;
171
250
  // Store states are immutable snapshots (every mutation makes a new object),
@@ -239,6 +318,8 @@ export function createEngineDaemonService({
239
318
  /** Advance the engine's published revision one step. */
240
319
  function advance(entry) {
241
320
  const snapshot = snapshotOf(entry);
321
+ indexSessionEntry(entry, snapshot?.sessionId);
322
+ updateEntryBusy(entry, snapshot);
242
323
  const previous = entry.publishedSnapshot;
243
324
  const previousRevision = entry.revision || 0;
244
325
  if (snapshot === previous) {
@@ -267,6 +348,30 @@ export function createEngineDaemonService({
267
348
  return String(entry?.engine?.getState?.()?.sessionId || '');
268
349
  }
269
350
 
351
+ function indexSessionEntry(entry, sessionId = currentSessionId(entry)) {
352
+ const nextId = String(sessionId || '');
353
+ const previousId = String(entry?.indexedSessionId || '');
354
+ if (previousId && previousId !== nextId && sessionsById.get(previousId) === entry) {
355
+ sessionsById.delete(previousId);
356
+ }
357
+ if (!entry || entry.disposed || !nextId) {
358
+ if (entry) entry.indexedSessionId = '';
359
+ return '';
360
+ }
361
+ const existing = sessionsById.get(nextId);
362
+ if (existing && existing !== entry && !existing.disposed) {
363
+ // Do not redirect an established address to a second engine. The load
364
+ // coordinator normally makes this impossible; retaining the first owner
365
+ // is the safest fallback if an embedder mutates a session id directly.
366
+ entry.indexedSessionId = '';
367
+ log(`duplicate session address refused session=${nextId}`);
368
+ return nextId;
369
+ }
370
+ sessionsById.set(nextId, entry);
371
+ entry.indexedSessionId = nextId;
372
+ return nextId;
373
+ }
374
+
270
375
  /** Publish one durable session-addressed frame. The engine pool is a daemon
271
376
  * implementation detail and never enters the client contract. */
272
377
  function publishStep(entry, step) {
@@ -296,71 +401,31 @@ export function createEngineDaemonService({
296
401
  return { revision: step.revision, full: step.snapshot };
297
402
  }
298
403
 
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
404
  /** Entry that currently holds a session live. */
339
405
  function sessionOwner(sessionId) {
340
406
  const id = String(sessionId || '');
341
407
  if (!id) return null;
342
- for (const entry of sessions) {
343
- if (entry.disposed) continue;
344
- if (currentSessionId(entry) === id) return entry;
408
+ const entry = sessionsById.get(id) || null;
409
+ if (!entry || entry.disposed) {
410
+ if (entry) sessionsById.delete(id);
411
+ return null;
345
412
  }
346
- return null;
413
+ return entry;
347
414
  }
348
415
 
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
416
  function publish(entry) {
362
417
  if (closed || entry.disposed) return;
363
418
  try {
419
+ if ((entry.subscribers?.size || 0) === 0) {
420
+ // A headless turn still needs busy/index liveness, but no client can
421
+ // consume a wire projection. Avoid cloning the growing transcript on
422
+ // every token; the next subscriber receives a fresh full snapshot.
423
+ const raw = entry.engine.getState?.() || {};
424
+ indexSessionEntry(entry, raw.sessionId);
425
+ updateEntryBusy(entry, raw);
426
+ releaseProjection(entry);
427
+ return;
428
+ }
364
429
  // Identical state produces no frame at all; a changed one travels as a
365
430
  // DELTA against the revision every attached view already holds.
366
431
  const step = advance(entry);
@@ -397,8 +462,17 @@ export function createEngineDaemonService({
397
462
  const entry = {
398
463
  engine, cwd: params.cwd || process.cwd(), timer: null, disposed: false,
399
464
  unsubscribe: null, subscribers: new Set(), reservedOnly: false, lastPublishedAt: 0,
465
+ indexedSessionId: '', busy: null, headless: !subscriberToken(ctx), retainedAt: null,
466
+ revision: revisionEpoch,
400
467
  };
401
468
  sessions.add(entry);
469
+ try {
470
+ const initialState = engine.getState?.() || {};
471
+ indexSessionEntry(entry, initialState.sessionId);
472
+ updateEntryBusy(entry, initialState);
473
+ } catch {
474
+ updateEntryBusy(entry, { busy: true });
475
+ }
402
476
  addSubscriber(entry, ctx);
403
477
  try {
404
478
  entry.unsubscribe = engine.subscribe?.(() => schedulePublish(entry)) ?? null;
@@ -442,6 +516,11 @@ export function createEngineDaemonService({
442
516
  await destroy(entry, 'session load mismatch');
443
517
  throw new Error(`session ${sessionId} could not be resumed`);
444
518
  }
519
+ // Publish/index the resumed identity before sessionLoads releases its
520
+ // single-flight promise. A second pane arriving in the next microtask must
521
+ // find this owner in O(1), not create a duplicate engine.
522
+ advance(entry);
523
+ retainUnwatched(entry, 'headless session load');
445
524
  log(`session ${sessionId} loaded on demand`);
446
525
  return entry;
447
526
  }
@@ -454,36 +533,45 @@ export function createEngineDaemonService({
454
533
  const inFlight = sessionLoads.get(sessionId);
455
534
  if (inFlight) return inFlight;
456
535
  let loading;
457
- loading = loadSessionEngine(sessionId, hints).finally(() => {
536
+ loading = (async () => {
537
+ if (typeof sessionExists === 'function'
538
+ && await sessionExists(sessionId) !== true) {
539
+ // A session may have been created while the durable check was in
540
+ // flight. Reuse that owner, but never materialize an unknown address
541
+ // merely because a stale pane subscribed to it.
542
+ const lateOwner = sessionOwner(sessionId);
543
+ if (lateOwner) return lateOwner;
544
+ throw new Error(`session ${sessionId} is not available`);
545
+ }
546
+ return loadSessionEngine(sessionId, hints);
547
+ })().finally(() => {
458
548
  if (sessionLoads.get(sessionId) === loading) sessionLoads.delete(sessionId);
459
549
  });
460
550
  sessionLoads.set(sessionId, loading);
461
551
  return loading;
462
552
  }
463
553
 
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
- } = {}) {
554
+ async function runSessionAction({
555
+ sessionId, action, args = [], open: openHints = {}, baseRevision = null,
556
+ } = {}, allowedActions) {
468
557
  if (closed) throw new Error('engine daemon service is closed');
469
558
  const id = String(sessionId || '');
470
559
  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
- }
560
+ const name = requireSessionAction(action, allowedActions);
478
561
  const entry = await entryForSession(id, openHints || {});
479
562
  const target = entry.engine[name];
480
- if (typeof target !== 'function') throw new TypeError(`engine method ${name} is unavailable`);
563
+ if (typeof target !== 'function') throw new TypeError(`session action ${name} is unavailable`);
481
564
  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
+ if (name === 'setCwd' && value) {
566
+ try {
567
+ const projects = await loadProjectStore();
568
+ projects.touchProjectSelected?.(value);
569
+ } catch (error) {
570
+ log(`project recency update failed (non-fatal): ${error?.message || error}`);
571
+ }
572
+ }
573
+ // Keep one compact record that the action reached the backend without
574
+ // serializing transcripts/catalogs into the daemon log.
487
575
  const valueSummary = value === null || value === undefined
488
576
  ? String(value)
489
577
  : typeof value === 'object'
@@ -491,37 +579,126 @@ export function createEngineDaemonService({
491
579
  ? `array(${value.length})`
492
580
  : `object${Array.isArray(value.items) ? ` items=${value.items.length}` : ''}`
493
581
  : String(value).replace(/\s+/g, ' ').slice(0, 160);
494
- log(`session call ${name} session=${id} result=${valueSummary}`);
582
+ log(`session action ${name} session=${id} result=${valueSummary}`);
495
583
  const step = advance(entry);
496
584
  if (step.changed) {
497
585
  publishStep(entry, step);
498
586
  }
499
587
  // Still unwatched: keep it on the retention clock exactly like an engine a
500
588
  // view released, so an untouched load cannot leak past the idle window.
501
- if (entry.headless) {
502
- entry.retainedAt = Date.now();
503
- startEvictionSweep();
504
- }
589
+ retainUnwatched(entry);
505
590
  return {
506
591
  value: sanitizeForWire(value) ?? null,
507
592
  sessionId: String(entry.engine.getState?.()?.sessionId || id),
508
593
  ...bodyForClient(step, Number.isInteger(baseRevision) ? baseRevision : null),
509
- sync: syncSurfaceOf(entry, { refresh: shaping }),
510
594
  };
511
595
  }
512
596
 
597
+ async function listSessionCatalog(options = {}) {
598
+ if (closed) throw new Error('engine daemon service is closed');
599
+ if (typeof listSessions !== 'function') {
600
+ throw new Error('session catalog is unavailable');
601
+ }
602
+ const sessions = await listSessions(options || {});
603
+ return { sessions: sanitizeForWire(Array.isArray(sessions) ? sessions : []) };
604
+ }
605
+
606
+ async function loadProjectStore() {
607
+ if (typeof desktopRuntime?.loadProjects !== 'function') {
608
+ throw new Error('project backend is unavailable');
609
+ }
610
+ projectStorePromise ??= Promise.resolve(desktopRuntime.loadProjects()).catch((error) => {
611
+ projectStorePromise = null;
612
+ throw error;
613
+ });
614
+ return projectStorePromise;
615
+ }
616
+
617
+ function requiredProjectPath(path) {
618
+ const value = String(path || '').trim();
619
+ if (!value) throw new TypeError('project path is required');
620
+ return value;
621
+ }
622
+
623
+ async function listProjectCatalog() {
624
+ const projects = await loadProjectStore();
625
+ return {
626
+ projects: sanitizeForWire(projects.listProjects?.() || []),
627
+ };
628
+ }
629
+
630
+ async function inspectProjectPath({ path } = {}) {
631
+ const projects = await loadProjectStore();
632
+ const resolved = projects.resolveProjectPath?.(requiredProjectPath(path)) || '';
633
+ if (!resolved) throw new TypeError('project path is required');
634
+ return {
635
+ path: resolved,
636
+ exists: projects.pathExists?.(resolved) === true,
637
+ directory: projects.isDirectory?.(resolved) === true,
638
+ };
639
+ }
640
+
641
+ async function addProjectEntry({ path } = {}) {
642
+ const projects = await loadProjectStore();
643
+ const project = projects.addProject?.(requiredProjectPath(path)) || null;
644
+ if (!project) throw new Error('project could not be registered');
645
+ return { project: sanitizeForWire(project) };
646
+ }
647
+
648
+ async function touchProjectEntry({ path } = {}) {
649
+ const projects = await loadProjectStore();
650
+ return {
651
+ project: sanitizeForWire(projects.touchProjectSelected?.(requiredProjectPath(path)) || null),
652
+ };
653
+ }
654
+
655
+ async function renameProjectEntry({ path, name = '' } = {}) {
656
+ const projects = await loadProjectStore();
657
+ const project = projects.renameProject?.(requiredProjectPath(path), String(name || '')) || null;
658
+ if (!project) throw new Error('project is not registered');
659
+ return { project: sanitizeForWire(project) };
660
+ }
661
+
662
+ async function removeProjectEntry({ path } = {}) {
663
+ const projects = await loadProjectStore();
664
+ return { removed: projects.removeProject?.(requiredProjectPath(path)) === true };
665
+ }
666
+
667
+ async function ensureProjectDirectory({ path } = {}) {
668
+ const projects = await loadProjectStore();
669
+ const resolved = projects.ensureDir?.(requiredProjectPath(path)) || '';
670
+ if (!resolved) throw new Error('project directory could not be created');
671
+ return { path: resolved };
672
+ }
673
+
674
+ function requireSessionAction(action, allowed) {
675
+ const name = String(action || '');
676
+ if (!allowed.has(name)) throw new TypeError(`session action ${name || '(empty)'} is unavailable`);
677
+ return name;
678
+ }
679
+
680
+ async function configureSession(params = {}, ctx = null) {
681
+ void ctx;
682
+ return runSessionAction(params, SESSION_CONFIGURE_ACTION_SET);
683
+ }
684
+
513
685
  // ── Durable session protocol ───────────────────────────────────────────────
514
686
  // A connection is only a subscription. Session execution is accepted,
515
687
  // queued, and owned here; unsubscribe/client death never calls abort or
516
688
  // dispose. The client addresses a durable session id instead of a
517
689
  // client-owned engine handle.
518
690
 
519
- function sessionResult(entry, step, baseRevision = null, extra = {}) {
691
+ function sessionResult(
692
+ entry,
693
+ step,
694
+ baseRevision = null,
695
+ extra = {},
696
+ ) {
520
697
  return {
521
698
  sessionId: currentSessionId(entry),
699
+ reservedOnly: entry.reservedOnly === true,
522
700
  ...extra,
523
701
  ...bodyForClient(step, Number.isInteger(baseRevision) ? baseRevision : null),
524
- sync: syncSurfaceOf(entry),
525
702
  };
526
703
  }
527
704
 
@@ -555,8 +732,8 @@ export function createEngineDaemonService({
555
732
  if (!sessionId) throw new Error('session creation returned no sessionId');
556
733
  const step = advance(entry);
557
734
  if (step.changed) publishStep(entry, step);
558
- invalidateSyncSurfaces();
559
735
  log(`session created session=${sessionId}`);
736
+ retainUnwatched(entry, 'headless session create');
560
737
  return sessionResult(entry, step);
561
738
  } catch (error) {
562
739
  await destroy(entry, 'session creation failed', { keepBackgroundWork: true });
@@ -564,16 +741,26 @@ export function createEngineDaemonService({
564
741
  }
565
742
  }
566
743
 
567
- async function readSession({ sessionId, open: openHints = {}, baseRevision = null } = {}) {
744
+ async function readSession(params = {}, ctx = null) {
745
+ const {
746
+ sessionId, open: openHints = {}, baseRevision = null, baseSyncRevision = null,
747
+ } = params;
748
+ if (params.action != null) {
749
+ void ctx;
750
+ return runSessionAction(params, SESSION_READ_ACTION_SET);
751
+ }
568
752
  const id = String(sessionId || '');
569
753
  if (!id) throw new TypeError('sessionId is required');
570
754
  const entry = await entryForSession(id, openHints || {});
571
755
  const step = advance(entry);
756
+ retainUnwatched(entry, 'headless session read');
572
757
  return sessionResult(entry, step, baseRevision);
573
758
  }
574
759
 
575
760
  async function subscribeSession(
576
- { sessionId, open: openHints = {}, baseRevision = null } = {},
761
+ {
762
+ sessionId, open: openHints = {}, baseRevision = null, baseSyncRevision = null,
763
+ } = {},
577
764
  ctx = null,
578
765
  ) {
579
766
  const id = String(sessionId || '');
@@ -600,6 +787,7 @@ export function createEngineDaemonService({
600
787
  return { sessionId: id, unsubscribed: true };
601
788
  }
602
789
  entry.retainedAt = Date.now();
790
+ releaseProjection(entry);
603
791
  startEvictionSweep();
604
792
  }
605
793
  log(`session unsubscribed session=${id}${token ? ` client=${token}` : ''}`);
@@ -612,33 +800,47 @@ export function createEngineDaemonService({
612
800
  const id = String(sessionId || '');
613
801
  if (!id) throw new TypeError('sessionId is required');
614
802
  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.
803
+ const target = typeof entry.engine.submitAsync === 'function'
804
+ ? entry.engine.submitAsync
805
+ : entry.engine.submit;
806
+ if (typeof target !== 'function') throw new TypeError('engine submit intake is unavailable');
807
+ // Await intake only: submitAsync resolves once the prompt is represented by
808
+ // the queue/user row, while provider execution remains daemon-owned and
809
+ // detached. Legacy embedders with synchronous submit retain the fallback.
620
810
  const accepted = await Promise.resolve(target.call(entry.engine, prompt, options || {}));
811
+ const firstSubmit = accepted === true && entry.reservedOnly;
621
812
  if (accepted === true) {
622
813
  entry.reservedOnly = false;
623
- // First submit materializes a reserved session in the store.
624
- invalidateSyncSurfaces();
625
814
  }
626
815
  const step = advance(entry);
627
816
  if (step.changed) publishStep(entry, step);
817
+ retainUnwatched(entry, 'headless session submit');
628
818
  log(`session submit session=${id} accepted=${accepted === true}`);
629
- return sessionResult(entry, step, baseRevision, { accepted: accepted === true });
819
+ return sessionResult(
820
+ entry,
821
+ step,
822
+ baseRevision,
823
+ { accepted: accepted === true },
824
+ );
630
825
  }
631
826
 
632
- async function abortSession({ sessionId, open: openHints = {}, baseRevision = null } = {}) {
827
+ async function abortSession({
828
+ sessionId, open: openHints = {}, options = {},
829
+ baseRevision = null,
830
+ } = {}) {
633
831
  const id = String(sessionId || '');
634
832
  if (!id) throw new TypeError('sessionId is required');
635
833
  const entry = await entryForSession(id, openHints || {});
636
834
  const target = entry.engine.abort;
637
835
  if (typeof target !== 'function') throw new TypeError('engine method abort is unavailable');
638
- const aborted = await target.call(entry.engine);
836
+ const rawResult = await target.call(entry.engine, options || {});
837
+ const abortResult = rawResult && typeof rawResult === 'object'
838
+ ? rawResult
839
+ : { aborted: rawResult === true };
639
840
  const step = advance(entry);
640
841
  if (step.changed) publishStep(entry, step);
641
- return sessionResult(entry, step, baseRevision, { aborted: aborted === true });
842
+ retainUnwatched(entry, 'headless session abort');
843
+ return sessionResult(entry, step, baseRevision, abortResult);
642
844
  }
643
845
 
644
846
  async function approveSession({
@@ -654,6 +856,7 @@ export function createEngineDaemonService({
654
856
  const approved = await target.call(entry.engine, approvalId, decision);
655
857
  const step = advance(entry);
656
858
  if (step.changed) publishStep(entry, step);
859
+ retainUnwatched(entry, 'headless session approval');
657
860
  return sessionResult(entry, step, baseRevision, { approved: approved === true });
658
861
  }
659
862
 
@@ -665,10 +868,15 @@ export function createEngineDaemonService({
665
868
  if (!entry || entry.disposed) return { ok: true };
666
869
  const sessionId = currentSessionId(entry);
667
870
  entry.disposed = true;
871
+ releaseProjection(entry);
668
872
  if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
669
873
  try { entry.unsubscribe?.(); } catch {}
670
874
  sessions.delete(entry);
671
- invalidateSyncSurfaces();
875
+ if (entry.indexedSessionId && sessionsById.get(entry.indexedSessionId) === entry) {
876
+ sessionsById.delete(entry.indexedSessionId);
877
+ }
878
+ if (entry.busy === true) busyEntries = Math.max(0, busyEntries - 1);
879
+ entry.busy = false;
672
880
  try { await entry.engine.dispose?.(reason, { keepBackgroundWork }); }
673
881
  catch (err) { log(`session dispose failed session=${sessionId}: ${err?.message || err}`); }
674
882
  if (announce && sessionId) {
@@ -707,13 +915,14 @@ export function createEngineDaemonService({
707
915
 
708
916
  function publishDesktopEvent(desktopId, message) {
709
917
  const wire = sanitizeForWire(message);
710
- if (!wire || !desktopBackend || desktopBackend.desktopId !== desktopId) return;
918
+ const backend = desktopBackendsById.get(desktopId);
919
+ if (!wire || !backend) return;
711
920
  onFrame({
712
921
  type: 'desktop-event',
713
922
  key: desktopEventKey(desktopId, wire),
714
923
  desktopId,
715
924
  message: wire,
716
- }, desktopBackend.subscribers);
925
+ }, backend.subscribers);
717
926
  }
718
927
 
719
928
  async function initializeDesktopBackend({ desktopId, moduleUrl, options = {} } = {}) {
@@ -729,19 +938,19 @@ export function createEngineDaemonService({
729
938
  if (parsed.protocol !== 'file:') {
730
939
  throw new TypeError('desktop backend moduleUrl must be a file URL');
731
940
  }
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}`);
941
+ const existingByModule = desktopBackendsByModule.get(requestedModule);
942
+ if (existingByModule) return existingByModule;
943
+ const existingById = desktopBackendsById.get(requestedId);
944
+ if (existingById) {
945
+ if (existingById.moduleUrl !== requestedModule) {
946
+ throw new Error(`desktopId ${requestedId} is already bound to another backend module`);
739
947
  }
740
- return desktopBackend;
948
+ return existingById;
741
949
  }
742
- if (desktopBackendPromise) return desktopBackendPromise;
743
- desktopBackendPromise = (async () => {
744
- const loaded = await import(requestedModule);
950
+ const pending = desktopBackendPromises.get(requestedModule);
951
+ if (pending) return pending;
952
+ const loading = (async () => {
953
+ const loaded = await loadDesktopBackendModule(requestedModule);
745
954
  if (typeof loaded.createDesktopBackend !== 'function') {
746
955
  throw new TypeError('desktop backend module has no createDesktopBackend export');
747
956
  }
@@ -763,14 +972,18 @@ export function createEngineDaemonService({
763
972
  instance,
764
973
  subscribers: new Set(),
765
974
  };
766
- desktopBackend = record;
767
- log(`desktop backend loaded id=${requestedId}`);
975
+ desktopBackendsById.set(requestedId, record);
976
+ desktopBackendsByModule.set(requestedModule, record);
977
+ log(`desktop backend loaded id=${requestedId} module=${requestedModule}`);
768
978
  return record;
769
979
  })();
980
+ desktopBackendPromises.set(requestedModule, loading);
770
981
  try {
771
- return await desktopBackendPromise;
982
+ return await loading;
772
983
  } finally {
773
- desktopBackendPromise = null;
984
+ if (desktopBackendPromises.get(requestedModule) === loading) {
985
+ desktopBackendPromises.delete(requestedModule);
986
+ }
774
987
  }
775
988
  }
776
989
 
@@ -783,10 +996,9 @@ export function createEngineDaemonService({
783
996
 
784
997
  function requireDesktopBackend(desktopId) {
785
998
  const id = String(desktopId || '');
786
- if (!desktopBackend || desktopBackend.desktopId !== id) {
787
- throw new Error('desktop backend is not initialized');
788
- }
789
- return desktopBackend;
999
+ const backend = desktopBackendsById.get(id);
1000
+ if (!backend) throw new Error('desktop backend is not initialized');
1001
+ return backend;
790
1002
  }
791
1003
 
792
1004
  async function desktopInvoke({ desktopId, method, args = [] } = {}, ctx = null) {
@@ -818,6 +1030,14 @@ export function createEngineDaemonService({
818
1030
  'desktop.invoke': desktopInvoke,
819
1031
  'desktop.control': desktopControl,
820
1032
  'desktop.unsubscribe': desktopUnsubscribe,
1033
+ 'project.list': listProjectCatalog,
1034
+ 'project.inspect': inspectProjectPath,
1035
+ 'project.add': addProjectEntry,
1036
+ 'project.touch': touchProjectEntry,
1037
+ 'project.rename': renameProjectEntry,
1038
+ 'project.remove': removeProjectEntry,
1039
+ 'project.ensureDirectory': ensureProjectDirectory,
1040
+ 'session.list': listSessionCatalog,
821
1041
  'session.create': createSession,
822
1042
  'session.read': readSession,
823
1043
  'session.subscribe': subscribeSession,
@@ -825,7 +1045,7 @@ export function createEngineDaemonService({
825
1045
  'session.submit': submitSession,
826
1046
  'session.abort': abortSession,
827
1047
  'session.approve': approveSession,
828
- 'session.invoke': sessionCall,
1048
+ 'session.configure': configureSession,
829
1049
  };
830
1050
 
831
1051
  async function handleCall(name, args = {}, ctx = null) {
@@ -837,9 +1057,12 @@ export function createEngineDaemonService({
837
1057
  async function stop(reason = 'service stop') {
838
1058
  closed = true;
839
1059
  if (evictTimer) { clearInterval(evictTimer); evictTimer = null; }
840
- const backend = desktopBackend;
841
- desktopBackend = null;
842
- if (backend?.instance?.dispose) {
1060
+ const backends = [...new Set(desktopBackendsById.values())];
1061
+ desktopBackendsById.clear();
1062
+ desktopBackendsByModule.clear();
1063
+ desktopBackendPromises.clear();
1064
+ for (const backend of backends) {
1065
+ if (!backend?.instance?.dispose) continue;
843
1066
  try { await backend.instance.dispose(reason); }
844
1067
  catch (error) { log(`desktop backend dispose failed: ${error?.message || error}`); }
845
1068
  }
@@ -849,16 +1072,37 @@ export function createEngineDaemonService({
849
1072
  }
850
1073
 
851
1074
  return {
852
- handleCall, createSession, readSession, subscribeSession, unsubscribeSession,
1075
+ handleCall, listSessionCatalog, createSession, readSession, subscribeSession, unsubscribeSession,
853
1076
  submitSession, abortSession, approveSession,
854
- sessionCall, stop, releaseClient,
1077
+ configureSession, stop, releaseClient,
855
1078
  get size() { return sessions.size; },
856
1079
  /** 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. */
1080
+ get busyCount() { return busyEntries; },
1081
+ get status() {
1082
+ let watched = 0;
1083
+ let retained = 0;
1084
+ let projected = 0;
1085
+ for (const entry of sessions) {
1086
+ if ((entry.subscribers?.size || 0) > 0) watched += 1;
1087
+ else if (entry.retainedAt) retained += 1;
1088
+ if (entry.snapshotCache || entry.publishedSnapshot) projected += 1;
1089
+ }
1090
+ return {
1091
+ live: sessions.size,
1092
+ busy: busyEntries,
1093
+ watched,
1094
+ retained,
1095
+ projected,
1096
+ };
1097
+ },
1098
+ /** Phone clients hosted by daemon-owned desktop adapters. */
859
1099
  get externalClientCount() {
860
- const count = Number(desktopBackend?.instance?.clientCount ?? 0);
861
- return Number.isSafeInteger(count) && count > 0 ? count : 0;
1100
+ let total = 0;
1101
+ for (const backend of desktopBackendsById.values()) {
1102
+ const count = Number(backend?.instance?.clientCount ?? 0);
1103
+ if (Number.isSafeInteger(count) && count > 0) total += count;
1104
+ }
1105
+ return total;
862
1106
  },
863
1107
  };
864
1108
  }