mixdog 0.9.68 → 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 (48) 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.mjs +0 -11
  11. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  12. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  13. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  14. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  16. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  17. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  18. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  19. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  20. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  21. package/src/runtime/channels/lib/webhook.mjs +13 -1
  22. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  23. package/src/session-runtime/model-capabilities.mjs +6 -4
  24. package/src/session-runtime/notification-bus.mjs +82 -0
  25. package/src/session-runtime/provider-auth-api.mjs +16 -1
  26. package/src/session-runtime/provider-usage.mjs +3 -0
  27. package/src/session-runtime/remote-control.mjs +166 -0
  28. package/src/session-runtime/remote-transcript.mjs +126 -0
  29. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  30. package/src/session-runtime/runtime-core.mjs +143 -705
  31. package/src/session-runtime/runtime-tunables.mjs +57 -0
  32. package/src/session-runtime/self-update.mjs +129 -0
  33. package/src/session-runtime/skills-api.mjs +77 -0
  34. package/src/session-runtime/tool-surface.mjs +83 -0
  35. package/src/session-runtime/workflow-agents-api.mjs +206 -3
  36. package/src/session-runtime/workflow.mjs +84 -17
  37. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  38. package/src/standalone/agent-tool.mjs +22 -346
  39. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  40. package/src/standalone/channel-worker.mjs +7 -13
  41. package/src/tui/components/StatusLine.jsx +6 -5
  42. package/src/tui/dist/index.mjs +124 -94
  43. package/src/tui/engine/labels.mjs +0 -8
  44. package/src/tui/engine/session-api-ext.mjs +17 -8
  45. package/src/tui/engine/turn-watchdog.mjs +92 -0
  46. package/src/tui/engine/turn.mjs +18 -70
  47. package/src/workflows/default/WORKFLOW.md +1 -1
  48. 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({
@@ -334,15 +333,10 @@ export async function createMixdogSessionRuntime({
334
333
  // dead/replaced owner (clear, delete) hands the seat to the next current
335
334
  // session that asks.
336
335
  let remoteSessionId = null;
337
- // Remote-mode transcript writer (Discord outbound). Lazily created per
338
- // session.id + cwd inside ask(); only active while remoteEnabled.
339
- let _transcriptWriter = null;
340
- let _twKey = '';
341
- // One-shot: an 'acquired' verdict (or other rebind trigger) landed before a
342
- // session/writer existed, so the rebind push could not fire. Set true when a
343
- // push is deferred; the next session-create / turn-start flushes it exactly
344
- // once so the daemon forwarder always ends bound to THIS session's transcript.
345
- let _pendingRebind = false;
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();
346
340
  // Last assistant text handed to the transcript writer (via onAssistantText),
347
341
  // so the post-turn final-content append can skip an exact duplicate.
348
342
  let _lastAppendedAssistant = '';
@@ -370,6 +364,9 @@ export async function createMixdogSessionRuntime({
370
364
  clearTimeout(timeoutId);
371
365
  keychainPrewarmWaitDone = true;
372
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.
373
370
  })();
374
371
  return keychainPrewarmWaitPromise;
375
372
  }
@@ -545,50 +542,26 @@ export async function createMixdogSessionRuntime({
545
542
  ...extra,
546
543
  };
547
544
  }
548
- const sessionPrewarmDelayMs = envDelayMs('MIXDOG_SESSION_PREWARM_DELAY_MS', 50, { min: 0, max: 10_000 });
549
- const providerSetupWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_SETUP_WARMUP_DELAY_MS', 300, { min: 0, max: 60_000 });
550
- const modelCatalogWarmupDelayMs = envDelayMs('MIXDOG_MODEL_CATALOG_WARMUP_DELAY_MS', 200, { min: 0, max: 60_000 });
551
- const providerWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_WARMUP_DELAY_MS', 1_500, { min: 0, max: 60_000 });
552
- // Background model-catalog prefetch delay. Kept short so the first `/model`
553
- // open finds a warm cache instead of paying a cold full network load. The
554
- // work is async + unref'd, so short-lived detached runtimes still exit
555
- // cleanly without waiting on it. Operators can raise it via env if a
556
- // detached runtime must avoid the /models round-trip entirely.
557
- const providerModelWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS', 2_000, { min: 0, max: 120_000 });
558
- const codeGraphPrewarmDelayMs = envDelayMs('MIXDOG_CODE_GRAPH_PREWARM_DELAY_MS', 250, { min: 0, max: 60_000 });
559
- const statuslineUsageWarmupDelayMs = envDelayMs('MIXDOG_STATUSLINE_USAGE_WARMUP_DELAY_MS', 800, { min: 0, max: 60_000 });
560
- // Idle keep-alive: re-fetch usage before the statusline's 10-min staleness cut
561
- // (LIVE_USAGE_SNAPSHOT_MAX_AGE_MS) so the usage segment does not disappear
562
- // while the session sits idle with no turns to trigger a refresh.
563
- const statuslineUsageRefreshDelayMs = envDelayMs('MIXDOG_STATUSLINE_USAGE_REFRESH_MS', 240_000, { min: 30_000, max: 540_000 });
564
- const channelStartDelayMs = envDelayMs('MIXDOG_CHANNEL_START_DELAY_MS', 10_000, { min: 0, max: 120_000 });
565
- const backgroundBusyRetryMs = envDelayMs('MIXDOG_BACKGROUND_BUSY_RETRY_MS', 1_000, { min: 50, max: 10_000 });
566
- const sessionPrewarmEnabled = !envFlag('MIXDOG_DISABLE_SESSION_PREWARM')
567
- && (envFlag('MIXDOG_ENABLE_SESSION_PREWARM') || envPresent('MIXDOG_SESSION_PREWARM_DELAY_MS'));
568
- const providerWarmupEnabled = !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
569
- && (
570
- envFlag('MIXDOG_ENABLE_PROVIDER_WARMUP')
571
- || envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')
572
- || envPresent('MIXDOG_PROVIDER_WARMUP_DELAY_MS')
573
- || envPresent('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS')
574
- );
575
- // Boot-time model-catalog prefetch is intentionally decoupled from the
576
- // heavier providerWarmupEnabled gate (which stays opt-in for provider
577
- // *init* side effects). Fetching the model list in the background after a
578
- // short delay is cheap, fire-and-forget, and unref'd, so it is ON by
579
- // default — otherwise the FIRST `/model` open always paid a cold full
580
- // network load. Operators can still disable it explicitly.
581
- const modelPrefetchEnabled = !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
582
- && !envFlag('MIXDOG_DISABLE_MODEL_PREFETCH');
583
- const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM');
584
- const modelCatalogWarmupEnabled = !envFlag('MIXDOG_DISABLE_MODEL_CATALOG_WARMUP');
585
- // Lazy code-graph prewarm (default ON): do NOT prewarm at startup / on cwd
586
- // change — that fired ~250ms after the first frame and, in a large tree,
587
- // burned a worker (and felt like a freeze) before the user did anything.
588
- // Instead prewarm ONCE on the first real turn, when a code lookup is actually
589
- // imminent. Operators who want the old eager behavior can set
590
- // MIXDOG_CODE_GRAPH_PREWARM_EAGER=1.
591
- 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();
592
565
  let codeGraphFirstTurnPrewarmDone = false;
593
566
  const modelMetaByRoute = new Map();
594
567
  const notificationListeners = new Set();
@@ -640,251 +613,40 @@ export async function createMixdogSessionRuntime({
640
613
  setDesktopSession: (v) => { desktopSession = v; },
641
614
  state: mcpState,
642
615
  });
643
- let preSessionToolSurface = null;
644
616
  const hooksStartedAt = performance.now();
645
617
  const hooks = createStandaloneHookBus({ dataDir: cfgMod.getPluginData() });
646
618
  hooks.emit('runtime:start', { cwd: currentCwd, provider: route.provider, model: route.model, toolMode: mode });
647
619
  bootProfile('hooks:ready', { ms: (performance.now() - hooksStartedAt).toFixed(1) });
648
620
 
649
- // ---------------------------------------------------------------------
650
- // Self-update (npm registry version check + optional auto-install).
651
- // updateCheckState mirrors the last-known checkLatestVersion() result;
652
- // updateProcessState tracks the in-flight install lifecycle so the TUI can
653
- // poll getUpdateStatus() instead of needing a push/event channel. Both are
654
- // purely in-memory (per runtime instance) the on-disk cache lives inside
655
- // update-checker.mjs and is what actually enforces the 24h TTL.
656
- let updateCheckState = {
657
- currentVersion: null,
658
- latestVersion: null,
659
- updateAvailable: false,
660
- lastCheckedAt: 0,
661
- };
662
- // phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
663
- let updateProcessState = { phase: 'idle', version: null, error: null };
664
- // Boot detects an available update and STAGES it in the background (a hidden,
665
- // detached npm install into ~/.mixdog/data/staging/<ver>). The staged package
666
- // is swapped into the global dir on the next clean launch (cli.mjs
667
- // pre-import), never while a session is live — so npm never overwrites the
668
- // .mjs files node currently holds (the old shutdown `npm install -g` did, and
669
- // caused Windows TAR_ENTRY_ERROR / ENOENT). The live-session refcount that a
670
- // concurrent launch consults to defer its swap is registered earlier, in
671
- // cli.mjs (pre-import), so this process is visible before any runtime loads.
672
-
673
- function autoUpdateEnabled() {
674
- return config?.update?.auto !== false;
675
- }
676
-
677
- async function checkForUpdateInternal({ force = false } = {}) {
678
- if (updateProcessState.phase !== 'installing') updateProcessState.phase = 'checking';
679
- try {
680
- const result = await checkLatestVersion({ force, dataDir: cfgMod.getPluginData?.() || STANDALONE_DATA_DIR });
681
- updateCheckState = {
682
- currentVersion: result.currentVersion,
683
- latestVersion: result.latestVersion,
684
- updateAvailable: result.updateAvailable,
685
- lastCheckedAt: result.lastCheckedAt,
686
- };
687
- } catch {
688
- // checkLatestVersion() is already silent-safe; this catch is belt-and-
689
- // braces so a boot-time call can never crash the runtime.
690
- } finally {
691
- if (updateProcessState.phase === 'checking') updateProcessState.phase = 'idle';
692
- }
693
- return updateCheckState;
694
- }
695
-
696
- async function runUpdateNowInternal() {
697
- if (updateProcessState.phase === 'installing') {
698
- return { ...updateProcessState, alreadyInstalling: true, error: 'update already in progress' };
699
- }
700
- if (isDevInstall()) {
701
- updateProcessState = { phase: 'failed', version: null, error: 'dev install — update skipped' };
702
- return updateProcessState;
703
- }
704
- const ver = updateCheckState.latestVersion;
705
- if (!ver || !updateCheckState.updateAvailable) {
706
- updateProcessState = { phase: 'idle', version: null, error: null };
707
- return { ...updateProcessState, error: 'no update available' };
708
- }
709
- // "Update now" stages the new version (verified, self-contained) rather than
710
- // installing over the live global dir; the swap applies on the next launch.
711
- // phase 'installed' here means "staged & ready — restart to apply".
712
- updateProcessState = { phase: 'installing', version: ver, error: null };
713
- try {
714
- const result = await runStagedInstall(ver);
715
- if (result?.ok) {
716
- updateProcessState = { phase: 'installed', version: result.version || ver, error: null };
717
- } else {
718
- updateProcessState = { phase: 'failed', version: null, error: result?.error || 'update failed' };
719
- }
720
- } catch (err) {
721
- updateProcessState = { phase: 'failed', version: null, error: err?.message || String(err) };
722
- }
723
- return updateProcessState;
724
- }
725
-
726
- // Non-blocking boot hook: fires after the runtime object below is fully
727
- // constructed (setTimeout(0) defers past the synchronous return), so a
728
- // slow/hanging registry request can never delay session boot. The check
729
- // ALWAYS runs (populates updateCheckState for the maintenance picker), but
730
- // when an update is available it kicks off a hidden BACKGROUND staging
731
- // install (spawnStagedInstall) into ~/.mixdog/data/staging/<ver>. The actual
732
- // swap into the global dir happens on the next clean launch (cli.mjs
733
- // pre-import), so npm never overwrites files this live process holds.
734
- // force:true — always hit the registry at boot (the 24h disk cache went
735
- // stale-visible: it kept reporting an older "latest" than the installed
736
- // version). checkLatestVersion() still falls back to the cache offline.
737
- // isDevInstall() gate: a git checkout / clone (or non-node_modules install)
738
- // must never self-update — staging + swap would fight the working tree.
739
- const updateBootTimer = setTimeout(() => {
740
- void (async () => {
741
- await checkForUpdateInternal({ force: true });
742
- if (!(autoUpdateEnabled() && !isDevInstall() && updateCheckState.updateAvailable)) return;
743
- const ver = updateCheckState.latestVersion;
744
- if (!ver) return;
745
- // The notice fires ONLY once staging has completed (a ready-to-apply
746
- // package sits on disk) — never upfront — so the user sees no "update
747
- // available / installs on quit" nag while the background stage runs
748
- // silently. The wording lives in the notice surface (notification-plan):
749
- // this emit only carries meta.version. TUI maps meta.kind 'update-notice'
750
- // to a transient notice, never a model-visible message; tone 'info' =
751
- // non-urgent, applies on the next launch.
752
- const announceReady = () => {
753
- emitRuntimeNotification('update ready', { kind: 'update-notice', version: ver, tone: 'info' });
754
- };
755
- // Already staged in a prior session → announce immediately.
756
- if (isStagedComplete(ver)) { announceReady(); return; }
757
- try { spawnStagedInstall(ver); } catch { /* best-effort background stage */ }
758
- // Poll for staging completion, then announce once. The interval is
759
- // unref'd so it never holds the process open, and gives up silently
760
- // after the cap — the next launch retries.
761
- const POLL_MS = 3_000;
762
- const MAX_MS = 10 * 60 * 1000;
763
- const startedAt = Date.now();
764
- const poll = setInterval(() => {
765
- if (isStagedComplete(ver)) {
766
- clearInterval(poll);
767
- announceReady();
768
- } else if (Date.now() - startedAt > MAX_MS) {
769
- clearInterval(poll);
770
- }
771
- }, POLL_MS);
772
- poll.unref?.();
773
- })().catch(() => {});
774
- }, 0);
775
- updateBootTimer.unref?.();
776
-
777
- function emitRuntimeNotification(content, meta = {}) {
778
- const text = String(content || '').trim();
779
- if (!text) return { handled: false, modelVisibleDelivered: false };
780
- const event = { content: text, meta: meta && typeof meta === 'object' ? meta : {} };
781
- let handled = false;
782
- for (const listener of [...notificationListeners]) {
783
- try {
784
- if (listener(event) === true) handled = true;
785
- } catch {}
786
- }
787
- // EXPLICIT model-visible-delivery ack: only the TUI execution-ui path sets
788
- // event.modelVisibleDelivered when it enqueues the model-visible completion
789
- // body into the active loop. A generic display-only / API listener that
790
- // returns true does NOT set it, so `handled` alone must never be read as
791
- // "the model saw the body".
792
- const modelVisibleDelivered = event.modelVisibleDelivered === true;
793
- return { handled, modelVisibleDelivered };
794
- }
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();
795
634
 
796
- function notifyFnForSession(callerSessionId) {
797
- return (text, meta = {}) => {
798
- const { handled: handledByRuntimeListener, modelVisibleDelivered } = emitRuntimeNotification(text, meta);
799
- let enqueued = false;
800
- // Model-visible delivery ack (TUI execution-ui only): register this
801
- // completion in the process-local delivered registry so a racing enqueue
802
- // on another path (background reconcile/fallback/notify) within THIS
803
- // process is skipped instead of double-injecting the same body next turn.
804
- if (modelVisibleDelivered) {
805
- try {
806
- const deliveredVisible = modelVisibleToolCompletionMessage(text, meta);
807
- recordDeliveredCompletion({ executionId: meta?.execution_id, text: deliveredVisible });
808
- } catch {}
809
- }
810
- // TUI sessions consume raw execution notifications for UI/task cards via
811
- // onNotification, but those raw envelopes are internal-only in pending
812
- // drain. The TUI execution-ui path injects the model-visible twin of a
813
- // terminal completion into the active loop and acks with
814
- // modelVisibleDelivered, so mirroring it into the pending queue would
815
- // double-inject the same completion — skip only on that EXPLICIT ack. A
816
- // generic display-only / API listener returning true is NOT an ack: the
817
- // mirror stays as the sole model-visible delivery.
818
- if (shouldMirrorCompletionToPendingQueue({
819
- callerSessionId,
820
- modelVisibleDelivered,
821
- hasEnqueue: typeof mgr.enqueuePendingMessage === 'function',
822
- text,
823
- meta,
824
- })) {
825
- try {
826
- const visible = modelVisibleToolCompletionMessage(text, meta);
827
- // Terminal completion (gated by shouldPersistModelVisibleToolCompletion)
828
- // → tag so drain discards it on resume rather than replaying out-of-order.
829
- if (visible) {
830
- if (isDeliveredCompletion({ executionId: meta?.execution_id, text: visible })) {
831
- // Already delivered+ACKed on the TUI path → suppress the mirror but
832
- // report DELIVERED so the caller marks it notified and never retries.
833
- logDuplicateSkip('mirror', { executionId: meta?.execution_id, text: visible });
834
- enqueued = true;
835
- } else {
836
- enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
837
- }
838
- }
839
- } catch {}
840
- }
841
- // Headless/API listeners may exist but not consume the event; preserve
842
- // the old fallback for non-terminal notifications only when unhandled.
843
- if (!enqueued && !handledByRuntimeListener && callerSessionId
844
- && typeof mgr.enqueuePendingMessage === 'function') {
845
- try {
846
- const visible = modelVisibleToolCompletionMessage(text, meta);
847
- // modelVisibleToolCompletionMessage only returns non-empty for a
848
- // persistable terminal completion, so this fallback is a completion
849
- // too → tag it (genuine non-completion notifications yield '' above).
850
- if (visible) {
851
- if (isDeliveredCompletion({ executionId: meta?.execution_id, text: visible })) {
852
- // Already delivered+ACKed on the TUI path → suppress the fallback
853
- // enqueue but report DELIVERED so the caller stops retrying.
854
- logDuplicateSkip('fallback', { executionId: meta?.execution_id, text: visible });
855
- enqueued = true;
856
- } else {
857
- enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
858
- }
859
- }
860
- } catch {}
861
- }
862
- return enqueued || handledByRuntimeListener;
863
- };
864
- }
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
+ });
865
641
 
866
- function skillsStatus() {
867
- const skills = typeof contextMod.collectSkillsCached === 'function'
868
- ? contextMod.collectSkillsCached(currentCwd)
869
- : [];
870
- const norm = (value) => String(value || '').replace(/\\/g, '/').toLowerCase();
871
- const cwdNorm = norm(currentCwd);
872
- const sourceForSkill = (filePath) => {
873
- const path = norm(filePath);
874
- if (cwdNorm && path.startsWith(`${cwdNorm}/.mixdog/skills/`)) return 'project';
875
- return 'skill';
876
- };
877
- return {
878
- cwd: currentCwd,
879
- count: skills.length,
880
- skills: skills.map((skill) => ({
881
- name: skill.name,
882
- description: skill.description || '',
883
- filePath: skill.filePath || null,
884
- source: sourceForSkill(skill.filePath),
885
- })),
886
- };
887
- }
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 });
888
650
 
889
651
  // cwd resolution/apply + plugins-status + core-memory context. Extracted to
890
652
  // session-runtime/cwd-plugins.mjs; the facade keeps ownership of the mutable
@@ -932,52 +694,6 @@ export async function createMixdogSessionRuntime({
932
694
  STANDALONE_DATA_DIR,
933
695
  });
934
696
 
935
- function skillContent(name) {
936
- const res = typeof contextMod.loadSkillResource === 'function'
937
- ? contextMod.loadSkillResource(name, currentCwd)
938
- : null;
939
- if (!res) throw new Error(`skill not found: ${name}`);
940
- return { name, content: res.content, dir: res.dir };
941
- }
942
-
943
- function skillToolContent(name) {
944
- if (typeof contextMod.isSkillDisabled === 'function' && contextMod.isSkillDisabled(name)) {
945
- const label = String(name || '').trim() || 'skill';
946
- return `Error: skill "${label}" is disabled`;
947
- }
948
- const skill = skillContent(name);
949
- // Return the general tool envelope so the main/Lead session behaves the
950
- // same as agent-loop sessions: the model-visible tool_result is the short
951
- // stub (`Loaded skill: <name>`) and the full SKILL.md body is delivered
952
- // ONCE as a separate injected role:'user' message (newMessages). The
953
- // envelope passes through internal-tools._normalize untouched (it preserves
954
- // __toolEnvelope objects), and the agent loop's central normalizeToolEnvelope
955
- // splits it into stub + injected user body.
956
- return contextMod.buildSkillToolEnvelope(skill.name, skill.content, skill.dir);
957
- }
958
-
959
- function addProjectSkill(input = {}) {
960
- const name = clean(input.name).replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
961
- if (!name) throw new Error('skill name is required');
962
- const dir = join(currentCwd, '.mixdog', 'skills', name);
963
- const filePath = join(dir, 'SKILL.md');
964
- if (existsSync(filePath)) throw new Error(`skill already exists: ${name}`);
965
- const description = clean(input.description) || 'Project skill.';
966
- mkdirSync(dir, { recursive: true });
967
- writeFileSync(filePath, [
968
- '---',
969
- `name: ${name}`,
970
- `description: ${description}`,
971
- '---',
972
- '',
973
- '# Instructions',
974
- '',
975
- 'Describe when and how to use this skill.',
976
- '',
977
- ].join('\n'), 'utf8');
978
- return { name, filePath };
979
- }
980
-
981
697
  const agentToolStartedAt = performance.now();
982
698
  const agentTool = createStandaloneAgent({
983
699
  cfgMod,
@@ -1082,25 +798,26 @@ export async function createMixdogSessionRuntime({
1082
798
  // schema-visible tool that policy always rejects is a guaranteed error turn
1083
799
  // (user-reported in Solo). Names derive from the live agent tool defs.
1084
800
  const agentToolNames = new Set(agentTool.tools.map((tool) => String(tool?.name || '')).filter(Boolean));
1085
- function workflowAllowsAgents() {
1086
- try {
1087
- const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
1088
- const pack = loadWorkflowPack(dataDir, activeWorkflowId(config));
1089
- if (!pack || pack.agentsConfigured !== true) return true;
1090
- return Array.isArray(pack.agents) && pack.agents.length > 0;
1091
- } catch {
1092
- return true;
1093
- }
1094
- }
1095
- function modelStandaloneTools() {
1096
- return workflowAllowsAgents()
1097
- ? standaloneTools
1098
- : standaloneTools.filter((tool) => !agentToolNames.has(String(tool?.name || '')));
1099
- }
1100
-
1101
- function invalidatePreSessionToolSurface() {
1102
- preSessionToolSurface = null;
1103
- }
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
+ });
1104
821
 
1105
822
  const { contextStatus: computeContextStatus, invalidateContextStatusCache } = createContextStatus({
1106
823
  getSession: () => session,
@@ -1108,34 +825,6 @@ export async function createMixdogSessionRuntime({
1108
825
  getCurrentCwd: () => currentCwd,
1109
826
  getMode: () => mode,
1110
827
  });
1111
-
1112
- function buildPreSessionToolSurface() {
1113
- const previewTools = typeof mgr.previewSessionTools === 'function'
1114
- ? mgr.previewSessionTools(toolSpecForMode(mode), [])
1115
- : [];
1116
- const tools = filterDisallowedTools(previewTools, LEAD_DISALLOWED_TOOLS);
1117
- const surface = { tools: Array.isArray(tools) ? tools.slice() : [] };
1118
- applyDeferredToolSurface(surface, deferredSurfaceModeForLead(mode), modelStandaloneTools(), { provider: route.provider });
1119
- return surface;
1120
- }
1121
-
1122
- function activeToolSurface() {
1123
- if (session) return session;
1124
- preSessionToolSurface ??= buildPreSessionToolSurface();
1125
- return preSessionToolSurface;
1126
- }
1127
-
1128
- function applyPreSessionToolSelection() {
1129
- if (!session || !preSessionToolSurface) return;
1130
- const selected = Array.isArray(preSessionToolSurface.deferredSelectedTools)
1131
- ? preSessionToolSurface.deferredSelectedTools
1132
- : [];
1133
- const discovered = Array.isArray(preSessionToolSurface.deferredDiscoveredTools)
1134
- ? preSessionToolSurface.deferredDiscoveredTools
1135
- : [];
1136
- const replay = [...new Set([...selected, ...discovered])];
1137
- if (replay.length) selectDeferredTools(session, replay, deferredSurfaceModeForLead(mode));
1138
- }
1139
828
  internalTools.setInternalToolsProvider({
1140
829
  tools: [...standaloneTools, ...searchRuntimeTools.filter((tool) => tool?.public === false)],
1141
830
  executor: async (name, args, callerCtx = {}) => {
@@ -1371,6 +1060,7 @@ export async function createMixdogSessionRuntime({
1371
1060
  const {
1372
1061
  refreshStatuslineUsageSnapshot,
1373
1062
  cachedProviderSetup,
1063
+ hasProviderSetupCached,
1374
1064
  getUsageDashboard,
1375
1065
  } = createProviderUsage({
1376
1066
  caches: providerUsageCaches,
@@ -1717,313 +1407,59 @@ export async function createMixdogSessionRuntime({
1717
1407
  state: prewarmState,
1718
1408
  });
1719
1409
 
1720
- // Eagerly create/refresh the remote transcript writer for the CURRENT
1721
- // session + cwd, publish the session record, and ensure the transcript
1722
- // file exists on disk. Called from startRemote() (so the channel worker's
1723
- // activate-time discovery finds THIS session immediately instead of
1724
- // waiting for the first turn) and from ask() at turn start. Returns true
1725
- // when a writer is bound.
1726
- function ensureRemoteTranscriptWriter() {
1727
- if (!remoteEnabled || !session?.id) return false;
1728
- // Owner gate: late adopt covers lazy create (owner unknown at /remote
1729
- // time); a live owner blocks every other session; a dead owner (cleared
1730
- // or deleted session) hands the relay to the current session.
1731
- if (!remoteSessionId) {
1732
- remoteSessionId = session.id;
1733
- } else if (session.id !== remoteSessionId) {
1734
- const owner = mgr.getSession(remoteSessionId);
1735
- if (owner && owner.closed !== true && owner.status !== 'closed') return false;
1736
- remoteSessionId = session.id;
1737
- }
1738
- const twKey = `${session.id}\u0000${currentCwd}`;
1739
- if (_twKey !== twKey) {
1740
- try {
1741
- _transcriptWriter = createTranscriptWriter({
1742
- mixdogHome: mixdogHome(),
1743
- sessionId: session.id,
1744
- cwd: currentCwd,
1745
- pid: process.pid,
1746
- });
1747
- _transcriptWriter.writeSessionRecord();
1748
- _twKey = twKey;
1749
- } catch (error) {
1750
- process.stderr.write(`mixdog: transcript-writer: init failed: ${error?.message || error}\n`);
1751
- _transcriptWriter = null;
1752
- _twKey = '';
1753
- return false;
1754
- }
1755
- } else {
1756
- // Same binding — refresh updatedAt so worker-side discovery keeps
1757
- // ranking this session as the live parent-chain candidate.
1758
- try { _transcriptWriter?.writeSessionRecord(); } catch {}
1759
- }
1760
- try { _transcriptWriter?.ensureTranscriptFile(); }
1761
- catch (error) { process.stderr.write(`mixdog: transcript-writer: ensureTranscriptFile failed: ${error?.message || error}\n`); }
1762
- return _transcriptWriter != null;
1763
- }
1764
-
1765
- // Push the CURRENT transcript path to the channel worker so outbound
1766
- // forwarding repoints immediately at the moments the binding can go stale
1767
- // (auto-acquire, newSession/resume, clear) instead of waiting for the next
1768
- // inbound parent-chain steal. Best-effort: ensures the writer, then fires
1769
- // the dedicated idempotent worker op — a missing/not-ready worker or a bind
1770
- // failure must never throw into the lead paths that call this.
1771
- function pushTranscriptRebind() {
1772
- if (!remoteEnabled) return;
1773
- // Writer not bindable yet (e.g. 'acquired' before the session exists in
1774
- // lazy mode): defer instead of silently dropping the push. flushPending-
1775
- // TranscriptRebind() re-fires this exactly once when the writer is ready.
1776
- if (!ensureRemoteTranscriptWriter()) { _pendingRebind = true; return; }
1777
- const transcriptPath = _transcriptWriter?.transcriptPath;
1778
- if (!transcriptPath || !channelsEnabled()) { _pendingRebind = true; return; }
1779
- _pendingRebind = false;
1780
- executeTranscriptRebind(transcriptPath, 1);
1781
- }
1782
-
1783
- // Fire the idempotent worker op with bounded retry. A rejected/throwing
1784
- // channels.execute retries a few times with short backoff; the final failure
1785
- // surfaces one stderr line (not only the env-gated bootProfile) so a lost
1786
- // rebind is diagnosable by default. Best-effort throughout — never throws
1787
- // into the lead paths that call pushTranscriptRebind().
1788
- function executeTranscriptRebind(transcriptPath, attempt) {
1789
- const maxAttempts = 3;
1790
- const onError = (error) => {
1791
- const detail = error?.message || String(error);
1792
- bootProfile('channels:rebind-push-failed', { attempt, error: detail });
1793
- if (attempt < maxAttempts && remoteEnabled && !closeRequested) {
1794
- const timer = setTimeout(() => {
1795
- // Abort the retry chain silently if remote was dropped or the writer
1796
- // moved on (supersede→re-acquire, newSession/clear): re-firing the
1797
- // captured path would rebind forwarding back to a stale transcript.
1798
- if (!remoteEnabled || !channelsEnabled()) return;
1799
- if (_transcriptWriter?.transcriptPath !== transcriptPath) return;
1800
- executeTranscriptRebind(transcriptPath, attempt + 1);
1801
- }, 150 * attempt);
1802
- timer.unref?.();
1803
- } else {
1804
- process.stderr.write(`mixdog: channels: rebind_current_transcript failed after ${attempt} attempt(s): ${detail}\n`);
1805
- }
1806
- };
1807
- try {
1808
- void channels.execute('rebind_current_transcript', { transcriptPath }).catch(onError);
1809
- } catch (error) {
1810
- onError(error);
1811
- }
1812
- }
1813
-
1814
- // Re-fire a deferred rebind exactly once, after a session/writer becomes
1815
- // available (session create or turn start). No-op unless a push was deferred,
1816
- // so no unconditional rebind fires per turn for already-bound sessions.
1817
- function flushPendingTranscriptRebind() {
1818
- if (!_pendingRebind || !remoteEnabled) return;
1819
- pushTranscriptRebind();
1820
- }
1821
-
1822
- // Remote (Discord channel) mode is opt-in per session. Only a session that
1823
- // explicitly enables remote — via `mixdog --remote` or the runtime toggle —
1824
- // boots the channel worker and contends for channel ownership.
1825
- // startRemote() is FORCE-TAKEOVER: it always (re)claims the bridge seat and
1826
- // rebinds output forwarding to this session, even when the worker is
1827
- // already running (e.g. `/remote` re-issued after another session took the
1828
- // seat, or to re-pin forwarding onto the current transcript).
1829
- function startRemote(options = {}) {
1830
- const intent = options?.intent === 'auto' ? 'auto' : 'explicit';
1831
- // Claim intent reaches the worker's boot claim via MIXDOG_REMOTE_INTENT
1832
- // (last-wins for explicit, claim-if-vacant for auto). It is set transiently
1833
- // around the worker fork below (see invokeChannelStart) and restored right
1834
- // after — NOT here on the shared process.env — so unrelated children forked
1835
- // during the boot window never inherit a stale intent.
1836
- if (intent === 'auto') {
1837
- // Auto-start (config/delayed): do NOT flip this session to remote up
1838
- // front. Boot the worker to ATTEMPT a claim-if-vacant; remoteEnabled is
1839
- // set only when the worker reports it actually acquired the seat (the
1840
- // 'acquired' notification). A live owner already holding the seat makes
1841
- // the worker back off silently and this session stays non-remote.
1842
- remoteClaimPending = true;
1843
- } else {
1844
- remoteEnabled = true;
1845
- // Explicit toggle: the CURRENT session owns the relay (lazy create
1846
- // adopts inside ensureRemoteTranscriptWriter when no session exists yet).
1847
- remoteSessionId = session?.id || null;
1848
- }
1849
- // Boot the memory daemon eagerly. The channels worker forwards
1850
- // transcript ingests/entries to the memory HTTP service, whose port is
1851
- // published to active-instance.json by getMemoryModule().init(). Without
1852
- // this, memory only starts on the first turn's getMemoryModule() call —
1853
- // so early channel traffic finds no memory_port and gets buffered (or,
1854
- // pre-drainer, silently dropped). Runs BEFORE channel claim so the port is
1855
- // racing to be live by the time the worker sends its first ingest.
1856
- //
1857
- // Not fire-and-forget: init() only resolves the module handle, so a bare
1858
- // getMemoryModule() could return before /health reports ok — leaving early
1859
- // ingests to hit a not-yet-listening port. Await the proxy start() and then
1860
- // poll /health with a bounded retry so the daemon is provably reachable
1861
- // (or logged failed) rather than assumed-up. Still non-blocking to the
1862
- // caller: the whole probe is detached, but it internally awaits readiness.
1863
- void (async () => {
1864
- try {
1865
- // Yield one event-loop tick before the heavy chain below (module
1866
- // resolve, daemon fork/health-poll) starts, so Ink's next render
1867
- // (scheduled via setImmediate/timers) and any queued keypress
1868
- // events get a turn first instead of being starved by this
1869
- // detached chain's synchronous setup work.
1870
- await new Promise((r) => setImmediate(r));
1871
- const mod = await getMemoryModule();
1872
- const started = typeof mod?.start === 'function' ? await mod.start() : null;
1873
- const port = started?.port;
1874
- if (!port) { bootProfile('channels:memory-eager-init-failed', { error: 'no port from start()' }); return; }
1875
- for (let i = 0; i < 30; i++) {
1876
- try {
1877
- const ok = await new Promise((res) => {
1878
- const req = httpMod.request({ hostname: '127.0.0.1', port, path: '/health', timeout: 1500 }, (r) => {
1879
- let d = ''; r.on('data', (c) => { d += c; }); r.on('end', () => {
1880
- try { res(JSON.parse(d)?.status === 'ok'); } catch { res(false); }
1881
- });
1882
- });
1883
- req.on('error', () => res(false));
1884
- req.on('timeout', () => { req.destroy(); res(false); });
1885
- req.end();
1886
- });
1887
- if (ok) { bootProfile('channels:memory-eager-init-ready', { port }); return; }
1888
- } catch {}
1889
- await new Promise((r) => setTimeout(r, 500));
1890
- }
1891
- bootProfile('channels:memory-eager-init-failed', { error: `health not ok after retries (port ${port})` });
1892
- } catch (error) {
1893
- bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) });
1894
- }
1895
- })();
1896
- // Publish this session's record + transcript file BEFORE the worker's
1897
- // activate-time discovery polls, so output forwarding binds to this
1898
- // terminal session immediately instead of waiting for the first turn.
1899
- // No-op when the session has not been created yet (lazy mode); that
1900
- // case is covered by the turn-start rebind in ask().
1901
- ensureRemoteTranscriptWriter();
1902
- if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
1903
- bootProfile('channels:start-skipped');
1904
- return true;
1905
- }
1906
- if (closeRequested) return true;
1907
- if (prewarmTimers.channelStartTimer) {
1908
- clearTimeout(prewarmTimers.channelStartTimer);
1909
- prewarmTimers.channelStartTimer = null;
1910
- }
1911
- bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
1912
- void (async () => {
1913
- // Channels-module toggle gates MESSAGING only: automation (schedules/
1914
- // webhooks) still boots the worker — its backend runs headless when
1915
- // messaging is off or unconfigured (see channels/lib/config.mjs).
1916
- if (!channelsEnabled() && !(await hasActiveAutomation().catch(() => false))) {
1917
- bootProfile('channels:start-disabled');
1918
- remoteClaimPending = false;
1919
- return;
1920
- }
1921
- // A backend switch may still be pending or writing asynchronously. Drain
1922
- // it before the worker reads config; never race it with a sync lock wait.
1923
- try { await flushBackendSave(); } catch {}
1924
- // Yield before the createCurrentSession/transcript/fork chain below —
1925
- // same rationale as the memory-eager-init yield above: this detached
1926
- // chain runs synchronous config/fs work (createCurrentSession, backend
1927
- // flush, transcript writer) back-to-back, and without a tick break it
1928
- // can run ahead of Ink's queued render/input handling.
1929
- await new Promise((r) => setImmediate(r));
1930
- // Immediate-occupancy guarantee: make sure a session + transcript
1931
- // exist BEFORE the worker boots — a freshly-forked worker claims and
1932
- // runs transcript discovery inside its own start(), so publishing the
1933
- // session record/file first lets that very first discovery pass bind
1934
- // output forwarding to THIS terminal instead of a persisted/stale
1935
- // neighbour. Lazy mode means the session may not exist yet at /remote
1936
- // time; create it here (idempotent — reuses a live session, joins an
1937
- // in-flight create). On create failure we still claim: that matches
1938
- // the pre-eager behavior (bind resolves on the first turn's rebind).
1939
- try { await createCurrentSession('remote-start'); }
1940
- catch (error) { bootProfile('channels:remote-session-create-failed', { error: error?.message || String(error) }); }
1941
- ensureRemoteTranscriptWriter();
1942
- // Re-check after the awaits above: stopRemote()/superseded or runtime
1943
- // close may have landed mid-chain — do not boot/claim for a session
1944
- // that already turned remote off.
1945
- if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
1946
- // Set the fork-inherited intent immediately before the worker fork (the
1947
- // fork reads process.env synchronously inside invokeChannelStart) and
1948
- // restore the prior value the instant it resolves, so the pollution
1949
- // window is just this fork rather than the whole boot chain.
1950
- const _prevIntent = process.env.MIXDOG_REMOTE_INTENT;
1951
- try {
1952
- process.env.MIXDOG_REMOTE_INTENT = intent;
1953
- await invokeChannelStart();
1954
- } finally {
1955
- if (_prevIntent === undefined) delete process.env.MIXDOG_REMOTE_INTENT;
1956
- else process.env.MIXDOG_REMOTE_INTENT = _prevIntent;
1957
- }
1958
- if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
1959
- // Explicit claims are unconditional. Auto-start dispatches the same
1960
- // activation as claim-if-vacant so it can promote an existing
1961
- // automation-only daemon without stealing a live remote owner.
1962
- await channels.execute('activate_channel_bridge', {
1963
- active: true,
1964
- claimIfVacant: intent === 'auto',
1965
- sessionId: session?.id || remoteSessionId || null,
1966
- transcriptPath: _transcriptWriter?.transcriptPath || null,
1967
- });
1968
- // Claim attempt dispatched; the worker's acquire/supersede notification
1969
- // now owns the remoteEnabled transition. Drop the transient marker.
1970
- remoteClaimPending = false;
1971
- })().catch((error) => {
1972
- remoteClaimPending = false;
1973
- if (remoteEnabled) {
1974
- remoteEnabled = false;
1975
- remoteSessionId = null;
1976
- channels.stop('remote-claim-failed').catch(() => {});
1977
- emitRemoteStateChange(false, 'claim-failed');
1978
- }
1979
- bootProfile('channels:claim-failed', { error: error?.message || String(error) });
1980
- });
1981
- return true;
1982
- }
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();
1983
1428
 
1984
- // Explicit user OFF is a desired-state command, not a toggle retry. Ask the
1985
- // daemon to deactivate the machine-global bridge before detaching this
1986
- // client; the call runs in the background so the UI never waits through a
1987
- // daemon spawn/reconnect window. Repeated releases remain idempotent.
1988
- function releaseRemote(reason) {
1989
- const releasingSessionId = remoteSessionId || session?.id || null;
1990
- remoteEnabled = false;
1991
- remoteSessionId = null;
1992
- remoteClaimPending = false;
1993
- if (prewarmTimers.channelStartTimer) {
1994
- clearTimeout(prewarmTimers.channelStartTimer);
1995
- prewarmTimers.channelStartTimer = null;
1996
- }
1997
- void (async () => {
1998
- try {
1999
- await channels.execute('activate_channel_bridge', {
2000
- active: false,
2001
- sessionId: releasingSessionId,
2002
- });
2003
- } catch (error) {
2004
- bootProfile('channels:release-failed', { error: error?.message || String(error) });
2005
- emitRemoteStateChange(false, 'release-failed');
2006
- } finally {
2007
- await channels.stop(reason || 'remote-disabled').catch(() => {});
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;
2008
1444
  }
2009
- })();
2010
- return false;
2011
- }
2012
-
2013
- function stopRemote(reason) {
2014
- remoteEnabled = false;
2015
- remoteSessionId = null;
2016
- // A pending auto-claim is abandoned by an explicit stop/supersede.
2017
- remoteClaimPending = false;
2018
- // Cancel any pending deferred start so it can't fire after remote is off.
2019
- if (prewarmTimers.channelStartTimer) { clearTimeout(prewarmTimers.channelStartTimer); prewarmTimers.channelStartTimer = null; }
2020
- // Route /remote-off and supersede through a WAITING stop: the runtime keeps
2021
- // running, so we don't block on it (no await), but the waiting path runs the
2022
- // full SIGTERM -> taskkill /T /F escalation ladder to guarantee the worker
2023
- // dies rather than lingering as a zombie holding the bridge seat.
2024
- channels.stop(reason || 'remote-disabled').catch(() => {});
2025
- return true;
2026
- }
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);
2027
1463
 
2028
1464
  function isRemoteEnabled() {
2029
1465
  return remoteEnabled;
@@ -2101,7 +1537,6 @@ export async function createMixdogSessionRuntime({
2101
1537
  // early /remote (startRemote clears it), stopRemote(), and close() all
2102
1538
  // cancel it through the existing clearTimeout paths. Runtime /remote calls
2103
1539
  // still start immediately (user-initiated, UI already painted).
2104
- const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
2105
1540
  if (remoteEnabled || remoteAutoStartRequested) {
2106
1541
  const remoteStartIntent = remoteEnabled ? 'explicit' : 'auto';
2107
1542
  bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs, intent: remoteStartIntent });
@@ -2166,8 +1601,8 @@ export async function createMixdogSessionRuntime({
2166
1601
  recapEnabledFn,
2167
1602
  channelsEnabled,
2168
1603
  autoUpdateEnabled,
2169
- getUpdateCheckState: () => updateCheckState,
2170
- getUpdateProcessState: () => updateProcessState,
1604
+ getUpdateCheckState: () => selfUpdate.getCheckState(),
1605
+ getUpdateProcessState: () => selfUpdate.getProcessState(),
2171
1606
  invalidateContextStatusCache: (...a) => invalidateContextStatusCache(...a),
2172
1607
  invalidatePreSessionToolSurface: (...a) => invalidatePreSessionToolSurface(...a),
2173
1608
  scheduleChannelStart: (...a) => scheduleChannelStart(...a),
@@ -2204,6 +1639,8 @@ export async function createMixdogSessionRuntime({
2204
1639
  displayConfig,
2205
1640
  reloadFullConfig,
2206
1641
  awaitKeychainPrewarm,
1642
+ isKeychainPrewarmReady: () => keychainPrewarmWaitDone,
1643
+ hasProviderSetupCached,
2207
1644
  invalidateProviderCaches,
2208
1645
  warmProviderModelCache,
2209
1646
  refreshProviderCatalogs: () => ensureProvidersReady(config.providers || {}).then(() => reg.refreshCatalogs()),
@@ -2327,6 +1764,7 @@ export async function createMixdogSessionRuntime({
2327
1764
  displayConfig,
2328
1765
  agentRouteFromConfig,
2329
1766
  loadAgentDefinition,
1767
+ listCustomAgentIds,
2330
1768
  activeWorkflowId,
2331
1769
  listWorkflowPacks,
2332
1770
  loadWorkflowPack,
@@ -2355,8 +1793,8 @@ export async function createMixdogSessionRuntime({
2355
1793
  getCloseRequested: () => closeRequested,
2356
1794
  getPendingSessionReset: () => pendingSessionReset,
2357
1795
  setPendingSessionReset: (v) => { pendingSessionReset = v; },
2358
- getTranscriptWriter: () => _transcriptWriter,
2359
- getTwKey: () => _twKey,
1796
+ getTranscriptWriter: () => remoteTranscript.transcriptWriter,
1797
+ getTwKey: () => remoteTranscript.transcriptKey,
2360
1798
  getLastAppendedAssistant: () => _lastAppendedAssistant,
2361
1799
  setLastAppendedAssistant: (v) => { _lastAppendedAssistant = v; },
2362
1800
  scheduleCodeGraphPrewarm,