mixdog 0.9.85 → 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 (74) hide show
  1. package/README.md +7 -1
  2. package/package.json +3 -2
  3. package/src/defaults/agents.json +12 -0
  4. package/src/defaults/skills/setup/SKILL.md +44 -12
  5. package/src/help.mjs +40 -9
  6. package/src/lib/keychain-cjs.cjs +11 -1
  7. package/src/rules/agent/43-title-agent.md +22 -0
  8. package/src/rules/shared/01-tool.md +3 -3
  9. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  10. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  13. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  14. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  16. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
  17. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  18. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
  19. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  20. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  21. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  22. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  23. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  24. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  28. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  29. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  30. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  31. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  32. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  33. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  34. package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
  35. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  36. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  37. package/src/runtime/media/renditions.mjs +1 -1
  38. package/src/runtime/media/store.mjs +1 -1
  39. package/src/runtime/shared/atomic-file.mjs +25 -14
  40. package/src/runtime/shared/automation-attachments.mjs +3 -3
  41. package/src/runtime/shared/child-guardian.mjs +15 -5
  42. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  43. package/src/runtime/shared/resource-admission.mjs +7 -3
  44. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  45. package/src/session-runtime/lifecycle-api.mjs +34 -9
  46. package/src/session-runtime/prewarm.mjs +35 -0
  47. package/src/session-runtime/remote-control.mjs +8 -47
  48. package/src/session-runtime/remote-transcript.mjs +3 -7
  49. package/src/session-runtime/route-preparation.mjs +24 -0
  50. package/src/session-runtime/runtime-core.mjs +37 -85
  51. package/src/session-runtime/session-turn-api.mjs +9 -2
  52. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  53. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  54. package/src/standalone/agent-shard/shard-pool.mjs +442 -0
  55. package/src/standalone/agent-tool.mjs +222 -0
  56. package/src/standalone/channel-daemon-transport.mjs +126 -185
  57. package/src/standalone/channel-daemon.mjs +9 -10
  58. package/src/tui/App.jsx +16 -9
  59. package/src/tui/app/channel-pickers.mjs +3 -8
  60. package/src/tui/app/route-pickers.mjs +117 -152
  61. package/src/tui/app/settings-picker.mjs +90 -89
  62. package/src/tui/app/slash-commands.mjs +7 -7
  63. package/src/tui/app/slash-dispatch.mjs +0 -45
  64. package/src/tui/components/StatusLine.jsx +1 -1
  65. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  66. package/src/tui/dist/index.mjs +243 -323
  67. package/src/tui/engine/session-api-ext.mjs +24 -9
  68. package/src/tui/engine.mjs +7 -1
  69. package/src/tui/figures.mjs +0 -1
  70. package/src/ui/statusline-format.mjs +0 -1
  71. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  72. package/src/lib/config-cjs.cjs +0 -61
  73. package/src/runtime/shared/launcher-control.mjs +0 -258
  74. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -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;
@@ -1174,12 +1153,18 @@ export async function createMixdogSessionRuntime({
1174
1153
  return route;
1175
1154
  }
1176
1155
 
1177
- async function refreshRouteEffort(modelMetaOverride = null) {
1178
- await ensureProvidersReady(ensureProviderEnabled(config, route.provider));
1179
- const modelMeta = modelMetaOverride || await lookupModelMeta(route.provider, route.model);
1180
- const requested = hasOwn(route, 'effort') ? route.effort : (route.preset?.effort || null);
1181
- const effectiveEffort = coerceEffortFor(route.provider, modelMeta, requested);
1182
- 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);
1183
1168
  // Carry the catalog display name onto the route so the statusline shows a
1184
1169
  // human label (e.g. "Claude Fable 5") for preset-less direct models instead
1185
1170
  // of the raw id. `name` is only trusted when it differs from the raw model
@@ -1187,11 +1172,11 @@ export async function createMixdogSessionRuntime({
1187
1172
  // already-resolved label. Falls back to existing route.modelDisplay, then unset.
1188
1173
  const metaName = clean(modelMeta?.name);
1189
1174
  const modelDisplay = clean(modelMeta?.display) || clean(modelMeta?.displayName)
1190
- || (metaName && metaName !== clean(route.model) ? metaName : '')
1191
- || clean(route.modelDisplay);
1175
+ || (metaName && metaName !== clean(targetRoute.model) ? metaName : '')
1176
+ || clean(targetRoute.modelDisplay);
1192
1177
  route = {
1193
- ...route,
1194
- fast: fastCapable ? route.fast === true : false,
1178
+ ...targetRoute,
1179
+ fast: fastCapable ? targetRoute.fast === true : false,
1195
1180
  fastCapable,
1196
1181
  effectiveEffort,
1197
1182
  effortOptions: effortItemsFor(route.provider, modelMeta, effectiveEffort),
@@ -1397,6 +1382,7 @@ export async function createMixdogSessionRuntime({
1397
1382
  const {
1398
1383
  scheduleCodeGraphPrewarm,
1399
1384
  scheduleLeadSessionPrewarm,
1385
+ scheduleToolRuntimeWarmup,
1400
1386
  invokeChannelStart,
1401
1387
  scheduleChannelStart,
1402
1388
  } = createPrewarmSchedulers({
@@ -1439,7 +1425,6 @@ export async function createMixdogSessionRuntime({
1439
1425
  isCloseRequested: () => closeRequested,
1440
1426
  channelsEnabled,
1441
1427
  channels,
1442
- mgr,
1443
1428
  bootProfile,
1444
1429
  });
1445
1430
  const ensureRemoteTranscriptWriter = () => remoteTranscript.ensureRemoteTranscriptWriter();
@@ -1454,8 +1439,6 @@ export async function createMixdogSessionRuntime({
1454
1439
  setRemoteEnabled: (next) => { remoteEnabled = next; },
1455
1440
  getRemoteSessionId: () => remoteSessionId,
1456
1441
  setRemoteSessionId: (next) => { remoteSessionId = next; },
1457
- isRemoteClaimPending: () => remoteClaimPending,
1458
- setRemoteClaimPending: (next) => { remoteClaimPending = next; },
1459
1442
  isCloseRequested: () => closeRequested,
1460
1443
  clearChannelStartTimer: () => {
1461
1444
  if (prewarmTimers.channelStartTimer) {
@@ -1504,6 +1487,7 @@ export async function createMixdogSessionRuntime({
1504
1487
  codeGraphPrewarm: codeGraphPrewarmEnabled,
1505
1488
  });
1506
1489
  scheduleLeadSessionPrewarm();
1490
+ scheduleToolRuntimeWarmup();
1507
1491
  // Lazy mode (default): skip the startup prewarm entirely; the first turn
1508
1492
  // triggers it instead (see ask()). Eager mode keeps the old startup schedule.
1509
1493
  if (!codeGraphPrewarmLazy) {
@@ -1523,32 +1507,7 @@ export async function createMixdogSessionRuntime({
1523
1507
  // Channels are opt-in: only boot the worker when this session started in (or
1524
1508
  // was toggled into) remote mode. Non-remote sessions never contend for the
1525
1509
  // channel; see startRemote()/stopRemote() and the `/remote` toggle.
1526
- // `remote.autoStart` in mixdog-config.json makes terminal sessions attempt a
1527
- // remote claim at boot. Desktop sessions are explicit-only: opening the app
1528
- // must never claim or spawn the channel bridge until its Remote button is
1529
- // pressed, while the shared owner-state watcher still reflects a terminal
1530
- // that already owns it.
1531
- // The flag lives in the TOP-LEVEL `remote` section of mixdog-config.json
1532
- // (sibling of agent/ui/channels), not inside the agent section that
1533
- // cfgMod.loadConfig returns — read it via the shared whole-file reader.
1534
- // `config?.remote?.autoStart` is kept for back-compat with agent-section
1535
- // placement.
1536
- // `remote` (from `mixdog --remote`) is EXPLICIT force-takeover. Config
1537
- // `remote.autoStart` is AUTO: it must claim the seat ONLY if no live owner
1538
- // exists (claim-if-vacant) and back off silently otherwise — so it does NOT
1539
- // set remoteEnabled up front (that would assert remote before the claim is
1540
- // known to have won). Track it as a separate deferred auto request; the
1541
- // worker's acquire notification flips remoteEnabled if/when it wins the seat.
1542
- let remoteAutoStartRequested = false;
1543
- if (!remoteEnabled && !desktopSession) {
1544
- try {
1545
- if (config?.remote?.autoStart === true
1546
- || sharedCfgMod?.readSection?.('remote')?.autoStart === true) {
1547
- remoteAutoStartRequested = true;
1548
- }
1549
- } catch { /* unreadable config never blocks boot */ }
1550
- }
1551
- // 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
1552
1511
  // first frame. startRemote() front-loads heavy work — memory daemon fork
1553
1512
  // (PG + forced ONNX embed warmup in the child), eager session create, and
1554
1513
  // the channel-worker fork — and running it inline here interleaves that
@@ -1557,22 +1516,18 @@ export async function createMixdogSessionRuntime({
1557
1516
  // early /remote (startRemote clears it), stopRemote(), and close() all
1558
1517
  // cancel it through the existing clearTimeout paths. Runtime /remote calls
1559
1518
  // still start immediately (user-initiated, UI already painted).
1560
- if (remoteEnabled || remoteAutoStartRequested) {
1561
- const remoteStartIntent = remoteEnabled ? 'explicit' : 'auto';
1562
- bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs, intent: remoteStartIntent });
1519
+ if (remoteEnabled) {
1520
+ bootProfile('channels:manual-start-deferred', { delayMs: remoteAutoStartDelayMs });
1563
1521
  prewarmTimers.channelStartTimer = setTimeout(() => {
1564
1522
  prewarmTimers.channelStartTimer = null;
1565
- if (closeRequested) return;
1566
- // Explicit: a /remote-off before the timer clears remoteEnabled — skip.
1567
- // Auto: always attempt (claim-if-vacant is safe when a live owner exists).
1568
- if (remoteStartIntent === 'explicit' && !remoteEnabled) return;
1569
- startRemote({ intent: remoteStartIntent });
1523
+ if (closeRequested || !remoteEnabled) return;
1524
+ startRemote();
1570
1525
  }, remoteAutoStartDelayMs);
1571
1526
  prewarmTimers.channelStartTimer.unref?.();
1572
1527
  } else {
1573
1528
  // Automation decoupling (user decision): enabled schedules/webhooks boot
1574
1529
  // the worker on their own — no remote flag, no remote.autoStart, and no
1575
- // messaging backend. A later explicit/auto Remote claim promotes the same
1530
+ // messaging backend. A later explicit Remote ON promotes the same
1576
1531
  // daemon without restarting schedules or webhooks.
1577
1532
  prewarmTimers.channelStartTimer = setTimeout(() => {
1578
1533
  prewarmTimers.channelStartTimer = null;
@@ -1707,6 +1662,8 @@ export async function createMixdogSessionRuntime({
1707
1662
  applyResolvedCwd,
1708
1663
  resolveRoute,
1709
1664
  applyDeferredToolSurface,
1665
+ beginRoutePreparation: (task) => routePreparation.start(task),
1666
+ clearRoutePreparation: () => routePreparation.clear(),
1710
1667
  // Live getter: cwd-refresh session rebuilds must re-evaluate the
1711
1668
  // workflow's agent-tool gate, not reuse the boot-time array.
1712
1669
  getStandaloneTools: modelStandaloneTools,
@@ -1846,6 +1803,7 @@ export async function createMixdogSessionRuntime({
1846
1803
  notificationListeners,
1847
1804
  remoteStateListeners,
1848
1805
  awaitInitialMcpConnect,
1806
+ awaitRoutePreparation: () => routePreparation.wait(),
1849
1807
  });
1850
1808
 
1851
1809
  return {
@@ -1944,15 +1902,9 @@ export async function createMixdogSessionRuntime({
1944
1902
  getRemoteSessionId() {
1945
1903
  return remoteSessionId;
1946
1904
  },
1947
- // Move the relay seat to the CURRENT session (last-wins within this
1948
- // engine): rebind the transcript writer + forwarder without restarting
1949
- // 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.
1950
1906
  claimRemoteForCurrentSession() {
1951
- if (!remoteEnabled || !session?.id) return false;
1952
- remoteSessionId = session.id;
1953
- ensureRemoteTranscriptWriter();
1954
- pushTranscriptRebind();
1955
- return true;
1907
+ return startRemote();
1956
1908
  },
1957
1909
  // Subscribe to non-user-initiated remote flips (seat superseded). Returns
1958
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
  },