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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. 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 };