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.
- package/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/MIT.txt +56 -0
- package/LICENSES/codex-NOTICE.txt +6 -0
- package/NOTICE.md +46 -9
- package/package.json +12 -9
- package/scripts/.tmp-coverage-report.mjs +96 -0
- package/scripts/build-tui.mjs +17 -1
- package/scripts/fixtures/patch-replay-corpus.json +98 -0
- package/src/defaults/mixdog-config.template.json +5 -8
- package/src/rules/agent/30-explorer.md +7 -4
- package/src/rules/shared/01-tool.md +20 -29
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
- package/src/runtime/agent/orchestrator/config.mjs +246 -67
- package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
- package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
- package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
- package/src/runtime/channels/lib/config.mjs +2 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +2 -2
- package/src/runtime/memory/index.mjs +5 -38
- package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
- package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
- package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
- package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
- package/src/runtime/memory/lib/http-router.mjs +8 -0
- package/src/runtime/memory/lib/ko-morph.mjs +3 -20
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
- package/src/runtime/memory/lib/pg/process.mjs +32 -9
- package/src/runtime/memory/tool-defs.mjs +1 -5
- package/src/runtime/search/tool-defs.mjs +2 -2
- package/src/runtime/shared/agent-route-config.mjs +119 -0
- package/src/runtime/shared/atomic-file.mjs +80 -4
- package/src/runtime/shared/channel-notification-routing.mjs +2 -2
- package/src/runtime/shared/child-guardian.mjs +204 -108
- package/src/runtime/shared/child-spawn-gate.mjs +60 -31
- package/src/runtime/shared/config.mjs +130 -44
- package/src/runtime/shared/resource-admission.mjs +146 -97
- package/src/runtime/shared/stream-progress.mjs +6 -0
- package/src/runtime/shared/tool-surface.mjs +0 -5
- package/src/runtime/shared/turn-snapshot.mjs +31 -5
- package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
- package/src/session-runtime/config-helpers.mjs +39 -37
- package/src/session-runtime/model-route-api.mjs +7 -5
- package/src/session-runtime/provider-auth-api.mjs +0 -7
- package/src/session-runtime/runtime-core.mjs +11 -11
- package/src/session-runtime/runtime-tunables.mjs +4 -4
- package/src/session-runtime/session-lifecycle.mjs +9 -2
- package/src/session-runtime/session-turn-api.mjs +41 -9
- package/src/session-runtime/settings-api.mjs +5 -17
- package/src/session-runtime/tool-defs.mjs +3 -3
- package/src/session-runtime/workflow-agents-api.mjs +55 -81
- package/src/session-runtime/workflow.mjs +14 -24
- package/src/standalone/agent-dispatch-broker.mjs +198 -0
- package/src/standalone/agent-tool/helpers.mjs +2 -2
- package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
- package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
- package/src/standalone/agent-tool/tool-def.mjs +0 -14
- package/src/standalone/backend-daemon.mjs +97 -36
- package/src/standalone/channel-admin.mjs +12 -12
- package/src/standalone/channel-daemon-client.mjs +17 -1
- package/src/standalone/channel-daemon-transport.mjs +90 -8
- package/src/standalone/engine-daemon-client.mjs +380 -205
- package/src/standalone/engine-daemon-protocol.mjs +3 -4
- package/src/standalone/engine-daemon-service.mjs +378 -134
- package/src/standalone/engine-daemon-transport.mjs +268 -77
- package/src/standalone/explore-tool.mjs +187 -70
- package/src/standalone/fair-call-scheduler.mjs +264 -0
- package/src/standalone/session-protocol.mjs +188 -0
- package/src/tui/App.jsx +30 -17
- package/src/tui/app/app-view.jsx +5 -2
- package/src/tui/app/core-memory-picker.mjs +19 -19
- package/src/tui/app/doctor.mjs +5 -11
- package/src/tui/app/message-selector.mjs +103 -0
- package/src/tui/app/onboarding-steps.mjs +1 -2
- package/src/tui/app/project-picker.mjs +80 -56
- package/src/tui/app/prompt-submit.mjs +46 -41
- package/src/tui/app/resume-picker.mjs +2 -2
- package/src/tui/app/route-pickers.mjs +3 -1
- package/src/tui/app/settings-picker.mjs +6 -8
- package/src/tui/app/transcript-window.mjs +38 -0
- package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
- package/src/tui/app/use-prompt-handlers.mjs +86 -21
- package/src/tui/app/use-prompt-hint.mjs +3 -2
- package/src/tui/app/use-prompt-queue-history.mjs +10 -3
- package/src/tui/app/use-transcript-scroll.mjs +9 -2
- package/src/tui/app/use-transcript-window.mjs +50 -17
- package/src/tui/components/PromptInput.jsx +71 -38
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
- package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
- package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
- package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
- package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
- package/src/tui/dist/index.mjs +920 -1592
- package/src/tui/engine/live-share.mjs +34 -0
- package/src/tui/engine/session-api-ext.mjs +1 -4
- package/src/tui/engine/session-api.mjs +103 -33
- package/src/tui/engine/session-flow.mjs +7 -1
- package/src/tui/engine/tool-card-results.mjs +4 -2
- package/src/tui/engine/tool-result-text.mjs +10 -0
- package/src/tui/engine/turn.mjs +9 -69
- package/src/tui/engine-local-session.mjs +47 -31
- package/src/tui/index.jsx +1 -2
- package/src/tui/spinner-meta.mjs +4 -4
- package/src/tui/spinner-verbs.mjs +51 -3
- 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
|
-
|
|
85
|
-
let
|
|
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) ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
if (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 =
|
|
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(`
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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.
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
//
|
|
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(
|
|
819
|
+
return sessionResult(
|
|
820
|
+
entry,
|
|
821
|
+
step,
|
|
822
|
+
baseRevision,
|
|
823
|
+
{ accepted: accepted === true },
|
|
824
|
+
);
|
|
630
825
|
}
|
|
631
826
|
|
|
632
|
-
async function abortSession({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
948
|
+
return existingById;
|
|
741
949
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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
|
|
982
|
+
return await loading;
|
|
772
983
|
} finally {
|
|
773
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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.
|
|
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
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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
|
|
858
|
-
|
|
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
|
-
|
|
861
|
-
|
|
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
|
}
|