mixdog 0.9.17 → 0.9.19

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 (129) hide show
  1. package/package.json +3 -2
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/output-style-smoke.mjs +2 -2
  4. package/scripts/recall-bench-cases.json +11 -0
  5. package/scripts/recall-bench.mjs +91 -2
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/tool-efficiency-diag.mjs +5 -2
  9. package/scripts/tool-smoke.mjs +101 -27
  10. package/src/agents/debugger/AGENT.md +3 -3
  11. package/src/agents/heavy-worker/AGENT.md +7 -10
  12. package/src/agents/maintainer/AGENT.md +1 -2
  13. package/src/agents/reviewer/AGENT.md +1 -2
  14. package/src/agents/worker/AGENT.md +5 -8
  15. package/src/defaults/agents.json +3 -0
  16. package/src/mixdog-session-runtime.mjs +23 -6
  17. package/src/rules/agent/00-core.md +4 -3
  18. package/src/rules/agent/30-explorer.md +53 -22
  19. package/src/rules/lead/02-channels.md +3 -3
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +24 -29
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  25. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  26. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  28. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  32. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  33. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  34. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  35. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  36. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  37. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  39. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  40. package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
  41. package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
  42. package/src/runtime/channels/backends/discord.mjs +139 -7
  43. package/src/runtime/channels/index.mjs +290 -76
  44. package/src/runtime/channels/lib/crash-log.mjs +21 -3
  45. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  46. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  47. package/src/runtime/channels/lib/output-forwarder.mjs +156 -14
  48. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  49. package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
  50. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  51. package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
  52. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  53. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  54. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  55. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  56. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  57. package/src/runtime/shared/atomic-file.mjs +10 -4
  58. package/src/runtime/shared/background-tasks.mjs +4 -2
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  61. package/src/runtime/shared/tool-surface.mjs +30 -1
  62. package/src/session-runtime/config-lifecycle.mjs +1 -1
  63. package/src/session-runtime/cwd-plugins.mjs +46 -3
  64. package/src/session-runtime/mcp-glue.mjs +24 -3
  65. package/src/session-runtime/output-styles.mjs +44 -10
  66. package/src/session-runtime/workflow.mjs +16 -1
  67. package/src/standalone/channel-worker.mjs +88 -7
  68. package/src/standalone/explore-tool.mjs +1 -1
  69. package/src/tui/App.jsx +57 -77
  70. package/src/tui/app/channel-pickers.mjs +45 -0
  71. package/src/tui/app/slash-commands.mjs +0 -1
  72. package/src/tui/app/slash-dispatch.mjs +0 -16
  73. package/src/tui/app/transcript-window.mjs +66 -1
  74. package/src/tui/app/use-mouse-input.mjs +9 -2
  75. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  76. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  77. package/src/tui/app/use-transcript-window.mjs +74 -8
  78. package/src/tui/components/PromptInput.jsx +33 -64
  79. package/src/tui/components/ToolExecution.jsx +2 -2
  80. package/src/tui/dist/index.mjs +4744 -4806
  81. package/src/tui/engine.mjs +109 -20
  82. package/src/tui/lib/voice-setup.mjs +166 -0
  83. package/src/tui/paste-attachments.mjs +12 -5
  84. package/src/tui/prompt-history-store.mjs +125 -12
  85. package/scripts/bench/cache-probe-tasks.json +0 -8
  86. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  87. package/scripts/bench/lead-review-tasks.json +0 -20
  88. package/scripts/bench/r4-mixed-tasks.json +0 -20
  89. package/scripts/bench/r5-orchestrated-task.json +0 -7
  90. package/scripts/bench/review-tasks.json +0 -20
  91. package/scripts/bench/round-codex.json +0 -114
  92. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  93. package/scripts/bench/round-mixdog-lead.json +0 -269
  94. package/scripts/bench/round-mixdog.json +0 -126
  95. package/scripts/bench/round-r10-bigsample.json +0 -679
  96. package/scripts/bench/round-r11-codexalign.json +0 -257
  97. package/scripts/bench/round-r13-clientmeta.json +0 -464
  98. package/scripts/bench/round-r14-betafeatures.json +0 -466
  99. package/scripts/bench/round-r15-fulldefault.json +0 -462
  100. package/scripts/bench/round-r16-sessionid.json +0 -466
  101. package/scripts/bench/round-r17-wirebytes.json +0 -456
  102. package/scripts/bench/round-r18-prewarm.json +0 -468
  103. package/scripts/bench/round-r19-clean.json +0 -472
  104. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  105. package/scripts/bench/round-r21-delta-retry.json +0 -473
  106. package/scripts/bench/round-r22-full-probe.json +0 -693
  107. package/scripts/bench/round-r23-itemprobe.json +0 -701
  108. package/scripts/bench/round-r24-shapefix.json +0 -677
  109. package/scripts/bench/round-r25-serial.json +0 -464
  110. package/scripts/bench/round-r26-parallel3.json +0 -671
  111. package/scripts/bench/round-r27-parallel10.json +0 -894
  112. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  113. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  114. package/scripts/bench/round-r30-instid.json +0 -253
  115. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  116. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  117. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  118. package/scripts/bench/round-r34-orchestrated.json +0 -120
  119. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  120. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  121. package/scripts/bench/round-r4-codex.json +0 -114
  122. package/scripts/bench/round-r4-mixed.json +0 -225
  123. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  124. package/scripts/bench/round-r6-codex.json +0 -114
  125. package/scripts/bench/round-r6-solo.json +0 -257
  126. package/scripts/bench/round-r7-full.json +0 -254
  127. package/scripts/bench/round-r8-fulldefault.json +0 -255
  128. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  129. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -205,20 +205,43 @@ function registerShardListeners(self) {
205
205
 
206
206
  async function awaitLogin(self) {
207
207
  let readyTimeout;
208
+ // Adaptive ready timeout: grow on consecutive login failures (30s→60s→90s
209
+ // cap) so an identify delayed by gateway rate-limiting can still complete
210
+ // its READY handshake instead of tripping a fixed 30s timeout every retry.
211
+ const readyMs = Math.min(90_000, 30_000 + (self._loginFailures || 0) * 30_000);
208
212
  const readyPromise = new Promise((resolve, reject) => {
209
- readyTimeout = setTimeout(() => reject(new Error("discord ready timeout (30s)")), 3e4);
213
+ readyTimeout = setTimeout(
214
+ () => reject(new Error(`discord ready timeout (${Math.round(readyMs / 1000)}s)`)),
215
+ readyMs
216
+ );
210
217
  self.client.once(self._discordEvents().ClientReady, () => {
211
218
  clearTimeout(readyTimeout);
212
219
  resolve();
213
220
  });
214
221
  });
222
+ // Guarantee the ready-timeout rejection can never surface as a process-global
223
+ // unhandledRejection regardless of how the race below settles: attach an
224
+ // absorbing handler; the real handling still happens via the awaits.
225
+ readyPromise.catch(() => {});
226
+ // Observe the ready timer from the start: login() can hang past the 30s
227
+ // timer (gateway reconnect), and a rejection on an un-awaited readyPromise
228
+ // becomes a process-global unhandledRejection → fatal crash path
229
+ // (channels/index.mjs unhandledRejection → _fatalCrash → exit(1)). Racing
230
+ // login with readyPromise keeps the rejection inside this connect() chain,
231
+ // which every caller already catches non-fatally.
232
+ const loginPromise = self.client.login(self.token);
233
+ // A late login settlement after the timeout raced ahead must not itself
234
+ // become a second unhandled rejection.
235
+ loginPromise.catch(() => {});
215
236
  try {
216
- await self.client.login(self.token);
237
+ await Promise.race([loginPromise, readyPromise]);
238
+ await readyPromise;
239
+ self._loginFailures = 0;
217
240
  } catch (err) {
241
+ self._loginFailures = (self._loginFailures || 0) + 1;
218
242
  clearTimeout(readyTimeout);
219
243
  throw err;
220
244
  }
221
- await readyPromise;
222
245
  }
223
246
 
224
247
  export {
@@ -23,6 +23,20 @@ import { normalizeAccess, safeAttName } from "./discord-access.mjs";
23
23
  import { MAX_ATTACHMENT_BYTES, downloadSingleAttachment } from "./discord-attachments.mjs";
24
24
  import * as gateway from "./discord-gateway.mjs";
25
25
  const MAX_CHUNK_LIMIT = MAX_DISCORD_MESSAGE;
26
+ // Per-attempt cap for a single discord.js fetch/send. Without it a wedged
27
+ // socket (network blip, "other side closed") leaves the await pending forever,
28
+ // which upstream leaves the forwarder's `sending` flag stuck and silences all
29
+ // outbound. On timeout we treat the client as dead and reset+reconnect it.
30
+ const SEND_ATTEMPT_TIMEOUT_MS = 30_000;
31
+ // Reconnect backoff: consecutive _resetClient failures back off exponentially
32
+ // with jitter up to a cap so a wedged/rate-limited gateway is not hammered with
33
+ // fixed sub-minute destroy()/login() cycles (that fixed cadence triggered
34
+ // Discord identify rate-limiting → the ready-timeout death loop).
35
+ const RESET_BACKOFF_BASE_MS = 2_000;
36
+ const RESET_BACKOFF_CAP_MS = 60_000;
37
+ // After a timed-out send is reset, cap how long we wait for the original
38
+ // (abandoned) promise to actually settle before giving up with unknown outcome.
39
+ const SETTLE_CAP_MS = 15_000;
26
40
  const RECENT_SENT_CAP = 200;
27
41
  class DiscordBackend {
28
42
  name = "discord";
@@ -41,7 +55,12 @@ class DiscordBackend {
41
55
  initialAccess;
42
56
  recentSentIds = /* @__PURE__ */ new Set();
43
57
  sendCount = 0;
58
+ _resetFailures = 0;
59
+ _loginFailures = 0;
44
60
  typingIntervals = /* @__PURE__ */ new Map();
61
+ // In-flight sendMessage() promises, awaited by drainPendingSends() before a
62
+ // handoff disconnect so a reply is not cut off mid-delivery.
63
+ _pendingSends = /* @__PURE__ */ new Set();
45
64
  constructor(config, stateDir) {
46
65
  this.token = config.token;
47
66
  this.mainChannelId = config.mainChannelId ?? "";
@@ -74,11 +93,70 @@ class DiscordBackend {
74
93
  if (this.client) this.client.destroy();
75
94
  this._connectPromise = null;
76
95
  }
96
+ // ── Self-heal helpers ──────────────────────────────────────────────
97
+ /** Race a discord.js op-promise against a per-attempt timeout so a wedged
98
+ * socket can't hang the outbound path. On timeout the rejection carries
99
+ * `_timeout` so the caller knows the underlying op is still in flight (and
100
+ * must be settled/adopted, not blindly retried → duplicate). */
101
+ _withTimeout(promise, label) {
102
+ let timer;
103
+ const timeout = new Promise((_, reject) => {
104
+ timer = setTimeout(() => {
105
+ const e = new Error(`${label} timed out after ${SEND_ATTEMPT_TIMEOUT_MS}ms`);
106
+ e._timeout = true;
107
+ reject(e);
108
+ }, SEND_ATTEMPT_TIMEOUT_MS);
109
+ });
110
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
111
+ }
112
+ /** True when an error means the client is dead (aborted, "other side
113
+ * closed", or our per-attempt timeout) and must be reset+reconnected. */
114
+ _shouldResetClient(err) {
115
+ if (err?.name === "AbortError" || err?.code === "ABORT_ERR") return true;
116
+ const msg = String(err?.message || err || "").toLowerCase();
117
+ return msg.includes("aborted") || msg.includes("other side closed") ||
118
+ msg.includes("socket hang up") || msg.includes("econnreset") || msg.includes("timed out");
119
+ }
120
+ /** Destroy the wedged client and rebuild it via connect() so the next
121
+ * fetch/send (and the forwarder's queue-head-preserving retry) hits a fresh
122
+ * gateway/REST client. Serialized behind a single in-flight reset so
123
+ * concurrent callers await the SAME reconnect instead of racing multiple
124
+ * destroy()/login() cycles. */
125
+ async _resetClient() {
126
+ if (this._resetPromise) return this._resetPromise;
127
+ this._resetPromise = (async () => {
128
+ // Back off before rebuilding: on repeated reset failures wait an
129
+ // exponentially growing (jittered) delay capped at RESET_BACKOFF_CAP_MS
130
+ // so we never re-identify in a tight loop.
131
+ const n = this._resetFailures || 0;
132
+ if (n > 0) {
133
+ const cap = Math.min(RESET_BACKOFF_CAP_MS, RESET_BACKOFF_BASE_MS * 2 ** (n - 1));
134
+ const delay = Math.floor(cap / 2 + Math.random() * (cap / 2));
135
+ await new Promise((r) => setTimeout(r, delay));
136
+ }
137
+ try { this.client?.destroy?.(); } catch {}
138
+ this.client = null;
139
+ this._connectPromise = null;
140
+ await this.connect();
141
+ this._resetFailures = 0;
142
+ })().catch((err) => {
143
+ this._resetFailures = (this._resetFailures || 0) + 1;
144
+ throw err;
145
+ }).finally(() => { this._resetPromise = null; });
146
+ return this._resetPromise;
147
+ }
148
+ /** Health-check the gateway; reset+reconnect if the ws is not READY. Called
149
+ * by the owner re-claim self-heal path. (discord.js Status.Ready === 0) */
150
+ async healthCheck() {
151
+ if (this.client && this.client.ws?.status === 0) return;
152
+ await this._resetClient();
153
+ }
77
154
  resetSendCount() {
78
155
  this.sendCount = 0;
79
156
  }
80
157
  startTyping(channelId) {
81
158
  this.stopTyping(channelId);
159
+ if (!this.client) return;
82
160
  const ch = this.client.channels.cache.get(channelId);
83
161
  if (ch && "sendTyping" in ch) {
84
162
  void ch.sendTyping().catch(() => {
@@ -100,7 +178,25 @@ class DiscordBackend {
100
178
  }
101
179
  }
102
180
  // ── Outbound operations ────────────────────────────────────────────
181
+ // Public entry: tracks the send so drainPendingSends() can await in-flight
182
+ // deliveries before an ownership-handoff disconnect. Delegates to the impl.
103
183
  async sendMessage(chatId, text, opts) {
184
+ const p = this._sendMessageImpl(chatId, text, opts);
185
+ const tracked = Promise.resolve(p).catch(() => {});
186
+ this._pendingSends.add(tracked);
187
+ tracked.finally(() => this._pendingSends.delete(tracked));
188
+ return p;
189
+ }
190
+ /** Await in-flight sends (bounded) so a handoff drains outbound before
191
+ * disconnect. Bounded by timeoutMs so a wedged send can't stall teardown. */
192
+ async drainPendingSends(timeoutMs = 4000) {
193
+ if (this._pendingSends.size === 0) return;
194
+ await Promise.race([
195
+ Promise.allSettled([...this._pendingSends]),
196
+ new Promise((r) => setTimeout(r, timeoutMs)),
197
+ ]);
198
+ }
199
+ async _sendMessageImpl(chatId, text, opts) {
104
200
  const ch = await this.fetchAllowedChannel(chatId);
105
201
  if (!("send" in ch)) throw new Error("channel is not sendable");
106
202
  const files = opts?.files ?? [];
@@ -172,13 +268,38 @@ class DiscordBackend {
172
268
  // resumes at this chunk index instead of re-sending 0..i-1.
173
269
  let attempt = 0;
174
270
  for (;;) {
271
+ const sendPromise = ch.send(payload);
175
272
  try {
176
- const sent = await ch.send(payload);
273
+ const sent = await this._withTimeout(sendPromise, "discord send");
177
274
  this.noteSent(sent.id);
178
275
  sentIds.push(sent.id);
179
276
  break;
180
277
  } catch (err) {
181
278
  attempt++;
279
+ if (err?._timeout) {
280
+ // Watchdog abandoned the send but the socket may still deliver.
281
+ // Destroy the client to tear down the in-flight REST socket so the
282
+ // ORIGINAL promise settles, then AWAIT + adopt its real outcome:
283
+ // delivered → done (no retry → no duplicate); else requeue against
284
+ // the fresh client via a resume token.
285
+ try { await this._resetClient(); } catch {}
286
+ // Cap the settlement wait: destroy() should abort the in-flight
287
+ // socket, but never hang here if it doesn't. On unknown outcome
288
+ // throw retryable (duplicate risk accepted over a permanent hang).
289
+ sendPromise.catch(() => {}); // swallow a late rejection
290
+ const settled = await Promise.race([
291
+ sendPromise.then((v) => ({ ok: true, v }), () => ({ ok: false })),
292
+ new Promise((r) => setTimeout(() => r({ ok: false }), SETTLE_CAP_MS))
293
+ ]);
294
+ if (settled.ok) {
295
+ this.noteSent(settled.v.id);
296
+ sentIds.push(settled.v.id);
297
+ break;
298
+ }
299
+ const e = err instanceof Error ? err : new Error(String(err));
300
+ e.resumeToken = { hash: contentHash, nextChunkIdx: i, sentIds: [...sentIds], prefixed: applyPrefix, limit };
301
+ throw e;
302
+ }
182
303
  const status = err?.status ?? err?.code ?? err?.httpStatus;
183
304
  // Classify: PERMANENT = a 4xx client error (unknown channel/message,
184
305
  // missing access/permissions, 404 …) that will never succeed on
@@ -192,7 +313,15 @@ class DiscordBackend {
192
313
  e.permanent = true;
193
314
  throw e;
194
315
  }
195
- if (attempt >= 3) {
316
+ // Dead client (abort / "other side closed" / per-attempt timeout):
317
+ // destroy + reconnect so the forwarder's transient-retry path hits a
318
+ // fresh client, and throw a resume token now instead of burning the
319
+ // remaining attempts on a stale channel handle bound to the old client.
320
+ const dead = this._shouldResetClient(err);
321
+ if (dead) {
322
+ try { await this._resetClient(); } catch {}
323
+ }
324
+ if (dead || attempt >= 3) {
196
325
  // Attach an opaque resume token pointing at the chunk that failed
197
326
  // (i). sentIds holds every chunk already delivered (including any
198
327
  // seeded from a prior token).
@@ -301,13 +430,13 @@ class DiscordBackend {
301
430
  const msg = await ch.messages.fetch(messageId);
302
431
  await msg.delete();
303
432
  }
304
- async downloadAttachment(chatId, messageId) {
433
+ async downloadAttachment(chatId, messageId, opts = {}) {
305
434
  const ch = await this.fetchAllowedChannel(chatId);
306
435
  const msg = await ch.messages.fetch(messageId);
307
436
  if (msg.attachments.size === 0) return [];
308
437
  const results = [];
309
438
  for (const att of msg.attachments.values()) {
310
- const path = await this.downloadSingleAttachment(att);
439
+ const path = await this.downloadSingleAttachment(att, opts);
311
440
  results.push({
312
441
  id: att.id,
313
442
  path,
@@ -526,7 +655,10 @@ class DiscordBackend {
526
655
  }
527
656
  // ── Channel helpers ────────────────────────────────────────────────
528
657
  async fetchTextChannel(id) {
529
- const ch = await this.client.channels.fetch(id);
658
+ if (!this.client) await this.connect();
659
+ const p = this.client.channels.fetch(id);
660
+ p.catch(() => {}); // if the watchdog wins, don't leak an unhandled rejection
661
+ const ch = await this._withTimeout(p, "discord channels.fetch");
530
662
  if (!ch || !ch.isTextBased()) {
531
663
  throw new Error(`channel ${id} not found or not text-based`);
532
664
  }
@@ -571,8 +703,8 @@ class DiscordBackend {
571
703
  throw new Error(`refusing to send channel state: ${f}`);
572
704
  }
573
705
  }
574
- async downloadSingleAttachment(att) {
575
- return downloadSingleAttachment(att, this.inboxDir);
706
+ async downloadSingleAttachment(att, opts = {}) {
707
+ return downloadSingleAttachment(att, this.inboxDir, opts);
576
708
  }
577
709
  }
578
710
  export {