mixdog 0.9.95 → 0.9.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/MIT.txt +56 -0
- package/LICENSES/codex-NOTICE.txt +6 -0
- package/NOTICE.md +46 -9
- package/package.json +12 -9
- package/scripts/.tmp-coverage-report.mjs +96 -0
- package/scripts/build-tui.mjs +17 -1
- package/scripts/fixtures/patch-replay-corpus.json +98 -0
- package/src/defaults/mixdog-config.template.json +5 -8
- package/src/rules/agent/30-explorer.md +7 -4
- package/src/rules/shared/01-tool.md +20 -29
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
- package/src/runtime/agent/orchestrator/config.mjs +246 -67
- package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
- package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
- package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
- package/src/runtime/channels/lib/config.mjs +2 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +2 -2
- package/src/runtime/memory/index.mjs +5 -38
- package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
- package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
- package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
- package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
- package/src/runtime/memory/lib/http-router.mjs +8 -0
- package/src/runtime/memory/lib/ko-morph.mjs +3 -20
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
- package/src/runtime/memory/lib/pg/process.mjs +32 -9
- package/src/runtime/memory/tool-defs.mjs +1 -5
- package/src/runtime/search/tool-defs.mjs +2 -2
- package/src/runtime/shared/agent-route-config.mjs +119 -0
- package/src/runtime/shared/atomic-file.mjs +80 -4
- package/src/runtime/shared/channel-notification-routing.mjs +2 -2
- package/src/runtime/shared/child-guardian.mjs +204 -108
- package/src/runtime/shared/child-spawn-gate.mjs +60 -31
- package/src/runtime/shared/config.mjs +130 -44
- package/src/runtime/shared/resource-admission.mjs +146 -97
- package/src/runtime/shared/stream-progress.mjs +6 -0
- package/src/runtime/shared/tool-surface.mjs +0 -5
- package/src/runtime/shared/turn-snapshot.mjs +31 -5
- package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
- package/src/session-runtime/config-helpers.mjs +39 -37
- package/src/session-runtime/model-route-api.mjs +7 -5
- package/src/session-runtime/provider-auth-api.mjs +0 -7
- package/src/session-runtime/runtime-core.mjs +11 -11
- package/src/session-runtime/runtime-tunables.mjs +4 -4
- package/src/session-runtime/session-lifecycle.mjs +9 -2
- package/src/session-runtime/session-turn-api.mjs +41 -9
- package/src/session-runtime/settings-api.mjs +5 -17
- package/src/session-runtime/tool-defs.mjs +3 -3
- package/src/session-runtime/workflow-agents-api.mjs +55 -81
- package/src/session-runtime/workflow.mjs +14 -24
- package/src/standalone/agent-dispatch-broker.mjs +198 -0
- package/src/standalone/agent-tool/helpers.mjs +2 -2
- package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
- package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
- package/src/standalone/agent-tool/tool-def.mjs +0 -14
- package/src/standalone/backend-daemon.mjs +97 -36
- package/src/standalone/channel-admin.mjs +12 -12
- package/src/standalone/channel-daemon-client.mjs +17 -1
- package/src/standalone/channel-daemon-transport.mjs +90 -8
- package/src/standalone/engine-daemon-client.mjs +380 -205
- package/src/standalone/engine-daemon-protocol.mjs +3 -4
- package/src/standalone/engine-daemon-service.mjs +378 -134
- package/src/standalone/engine-daemon-transport.mjs +268 -77
- package/src/standalone/explore-tool.mjs +187 -70
- package/src/standalone/fair-call-scheduler.mjs +264 -0
- package/src/standalone/session-protocol.mjs +188 -0
- package/src/tui/App.jsx +30 -17
- package/src/tui/app/app-view.jsx +5 -2
- package/src/tui/app/core-memory-picker.mjs +19 -19
- package/src/tui/app/doctor.mjs +5 -11
- package/src/tui/app/message-selector.mjs +103 -0
- package/src/tui/app/onboarding-steps.mjs +1 -2
- package/src/tui/app/project-picker.mjs +80 -56
- package/src/tui/app/prompt-submit.mjs +46 -41
- package/src/tui/app/resume-picker.mjs +2 -2
- package/src/tui/app/route-pickers.mjs +3 -1
- package/src/tui/app/settings-picker.mjs +6 -8
- package/src/tui/app/transcript-window.mjs +38 -0
- package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
- package/src/tui/app/use-prompt-handlers.mjs +86 -21
- package/src/tui/app/use-prompt-hint.mjs +3 -2
- package/src/tui/app/use-prompt-queue-history.mjs +10 -3
- package/src/tui/app/use-transcript-scroll.mjs +9 -2
- package/src/tui/app/use-transcript-window.mjs +50 -17
- package/src/tui/components/PromptInput.jsx +71 -38
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
- package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
- package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
- package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
- package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
- package/src/tui/dist/index.mjs +920 -1592
- package/src/tui/engine/live-share.mjs +34 -0
- package/src/tui/engine/session-api-ext.mjs +1 -4
- package/src/tui/engine/session-api.mjs +103 -33
- package/src/tui/engine/session-flow.mjs +7 -1
- package/src/tui/engine/tool-card-results.mjs +4 -2
- package/src/tui/engine/tool-result-text.mjs +10 -0
- package/src/tui/engine/turn.mjs +9 -69
- package/src/tui/engine-local-session.mjs +47 -31
- package/src/tui/index.jsx +1 -2
- package/src/tui/spinner-meta.mjs +4 -4
- package/src/tui/spinner-verbs.mjs +51 -3
- package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
|
@@ -13,7 +13,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
import {
|
|
14
14
|
ENGINE_DAEMON_PROTOCOL,
|
|
15
15
|
} from './engine-daemon-protocol.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createSessionProtocolClient,
|
|
18
|
+
SESSION_CONFIGURE_ACTIONS,
|
|
19
|
+
SESSION_CONFIGURE_ACTION_SET,
|
|
20
|
+
SESSION_READ_ACTIONS,
|
|
21
|
+
SESSION_READ_ACTION_SET,
|
|
22
|
+
} from './session-protocol.mjs';
|
|
17
23
|
|
|
18
24
|
function runtimeRoot() {
|
|
19
25
|
return process.env.MIXDOG_RUNTIME_ROOT
|
|
@@ -34,8 +40,31 @@ function daemonEntry() {
|
|
|
34
40
|
// Data calls and lifecycle control must never share a socket pool. A burst of
|
|
35
41
|
// long /call requests can occupy every data socket; health/register/deregister
|
|
36
42
|
// still need a reserved lane so a new terminal can always attach or recover.
|
|
37
|
-
const daemonCallAgent = new http.Agent({
|
|
38
|
-
|
|
43
|
+
const daemonCallAgent = new http.Agent({
|
|
44
|
+
keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 64, maxFreeSockets: 8,
|
|
45
|
+
});
|
|
46
|
+
const daemonUrgentAgent = new http.Agent({
|
|
47
|
+
keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 8, maxFreeSockets: 2,
|
|
48
|
+
});
|
|
49
|
+
const daemonControlAgent = new http.Agent({
|
|
50
|
+
keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 4, maxFreeSockets: 2,
|
|
51
|
+
});
|
|
52
|
+
const URGENT_CALLS = new Set([
|
|
53
|
+
'session.submit',
|
|
54
|
+
'session.abort',
|
|
55
|
+
'session.approve',
|
|
56
|
+
'session.unsubscribe',
|
|
57
|
+
'desktop.control',
|
|
58
|
+
'desktop.unsubscribe',
|
|
59
|
+
]);
|
|
60
|
+
const EVENT_STREAM_RECONNECT_BASE_MS = 100;
|
|
61
|
+
const EVENT_STREAM_RECONNECT_MAX_MS = 1_000;
|
|
62
|
+
const EVENT_STREAM_HEALTH_GRACE_MS = 750;
|
|
63
|
+
// A daemon that restarted needs a moment to rebind its port, so one immediate
|
|
64
|
+
// re-attach often lands in the same gap the first call died in. Mirror the
|
|
65
|
+
// event-stream policy above with a short bounded ladder; `callId` keeps a
|
|
66
|
+
// retried submit idempotent on the daemon side, so replaying is safe.
|
|
67
|
+
const CALL_RECOVERY_BACKOFF_MS = Object.freeze([0, 150, 600]);
|
|
39
68
|
|
|
40
69
|
function request({
|
|
41
70
|
port,
|
|
@@ -45,6 +74,7 @@ function request({
|
|
|
45
74
|
body = null,
|
|
46
75
|
timeoutMs = 120_000,
|
|
47
76
|
control = false,
|
|
77
|
+
urgent = false,
|
|
48
78
|
}) {
|
|
49
79
|
return new Promise((resolve, reject) => {
|
|
50
80
|
const payload = body ? JSON.stringify(body) : null;
|
|
@@ -53,7 +83,7 @@ function request({
|
|
|
53
83
|
port,
|
|
54
84
|
path: urlPath,
|
|
55
85
|
method,
|
|
56
|
-
agent: control ? daemonControlAgent : daemonCallAgent,
|
|
86
|
+
agent: control ? daemonControlAgent : urgent ? daemonUrgentAgent : daemonCallAgent,
|
|
57
87
|
headers: {
|
|
58
88
|
'X-Mixdog-Daemon-Token': token,
|
|
59
89
|
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
|
|
@@ -221,6 +251,11 @@ export async function attachEngineDaemon({
|
|
|
221
251
|
registrationId = randomUUID(),
|
|
222
252
|
onFrame = () => {},
|
|
223
253
|
onFatal = () => {},
|
|
254
|
+
onStreamDisconnect = () => {},
|
|
255
|
+
onStreamReconnect = () => {},
|
|
256
|
+
streamReconnectBaseMs = EVENT_STREAM_RECONNECT_BASE_MS,
|
|
257
|
+
streamReconnectMaxMs = EVENT_STREAM_RECONNECT_MAX_MS,
|
|
258
|
+
streamReconnectHealthGraceMs = EVENT_STREAM_HEALTH_GRACE_MS,
|
|
224
259
|
log = () => {},
|
|
225
260
|
} = {}) {
|
|
226
261
|
if (!discovery?.port || !discovery?.token) throw new Error('daemon discovery {port, token} required');
|
|
@@ -254,9 +289,62 @@ export async function attachEngineDaemon({
|
|
|
254
289
|
|
|
255
290
|
let closed = false;
|
|
256
291
|
let streamRequest = null;
|
|
292
|
+
let reconnectTimer = null;
|
|
293
|
+
let reconnectAttempt = 0;
|
|
294
|
+
let disconnectedAt = 0;
|
|
295
|
+
let lastLossReason = '';
|
|
296
|
+
let streamWasReady = false;
|
|
297
|
+
let fatalSignalled = false;
|
|
298
|
+
const reconnectBaseMs = Math.max(1, Number(streamReconnectBaseMs) || EVENT_STREAM_RECONNECT_BASE_MS);
|
|
299
|
+
const reconnectMaxMs = Math.max(
|
|
300
|
+
reconnectBaseMs,
|
|
301
|
+
Number(streamReconnectMaxMs) || EVENT_STREAM_RECONNECT_MAX_MS,
|
|
302
|
+
);
|
|
303
|
+
const healthGraceMs = Math.max(
|
|
304
|
+
reconnectBaseMs,
|
|
305
|
+
Number(streamReconnectHealthGraceMs) || EVENT_STREAM_HEALTH_GRACE_MS,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
function signalFatal(reason) {
|
|
309
|
+
if (closed || fatalSignalled) return;
|
|
310
|
+
fatalSignalled = true;
|
|
311
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
312
|
+
reconnectTimer = null;
|
|
313
|
+
try { onFatal(reason); } catch {}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function scheduleStreamReconnect(reason) {
|
|
317
|
+
if (closed || fatalSignalled || reconnectTimer) return;
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
if (!disconnectedAt) {
|
|
320
|
+
disconnectedAt = now;
|
|
321
|
+
lastLossReason = String(reason || 'sse disconnected');
|
|
322
|
+
try { onStreamDisconnect({ reason: lastLossReason }); } catch {}
|
|
323
|
+
}
|
|
324
|
+
const attempt = reconnectAttempt + 1;
|
|
325
|
+
reconnectAttempt = attempt;
|
|
326
|
+
const delayMs = Math.min(reconnectMaxMs, reconnectBaseMs * (2 ** Math.min(attempt - 1, 8)));
|
|
327
|
+
log(`engine daemon event stream reconnecting attempt=${attempt} delayMs=${delayMs} reason=${lastLossReason}`);
|
|
328
|
+
reconnectTimer = setTimeout(() => {
|
|
329
|
+
reconnectTimer = null;
|
|
330
|
+
void (async () => {
|
|
331
|
+
if (closed || fatalSignalled) return;
|
|
332
|
+
const downtimeMs = Math.max(0, Date.now() - disconnectedAt);
|
|
333
|
+
if (downtimeMs >= healthGraceMs) {
|
|
334
|
+
const health = await probeEngineDaemonHealth({ port, token: serverToken, timeoutMs: 500 });
|
|
335
|
+
if (!health || Number(health.pid) !== Number(discovery.pid)) {
|
|
336
|
+
signalFatal(`${lastLossReason}; daemon unavailable or replaced`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
openStream();
|
|
341
|
+
})().catch(() => signalFatal(`${lastLossReason}; daemon health check failed`));
|
|
342
|
+
}, delayMs);
|
|
343
|
+
reconnectTimer.unref?.();
|
|
344
|
+
}
|
|
257
345
|
|
|
258
346
|
function openStream() {
|
|
259
|
-
if (closed) return;
|
|
347
|
+
if (closed || fatalSignalled || streamRequest) return;
|
|
260
348
|
const req = http.request({
|
|
261
349
|
hostname: '127.0.0.1',
|
|
262
350
|
port,
|
|
@@ -267,9 +355,24 @@ export async function attachEngineDaemon({
|
|
|
267
355
|
if (req !== streamRequest || closed) { res.resume(); return; }
|
|
268
356
|
if (res.statusCode !== 200) {
|
|
269
357
|
res.resume();
|
|
270
|
-
|
|
358
|
+
streamRequest = null;
|
|
359
|
+
if (res.statusCode === 401 || res.statusCode === 403 || res.statusCode === 404) {
|
|
360
|
+
signalFatal(`bad sse status ${res.statusCode}`);
|
|
361
|
+
} else {
|
|
362
|
+
scheduleStreamReconnect(`bad sse status ${res.statusCode}`);
|
|
363
|
+
}
|
|
271
364
|
return;
|
|
272
365
|
}
|
|
366
|
+
const reconnected = streamWasReady && disconnectedAt > 0;
|
|
367
|
+
const reconnectInfo = reconnected ? {
|
|
368
|
+
reason: lastLossReason,
|
|
369
|
+
attempt: reconnectAttempt,
|
|
370
|
+
downtimeMs: Math.max(0, Date.now() - disconnectedAt),
|
|
371
|
+
} : null;
|
|
372
|
+
streamWasReady = true;
|
|
373
|
+
reconnectAttempt = 0;
|
|
374
|
+
disconnectedAt = 0;
|
|
375
|
+
lastLossReason = '';
|
|
273
376
|
res.setEncoding('utf8');
|
|
274
377
|
let buffer = '';
|
|
275
378
|
res.on('data', (chunk) => {
|
|
@@ -289,14 +392,31 @@ export async function attachEngineDaemon({
|
|
|
289
392
|
}
|
|
290
393
|
}
|
|
291
394
|
});
|
|
395
|
+
let lossHandled = false;
|
|
292
396
|
const lost = (reason) => {
|
|
293
397
|
if (req !== streamRequest || closed) return;
|
|
294
|
-
|
|
398
|
+
if (lossHandled) return;
|
|
399
|
+
lossHandled = true;
|
|
400
|
+
streamRequest = null;
|
|
401
|
+
scheduleStreamReconnect(reason);
|
|
295
402
|
};
|
|
296
403
|
res.on('end', () => lost('sse ended'));
|
|
297
404
|
res.on('error', () => lost('sse error'));
|
|
405
|
+
res.on('aborted', () => lost('sse aborted'));
|
|
406
|
+
res.on('close', () => lost('sse closed'));
|
|
407
|
+
if (reconnectInfo) {
|
|
408
|
+
log(
|
|
409
|
+
`engine daemon event stream reconnected attempt=${reconnectInfo.attempt}`
|
|
410
|
+
+ ` downtimeMs=${reconnectInfo.downtimeMs}`,
|
|
411
|
+
);
|
|
412
|
+
try { onStreamReconnect(reconnectInfo); } catch {}
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
req.on('error', () => {
|
|
416
|
+
if (req !== streamRequest || closed) return;
|
|
417
|
+
streamRequest = null;
|
|
418
|
+
scheduleStreamReconnect('sse request error');
|
|
298
419
|
});
|
|
299
|
-
req.on('error', () => { if (req === streamRequest && !closed) { try { onFatal('sse request error'); } catch {} } });
|
|
300
420
|
streamRequest = req;
|
|
301
421
|
req.end();
|
|
302
422
|
}
|
|
@@ -309,6 +429,7 @@ export async function attachEngineDaemon({
|
|
|
309
429
|
port, token: serverToken, method: 'POST', path: '/call',
|
|
310
430
|
body: { token: clientToken, name, args: args || {}, ...(callId ? { callId } : {}) },
|
|
311
431
|
timeoutMs,
|
|
432
|
+
urgent: URGENT_CALLS.has(name),
|
|
312
433
|
});
|
|
313
434
|
} catch (err) {
|
|
314
435
|
// Transport death (daemon restarted/unreachable) is recoverable by
|
|
@@ -323,6 +444,8 @@ export async function attachEngineDaemon({
|
|
|
323
444
|
async function close(reason = 'client close') {
|
|
324
445
|
if (closed) return;
|
|
325
446
|
closed = true;
|
|
447
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
448
|
+
reconnectTimer = null;
|
|
326
449
|
try { streamRequest?.destroy?.(); } catch {}
|
|
327
450
|
try {
|
|
328
451
|
await request({
|
|
@@ -401,12 +524,7 @@ async function ensureSharedAttachment({ cwd, log }) {
|
|
|
401
524
|
if (shared?.client) return shared;
|
|
402
525
|
if (shared?.pending) return shared.pending;
|
|
403
526
|
const pending = (async () => {
|
|
404
|
-
const
|
|
405
|
-
? engineDaemonLocalBridge()
|
|
406
|
-
: null;
|
|
407
|
-
const discovery = localBridge
|
|
408
|
-
? { pid: process.pid, local: true }
|
|
409
|
-
: await ensureEngineDaemon({ cwd, log });
|
|
527
|
+
const discovery = await ensureEngineDaemon({ cwd, log });
|
|
410
528
|
const views = new Map();
|
|
411
529
|
const state = { client: null, views, discovery, refs: liveViews.size, log, cwd };
|
|
412
530
|
const attachmentOptions = {
|
|
@@ -416,7 +534,7 @@ async function ensureSharedAttachment({ cwd, log }) {
|
|
|
416
534
|
const bucket = views.get(String(frame?.sessionId || ''));
|
|
417
535
|
if (!bucket) return;
|
|
418
536
|
for (const view of [...bucket]) {
|
|
419
|
-
if (frame.type === 'session-state') view.applyFrame(frame);
|
|
537
|
+
if (frame.type === 'session-state') view.applyFrame(frame, state);
|
|
420
538
|
else if (frame.type === 'session-gone') view.applyGone(frame.reason || 'session unloaded');
|
|
421
539
|
}
|
|
422
540
|
},
|
|
@@ -429,9 +547,8 @@ async function ensureSharedAttachment({ cwd, log }) {
|
|
|
429
547
|
scheduleReattach({ cwd: state.cwd, log });
|
|
430
548
|
},
|
|
431
549
|
};
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
: await attachEngineDaemon({ discovery, ...attachmentOptions });
|
|
550
|
+
const rawTransport = await attachEngineDaemon({ discovery, ...attachmentOptions });
|
|
551
|
+
state.client = createSessionProtocolClient(rawTransport);
|
|
435
552
|
shared = state;
|
|
436
553
|
for (const view of liveViews) {
|
|
437
554
|
addSessionView(views, view.sessionId(), view);
|
|
@@ -450,11 +567,6 @@ async function ensureSharedAttachment({ cwd, log }) {
|
|
|
450
567
|
}
|
|
451
568
|
}
|
|
452
569
|
|
|
453
|
-
const LOCAL_ENGINE_KEYS = new Set([
|
|
454
|
-
'getState', 'subscribe', 'dispose', 'isRemoteEngine', 'disposedView',
|
|
455
|
-
'then', 'catch', 'finally', 'toJSON', 'inspect', Symbol.toStringTag,
|
|
456
|
-
]);
|
|
457
|
-
|
|
458
570
|
/** Remote counterpart of createEngineSession(): this is a session projection,
|
|
459
571
|
* while execution and durable intake stay in the backend daemon. */
|
|
460
572
|
export async function createRemoteEngineSession(options = {}) {
|
|
@@ -469,16 +581,24 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
469
581
|
desktopSession: options.desktopSession ?? null,
|
|
470
582
|
};
|
|
471
583
|
let attachment = await ensureSharedAttachment({ cwd, log });
|
|
472
|
-
const created = await attachment.client.
|
|
584
|
+
const created = await attachment.client.create(openParams, {
|
|
473
585
|
callId: `session-create:${process.pid}:${randomUUID()}`,
|
|
474
586
|
});
|
|
475
587
|
let sessionId = String(created?.sessionId || '');
|
|
476
588
|
if (!sessionId) throw new Error('session.create returned no sessionId');
|
|
477
589
|
let state = created?.full ?? {};
|
|
478
590
|
let revision = Number(created?.revision) || 0;
|
|
479
|
-
|
|
591
|
+
// A revision belongs to one session projection on one daemon attachment. It
|
|
592
|
+
// detects a missing patch on that stream; it is not session history and
|
|
593
|
+
// never crosses a session rebind or daemon restart. This mirrors Codex
|
|
594
|
+
// thread/resume: seed a full thread snapshot, then consume notifications
|
|
595
|
+
// from the newly attached listener.
|
|
596
|
+
let revisionAttachment = attachment;
|
|
597
|
+
let revisionSessionId = sessionId;
|
|
598
|
+
let reservedOnly = created?.reservedOnly === true;
|
|
480
599
|
let disposed = false;
|
|
481
600
|
const listeners = new Set();
|
|
601
|
+
const resultAttachments = new WeakMap();
|
|
482
602
|
|
|
483
603
|
function emit() {
|
|
484
604
|
for (const listener of [...listeners]) {
|
|
@@ -486,11 +606,29 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
486
606
|
}
|
|
487
607
|
}
|
|
488
608
|
|
|
489
|
-
function applyBody(body) {
|
|
609
|
+
function applyBody(body, sourceAttachment = resultAttachments.get(body) || attachment) {
|
|
490
610
|
if (disposed || !body) return false;
|
|
611
|
+
// A response from the dead attachment may finish after recovery. It cannot
|
|
612
|
+
// repaint the session that is now subscribed to a replacement daemon.
|
|
613
|
+
if (sourceAttachment !== attachment) return true;
|
|
614
|
+
if (Object.hasOwn(body, 'reservedOnly')) reservedOnly = body.reservedOnly === true;
|
|
615
|
+
const incomingRevision = Number(body.revision);
|
|
616
|
+
if (revisionAttachment !== sourceAttachment || revisionSessionId !== sessionId) {
|
|
617
|
+
// A fresh attachment/session projection has no patch baseline. Only its
|
|
618
|
+
// authoritative full snapshot may establish the new epoch; numeric
|
|
619
|
+
// revisions from another projection are deliberately incomparable.
|
|
620
|
+
if (body.full === undefined || body.full === null) return false;
|
|
621
|
+
state = body.full;
|
|
622
|
+
revision = Number.isFinite(incomingRevision) ? incomingRevision : 0;
|
|
623
|
+
revisionAttachment = sourceAttachment;
|
|
624
|
+
revisionSessionId = sessionId;
|
|
625
|
+
emit();
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
if (Number.isFinite(incomingRevision) && incomingRevision < revision) return true;
|
|
491
629
|
if (body.full !== undefined && body.full !== null) {
|
|
492
630
|
state = body.full;
|
|
493
|
-
revision = Number(
|
|
631
|
+
revision = Number.isFinite(incomingRevision) ? incomingRevision : revision;
|
|
494
632
|
emit();
|
|
495
633
|
return true;
|
|
496
634
|
}
|
|
@@ -514,16 +652,28 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
514
652
|
return false;
|
|
515
653
|
}
|
|
516
654
|
|
|
655
|
+
function baseRevisionFor(
|
|
656
|
+
sourceAttachment = attachment,
|
|
657
|
+
targetSessionId = sessionId,
|
|
658
|
+
) {
|
|
659
|
+
return revisionAttachment === sourceAttachment && revisionSessionId === targetSessionId
|
|
660
|
+
? revision
|
|
661
|
+
: null;
|
|
662
|
+
}
|
|
663
|
+
|
|
517
664
|
let resyncing = false;
|
|
518
665
|
function resync(reason) {
|
|
519
666
|
if (disposed || resyncing) return;
|
|
520
667
|
resyncing = true;
|
|
521
|
-
|
|
522
|
-
|
|
668
|
+
const requestAttachment = attachment;
|
|
669
|
+
void requestAttachment.client.read({
|
|
670
|
+
sessionId,
|
|
671
|
+
open: openParams,
|
|
672
|
+
baseRevision: baseRevisionFor(requestAttachment),
|
|
523
673
|
}).then((result) => {
|
|
524
|
-
if (disposed) return;
|
|
525
|
-
if (result
|
|
526
|
-
if (!applyBody(result) && result?.revision !== undefined) {
|
|
674
|
+
if (disposed || requestAttachment !== attachment) return;
|
|
675
|
+
if (result && typeof result === 'object') resultAttachments.set(result, requestAttachment);
|
|
676
|
+
if (!applyBody(result, requestAttachment) && result?.revision !== undefined) {
|
|
527
677
|
log(`session ${sessionId} resync returned an unusable body`);
|
|
528
678
|
}
|
|
529
679
|
}).catch((err) => {
|
|
@@ -542,19 +692,49 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
542
692
|
removeSessionView(previousAttachment.views, sessionId, view);
|
|
543
693
|
attachment = nextAttachment;
|
|
544
694
|
}
|
|
695
|
+
const recoveryAttachment = attachment;
|
|
696
|
+
// Route notifications before asking the daemon to subscribe. The
|
|
697
|
+
// session.subscribe response is the full baseline; frames racing after
|
|
698
|
+
// server-side subscription are either full themselves or trigger a
|
|
699
|
+
// baseline read, never disappear between the response and local wiring.
|
|
700
|
+
addSessionView(recoveryAttachment.views, sessionId, view);
|
|
701
|
+
revisionAttachment = null;
|
|
702
|
+
revisionSessionId = '';
|
|
545
703
|
let result;
|
|
546
704
|
try {
|
|
547
|
-
result = await
|
|
548
|
-
sessionId, open: openParams, baseRevision:
|
|
705
|
+
result = await recoveryAttachment.client.subscribe({
|
|
706
|
+
sessionId, open: openParams, baseRevision: null,
|
|
707
|
+
});
|
|
708
|
+
} catch (error) {
|
|
709
|
+
// A reserved New task has no durable transcript until its first
|
|
710
|
+
// accepted submit. If the daemon dies before then, recreate the same
|
|
711
|
+
// durable address instead of silently switching the pane to another
|
|
712
|
+
// session. A submitted session always retries subscribe first so an
|
|
713
|
+
// accepted-but-unacknowledged prompt can never be shadowed.
|
|
714
|
+
if (!reservedOnly || !/session .* is not available/i.test(String(error?.message || error))) {
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
result = await recoveryAttachment.client.create({
|
|
718
|
+
...openParams,
|
|
719
|
+
sessionId,
|
|
720
|
+
}, {
|
|
721
|
+
callId: `session-recover-reservation:${sessionId}`,
|
|
549
722
|
});
|
|
550
|
-
} catch {
|
|
551
|
-
result = await attachment.client.call('session.create', {
|
|
552
|
-
...openParams, sessionId,
|
|
553
|
-
}, { callId: `session-recreate:${sessionId}` });
|
|
554
723
|
}
|
|
555
|
-
|
|
556
|
-
if (result
|
|
557
|
-
if (!applyBody(result
|
|
724
|
+
if (disposed || recoveryAttachment !== attachment) return;
|
|
725
|
+
if (result && typeof result === 'object') resultAttachments.set(result, recoveryAttachment);
|
|
726
|
+
if (!applyBody(result, recoveryAttachment)) {
|
|
727
|
+
const baseline = await recoveryAttachment.client.read({
|
|
728
|
+
sessionId, open: openParams, baseRevision: null,
|
|
729
|
+
});
|
|
730
|
+
if (disposed || recoveryAttachment !== attachment) return;
|
|
731
|
+
if (baseline && typeof baseline === 'object') {
|
|
732
|
+
resultAttachments.set(baseline, recoveryAttachment);
|
|
733
|
+
}
|
|
734
|
+
if (!applyBody(baseline, recoveryAttachment)) {
|
|
735
|
+
throw new Error(`session ${sessionId} recovery returned no full snapshot`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
558
738
|
log(`session ${sessionId} projection recovered`);
|
|
559
739
|
})();
|
|
560
740
|
try {
|
|
@@ -566,15 +746,13 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
566
746
|
|
|
567
747
|
view = {
|
|
568
748
|
sessionId: () => sessionId,
|
|
569
|
-
applyFrame(frame) {
|
|
749
|
+
applyFrame(frame, sourceAttachment = attachment) {
|
|
570
750
|
if (disposed) return;
|
|
571
|
-
if (!applyBody(frame)) resync('revision gap');
|
|
751
|
+
if (!applyBody(frame, sourceAttachment)) resync('revision gap');
|
|
572
752
|
},
|
|
573
753
|
applyGone(reason) {
|
|
574
754
|
if (disposed) return;
|
|
575
|
-
|
|
576
|
-
log(`session ${sessionId} reload after ${reason} failed: ${err?.message || err}`);
|
|
577
|
-
});
|
|
755
|
+
log(`session ${sessionId} is no longer available (${reason})`);
|
|
578
756
|
},
|
|
579
757
|
applyDetached(reason) {
|
|
580
758
|
log(`session ${sessionId} projection detached (${reason})`);
|
|
@@ -586,21 +764,51 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
586
764
|
liveViews.add(view);
|
|
587
765
|
|
|
588
766
|
async function sendCall(route, payload, callId) {
|
|
589
|
-
const send = () =>
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
if (
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
await
|
|
599
|
-
|
|
767
|
+
const send = async () => {
|
|
768
|
+
const sourceAttachment = attachment;
|
|
769
|
+
let result;
|
|
770
|
+
if (route === 'session.create') result = await sourceAttachment.client.create(payload, { callId });
|
|
771
|
+
else if (route === 'session.read') result = await sourceAttachment.client.read(payload, { callId });
|
|
772
|
+
else if (route === 'session.subscribe') result = await sourceAttachment.client.subscribe(payload, { callId });
|
|
773
|
+
else if (route === 'session.submit') result = await sourceAttachment.client.submit(payload, { callId });
|
|
774
|
+
else if (route === 'session.abort') result = await sourceAttachment.client.abort(payload, { callId });
|
|
775
|
+
else if (route === 'session.approve') result = await sourceAttachment.client.approve(payload, { callId });
|
|
776
|
+
else if (route === 'session.configure') result = await sourceAttachment.client.configure(payload, { callId });
|
|
777
|
+
else if (route === 'project.list') result = await sourceAttachment.client.projectList(payload, { callId });
|
|
778
|
+
else if (route === 'project.inspect') result = await sourceAttachment.client.projectInspect(payload, { callId });
|
|
779
|
+
else if (route === 'project.add') result = await sourceAttachment.client.projectAdd(payload, { callId });
|
|
780
|
+
else if (route === 'project.touch') result = await sourceAttachment.client.projectTouch(payload, { callId });
|
|
781
|
+
else if (route === 'project.rename') result = await sourceAttachment.client.projectRename(payload, { callId });
|
|
782
|
+
else if (route === 'project.remove') result = await sourceAttachment.client.projectRemove(payload, { callId });
|
|
783
|
+
else if (route === 'project.ensureDirectory') {
|
|
784
|
+
result = await sourceAttachment.client.projectEnsureDirectory(payload, { callId });
|
|
785
|
+
}
|
|
786
|
+
else throw new TypeError(`session route ${route} is unavailable`);
|
|
787
|
+
if (result && typeof result === 'object') {
|
|
788
|
+
resultAttachments.set(result, sourceAttachment);
|
|
789
|
+
}
|
|
790
|
+
return result;
|
|
791
|
+
};
|
|
792
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
793
|
+
try {
|
|
794
|
+
return await send();
|
|
795
|
+
} catch (err) {
|
|
796
|
+
const recoverable = err?.daemonTransportError
|
|
797
|
+
|| /unknown client token/i.test(String(err?.message || ''));
|
|
798
|
+
if (!recoverable || disposed || attempt >= CALL_RECOVERY_BACKOFF_MS.length) throw err;
|
|
799
|
+
const waitMs = CALL_RECOVERY_BACKOFF_MS[attempt];
|
|
800
|
+
if (waitMs > 0) await new Promise((resolve) => { setTimeout(resolve, waitMs); });
|
|
801
|
+
if (disposed) throw err;
|
|
802
|
+
if (shared === attachment) shared = null;
|
|
803
|
+
const next = await ensureSharedAttachment({ cwd, log });
|
|
804
|
+
await recover(next);
|
|
805
|
+
if (attempt > 0) log(`session ${sessionId} call ${route} re-attached (attempt ${attempt + 1})`);
|
|
806
|
+
}
|
|
600
807
|
}
|
|
601
808
|
}
|
|
602
809
|
|
|
603
810
|
async function applyResult(result, reason) {
|
|
811
|
+
const sourceAttachment = resultAttachments.get(result) || attachment;
|
|
604
812
|
const nextSessionId = String(result?.sessionId || sessionId);
|
|
605
813
|
if (nextSessionId && nextSessionId !== sessionId) {
|
|
606
814
|
const previousSessionId = sessionId;
|
|
@@ -608,42 +816,44 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
608
816
|
removeSessionView(attachment.views, previousSessionId, view);
|
|
609
817
|
sessionId = nextSessionId;
|
|
610
818
|
}
|
|
611
|
-
if (result
|
|
612
|
-
|
|
819
|
+
if (!applyBody(result, sourceAttachment) && result?.revision !== undefined) {
|
|
820
|
+
resync(`${reason} body gap`);
|
|
821
|
+
}
|
|
613
822
|
return result;
|
|
614
823
|
}
|
|
615
824
|
|
|
616
|
-
let
|
|
617
|
-
function
|
|
618
|
-
const run =
|
|
619
|
-
|
|
825
|
+
let transitionChain = Promise.resolve();
|
|
826
|
+
function serializeTransition(task) {
|
|
827
|
+
const run = transitionChain.then(task);
|
|
828
|
+
transitionChain = run.then(() => {}, () => {});
|
|
620
829
|
return run;
|
|
621
830
|
}
|
|
622
831
|
|
|
623
832
|
function remoteCall(method, args, callOptions = {}) {
|
|
624
|
-
return
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
833
|
+
if (disposed) return Promise.reject(new Error('This session view is disposed.'));
|
|
834
|
+
const route = SESSION_READ_ACTION_SET.has(method)
|
|
835
|
+
? 'session.read'
|
|
836
|
+
: SESSION_CONFIGURE_ACTION_SET.has(method)
|
|
837
|
+
? 'session.configure'
|
|
838
|
+
: null;
|
|
839
|
+
if (!route) return Promise.reject(new TypeError(`session action ${method} is unavailable`));
|
|
840
|
+
const targetSessionId = sessionId;
|
|
841
|
+
const baseRevision = baseRevisionFor(attachment, targetSessionId);
|
|
842
|
+
const stableCallId = typeof callOptions.callId === 'string' && callOptions.callId.trim()
|
|
843
|
+
? callOptions.callId.trim()
|
|
844
|
+
: randomUUID();
|
|
845
|
+
return sendCall(route, {
|
|
846
|
+
sessionId: targetSessionId,
|
|
847
|
+
action: method,
|
|
848
|
+
args,
|
|
849
|
+
open: openParams,
|
|
850
|
+
baseRevision,
|
|
851
|
+
}, stableCallId).then(async (result) => {
|
|
852
|
+
if (!disposed && sessionId === targetSessionId) await applyResult(result, method);
|
|
637
853
|
return result?.value ?? null;
|
|
638
854
|
});
|
|
639
855
|
}
|
|
640
856
|
|
|
641
|
-
function dispatch(method, args) {
|
|
642
|
-
void remoteCall(method, args).catch((err) => {
|
|
643
|
-
log(`session ${method} failed: ${err?.message || err}`);
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
|
|
647
857
|
async function rebindTo(result, previousSessionId) {
|
|
648
858
|
const nextSessionId = String(result?.sessionId || '');
|
|
649
859
|
if (!nextSessionId) throw new Error('session route returned no sessionId');
|
|
@@ -651,11 +861,10 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
651
861
|
addSessionView(attachment.views, nextSessionId, view);
|
|
652
862
|
removeSessionView(attachment.views, previousSessionId, view);
|
|
653
863
|
sessionId = nextSessionId;
|
|
654
|
-
if (result?.sync) sync = result.sync;
|
|
655
864
|
if (!applyBody(result) && result?.revision !== undefined) resync('session rebind body gap');
|
|
656
865
|
if (previousSessionId && previousSessionId !== nextSessionId && lastLocalView) {
|
|
657
866
|
try {
|
|
658
|
-
await attachment.client.
|
|
867
|
+
await attachment.client.unsubscribe({ sessionId: previousSessionId });
|
|
659
868
|
} catch (err) {
|
|
660
869
|
log(`session ${previousSessionId} unsubscribe failed: ${err?.message || err}`);
|
|
661
870
|
}
|
|
@@ -664,7 +873,7 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
664
873
|
}
|
|
665
874
|
|
|
666
875
|
async function createReservedSession() {
|
|
667
|
-
return
|
|
876
|
+
return serializeTransition(async () => {
|
|
668
877
|
if (disposed) throw new Error('This session view is disposed.');
|
|
669
878
|
const previousSessionId = sessionId;
|
|
670
879
|
const result = await sendCall('session.create', openParams,
|
|
@@ -676,11 +885,13 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
676
885
|
async function resumeSession(targetSessionId, resumeOptions) {
|
|
677
886
|
const target = String(targetSessionId || '');
|
|
678
887
|
if (!target) return false;
|
|
679
|
-
return
|
|
888
|
+
return serializeTransition(async () => {
|
|
680
889
|
if (disposed) throw new Error('This session view is disposed.');
|
|
681
890
|
if (target === sessionId) {
|
|
682
891
|
const result = await sendCall('session.read', {
|
|
683
|
-
sessionId,
|
|
892
|
+
sessionId,
|
|
893
|
+
open: openParams,
|
|
894
|
+
baseRevision: baseRevisionFor(),
|
|
684
895
|
}, randomUUID());
|
|
685
896
|
await applyResult(result, 'resume');
|
|
686
897
|
return true;
|
|
@@ -701,17 +912,26 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
701
912
|
if (disposed) throw new Error('This session view is disposed.');
|
|
702
913
|
const submissionId = String(options?.id || '').trim()
|
|
703
914
|
|| `session-submit-${process.pid}-${Date.now()}-${(submitSeq += 1)}`;
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
915
|
+
const targetSessionId = sessionId;
|
|
916
|
+
const baseRevision = baseRevisionFor(attachment, targetSessionId);
|
|
917
|
+
const result = await sendCall('session.submit', {
|
|
918
|
+
sessionId: targetSessionId,
|
|
919
|
+
prompt,
|
|
920
|
+
options: { ...(options || {}), id: submissionId },
|
|
921
|
+
open: openParams,
|
|
922
|
+
baseRevision,
|
|
923
|
+
}, `session-submit:${targetSessionId}:${submissionId}`);
|
|
924
|
+
if (!disposed && sessionId === targetSessionId) await applyResult(result, 'submit');
|
|
925
|
+
return result?.accepted === true;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function abortAsync(options = {}) {
|
|
929
|
+
if (disposed) return { aborted: false };
|
|
930
|
+
const result = await sendCall('session.abort', {
|
|
931
|
+
sessionId, open: openParams, options,
|
|
932
|
+
baseRevision: baseRevisionFor(),
|
|
933
|
+
}, randomUUID());
|
|
934
|
+
return await applyResult(result, 'abort');
|
|
715
935
|
}
|
|
716
936
|
|
|
717
937
|
const base = {
|
|
@@ -722,14 +942,49 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
722
942
|
listeners.add(listener);
|
|
723
943
|
return () => listeners.delete(listener);
|
|
724
944
|
},
|
|
725
|
-
listSessions(options) {
|
|
726
|
-
|
|
727
|
-
return Array.isArray(
|
|
945
|
+
async listSessions(options = {}) {
|
|
946
|
+
const result = await attachment.client.list(options, { callId: randomUUID() });
|
|
947
|
+
return Array.isArray(result?.sessions) ? result.sessions : [];
|
|
948
|
+
},
|
|
949
|
+
async listProjects() {
|
|
950
|
+
const result = await sendCall('project.list', {}, randomUUID());
|
|
951
|
+
return Array.isArray(result?.projects) ? result.projects : [];
|
|
952
|
+
},
|
|
953
|
+
async inspectProjectPath(projectPath) {
|
|
954
|
+
return await sendCall('project.inspect', { path: projectPath }, randomUUID());
|
|
955
|
+
},
|
|
956
|
+
async addProject(projectPath) {
|
|
957
|
+
const result = await sendCall('project.add', { path: projectPath }, randomUUID());
|
|
958
|
+
return result?.project ?? null;
|
|
959
|
+
},
|
|
960
|
+
async touchProjectSelected(projectPath) {
|
|
961
|
+
const result = await sendCall('project.touch', { path: projectPath }, randomUUID());
|
|
962
|
+
return result?.project ?? null;
|
|
963
|
+
},
|
|
964
|
+
async renameProject(projectPath, name) {
|
|
965
|
+
const result = await sendCall('project.rename', {
|
|
966
|
+
path: projectPath,
|
|
967
|
+
name,
|
|
968
|
+
}, randomUUID());
|
|
969
|
+
return result?.project ?? null;
|
|
728
970
|
},
|
|
729
|
-
|
|
730
|
-
|
|
971
|
+
async removeProject(projectPath) {
|
|
972
|
+
const result = await sendCall('project.remove', { path: projectPath }, randomUUID());
|
|
973
|
+
return result?.removed === true;
|
|
731
974
|
},
|
|
732
|
-
async
|
|
975
|
+
async ensureProjectDirectory(projectPath) {
|
|
976
|
+
const result = await sendCall('project.ensureDirectory', {
|
|
977
|
+
path: projectPath,
|
|
978
|
+
}, randomUUID());
|
|
979
|
+
return String(result?.path || '');
|
|
980
|
+
},
|
|
981
|
+
async newSession(options = {}) {
|
|
982
|
+
// A newly-created daemon view already owns a durable reserved address.
|
|
983
|
+
// New-task setup may ask for a fresh session before the first submit;
|
|
984
|
+
// reusing that reservation avoids a second session.create round trip and
|
|
985
|
+
// duplicate provider/session prewarm. Explicit route/workflow changes
|
|
986
|
+
// omit this flag and retain the ordinary new-session boundary.
|
|
987
|
+
if (options?.reuseReservation === true && reservedOnly) return true;
|
|
733
988
|
await createReservedSession();
|
|
734
989
|
return true;
|
|
735
990
|
},
|
|
@@ -737,13 +992,10 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
737
992
|
async prefetchSession(targetSessionId) {
|
|
738
993
|
const target = String(targetSessionId || '');
|
|
739
994
|
if (!target) return false;
|
|
740
|
-
await
|
|
995
|
+
await attachment.client.read({
|
|
741
996
|
sessionId: target,
|
|
742
|
-
method: 'read',
|
|
743
997
|
open: openParams,
|
|
744
|
-
|
|
745
|
-
log,
|
|
746
|
-
attempts: 1,
|
|
998
|
+
baseRevision: null,
|
|
747
999
|
});
|
|
748
1000
|
return true;
|
|
749
1001
|
},
|
|
@@ -755,26 +1007,28 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
755
1007
|
});
|
|
756
1008
|
return true;
|
|
757
1009
|
},
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
await applyResult(result, 'abort');
|
|
764
|
-
}).catch((err) => log(`session abort failed: ${err?.message || err}`));
|
|
1010
|
+
abortAsync,
|
|
1011
|
+
abort(options = {}) {
|
|
1012
|
+
// Cancellation must bypass this view's ordered mutation chain. A slow
|
|
1013
|
+
// capability/read call ahead of it must never delay the user's ESC.
|
|
1014
|
+
void abortAsync(options).catch((err) => log(`session abort failed: ${err?.message || err}`));
|
|
765
1015
|
return true;
|
|
766
1016
|
},
|
|
767
1017
|
resolveToolApproval(id, decision) {
|
|
768
|
-
|
|
1018
|
+
// Approval unblocks a live turn and has the same priority requirement as
|
|
1019
|
+
// abort. Revision gaps caused by an overlapping ordinary call self-heal
|
|
1020
|
+
// through applyResult()/resync().
|
|
1021
|
+
void (async () => {
|
|
1022
|
+
if (disposed) return;
|
|
769
1023
|
const result = await sendCall('session.approve', {
|
|
770
1024
|
sessionId,
|
|
771
1025
|
approvalId: id,
|
|
772
1026
|
decision,
|
|
773
1027
|
open: openParams,
|
|
774
|
-
baseRevision:
|
|
1028
|
+
baseRevision: baseRevisionFor(),
|
|
775
1029
|
}, randomUUID());
|
|
776
1030
|
await applyResult(result, 'approval');
|
|
777
|
-
}).catch((err) => log(`session approval failed: ${err?.message || err}`));
|
|
1031
|
+
})().catch((err) => log(`session approval failed: ${err?.message || err}`));
|
|
778
1032
|
return true;
|
|
779
1033
|
},
|
|
780
1034
|
async dispose(reason = 'view dispose') {
|
|
@@ -785,7 +1039,7 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
785
1039
|
const lastLocalView = sessionViewCount(attachment.views, releasedSessionId) <= 1;
|
|
786
1040
|
if (lastLocalView) {
|
|
787
1041
|
try {
|
|
788
|
-
await attachment.client.
|
|
1042
|
+
await attachment.client.unsubscribe({ sessionId: releasedSessionId });
|
|
789
1043
|
} catch (err) {
|
|
790
1044
|
log(`session ${releasedSessionId} unsubscribe failed: ${err?.message || err}`);
|
|
791
1045
|
}
|
|
@@ -798,97 +1052,18 @@ export async function createRemoteEngineSession(options = {}) {
|
|
|
798
1052
|
// Last view gone: drop pooled keep-alive sockets so an idle connection
|
|
799
1053
|
// cannot keep the process (or a test runner) alive.
|
|
800
1054
|
try { daemonCallAgent.destroy(); } catch {}
|
|
1055
|
+
try { daemonUrgentAgent.destroy(); } catch {}
|
|
801
1056
|
try { daemonControlAgent.destroy(); } catch {}
|
|
802
1057
|
if (shared === attachment) shared = null;
|
|
803
1058
|
}
|
|
804
1059
|
},
|
|
805
1060
|
};
|
|
806
1061
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
if (property in target || LOCAL_ENGINE_KEYS.has(property)) {
|
|
810
|
-
return Reflect.get(target, property, receiver);
|
|
811
|
-
}
|
|
812
|
-
if (typeof property === 'symbol') return undefined;
|
|
813
|
-
return (...args) => remoteCall(property, args);
|
|
814
|
-
},
|
|
815
|
-
has(target, property) {
|
|
816
|
-
return typeof property === 'string' ? true : Reflect.has(target, property);
|
|
817
|
-
},
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/** Session-addressed backend request from a view that holds NO engine for that
|
|
822
|
-
* session. The daemon owns the engine pool, so a renderer (desktop pane, TUI
|
|
823
|
-
* tab) hands it the request and the daemon resolves — or loads — the session's
|
|
824
|
-
* engine. This is what makes "no live engine here" impossible to hit.
|
|
825
|
-
* Only a dead transport is retried; an engine-side verdict comes straight back. */
|
|
826
|
-
export async function callDaemonSession({
|
|
827
|
-
sessionId,
|
|
828
|
-
method,
|
|
829
|
-
args = [],
|
|
830
|
-
open: openHints = {},
|
|
831
|
-
cwd = process.cwd(),
|
|
832
|
-
log = () => {},
|
|
833
|
-
attempts = 3,
|
|
834
|
-
} = {}) {
|
|
835
|
-
const id = String(sessionId || '');
|
|
836
|
-
if (!id) throw new TypeError('sessionId is required');
|
|
837
|
-
const name = String(method || '');
|
|
838
|
-
if (!name) throw new TypeError('method is required');
|
|
839
|
-
let route = 'session.invoke';
|
|
840
|
-
let payload = { sessionId: id, method: name, args, open: openHints || {} };
|
|
841
|
-
if (name === 'submit') {
|
|
842
|
-
route = 'session.submit';
|
|
843
|
-
payload = {
|
|
844
|
-
sessionId: id,
|
|
845
|
-
prompt: args?.[0],
|
|
846
|
-
options: args?.[1] || {},
|
|
847
|
-
open: openHints || {},
|
|
848
|
-
};
|
|
849
|
-
} else if (name === 'abort') {
|
|
850
|
-
route = 'session.abort';
|
|
851
|
-
payload = { sessionId: id, open: openHints || {} };
|
|
852
|
-
} else if (name === 'resolveToolApproval') {
|
|
853
|
-
route = 'session.approve';
|
|
854
|
-
payload = {
|
|
855
|
-
sessionId: id,
|
|
856
|
-
approvalId: args?.[0],
|
|
857
|
-
decision: args?.[1],
|
|
858
|
-
open: openHints || {},
|
|
859
|
-
};
|
|
860
|
-
} else if (name === 'read') {
|
|
861
|
-
// Prefetch/peek: WARM (or load) the session's engine. This is a daemon
|
|
862
|
-
// route, not an engine method — sending it as session.invoke asked the
|
|
863
|
-
// engine for a `read()` that the store contract has never had, so every
|
|
864
|
-
// prefetch failed with "engine method read is unavailable".
|
|
865
|
-
route = 'session.read';
|
|
866
|
-
payload = { sessionId: id, open: openHints || {}, baseRevision: null };
|
|
1062
|
+
for (const action of SESSION_READ_ACTIONS) {
|
|
1063
|
+
if (!Object.hasOwn(base, action)) base[action] = (...args) => remoteCall(action, args);
|
|
867
1064
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
// twice by the daemon.
|
|
871
|
-
const submissionId = name === 'submit' ? String(args?.[1]?.id || '').trim() : '';
|
|
872
|
-
const callId = submissionId
|
|
873
|
-
? `session-submit:${id}:${submissionId}`
|
|
874
|
-
: randomUUID();
|
|
875
|
-
let lastError = null;
|
|
876
|
-
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
|
877
|
-
const attachment = await ensureSharedAttachment({ cwd, log });
|
|
878
|
-
try {
|
|
879
|
-
const result = await attachment.client.call(route, payload, { callId });
|
|
880
|
-
if (name === 'submit') return { ...result, value: result?.accepted === true };
|
|
881
|
-
if (name === 'abort') return { ...result, value: result?.aborted === true };
|
|
882
|
-
if (name === 'resolveToolApproval') return { ...result, value: result?.approved === true };
|
|
883
|
-
return result;
|
|
884
|
-
} catch (err) {
|
|
885
|
-
if (!err?.daemonTransportError) throw err;
|
|
886
|
-
lastError = err;
|
|
887
|
-
// The attachment died under us; drop it so the next attempt re-discovers
|
|
888
|
-
// (or respawns) the daemon instead of calling into a closed socket.
|
|
889
|
-
if (shared === attachment) shared = null;
|
|
890
|
-
await delay(200 * (attempt + 1));
|
|
891
|
-
}
|
|
1065
|
+
for (const action of SESSION_CONFIGURE_ACTIONS) {
|
|
1066
|
+
if (!Object.hasOwn(base, action)) base[action] = (...args) => remoteCall(action, args);
|
|
892
1067
|
}
|
|
893
|
-
|
|
1068
|
+
return base;
|
|
894
1069
|
}
|