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.
- package/package.json +8 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
- package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
- package/src/runtime/channels/lib/owned-runtime.mjs +8 -1
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
- package/src/runtime/channels/lib/webhook.mjs +13 -1
- package/src/runtime/media/adapters/codex-image.mjs +109 -0
- package/src/runtime/media/adapters/gemini-image.mjs +68 -0
- package/src/runtime/media/adapters/gemini-video.mjs +119 -0
- package/src/runtime/media/adapters/xai-media.mjs +116 -0
- package/src/runtime/media/auth.mjs +51 -0
- package/src/runtime/media/index.mjs +17 -0
- package/src/runtime/media/jobs.mjs +174 -0
- package/src/runtime/media/lanes.mjs +230 -0
- package/src/runtime/media/store.mjs +164 -0
- package/src/runtime/media/upstream-error.mjs +39 -0
- package/src/session-runtime/channel-config-api.mjs +12 -1
- package/src/session-runtime/media-api.mjs +47 -0
- package/src/session-runtime/memory-daemon-probe.mjs +61 -0
- package/src/session-runtime/model-capabilities.mjs +6 -4
- package/src/session-runtime/model-route-api.mjs +4 -1
- package/src/session-runtime/notification-bus.mjs +82 -0
- package/src/session-runtime/provider-auth-api.mjs +16 -1
- package/src/session-runtime/provider-usage.mjs +3 -0
- package/src/session-runtime/remote-control.mjs +166 -0
- package/src/session-runtime/remote-transcript.mjs +126 -0
- package/src/session-runtime/remote-transition-queue.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +160 -706
- package/src/session-runtime/runtime-tunables.mjs +57 -0
- package/src/session-runtime/self-update.mjs +129 -0
- package/src/session-runtime/skills-api.mjs +77 -0
- package/src/session-runtime/tool-surface.mjs +83 -0
- package/src/session-runtime/warmup-schedulers.mjs +14 -1
- package/src/session-runtime/workflow-agents-api.mjs +235 -7
- package/src/session-runtime/workflow.mjs +84 -17
- package/src/standalone/agent-tool/worker-index.mjs +287 -0
- package/src/standalone/agent-tool.mjs +22 -346
- package/src/standalone/agent-watchdog-registry.mjs +101 -0
- package/src/standalone/channel-worker.mjs +7 -13
- package/src/tui/components/StatusLine.jsx +6 -5
- package/src/tui/dist/index.mjs +144 -95
- package/src/tui/engine/labels.mjs +0 -8
- package/src/tui/engine/session-api-ext.mjs +31 -8
- package/src/tui/engine/turn-watchdog.mjs +92 -0
- package/src/tui/engine/turn.mjs +18 -70
- package/src/tui/engine.mjs +39 -1
- package/src/workflows/default/WORKFLOW.md +1 -1
- 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
|
-
//
|
|
122
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|