mixdog 0.9.2 → 0.9.3

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 (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
@@ -1367,7 +1367,7 @@ function prepareInput(patchStr) {
1367
1367
  return String(patchStr).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
1368
1368
  }
1369
1369
 
1370
- function isCodexApplyPatchEnvelope(patchStr) {
1370
+ function isApplyPatchEnvelope(patchStr) {
1371
1371
  const text = prepareInput(patchStr).trimStart();
1372
1372
  return text.startsWith('*** Begin Patch')
1373
1373
  || text.startsWith('*** Add File:')
@@ -1377,7 +1377,7 @@ function isCodexApplyPatchEnvelope(patchStr) {
1377
1377
 
1378
1378
  function isV4APatchInput(patchStr, format) {
1379
1379
  return String(format || '').toLowerCase() === 'v4a'
1380
- || isCodexApplyPatchEnvelope(patchStr);
1380
+ || isApplyPatchEnvelope(patchStr);
1381
1381
  }
1382
1382
 
1383
1383
  const UNIFIED_HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/;
@@ -2947,7 +2947,7 @@ async function _executePatchTool(name, args, cwd, options = {}) {
2947
2947
  return errText;
2948
2948
  }
2949
2949
  if (isPatchErrorText(String(result))) maybeCapturePatchReplay(args, effectiveCwd, String(result));
2950
- // ② completion progress (claude "Found N" parity). Best-effort, no-op
2950
+ // ② completion progress ("Found N" summary line). Best-effort, no-op
2951
2951
  // when onProgress is absent (no progressToken). Never throws — only
2952
2952
  // emits on success (an "Error:" body is left to the tool result alone).
2953
2953
  if (typeof options?.onProgress === 'function') {
@@ -1,5 +1,4 @@
1
- // Per-tool live-status message builder (ported from a reference agent's progress
2
- // generalization / getActivityDescription). Dependency-light and
1
+ // Per-tool live-status message builder. Dependency-light and
3
2
  // side-effect-free: given a tool name + its raw args, return a short
4
3
  // human-readable "what's happening now" string. Used by the central dispatch
5
4
  // path to emit a single start-of-tool progress notification. Every tool gets a
@@ -53,8 +53,7 @@ const SHELL_OUTPUT_DISK_CAP = 100 * 1024 * 1024;
53
53
 
54
54
  // Background-task disk watchdog cadence. The size guard polls the spilled
55
55
  // stdout/stderr files every interval and SIGKILLs the child once the
56
- // combined size exceeds SHELL_OUTPUT_DISK_CAP. 5 s matches reference agent
57
- // upstream cadence — short enough that a runaway loop is caught within a
56
+ // combined size exceeds SHELL_OUTPUT_DISK_CAP short enough that a runaway loop is caught within a
58
57
  // few seconds, long enough that the stat overhead is negligible.
59
58
  const SIZE_WATCHDOG_INTERVAL_MS = 1_000;
60
59
 
@@ -8,8 +8,7 @@
8
8
  // pyenv / mise / asdf / direnv setup the user gets in their interactive
9
9
  // terminal — without paying a fresh login-shell startup on every call.
10
10
  //
11
- // Mirrors upstream shell snapshot pattern (reference ShellSnapshot.ts:413,
12
- // createAndSaveSnapshot). Simpler scope: bash and zsh only, no embedded
11
+ // Scope: bash and zsh only, no embedded
13
12
  // search-tool injection (mixdog ships its own grep/glob helpers).
14
13
 
15
14
  import { spawn } from 'node:child_process';
@@ -34,8 +33,7 @@ const _cache = new Map();
34
33
  // command. Cleared on process exit (process-scoped Set).
35
34
  const _failedShells = new Set();
36
35
 
37
- // Mirrors reference cleanupRegistry pattern (utils/
38
- // cleanupRegistry.ts + ShellSnapshot.ts:534-545). Snapshot files are
36
+ // Snapshot files are
39
37
  // session-scoped and must be unlinked on graceful shutdown — otherwise
40
38
  // they pile up forever (each rc-file mtime change creates a new file).
41
39
  const _activeSnapshots = new Set();
@@ -1,16 +1,8 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
- import {
4
- ListToolsRequestSchema,
5
- CallToolRequestSchema
6
- } from "@modelcontextprotocol/sdk/types.js";
7
1
  import { spawn } from "child_process";
8
2
  import * as fs from "fs";
9
- import * as http from "http";
10
3
  import * as os from "os";
11
4
  import * as path from "path";
12
5
  import { performance } from "perf_hooks";
13
- import { pathToFileURL } from "url";
14
6
  import { createRequire } from "module";
15
7
  const _require = createRequire(import.meta.url);
16
8
  import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./lib/config.mjs";
@@ -62,128 +54,27 @@ import {
62
54
  RUNTIME_ROOT
63
55
  } from "./lib/runtime-paths.mjs";
64
56
  import { getDiscordToken } from "./lib/config.mjs";
57
+ import { bootProfile, localTimestamp } from "./lib/boot-profile.mjs";
58
+ import {
59
+ isChannelsDegraded,
60
+ logCrash,
61
+ _isBenignCrash,
62
+ BENIGN_CRASH_FATAL_THRESHOLD,
63
+ BENIGN_CRASH_STREAK_WINDOW_MS,
64
+ } from "./lib/crash-log.mjs";
65
+ import { dropTrace, preview, _dtIdxFlush } from "./lib/index-drop-trace.mjs";
66
+ import { normalizeWhisperLanguage, detectDeviceLanguage } from "./lib/whisper-language.mjs";
65
67
  const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.url).href;
66
68
  const {
67
69
  appendEntry: memoryAppendEntry,
68
70
  ingestTranscript: memoryIngestTranscript,
69
71
  } = await import(memoryClientModulePath);
70
- const DEFAULT_PLUGIN_VERSION = "0.0.1";
71
- const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ""));
72
- const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
73
- function bootProfile(event, fields = {}) {
74
- if (!BOOT_PROFILE_ENABLED) return;
75
- const elapsedMs = performance.now() - BOOT_PROFILE_START;
76
- const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `channels:${event}`];
77
- for (const [key, value] of Object.entries(fields || {})) {
78
- if (value === undefined || value === null || value === "") continue;
79
- parts.push(`${key}=${String(value).replace(/\s+/g, "_")}`);
80
- }
81
- try { process.stderr.write(`${parts.join(" ")}\n`); } catch {}
82
- }
83
- function localTimestamp() {
84
- return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { hour12: false });
85
- }
86
- function readPluginVersion() {
87
- try {
88
- const pkg = JSON.parse(fs.readFileSync(new URL("../../../package.json", import.meta.url), "utf8"));
89
- return pkg.version || DEFAULT_PLUGIN_VERSION;
90
- } catch {
91
- return DEFAULT_PLUGIN_VERSION;
92
- }
93
- }
94
- const PLUGIN_VERSION = readPluginVersion();
95
- let crashLogging = false;
96
- let _channelsDegraded = false;
97
- let _stderrBroken = false;
98
- function isChannelsDegraded() { return _channelsDegraded; }
99
-
100
- // stderr can break when the parent stdio pipe closes. Node then emits an
101
- // async 'error' on process.stderr, which sync try/catch around write() does
102
- // not catch — without a listener, that error becomes uncaughtException and
103
- // re-enters logCrash, looping until the disk fills. Register a suppressor
104
- // once at load time and stop writing to stderr after the first EPIPE so the
105
- // loop cannot start.
106
- try {
107
- process.stderr.on('error', (e) => {
108
- if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
109
- _stderrBroken = true;
110
- _channelsDegraded = true;
111
- }
112
- });
113
- } catch {}
114
-
115
- // Crash log guards: dedup repeated identical errors (a single broken handler
116
- // can fire thousands of times per minute) and rotate at a 10 MB cap so the
117
- // file cannot grow unbounded. One .old generation is kept; older rolls drop.
118
- const CRASH_LOG_MAX_BYTES = 10 * 1024 * 1024;
119
- let _lastCrashSig = "";
120
- let _crashRepeatCount = 0;
121
-
122
- function _writeCrashLine(crashLog, line) {
123
- try {
124
- let size = 0;
125
- try { size = fs.statSync(crashLog).size; } catch {}
126
- if (size + line.length > CRASH_LOG_MAX_BYTES) {
127
- try { fs.renameSync(crashLog, crashLog + ".old"); } catch {}
128
- }
129
- fs.appendFileSync(crashLog, line);
130
- } catch {}
131
- }
132
-
133
- function logCrash(label, err) {
134
- if (crashLogging) return;
135
- crashLogging = true;
136
- const msg = `[${localTimestamp()}] mixdog: ${label}: ${err}
137
- ${err instanceof Error ? err.stack : ""}
138
- `;
139
- if (!_stderrBroken) {
140
- try { process.stderr.write(msg); } catch (e) {
141
- if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
142
- _stderrBroken = true;
143
- }
144
- }
145
- }
146
- const sig = `${label}|${err && err.message ? err.message : String(err)}`;
147
- const crashLog = path.join(DATA_DIR, "crash.log");
148
- if (sig === _lastCrashSig) {
149
- // Same error repeating — count it but skip the disk write. The next
150
- // distinct error (or EPIPE branch below) flushes the suppressed total.
151
- _crashRepeatCount += 1;
152
- } else {
153
- if (_crashRepeatCount > 0) {
154
- _writeCrashLine(crashLog, `[${localTimestamp()}] mixdog: previous error repeated ${_crashRepeatCount} more time(s)\n`);
155
- _crashRepeatCount = 0;
156
- }
157
- _lastCrashSig = sig;
158
- _writeCrashLine(crashLog, msg);
159
- }
160
- if (err instanceof Error && err.message.includes("EPIPE")) {
161
- _channelsDegraded = true;
162
- _stderrBroken = true;
163
- }
164
- crashLogging = false;
165
- }
166
72
  // Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
167
73
  // after an unhandled rejection whose async state was already corrupted
168
74
  // (observed: EPERM on active-instance.json rename retry), so it spun
169
75
  // forever doing nothing useful — a zombie Lead. Fatal-exit on repeat.
170
- // Benign whitelist: transient EPERM/EACCES/EBUSY on the active-instance
171
- // rename path is expected under Windows file-lock contention and is
172
- // already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
173
- // occurrence must NOT be fatal, only a run of 3+ in a row without an
174
- // intervening distinct/successful event.
175
- const BENIGN_CRASH_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
176
- const BENIGN_CRASH_FATAL_THRESHOLD = 3;
177
- // "In a row" needs a time dimension: benign errors minutes/hours apart are
178
- // independent contention events, not a corrupted-state run. Only count a
179
- // streak when hits land within this window of the previous one.
180
- const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
181
76
  let _benignCrashStreak = 0;
182
77
  let _lastBenignCrashAt = 0;
183
- function _isBenignCrash(err) {
184
- const code = err?.code || (/\b(EPERM|EACCES|EBUSY)\b/.exec(String(err?.message || err)) || [])[0];
185
- return BENIGN_CRASH_CODES.has(code);
186
- }
187
78
  function _fatalCrash(label, err) {
188
79
  logCrash(label, err);
189
80
  const benign = _isBenignCrash(err);
@@ -236,17 +127,6 @@ let config = loadConfig();
236
127
  let backend = createBackend(config);
237
128
  const INSTANCE_ID = makeInstanceId();
238
129
  const TERMINAL_LEAD_PID = getTerminalLeadPid();
239
- // ── drop-trace instrumentation ──────────────────────────────────────────────
240
- const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
241
- const DROP_TRACE_ENABLED =
242
- process.env.MIXDOG_DROP_TRACE === "1" ||
243
- process.env.MIXDOG_DROP_TRACE === "true" ||
244
- process.env.MIXDOG_DEBUG_CHANNELS === "1" ||
245
- process.env.MIXDOG_DEBUG_CHANNELS === "true";
246
- // One-shot rotation for drop-trace.log at worker boot.
247
- if (DROP_TRACE_ENABLED) {
248
- try { if (fs.statSync(_dropTraceLog).size > 10 * 1024 * 1024) fs.renameSync(_dropTraceLog, _dropTraceLog + '.1') } catch {}
249
- }
250
130
  // Rotate additional worker logs (10 MB threshold).
251
131
  for (const _rotLog of ["channels-worker.log", "schedule.log", "event.log", "memory-worker.log", "mcp-debug.log", "webhook.log", "pg.log", "session-start.log"]) {
252
132
  const _rotPath = path.join(DATA_DIR, _rotLog);
@@ -287,42 +167,6 @@ try {
287
167
  try {
288
168
  pruneStalePluginDataLogSiblings(DATA_DIR, DEFAULT_STALE_LOG_SIBLING_MAX);
289
169
  } catch {}
290
-
291
- // ── Buffered drop-trace writer (channels/index) ──────────────────────────────
292
- // Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
293
- // Drains on process exit so no log lines are lost.
294
- let _dtIdxBuf = "";
295
- let _dtIdxBytes = 0;
296
- let _dtIdxFlushTimer = null;
297
- let _dtIdxStream = null;
298
- function _dtIdxGetStream() {
299
- if (!_dtIdxStream) _dtIdxStream = fs.createWriteStream(_dropTraceLog, { flags: "a" });
300
- return _dtIdxStream;
301
- }
302
- async function _dtIdxFlush() {
303
- if (_dtIdxFlushTimer) { clearTimeout(_dtIdxFlushTimer); _dtIdxFlushTimer = null; }
304
- if (!_dtIdxBuf) return;
305
- const stream = _dtIdxGetStream();
306
- const buf = _dtIdxBuf;
307
- _dtIdxBuf = "";
308
- _dtIdxBytes = 0;
309
- try {
310
- const ok = stream.write(buf);
311
- if (!ok) { const { once } = await import("node:events"); await once(stream, "drain").catch(() => {}); }
312
- } catch {}
313
- }
314
- function _dtIdxScheduleFlush() {
315
- if (_dtIdxFlushTimer) return;
316
- _dtIdxFlushTimer = setTimeout(() => { void _dtIdxFlush(); }, 1000);
317
- if (_dtIdxFlushTimer.unref) _dtIdxFlushTimer.unref();
318
- }
319
- function _dtIdxAppend(line) {
320
- _dtIdxBuf += line;
321
- _dtIdxBytes += Buffer.byteLength(line);
322
- if (_dtIdxBytes >= 65536) { void _dtIdxFlush(); return; }
323
- _dtIdxScheduleFlush();
324
- }
325
- process.on("exit", () => { void _dtIdxFlush(); });
326
170
  // SIGTERM: flush the drop-trace buffer, but do NOT exit here. In worker
327
171
  // mode the graceful `_channelsShutdownHandler` below owns shutdown
328
172
  // (stop() → cleanup → process.exit). In non-worker mode no SIGTERM
@@ -332,21 +176,6 @@ process.on("SIGTERM", () => {
332
176
  void _dtIdxFlush();
333
177
  if (!_isWorkerMode) process.exit(0);
334
178
  });
335
-
336
- function preview(text) {
337
- if (!text) return "";
338
- const s = String(text).replace(/\n/g, "\\n");
339
- return s.length > 120 ? s.slice(0, 120) + "…" : s;
340
- }
341
- function dropTrace(event, fields) {
342
- if (!DROP_TRACE_ENABLED) return;
343
- try {
344
- const ts = (/* @__PURE__ */ new Date()).toISOString();
345
- const loc = `[${ts}][pid=${process.pid}] ${event}`;
346
- const kv = fields ? " " + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(" ") : "";
347
- _dtIdxAppend(loc + kv + "\n");
348
- } catch {}
349
- }
350
179
  // ────────────────────────────────────────────────────────────────────────────
351
180
  ensureRuntimeDirs();
352
181
  cleanupStaleRuntimeFiles();
@@ -1620,41 +1449,6 @@ function runCmd(cmd, args, capture = false) {
1620
1449
  proc.on("error", reject);
1621
1450
  });
1622
1451
  }
1623
- let resolvedWhisperLanguage = null;
1624
- function normalizeWhisperLanguage(value) {
1625
- const raw = String(value ?? "").trim().toLowerCase();
1626
- if (!raw || raw === "auto") return null;
1627
- if (raw.startsWith("ko")) return "ko";
1628
- if (raw.startsWith("ja")) return "ja";
1629
- if (raw.startsWith("en")) return "en";
1630
- if (raw.startsWith("zh")) return "zh";
1631
- if (raw.startsWith("de")) return "de";
1632
- if (raw.startsWith("fr")) return "fr";
1633
- if (raw.startsWith("es")) return "es";
1634
- if (raw.startsWith("it")) return "it";
1635
- if (raw.startsWith("pt")) return "pt";
1636
- if (raw.startsWith("ru")) return "ru";
1637
- return raw;
1638
- }
1639
- function detectDeviceLanguage() {
1640
- if (resolvedWhisperLanguage) return resolvedWhisperLanguage;
1641
- const candidates = [
1642
- process.env.MIXDOG_CHANNELS_WHISPER_LANGUAGE,
1643
- process.env.LC_ALL,
1644
- process.env.LC_MESSAGES,
1645
- process.env.LANG,
1646
- Intl.DateTimeFormat().resolvedOptions().locale
1647
- ];
1648
- for (const candidate of candidates) {
1649
- const normalized = normalizeWhisperLanguage(candidate);
1650
- if (normalized) {
1651
- resolvedWhisperLanguage = normalized;
1652
- return normalized;
1653
- }
1654
- }
1655
- resolvedWhisperLanguage = "auto";
1656
- return resolvedWhisperLanguage;
1657
- }
1658
1452
  // ── voice.transcription concurrency queue (max=1 by default, config-driven) ──
1659
1453
  const _voiceTranscriptionQueue = (() => {
1660
1454
  let running = 0;
@@ -1789,23 +1583,10 @@ async function _doTranscribeVoice(audioPath, attachmentId) {
1789
1583
  }
1790
1584
  }
1791
1585
  import { TOOL_DEFS } from './tool-defs.mjs';
1792
- function createHttpMcpServer() {
1793
- const s = new Server(
1794
- { name: "mixdog", version: PLUGIN_VERSION },
1795
- { capabilities: { tools: {} }, instructions: INSTRUCTIONS }
1796
- );
1797
- s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }));
1798
- s.setRequestHandler(CallToolRequestSchema, async (req) => {
1799
- const toolName = req.params.name;
1800
- const args = req.params.arguments ?? {};
1801
- return handleToolCallWithBridgeRetry(toolName, args);
1802
- });
1803
- return s;
1804
- }
1805
1586
  // Tool dispatch in worker mode goes through the IPC `call` handler at the
1806
- // bottom of this file (parent's `callWorker` → `handleToolCall`). The HTTP
1807
- // MCP path uses its own short-lived `Server` instance built by
1808
- // `createHttpMcpServer()` above. There is no orphan worker-level Server.
1587
+ // bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
1588
+ // orphan worker-level MCP Server: the parent (server.mjs) owns the single
1589
+ // connected transport and routes CallTool through the IPC `call` path.
1809
1590
  const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch", "react", "edit_message", "download_attachment", "trigger_schedule"]);
1810
1591
  // ── Backend-tool dispatch helpers ───────────────────────────────────────────
1811
1592
  // Each helper dispatches through the local backend (this process is always the
@@ -1908,7 +1689,7 @@ async function dispatchDownloadAttachment(args) {
1908
1689
  ${lines.join("\n")}` }] };
1909
1690
  }
1910
1691
  async function handleToolCall(name, args, _signal) {
1911
- if (_channelsDegraded) {
1692
+ if (isChannelsDegraded()) {
1912
1693
  return { content: [{ type: 'text', text: `[channels degraded] ${name} unavailable — restart MCP to recover` }], isError: true }
1913
1694
  }
1914
1695
  let result;
@@ -0,0 +1,23 @@
1
+ import { performance } from "perf_hooks";
2
+
3
+ // Boot-timing instrumentation + shared local timestamp helper.
4
+ // Extracted verbatim from channels/index.mjs (behavior-preserving).
5
+ const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ""));
6
+ const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
7
+
8
+ function bootProfile(event, fields = {}) {
9
+ if (!BOOT_PROFILE_ENABLED) return;
10
+ const elapsedMs = performance.now() - BOOT_PROFILE_START;
11
+ const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `channels:${event}`];
12
+ for (const [key, value] of Object.entries(fields || {})) {
13
+ if (value === undefined || value === null || value === "") continue;
14
+ parts.push(`${key}=${String(value).replace(/\s+/g, "_")}`);
15
+ }
16
+ try { process.stderr.write(`${parts.join(" ")}\n`); } catch {}
17
+ }
18
+
19
+ function localTimestamp() {
20
+ return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { hour12: false });
21
+ }
22
+
23
+ export { BOOT_PROFILE_ENABLED, BOOT_PROFILE_START, bootProfile, localTimestamp };
@@ -0,0 +1,106 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { DATA_DIR } from "./config.mjs";
4
+ import { localTimestamp } from "./boot-profile.mjs";
5
+
6
+ // Crash logging + degraded-state tracking for the channels worker.
7
+ // Extracted verbatim from channels/index.mjs (behavior-preserving).
8
+ //
9
+ // Degraded/stderr-broken state is module-scoped here; index.mjs reads it via
10
+ // isChannelsDegraded() and installs the process-level unhandledRejection/
11
+ // uncaughtException handlers (which need the worker's stop()).
12
+ let crashLogging = false;
13
+ let _channelsDegraded = false;
14
+ let _stderrBroken = false;
15
+ function isChannelsDegraded() { return _channelsDegraded; }
16
+
17
+ // stderr can break when the parent stdio pipe closes. Node then emits an
18
+ // async 'error' on process.stderr, which sync try/catch around write() does
19
+ // not catch — without a listener, that error becomes uncaughtException and
20
+ // re-enters logCrash, looping until the disk fills. Register a suppressor
21
+ // once at load time and stop writing to stderr after the first EPIPE so the
22
+ // loop cannot start.
23
+ try {
24
+ process.stderr.on('error', (e) => {
25
+ if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
26
+ _stderrBroken = true;
27
+ _channelsDegraded = true;
28
+ }
29
+ });
30
+ } catch {}
31
+
32
+ // Crash log guards: dedup repeated identical errors (a single broken handler
33
+ // can fire thousands of times per minute) and rotate at a 10 MB cap so the
34
+ // file cannot grow unbounded. One .old generation is kept; older rolls drop.
35
+ const CRASH_LOG_MAX_BYTES = 10 * 1024 * 1024;
36
+ let _lastCrashSig = "";
37
+ let _crashRepeatCount = 0;
38
+
39
+ function _writeCrashLine(crashLog, line) {
40
+ try {
41
+ let size = 0;
42
+ try { size = fs.statSync(crashLog).size; } catch {}
43
+ if (size + line.length > CRASH_LOG_MAX_BYTES) {
44
+ try { fs.renameSync(crashLog, crashLog + ".old"); } catch {}
45
+ }
46
+ fs.appendFileSync(crashLog, line);
47
+ } catch {}
48
+ }
49
+
50
+ function logCrash(label, err) {
51
+ if (crashLogging) return;
52
+ crashLogging = true;
53
+ const msg = `[${localTimestamp()}] mixdog: ${label}: ${err}
54
+ ${err instanceof Error ? err.stack : ""}
55
+ `;
56
+ if (!_stderrBroken) {
57
+ try { process.stderr.write(msg); } catch (e) {
58
+ if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
59
+ _stderrBroken = true;
60
+ }
61
+ }
62
+ }
63
+ const sig = `${label}|${err && err.message ? err.message : String(err)}`;
64
+ const crashLog = path.join(DATA_DIR, "crash.log");
65
+ if (sig === _lastCrashSig) {
66
+ // Same error repeating — count it but skip the disk write. The next
67
+ // distinct error (or EPIPE branch below) flushes the suppressed total.
68
+ _crashRepeatCount += 1;
69
+ } else {
70
+ if (_crashRepeatCount > 0) {
71
+ _writeCrashLine(crashLog, `[${localTimestamp()}] mixdog: previous error repeated ${_crashRepeatCount} more time(s)\n`);
72
+ _crashRepeatCount = 0;
73
+ }
74
+ _lastCrashSig = sig;
75
+ _writeCrashLine(crashLog, msg);
76
+ }
77
+ if (err instanceof Error && err.message.includes("EPIPE")) {
78
+ _channelsDegraded = true;
79
+ _stderrBroken = true;
80
+ }
81
+ crashLogging = false;
82
+ }
83
+
84
+ // Benign whitelist: transient EPERM/EACCES/EBUSY on the active-instance
85
+ // rename path is expected under Windows file-lock contention and is
86
+ // already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
87
+ // occurrence must NOT be fatal, only a run of 3+ in a row without an
88
+ // intervening distinct/successful event.
89
+ const BENIGN_CRASH_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
90
+ const BENIGN_CRASH_FATAL_THRESHOLD = 3;
91
+ // "In a row" needs a time dimension: benign errors minutes/hours apart are
92
+ // independent contention events, not a corrupted-state run. Only count a
93
+ // streak when hits land within this window of the previous one.
94
+ const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
95
+ function _isBenignCrash(err) {
96
+ const code = err?.code || (/\b(EPERM|EACCES|EBUSY)\b/.exec(String(err?.message || err)) || [])[0];
97
+ return BENIGN_CRASH_CODES.has(code);
98
+ }
99
+
100
+ export {
101
+ isChannelsDegraded,
102
+ logCrash,
103
+ _isBenignCrash,
104
+ BENIGN_CRASH_FATAL_THRESHOLD,
105
+ BENIGN_CRASH_STREAK_WINDOW_MS,
106
+ };
@@ -0,0 +1,72 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { DATA_DIR } from "./config.mjs";
4
+
5
+ // Drop-trace instrumentation for channels/index.mjs.
6
+ // Extracted verbatim (behavior-preserving). Distinct from lib/drop-trace.mjs
7
+ // (which the output-forwarder uses): this instance is owned by index.mjs and
8
+ // keeps its own buffered writer + `preview` helper so the two trace streams
9
+ // stay byte-identical to the pre-split behavior.
10
+ const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
11
+ const DROP_TRACE_ENABLED =
12
+ process.env.MIXDOG_DROP_TRACE === "1" ||
13
+ process.env.MIXDOG_DROP_TRACE === "true" ||
14
+ process.env.MIXDOG_DEBUG_CHANNELS === "1" ||
15
+ process.env.MIXDOG_DEBUG_CHANNELS === "true";
16
+ // One-shot rotation for drop-trace.log at worker boot.
17
+ if (DROP_TRACE_ENABLED) {
18
+ try { if (fs.statSync(_dropTraceLog).size > 10 * 1024 * 1024) fs.renameSync(_dropTraceLog, _dropTraceLog + '.1') } catch {}
19
+ }
20
+
21
+ // ── Buffered drop-trace writer (channels/index) ──────────────────────────────
22
+ // Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
23
+ // Drains on process exit so no log lines are lost.
24
+ let _dtIdxBuf = "";
25
+ let _dtIdxBytes = 0;
26
+ let _dtIdxFlushTimer = null;
27
+ let _dtIdxStream = null;
28
+ function _dtIdxGetStream() {
29
+ if (!_dtIdxStream) _dtIdxStream = fs.createWriteStream(_dropTraceLog, { flags: "a" });
30
+ return _dtIdxStream;
31
+ }
32
+ async function _dtIdxFlush() {
33
+ if (_dtIdxFlushTimer) { clearTimeout(_dtIdxFlushTimer); _dtIdxFlushTimer = null; }
34
+ if (!_dtIdxBuf) return;
35
+ const stream = _dtIdxGetStream();
36
+ const buf = _dtIdxBuf;
37
+ _dtIdxBuf = "";
38
+ _dtIdxBytes = 0;
39
+ try {
40
+ const ok = stream.write(buf);
41
+ if (!ok) { const { once } = await import("node:events"); await once(stream, "drain").catch(() => {}); }
42
+ } catch {}
43
+ }
44
+ function _dtIdxScheduleFlush() {
45
+ if (_dtIdxFlushTimer) return;
46
+ _dtIdxFlushTimer = setTimeout(() => { void _dtIdxFlush(); }, 1000);
47
+ if (_dtIdxFlushTimer.unref) _dtIdxFlushTimer.unref();
48
+ }
49
+ function _dtIdxAppend(line) {
50
+ _dtIdxBuf += line;
51
+ _dtIdxBytes += Buffer.byteLength(line);
52
+ if (_dtIdxBytes >= 65536) { void _dtIdxFlush(); return; }
53
+ _dtIdxScheduleFlush();
54
+ }
55
+ process.on("exit", () => { void _dtIdxFlush(); });
56
+
57
+ function preview(text) {
58
+ if (!text) return "";
59
+ const s = String(text).replace(/\n/g, "\\n");
60
+ return s.length > 120 ? s.slice(0, 120) + "…" : s;
61
+ }
62
+ function dropTrace(event, fields) {
63
+ if (!DROP_TRACE_ENABLED) return;
64
+ try {
65
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
66
+ const loc = `[${ts}][pid=${process.pid}] ${event}`;
67
+ const kv = fields ? " " + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(" ") : "";
68
+ _dtIdxAppend(loc + kv + "\n");
69
+ } catch {}
70
+ }
71
+
72
+ export { DROP_TRACE_ENABLED, dropTrace, preview, _dtIdxFlush };
@@ -154,6 +154,14 @@ class OutputForwarder {
154
154
  try {
155
155
  const stat = this._pendingStat ?? statSync(this.transcriptPath);
156
156
  this._pendingStat = null;
157
+ // File shrank below our cursor: the transcript was rotated out from
158
+ // under us (buffered-appender rotation, or an external truncation).
159
+ // Treat it as a fresh file and reset the cursor instead of getting
160
+ // stuck forever (stat.size <= readFileSize would otherwise short
161
+ // circuit below and never read the new content).
162
+ if (stat.size < this.readFileSize) {
163
+ this.readFileSize = 0;
164
+ }
157
165
  if (stat.size <= this.readFileSize) {
158
166
  return { lines: [], nextFileSize: this.readFileSize };
159
167
  }