mixdog 0.9.68 → 0.9.70

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 (64) hide show
  1. package/package.json +8 -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/owned-runtime.mjs +8 -1
  21. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  22. package/src/runtime/channels/lib/webhook.mjs +13 -1
  23. package/src/runtime/media/adapters/codex-image.mjs +109 -0
  24. package/src/runtime/media/adapters/gemini-image.mjs +68 -0
  25. package/src/runtime/media/adapters/gemini-video.mjs +119 -0
  26. package/src/runtime/media/adapters/xai-media.mjs +116 -0
  27. package/src/runtime/media/auth.mjs +51 -0
  28. package/src/runtime/media/index.mjs +17 -0
  29. package/src/runtime/media/jobs.mjs +174 -0
  30. package/src/runtime/media/lanes.mjs +230 -0
  31. package/src/runtime/media/store.mjs +164 -0
  32. package/src/runtime/media/upstream-error.mjs +39 -0
  33. package/src/session-runtime/channel-config-api.mjs +12 -1
  34. package/src/session-runtime/media-api.mjs +47 -0
  35. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  36. package/src/session-runtime/model-capabilities.mjs +6 -4
  37. package/src/session-runtime/model-route-api.mjs +4 -1
  38. package/src/session-runtime/notification-bus.mjs +82 -0
  39. package/src/session-runtime/provider-auth-api.mjs +16 -1
  40. package/src/session-runtime/provider-usage.mjs +3 -0
  41. package/src/session-runtime/remote-control.mjs +166 -0
  42. package/src/session-runtime/remote-transcript.mjs +126 -0
  43. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  44. package/src/session-runtime/runtime-core.mjs +160 -706
  45. package/src/session-runtime/runtime-tunables.mjs +57 -0
  46. package/src/session-runtime/self-update.mjs +129 -0
  47. package/src/session-runtime/skills-api.mjs +77 -0
  48. package/src/session-runtime/tool-surface.mjs +83 -0
  49. package/src/session-runtime/warmup-schedulers.mjs +14 -1
  50. package/src/session-runtime/workflow-agents-api.mjs +235 -7
  51. package/src/session-runtime/workflow.mjs +84 -17
  52. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  53. package/src/standalone/agent-tool.mjs +22 -346
  54. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  55. package/src/standalone/channel-worker.mjs +7 -13
  56. package/src/tui/components/StatusLine.jsx +6 -5
  57. package/src/tui/dist/index.mjs +144 -95
  58. package/src/tui/engine/labels.mjs +0 -8
  59. package/src/tui/engine/session-api-ext.mjs +31 -8
  60. package/src/tui/engine/turn-watchdog.mjs +92 -0
  61. package/src/tui/engine/turn.mjs +18 -70
  62. package/src/tui/engine.mjs +39 -1
  63. package/src/workflows/default/WORKFLOW.md +1 -1
  64. package/src/workflows/solo/WORKFLOW.md +1 -1
@@ -0,0 +1,61 @@
1
+ // Eager memory-daemon boot for remote sessions. The channels worker forwards
2
+ // transcript ingests to the memory HTTP service, so its port must be published
3
+ // (and actually listening) before the worker sends its first ingest — otherwise
4
+ // early channel traffic finds no port and gets buffered. Extracted from
5
+ // runtime-core's startRemote, which now just fires it.
6
+ import * as httpMod from 'node:http';
7
+
8
+ const HEALTH_ATTEMPTS = 30;
9
+ const HEALTH_RETRY_MS = 500;
10
+ const HEALTH_TIMEOUT_MS = 1500;
11
+
12
+ function healthOk(port) {
13
+ return new Promise((resolve) => {
14
+ const request = httpMod.request(
15
+ { hostname: '127.0.0.1', port, path: '/health', timeout: HEALTH_TIMEOUT_MS },
16
+ (response) => {
17
+ let body = '';
18
+ response.on('data', (chunk) => { body += chunk; });
19
+ response.on('end', () => {
20
+ try { resolve(JSON.parse(body)?.status === 'ok'); } catch { resolve(false); }
21
+ });
22
+ },
23
+ );
24
+ request.on('error', () => resolve(false));
25
+ request.on('timeout', () => { request.destroy(); resolve(false); });
26
+ request.end();
27
+ });
28
+ }
29
+
30
+ /** Detached, never throws: resolving the module only hands back a handle, so
31
+ * this awaits start() and then polls /health until the daemon is provably
32
+ * reachable (or the failure is profiled). */
33
+ export function startMemoryDaemonEagerly({ getMemoryModule, bootProfile }) {
34
+ void (async () => {
35
+ try {
36
+ // Yield one tick first: the module resolve + daemon fork below is a long
37
+ // synchronous chain that would otherwise starve Ink's queued render and
38
+ // keypress handling.
39
+ await new Promise((resolve) => setImmediate(resolve));
40
+ const module = await getMemoryModule();
41
+ const started = typeof module?.start === 'function' ? await module.start() : null;
42
+ const port = started?.port;
43
+ if (!port) {
44
+ bootProfile('channels:memory-eager-init-failed', { error: 'no port from start()' });
45
+ return;
46
+ }
47
+ for (let attempt = 0; attempt < HEALTH_ATTEMPTS; attempt += 1) {
48
+ try {
49
+ if (await healthOk(port)) {
50
+ bootProfile('channels:memory-eager-init-ready', { port });
51
+ return;
52
+ }
53
+ } catch { /* probe failure is retried below */ }
54
+ await new Promise((resolve) => setTimeout(resolve, HEALTH_RETRY_MS));
55
+ }
56
+ bootProfile('channels:memory-eager-init-failed', { error: `health not ok after retries (port ${port})` });
57
+ } catch (error) {
58
+ bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) });
59
+ }
60
+ })();
61
+ }
@@ -103,6 +103,8 @@ export function fastPreferenceFor(config, provider, model) {
103
103
  if (!key) return false;
104
104
  const saved = config?.modelSettings?.[key];
105
105
  if (saved && typeof saved === 'object' && hasOwn(saved, 'fast')) return saved.fast === true;
106
+ // Pre-modelSettings configs kept the preference in a parallel `fastModels`
107
+ // map. Reading it keeps those installs working; nothing writes it anymore.
106
108
  return config?.fastModels?.[key] === true;
107
109
  }
108
110
 
@@ -118,11 +120,11 @@ export function saveModelSettings(cfgMod, route, { fastCapable = true, baseConfi
118
120
  else nextSetting.fast = false;
119
121
  modelSettings[key] = nextSetting;
120
122
 
121
- // Legacy compatibility: keep fastModels true entries for old readers, but
122
- // let modelSettings.fast=false override them in new readers.
123
+ // modelSettings is the single source of truth for the fast preference; the
124
+ // stale mirror in `fastModels` is dropped for the key being written so the
125
+ // two can never disagree.
123
126
  const fastModels = { ...(nextConfig.fastModels || {}) };
124
- if (nextSetting.fast === true) fastModels[key] = true;
125
- else delete fastModels[key];
127
+ delete fastModels[key];
126
128
 
127
129
  const savedConfig = { ...nextConfig, modelSettings, fastModels };
128
130
  cfgMod.saveConfig(savedConfig);
@@ -96,6 +96,10 @@ export function createModelRouteApi(deps) {
96
96
  if (!searchCapableFor(selectedRoute.provider, modelMeta)) {
97
97
  throw new Error(`model "${selectedRoute.model}" is not marked as web-search capable`);
98
98
  }
99
+ // Route-scope isolation: the search route stores its own effort/fast in
100
+ // config.searchRoute. The shared config.modelSettings[provider/model]
101
+ // bucket belongs to the MAIN route alone, so a search-model pick that
102
+ // happens to match Main must not rewrite Main's saved effort/fast.
99
103
  const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
100
104
  const effort = coerceEffortFor(selectedRoute.provider, modelMeta, selectedRoute.effort);
101
105
  selectedRoute = {
@@ -103,7 +107,6 @@ export function createModelRouteApi(deps) {
103
107
  ...(effort ? { effort } : {}),
104
108
  fast: fastCapable ? selectedRoute.fast === true : false,
105
109
  };
106
- adoptConfig(saveModelSettings(cfgMod, selectedRoute, { fastCapable, baseConfig: getConfig() }), { hasSecrets: getConfigHasSecrets() });
107
110
  const routeToSave = normalizeSearchRouteConfig(selectedRoute);
108
111
  const nextConfig = { ...getConfig() };
109
112
  nextConfig.searchRoute = routeToSave;
@@ -0,0 +1,82 @@
1
+ // Runtime notification fan-out. A notification reaches listeners (TUI/API) and,
2
+ // for terminal tool completions, may also be mirrored into the session's
3
+ // pending queue so the model sees it next turn. The delivered registry keeps
4
+ // those two paths from double-injecting the same completion.
5
+ import { modelVisibleToolCompletionMessage } from '../runtime/shared/tool-execution-contract.mjs';
6
+ import { markCompletionEntry } from '../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
7
+ import {
8
+ isDeliveredCompletion,
9
+ logDuplicateSkip,
10
+ recordDeliveredCompletion,
11
+ } from '../runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
12
+ import { shouldMirrorCompletionToPendingQueue } from './runtime-tool-routing.mjs';
13
+
14
+ export function createNotificationBus({ listeners, mgr }) {
15
+ function emitRuntimeNotification(content, meta = {}) {
16
+ const text = String(content || '').trim();
17
+ if (!text) return { handled: false, modelVisibleDelivered: false };
18
+ const event = { content: text, meta: meta && typeof meta === 'object' ? meta : {} };
19
+ let handled = false;
20
+ for (const listener of [...listeners]) {
21
+ try {
22
+ if (listener(event) === true) handled = true;
23
+ } catch { /* a broken listener never blocks the rest */ }
24
+ }
25
+ // EXPLICIT model-visible ack: only the TUI execution-ui path sets
26
+ // event.modelVisibleDelivered when it enqueues the body into the active
27
+ // loop. A display-only listener returning true is NOT that ack.
28
+ return { handled, modelVisibleDelivered: event.modelVisibleDelivered === true };
29
+ }
30
+
31
+ // Enqueue the model-visible twin unless it was already delivered; returns
32
+ // true when the caller may treat the completion as delivered.
33
+ function enqueueCompletion(callerSessionId, text, meta, path) {
34
+ const visible = modelVisibleToolCompletionMessage(text, meta);
35
+ // Non-completion notifications yield '' here and are never queued.
36
+ if (!visible) return false;
37
+ if (isDeliveredCompletion({ executionId: meta?.execution_id, text: visible })) {
38
+ // Delivered + ACKed on the TUI path: suppress the duplicate but report
39
+ // DELIVERED so the caller stops retrying.
40
+ logDuplicateSkip(path, { executionId: meta?.execution_id, text: visible });
41
+ return true;
42
+ }
43
+ return mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
44
+ }
45
+
46
+ function notifyFnForSession(callerSessionId) {
47
+ return (text, meta = {}) => {
48
+ const { handled, modelVisibleDelivered } = emitRuntimeNotification(text, meta);
49
+ let enqueued = false;
50
+ // Record the TUI delivery so a racing enqueue in THIS process (background
51
+ // reconcile / fallback / notify) skips instead of double-injecting.
52
+ if (modelVisibleDelivered) {
53
+ try {
54
+ recordDeliveredCompletion({
55
+ executionId: meta?.execution_id,
56
+ text: modelVisibleToolCompletionMessage(text, meta),
57
+ });
58
+ } catch { /* registry is best-effort */ }
59
+ }
60
+ // TUI sessions consume raw envelopes for UI cards, but those are
61
+ // internal-only in pending drain: mirror the model-visible twin unless the
62
+ // TUI explicitly acked it.
63
+ if (shouldMirrorCompletionToPendingQueue({
64
+ callerSessionId,
65
+ modelVisibleDelivered,
66
+ hasEnqueue: typeof mgr.enqueuePendingMessage === 'function',
67
+ text,
68
+ meta,
69
+ })) {
70
+ try { enqueued = enqueueCompletion(callerSessionId, text, meta, 'mirror'); } catch { /* best-effort */ }
71
+ }
72
+ // Headless/API listeners may exist without consuming the event: keep the
73
+ // fallback enqueue for an otherwise unhandled completion.
74
+ if (!enqueued && !handled && callerSessionId && typeof mgr.enqueuePendingMessage === 'function') {
75
+ try { enqueued = enqueueCompletion(callerSessionId, text, meta, 'fallback'); } catch { /* best-effort */ }
76
+ }
77
+ return enqueued || handled;
78
+ };
79
+ }
80
+
81
+ return { emitRuntimeNotification, notifyFnForSession };
82
+ }
@@ -24,6 +24,8 @@ export function createProviderAuthApi({
24
24
  displayConfig,
25
25
  reloadFullConfig,
26
26
  awaitKeychainPrewarm,
27
+ isKeychainPrewarmReady = () => true,
28
+ hasProviderSetupCached = () => true,
27
29
  invalidateProviderCaches,
28
30
  warmProviderModelCache,
29
31
  refreshProviderCatalogs,
@@ -56,8 +58,21 @@ export function createProviderAuthApi({
56
58
  return renderProviderStatus(displayConfig());
57
59
  },
58
60
  async getProviderSetup(options = {}) {
59
- await awaitKeychainPrewarm();
60
61
  const force = options?.force === true || options?.refresh === true;
62
+ // An unforced read never blocks: the authoritative setup waits on the OS
63
+ // keychain AND probes local provider ports, which together take seconds on
64
+ // a cold start and used to stall the whole settings sweep behind it. Serve
65
+ // the no-secrets snapshot until the real one is cached — flagged, so the
66
+ // caller shows "checking" instead of a wrong "not connected" — and let the
67
+ // scheduled warmup publish the authoritative result for the next read.
68
+ if (!force && (!isKeychainPrewarmReady() || !hasProviderSetupCached())) {
69
+ const quick = await cachedProviderSetup({ quick: true });
70
+ void Promise.resolve(awaitKeychainPrewarm())
71
+ .then(() => cachedProviderSetup({}))
72
+ .catch(() => {});
73
+ return { ...quick, pendingSecrets: !isKeychainPrewarmReady() };
74
+ }
75
+ await awaitKeychainPrewarm();
61
76
  if (force) reloadFullConfig();
62
77
  return await cachedProviderSetup({ force });
63
78
  },
@@ -118,6 +118,9 @@ export function createProviderUsage({
118
118
  return {
119
119
  refreshStatuslineUsageSnapshot,
120
120
  cachedProviderSetup,
121
+ // True once a secrets-aware setup is cached: callers that must not block
122
+ // (settings hydration) can serve the quick snapshot until then.
123
+ hasProviderSetupCached: () => Boolean(caches.providerSetupCache.setup),
121
124
  getUsageDashboard,
122
125
  };
123
126
  }
@@ -0,0 +1,166 @@
1
+ // Remote (channel relay) lifecycle for a session: claim, release and stop.
2
+ // Remote mode is opt-in per session — only a session that turns it on boots the
3
+ // channel worker and contends for the machine-global bridge seat. Extracted
4
+ // from runtime-core, which injects the mutable session/remote state it owns.
5
+ import { startMemoryDaemonEagerly } from './memory-daemon-probe.mjs';
6
+
7
+ export function createRemoteControl({
8
+ getSession,
9
+ isRemoteEnabled,
10
+ setRemoteEnabled,
11
+ getRemoteSessionId,
12
+ setRemoteSessionId,
13
+ setRemoteClaimPending,
14
+ isRemoteClaimPending,
15
+ isCloseRequested,
16
+ clearChannelStartTimer,
17
+ remoteTransitions,
18
+ channels,
19
+ channelsEnabled,
20
+ hasActiveAutomation,
21
+ flushBackendSave,
22
+ invokeChannelStart,
23
+ createCurrentSession,
24
+ ensureRemoteTranscriptWriter,
25
+ getTranscriptPath,
26
+ emitRemoteStateChange,
27
+ getMemoryModule,
28
+ bootProfile,
29
+ envFlag,
30
+ }) {
31
+ // FORCE-TAKEOVER for an explicit claim: it always (re)claims the seat and
32
+ // rebinds forwarding to this session, even when the worker already runs
33
+ // (`/remote` re-issued after another session took the seat).
34
+ function startRemote(options = {}) {
35
+ const intent = options?.intent === 'auto' ? 'auto' : 'explicit';
36
+ if (intent === 'auto') {
37
+ // Auto-start attempts a claim-if-vacant: remoteEnabled flips only when the
38
+ // worker reports it actually acquired the seat, so a live owner elsewhere
39
+ // leaves this session non-remote.
40
+ setRemoteClaimPending(true);
41
+ } else {
42
+ setRemoteEnabled(true);
43
+ // Explicit toggle: the CURRENT session owns the relay (lazy create adopts
44
+ // inside ensureRemoteTranscriptWriter when no session exists yet).
45
+ setRemoteSessionId(getSession()?.id || null);
46
+ }
47
+ // The worker forwards transcript ingests to the memory service, so its port
48
+ // must be live before the first ingest.
49
+ startMemoryDaemonEagerly({ getMemoryModule, bootProfile });
50
+ // Publish the session record + transcript file BEFORE the worker's
51
+ // activate-time discovery polls, so forwarding binds to THIS terminal
52
+ // instead of a stale neighbour. A no-op in lazy mode; the turn-start rebind
53
+ // covers that case.
54
+ ensureRemoteTranscriptWriter();
55
+ if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
56
+ bootProfile('channels:start-skipped');
57
+ return true;
58
+ }
59
+ if (isCloseRequested()) return true;
60
+ clearChannelStartTimer();
61
+ bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
62
+ return remoteTransitions.run(async () => {
63
+ try {
64
+ // The channels-module toggle gates MESSAGING only: automation still
65
+ // boots the worker, which runs headless when messaging is off.
66
+ if (!channelsEnabled() && !(await hasActiveAutomation().catch(() => false))) {
67
+ bootProfile('channels:start-disabled');
68
+ setRemoteClaimPending(false);
69
+ return isRemoteEnabled();
70
+ }
71
+ // A backend switch may still be writing; drain it before the worker
72
+ // reads config rather than racing a sync lock wait.
73
+ try { await flushBackendSave(); } catch { /* best-effort */ }
74
+ // Yield before the create/transcript/fork chain so Ink's queued render
75
+ // and input handling are not starved by this detached chain.
76
+ await new Promise((resolve) => setImmediate(resolve));
77
+ // Immediate-occupancy guarantee: a freshly forked worker runs transcript
78
+ // discovery inside its own start(), so the session must exist first.
79
+ // Idempotent; on failure we still claim (bind resolves on first turn).
80
+ try { await createCurrentSession('remote-start'); } catch (error) {
81
+ bootProfile('channels:remote-session-create-failed', { error: error?.message || String(error) });
82
+ }
83
+ ensureRemoteTranscriptWriter();
84
+ // Re-check after the awaits: stop/supersede or runtime close may have
85
+ // landed mid-chain — never boot for a session that turned remote off.
86
+ if ((!isRemoteEnabled() && !isRemoteClaimPending()) || isCloseRequested()) {
87
+ setRemoteClaimPending(false);
88
+ return false;
89
+ }
90
+ // The fork reads process.env synchronously, so set the inherited intent
91
+ // immediately before it and restore right after: the pollution window is
92
+ // this fork instead of the whole boot chain.
93
+ const previousIntent = process.env.MIXDOG_REMOTE_INTENT;
94
+ try {
95
+ process.env.MIXDOG_REMOTE_INTENT = intent;
96
+ await invokeChannelStart();
97
+ } finally {
98
+ if (previousIntent === undefined) delete process.env.MIXDOG_REMOTE_INTENT;
99
+ else process.env.MIXDOG_REMOTE_INTENT = previousIntent;
100
+ }
101
+ if ((!isRemoteEnabled() && !isRemoteClaimPending()) || isCloseRequested()) {
102
+ setRemoteClaimPending(false);
103
+ return false;
104
+ }
105
+ // Explicit claims are unconditional; auto-start dispatches the same
106
+ // activation as claim-if-vacant so it promotes an automation-only daemon
107
+ // without stealing a live remote owner.
108
+ await channels.execute('activate_channel_bridge', {
109
+ active: true,
110
+ claimIfVacant: intent === 'auto',
111
+ sessionId: getSession()?.id || getRemoteSessionId() || null,
112
+ transcriptPath: getTranscriptPath(),
113
+ });
114
+ // The worker's acquire/supersede notification now owns the transition.
115
+ setRemoteClaimPending(false);
116
+ return isRemoteEnabled();
117
+ } catch (error) {
118
+ setRemoteClaimPending(false);
119
+ if (isRemoteEnabled()) {
120
+ setRemoteEnabled(false);
121
+ setRemoteSessionId(null);
122
+ await channels.stop('remote-claim-failed').catch(() => {});
123
+ emitRemoteStateChange(false, 'claim-failed');
124
+ }
125
+ bootProfile('channels:claim-failed', { error: error?.message || String(error) });
126
+ return false;
127
+ }
128
+ });
129
+ }
130
+
131
+ // Explicit user OFF is a desired-state command, not a toggle retry: deactivate
132
+ // the machine-global bridge before detaching. The promise settles only after
133
+ // detach, keeping the desktop toggle busy until a later ON can reconnect.
134
+ function releaseRemote(reason) {
135
+ const releasingSessionId = getRemoteSessionId() || getSession()?.id || null;
136
+ setRemoteEnabled(false);
137
+ setRemoteSessionId(null);
138
+ setRemoteClaimPending(false);
139
+ clearChannelStartTimer();
140
+ return remoteTransitions.run(async () => {
141
+ try {
142
+ await channels.execute('activate_channel_bridge', { active: false, sessionId: releasingSessionId });
143
+ } catch (error) {
144
+ bootProfile('channels:release-failed', { error: error?.message || String(error) });
145
+ emitRemoteStateChange(false, 'release-failed');
146
+ } finally {
147
+ await channels.stop(reason || 'remote-disabled').catch(() => {});
148
+ }
149
+ return false;
150
+ });
151
+ }
152
+
153
+ // /remote-off and supersede: the runtime keeps running so this never blocks,
154
+ // but the waiting stop path runs the full SIGTERM -> taskkill escalation so the
155
+ // worker cannot linger as a zombie holding the seat.
156
+ function stopRemote(reason) {
157
+ setRemoteEnabled(false);
158
+ setRemoteSessionId(null);
159
+ setRemoteClaimPending(false);
160
+ clearChannelStartTimer();
161
+ channels.stop(reason || 'remote-disabled').catch(() => {});
162
+ return true;
163
+ }
164
+
165
+ return { startRemote, releaseRemote, stopRemote };
166
+ }
@@ -0,0 +1,126 @@
1
+ // Remote transcript binding: the channel worker forwards output by watching a
2
+ // per-session transcript file, so the runtime must (re)create the writer for
3
+ // the CURRENT session+cwd and push the path to the worker whenever the binding
4
+ // can go stale (relay acquire, new session, resume, clear). Extracted from
5
+ // runtime-core; every mutable local it needs is injected as an accessor.
6
+ import { createTranscriptWriter } from '../runtime/shared/transcript-writer.mjs';
7
+ import { mixdogHome } from '../runtime/shared/plugin-paths.mjs';
8
+
9
+ const REBIND_MAX_ATTEMPTS = 3;
10
+
11
+ export function createRemoteTranscript({
12
+ getSession,
13
+ getCwd,
14
+ isRemoteEnabled,
15
+ getRemoteSessionId,
16
+ setRemoteSessionId,
17
+ isCloseRequested,
18
+ channelsEnabled,
19
+ channels,
20
+ mgr,
21
+ bootProfile,
22
+ }) {
23
+ let writer = null;
24
+ let writerKey = '';
25
+ let pendingRebind = false;
26
+
27
+ return {
28
+ get transcriptWriter() { return writer; },
29
+ // Session+cwd identity of the current binding: a change means forwarding
30
+ // must be re-activated for the new transcript.
31
+ get transcriptKey() { return writerKey; },
32
+ get pendingRebind() { return pendingRebind; },
33
+ resetPendingRebind() { pendingRebind = false; },
34
+ ensureRemoteTranscriptWriter,
35
+ pushTranscriptRebind,
36
+ flushPendingTranscriptRebind,
37
+ };
38
+
39
+ // Bind (or refresh) the writer for the CURRENT session+cwd. The owner gate
40
+ // covers lazy create: an unset owner adopts this session, a LIVE owner blocks
41
+ // every other session, and a dead owner hands the relay over.
42
+ function ensureRemoteTranscriptWriter() {
43
+ const session = getSession();
44
+ if (!isRemoteEnabled() || !session?.id) return false;
45
+ const owner = getRemoteSessionId();
46
+ if (!owner) {
47
+ setRemoteSessionId(session.id);
48
+ } else if (session.id !== owner) {
49
+ const ownerSession = mgr.getSession(owner);
50
+ if (ownerSession && ownerSession.closed !== true && ownerSession.status !== 'closed') return false;
51
+ setRemoteSessionId(session.id);
52
+ }
53
+ const cwd = getCwd();
54
+ const key = `${session.id}\u0000${cwd}`;
55
+ if (writerKey !== key) {
56
+ try {
57
+ writer = createTranscriptWriter({
58
+ mixdogHome: mixdogHome(),
59
+ sessionId: session.id,
60
+ cwd,
61
+ pid: process.pid,
62
+ });
63
+ writer.writeSessionRecord();
64
+ writerKey = key;
65
+ } catch (error) {
66
+ process.stderr.write(`mixdog: transcript-writer: init failed: ${error?.message || error}\n`);
67
+ writer = null;
68
+ writerKey = '';
69
+ return false;
70
+ }
71
+ } else {
72
+ // Same binding — refresh updatedAt so worker-side discovery keeps ranking
73
+ // this session as the live parent-chain candidate.
74
+ try { writer?.writeSessionRecord(); } catch { /* discovery hint only */ }
75
+ }
76
+ try { writer?.ensureTranscriptFile(); } catch (error) {
77
+ process.stderr.write(`mixdog: transcript-writer: ensureTranscriptFile failed: ${error?.message || error}\n`);
78
+ }
79
+ return writer != null;
80
+ }
81
+
82
+ // Repoint outbound forwarding at the current transcript. Best-effort: a
83
+ // writer that is not bindable yet (relay acquired before the session exists)
84
+ // defers, and flushPendingTranscriptRebind() re-fires it exactly once.
85
+ function pushTranscriptRebind() {
86
+ if (!isRemoteEnabled()) return;
87
+ if (!ensureRemoteTranscriptWriter()) { pendingRebind = true; return; }
88
+ const transcriptPath = writer?.transcriptPath;
89
+ if (!transcriptPath || !channelsEnabled()) { pendingRebind = true; return; }
90
+ pendingRebind = false;
91
+ executeTranscriptRebind(transcriptPath, 1);
92
+ }
93
+
94
+ // Bounded retry on the idempotent worker op; the final failure surfaces one
95
+ // stderr line so a lost rebind is diagnosable without the env-gated profile.
96
+ function executeTranscriptRebind(transcriptPath, attempt) {
97
+ const onError = (error) => {
98
+ const detail = error?.message || String(error);
99
+ bootProfile('channels:rebind-push-failed', { attempt, error: detail });
100
+ if (attempt < REBIND_MAX_ATTEMPTS && isRemoteEnabled() && !isCloseRequested()) {
101
+ const timer = setTimeout(() => {
102
+ // Drop the chain if remote went away or the writer moved on: re-firing
103
+ // the captured path would rebind forwarding to a stale transcript.
104
+ if (!isRemoteEnabled() || !channelsEnabled()) return;
105
+ if (writer?.transcriptPath !== transcriptPath) return;
106
+ executeTranscriptRebind(transcriptPath, attempt + 1);
107
+ }, 150 * attempt);
108
+ timer.unref?.();
109
+ } else {
110
+ process.stderr.write(`mixdog: channels: rebind_current_transcript failed after ${attempt} attempt(s): ${detail}\n`);
111
+ }
112
+ };
113
+ try {
114
+ void channels.execute('rebind_current_transcript', { transcriptPath }).catch(onError);
115
+ } catch (error) {
116
+ onError(error);
117
+ }
118
+ }
119
+
120
+ // Re-fire a deferred rebind once the writer becomes available; a no-op for
121
+ // already-bound sessions so no unconditional rebind fires per turn.
122
+ function flushPendingTranscriptRebind() {
123
+ if (!pendingRebind || !isRemoteEnabled()) return;
124
+ pushTranscriptRebind();
125
+ }
126
+ }
@@ -0,0 +1,14 @@
1
+ export function createRemoteTransitionQueue() {
2
+ let tail = Promise.resolve();
3
+
4
+ return {
5
+ run(task) {
6
+ const result = tail.then(() => task());
7
+ tail = result.then(() => undefined, () => undefined);
8
+ return result;
9
+ },
10
+ settled() {
11
+ return tail;
12
+ },
13
+ };
14
+ }