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