mixdog 0.9.84 → 0.9.86

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 (91) hide show
  1. package/README.md +7 -1
  2. package/package.json +3 -2
  3. package/scripts/channel-daemon-stub.mjs +2 -1
  4. package/src/defaults/agents.json +12 -0
  5. package/src/defaults/skills/setup/SKILL.md +44 -12
  6. package/src/help.mjs +40 -9
  7. package/src/lib/keychain-cjs.cjs +11 -1
  8. package/src/rules/agent/43-title-agent.md +22 -0
  9. package/src/rules/shared/01-tool.md +3 -3
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  11. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  13. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
  14. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  15. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  16. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +38 -4
  18. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  19. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +183 -41
  20. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  21. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +12 -2
  22. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  23. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  24. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  25. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  26. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  27. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  35. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  36. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  37. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  38. package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
  39. package/src/runtime/channels/lib/owned-runtime.mjs +6 -8
  40. package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
  41. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  42. package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
  43. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  44. package/src/runtime/media/renditions.mjs +1 -1
  45. package/src/runtime/media/store.mjs +1 -1
  46. package/src/runtime/memory/index.mjs +14 -5
  47. package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
  48. package/src/runtime/shared/atomic-file.mjs +76 -38
  49. package/src/runtime/shared/automation-attachments.mjs +3 -3
  50. package/src/runtime/shared/child-guardian.mjs +15 -5
  51. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  52. package/src/runtime/shared/err-text.mjs +1 -0
  53. package/src/runtime/shared/resource-admission.mjs +7 -3
  54. package/src/runtime/shared/safe-ipc-send.mjs +34 -0
  55. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  56. package/src/session-runtime/lifecycle-api.mjs +43 -12
  57. package/src/session-runtime/prewarm.mjs +35 -0
  58. package/src/session-runtime/remote-control.mjs +8 -47
  59. package/src/session-runtime/remote-transcript.mjs +3 -7
  60. package/src/session-runtime/route-preparation.mjs +24 -0
  61. package/src/session-runtime/runtime-core.mjs +42 -86
  62. package/src/session-runtime/session-turn-api.mjs +9 -2
  63. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  64. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  65. package/src/standalone/agent-shard/shard-pool.mjs +442 -0
  66. package/src/standalone/agent-tool.mjs +222 -0
  67. package/src/standalone/channel-daemon-transport.mjs +126 -185
  68. package/src/standalone/channel-daemon.mjs +11 -11
  69. package/src/standalone/channel-worker.mjs +21 -19
  70. package/src/standalone/memory-runtime-proxy.mjs +98 -23
  71. package/src/tui/App.jsx +16 -9
  72. package/src/tui/app/channel-pickers.mjs +3 -8
  73. package/src/tui/app/route-pickers.mjs +117 -152
  74. package/src/tui/app/settings-picker.mjs +90 -89
  75. package/src/tui/app/slash-commands.mjs +7 -7
  76. package/src/tui/app/slash-dispatch.mjs +23 -58
  77. package/src/tui/app/theme-effort-pickers.mjs +3 -1
  78. package/src/tui/components/StatusLine.jsx +1 -1
  79. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  80. package/src/tui/dist/index.mjs +358 -380
  81. package/src/tui/engine/live-share.mjs +2 -2
  82. package/src/tui/engine/session-api-ext.mjs +24 -9
  83. package/src/tui/engine/session-flow.mjs +20 -10
  84. package/src/tui/engine/turn.mjs +28 -16
  85. package/src/tui/engine.mjs +24 -2
  86. package/src/tui/figures.mjs +0 -1
  87. package/src/ui/statusline-format.mjs +0 -1
  88. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  89. package/src/lib/config-cjs.cjs +0 -61
  90. package/src/runtime/shared/launcher-control.mjs +0 -258
  91. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -49,6 +49,7 @@ export function createLifecycleApi(deps) {
49
49
  createCurrentSession, refreshRouteEffort,
50
50
  invalidateContextStatusCache, invalidatePreSessionToolSurface,
51
51
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, getStandaloneTools,
52
+ beginRoutePreparation, clearRoutePreparation,
52
53
  pushTranscriptRebind,
53
54
  notificationListeners, remoteStateListeners, desktopSession,
54
55
  } = deps;
@@ -158,6 +159,12 @@ export function createLifecycleApi(deps) {
158
159
  return {
159
160
  async close(reason = 'cli-exit', options = {}) {
160
161
  const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
162
+ // Desktop multi-engine hosts dispose idle engines while other sessions
163
+ // keep working in the SAME process. The background-work registries
164
+ // (shared background tasks, shell jobs, bash sessions) are
165
+ // process-global, so a non-exit dispose must leave them running instead
166
+ // of reaping every session's jobs. CLI exit paths never set this.
167
+ const keepBackgroundWork = options?.keepBackgroundWork === true;
161
168
  setCloseRequested(true);
162
169
  // Self-update now stages in the background and swaps on the next clean
163
170
  // launch (see staged-update.mjs) — nothing installs at shutdown. On a
@@ -218,7 +225,7 @@ export function createLifecycleApi(deps) {
218
225
  warmupTimers[timerKey] = null;
219
226
  }
220
227
  }
221
- try { cancelBackgroundTasks({ reason, notify: false }); } catch {}
228
+ try { if (!keepBackgroundWork) cancelBackgroundTasks({ reason, notify: false }); } catch {}
222
229
  const channelStop = channels.stop(reason, detach ? { waitForExit: false } : undefined);
223
230
  try { agentTool.closeAll(reason); } catch {}
224
231
  let mcpStop = null;
@@ -260,12 +267,12 @@ export function createLifecycleApi(deps) {
260
267
  invalidateContextStatusCache();
261
268
  notificationListeners?.clear?.();
262
269
  remoteStateListeners?.clear?.();
263
- const shellJobsStop = globalThis.__mixdogShellJobsRuntimeLoaded === true
270
+ const shellJobsStop = !keepBackgroundWork && globalThis.__mixdogShellJobsRuntimeLoaded === true
264
271
  ? import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs')
265
272
  .then((mod) => mod?.shutdownShellJobs?.(reason, { sync: !detach }))
266
273
  .catch(() => {})
267
274
  : null;
268
- const bashSessionsStop = globalThis.__mixdogBashSessionRuntimeLoaded === true
275
+ const bashSessionsStop = !keepBackgroundWork && globalThis.__mixdogBashSessionRuntimeLoaded === true
269
276
  ? import('../runtime/agent/orchestrator/tools/bash-session.mjs')
270
277
  .then((mod) => mod?.shutdownBashSessions?.(reason))
271
278
  .catch(() => {})
@@ -336,7 +343,8 @@ export function createLifecycleApi(deps) {
336
343
  pushTranscriptRebind?.();
337
344
  return true;
338
345
  },
339
- async switchContext({ cwd, desktopSession: nextDesktopSession } = {}) {
346
+ async switchContext({ cwd, desktopSession: nextDesktopSession, forResume = false } = {}) {
347
+ clearRoutePreparation?.();
340
348
  const session = getSession();
341
349
  if (session?.id) {
342
350
  const cleanupReason = 'desktop-context-switch';
@@ -370,7 +378,7 @@ export function createLifecycleApi(deps) {
370
378
  // that session's provider/model. A fresh desktop task or project must
371
379
  // return to the configured Lead route instead of inheriting the route
372
380
  // of whichever session happened to be open immediately beforehand.
373
- if (typeof setRoute === 'function' && typeof getConfig === 'function'
381
+ if (!forResume && typeof setRoute === 'function' && typeof getConfig === 'function'
374
382
  && typeof resolveRoute === 'function') {
375
383
  setRoute(resolveRoute(getConfig(), {}));
376
384
  await refreshRouteEffort?.();
@@ -400,6 +408,7 @@ export function createLifecycleApi(deps) {
400
408
  return mgr.prefetchSession?.(id, toolSpecForMode(getMode())) === true;
401
409
  },
402
410
  async resume(id) {
411
+ clearRoutePreparation?.();
403
412
  const prev = getSession();
404
413
  const previousId = prev?.id || null;
405
414
  const previousMessages = prev?.messages || null;
@@ -424,22 +433,44 @@ export function createLifecycleApi(deps) {
424
433
  }
425
434
  setSession(resumed);
426
435
  applyResolvedCwd(resolveResumeCwd(resumed, getCurrentCwd()), { markRefresh: false });
436
+ // Cwd application is synchronous even though MCP reconnect may continue
437
+ // in the background. Commit it before returning the resume transcript.
438
+ resumed.cwd = getCurrentCwd();
427
439
  const route = getRoute();
428
440
  // The resumed session's OWN effort wins. resolveRoute always returns an
429
441
  // effort key, so this used to reinstate the effort of whichever session
430
442
  // happened to be open before — a schedule/webhook session opened with a
431
443
  // different effort than the one it actually ran with.
432
444
  const resumeEffort = resumed.effort || (hasOwn(route, 'effort') ? route.effort : undefined);
433
- setRoute(resolveRoute(getConfig(), { provider: resumed.provider, model: resumed.model, effort: resumeEffort }));
434
- await refreshRouteEffort();
435
- const session = getSession();
436
- session.effort = getRoute().effectiveEffort || null;
437
- session.cwd = getCurrentCwd();
438
- applyDeferredToolSurface(session, deferredSurfaceModeForLead(getMode()), getStandaloneTools(), { provider: getRoute().provider });
445
+ const resumedRoute = resolveRoute(
446
+ getConfig(),
447
+ { provider: resumed.provider, model: resumed.model, effort: resumeEffort },
448
+ );
449
+ setRoute(resumedRoute);
450
+ const finishRoutePreparation = async () => {
451
+ const preparedRoute = await refreshRouteEffort(null, resumedRoute);
452
+ // Session or route changed while provider metadata was loading.
453
+ if (!preparedRoute || getSession() !== resumed || getRoute() !== preparedRoute) return false;
454
+ const activeSession = getSession();
455
+ activeSession.effort = getRoute().effectiveEffort || null;
456
+ activeSession.cwd = getCurrentCwd();
457
+ applyDeferredToolSurface(
458
+ activeSession,
459
+ deferredSurfaceModeForLead(getMode()),
460
+ getStandaloneTools(),
461
+ { provider: getRoute().provider },
462
+ );
463
+ writeStatuslineRoute(statusRoutes, activeSession, getRoute());
464
+ return true;
465
+ };
466
+ if (typeof beginRoutePreparation === 'function') {
467
+ beginRoutePreparation(finishRoutePreparation);
468
+ } else {
469
+ await finishRoutePreparation();
470
+ }
439
471
  invalidatePreSessionToolSurface();
440
472
  invalidateContextStatusCache();
441
473
  setSessionNeedsCwdRefresh(false);
442
- writeStatuslineRoute(statusRoutes, session, getRoute());
443
474
  // Session swapped to the resumed one: repoint the worker to the current
444
475
  // transcript instead of waiting for the next inbound steal.
445
476
  pushTranscriptRebind?.();
@@ -91,6 +91,40 @@ export function createPrewarmSchedulers({
91
91
  timer.unref?.();
92
92
  }
93
93
 
94
+ // Tool-runtime warmup: the per-call cold starts a session's FIRST tool call
95
+ // would otherwise pay — pwsh standby processes (~300ms each) and the o200k
96
+ // encoder + token-count worker (~100ms). Each part is independent and
97
+ // best-effort; failures only mark the boot profile.
98
+ function scheduleToolRuntimeWarmup(delayMs = 2500) {
99
+ if (envFlag('MIXDOG_DISABLE_TOOL_PREWARM')) {
100
+ bootProfile('tool-runtime:prewarm-skipped');
101
+ return;
102
+ }
103
+ const timer = setTimeout(() => void (async () => {
104
+ if (isCloseRequested()) return;
105
+ try {
106
+ const { prewarmShellStandbys } = await import('../runtime/agent/orchestrator/tools/builtin/bash-tool.mjs');
107
+ bootProfile('tool-runtime:shell-standby', { warmed: prewarmShellStandbys() === true });
108
+ } catch (error) {
109
+ bootProfile('tool-runtime:shell-standby-failed', { error: error?.message || String(error) });
110
+ }
111
+ try {
112
+ const { prewarmTokenEstimator } = await import('../runtime/agent/orchestrator/session/context-utils.mjs');
113
+ bootProfile('tool-runtime:token-estimator', { warmed: prewarmTokenEstimator() === true });
114
+ } catch (error) {
115
+ bootProfile('tool-runtime:token-estimator-failed', { error: error?.message || String(error) });
116
+ }
117
+ try {
118
+ const { prewarmAgentShard } = await import('../standalone/agent-shard/shard-pool.mjs');
119
+ const warmed = await prewarmAgentShard();
120
+ if (warmed) bootProfile('tool-runtime:agent-shard', { warmed: true });
121
+ } catch (error) {
122
+ bootProfile('tool-runtime:agent-shard-failed', { error: error?.message || String(error) });
123
+ }
124
+ })(), delayMs);
125
+ timer.unref?.();
126
+ }
127
+
94
128
  function invokeChannelStart() {
95
129
  if (state.channelStartPromise) return state.channelStartPromise;
96
130
  const startedAt = performance.now();
@@ -142,6 +176,7 @@ export function createPrewarmSchedulers({
142
176
  return {
143
177
  scheduleCodeGraphPrewarm,
144
178
  scheduleLeadSessionPrewarm,
179
+ scheduleToolRuntimeWarmup,
145
180
  invokeChannelStart,
146
181
  scheduleChannelStart,
147
182
  };
@@ -10,8 +10,6 @@ export function createRemoteControl({
10
10
  setRemoteEnabled,
11
11
  getRemoteSessionId,
12
12
  setRemoteSessionId,
13
- setRemoteClaimPending,
14
- isRemoteClaimPending,
15
13
  isCloseRequested,
16
14
  clearChannelStartTimer,
17
15
  remoteTransitions,
@@ -28,22 +26,11 @@ export function createRemoteControl({
28
26
  bootProfile,
29
27
  envFlag,
30
28
  }) {
31
- // FORCE-TAKEOVER for an explicit claim: it always (re)claims the seat and
32
- // rebinds forwarding to this session, even when the worker already runs
33
- // (`/remote` re-issued after another session took the seat).
34
- function startRemote(options = {}) {
35
- const intent = options?.intent === 'auto' ? 'auto' : 'explicit';
36
- if (intent === 'auto') {
37
- // Auto-start attempts a claim-if-vacant: remoteEnabled flips only when the
38
- // worker reports it actually acquired the seat, so a live owner elsewhere
39
- // leaves this session non-remote.
40
- setRemoteClaimPending(true);
41
- } else {
42
- setRemoteEnabled(true);
43
- // Explicit toggle: the CURRENT session owns the relay (lazy create adopts
44
- // inside ensureRemoteTranscriptWriter when no session exists yet).
45
- setRemoteSessionId(getSession()?.id || null);
46
- }
29
+ // Manual ON always targets the current session and explicitly overrides any
30
+ // previous manual owner. There is no auto/claim-if-vacant path.
31
+ function startRemote() {
32
+ setRemoteEnabled(true);
33
+ setRemoteSessionId(getSession()?.id || null);
47
34
  // The worker forwards transcript ingests to the memory service, so its port
48
35
  // must be live before the first ingest.
49
36
  startMemoryDaemonEagerly({ getMemoryModule, bootProfile });
@@ -65,7 +52,6 @@ export function createRemoteControl({
65
52
  // boots the worker, which runs headless when messaging is off.
66
53
  if (!channelsEnabled() && !(await hasActiveAutomation().catch(() => false))) {
67
54
  bootProfile('channels:start-disabled');
68
- setRemoteClaimPending(false);
69
55
  return isRemoteEnabled();
70
56
  }
71
57
  // A backend switch may still be writing; drain it before the worker
@@ -83,39 +69,16 @@ export function createRemoteControl({
83
69
  ensureRemoteTranscriptWriter();
84
70
  // Re-check after the awaits: stop/supersede or runtime close may have
85
71
  // landed mid-chain — never boot for a session that turned remote off.
86
- if ((!isRemoteEnabled() && !isRemoteClaimPending()) || isCloseRequested()) {
87
- setRemoteClaimPending(false);
88
- return false;
89
- }
90
- // The fork reads process.env synchronously, so set the inherited intent
91
- // immediately before it and restore right after: the pollution window is
92
- // this fork instead of the whole boot chain.
93
- const previousIntent = process.env.MIXDOG_REMOTE_INTENT;
94
- try {
95
- process.env.MIXDOG_REMOTE_INTENT = intent;
96
- await invokeChannelStart();
97
- } finally {
98
- if (previousIntent === undefined) delete process.env.MIXDOG_REMOTE_INTENT;
99
- else process.env.MIXDOG_REMOTE_INTENT = previousIntent;
100
- }
101
- if ((!isRemoteEnabled() && !isRemoteClaimPending()) || isCloseRequested()) {
102
- setRemoteClaimPending(false);
103
- return false;
104
- }
105
- // Explicit claims are unconditional; auto-start dispatches the same
106
- // activation as claim-if-vacant so it promotes an automation-only daemon
107
- // without stealing a live remote owner.
72
+ if (!isRemoteEnabled() || isCloseRequested()) return false;
73
+ await invokeChannelStart();
74
+ if (!isRemoteEnabled() || isCloseRequested()) return false;
108
75
  await channels.execute('activate_channel_bridge', {
109
76
  active: true,
110
- claimIfVacant: intent === 'auto',
111
77
  sessionId: getSession()?.id || getRemoteSessionId() || null,
112
78
  transcriptPath: getTranscriptPath(),
113
79
  });
114
- // The worker's acquire/supersede notification now owns the transition.
115
- setRemoteClaimPending(false);
116
80
  return isRemoteEnabled();
117
81
  } catch (error) {
118
- setRemoteClaimPending(false);
119
82
  if (isRemoteEnabled()) {
120
83
  setRemoteEnabled(false);
121
84
  setRemoteSessionId(null);
@@ -135,7 +98,6 @@ export function createRemoteControl({
135
98
  const releasingSessionId = getRemoteSessionId() || getSession()?.id || null;
136
99
  setRemoteEnabled(false);
137
100
  setRemoteSessionId(null);
138
- setRemoteClaimPending(false);
139
101
  clearChannelStartTimer();
140
102
  return remoteTransitions.run(async () => {
141
103
  try {
@@ -156,7 +118,6 @@ export function createRemoteControl({
156
118
  function stopRemote(reason) {
157
119
  setRemoteEnabled(false);
158
120
  setRemoteSessionId(null);
159
- setRemoteClaimPending(false);
160
121
  clearChannelStartTimer();
161
122
  channels.stop(reason || 'remote-disabled').catch(() => {});
162
123
  return true;
@@ -17,7 +17,6 @@ export function createRemoteTranscript({
17
17
  isCloseRequested,
18
18
  channelsEnabled,
19
19
  channels,
20
- mgr,
21
20
  bootProfile,
22
21
  }) {
23
22
  let writer = null;
@@ -36,9 +35,8 @@ export function createRemoteTranscript({
36
35
  flushPendingTranscriptRebind,
37
36
  };
38
37
 
39
- // Bind (or refresh) the writer for the CURRENT session+cwd. The owner gate
40
- // covers lazy create: an unset owner adopts this session, a LIVE owner blocks
41
- // every other session, and a dead owner hands the relay over.
38
+ // Bind (or refresh) only the manually selected session. Session creation,
39
+ // close, or liveness never transfers Remote ownership.
42
40
  function ensureRemoteTranscriptWriter() {
43
41
  const session = getSession();
44
42
  if (!isRemoteEnabled() || !session?.id) return false;
@@ -46,9 +44,7 @@ export function createRemoteTranscript({
46
44
  if (!owner) {
47
45
  setRemoteSessionId(session.id);
48
46
  } else if (session.id !== owner) {
49
- const ownerSession = mgr.getSession(owner);
50
- if (ownerSession && ownerSession.closed !== true && ownerSession.status !== 'closed') return false;
51
- setRemoteSessionId(session.id);
47
+ return false;
52
48
  }
53
49
  const cwd = getCwd();
54
50
  const key = `${session.id}\u0000${cwd}`;
@@ -0,0 +1,24 @@
1
+ // One background route preparation can be associated with the currently
2
+ // resumed session. Resume/navigation reads do not wait for it; the next real
3
+ // turn does. Clearing replaces the visible gate without attempting to cancel
4
+ // provider initialization that may already be shared elsewhere.
5
+ export function createRoutePreparationGate({ onError } = {}) {
6
+ let pending = Promise.resolve();
7
+
8
+ return {
9
+ clear() {
10
+ pending = Promise.resolve();
11
+ },
12
+ start(task) {
13
+ const operation = Promise.resolve().then(task);
14
+ pending = operation;
15
+ // Keep the original rejecting promise for waiters while still marking a
16
+ // background failure as observed until a submit reaches the same gate.
17
+ void operation.catch((error) => onError?.(error));
18
+ return operation;
19
+ },
20
+ wait() {
21
+ return pending;
22
+ },
23
+ };
24
+ }
@@ -246,6 +246,7 @@ import { createToolSurface } from './tool-surface.mjs';
246
246
  import { readRuntimeTunables } from './runtime-tunables.mjs';
247
247
  import { createSessionTurnApi } from './session-turn-api.mjs';
248
248
  import { providerInitCacheKey } from './provider-init-key.mjs';
249
+ import { createRoutePreparationGate } from './route-preparation.mjs';
249
250
  import {
250
251
  RUNTIME,
251
252
  SEARCH_RUNTIME,
@@ -322,17 +323,8 @@ export async function createMixdogSessionRuntime({
322
323
  let desktopSession = initialDesktopSession;
323
324
  bootProfile('session-runtime:start', { provider, model, toolMode, cwd });
324
325
  let remoteEnabled = remote === true;
325
- // Transient marker: an AUTO start (config/delayed autoStart) has forked the
326
- // worker to ATTEMPT a claim-if-vacant but has NOT asserted remote for this
327
- // session yet. remoteEnabled flips only if the worker reports it acquired the
328
- // seat (the 'acquired' notification). Lets the deferred start chain proceed
329
- // past its remoteEnabled guards without prematurely showing this session as
330
- // remote (single-holder: a live owner must not be stolen by autoStart).
331
- let remoteClaimPending = false;
332
- // SESSION-SCOPED remote (user decision): the session that toggled remote
333
- // OWNS the channel relay. Other sessions' turns never rebind or relay; a
334
- // dead/replaced owner (clear, delete) hands the seat to the next current
335
- // session that asks.
326
+ // SESSION-SCOPED Remote is manual-only. Explicit ON selects/overrides; no
327
+ // registration, turn, session death, or process death transfers ownership.
336
328
  let remoteSessionId = null;
337
329
  // Desktop/TUI Remote toggles are desired-state transitions. Serialize the
338
330
  // daemon deactivate/detach and reconnect/activate chains so an OFF that is
@@ -371,6 +363,11 @@ export async function createMixdogSessionRuntime({
371
363
  })();
372
364
  return keychainPrewarmWaitPromise;
373
365
  }
366
+ const routePreparation = createRoutePreparationGate({
367
+ onError: (error) => bootProfile('route-preparation:failed', {
368
+ error: error?.message || String(error),
369
+ }),
370
+ });
374
371
 
375
372
  const importsStartedAt = performance.now();
376
373
  const [
@@ -744,28 +741,10 @@ export async function createMixdogSessionRuntime({
744
741
  // newer remote session. Drop remote mode entirely on this session (no
745
742
  // handover, no retry) and tell UI listeners so the indicator updates.
746
743
  if (msg?.method === 'notifications/mixdog/remote') {
747
- // Any acquire/supersede verdict resolves an in-flight auto claim attempt.
748
- remoteClaimPending = false;
749
744
  if (msg?.params?.state === 'superseded' && remoteEnabled) {
750
745
  stopRemote('superseded-by-newer-remote-session');
751
746
  emitRemoteStateChange(false, 'superseded');
752
747
  }
753
- // Symmetric acquire: the worker took the bridge seat (boot make-before-
754
- // break or activate). Flip remote ON via the same side-state a user
755
- // /remote toggles — remoteEnabled + transcript writer — but WITHOUT
756
- // re-invoking channel start (the worker is already running; startRemote
757
- // would re-fork/activate). Idempotent: no-op when already enabled.
758
- if (msg?.params?.state === 'acquired' && !remoteEnabled) {
759
- remoteEnabled = true;
760
- if (!remoteSessionId) remoteSessionId = session?.id || null;
761
- ensureRemoteTranscriptWriter();
762
- // Auto-acquire: the worker restored yesterday's transcript from
763
- // persisted status and we just created the CURRENT writer. Push the
764
- // repoint now instead of waiting for the next inbound parent-chain
765
- // steal, so outbound forwarding never tails a stale transcript.
766
- pushTranscriptRebind();
767
- emitRemoteStateChange(true, 'acquired');
768
- }
769
748
  return;
770
749
  }
771
750
  if (msg?.method !== 'notifications/claude/channel') return;
@@ -845,7 +824,11 @@ export async function createMixdogSessionRuntime({
845
824
  if (name === 'recall' || name === 'memory' || name === 'search_memories') {
846
825
  const memoryMod = await getMemoryModule();
847
826
  if (!memoryMod?.handleToolCall) throw new Error('memory runtime is not available');
848
- return await memoryMod.handleToolCall(name, memoryToolArgsForCaller(args, callerCwd));
827
+ return await memoryMod.handleToolCall(
828
+ name,
829
+ memoryToolArgsForCaller(args, callerCwd),
830
+ callerCtx?.signal || session?.controller?.signal || null,
831
+ );
849
832
  }
850
833
  if (name === 'code_graph') {
851
834
  const codeGraphMod = await getCodeGraphModule();
@@ -1170,12 +1153,18 @@ export async function createMixdogSessionRuntime({
1170
1153
  return route;
1171
1154
  }
1172
1155
 
1173
- async function refreshRouteEffort(modelMetaOverride = null) {
1174
- await ensureProvidersReady(ensureProviderEnabled(config, route.provider));
1175
- const modelMeta = modelMetaOverride || await lookupModelMeta(route.provider, route.model);
1176
- const requested = hasOwn(route, 'effort') ? route.effort : (route.preset?.effort || null);
1177
- const effectiveEffort = coerceEffortFor(route.provider, modelMeta, requested);
1178
- const fastCapable = fastCapableFor(route.provider, modelMeta);
1156
+ async function refreshRouteEffort(modelMetaOverride = null, expectedRoute = null) {
1157
+ const targetRoute = expectedRoute || route;
1158
+ await ensureProvidersReady(ensureProviderEnabled(config, targetRoute.provider));
1159
+ const modelMeta = modelMetaOverride || await lookupModelMeta(targetRoute.provider, targetRoute.model);
1160
+ // A rapid second resume/model change can replace the route while provider
1161
+ // metadata is loading. Never let the older completion overwrite it.
1162
+ if (expectedRoute && route !== expectedRoute) return null;
1163
+ const requested = hasOwn(targetRoute, 'effort')
1164
+ ? targetRoute.effort
1165
+ : (targetRoute.preset?.effort || null);
1166
+ const effectiveEffort = coerceEffortFor(targetRoute.provider, modelMeta, requested);
1167
+ const fastCapable = fastCapableFor(targetRoute.provider, modelMeta);
1179
1168
  // Carry the catalog display name onto the route so the statusline shows a
1180
1169
  // human label (e.g. "Claude Fable 5") for preset-less direct models instead
1181
1170
  // of the raw id. `name` is only trusted when it differs from the raw model
@@ -1183,11 +1172,11 @@ export async function createMixdogSessionRuntime({
1183
1172
  // already-resolved label. Falls back to existing route.modelDisplay, then unset.
1184
1173
  const metaName = clean(modelMeta?.name);
1185
1174
  const modelDisplay = clean(modelMeta?.display) || clean(modelMeta?.displayName)
1186
- || (metaName && metaName !== clean(route.model) ? metaName : '')
1187
- || clean(route.modelDisplay);
1175
+ || (metaName && metaName !== clean(targetRoute.model) ? metaName : '')
1176
+ || clean(targetRoute.modelDisplay);
1188
1177
  route = {
1189
- ...route,
1190
- fast: fastCapable ? route.fast === true : false,
1178
+ ...targetRoute,
1179
+ fast: fastCapable ? targetRoute.fast === true : false,
1191
1180
  fastCapable,
1192
1181
  effectiveEffort,
1193
1182
  effortOptions: effortItemsFor(route.provider, modelMeta, effectiveEffort),
@@ -1393,6 +1382,7 @@ export async function createMixdogSessionRuntime({
1393
1382
  const {
1394
1383
  scheduleCodeGraphPrewarm,
1395
1384
  scheduleLeadSessionPrewarm,
1385
+ scheduleToolRuntimeWarmup,
1396
1386
  invokeChannelStart,
1397
1387
  scheduleChannelStart,
1398
1388
  } = createPrewarmSchedulers({
@@ -1435,7 +1425,6 @@ export async function createMixdogSessionRuntime({
1435
1425
  isCloseRequested: () => closeRequested,
1436
1426
  channelsEnabled,
1437
1427
  channels,
1438
- mgr,
1439
1428
  bootProfile,
1440
1429
  });
1441
1430
  const ensureRemoteTranscriptWriter = () => remoteTranscript.ensureRemoteTranscriptWriter();
@@ -1450,8 +1439,6 @@ export async function createMixdogSessionRuntime({
1450
1439
  setRemoteEnabled: (next) => { remoteEnabled = next; },
1451
1440
  getRemoteSessionId: () => remoteSessionId,
1452
1441
  setRemoteSessionId: (next) => { remoteSessionId = next; },
1453
- isRemoteClaimPending: () => remoteClaimPending,
1454
- setRemoteClaimPending: (next) => { remoteClaimPending = next; },
1455
1442
  isCloseRequested: () => closeRequested,
1456
1443
  clearChannelStartTimer: () => {
1457
1444
  if (prewarmTimers.channelStartTimer) {
@@ -1500,6 +1487,7 @@ export async function createMixdogSessionRuntime({
1500
1487
  codeGraphPrewarm: codeGraphPrewarmEnabled,
1501
1488
  });
1502
1489
  scheduleLeadSessionPrewarm();
1490
+ scheduleToolRuntimeWarmup();
1503
1491
  // Lazy mode (default): skip the startup prewarm entirely; the first turn
1504
1492
  // triggers it instead (see ask()). Eager mode keeps the old startup schedule.
1505
1493
  if (!codeGraphPrewarmLazy) {
@@ -1519,32 +1507,7 @@ export async function createMixdogSessionRuntime({
1519
1507
  // Channels are opt-in: only boot the worker when this session started in (or
1520
1508
  // was toggled into) remote mode. Non-remote sessions never contend for the
1521
1509
  // channel; see startRemote()/stopRemote() and the `/remote` toggle.
1522
- // `remote.autoStart` in mixdog-config.json makes terminal sessions attempt a
1523
- // remote claim at boot. Desktop sessions are explicit-only: opening the app
1524
- // must never claim or spawn the channel bridge until its Remote button is
1525
- // pressed, while the shared owner-state watcher still reflects a terminal
1526
- // that already owns it.
1527
- // The flag lives in the TOP-LEVEL `remote` section of mixdog-config.json
1528
- // (sibling of agent/ui/channels), not inside the agent section that
1529
- // cfgMod.loadConfig returns — read it via the shared whole-file reader.
1530
- // `config?.remote?.autoStart` is kept for back-compat with agent-section
1531
- // placement.
1532
- // `remote` (from `mixdog --remote`) is EXPLICIT force-takeover. Config
1533
- // `remote.autoStart` is AUTO: it must claim the seat ONLY if no live owner
1534
- // exists (claim-if-vacant) and back off silently otherwise — so it does NOT
1535
- // set remoteEnabled up front (that would assert remote before the claim is
1536
- // known to have won). Track it as a separate deferred auto request; the
1537
- // worker's acquire notification flips remoteEnabled if/when it wins the seat.
1538
- let remoteAutoStartRequested = false;
1539
- if (!remoteEnabled && !desktopSession) {
1540
- try {
1541
- if (config?.remote?.autoStart === true
1542
- || sharedCfgMod?.readSection?.('remote')?.autoStart === true) {
1543
- remoteAutoStartRequested = true;
1544
- }
1545
- } catch { /* unreadable config never blocks boot */ }
1546
- }
1547
- // Boot-time remote start (autoStart or --remote) is DEFERRED past the TUI's
1510
+ // A manual `mixdog --remote` request is deferred past the TUI's
1548
1511
  // first frame. startRemote() front-loads heavy work — memory daemon fork
1549
1512
  // (PG + forced ONNX embed warmup in the child), eager session create, and
1550
1513
  // the channel-worker fork — and running it inline here interleaves that
@@ -1553,22 +1516,18 @@ export async function createMixdogSessionRuntime({
1553
1516
  // early /remote (startRemote clears it), stopRemote(), and close() all
1554
1517
  // cancel it through the existing clearTimeout paths. Runtime /remote calls
1555
1518
  // still start immediately (user-initiated, UI already painted).
1556
- if (remoteEnabled || remoteAutoStartRequested) {
1557
- const remoteStartIntent = remoteEnabled ? 'explicit' : 'auto';
1558
- bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs, intent: remoteStartIntent });
1519
+ if (remoteEnabled) {
1520
+ bootProfile('channels:manual-start-deferred', { delayMs: remoteAutoStartDelayMs });
1559
1521
  prewarmTimers.channelStartTimer = setTimeout(() => {
1560
1522
  prewarmTimers.channelStartTimer = null;
1561
- if (closeRequested) return;
1562
- // Explicit: a /remote-off before the timer clears remoteEnabled — skip.
1563
- // Auto: always attempt (claim-if-vacant is safe when a live owner exists).
1564
- if (remoteStartIntent === 'explicit' && !remoteEnabled) return;
1565
- startRemote({ intent: remoteStartIntent });
1523
+ if (closeRequested || !remoteEnabled) return;
1524
+ startRemote();
1566
1525
  }, remoteAutoStartDelayMs);
1567
1526
  prewarmTimers.channelStartTimer.unref?.();
1568
1527
  } else {
1569
1528
  // Automation decoupling (user decision): enabled schedules/webhooks boot
1570
1529
  // the worker on their own — no remote flag, no remote.autoStart, and no
1571
- // messaging backend. A later explicit/auto Remote claim promotes the same
1530
+ // messaging backend. A later explicit Remote ON promotes the same
1572
1531
  // daemon without restarting schedules or webhooks.
1573
1532
  prewarmTimers.channelStartTimer = setTimeout(() => {
1574
1533
  prewarmTimers.channelStartTimer = null;
@@ -1703,6 +1662,8 @@ export async function createMixdogSessionRuntime({
1703
1662
  applyResolvedCwd,
1704
1663
  resolveRoute,
1705
1664
  applyDeferredToolSurface,
1665
+ beginRoutePreparation: (task) => routePreparation.start(task),
1666
+ clearRoutePreparation: () => routePreparation.clear(),
1706
1667
  // Live getter: cwd-refresh session rebuilds must re-evaluate the
1707
1668
  // workflow's agent-tool gate, not reuse the boot-time array.
1708
1669
  getStandaloneTools: modelStandaloneTools,
@@ -1842,6 +1803,7 @@ export async function createMixdogSessionRuntime({
1842
1803
  notificationListeners,
1843
1804
  remoteStateListeners,
1844
1805
  awaitInitialMcpConnect,
1806
+ awaitRoutePreparation: () => routePreparation.wait(),
1845
1807
  });
1846
1808
 
1847
1809
  return {
@@ -1940,15 +1902,9 @@ export async function createMixdogSessionRuntime({
1940
1902
  getRemoteSessionId() {
1941
1903
  return remoteSessionId;
1942
1904
  },
1943
- // Move the relay seat to the CURRENT session (last-wins within this
1944
- // engine): rebind the transcript writer + forwarder without restarting
1945
- // the worker. No-op when remote is off or no session exists.
1905
+ // Back-compat alias for an explicit manual ON in the current session.
1946
1906
  claimRemoteForCurrentSession() {
1947
- if (!remoteEnabled || !session?.id) return false;
1948
- remoteSessionId = session.id;
1949
- ensureRemoteTranscriptWriter();
1950
- pushTranscriptRebind();
1951
- return true;
1907
+ return startRemote();
1952
1908
  },
1953
1909
  // Subscribe to non-user-initiated remote flips (seat superseded). Returns
1954
1910
  // an unsubscribe function. TUI uses this to sync its Remote indicator and
@@ -43,7 +43,7 @@ export function createSessionTurnApi(deps) {
43
43
  scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
44
44
  agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
45
45
  activeToolSurface, applyResolvedCwd, resolveCwdPath, agentStatusState, notificationListeners,
46
- awaitInitialMcpConnect,
46
+ awaitInitialMcpConnect, awaitRoutePreparation,
47
47
  } = deps;
48
48
  const enqueueRemoteAttachedPrompt = (prompt) => {
49
49
  const attachedSession = getSession();
@@ -84,6 +84,10 @@ export function createSessionTurnApi(deps) {
84
84
  session: attachedSession,
85
85
  };
86
86
  }
87
+ // Historical-session resume publishes its transcript immediately while
88
+ // provider/model metadata initializes in the background. Only the next
89
+ // actual turn waits, guaranteeing that it cannot run on a stale route.
90
+ await awaitRoutePreparation?.();
87
91
  setActiveTurnCount(getActiveTurnCount() + 1);
88
92
  // Lazy code-graph prewarm: kick off the build ONCE, on the first real
89
93
  // turn, so a likely code lookup hits a warm cache.
@@ -115,7 +119,10 @@ export function createSessionTurnApi(deps) {
115
119
  void invokeChannelStart()
116
120
  .then(() => {
117
121
  if (!getRemoteEnabled() || getCloseRequested()) return undefined;
118
- return channels.execute('activate_channel_bridge', { active: true });
122
+ // A turn may refresh the current owner's transcript, but it
123
+ // must never acquire/override Remote implicitly.
124
+ pushTranscriptRebind();
125
+ return undefined;
119
126
  })
120
127
  .catch((error) => bootProfile('channels:turn-rebind-failed', { error: error?.message || String(error) }));
121
128
  }
@@ -165,6 +165,9 @@ export function createWorkflowAgentsApi(deps) {
165
165
  description: workflow.description,
166
166
  source: workflow.source,
167
167
  active: workflow.id === active,
168
+ // Distinguishes "agents: (empty) = Lead only" from "no agents key =
169
+ // all fixed agents" for surfaces that render the pack's agent set.
170
+ agentsConfigured: workflow.agentsConfigured === true,
168
171
  agents: workflow.agents,
169
172
  }));
170
173
  },