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