mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { recordFetchedMessages } from "./status-snapshot.mjs";
|
|
2
|
+
import {
|
|
3
|
+
discoverSessionBoundTranscript,
|
|
4
|
+
findLatestTranscriptByMtime,
|
|
5
|
+
sameResolvedPath,
|
|
6
|
+
} from "./output-forwarder.mjs";
|
|
7
|
+
import { refreshActiveInstance } from "./runtime-paths.mjs";
|
|
8
|
+
import { isNetworkError, retryOnNetwork } from "./network-retry.mjs";
|
|
9
|
+
// Inbound message pipeline extracted from channels/index.mjs (behavior-
|
|
10
|
+
// preserving): serial inbound queue, backend.onMessage transcript (re)bind +
|
|
11
|
+
// steal logic, and handleInbound voice-transcription + parent notify. Bound to
|
|
12
|
+
// live runtime getters and shared primitives.
|
|
13
|
+
export function createInboundHandler({
|
|
14
|
+
getBackend,
|
|
15
|
+
getConfig,
|
|
16
|
+
getBridgeRuntimeConnected,
|
|
17
|
+
getChannelBridgeActive,
|
|
18
|
+
instanceId,
|
|
19
|
+
forwarder,
|
|
20
|
+
scheduler,
|
|
21
|
+
statusState,
|
|
22
|
+
getBridgeOwnershipSnapshot,
|
|
23
|
+
refreshBridgeOwnershipSafe,
|
|
24
|
+
writeChannelOwner,
|
|
25
|
+
shouldDropDuplicateInbound,
|
|
26
|
+
resolveInboundRoute,
|
|
27
|
+
isVoiceAttachment,
|
|
28
|
+
transcribeVoice,
|
|
29
|
+
getPersistedTranscriptPath,
|
|
30
|
+
sessionIdFromTranscriptPath,
|
|
31
|
+
pickUsableTranscriptPath,
|
|
32
|
+
applyTranscriptBinding,
|
|
33
|
+
rebindTranscriptContext,
|
|
34
|
+
sendNotifyToParent,
|
|
35
|
+
memoryAppendEntry,
|
|
36
|
+
startServerTyping,
|
|
37
|
+
stopServerTyping,
|
|
38
|
+
}) {
|
|
39
|
+
const inboundQueue = (() => {
|
|
40
|
+
let tail = Promise.resolve();
|
|
41
|
+
let _iqDepth = 0;
|
|
42
|
+
const _IQ_MAX_DEPTH = 1000;
|
|
43
|
+
return (fn) => {
|
|
44
|
+
if (_iqDepth >= _IQ_MAX_DEPTH) {
|
|
45
|
+
try { process.stderr.write(`mixdog: inboundQueue overflow (depth=${_iqDepth}), dropping message\n`); } catch {}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
_iqDepth++;
|
|
49
|
+
tail = Promise.resolve(tail).then(fn).catch((err) => {
|
|
50
|
+
try { process.stderr.write(`mixdog: inboundQueue error: ${err && err.message || err}\n`); } catch {}
|
|
51
|
+
}).finally(() => { _iqDepth--; });
|
|
52
|
+
};
|
|
53
|
+
})();
|
|
54
|
+
|
|
55
|
+
getBackend().onMessage = (msg) => {
|
|
56
|
+
const receivedAtMs = Number.isFinite(msg.receivedAtMs) ? msg.receivedAtMs : Date.now();
|
|
57
|
+
const onMessageAtMs = Date.now();
|
|
58
|
+
if (!getBridgeRuntimeConnected() || !getBridgeOwnershipSnapshot().owned) {
|
|
59
|
+
refreshBridgeOwnershipSafe();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!getChannelBridgeActive()) return;
|
|
63
|
+
if (shouldDropDuplicateInbound(msg)) return;
|
|
64
|
+
// No label concept anymore: the channel id IS the identifier.
|
|
65
|
+
recordFetchedMessages(msg.chatId, msg.chatId, [{ id: msg.messageId }], { markRead: true });
|
|
66
|
+
if (!writeChannelOwner(msg.chatId)) return;
|
|
67
|
+
const route = resolveInboundRoute(msg.chatId, msg.parentChatId);
|
|
68
|
+
scheduler.noteActivity();
|
|
69
|
+
startServerTyping(route.targetChatId);
|
|
70
|
+
getBackend().resetSendCount();
|
|
71
|
+
// Pin the prior turn's bound channel before this fire-and-forget flush so the
|
|
72
|
+
// imminent rebind below (which mutates forwarder.channelId synchronously)
|
|
73
|
+
// cannot redirect the previous turn's final output to the new channel.
|
|
74
|
+
const priorForwardChannelId = forwarder.channelId || null;
|
|
75
|
+
void forwarder.forwardFinalText(0, priorForwardChannelId).catch((err) => {
|
|
76
|
+
try { process.stderr.write(`mixdog: forwardFinalText rejection: ${err?.stack || err}\n`); } catch {}
|
|
77
|
+
}).finally(() => forwarder.reset());
|
|
78
|
+
const previousPath = getPersistedTranscriptPath();
|
|
79
|
+
let boundTranscript = null;
|
|
80
|
+
let stoleSelfTranscript = false;
|
|
81
|
+
let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
|
|
82
|
+
let needsStealPoll = false;
|
|
83
|
+
// Reuse the current binding only while it still points at THIS owner's own
|
|
84
|
+
// session. discoverSessionBoundTranscript() now ranks the live parent-chain
|
|
85
|
+
// session (the one that forked this worker and receives injected input)
|
|
86
|
+
// above a more-recently-touched neighbour, so when a co-located session
|
|
87
|
+
// owns the stale binding we steal it back here instead of tailing the wrong
|
|
88
|
+
// transcript for the rest of the process lifetime. Steal whenever the live
|
|
89
|
+
// parent-chain (self) candidate resolves to a different path — even when its
|
|
90
|
+
// transcript is not on disk yet: we keep selfBound.exists=false so the
|
|
91
|
+
// downstream `!boundTranscript?.exists` branch routes through
|
|
92
|
+
// rebindTranscriptContext()'s pending-bind + re-arm poll, which forwards the
|
|
93
|
+
// first assistant reply once the self transcript is created. Marking the
|
|
94
|
+
// stale neighbour path as exists=true here would suppress that rearm and
|
|
95
|
+
// keep tailing the wrong session for the whole turn.
|
|
96
|
+
if (transcriptPath) {
|
|
97
|
+
const selfBound = discoverSessionBoundTranscript();
|
|
98
|
+
const shouldStealBoundTranscript = Boolean(
|
|
99
|
+
selfBound?.transcriptPath &&
|
|
100
|
+
!sameResolvedPath(selfBound.transcriptPath, transcriptPath) &&
|
|
101
|
+
selfBound.active === true &&
|
|
102
|
+
(selfBound.parentChain === true || selfBound.cwdMatches === true)
|
|
103
|
+
);
|
|
104
|
+
if (shouldStealBoundTranscript) {
|
|
105
|
+
process.stderr.write(`mixdog: inbound rebind: stealing transcript ${transcriptPath} -> ${selfBound.transcriptPath} (source=${selfBound.source || "unknown"}, exists=${selfBound.exists})\n`);
|
|
106
|
+
transcriptPath = selfBound.transcriptPath;
|
|
107
|
+
boundTranscript = selfBound;
|
|
108
|
+
stoleSelfTranscript = true;
|
|
109
|
+
} else {
|
|
110
|
+
boundTranscript = {
|
|
111
|
+
sessionId: sessionIdFromTranscriptPath(transcriptPath),
|
|
112
|
+
sessionCwd: statusState.read().sessionCwd ?? null,
|
|
113
|
+
transcriptPath,
|
|
114
|
+
exists: true
|
|
115
|
+
};
|
|
116
|
+
// Fast path skips the poll below (zero added latency) unless we lack a
|
|
117
|
+
// confident, currently-active self-bound candidate — that's the
|
|
118
|
+
// ~ms race window right after activate, before the parent-chain
|
|
119
|
+
// session record is published, where the steal gate above fails on
|
|
120
|
+
// the very first inbound even though a real self session exists.
|
|
121
|
+
if (!selfBound || selfBound.active !== true) needsStealPoll = true;
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
boundTranscript = discoverSessionBoundTranscript();
|
|
125
|
+
transcriptPath = pickUsableTranscriptPath(boundTranscript, previousPath);
|
|
126
|
+
// Only fall back to latest-by-mtime when discovery did NOT produce a
|
|
127
|
+
// confident, existing current-session transcript. detectCurrentSessionTranscript()
|
|
128
|
+
// already weighs mtime (with a 30s decisive threshold) against active-pid /
|
|
129
|
+
// cwd affinity, so overriding a real detected binding with the raw newest
|
|
130
|
+
// file would clobber the current session with an unrelated, more-recently
|
|
131
|
+
// touched transcript (wrong-session forward).
|
|
132
|
+
if (!boundTranscript?.exists) {
|
|
133
|
+
const latestByMtime = findLatestTranscriptByMtime(boundTranscript?.sessionCwd);
|
|
134
|
+
if (latestByMtime && latestByMtime !== transcriptPath) {
|
|
135
|
+
transcriptPath = latestByMtime;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Binding-settled signal: resolves once the queued binding task below
|
|
140
|
+
// (poll-if-needed + apply-bind + rebind) has run, so the react/status
|
|
141
|
+
// IIFE can read the FINAL transcriptPath/boundTranscript instead of the
|
|
142
|
+
// pre-poll snapshot. onMessage itself stays synchronous — nothing here
|
|
143
|
+
// blocks message ordering or delays the enqueue calls.
|
|
144
|
+
let bindingDoneResolve;
|
|
145
|
+
const bindingDone = new Promise((resolve) => { bindingDoneResolve = resolve; });
|
|
146
|
+
const queuedAtMs = Date.now();
|
|
147
|
+
const preQueueMs = queuedAtMs - onMessageAtMs;
|
|
148
|
+
const gatewayToQueueMs = queuedAtMs - receivedAtMs;
|
|
149
|
+
if (preQueueMs > 250 || gatewayToQueueMs > 500) {
|
|
150
|
+
process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
|
|
151
|
+
}
|
|
152
|
+
// ONE queued task per message: binding (poll-if-needed + bind + rebind)
|
|
153
|
+
// first, then handleInbound. Keeping both phases in a single task preserves
|
|
154
|
+
// the queue's per-message depth accounting — the overflow guard drops a
|
|
155
|
+
// whole message, never just its handleInbound half — and guarantees
|
|
156
|
+
// bindingDone/stopServerTyping always settle even when the binding phase
|
|
157
|
+
// throws. FIFO is preserved: inboundQueue chains tasks in call order, so
|
|
158
|
+
// this message's poll delay (if any) defers only its own delivery.
|
|
159
|
+
inboundQueue(async () => {
|
|
160
|
+
try {
|
|
161
|
+
if (needsStealPoll) {
|
|
162
|
+
const POLL_INTERVAL_MS = 50;
|
|
163
|
+
const POLL_TIMEOUT_MS = 500;
|
|
164
|
+
const pollStart = Date.now();
|
|
165
|
+
while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
167
|
+
// fresh: bypass the negative parent-pid cache inside the walk —
|
|
168
|
+
// a transient parent-lookup miss cached just before this poll
|
|
169
|
+
// would otherwise pin every retry to null until its TTL expires,
|
|
170
|
+
// defeating the whole first-inbound recovery window.
|
|
171
|
+
const retryBound = discoverSessionBoundTranscript({ fresh: true });
|
|
172
|
+
const retryShouldSteal = Boolean(
|
|
173
|
+
retryBound?.transcriptPath &&
|
|
174
|
+
!sameResolvedPath(retryBound.transcriptPath, transcriptPath) &&
|
|
175
|
+
retryBound.active === true &&
|
|
176
|
+
(retryBound.parentChain === true || retryBound.cwdMatches === true)
|
|
177
|
+
);
|
|
178
|
+
if (retryShouldSteal) {
|
|
179
|
+
process.stderr.write(`mixdog: inbound rebind (poll +${Date.now() - pollStart}ms): stealing transcript ${transcriptPath} -> ${retryBound.transcriptPath} (source=${retryBound.source || "unknown"}, exists=${retryBound.exists})\n`);
|
|
180
|
+
transcriptPath = retryBound.transcriptPath;
|
|
181
|
+
boundTranscript = retryBound;
|
|
182
|
+
stoleSelfTranscript = true;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (transcriptPath) {
|
|
188
|
+
applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
|
|
189
|
+
} else {
|
|
190
|
+
refreshActiveInstance(instanceId, { channelId: route.targetChatId }, { onlyIfOwned: true });
|
|
191
|
+
}
|
|
192
|
+
if (!boundTranscript?.exists) {
|
|
193
|
+
await rebindTranscriptContext(route.targetChatId, {
|
|
194
|
+
// For a stolen self transcript (not yet on disk) the sync bind above
|
|
195
|
+
// persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
|
|
196
|
+
// setContext resume from offset 0 once the file appears — forwarding
|
|
197
|
+
// the first assistant reply. Relying on replayFromStart instead would
|
|
198
|
+
// race: the discovery loop only sets replayFromStart when it first saw
|
|
199
|
+
// the transcript as PENDING, so a file that already exists on the first
|
|
200
|
+
// loop iteration would bind at EOF and skip the reply. Non-steal keeps
|
|
201
|
+
// the original catch-up-from-cursor behaviour.
|
|
202
|
+
previousPath: transcriptPath,
|
|
203
|
+
catchUp: true,
|
|
204
|
+
catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
|
|
205
|
+
persistStatus: true
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
try { process.stderr.write(`mixdog: inbound binding error: ${err}\n`); } catch {}
|
|
210
|
+
} finally {
|
|
211
|
+
bindingDoneResolve();
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await handleInbound(msg, route, {
|
|
215
|
+
sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
|
|
216
|
+
receivedAtMs,
|
|
217
|
+
queuedAtMs
|
|
218
|
+
});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
process.stderr.write(`mixdog: handleInbound error: ${err}\n`);
|
|
221
|
+
} finally {
|
|
222
|
+
stopServerTyping();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
void (async () => {
|
|
226
|
+
try {
|
|
227
|
+
await getBackend().react(msg.chatId, msg.messageId, "\u{1F914}");
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
await bindingDone;
|
|
231
|
+
statusState.update((state) => {
|
|
232
|
+
state.channelId = route.targetChatId;
|
|
233
|
+
state.userMessageId = msg.messageId;
|
|
234
|
+
state.emoji = "\u{1F914}";
|
|
235
|
+
state.sentCount = 0;
|
|
236
|
+
state.sessionIdle = false;
|
|
237
|
+
if (transcriptPath) state.transcriptPath = transcriptPath;
|
|
238
|
+
else delete state.transcriptPath;
|
|
239
|
+
state.sessionCwd = boundTranscript?.sessionCwd ?? null;
|
|
240
|
+
});
|
|
241
|
+
})();
|
|
242
|
+
};
|
|
243
|
+
async function handleInbound(msg, route, options = {}) {
|
|
244
|
+
const handleStartMs = Date.now();
|
|
245
|
+
let text = msg.text;
|
|
246
|
+
const voiceAtts = msg.attachments.filter((a) => isVoiceAttachment(a.contentType));
|
|
247
|
+
if (voiceAtts.length > 0) {
|
|
248
|
+
if (getConfig().voice?.enabled === false) {
|
|
249
|
+
process.stderr.write(`mixdog: voice.transcription skipped — voice.enabled=false\n`);
|
|
250
|
+
text = text || "[voice message]";
|
|
251
|
+
} else {
|
|
252
|
+
try {
|
|
253
|
+
const files = await retryOnNetwork(
|
|
254
|
+
// Short per-attempt timeout (vs the 180s default) bounds worst-case
|
|
255
|
+
// blockage of the serial inboundQueue on a bad voice message.
|
|
256
|
+
() => getBackend().downloadAttachment(msg.chatId, msg.messageId, { timeoutMs: 20_000 }),
|
|
257
|
+
{ label: "voice.download" }
|
|
258
|
+
);
|
|
259
|
+
// concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
|
|
260
|
+
for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
|
|
261
|
+
const _t0 = Date.now();
|
|
262
|
+
const transcript = await retryOnNetwork(
|
|
263
|
+
() => transcribeVoice(f.path, { attachmentId: f.id }),
|
|
264
|
+
{ label: "voice.transcription" }
|
|
265
|
+
);
|
|
266
|
+
const _elapsed = Date.now() - _t0;
|
|
267
|
+
if (transcript) {
|
|
268
|
+
text = transcript;
|
|
269
|
+
process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
|
|
270
|
+
} else {
|
|
271
|
+
process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
|
|
272
|
+
text = text || "[voice message \u2014 transcription failed]";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
const netFail = isNetworkError(err);
|
|
277
|
+
process.stderr.write(`mixdog: voice.transcription error${netFail ? " (network, retries exhausted)" : ""}: ${err}\n`);
|
|
278
|
+
const marker = netFail
|
|
279
|
+
? "[attachment: voice transcription failed (network)]"
|
|
280
|
+
: `[voice message \u2014 transcription error: ${err?.message || err}]`;
|
|
281
|
+
text = text ? `${text} ${marker}` : marker;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const hasVoiceAtt = voiceAtts.length > 0;
|
|
286
|
+
const attMeta = msg.attachments.length > 0 && !hasVoiceAtt ? {
|
|
287
|
+
attachment_count: String(msg.attachments.length),
|
|
288
|
+
attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
|
|
289
|
+
} : {};
|
|
290
|
+
const messageBody = route.sourceMode === "monitor" && route.sourceLabel ? `[monitor:${route.sourceLabel}] ${text}` : text;
|
|
291
|
+
const notificationMeta = {
|
|
292
|
+
chat_id: route.targetChatId,
|
|
293
|
+
message_id: msg.messageId,
|
|
294
|
+
user: msg.user,
|
|
295
|
+
user_id: msg.userId,
|
|
296
|
+
ts: msg.ts,
|
|
297
|
+
...route.sourceMode === "monitor" ? {
|
|
298
|
+
source_chat_id: route.sourceChatId,
|
|
299
|
+
source_mode: route.sourceMode,
|
|
300
|
+
...route.sourceLabel ? { source_label: route.sourceLabel } : {}
|
|
301
|
+
} : {},
|
|
302
|
+
...attMeta,
|
|
303
|
+
...msg.imagePath ? { image_path: msg.imagePath } : {}
|
|
304
|
+
};
|
|
305
|
+
const notificationContent = messageBody;
|
|
306
|
+
sendNotifyToParent("notifications/claude/channel", {
|
|
307
|
+
content: notificationContent,
|
|
308
|
+
meta: notificationMeta
|
|
309
|
+
});
|
|
310
|
+
const notifiedAtMs = Date.now();
|
|
311
|
+
const receivedAtMs = Number.isFinite(options.receivedAtMs) ? options.receivedAtMs : handleStartMs;
|
|
312
|
+
const queuedAtMs = Number.isFinite(options.queuedAtMs) ? options.queuedAtMs : handleStartMs;
|
|
313
|
+
const queueMs = handleStartMs - queuedAtMs;
|
|
314
|
+
const handleMs = notifiedAtMs - handleStartMs;
|
|
315
|
+
const totalMs = notifiedAtMs - receivedAtMs;
|
|
316
|
+
if (queueMs > 250 || handleMs > 250 || totalMs > 500) {
|
|
317
|
+
process.stderr.write(`mixdog: inbound latency delivered total=${totalMs}ms queue=${queueMs}ms handle=${handleMs}ms channel=${route.targetChatId} attachments=${msg.attachments.length}\n`);
|
|
318
|
+
}
|
|
319
|
+
void memoryAppendEntry({
|
|
320
|
+
ts: msg.ts,
|
|
321
|
+
role: "user",
|
|
322
|
+
content: messageBody,
|
|
323
|
+
sourceRef: `discord:${route.targetChatId}#${msg.messageId}`,
|
|
324
|
+
sessionId: `discord:${route.targetChatId}`,
|
|
325
|
+
cwd: statusState.read().sessionCwd,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
// Discord permission-prompt + interaction/modal routing extracted from
|
|
4
|
+
// channels/index.mjs (behavior-preserving). Installs backend.onInteraction /
|
|
5
|
+
// onModalRequest handlers and the terminal tool-exec signal watcher; returns the
|
|
6
|
+
// pending-permission map + marker refresher for the worker IPC handler.
|
|
7
|
+
export function createInteractionHandlers({
|
|
8
|
+
getBackend,
|
|
9
|
+
getConfig,
|
|
10
|
+
getBridgeRuntimeConnected,
|
|
11
|
+
instanceId,
|
|
12
|
+
getBridgeOwnershipSnapshot,
|
|
13
|
+
refreshBridgeOwnershipSafe,
|
|
14
|
+
pendingSetup,
|
|
15
|
+
buildModalRequestSpec,
|
|
16
|
+
loadProfileConfig,
|
|
17
|
+
getDiscordToken,
|
|
18
|
+
sendNotifyToParent,
|
|
19
|
+
scheduler,
|
|
20
|
+
controlClaudeSession,
|
|
21
|
+
writeTextFile,
|
|
22
|
+
TURN_END_FILE,
|
|
23
|
+
getPermissionResultPath,
|
|
24
|
+
TERMINAL_LEAD_PID,
|
|
25
|
+
localTimestamp,
|
|
26
|
+
isMixdogDebug,
|
|
27
|
+
appendSessionStartCriticalLog,
|
|
28
|
+
DATA_DIR,
|
|
29
|
+
_bootLog,
|
|
30
|
+
RUNTIME_ROOT,
|
|
31
|
+
}) {
|
|
32
|
+
function editDiscordMessage(channelId, messageId, label) {
|
|
33
|
+
// Behavior-preserving: route through the getBackend() abstraction (which uses
|
|
34
|
+
// discord.js under the hood) instead of issuing a raw REST PATCH. Errors
|
|
35
|
+
// are swallowed to stderr to match the prior fire-and-forget shape — the
|
|
36
|
+
// call site never awaited the HTTPS request either.
|
|
37
|
+
if (!getDiscordToken()) return;
|
|
38
|
+
const text = `\u{1F510} **Permission Request** \u2014 ${label}`;
|
|
39
|
+
void getBackend().editMessage(channelId, messageId, text, { components: [] }).catch((err) => {
|
|
40
|
+
process.stderr.write(`mixdog: editDiscordMessage failed: ${err}
|
|
41
|
+
`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
getBackend().onModalRequest = async (rawInteraction) => {
|
|
45
|
+
if (!getBridgeRuntimeConnected() || !getBridgeOwnershipSnapshot().owned) {
|
|
46
|
+
refreshBridgeOwnershipSafe();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = await import("discord.js");
|
|
50
|
+
const customId = rawInteraction.customId;
|
|
51
|
+
const channelId = rawInteraction.channelId ?? "";
|
|
52
|
+
pendingSetup.rememberMessage(rawInteraction.user.id, channelId, rawInteraction.message?.id);
|
|
53
|
+
const modalSpec = buildModalRequestSpec(
|
|
54
|
+
customId,
|
|
55
|
+
pendingSetup.get(rawInteraction.user.id, channelId),
|
|
56
|
+
loadProfileConfig()
|
|
57
|
+
);
|
|
58
|
+
if (!modalSpec) return;
|
|
59
|
+
const modal = new ModalBuilder().setCustomId(modalSpec.customId).setTitle(modalSpec.title);
|
|
60
|
+
const rows = modalSpec.fields.map(
|
|
61
|
+
(field) => new ActionRowBuilder().addComponents((() => {
|
|
62
|
+
const input = new TextInputBuilder().setCustomId(field.id).setLabel(field.label).setStyle(TextInputStyle.Short).setRequired(field.required);
|
|
63
|
+
if (field.value) input.setValue(field.value);
|
|
64
|
+
return input;
|
|
65
|
+
})())
|
|
66
|
+
);
|
|
67
|
+
modal.addComponents(...rows);
|
|
68
|
+
await rawInteraction.showModal(modal);
|
|
69
|
+
};
|
|
70
|
+
const pendingPermRequests = new Map();
|
|
71
|
+
const TOOL_EXEC_CONSUMER_MARKER = path.join(RUNTIME_ROOT, '.tool-exec-consumer');
|
|
72
|
+
function refreshToolExecConsumerMarker() {
|
|
73
|
+
try {
|
|
74
|
+
if (pendingPermRequests.size > 0) {
|
|
75
|
+
fs.writeFileSync(TOOL_EXEC_CONSUMER_MARKER, String(Date.now()));
|
|
76
|
+
} else {
|
|
77
|
+
try { fs.unlinkSync(TOOL_EXEC_CONSUMER_MARKER); } catch {}
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
// Watch for terminal-approved tool executions. The PostToolUse hook writes a
|
|
82
|
+
// signal file per tool call; when we see one, find the oldest pending perm
|
|
83
|
+
// request with a matching tool name and mark its Discord message as
|
|
84
|
+
// "Allowed (terminal)" so users don't see stale active buttons.
|
|
85
|
+
try {
|
|
86
|
+
try { if (!fs.existsSync(RUNTIME_ROOT)) fs.mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
|
|
87
|
+
const SIGNAL_RE = /^tool-exec-\d+-[0-9a-f]+\.signal$/;
|
|
88
|
+
fs.watch(RUNTIME_ROOT, { persistent: false }, (eventType, filename) => {
|
|
89
|
+
if (!filename || !SIGNAL_RE.test(filename)) return;
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
try {
|
|
92
|
+
const signalPath = path.join(RUNTIME_ROOT, filename);
|
|
93
|
+
let raw;
|
|
94
|
+
try { raw = fs.readFileSync(signalPath, 'utf8'); } catch { return; }
|
|
95
|
+
let payload;
|
|
96
|
+
try { payload = JSON.parse(raw); } catch { return; }
|
|
97
|
+
const toolName = payload?.toolName;
|
|
98
|
+
if (!toolName) return;
|
|
99
|
+
const sigFilePath = payload?.filePath || '';
|
|
100
|
+
let oldestKey = null;
|
|
101
|
+
let oldestEntry = null;
|
|
102
|
+
for (const [k, v] of pendingPermRequests) {
|
|
103
|
+
if (v.toolName !== toolName) continue;
|
|
104
|
+
// Bind on filePath too. If both sides are empty (non-file tools
|
|
105
|
+
// like Bash), toolName alone is the match. Otherwise both must
|
|
106
|
+
// equal — prevents two concurrent Edit/Write requests from
|
|
107
|
+
// cross-approving each other.
|
|
108
|
+
const vFilePath = v.filePath || '';
|
|
109
|
+
if (vFilePath || sigFilePath) {
|
|
110
|
+
if (vFilePath !== sigFilePath) continue;
|
|
111
|
+
}
|
|
112
|
+
if (!oldestEntry || v.createdAt < oldestEntry.createdAt) {
|
|
113
|
+
oldestKey = k;
|
|
114
|
+
oldestEntry = v;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// No matching pending request — leave the signal on disk so a
|
|
118
|
+
// agent role hook (or other consumer) gets a chance to claim it.
|
|
119
|
+
if (!oldestKey || !oldestEntry) return;
|
|
120
|
+
if (oldestEntry.channelId && oldestEntry.messageId) {
|
|
121
|
+
try {
|
|
122
|
+
editDiscordMessage(oldestEntry.channelId, oldestEntry.messageId, 'Allowed (terminal)');
|
|
123
|
+
} catch (err) {
|
|
124
|
+
try { process.stderr.write(`mixdog channels: tool-exec signal edit failed: ${err && err.message || err}\n`); } catch {}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
pendingPermRequests.delete(oldestKey);
|
|
128
|
+
refreshToolExecConsumerMarker();
|
|
129
|
+
// Only unlink once we've confirmed the match and handled it.
|
|
130
|
+
try { fs.unlinkSync(signalPath); } catch {}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
try { process.stderr.write(`mixdog channels: tool-exec signal handler error: ${err && err.message || err}\n`); } catch {}
|
|
133
|
+
}
|
|
134
|
+
}, 50);
|
|
135
|
+
});
|
|
136
|
+
// Stale-signal sweeper: any signal file older than 60s is removed so
|
|
137
|
+
// unclaimed files don't accumulate on disk. Runs every 30s.
|
|
138
|
+
setInterval(() => {
|
|
139
|
+
try {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const entries = fs.readdirSync(RUNTIME_ROOT);
|
|
142
|
+
for (const name of entries) {
|
|
143
|
+
if (!SIGNAL_RE.test(name)) continue;
|
|
144
|
+
const p = path.join(RUNTIME_ROOT, name);
|
|
145
|
+
try {
|
|
146
|
+
const st = fs.statSync(p);
|
|
147
|
+
if (now - st.mtimeMs > 60_000) {
|
|
148
|
+
try { fs.unlinkSync(p); } catch {}
|
|
149
|
+
}
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
}, 30_000)?.unref?.();
|
|
154
|
+
} catch (err) {
|
|
155
|
+
try { process.stderr.write(`mixdog channels: tool-exec signal watcher setup failed: ${err && err.message || err}\n`); } catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getBackend().onInteraction = (interaction) => {
|
|
159
|
+
// Channel-route permission reply. Custom_id format: perm-ch-{request_id}-{allow|session|deny}.
|
|
160
|
+
// request_id is the 5-letter short ID CC generates via shortRequestId().
|
|
161
|
+
// Emit notifications/claude/channel/permission back to the MCP host; the race
|
|
162
|
+
// logic in interactiveHandler.ts resolves the pending request and dismisses
|
|
163
|
+
// every other racer (local dialog, bridge, hook, classifier).
|
|
164
|
+
if (interaction.customId?.startsWith("perm-ch-")) {
|
|
165
|
+
const match = interaction.customId.match(/^perm-ch-([a-km-z]{5})-(allow|session|deny)$/);
|
|
166
|
+
if (!match) return;
|
|
167
|
+
const [, requestId, action] = match;
|
|
168
|
+
const access = getConfig().access;
|
|
169
|
+
if (access?.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
|
|
170
|
+
process.stderr.write(`mixdog: perm-ch button rejected — user ${interaction.userId} not in allowFrom\n`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const pending = pendingPermRequests.get(requestId);
|
|
174
|
+
pendingPermRequests.delete(requestId);
|
|
175
|
+
refreshToolExecConsumerMarker();
|
|
176
|
+
const params = { request_id: requestId };
|
|
177
|
+
if (action === 'deny') {
|
|
178
|
+
params.behavior = 'deny';
|
|
179
|
+
} else if (action === 'session') {
|
|
180
|
+
params.behavior = 'allow';
|
|
181
|
+
const toolName = pending?.toolName;
|
|
182
|
+
if (toolName) {
|
|
183
|
+
params.updatedPermissions = [{ type: 'addRules', rules: [{ toolName }], behavior: 'allow', destination: 'session' }];
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
params.behavior = 'allow';
|
|
187
|
+
}
|
|
188
|
+
sendNotifyToParent('notifications/claude/channel/permission', params);
|
|
189
|
+
const labels = { allow: 'Approved', session: 'Session Approved', deny: 'Denied' };
|
|
190
|
+
if (interaction.message?.id && interaction.channelId) {
|
|
191
|
+
editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (interaction.customId?.startsWith("perm-")) {
|
|
196
|
+
const match = interaction.customId.match(/^perm-([0-9a-f]{32})-(allow|session|deny)$/);
|
|
197
|
+
if (!match) return;
|
|
198
|
+
const [, uuid, action] = match;
|
|
199
|
+
const access = getConfig().access;
|
|
200
|
+
if (!access) {
|
|
201
|
+
const _permDropLine = `[${localTimestamp()}] perm interaction dropped: no access getConfig()\n`;
|
|
202
|
+
if (isMixdogDebug()) {
|
|
203
|
+
fs.appendFileSync(_bootLog, _permDropLine);
|
|
204
|
+
} else {
|
|
205
|
+
appendSessionStartCriticalLog(DATA_DIR, `[channels] ${_permDropLine}`);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (access.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
|
|
210
|
+
process.stderr.write(`mixdog: perm button rejected \u2014 user ${interaction.userId} not in allowFrom
|
|
211
|
+
`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const resultPaths = [getPermissionResultPath(instanceId, uuid)];
|
|
215
|
+
const leadInstanceId = String(TERMINAL_LEAD_PID);
|
|
216
|
+
if (leadInstanceId && leadInstanceId !== instanceId) {
|
|
217
|
+
resultPaths.push(getPermissionResultPath(leadInstanceId, uuid));
|
|
218
|
+
}
|
|
219
|
+
for (const resultPath of resultPaths) {
|
|
220
|
+
try {
|
|
221
|
+
fs.writeFileSync(resultPath, action, { flag: "wx" });
|
|
222
|
+
} catch (e) {
|
|
223
|
+
if (e.code !== "EEXIST") {
|
|
224
|
+
process.stderr.write(`mixdog: writePermissionResult failed: ${e.message}\n`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const labels = { allow: "Approved", session: "Session Approved", deny: "Denied" };
|
|
229
|
+
if (interaction.message?.id && interaction.channelId) {
|
|
230
|
+
editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!getBridgeRuntimeConnected() || !getBridgeOwnershipSnapshot().owned) {
|
|
235
|
+
refreshBridgeOwnershipSafe();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
scheduler.noteActivity();
|
|
239
|
+
if (interaction.customId === "stop_task") {
|
|
240
|
+
controlClaudeSession(instanceId, { type: "interrupt" })
|
|
241
|
+
.catch(err => process.stderr.write(`[channels] controlClaudeSession rejected: ${err?.message || err}\n`));
|
|
242
|
+
writeTextFile(TURN_END_FILE, String(Date.now()));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
sendNotifyToParent("notifications/claude/channel", {
|
|
246
|
+
content: `[interaction] ${interaction.type}: ${interaction.customId}${interaction.values ? " values=" + interaction.values.join(",") : ""}`,
|
|
247
|
+
meta: {
|
|
248
|
+
chat_id: interaction.channelId,
|
|
249
|
+
user: `interaction:${interaction.type}`,
|
|
250
|
+
user_id: interaction.userId,
|
|
251
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
252
|
+
interaction_type: interaction.type,
|
|
253
|
+
custom_id: interaction.customId,
|
|
254
|
+
...interaction.values ? { values: interaction.values.join(",") } : {},
|
|
255
|
+
...interaction.message ? { message_id: interaction.message.id } : {}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
return { pendingPermRequests, refreshToolExecConsumerMarker };
|
|
260
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Network-error detection + retry helper extracted from channels/index.mjs
|
|
2
|
+
// (behavior-preserving).
|
|
3
|
+
const NETWORK_ERR_RE = /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network|timeout|aborted|TimeoutError/i;
|
|
4
|
+
function isNetworkError(err) {
|
|
5
|
+
const s = `${err?.code ?? ""} ${err?.name ?? ""} ${err?.message ?? err ?? ""}`;
|
|
6
|
+
return NETWORK_ERR_RE.test(s);
|
|
7
|
+
}
|
|
8
|
+
async function retryOnNetwork(fn, { attempts = 3, baseDelayMs = 300, label = "op" } = {}) {
|
|
9
|
+
let lastErr;
|
|
10
|
+
for (let i = 0; i < attempts; i++) {
|
|
11
|
+
try {
|
|
12
|
+
return await fn();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
lastErr = err;
|
|
15
|
+
if (i === attempts - 1 || !isNetworkError(err)) throw err;
|
|
16
|
+
const delay = baseDelayMs * (i + 1);
|
|
17
|
+
process.stderr.write(`mixdog: ${label} network failure (attempt ${i + 1}/${attempts}), retrying in ${delay}ms: ${err?.message || err}\n`);
|
|
18
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw lastErr;
|
|
22
|
+
}
|
|
23
|
+
export { isNetworkError, retryOnNetwork };
|