mixdog 0.9.18 → 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 (122) hide show
  1. package/package.json +3 -2
  2. package/scripts/output-style-smoke.mjs +2 -2
  3. package/scripts/recall-bench-cases.json +11 -0
  4. package/scripts/recall-bench.mjs +91 -2
  5. package/scripts/tool-efficiency-diag.mjs +4 -1
  6. package/scripts/tool-smoke.mjs +101 -27
  7. package/src/agents/debugger/AGENT.md +3 -3
  8. package/src/agents/heavy-worker/AGENT.md +7 -10
  9. package/src/agents/maintainer/AGENT.md +1 -2
  10. package/src/agents/reviewer/AGENT.md +1 -2
  11. package/src/agents/worker/AGENT.md +5 -8
  12. package/src/defaults/agents.json +3 -0
  13. package/src/mixdog-session-runtime.mjs +23 -6
  14. package/src/rules/agent/00-core.md +4 -3
  15. package/src/rules/agent/30-explorer.md +53 -22
  16. package/src/rules/lead/lead-tool.md +3 -2
  17. package/src/rules/shared/01-tool.md +24 -29
  18. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  21. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  25. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  28. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  29. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  30. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  32. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  33. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  34. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  35. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  36. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  37. package/src/runtime/channels/backends/discord.mjs +43 -1
  38. package/src/runtime/channels/index.mjs +145 -58
  39. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  40. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  41. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  42. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  43. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  44. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  45. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  46. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  47. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  48. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  49. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  50. package/src/runtime/shared/atomic-file.mjs +10 -4
  51. package/src/runtime/shared/background-tasks.mjs +4 -2
  52. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  53. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  54. package/src/runtime/shared/tool-surface.mjs +30 -1
  55. package/src/session-runtime/config-lifecycle.mjs +1 -1
  56. package/src/session-runtime/cwd-plugins.mjs +46 -3
  57. package/src/session-runtime/mcp-glue.mjs +24 -3
  58. package/src/session-runtime/output-styles.mjs +44 -10
  59. package/src/session-runtime/workflow.mjs +16 -1
  60. package/src/standalone/channel-worker.mjs +74 -5
  61. package/src/standalone/explore-tool.mjs +1 -1
  62. package/src/tui/App.jsx +57 -77
  63. package/src/tui/app/channel-pickers.mjs +45 -0
  64. package/src/tui/app/slash-commands.mjs +0 -1
  65. package/src/tui/app/slash-dispatch.mjs +0 -16
  66. package/src/tui/app/transcript-window.mjs +44 -1
  67. package/src/tui/app/use-mouse-input.mjs +9 -2
  68. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  69. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  70. package/src/tui/app/use-transcript-window.mjs +65 -5
  71. package/src/tui/components/PromptInput.jsx +33 -64
  72. package/src/tui/components/ToolExecution.jsx +2 -2
  73. package/src/tui/dist/index.mjs +5330 -5443
  74. package/src/tui/engine.mjs +49 -10
  75. package/src/tui/lib/voice-setup.mjs +166 -0
  76. package/src/tui/paste-attachments.mjs +12 -5
  77. package/src/tui/prompt-history-store.mjs +125 -12
  78. package/scripts/bench/cache-probe-tasks.json +0 -8
  79. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  80. package/scripts/bench/lead-review-tasks.json +0 -20
  81. package/scripts/bench/r4-mixed-tasks.json +0 -20
  82. package/scripts/bench/r5-orchestrated-task.json +0 -7
  83. package/scripts/bench/review-tasks.json +0 -20
  84. package/scripts/bench/round-codex.json +0 -114
  85. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  86. package/scripts/bench/round-mixdog-lead.json +0 -269
  87. package/scripts/bench/round-mixdog.json +0 -126
  88. package/scripts/bench/round-r10-bigsample.json +0 -679
  89. package/scripts/bench/round-r11-codexalign.json +0 -257
  90. package/scripts/bench/round-r13-clientmeta.json +0 -464
  91. package/scripts/bench/round-r14-betafeatures.json +0 -466
  92. package/scripts/bench/round-r15-fulldefault.json +0 -462
  93. package/scripts/bench/round-r16-sessionid.json +0 -466
  94. package/scripts/bench/round-r17-wirebytes.json +0 -456
  95. package/scripts/bench/round-r18-prewarm.json +0 -468
  96. package/scripts/bench/round-r19-clean.json +0 -472
  97. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  98. package/scripts/bench/round-r21-delta-retry.json +0 -473
  99. package/scripts/bench/round-r22-full-probe.json +0 -693
  100. package/scripts/bench/round-r23-itemprobe.json +0 -701
  101. package/scripts/bench/round-r24-shapefix.json +0 -677
  102. package/scripts/bench/round-r25-serial.json +0 -464
  103. package/scripts/bench/round-r26-parallel3.json +0 -671
  104. package/scripts/bench/round-r27-parallel10.json +0 -894
  105. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  106. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  107. package/scripts/bench/round-r30-instid.json +0 -253
  108. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  109. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  110. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  111. package/scripts/bench/round-r34-orchestrated.json +0 -120
  112. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  113. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  114. package/scripts/bench/round-r4-codex.json +0 -114
  115. package/scripts/bench/round-r4-mixed.json +0 -225
  116. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  117. package/scripts/bench/round-r6-codex.json +0 -114
  118. package/scripts/bench/round-r6-solo.json +0 -257
  119. package/scripts/bench/round-r7-full.json +0 -254
  120. package/scripts/bench/round-r8-fulldefault.json +0 -255
  121. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  122. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -74,7 +74,7 @@ export function formatLineDelta(totals) {
74
74
  }
75
75
 
76
76
  export function parseUpdateSummary(text) {
77
- const match = /^(Updated|Created|Deleted)\s+(.+?)(?:\s+·\s+|$)/i.exec(String(text || '').trim());
77
+ const match = /^(Updated|Created|Deleted|Checked)\s+(.+?)(?:\s+·\s+|$)/i.exec(String(text || '').trim());
78
78
  if (!match) return null;
79
79
  const action = titleWord(match[1]);
80
80
  const target = match[2].trim();
@@ -703,7 +703,36 @@ export function formatAggregateDetail(summaries) {
703
703
  }
704
704
 
705
705
  const update = parseUpdateSummary(text);
706
- if (update) {
706
+ // Dry-run patch checks ("Checked foo.js · +7 -5") are validations, not
707
+ // edits: their line delta must NEVER be summed into the real edit total.
708
+ // They get their own metric so repeated checks still merge; the preview
709
+ // delta is shown only when the card has no real edit delta it could be
710
+ // confused with. Delta-less "Checked ..." texts (task/memory summaries)
711
+ // fall through to extras unchanged.
712
+ if (update && update.action === 'Checked') {
713
+ if (update.seen) {
714
+ const metric = addMetric('checked_files', {
715
+ files: new Set(),
716
+ fileCount: 0,
717
+ added: 0,
718
+ removed: 0,
719
+ seen: false,
720
+ render: (m) => {
721
+ const count = m.fileCount + m.files.size;
722
+ const target = count === 1 && m.fileCount === 0 ? [...m.files][0] : `${count} ${pluralize(count, 'file')}`;
723
+ const editDelta = formatLineDelta(metrics.get('updated_files'));
724
+ const delta = editDelta ? '' : formatLineDelta(m);
725
+ return delta ? `Checked ${target} · ${delta}` : `Checked ${target}`;
726
+ },
727
+ });
728
+ if (update.file) metric.files.add(update.file);
729
+ metric.fileCount += update.fileCount;
730
+ metric.added += update.added;
731
+ metric.removed += update.removed;
732
+ metric.seen = metric.seen || update.seen;
733
+ continue;
734
+ }
735
+ } else if (update) {
707
736
  const metric = addMetric('updated_files', {
708
737
  files: new Set(),
709
738
  fileCount: 0,
@@ -58,7 +58,7 @@ export function createConfigLifecycle({
58
58
  ) {
59
59
  return outputStyleStatusCache;
60
60
  }
61
- outputStyleStatusCache = outputStyleStatus(dataDir);
61
+ outputStyleStatusCache = outputStyleStatus(dataDir, { fresh });
62
62
  outputStyleStatusCacheAt = now;
63
63
  outputStyleStatusCacheDir = cacheDir;
64
64
  return outputStyleStatusCache;
@@ -45,6 +45,48 @@ export function createCwdPlugins({
45
45
  cfgMod,
46
46
  STANDALONE_DATA_DIR,
47
47
  }) {
48
+ // Per-plugin-root caches for pluginsStatus(): manifest + MCP discovery keyed
49
+ // by root path + manifest mtime (invalidated on manifest edit); recursive
50
+ // skill-file count keyed by root with a ~5s TTL fallback (the walk has no
51
+ // single mtime to key on).
52
+ const pluginRootCache = new Map();
53
+ const skillCountCache = new Map();
54
+ const mcpDiscoveryCache = new Map();
55
+ function manifestMtimeKey(root) {
56
+ let key = '';
57
+ for (const rel of ['.codex-plugin/plugin.json', 'plugin.json']) {
58
+ try { key += `${statSync(resolve(root, rel)).mtimeMs}:`; } catch { key += '0:'; }
59
+ }
60
+ return key;
61
+ }
62
+ function cachedPluginData(root) {
63
+ const key = manifestMtimeKey(root);
64
+ const hit = pluginRootCache.get(root);
65
+ if (hit && hit.key === key) return hit;
66
+ const entry = { key, manifest: pluginManifest(root) };
67
+ pluginRootCache.set(root, entry);
68
+ return entry;
69
+ }
70
+ // MCP discovery probes candidate script files (.mcp.json, scripts/run-mcp.mjs,
71
+ // ...) whose add/remove is NOT reflected in the manifest mtime, so key it on a
72
+ // short TTL instead (~5s, like the skill-file count).
73
+ function cachedMcpDiscovery(root) {
74
+ const now = Date.now();
75
+ const hit = mcpDiscoveryCache.get(root);
76
+ if (hit && (now - hit.at) < 5000) return hit.mcp;
77
+ const mcp = discoverPluginMcp(root);
78
+ mcpDiscoveryCache.set(root, { at: now, mcp });
79
+ return mcp;
80
+ }
81
+ function cachedSkillCount(root) {
82
+ const now = Date.now();
83
+ const hit = skillCountCache.get(root);
84
+ if (hit && (now - hit.at) < 5000) return hit.count;
85
+ const count = countSkillFiles(root);
86
+ skillCountCache.set(root, { at: now, count });
87
+ return count;
88
+ }
89
+
48
90
  function resolveCwdPath(value) {
49
91
  const raw = clean(value);
50
92
  if (!raw) throw new Error('cwd: path is required for action=set');
@@ -125,7 +167,8 @@ export function createCwdPlugins({
125
167
  const addRegisteredPlugin = (entry) => {
126
168
  const root = clean(entry.root);
127
169
  if (!root || !existsSync(root)) return;
128
- const manifest = pluginManifest(root);
170
+ const cached = cachedPluginData(root);
171
+ const manifest = cached.manifest;
129
172
  const name = clean(manifest.name) || clean(manifest.id) || clean(entry.name) || root.split(/[\\/]/).pop() || root;
130
173
  const plugin = {
131
174
  id: clean(entry.id) || name,
@@ -141,9 +184,9 @@ export function createCwdPlugins({
141
184
  root,
142
185
  installedAt: entry.installedAt || null,
143
186
  updatedAt: entry.updatedAt || null,
144
- skillCount: countSkillFiles(root),
187
+ skillCount: cachedSkillCount(root),
145
188
  ...(() => {
146
- const { mcpScript, mcpInline } = discoverPluginMcp(root);
189
+ const { mcpScript, mcpInline } = cachedMcpDiscovery(root);
147
190
  return { mcpScript, mcpInline };
148
191
  })(),
149
192
  };
@@ -4,9 +4,30 @@
4
4
  // `state` object so the facade's teardown/reconnect paths still observe it.
5
5
  // Method behavior is byte-for-byte identical; only grouping changes.
6
6
  import { resolve } from 'node:path';
7
+ import { statSync } from 'node:fs';
7
8
  import { clean } from './session-text.mjs';
8
9
  import { readProjectMcpServers } from './plugin-mcp.mjs';
9
10
 
11
+ // Cache project-local `.mcp.json` reads by path + mtime so repeated mcpStatus()
12
+ // calls skip existsSync+readFileSync+JSON.parse when the file is unchanged.
13
+ // Invalidated automatically on any mtime change (or create/delete via mtime=0).
14
+ const projectMcpCache = new Map();
15
+ const PROJECT_MCP_CACHE_MAX = 32;
16
+ function cachedProjectMcpServers(cwd) {
17
+ const path = resolve(cwd || '.', '.mcp.json');
18
+ let mtimeMs = 0;
19
+ try { mtimeMs = statSync(path).mtimeMs; } catch { mtimeMs = 0; }
20
+ const hit = projectMcpCache.get(path);
21
+ if (hit && hit.mtimeMs === mtimeMs) return hit.value;
22
+ const value = readProjectMcpServers(cwd);
23
+ // Bound the cache: Map preserves insertion order, so drop the oldest entry.
24
+ if (!projectMcpCache.has(path) && projectMcpCache.size >= PROJECT_MCP_CACHE_MAX) {
25
+ projectMcpCache.delete(projectMcpCache.keys().next().value);
26
+ }
27
+ projectMcpCache.set(path, { mtimeMs, value });
28
+ return value;
29
+ }
30
+
10
31
  export function createMcpGlue({
11
32
  mcpClient,
12
33
  getConfig,
@@ -23,15 +44,15 @@ export function createMcpGlue({
23
44
  }
24
45
 
25
46
  // Merge mixdog-config `agent.mcpServers` with project-local `.mcp.json`.
26
- // On name collision the project-local `.mcp.json` entry WINS (Claude Code
27
- // precedence: project > user config). `sources[name]` records each server's
47
+ // On name collision the project-local `.mcp.json` entry WINS
48
+ // (precedence: project > user config). `sources[name]` records each server's
28
49
  // origin ('config' | 'project') for status reporting.
29
50
  function resolveEffectiveMcpServers() {
30
51
  const config = getConfig();
31
52
  const configured = config?.mcpServers && typeof config.mcpServers === 'object'
32
53
  ? config.mcpServers
33
54
  : {};
34
- const project = readProjectMcpServers(getCurrentCwd());
55
+ const project = cachedProjectMcpServers(getCurrentCwd());
35
56
  const servers = { ...configured, ...project };
36
57
  const sources = {};
37
58
  for (const name of Object.keys(configured)) sources[name] = 'config';
@@ -1,7 +1,7 @@
1
1
  // Output-style catalog + metadata parsing. Roots are injected so this module
2
2
  // stays free of the runtime's path constants.
3
3
  import { basename, join } from 'node:path';
4
- import { readFileSync, readdirSync } from 'node:fs';
4
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
5
5
  import { clean } from './session-text.mjs';
6
6
  import { readJsonSafe } from './fs-utils.mjs';
7
7
 
@@ -55,7 +55,7 @@ function parseOutputStyleFrontmatter(markdown) {
55
55
  return meta;
56
56
  }
57
57
 
58
- function readOutputStyleMetadata(filePath, source) {
58
+ function computeOutputStyleMetadata(filePath, source) {
59
59
  let raw = '';
60
60
  try { raw = readFileSync(filePath, 'utf8'); } catch { return null; }
61
61
  const meta = parseOutputStyleFrontmatter(raw);
@@ -76,18 +76,52 @@ function readOutputStyleMetadata(filePath, source) {
76
76
  };
77
77
  }
78
78
 
79
- export function listOutputStyleCatalog(rootDir, dataDir) {
79
+ // Per-file metadata cache keyed by path + mtime, and a per-directory listing
80
+ // cache keyed by dir mtime — repeated catalog reads skip readdir/readFile when
81
+ // nothing changed. File content edits (unchanged dir mtime) are still caught by
82
+ // the per-file mtime check.
83
+ const styleFileCache = new Map();
84
+ function readOutputStyleMetadata(filePath, source) {
85
+ let mtimeMs = 0;
86
+ try { mtimeMs = statSync(filePath).mtimeMs; } catch { mtimeMs = 0; }
87
+ const hit = styleFileCache.get(filePath);
88
+ if (hit && hit.mtimeMs === mtimeMs) return hit.value;
89
+ const value = computeOutputStyleMetadata(filePath, source);
90
+ styleFileCache.set(filePath, { mtimeMs, value });
91
+ return value;
92
+ }
93
+
94
+ const styleDirCache = new Map();
95
+ function listStyleDirFiles(dir, fresh = false) {
96
+ let mtimeMs = 0;
97
+ try { mtimeMs = statSync(dir).mtimeMs; } catch { return null; }
98
+ if (!fresh) {
99
+ const hit = styleDirCache.get(dir);
100
+ if (hit && hit.mtimeMs === mtimeMs) return hit.files;
101
+ }
102
+ let entries;
103
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return null; }
104
+ const files = entries
105
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
106
+ .map((entry) => entry.name);
107
+ styleDirCache.set(dir, { mtimeMs, files });
108
+ return files;
109
+ }
110
+
111
+ export function listOutputStyleCatalog(rootDir, dataDir, { fresh = false } = {}) {
80
112
  const byId = new Map();
81
113
  const dirs = [
82
114
  { dir: join(rootDir, 'output-styles'), source: 'builtin' },
83
115
  { dir: join(dataDir, 'output-styles'), source: 'user' },
84
116
  ];
85
117
  for (const { dir, source } of dirs) {
86
- let entries = [];
87
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
88
- for (const entry of entries) {
89
- if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue;
90
- const style = readOutputStyleMetadata(join(dir, entry.name), source);
118
+ // Forced-fresh (e.g. setOutputStyle) bypasses the dir-listing cache so a
119
+ // just-added/removed .md is always seen; per-file metadata still reuses the
120
+ // path+mtime cache below.
121
+ const files = listStyleDirFiles(dir, fresh);
122
+ if (!files) continue;
123
+ for (const name of files) {
124
+ const style = readOutputStyleMetadata(join(dir, name), source);
91
125
  if (style) byId.set(style.id, style);
92
126
  }
93
127
  }
@@ -115,8 +149,8 @@ function configuredOutputStyleValue(dataDir) {
115
149
  return clean(unified.outputStyle || (unified.agent && unified.agent.outputStyle) || 'default') || 'default';
116
150
  }
117
151
 
118
- export function outputStyleStatus(rootDir, dataDir) {
119
- const styles = listOutputStyleCatalog(rootDir, dataDir);
152
+ export function outputStyleStatus(rootDir, dataDir, { fresh = false } = {}) {
153
+ const styles = listOutputStyleCatalog(rootDir, dataDir, { fresh });
120
154
  const configured = configuredOutputStyleValue(dataDir);
121
155
  const current = findOutputStyle(configured, styles)
122
156
  || findOutputStyle('default', styles)
@@ -201,7 +201,10 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
201
201
  }
202
202
 
203
203
  function workflowContextBlock(config, dir) {
204
- const pack = loadWorkflowPack(dir, activeWorkflowId(config));
204
+ return workflowContextBlockFromPack(loadWorkflowPack(dir, activeWorkflowId(config)), dir);
205
+ }
206
+
207
+ function workflowContextBlockFromPack(pack, dir) {
205
208
  if (!pack) return '';
206
209
  const lines = [`# Active Workflow: ${pack.name}`];
207
210
  if (pack.description) lines.push(pack.description);
@@ -219,6 +222,17 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
219
222
  return lines.join('\n\n');
220
223
  }
221
224
 
225
+ // Single-pass variant: loads the active WORKFLOW.md pack once and derives both
226
+ // the summary and the context block from it, so session-create does not re-read
227
+ // and re-parse WORKFLOW.md twice on the hot boot path.
228
+ function activeWorkflowContext(config, dir) {
229
+ const pack = loadWorkflowPack(dir, activeWorkflowId(config));
230
+ return {
231
+ summary: workflowSummary(pack),
232
+ context: workflowContextBlockFromPack(pack, dir),
233
+ };
234
+ }
235
+
222
236
  return {
223
237
  listWorkflowPacks,
224
238
  activeWorkflowId,
@@ -227,6 +241,7 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
227
241
  activeWorkflowSummary,
228
242
  loadAgentDefinition,
229
243
  workflowContextBlock,
244
+ activeWorkflowContext,
230
245
  };
231
246
  }
232
247
 
@@ -1,5 +1,5 @@
1
1
  import { execFile, fork, spawnSync } from 'node:child_process';
2
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, rmSync, writeFile } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -25,6 +25,9 @@ const WORKER_PRELOAD = fileURLToPath(new URL('./channel-worker-preload.cjs', imp
25
25
  // surfacing the failure to callers.
26
26
  const WORKER_BOOT_MAX_ATTEMPTS = 2;
27
27
  const WORKER_BOOT_RETRY_DELAY_MS = 1000;
28
+ // Fail an in-flight IPC send whose flush callback never fires (wedged channel)
29
+ // so the FIFO send queue can't stall permanently on one stuck message.
30
+ const SEND_FLUSH_TIMEOUT_MS = 10_000;
28
31
 
29
32
  function logLine(path, line) {
30
33
  try {
@@ -83,21 +86,39 @@ export function createStandaloneChannelWorker({
83
86
  let parentExitCleanup = null;
84
87
  const pending = new Map();
85
88
  const ownedChildPids = new Set();
89
+ // Strict-FIFO backpressure queue for child IPC. child.send returns false
90
+ // when the channel is over its backpressure threshold; we keep at most one
91
+ // send in flight and only dispatch the next once the prior send's flush
92
+ // callback fires, so bursts don't unboundedly buffer and order is preserved.
93
+ const sendQueue = [];
94
+ let sendInFlight = false;
95
+ // Guards the single in-flight send: the token invalidates a stale settler
96
+ // (flush callback / watchdog / rejectPending) so only the first one to fire
97
+ // releases the slot; the watchdog handle lets rejectPending cancel it.
98
+ let inFlightToken = 0;
99
+ let inFlightWatchdog = null;
86
100
  const logPath = join(dataDir, 'channels-worker-standalone.log');
87
101
  const useProcessWorker = process.env.MIXDOG_CHANNEL_WORKER_PROCESS !== '0';
88
102
  const clientDir = join(runtimeRoot(), 'channel-clients');
89
103
  const clientPath = join(clientDir, `${process.pid}.json`);
90
104
  let clientHeartbeatTimer = null;
91
105
  let clientHeartbeatExitCleanup = null;
106
+ let clientDirReady = false;
92
107
 
93
108
  function writeClientHeartbeat() {
109
+ // Must write every tick: liveness readers key off the file's rolling
110
+ // mtime/updatedAt, so an unchanged-skip would make the client look dead.
111
+ // mkdir once and write async to keep the 5s interval off the sync-fs path.
94
112
  try {
95
- mkdirSync(clientDir, { recursive: true });
96
- writeFileSync(clientPath, JSON.stringify({
113
+ if (!clientDirReady) {
114
+ mkdirSync(clientDir, { recursive: true });
115
+ clientDirReady = true;
116
+ }
117
+ writeFile(clientPath, JSON.stringify({
97
118
  pid: process.pid,
98
119
  cwd,
99
120
  updatedAt: Date.now(),
100
- }));
121
+ }), () => {});
101
122
  } catch {}
102
123
  }
103
124
 
@@ -147,6 +168,54 @@ export function createStandaloneChannelWorker({
147
168
  item.reject(error);
148
169
  }
149
170
  pending.clear();
171
+ // Fail any sends still queued/in-flight for the now-dead channel so their
172
+ // callers unblock instead of waiting on a flush callback that never fires.
173
+ // Invalidate + disarm the in-flight watchdog so it can't later flip
174
+ // sendInFlight under a freshly-started send (its owning call was already
175
+ // rejected via the pending map above).
176
+ inFlightToken++;
177
+ if (inFlightWatchdog) { clearTimeout(inFlightWatchdog); inFlightWatchdog = null; }
178
+ sendInFlight = false;
179
+ while (sendQueue.length) {
180
+ const item = sendQueue.shift();
181
+ try { item.cb?.(error); } catch {}
182
+ }
183
+ }
184
+
185
+ // Dispatch queued IPC sends one at a time, gating the next on the current
186
+ // send's flush callback so child.send backpressure (false return) is honored.
187
+ function drainSendQueue() {
188
+ if (sendInFlight) return;
189
+ const item = sendQueue.shift();
190
+ if (!item) return;
191
+ if (!child || !child.send) {
192
+ try { item.cb?.(new Error('channels worker is not running')); } catch {}
193
+ drainSendQueue();
194
+ return;
195
+ }
196
+ sendInFlight = true;
197
+ const token = ++inFlightToken;
198
+ // Settle exactly once, whichever of {flush callback, watchdog} fires first;
199
+ // a stale token (superseded by rejectPending or the sibling) is ignored.
200
+ const finish = (error) => {
201
+ if (token !== inFlightToken) return;
202
+ inFlightToken++;
203
+ if (inFlightWatchdog) { clearTimeout(inFlightWatchdog); inFlightWatchdog = null; }
204
+ sendInFlight = false;
205
+ try { item.cb?.(error); } catch {}
206
+ drainSendQueue();
207
+ };
208
+ // Watchdog: if the flush callback never fires (wedged IPC channel), fail
209
+ // this request via its normal rejection path and keep draining so a single
210
+ // stuck send can't wedge the queue forever.
211
+ inFlightWatchdog = setTimeout(() => finish(new Error('channels worker send timed out')), SEND_FLUSH_TIMEOUT_MS);
212
+ inFlightWatchdog.unref?.();
213
+ child.send(item.msg, (error) => finish(error));
214
+ }
215
+
216
+ function sendToChild(msg, cb) {
217
+ sendQueue.push({ msg, cb });
218
+ drainSendQueue();
150
219
  }
151
220
 
152
221
  function start() {
@@ -298,7 +367,7 @@ export function createStandaloneChannelWorker({
298
367
  reject(new Error(`channels tool timed out: ${name}`));
299
368
  }, timeoutMs);
300
369
  pending.set(callId, { resolve, reject, timer });
301
- child.send({ type: 'call', callId, name, args: args || {} }, (error) => {
370
+ sendToChild({ type: 'call', callId, name, args: args || {} }, (error) => {
302
371
  if (!error) return;
303
372
  pending.delete(callId);
304
373
  clearTimeout(timer);
@@ -70,7 +70,7 @@ function escapeXml(str) {
70
70
  // reminder. The full no-verdict contract lives at system level
71
71
  // (rules/agent/30-explorer.md).
72
72
  export function buildExplorerPrompt(query) {
73
- return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> answer NOW with those exact coordinates; zero -> one more batch if budget remains. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — never trace the chain, one anchor per concept. You locate WHERE, never WHY. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens or scope; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. EXPLORATION_FAILED only after all 3 turns found zero anchors. Final check before emitting: at least ONE anchor must contain a specific query token if every anchor matches only generic words, output EXPLORATION_FAILED instead; a plausible-looking wrong anchor is worse than an honest miss. Output only anchor lines formatted as path:line — symbol/name — short reason, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
73
+ return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> answer NOW with those exact coordinates; zero -> one more batch if budget remains. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — never trace the chain, one anchor per concept. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path or pass only a path seen in an earlier result; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak)a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
74
74
  }
75
75
 
76
76
  export function normalizeExploreQueries(rawQuery) {