mixdog 0.9.67 → 0.9.69

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 (73) hide show
  1. package/package.json +6 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  11. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  12. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  14. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  15. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  16. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  19. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  20. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  21. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  23. package/src/runtime/channels/lib/config.mjs +6 -5
  24. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  25. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  26. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  27. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  28. package/src/runtime/channels/lib/webhook.mjs +36 -46
  29. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  30. package/src/runtime/shared/automation-attachments.mjs +72 -0
  31. package/src/runtime/shared/automation-workflow.mjs +41 -0
  32. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  33. package/src/runtime/shared/schedules-db.mjs +18 -3
  34. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  35. package/src/runtime/shared/webhooks-db.mjs +19 -3
  36. package/src/session-runtime/channel-config-api.mjs +8 -1
  37. package/src/session-runtime/lifecycle-api.mjs +7 -0
  38. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  39. package/src/session-runtime/model-capabilities.mjs +6 -4
  40. package/src/session-runtime/notification-bus.mjs +82 -0
  41. package/src/session-runtime/provider-auth-api.mjs +16 -1
  42. package/src/session-runtime/provider-usage.mjs +3 -0
  43. package/src/session-runtime/remote-control.mjs +166 -0
  44. package/src/session-runtime/remote-transcript.mjs +126 -0
  45. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  46. package/src/session-runtime/runtime-core.mjs +184 -660
  47. package/src/session-runtime/runtime-tunables.mjs +57 -0
  48. package/src/session-runtime/self-update.mjs +129 -0
  49. package/src/session-runtime/skills-api.mjs +77 -0
  50. package/src/session-runtime/tool-surface.mjs +83 -0
  51. package/src/session-runtime/workflow-agents-api.mjs +206 -3
  52. package/src/session-runtime/workflow.mjs +84 -17
  53. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  54. package/src/standalone/agent-tool.mjs +22 -346
  55. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  56. package/src/standalone/channel-admin.mjs +36 -1
  57. package/src/standalone/channel-daemon-client.mjs +5 -1
  58. package/src/standalone/channel-daemon-transport.mjs +112 -20
  59. package/src/standalone/channel-daemon.mjs +6 -4
  60. package/src/standalone/channel-worker.mjs +7 -13
  61. package/src/tui/App.jsx +2 -32
  62. package/src/tui/app/channel-pickers.mjs +2 -143
  63. package/src/tui/app/slash-commands.mjs +1 -3
  64. package/src/tui/app/slash-dispatch.mjs +3 -3
  65. package/src/tui/components/StatusLine.jsx +6 -5
  66. package/src/tui/dist/index.mjs +162 -259
  67. package/src/tui/engine/labels.mjs +0 -8
  68. package/src/tui/engine/session-api-ext.mjs +45 -10
  69. package/src/tui/engine/turn-watchdog.mjs +92 -0
  70. package/src/tui/engine/turn.mjs +18 -70
  71. package/src/tui/engine.mjs +14 -1
  72. package/src/workflows/default/WORKFLOW.md +1 -1
  73. package/src/workflows/solo/WORKFLOW.md +1 -1
@@ -1,7 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import { performance } from 'node:perf_hooks';
4
- import httpMod from 'node:http';
5
4
  import keychain from '../lib/keychain-cjs.cjs';
6
5
  import './hitch-profile.mjs';
7
6
  import { ensureStandaloneEnvironment } from '../standalone/seeds.mjs';
@@ -11,15 +10,13 @@ import { EXPLORE_TOOL, runExplore } from '../standalone/explore-tool.mjs';
11
10
  import { createStandaloneChannelWorker } from '../standalone/channel-worker.mjs';
12
11
  import { createStandaloneMemoryRuntime } from '../standalone/memory-runtime-proxy.mjs';
13
12
  import { createStandaloneHookBus } from '../standalone/hook-bus.mjs';
13
+ import { createRemoteTransitionQueue } from './remote-transition-queue.mjs';
14
14
  import { writeLastSessionCwd } from '../runtime/shared/user-cwd.mjs';
15
15
  import { cancelBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
16
16
  import { createTranscriptWriter } from '../runtime/shared/transcript-writer.mjs';
17
17
  import { mixdogHome } from '../runtime/shared/plugin-paths.mjs';
18
18
  import { checkLatestVersion, localPackageVersion, isDevInstall } from '../runtime/shared/update-checker.mjs';
19
19
  import { spawnStagedInstall, runStagedInstall, isStagedComplete } from '../runtime/shared/staged-update.mjs';
20
- import {
21
- modelVisibleToolCompletionMessage,
22
- } from '../runtime/shared/tool-execution-contract.mjs';
23
20
  import {
24
21
  channelNotificationModelContent,
25
22
  shouldMirrorChannelNotificationToPending,
@@ -30,12 +27,6 @@ import {
30
27
  } from '../runtime/shared/markdown-frontmatter.mjs';
31
28
  import { setConfiguredShell } from '../runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs';
32
29
  import { hasUserConversationMessage } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
33
- import { markCompletionEntry } from '../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
34
- import {
35
- recordDeliveredCompletion,
36
- isDeliveredCompletion,
37
- logDuplicateSkip,
38
- } from '../runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
39
30
  import {
40
31
  beginOAuthProviderLogin,
41
32
  forgetProviderAuth,
@@ -236,7 +227,7 @@ import { createCwdPlugins } from './cwd-plugins.mjs';
236
227
  import { createSettingsApi } from './settings-api.mjs';
237
228
  import { createProviderModels } from './provider-models.mjs';
238
229
  import { createProviderUsage } from './provider-usage.mjs';
239
- import { envFlag, envPresent, envDelayMs } from './env.mjs';
230
+ import { envFlag } from './env.mjs';
240
231
  import { bootProfile, profiledImport } from './boot-profile.mjs';
241
232
  import { createChannelConfigApi } from './channel-config-api.mjs';
242
233
  import { createProviderAuthApi } from './provider-auth-api.mjs';
@@ -245,6 +236,13 @@ import { createLifecycleApi } from './lifecycle-api.mjs';
245
236
  import { createResourceApi } from './resource-api.mjs';
246
237
  import { createModelRouteApi } from './model-route-api.mjs';
247
238
  import { createWorkflowAgentsApi } from './workflow-agents-api.mjs';
239
+ import { createSelfUpdateController } from './self-update.mjs';
240
+ import { createSkillsApi } from './skills-api.mjs';
241
+ import { createRemoteTranscript } from './remote-transcript.mjs';
242
+ import { createRemoteControl } from './remote-control.mjs';
243
+ import { createNotificationBus } from './notification-bus.mjs';
244
+ import { createToolSurface } from './tool-surface.mjs';
245
+ import { readRuntimeTunables } from './runtime-tunables.mjs';
248
246
  import { createSessionTurnApi } from './session-turn-api.mjs';
249
247
  import { providerInitCacheKey } from './provider-init-key.mjs';
250
248
  import {
@@ -297,6 +295,7 @@ const {
297
295
  workflowSummary,
298
296
  activeWorkflowSummary,
299
297
  loadAgentDefinition,
298
+ listCustomAgentIds,
300
299
  workflowContextBlock,
301
300
  activeWorkflowContext,
302
301
  } = createWorkflowHelpers({
@@ -329,15 +328,15 @@ export async function createMixdogSessionRuntime({
329
328
  // past its remoteEnabled guards without prematurely showing this session as
330
329
  // remote (single-holder: a live owner must not be stolen by autoStart).
331
330
  let remoteClaimPending = false;
332
- // Remote-mode transcript writer (Discord outbound). Lazily created per
333
- // session.id + cwd inside ask(); only active while remoteEnabled.
334
- let _transcriptWriter = null;
335
- let _twKey = '';
336
- // One-shot: an 'acquired' verdict (or other rebind trigger) landed before a
337
- // session/writer existed, so the rebind push could not fire. Set true when a
338
- // push is deferred; the next session-create / turn-start flushes it exactly
339
- // once so the daemon forwarder always ends bound to THIS session's transcript.
340
- let _pendingRebind = false;
331
+ // SESSION-SCOPED remote (user decision): the session that toggled remote
332
+ // OWNS the channel relay. Other sessions' turns never rebind or relay; a
333
+ // dead/replaced owner (clear, delete) hands the seat to the next current
334
+ // session that asks.
335
+ let remoteSessionId = null;
336
+ // Desktop/TUI Remote toggles are desired-state transitions. Serialize the
337
+ // daemon deactivate/detach and reconnect/activate chains so an OFF that is
338
+ // still stopping can never tear down the worker created by the following ON.
339
+ const remoteTransitions = createRemoteTransitionQueue();
341
340
  // Last assistant text handed to the transcript writer (via onAssistantText),
342
341
  // so the post-turn final-content append can skip an exact duplicate.
343
342
  let _lastAppendedAssistant = '';
@@ -365,6 +364,9 @@ export async function createMixdogSessionRuntime({
365
364
  clearTimeout(timeoutId);
366
365
  keychainPrewarmWaitDone = true;
367
366
  }
367
+ // Invoked here: the assignment used to store the async FUNCTION, so every
368
+ // `await awaitKeychainPrewarm()` resolved instantly (awaiting a function is
369
+ // a no-op) and callers silently skipped the wait they asked for.
368
370
  })();
369
371
  return keychainPrewarmWaitPromise;
370
372
  }
@@ -540,50 +542,26 @@ export async function createMixdogSessionRuntime({
540
542
  ...extra,
541
543
  };
542
544
  }
543
- const sessionPrewarmDelayMs = envDelayMs('MIXDOG_SESSION_PREWARM_DELAY_MS', 50, { min: 0, max: 10_000 });
544
- const providerSetupWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_SETUP_WARMUP_DELAY_MS', 300, { min: 0, max: 60_000 });
545
- const modelCatalogWarmupDelayMs = envDelayMs('MIXDOG_MODEL_CATALOG_WARMUP_DELAY_MS', 200, { min: 0, max: 60_000 });
546
- const providerWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_WARMUP_DELAY_MS', 1_500, { min: 0, max: 60_000 });
547
- // Background model-catalog prefetch delay. Kept short so the first `/model`
548
- // open finds a warm cache instead of paying a cold full network load. The
549
- // work is async + unref'd, so short-lived detached runtimes still exit
550
- // cleanly without waiting on it. Operators can raise it via env if a
551
- // detached runtime must avoid the /models round-trip entirely.
552
- const providerModelWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS', 2_000, { min: 0, max: 120_000 });
553
- const codeGraphPrewarmDelayMs = envDelayMs('MIXDOG_CODE_GRAPH_PREWARM_DELAY_MS', 250, { min: 0, max: 60_000 });
554
- const statuslineUsageWarmupDelayMs = envDelayMs('MIXDOG_STATUSLINE_USAGE_WARMUP_DELAY_MS', 800, { min: 0, max: 60_000 });
555
- // Idle keep-alive: re-fetch usage before the statusline's 10-min staleness cut
556
- // (LIVE_USAGE_SNAPSHOT_MAX_AGE_MS) so the usage segment does not disappear
557
- // while the session sits idle with no turns to trigger a refresh.
558
- const statuslineUsageRefreshDelayMs = envDelayMs('MIXDOG_STATUSLINE_USAGE_REFRESH_MS', 240_000, { min: 30_000, max: 540_000 });
559
- const channelStartDelayMs = envDelayMs('MIXDOG_CHANNEL_START_DELAY_MS', 10_000, { min: 0, max: 120_000 });
560
- const backgroundBusyRetryMs = envDelayMs('MIXDOG_BACKGROUND_BUSY_RETRY_MS', 1_000, { min: 50, max: 10_000 });
561
- const sessionPrewarmEnabled = !envFlag('MIXDOG_DISABLE_SESSION_PREWARM')
562
- && (envFlag('MIXDOG_ENABLE_SESSION_PREWARM') || envPresent('MIXDOG_SESSION_PREWARM_DELAY_MS'));
563
- const providerWarmupEnabled = !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
564
- && (
565
- envFlag('MIXDOG_ENABLE_PROVIDER_WARMUP')
566
- || envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')
567
- || envPresent('MIXDOG_PROVIDER_WARMUP_DELAY_MS')
568
- || envPresent('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS')
569
- );
570
- // Boot-time model-catalog prefetch is intentionally decoupled from the
571
- // heavier providerWarmupEnabled gate (which stays opt-in for provider
572
- // *init* side effects). Fetching the model list in the background after a
573
- // short delay is cheap, fire-and-forget, and unref'd, so it is ON by
574
- // default — otherwise the FIRST `/model` open always paid a cold full
575
- // network load. Operators can still disable it explicitly.
576
- const modelPrefetchEnabled = !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
577
- && !envFlag('MIXDOG_DISABLE_MODEL_PREFETCH');
578
- const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM');
579
- const modelCatalogWarmupEnabled = !envFlag('MIXDOG_DISABLE_MODEL_CATALOG_WARMUP');
580
- // Lazy code-graph prewarm (default ON): do NOT prewarm at startup / on cwd
581
- // change — that fired ~250ms after the first frame and, in a large tree,
582
- // burned a worker (and felt like a freeze) before the user did anything.
583
- // Instead prewarm ONCE on the first real turn, when a code lookup is actually
584
- // imminent. Operators who want the old eager behavior can set
585
- // MIXDOG_CODE_GRAPH_PREWARM_EAGER=1.
586
- const codeGraphPrewarmLazy = codeGraphPrewarmEnabled && !envFlag('MIXDOG_CODE_GRAPH_PREWARM_EAGER');
545
+ // Env-tunable boot delays and feature gates: runtime-tunables.mjs.
546
+ const {
547
+ sessionPrewarmDelayMs,
548
+ providerSetupWarmupDelayMs,
549
+ modelCatalogWarmupDelayMs,
550
+ providerWarmupDelayMs,
551
+ providerModelWarmupDelayMs,
552
+ codeGraphPrewarmDelayMs,
553
+ statuslineUsageWarmupDelayMs,
554
+ statuslineUsageRefreshDelayMs,
555
+ channelStartDelayMs,
556
+ backgroundBusyRetryMs,
557
+ remoteAutoStartDelayMs,
558
+ sessionPrewarmEnabled,
559
+ providerWarmupEnabled,
560
+ modelPrefetchEnabled,
561
+ codeGraphPrewarmEnabled,
562
+ modelCatalogWarmupEnabled,
563
+ codeGraphPrewarmLazy,
564
+ } = readRuntimeTunables();
587
565
  let codeGraphFirstTurnPrewarmDone = false;
588
566
  const modelMetaByRoute = new Map();
589
567
  const notificationListeners = new Set();
@@ -635,251 +613,40 @@ export async function createMixdogSessionRuntime({
635
613
  setDesktopSession: (v) => { desktopSession = v; },
636
614
  state: mcpState,
637
615
  });
638
- let preSessionToolSurface = null;
639
616
  const hooksStartedAt = performance.now();
640
617
  const hooks = createStandaloneHookBus({ dataDir: cfgMod.getPluginData() });
641
618
  hooks.emit('runtime:start', { cwd: currentCwd, provider: route.provider, model: route.model, toolMode: mode });
642
619
  bootProfile('hooks:ready', { ms: (performance.now() - hooksStartedAt).toFixed(1) });
643
620
 
644
- // ---------------------------------------------------------------------
645
- // Self-update (npm registry version check + optional auto-install).
646
- // updateCheckState mirrors the last-known checkLatestVersion() result;
647
- // updateProcessState tracks the in-flight install lifecycle so the TUI can
648
- // poll getUpdateStatus() instead of needing a push/event channel. Both are
649
- // purely in-memory (per runtime instance) the on-disk cache lives inside
650
- // update-checker.mjs and is what actually enforces the 24h TTL.
651
- let updateCheckState = {
652
- currentVersion: null,
653
- latestVersion: null,
654
- updateAvailable: false,
655
- lastCheckedAt: 0,
656
- };
657
- // phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
658
- let updateProcessState = { phase: 'idle', version: null, error: null };
659
- // Boot detects an available update and STAGES it in the background (a hidden,
660
- // detached npm install into ~/.mixdog/data/staging/<ver>). The staged package
661
- // is swapped into the global dir on the next clean launch (cli.mjs
662
- // pre-import), never while a session is live — so npm never overwrites the
663
- // .mjs files node currently holds (the old shutdown `npm install -g` did, and
664
- // caused Windows TAR_ENTRY_ERROR / ENOENT). The live-session refcount that a
665
- // concurrent launch consults to defer its swap is registered earlier, in
666
- // cli.mjs (pre-import), so this process is visible before any runtime loads.
667
-
668
- function autoUpdateEnabled() {
669
- return config?.update?.auto !== false;
670
- }
671
-
672
- async function checkForUpdateInternal({ force = false } = {}) {
673
- if (updateProcessState.phase !== 'installing') updateProcessState.phase = 'checking';
674
- try {
675
- const result = await checkLatestVersion({ force, dataDir: cfgMod.getPluginData?.() || STANDALONE_DATA_DIR });
676
- updateCheckState = {
677
- currentVersion: result.currentVersion,
678
- latestVersion: result.latestVersion,
679
- updateAvailable: result.updateAvailable,
680
- lastCheckedAt: result.lastCheckedAt,
681
- };
682
- } catch {
683
- // checkLatestVersion() is already silent-safe; this catch is belt-and-
684
- // braces so a boot-time call can never crash the runtime.
685
- } finally {
686
- if (updateProcessState.phase === 'checking') updateProcessState.phase = 'idle';
687
- }
688
- return updateCheckState;
689
- }
690
-
691
- async function runUpdateNowInternal() {
692
- if (updateProcessState.phase === 'installing') {
693
- return { ...updateProcessState, alreadyInstalling: true, error: 'update already in progress' };
694
- }
695
- if (isDevInstall()) {
696
- updateProcessState = { phase: 'failed', version: null, error: 'dev install — update skipped' };
697
- return updateProcessState;
698
- }
699
- const ver = updateCheckState.latestVersion;
700
- if (!ver || !updateCheckState.updateAvailable) {
701
- updateProcessState = { phase: 'idle', version: null, error: null };
702
- return { ...updateProcessState, error: 'no update available' };
703
- }
704
- // "Update now" stages the new version (verified, self-contained) rather than
705
- // installing over the live global dir; the swap applies on the next launch.
706
- // phase 'installed' here means "staged & ready — restart to apply".
707
- updateProcessState = { phase: 'installing', version: ver, error: null };
708
- try {
709
- const result = await runStagedInstall(ver);
710
- if (result?.ok) {
711
- updateProcessState = { phase: 'installed', version: result.version || ver, error: null };
712
- } else {
713
- updateProcessState = { phase: 'failed', version: null, error: result?.error || 'update failed' };
714
- }
715
- } catch (err) {
716
- updateProcessState = { phase: 'failed', version: null, error: err?.message || String(err) };
717
- }
718
- return updateProcessState;
719
- }
720
-
721
- // Non-blocking boot hook: fires after the runtime object below is fully
722
- // constructed (setTimeout(0) defers past the synchronous return), so a
723
- // slow/hanging registry request can never delay session boot. The check
724
- // ALWAYS runs (populates updateCheckState for the maintenance picker), but
725
- // when an update is available it kicks off a hidden BACKGROUND staging
726
- // install (spawnStagedInstall) into ~/.mixdog/data/staging/<ver>. The actual
727
- // swap into the global dir happens on the next clean launch (cli.mjs
728
- // pre-import), so npm never overwrites files this live process holds.
729
- // force:true — always hit the registry at boot (the 24h disk cache went
730
- // stale-visible: it kept reporting an older "latest" than the installed
731
- // version). checkLatestVersion() still falls back to the cache offline.
732
- // isDevInstall() gate: a git checkout / clone (or non-node_modules install)
733
- // must never self-update — staging + swap would fight the working tree.
734
- const updateBootTimer = setTimeout(() => {
735
- void (async () => {
736
- await checkForUpdateInternal({ force: true });
737
- if (!(autoUpdateEnabled() && !isDevInstall() && updateCheckState.updateAvailable)) return;
738
- const ver = updateCheckState.latestVersion;
739
- if (!ver) return;
740
- // The notice fires ONLY once staging has completed (a ready-to-apply
741
- // package sits on disk) — never upfront — so the user sees no "update
742
- // available / installs on quit" nag while the background stage runs
743
- // silently. The wording lives in the notice surface (notification-plan):
744
- // this emit only carries meta.version. TUI maps meta.kind 'update-notice'
745
- // to a transient notice, never a model-visible message; tone 'info' =
746
- // non-urgent, applies on the next launch.
747
- const announceReady = () => {
748
- emitRuntimeNotification('update ready', { kind: 'update-notice', version: ver, tone: 'info' });
749
- };
750
- // Already staged in a prior session → announce immediately.
751
- if (isStagedComplete(ver)) { announceReady(); return; }
752
- try { spawnStagedInstall(ver); } catch { /* best-effort background stage */ }
753
- // Poll for staging completion, then announce once. The interval is
754
- // unref'd so it never holds the process open, and gives up silently
755
- // after the cap — the next launch retries.
756
- const POLL_MS = 3_000;
757
- const MAX_MS = 10 * 60 * 1000;
758
- const startedAt = Date.now();
759
- const poll = setInterval(() => {
760
- if (isStagedComplete(ver)) {
761
- clearInterval(poll);
762
- announceReady();
763
- } else if (Date.now() - startedAt > MAX_MS) {
764
- clearInterval(poll);
765
- }
766
- }, POLL_MS);
767
- poll.unref?.();
768
- })().catch(() => {});
769
- }, 0);
770
- updateBootTimer.unref?.();
771
-
772
- function emitRuntimeNotification(content, meta = {}) {
773
- const text = String(content || '').trim();
774
- if (!text) return { handled: false, modelVisibleDelivered: false };
775
- const event = { content: text, meta: meta && typeof meta === 'object' ? meta : {} };
776
- let handled = false;
777
- for (const listener of [...notificationListeners]) {
778
- try {
779
- if (listener(event) === true) handled = true;
780
- } catch {}
781
- }
782
- // EXPLICIT model-visible-delivery ack: only the TUI execution-ui path sets
783
- // event.modelVisibleDelivered when it enqueues the model-visible completion
784
- // body into the active loop. A generic display-only / API listener that
785
- // returns true does NOT set it, so `handled` alone must never be read as
786
- // "the model saw the body".
787
- const modelVisibleDelivered = event.modelVisibleDelivered === true;
788
- return { handled, modelVisibleDelivered };
789
- }
621
+ // Self-update: registry check + background staging live in self-update.mjs;
622
+ // the facade only wires config/data-dir/notification access into it. The
623
+ // boot check is deferred past this constructor so a hanging registry request
624
+ // can never delay session boot.
625
+ const selfUpdate = createSelfUpdateController({
626
+ getConfig: () => config,
627
+ getDataDir: () => cfgMod.getPluginData?.() || STANDALONE_DATA_DIR,
628
+ emitNotification: (...a) => emitRuntimeNotification(...a),
629
+ });
630
+ const autoUpdateEnabled = () => selfUpdate.autoUpdateEnabled();
631
+ const checkForUpdateInternal = (...a) => selfUpdate.checkForUpdate(...a);
632
+ const runUpdateNowInternal = (...a) => selfUpdate.runUpdateNow();
633
+ selfUpdate.startBootCheck();
790
634
 
791
- function notifyFnForSession(callerSessionId) {
792
- return (text, meta = {}) => {
793
- const { handled: handledByRuntimeListener, modelVisibleDelivered } = emitRuntimeNotification(text, meta);
794
- let enqueued = false;
795
- // Model-visible delivery ack (TUI execution-ui only): register this
796
- // completion in the process-local delivered registry so a racing enqueue
797
- // on another path (background reconcile/fallback/notify) within THIS
798
- // process is skipped instead of double-injecting the same body next turn.
799
- if (modelVisibleDelivered) {
800
- try {
801
- const deliveredVisible = modelVisibleToolCompletionMessage(text, meta);
802
- recordDeliveredCompletion({ executionId: meta?.execution_id, text: deliveredVisible });
803
- } catch {}
804
- }
805
- // TUI sessions consume raw execution notifications for UI/task cards via
806
- // onNotification, but those raw envelopes are internal-only in pending
807
- // drain. The TUI execution-ui path injects the model-visible twin of a
808
- // terminal completion into the active loop and acks with
809
- // modelVisibleDelivered, so mirroring it into the pending queue would
810
- // double-inject the same completion — skip only on that EXPLICIT ack. A
811
- // generic display-only / API listener returning true is NOT an ack: the
812
- // mirror stays as the sole model-visible delivery.
813
- if (shouldMirrorCompletionToPendingQueue({
814
- callerSessionId,
815
- modelVisibleDelivered,
816
- hasEnqueue: typeof mgr.enqueuePendingMessage === 'function',
817
- text,
818
- meta,
819
- })) {
820
- try {
821
- const visible = modelVisibleToolCompletionMessage(text, meta);
822
- // Terminal completion (gated by shouldPersistModelVisibleToolCompletion)
823
- // → tag so drain discards it on resume rather than replaying out-of-order.
824
- if (visible) {
825
- if (isDeliveredCompletion({ executionId: meta?.execution_id, text: visible })) {
826
- // Already delivered+ACKed on the TUI path → suppress the mirror but
827
- // report DELIVERED so the caller marks it notified and never retries.
828
- logDuplicateSkip('mirror', { executionId: meta?.execution_id, text: visible });
829
- enqueued = true;
830
- } else {
831
- enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
832
- }
833
- }
834
- } catch {}
835
- }
836
- // Headless/API listeners may exist but not consume the event; preserve
837
- // the old fallback for non-terminal notifications only when unhandled.
838
- if (!enqueued && !handledByRuntimeListener && callerSessionId
839
- && typeof mgr.enqueuePendingMessage === 'function') {
840
- try {
841
- const visible = modelVisibleToolCompletionMessage(text, meta);
842
- // modelVisibleToolCompletionMessage only returns non-empty for a
843
- // persistable terminal completion, so this fallback is a completion
844
- // too → tag it (genuine non-completion notifications yield '' above).
845
- if (visible) {
846
- if (isDeliveredCompletion({ executionId: meta?.execution_id, text: visible })) {
847
- // Already delivered+ACKed on the TUI path → suppress the fallback
848
- // enqueue but report DELIVERED so the caller stops retrying.
849
- logDuplicateSkip('fallback', { executionId: meta?.execution_id, text: visible });
850
- enqueued = true;
851
- } else {
852
- enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
853
- }
854
- }
855
- } catch {}
856
- }
857
- return enqueued || handledByRuntimeListener;
858
- };
859
- }
635
+ // Notification fan-out (listener broadcast + pending-queue mirroring of
636
+ // terminal completions) lives in notification-bus.mjs.
637
+ const { emitRuntimeNotification, notifyFnForSession } = createNotificationBus({
638
+ listeners: notificationListeners,
639
+ mgr,
640
+ });
860
641
 
861
- function skillsStatus() {
862
- const skills = typeof contextMod.collectSkillsCached === 'function'
863
- ? contextMod.collectSkillsCached(currentCwd)
864
- : [];
865
- const norm = (value) => String(value || '').replace(/\\/g, '/').toLowerCase();
866
- const cwdNorm = norm(currentCwd);
867
- const sourceForSkill = (filePath) => {
868
- const path = norm(filePath);
869
- if (cwdNorm && path.startsWith(`${cwdNorm}/.mixdog/skills/`)) return 'project';
870
- return 'skill';
871
- };
872
- return {
873
- cwd: currentCwd,
874
- count: skills.length,
875
- skills: skills.map((skill) => ({
876
- name: skill.name,
877
- description: skill.description || '',
878
- filePath: skill.filePath || null,
879
- source: sourceForSkill(skill.filePath),
880
- })),
881
- };
882
- }
642
+ // Skill listing/loading/creation lives in skills-api.mjs; the facade only
643
+ // supplies the mutable cwd and the context module.
644
+ const {
645
+ skillsStatus,
646
+ skillContent,
647
+ skillToolContent,
648
+ addProjectSkill,
649
+ } = createSkillsApi({ contextMod, getCwd: () => currentCwd });
883
650
 
884
651
  // cwd resolution/apply + plugins-status + core-memory context. Extracted to
885
652
  // session-runtime/cwd-plugins.mjs; the facade keeps ownership of the mutable
@@ -927,52 +694,6 @@ export async function createMixdogSessionRuntime({
927
694
  STANDALONE_DATA_DIR,
928
695
  });
929
696
 
930
- function skillContent(name) {
931
- const res = typeof contextMod.loadSkillResource === 'function'
932
- ? contextMod.loadSkillResource(name, currentCwd)
933
- : null;
934
- if (!res) throw new Error(`skill not found: ${name}`);
935
- return { name, content: res.content, dir: res.dir };
936
- }
937
-
938
- function skillToolContent(name) {
939
- if (typeof contextMod.isSkillDisabled === 'function' && contextMod.isSkillDisabled(name)) {
940
- const label = String(name || '').trim() || 'skill';
941
- return `Error: skill "${label}" is disabled`;
942
- }
943
- const skill = skillContent(name);
944
- // Return the general tool envelope so the main/Lead session behaves the
945
- // same as agent-loop sessions: the model-visible tool_result is the short
946
- // stub (`Loaded skill: <name>`) and the full SKILL.md body is delivered
947
- // ONCE as a separate injected role:'user' message (newMessages). The
948
- // envelope passes through internal-tools._normalize untouched (it preserves
949
- // __toolEnvelope objects), and the agent loop's central normalizeToolEnvelope
950
- // splits it into stub + injected user body.
951
- return contextMod.buildSkillToolEnvelope(skill.name, skill.content, skill.dir);
952
- }
953
-
954
- function addProjectSkill(input = {}) {
955
- const name = clean(input.name).replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
956
- if (!name) throw new Error('skill name is required');
957
- const dir = join(currentCwd, '.mixdog', 'skills', name);
958
- const filePath = join(dir, 'SKILL.md');
959
- if (existsSync(filePath)) throw new Error(`skill already exists: ${name}`);
960
- const description = clean(input.description) || 'Project skill.';
961
- mkdirSync(dir, { recursive: true });
962
- writeFileSync(filePath, [
963
- '---',
964
- `name: ${name}`,
965
- `description: ${description}`,
966
- '---',
967
- '',
968
- '# Instructions',
969
- '',
970
- 'Describe when and how to use this skill.',
971
- '',
972
- ].join('\n'), 'utf8');
973
- return { name, filePath };
974
- }
975
-
976
697
  const agentToolStartedAt = performance.now();
977
698
  const agentTool = createStandaloneAgent({
978
699
  cfgMod,
@@ -1032,6 +753,7 @@ export async function createMixdogSessionRuntime({
1032
753
  // would re-fork/activate). Idempotent: no-op when already enabled.
1033
754
  if (msg?.params?.state === 'acquired' && !remoteEnabled) {
1034
755
  remoteEnabled = true;
756
+ if (!remoteSessionId) remoteSessionId = session?.id || null;
1035
757
  ensureRemoteTranscriptWriter();
1036
758
  // Auto-acquire: the worker restored yesterday's transcript from
1037
759
  // persisted status and we just created the CURRENT writer. Push the
@@ -1076,25 +798,26 @@ export async function createMixdogSessionRuntime({
1076
798
  // schema-visible tool that policy always rejects is a guaranteed error turn
1077
799
  // (user-reported in Solo). Names derive from the live agent tool defs.
1078
800
  const agentToolNames = new Set(agentTool.tools.map((tool) => String(tool?.name || '')).filter(Boolean));
1079
- function workflowAllowsAgents() {
1080
- try {
1081
- const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
1082
- const pack = loadWorkflowPack(dataDir, activeWorkflowId(config));
1083
- if (!pack || pack.agentsConfigured !== true) return true;
1084
- return Array.isArray(pack.agents) && pack.agents.length > 0;
1085
- } catch {
1086
- return true;
1087
- }
1088
- }
1089
- function modelStandaloneTools() {
1090
- return workflowAllowsAgents()
1091
- ? standaloneTools
1092
- : standaloneTools.filter((tool) => !agentToolNames.has(String(tool?.name || '')));
1093
- }
1094
-
1095
- function invalidatePreSessionToolSurface() {
1096
- preSessionToolSurface = null;
1097
- }
801
+ // Lead tool surface (workflow-gated agent tool, pre-session preview, deferred
802
+ // replay) lives in tool-surface.mjs.
803
+ const {
804
+ modelStandaloneTools,
805
+ invalidatePreSessionToolSurface,
806
+ activeToolSurface,
807
+ applyPreSessionToolSelection,
808
+ } = createToolSurface({
809
+ mgr,
810
+ mode,
811
+ standaloneTools,
812
+ agentToolNames,
813
+ getSession: () => session,
814
+ getRoute: () => route,
815
+ getConfig: () => config,
816
+ cfgMod,
817
+ loadWorkflowPack,
818
+ activeWorkflowId,
819
+ dataDir: STANDALONE_DATA_DIR,
820
+ });
1098
821
 
1099
822
  const { contextStatus: computeContextStatus, invalidateContextStatusCache } = createContextStatus({
1100
823
  getSession: () => session,
@@ -1102,34 +825,6 @@ export async function createMixdogSessionRuntime({
1102
825
  getCurrentCwd: () => currentCwd,
1103
826
  getMode: () => mode,
1104
827
  });
1105
-
1106
- function buildPreSessionToolSurface() {
1107
- const previewTools = typeof mgr.previewSessionTools === 'function'
1108
- ? mgr.previewSessionTools(toolSpecForMode(mode), [])
1109
- : [];
1110
- const tools = filterDisallowedTools(previewTools, LEAD_DISALLOWED_TOOLS);
1111
- const surface = { tools: Array.isArray(tools) ? tools.slice() : [] };
1112
- applyDeferredToolSurface(surface, deferredSurfaceModeForLead(mode), modelStandaloneTools(), { provider: route.provider });
1113
- return surface;
1114
- }
1115
-
1116
- function activeToolSurface() {
1117
- if (session) return session;
1118
- preSessionToolSurface ??= buildPreSessionToolSurface();
1119
- return preSessionToolSurface;
1120
- }
1121
-
1122
- function applyPreSessionToolSelection() {
1123
- if (!session || !preSessionToolSurface) return;
1124
- const selected = Array.isArray(preSessionToolSurface.deferredSelectedTools)
1125
- ? preSessionToolSurface.deferredSelectedTools
1126
- : [];
1127
- const discovered = Array.isArray(preSessionToolSurface.deferredDiscoveredTools)
1128
- ? preSessionToolSurface.deferredDiscoveredTools
1129
- : [];
1130
- const replay = [...new Set([...selected, ...discovered])];
1131
- if (replay.length) selectDeferredTools(session, replay, deferredSurfaceModeForLead(mode));
1132
- }
1133
828
  internalTools.setInternalToolsProvider({
1134
829
  tools: [...standaloneTools, ...searchRuntimeTools.filter((tool) => tool?.public === false)],
1135
830
  executor: async (name, args, callerCtx = {}) => {
@@ -1365,6 +1060,7 @@ export async function createMixdogSessionRuntime({
1365
1060
  const {
1366
1061
  refreshStatuslineUsageSnapshot,
1367
1062
  cachedProviderSetup,
1063
+ hasProviderSetupCached,
1368
1064
  getUsageDashboard,
1369
1065
  } = createProviderUsage({
1370
1066
  caches: providerUsageCaches,
@@ -1711,260 +1407,59 @@ export async function createMixdogSessionRuntime({
1711
1407
  state: prewarmState,
1712
1408
  });
1713
1409
 
1714
- // Eagerly create/refresh the remote transcript writer for the CURRENT
1715
- // session + cwd, publish the session record, and ensure the transcript
1716
- // file exists on disk. Called from startRemote() (so the channel worker's
1717
- // activate-time discovery finds THIS session immediately instead of
1718
- // waiting for the first turn) and from ask() at turn start. Returns true
1719
- // when a writer is bound.
1720
- function ensureRemoteTranscriptWriter() {
1721
- if (!remoteEnabled || !session?.id) return false;
1722
- const twKey = `${session.id}\u0000${currentCwd}`;
1723
- if (_twKey !== twKey) {
1724
- try {
1725
- _transcriptWriter = createTranscriptWriter({
1726
- mixdogHome: mixdogHome(),
1727
- sessionId: session.id,
1728
- cwd: currentCwd,
1729
- pid: process.pid,
1730
- });
1731
- _transcriptWriter.writeSessionRecord();
1732
- _twKey = twKey;
1733
- } catch (error) {
1734
- process.stderr.write(`mixdog: transcript-writer: init failed: ${error?.message || error}\n`);
1735
- _transcriptWriter = null;
1736
- _twKey = '';
1737
- return false;
1738
- }
1739
- } else {
1740
- // Same binding — refresh updatedAt so worker-side discovery keeps
1741
- // ranking this session as the live parent-chain candidate.
1742
- try { _transcriptWriter?.writeSessionRecord(); } catch {}
1743
- }
1744
- try { _transcriptWriter?.ensureTranscriptFile(); }
1745
- catch (error) { process.stderr.write(`mixdog: transcript-writer: ensureTranscriptFile failed: ${error?.message || error}\n`); }
1746
- return _transcriptWriter != null;
1747
- }
1748
-
1749
- // Push the CURRENT transcript path to the channel worker so outbound
1750
- // forwarding repoints immediately at the moments the binding can go stale
1751
- // (auto-acquire, newSession/resume, clear) instead of waiting for the next
1752
- // inbound parent-chain steal. Best-effort: ensures the writer, then fires
1753
- // the dedicated idempotent worker op — a missing/not-ready worker or a bind
1754
- // failure must never throw into the lead paths that call this.
1755
- function pushTranscriptRebind() {
1756
- if (!remoteEnabled) return;
1757
- // Writer not bindable yet (e.g. 'acquired' before the session exists in
1758
- // lazy mode): defer instead of silently dropping the push. flushPending-
1759
- // TranscriptRebind() re-fires this exactly once when the writer is ready.
1760
- if (!ensureRemoteTranscriptWriter()) { _pendingRebind = true; return; }
1761
- const transcriptPath = _transcriptWriter?.transcriptPath;
1762
- if (!transcriptPath || !channelsEnabled()) { _pendingRebind = true; return; }
1763
- _pendingRebind = false;
1764
- executeTranscriptRebind(transcriptPath, 1);
1765
- }
1766
-
1767
- // Fire the idempotent worker op with bounded retry. A rejected/throwing
1768
- // channels.execute retries a few times with short backoff; the final failure
1769
- // surfaces one stderr line (not only the env-gated bootProfile) so a lost
1770
- // rebind is diagnosable by default. Best-effort throughout — never throws
1771
- // into the lead paths that call pushTranscriptRebind().
1772
- function executeTranscriptRebind(transcriptPath, attempt) {
1773
- const maxAttempts = 3;
1774
- const onError = (error) => {
1775
- const detail = error?.message || String(error);
1776
- bootProfile('channels:rebind-push-failed', { attempt, error: detail });
1777
- if (attempt < maxAttempts && remoteEnabled && !closeRequested) {
1778
- const timer = setTimeout(() => {
1779
- // Abort the retry chain silently if remote was dropped or the writer
1780
- // moved on (supersede→re-acquire, newSession/clear): re-firing the
1781
- // captured path would rebind forwarding back to a stale transcript.
1782
- if (!remoteEnabled || !channelsEnabled()) return;
1783
- if (_transcriptWriter?.transcriptPath !== transcriptPath) return;
1784
- executeTranscriptRebind(transcriptPath, attempt + 1);
1785
- }, 150 * attempt);
1786
- timer.unref?.();
1787
- } else {
1788
- process.stderr.write(`mixdog: channels: rebind_current_transcript failed after ${attempt} attempt(s): ${detail}\n`);
1789
- }
1790
- };
1791
- try {
1792
- void channels.execute('rebind_current_transcript', { transcriptPath }).catch(onError);
1793
- } catch (error) {
1794
- onError(error);
1795
- }
1796
- }
1797
-
1798
- // Re-fire a deferred rebind exactly once, after a session/writer becomes
1799
- // available (session create or turn start). No-op unless a push was deferred,
1800
- // so no unconditional rebind fires per turn for already-bound sessions.
1801
- function flushPendingTranscriptRebind() {
1802
- if (!_pendingRebind || !remoteEnabled) return;
1803
- pushTranscriptRebind();
1804
- }
1410
+ // Remote transcript binding + worker rebind pushes live in
1411
+ // remote-transcript.mjs; the facade injects the mutable session/cwd/remote
1412
+ // state it needs.
1413
+ const remoteTranscript = createRemoteTranscript({
1414
+ getSession: () => session,
1415
+ getCwd: () => currentCwd,
1416
+ isRemoteEnabled: () => remoteEnabled,
1417
+ getRemoteSessionId: () => remoteSessionId,
1418
+ setRemoteSessionId: (next) => { remoteSessionId = next; },
1419
+ isCloseRequested: () => closeRequested,
1420
+ channelsEnabled,
1421
+ channels,
1422
+ mgr,
1423
+ bootProfile,
1424
+ });
1425
+ const ensureRemoteTranscriptWriter = () => remoteTranscript.ensureRemoteTranscriptWriter();
1426
+ const pushTranscriptRebind = () => remoteTranscript.pushTranscriptRebind();
1427
+ const flushPendingTranscriptRebind = () => remoteTranscript.flushPendingTranscriptRebind();
1805
1428
 
1806
- // Remote (Discord channel) mode is opt-in per session. Only a session that
1807
- // explicitly enables remote via `mixdog --remote` or the runtime toggle —
1808
- // boots the channel worker and contends for channel ownership.
1809
- // startRemote() is FORCE-TAKEOVER: it always (re)claims the bridge seat and
1810
- // rebinds output forwarding to this session, even when the worker is
1811
- // already running (e.g. `/remote` re-issued after another session took the
1812
- // seat, or to re-pin forwarding onto the current transcript).
1813
- function startRemote(options = {}) {
1814
- const intent = options?.intent === 'auto' ? 'auto' : 'explicit';
1815
- // Claim intent reaches the worker's boot claim via MIXDOG_REMOTE_INTENT
1816
- // (last-wins for explicit, claim-if-vacant for auto). It is set transiently
1817
- // around the worker fork below (see invokeChannelStart) and restored right
1818
- // after — NOT here on the shared process.env — so unrelated children forked
1819
- // during the boot window never inherit a stale intent.
1820
- if (intent === 'auto') {
1821
- // Auto-start (config/delayed): do NOT flip this session to remote up
1822
- // front. Boot the worker to ATTEMPT a claim-if-vacant; remoteEnabled is
1823
- // set only when the worker reports it actually acquired the seat (the
1824
- // 'acquired' notification). A live owner already holding the seat makes
1825
- // the worker back off silently and this session stays non-remote.
1826
- remoteClaimPending = true;
1827
- } else {
1828
- remoteEnabled = true;
1829
- }
1830
- // Boot the memory daemon eagerly. The channels worker forwards
1831
- // transcript ingests/entries to the memory HTTP service, whose port is
1832
- // published to active-instance.json by getMemoryModule().init(). Without
1833
- // this, memory only starts on the first turn's getMemoryModule() call —
1834
- // so early channel traffic finds no memory_port and gets buffered (or,
1835
- // pre-drainer, silently dropped). Runs BEFORE channel claim so the port is
1836
- // racing to be live by the time the worker sends its first ingest.
1837
- //
1838
- // Not fire-and-forget: init() only resolves the module handle, so a bare
1839
- // getMemoryModule() could return before /health reports ok — leaving early
1840
- // ingests to hit a not-yet-listening port. Await the proxy start() and then
1841
- // poll /health with a bounded retry so the daemon is provably reachable
1842
- // (or logged failed) rather than assumed-up. Still non-blocking to the
1843
- // caller: the whole probe is detached, but it internally awaits readiness.
1844
- void (async () => {
1845
- try {
1846
- // Yield one event-loop tick before the heavy chain below (module
1847
- // resolve, daemon fork/health-poll) starts, so Ink's next render
1848
- // (scheduled via setImmediate/timers) and any queued keypress
1849
- // events get a turn first instead of being starved by this
1850
- // detached chain's synchronous setup work.
1851
- await new Promise((r) => setImmediate(r));
1852
- const mod = await getMemoryModule();
1853
- const started = typeof mod?.start === 'function' ? await mod.start() : null;
1854
- const port = started?.port;
1855
- if (!port) { bootProfile('channels:memory-eager-init-failed', { error: 'no port from start()' }); return; }
1856
- for (let i = 0; i < 30; i++) {
1857
- try {
1858
- const ok = await new Promise((res) => {
1859
- const req = httpMod.request({ hostname: '127.0.0.1', port, path: '/health', timeout: 1500 }, (r) => {
1860
- let d = ''; r.on('data', (c) => { d += c; }); r.on('end', () => {
1861
- try { res(JSON.parse(d)?.status === 'ok'); } catch { res(false); }
1862
- });
1863
- });
1864
- req.on('error', () => res(false));
1865
- req.on('timeout', () => { req.destroy(); res(false); });
1866
- req.end();
1867
- });
1868
- if (ok) { bootProfile('channels:memory-eager-init-ready', { port }); return; }
1869
- } catch {}
1870
- await new Promise((r) => setTimeout(r, 500));
1871
- }
1872
- bootProfile('channels:memory-eager-init-failed', { error: `health not ok after retries (port ${port})` });
1873
- } catch (error) {
1874
- bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) });
1875
- }
1876
- })();
1877
- // Publish this session's record + transcript file BEFORE the worker's
1878
- // activate-time discovery polls, so output forwarding binds to this
1879
- // terminal session immediately instead of waiting for the first turn.
1880
- // No-op when the session has not been created yet (lazy mode); that
1881
- // case is covered by the turn-start rebind in ask().
1882
- ensureRemoteTranscriptWriter();
1883
- if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
1884
- bootProfile('channels:start-skipped');
1885
- return true;
1886
- }
1887
- if (closeRequested) return true;
1888
- if (prewarmTimers.channelStartTimer) {
1889
- clearTimeout(prewarmTimers.channelStartTimer);
1890
- prewarmTimers.channelStartTimer = null;
1891
- }
1892
- bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
1893
- void (async () => {
1894
- // Channels-module toggle gates MESSAGING only: automation (schedules/
1895
- // webhooks) still boots the worker — its backend runs headless when
1896
- // messaging is off or unconfigured (see channels/lib/config.mjs).
1897
- if (!channelsEnabled() && !(await hasActiveAutomation().catch(() => false))) {
1898
- bootProfile('channels:start-disabled');
1899
- remoteClaimPending = false;
1900
- return;
1901
- }
1902
- // A backend switch may still be pending or writing asynchronously. Drain
1903
- // it before the worker reads config; never race it with a sync lock wait.
1904
- try { await flushBackendSave(); } catch {}
1905
- // Yield before the createCurrentSession/transcript/fork chain below —
1906
- // same rationale as the memory-eager-init yield above: this detached
1907
- // chain runs synchronous config/fs work (createCurrentSession, backend
1908
- // flush, transcript writer) back-to-back, and without a tick break it
1909
- // can run ahead of Ink's queued render/input handling.
1910
- await new Promise((r) => setImmediate(r));
1911
- // Immediate-occupancy guarantee: make sure a session + transcript
1912
- // exist BEFORE the worker boots — a freshly-forked worker claims and
1913
- // runs transcript discovery inside its own start(), so publishing the
1914
- // session record/file first lets that very first discovery pass bind
1915
- // output forwarding to THIS terminal instead of a persisted/stale
1916
- // neighbour. Lazy mode means the session may not exist yet at /remote
1917
- // time; create it here (idempotent — reuses a live session, joins an
1918
- // in-flight create). On create failure we still claim: that matches
1919
- // the pre-eager behavior (bind resolves on the first turn's rebind).
1920
- try { await createCurrentSession('remote-start'); }
1921
- catch (error) { bootProfile('channels:remote-session-create-failed', { error: error?.message || String(error) }); }
1922
- ensureRemoteTranscriptWriter();
1923
- // Re-check after the awaits above: stopRemote()/superseded or runtime
1924
- // close may have landed mid-chain — do not boot/claim for a session
1925
- // that already turned remote off.
1926
- if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
1927
- // Set the fork-inherited intent immediately before the worker fork (the
1928
- // fork reads process.env synchronously inside invokeChannelStart) and
1929
- // restore the prior value the instant it resolves, so the pollution
1930
- // window is just this fork rather than the whole boot chain.
1931
- const _prevIntent = process.env.MIXDOG_REMOTE_INTENT;
1932
- try {
1933
- process.env.MIXDOG_REMOTE_INTENT = intent;
1934
- await invokeChannelStart();
1935
- } finally {
1936
- if (_prevIntent === undefined) delete process.env.MIXDOG_REMOTE_INTENT;
1937
- else process.env.MIXDOG_REMOTE_INTENT = _prevIntent;
1938
- }
1939
- if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
1940
- // Explicit start: unconditional claim + forwarder rebind (last-wins seat
1941
- // overwrite + transcript rebind onto this session). AUTO start SKIPS this
1942
- // — the freshly-forked worker already ran its claim-if-vacant boot claim,
1943
- // and forcing activate here would steal a live owner that autoStart is
1944
- // meant to yield to. The worker's acquire notification drives remote ON.
1945
- if (intent !== 'auto') {
1946
- await channels.execute('activate_channel_bridge', { active: true });
1429
+ // Remote (channel relay) claim/release/stop live in remote-control.mjs; the
1430
+ // facade injects the mutable session + remote state they mutate.
1431
+ const remoteControl = createRemoteControl({
1432
+ getSession: () => session,
1433
+ isRemoteEnabled: () => remoteEnabled,
1434
+ setRemoteEnabled: (next) => { remoteEnabled = next; },
1435
+ getRemoteSessionId: () => remoteSessionId,
1436
+ setRemoteSessionId: (next) => { remoteSessionId = next; },
1437
+ isRemoteClaimPending: () => remoteClaimPending,
1438
+ setRemoteClaimPending: (next) => { remoteClaimPending = next; },
1439
+ isCloseRequested: () => closeRequested,
1440
+ clearChannelStartTimer: () => {
1441
+ if (prewarmTimers.channelStartTimer) {
1442
+ clearTimeout(prewarmTimers.channelStartTimer);
1443
+ prewarmTimers.channelStartTimer = null;
1947
1444
  }
1948
- // Claim attempt dispatched; the worker's acquire/supersede notification
1949
- // now owns the remoteEnabled transition. Drop the transient marker.
1950
- remoteClaimPending = false;
1951
- })().catch((error) => { remoteClaimPending = false; bootProfile('channels:claim-failed', { error: error?.message || String(error) }); });
1952
- return true;
1953
- }
1954
-
1955
- function stopRemote(reason) {
1956
- remoteEnabled = false;
1957
- // A pending auto-claim is abandoned by an explicit stop/supersede.
1958
- remoteClaimPending = false;
1959
- // Cancel any pending deferred start so it can't fire after remote is off.
1960
- if (prewarmTimers.channelStartTimer) { clearTimeout(prewarmTimers.channelStartTimer); prewarmTimers.channelStartTimer = null; }
1961
- // Route /remote-off and supersede through a WAITING stop: the runtime keeps
1962
- // running, so we don't block on it (no await), but the waiting path runs the
1963
- // full SIGTERM -> taskkill /T /F escalation ladder to guarantee the worker
1964
- // dies rather than lingering as a zombie holding the bridge seat.
1965
- channels.stop(reason || 'remote-disabled').catch(() => {});
1966
- return true;
1967
- }
1445
+ },
1446
+ remoteTransitions,
1447
+ channels,
1448
+ channelsEnabled,
1449
+ hasActiveAutomation,
1450
+ flushBackendSave,
1451
+ invokeChannelStart,
1452
+ createCurrentSession,
1453
+ ensureRemoteTranscriptWriter,
1454
+ getTranscriptPath: () => remoteTranscript.transcriptWriter?.transcriptPath || null,
1455
+ emitRemoteStateChange,
1456
+ getMemoryModule,
1457
+ bootProfile,
1458
+ envFlag,
1459
+ });
1460
+ const startRemote = (options) => remoteControl.startRemote(options);
1461
+ const releaseRemote = (reason) => remoteControl.releaseRemote(reason);
1462
+ const stopRemote = (reason) => remoteControl.stopRemote(reason);
1968
1463
 
1969
1464
  function isRemoteEnabled() {
1970
1465
  return remoteEnabled;
@@ -2008,8 +1503,11 @@ export async function createMixdogSessionRuntime({
2008
1503
  // Channels are opt-in: only boot the worker when this session started in (or
2009
1504
  // was toggled into) remote mode. Non-remote sessions never contend for the
2010
1505
  // channel; see startRemote()/stopRemote() and the `/remote` toggle.
2011
- // `remote.autoStart` in mixdog-config.json makes every session claim remote
2012
- // at boot (same force-takeover semantics as `mixdog --remote` / `/remote`).
1506
+ // `remote.autoStart` in mixdog-config.json makes terminal sessions attempt a
1507
+ // remote claim at boot. Desktop sessions are explicit-only: opening the app
1508
+ // must never claim or spawn the channel bridge until its Remote button is
1509
+ // pressed, while the shared owner-state watcher still reflects a terminal
1510
+ // that already owns it.
2013
1511
  // The flag lives in the TOP-LEVEL `remote` section of mixdog-config.json
2014
1512
  // (sibling of agent/ui/channels), not inside the agent section that
2015
1513
  // cfgMod.loadConfig returns — read it via the shared whole-file reader.
@@ -2022,7 +1520,7 @@ export async function createMixdogSessionRuntime({
2022
1520
  // known to have won). Track it as a separate deferred auto request; the
2023
1521
  // worker's acquire notification flips remoteEnabled if/when it wins the seat.
2024
1522
  let remoteAutoStartRequested = false;
2025
- if (!remoteEnabled) {
1523
+ if (!remoteEnabled && !desktopSession) {
2026
1524
  try {
2027
1525
  if (config?.remote?.autoStart === true
2028
1526
  || sharedCfgMod?.readSection?.('remote')?.autoStart === true) {
@@ -2039,7 +1537,6 @@ export async function createMixdogSessionRuntime({
2039
1537
  // early /remote (startRemote clears it), stopRemote(), and close() all
2040
1538
  // cancel it through the existing clearTimeout paths. Runtime /remote calls
2041
1539
  // still start immediately (user-initiated, UI already painted).
2042
- const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
2043
1540
  if (remoteEnabled || remoteAutoStartRequested) {
2044
1541
  const remoteStartIntent = remoteEnabled ? 'explicit' : 'auto';
2045
1542
  bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs, intent: remoteStartIntent });
@@ -2054,9 +1551,9 @@ export async function createMixdogSessionRuntime({
2054
1551
  prewarmTimers.channelStartTimer.unref?.();
2055
1552
  } else {
2056
1553
  // Automation decoupling (user decision): enabled schedules/webhooks boot
2057
- // the worker on their own — no remote flag, no remote.autoStart, no
2058
- // messaging backend required. Claim-if-vacant auto intent reuses the
2059
- // exact autoStart semantics (a live owner elsewhere wins silently).
1554
+ // the worker on their own — no remote flag, no remote.autoStart, and no
1555
+ // messaging backend. A later explicit/auto Remote claim promotes the same
1556
+ // daemon without restarting schedules or webhooks.
2060
1557
  prewarmTimers.channelStartTimer = setTimeout(() => {
2061
1558
  prewarmTimers.channelStartTimer = null;
2062
1559
  if (closeRequested || remoteEnabled) return;
@@ -2064,7 +1561,7 @@ export async function createMixdogSessionRuntime({
2064
1561
  .then((active) => {
2065
1562
  if (!active || closeRequested || remoteEnabled) return;
2066
1563
  bootProfile('channels:automation-autostart');
2067
- startRemote({ intent: 'auto' });
1564
+ void invokeChannelStart();
2068
1565
  })
2069
1566
  .catch(() => { /* automation probe is best-effort */ });
2070
1567
  }, remoteAutoStartDelayMs);
@@ -2104,8 +1601,8 @@ export async function createMixdogSessionRuntime({
2104
1601
  recapEnabledFn,
2105
1602
  channelsEnabled,
2106
1603
  autoUpdateEnabled,
2107
- getUpdateCheckState: () => updateCheckState,
2108
- getUpdateProcessState: () => updateProcessState,
1604
+ getUpdateCheckState: () => selfUpdate.getCheckState(),
1605
+ getUpdateProcessState: () => selfUpdate.getProcessState(),
2109
1606
  invalidateContextStatusCache: (...a) => invalidateContextStatusCache(...a),
2110
1607
  invalidatePreSessionToolSurface: (...a) => invalidatePreSessionToolSurface(...a),
2111
1608
  scheduleChannelStart: (...a) => scheduleChannelStart(...a),
@@ -2127,7 +1624,14 @@ export async function createMixdogSessionRuntime({
2127
1624
  setBackend,
2128
1625
  });
2129
1626
 
2130
- const channelConfigApi = createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon });
1627
+ const channelConfigApi = createChannelConfigApi({
1628
+ flushBackendSave,
1629
+ channels,
1630
+ reloadChannelsSoon,
1631
+ // Automation saved mid-session boots the worker (claim-if-vacant) even
1632
+ // though the boot-time autostart window has already passed.
1633
+ ensureAutomationRuntime: () => scheduleChannelStart(0),
1634
+ });
2131
1635
  const providerAuthApi = createProviderAuthApi({
2132
1636
  cfgMod,
2133
1637
  getConfig: () => config,
@@ -2135,6 +1639,8 @@ export async function createMixdogSessionRuntime({
2135
1639
  displayConfig,
2136
1640
  reloadFullConfig,
2137
1641
  awaitKeychainPrewarm,
1642
+ isKeychainPrewarmReady: () => keychainPrewarmWaitDone,
1643
+ hasProviderSetupCached,
2138
1644
  invalidateProviderCaches,
2139
1645
  warmProviderModelCache,
2140
1646
  refreshProviderCatalogs: () => ensureProvidersReady(config.providers || {}).then(() => reg.refreshCatalogs()),
@@ -2258,6 +1764,7 @@ export async function createMixdogSessionRuntime({
2258
1764
  displayConfig,
2259
1765
  agentRouteFromConfig,
2260
1766
  loadAgentDefinition,
1767
+ listCustomAgentIds,
2261
1768
  activeWorkflowId,
2262
1769
  listWorkflowPacks,
2263
1770
  loadWorkflowPack,
@@ -2286,8 +1793,8 @@ export async function createMixdogSessionRuntime({
2286
1793
  getCloseRequested: () => closeRequested,
2287
1794
  getPendingSessionReset: () => pendingSessionReset,
2288
1795
  setPendingSessionReset: (v) => { pendingSessionReset = v; },
2289
- getTranscriptWriter: () => _transcriptWriter,
2290
- getTwKey: () => _twKey,
1796
+ getTranscriptWriter: () => remoteTranscript.transcriptWriter,
1797
+ getTwKey: () => remoteTranscript.transcriptKey,
2291
1798
  getLastAppendedAssistant: () => _lastAppendedAssistant,
2292
1799
  setLastAppendedAssistant: (v) => { _lastAppendedAssistant = v; },
2293
1800
  scheduleCodeGraphPrewarm,
@@ -2404,9 +1911,26 @@ export async function createMixdogSessionRuntime({
2404
1911
  stopRemote(reason) {
2405
1912
  return stopRemote(reason);
2406
1913
  },
1914
+ releaseRemote(reason) {
1915
+ return releaseRemote(reason);
1916
+ },
2407
1917
  isRemoteEnabled() {
2408
1918
  return isRemoteEnabled();
2409
1919
  },
1920
+ // Session-scoped remote: the owning session id (null when off/unassigned).
1921
+ getRemoteSessionId() {
1922
+ return remoteSessionId;
1923
+ },
1924
+ // Move the relay seat to the CURRENT session (last-wins within this
1925
+ // engine): rebind the transcript writer + forwarder without restarting
1926
+ // the worker. No-op when remote is off or no session exists.
1927
+ claimRemoteForCurrentSession() {
1928
+ if (!remoteEnabled || !session?.id) return false;
1929
+ remoteSessionId = session.id;
1930
+ ensureRemoteTranscriptWriter();
1931
+ pushTranscriptRebind();
1932
+ return true;
1933
+ },
2410
1934
  // Subscribe to non-user-initiated remote flips (seat superseded). Returns
2411
1935
  // an unsubscribe function. TUI uses this to sync its Remote indicator and
2412
1936
  // show a "remote taken over" notice.