mixdog 0.9.95 → 0.9.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -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 { engineDaemonLocalBridge } from './engine-daemon-local-bridge.mjs';
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({ keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 8 });
38
- const daemonControlAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 4 });
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
- if (!closed) { try { onFatal(`bad sse status ${res.statusCode}`); } catch {} }
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
- try { onFatal(reason); } catch {}
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 localBridge = process.env.MIXDOG_ENGINE_DAEMON_HOST === '1'
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
- state.client = localBridge
433
- ? await localBridge.attach(attachmentOptions)
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.call('session.create', openParams, {
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
- let sync = created?.sync ?? {};
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(body.revision) || revision;
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
- void attachment.client.call('session.read', {
522
- sessionId, open: openParams, baseRevision: revision,
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?.sync) sync = result.sync;
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 attachment.client.call('session.subscribe', {
548
- sessionId, open: openParams, baseRevision: revision,
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
- addSessionView(attachment.views, sessionId, view);
556
- if (result?.sync) sync = result.sync;
557
- if (!applyBody(result) && result?.revision !== undefined) resync('recovery body gap');
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
- void recover(attachment).catch((err) => {
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 = () => attachment.client.call(route, payload, { callId });
590
- try {
591
- return await send();
592
- } catch (err) {
593
- const recoverable = err?.daemonTransportError
594
- || /unknown client token/i.test(String(err?.message || ''));
595
- if (!recoverable || disposed) throw err;
596
- if (shared === attachment) shared = null;
597
- const next = await ensureSharedAttachment({ cwd, log });
598
- await recover(next);
599
- return await send();
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?.sync) sync = result.sync;
612
- if (!applyBody(result) && result?.revision !== undefined) resync(`${reason} body gap`);
819
+ if (!applyBody(result, sourceAttachment) && result?.revision !== undefined) {
820
+ resync(`${reason} body gap`);
821
+ }
613
822
  return result;
614
823
  }
615
824
 
616
- let callChain = Promise.resolve();
617
- function serialize(task) {
618
- const run = callChain.then(task);
619
- callChain = run.then(() => {}, () => {});
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 serialize(async () => {
625
- if (disposed) throw new Error('This session view is disposed.');
626
- const stableCallId = typeof callOptions.callId === 'string' && callOptions.callId.trim()
627
- ? callOptions.callId.trim()
628
- : randomUUID();
629
- const result = await sendCall('session.invoke', {
630
- sessionId,
631
- method,
632
- args,
633
- open: openParams,
634
- baseRevision: revision,
635
- }, stableCallId);
636
- await applyResult(result, method);
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.call('session.unsubscribe', { sessionId: previousSessionId });
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 serialize(async () => {
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 serialize(async () => {
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, open: openParams, baseRevision: revision,
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
- return serialize(async () => {
705
- const result = await sendCall('session.submit', {
706
- sessionId,
707
- prompt,
708
- options: { ...(options || {}), id: submissionId },
709
- open: openParams,
710
- baseRevision: revision,
711
- }, `session-submit:${sessionId}:${submissionId}`);
712
- await applyResult(result, 'submit');
713
- return result?.accepted === true;
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
- if (options?.refreshFromStorage) dispatch('listSessions', [options]);
727
- return Array.isArray(sync.sessions) ? sync.sessions : [];
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
- sessionStoreDir() {
730
- return typeof sync.sessionStoreDir === 'string' ? sync.sessionStoreDir : null;
971
+ async removeProject(projectPath) {
972
+ const result = await sendCall('project.remove', { path: projectPath }, randomUUID());
973
+ return result?.removed === true;
731
974
  },
732
- async newSession() {
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 callDaemonSession({
995
+ await attachment.client.read({
741
996
  sessionId: target,
742
- method: 'read',
743
997
  open: openParams,
744
- cwd,
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
- abort() {
759
- void serialize(async () => {
760
- const result = await sendCall('session.abort', {
761
- sessionId, open: openParams, baseRevision: revision,
762
- }, randomUUID());
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
- void serialize(async () => {
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: revision,
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.call('session.unsubscribe', { sessionId: releasedSessionId });
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
- return new Proxy(base, {
808
- get(target, property, receiver) {
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
- // One id for the whole retry sequence. In particular, a submit whose HTTP
869
- // response was lost must hit the transport cache instead of being accepted
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
- throw lastError || new Error('engine daemon session call failed');
1068
+ return base;
894
1069
  }