mixdog 0.9.115 → 0.9.117

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 (32) hide show
  1. package/package.json +1 -1
  2. package/scripts/tool-stress.mjs +143 -0
  3. package/src/cli.mjs +1 -0
  4. package/src/rules/shared/01-tool.md +24 -20
  5. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +0 -2
  6. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -3
  7. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +24 -1
  8. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +54 -5
  9. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +8 -9
  10. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +13 -0
  11. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +4 -1
  12. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +7 -0
  13. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  14. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -6
  16. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +19 -9
  17. package/src/runtime/channels/lib/tool-dispatch.mjs +0 -26
  18. package/src/runtime/channels/lib/worker-main.mjs +0 -18
  19. package/src/runtime/channels/tool-defs.mjs +3 -45
  20. package/src/runtime/shared/child-spawn-gate.mjs +16 -5
  21. package/src/runtime/shared/tool-result-summary.mjs +0 -1
  22. package/src/runtime/shared/tool-surface.mjs +0 -7
  23. package/src/runtime/shared/uv-threadpool-boot.mjs +13 -0
  24. package/src/session-runtime/runtime-core.mjs +1 -0
  25. package/src/session-runtime/tool-catalog-data.mjs +1 -3
  26. package/src/session-runtime/warmup-schedulers.mjs +8 -0
  27. package/src/session-runtime/workflow.mjs +13 -1
  28. package/src/standalone/channel-worker.mjs +0 -2
  29. package/src/standalone/daemon.mjs +3 -0
  30. package/src/tui/dist/index.mjs +0 -8
  31. package/src/tui/session/session-api.mjs +74 -10
  32. package/src/runtime/channels/lib/provider-dispatch.mjs +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.115",
3
+ "version": "0.9.117",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -0,0 +1,143 @@
1
+ // Tool-layer burst stress: hammers the in-process tool execution surface with
2
+ // concurrent multi-session waves and checks that results stay correct and
3
+ // bounded under load (no unhandled rejections, no resource-pressure failures,
4
+ // patch/read integrity preserved, cancellation clean). One-shot script:
5
+ // `node scripts/tool-stress.mjs`. Exit 0 = stable, 1 = instability found.
6
+ import '../src/runtime/shared/uv-threadpool-boot.mjs';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { executeBuiltinTool } from '../src/runtime/agent/orchestrator/tools/builtin.mjs';
12
+ import { executeCodeGraphTool } from '../src/runtime/agent/orchestrator/tools/code-graph.mjs';
13
+ import { executePatchTool } from '../src/runtime/agent/orchestrator/tools/patch.mjs';
14
+
15
+ const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
16
+ const SESSIONS = 8;
17
+ const WAVES = 5;
18
+ const stats = new Map(); // tool -> {n, errs:[], lat:[]}
19
+ const failures = [];
20
+
21
+ function record(tool, ms, result, expectRe) {
22
+ let s = stats.get(tool);
23
+ if (!s) { s = { n: 0, errs: [], lat: [] }; stats.set(tool, s); }
24
+ s.n += 1; s.lat.push(ms);
25
+ const text = String(result ?? '');
26
+ if (/^Error:|resource pressure|ERESOURCEPRESSURE|EAGAIN/i.test(text)) s.errs.push(text.slice(0, 160));
27
+ else if (expectRe && !expectRe.test(text)) s.errs.push(`unexpected output: ${text.slice(0, 120)}`);
28
+ }
29
+
30
+ async function timed(tool, expectRe, fn) {
31
+ const t0 = Date.now();
32
+ try {
33
+ const out = await fn();
34
+ record(tool, Date.now() - t0, out, expectRe);
35
+ return out;
36
+ } catch (err) {
37
+ record(tool, Date.now() - t0, `Error: thrown ${err?.message || err}`);
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function pct(list, p) {
43
+ if (!list.length) return 0;
44
+ const sorted = [...list].sort((a, b) => a - b);
45
+ return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
46
+ }
47
+
48
+ const tmp = mkdtempSync(join(tmpdir(), 'mixdog-tool-stress-'));
49
+ const t0 = Date.now();
50
+ try {
51
+ // ── Phase A+C: concurrent multi-session waves (search/read/graph/shell +
52
+ // per-session patch integrity riding the same load) ──────────────────────
53
+ for (let wave = 0; wave < WAVES; wave++) {
54
+ const calls = [];
55
+ for (let s = 0; s < SESSIONS; s++) {
56
+ const opts = { sessionId: `stress-s${s}` };
57
+ const marker = `stress_w${wave}_s${s}`;
58
+ calls.push(
59
+ timed('grep', /path-string|paths only|grep|\(no matches\)|Fuzzy/i, () => executeBuiltinTool('grep', {
60
+ pattern: ['Fuzzy filename', 'paths only'], path: 'src/runtime/agent/orchestrator/tools/builtin', glob: '*.mjs', limit: 20, context: 0,
61
+ }, root, opts)),
62
+ timed('glob', /tool-defs\.mjs|\.mjs/, () => executeBuiltinTool('glob', {
63
+ pattern: '**/*.mjs', path: 'src/session-runtime', limit: 40,
64
+ }, root, opts)),
65
+ timed('find', /tool-defs|no fuzzy match/, () => executeBuiltinTool('find', {
66
+ query: 'tool-defs', limit: 8,
67
+ }, root, opts)),
68
+ timed('list', /01-tool\.md|file/, () => executeBuiltinTool('list', {
69
+ path: 'src/rules/shared',
70
+ }, root, opts)),
71
+ timed('read', /Tool Use|read/, () => executeBuiltinTool('read', {
72
+ path: [['src/rules/shared/01-tool.md', 0, 10], ['package.json', 0, 5]],
73
+ }, root, opts)),
74
+ timed('code_graph', /symbol|binding|files|edges/i, () => executeCodeGraphTool('code_graph', {
75
+ mode: 'symbols', files: 'scripts/smoke.mjs',
76
+ }, root)),
77
+ timed('shell', /55350/, () => executeBuiltinTool('shell', {
78
+ command: 'node -e "console.log(123*450)"', timeout: 60_000,
79
+ }, root, opts)),
80
+ (async () => {
81
+ const patch = [
82
+ '*** Begin Patch',
83
+ `*** Add File: ${marker}.txt`,
84
+ `+payload ${marker}`,
85
+ '*** End Patch',
86
+ ].join('\n');
87
+ await timed('apply_patch', /applied|OK/i, () => executePatchTool('apply_patch', { patch, base_path: tmp }, tmp, opts));
88
+ const back = await timed('read-verify', new RegExp(`payload ${marker}`), () => executeBuiltinTool('read', { path: `${marker}.txt` }, tmp, opts));
89
+ if (!String(back || '').includes(`payload ${marker}`)) failures.push(`patch integrity lost for ${marker}`);
90
+ })(),
91
+ );
92
+ }
93
+ await Promise.all(calls);
94
+ }
95
+
96
+ // ── Phase B: oversized inputs stay budget-bounded ─────────────────────────
97
+ await Promise.all([
98
+ timed('grep-broad', /Showing|import|export/, () => executeBuiltinTool('grep', {
99
+ pattern: 'import', path: 'src', limit: 300, mode: 'files',
100
+ }, root, { sessionId: 'stress-big' })),
101
+ timed('glob-broad', /\.mjs|entries/, () => executeBuiltinTool('glob', {
102
+ pattern: '**/*', path: 'src/runtime/agent/orchestrator/tools', limit: 0,
103
+ }, root, { sessionId: 'stress-big' })),
104
+ timed('read-big', /./, () => executeBuiltinTool('read', {
105
+ path: 'src/tui/dist/index.mjs', limit: 2000,
106
+ }, root, { sessionId: 'stress-big' })),
107
+ ]);
108
+ for (const [tool, s] of stats) {
109
+ if (tool.endsWith('-broad') || tool === 'read-big') {
110
+ const worst = Math.max(...s.lat);
111
+ if (worst > 30_000) failures.push(`${tool} exceeded 30s budget: ${worst}ms`);
112
+ }
113
+ }
114
+
115
+ // ── Phase D: cancellation under load ─────────────────────────────────────
116
+ const bg = await timed('shell-async', /task_id/, () => executeBuiltinTool('shell', {
117
+ command: 'node -e "setTimeout(()=>{}, 30000)"', mode: 'async', timeout: 60_000,
118
+ }, root, { sessionId: 'stress-cancel' }));
119
+ const bgId = (/task_id:\s*(\S+)/.exec(String(bg)) || [])[1];
120
+ if (!bgId) failures.push('async shell did not return task_id');
121
+ else {
122
+ await timed('task-cancel', /cancelled/, () => executeBuiltinTool('task', { action: 'cancel', task_id: bgId }, root, { sessionId: 'stress-cancel' }));
123
+ const st = await timed('task-status', /cancelled|failed/, () => executeBuiltinTool('task', { action: 'status', task_id: bgId }, root, { sessionId: 'stress-cancel' }));
124
+ if (!/cancelled/.test(String(st))) failures.push(`cancelled task not reported cancelled: ${String(st).slice(0, 120)}`);
125
+ }
126
+ } finally {
127
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
128
+ }
129
+
130
+ // ── Report ──────────────────────────────────────────────────────────────────
131
+ let errTotal = 0;
132
+ for (const [tool, s] of [...stats.entries()].sort()) {
133
+ errTotal += s.errs.length;
134
+ console.log(
135
+ `${tool.padEnd(14)} n=${String(s.n).padStart(3)} errs=${s.errs.length}`
136
+ + ` p50=${pct(s.lat, 50)}ms p95=${pct(s.lat, 95)}ms max=${Math.max(...s.lat)}ms`,
137
+ );
138
+ for (const e of s.errs.slice(0, 3)) console.log(` ! ${e}`);
139
+ }
140
+ for (const f of failures) console.log(`FAIL ${f}`);
141
+ const calls = [...stats.values()].reduce((a, s) => a + s.n, 0);
142
+ console.log(`tool stress ${failures.length || errTotal ? 'FAILED' : 'passed'} calls=${calls} errors=${errTotal} failures=${failures.length} elapsed=${Math.round((Date.now() - t0) / 1000)}s`);
143
+ process.exit(failures.length || errTotal ? 1 : 0);
package/src/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import './runtime/shared/uv-threadpool-boot.mjs';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { classifyCliInvocation } from './headless-command.mjs';
5
6
  import {
@@ -1,7 +1,7 @@
1
1
  # Tool Use
2
2
 
3
3
  - Baseline routing assigns each facet directly by the evidence needed to
4
- determine the complete edit:
4
+ determine the complete answer or edit:
5
5
  path/name only→`find`; wildcard/recursive paths→`glob` (including known-root
6
6
  unknown descendants); exact directory entries→`list`;
7
7
  source content/value/`path:line`→`grep`; exact symbol/relation→`code_graph`;
@@ -18,41 +18,45 @@
18
18
  relies on it. Within the current project, pass
19
19
  project-relative paths and omit optional scopes equal to its root; explicit
20
20
  paths may be outside cwd only for targets outside the project.
21
- - Plan the fewest dependent rounds, then the fewest calls. A conclusive
22
- result ends its facet, and known state anything the task supplied, a
23
- tool returned, or a check already proved — is never re-found,
24
- re-derived, or re-verified. Batch calls iff none needs
21
+ - Plan the fewest dependent rounds, then the fewest calls. Known state —
22
+ anything the task supplied, a tool returned, or a check already proved
23
+ is never re-found, re-derived, or re-verified; a change to its subject
24
+ re-opens it. Batch calls iff none needs
25
25
  another's output or can change another's inputs/state; otherwise
26
26
  serialize. Before each batch, deduplicate the facets still required by the request,
27
27
  route each once to the cheapest sufficient tool with all required
28
28
  variants/scopes — every distinct sample/format in the same batch —
29
29
  and launch every independent call together — never
30
30
  split or duplicate a facet across tools, mutate merely to widen
31
- retrieval, reserve known work, or cap fanout. Guessed terms go wide
32
- with batched fan-out; narrow a scope only on verified cues returned
33
- siblings/conventions or known literals. Mine each returned output for
34
- every remaining facet before the next round. Symbol relations end at
31
+ retrieval, reserve known work, or cap fanout. Each round asks everything
32
+ it can and keeps everything it gets: guesses go wide in one batch, scopes
33
+ narrow only on verified cues returned siblings/conventions or known
34
+ literals and returned output is fully mined before the next round.
35
+ Symbol relations end at
35
36
  `code_graph`; values/locations end at the context grep returns; `read`
36
37
  covers only what returned spans cannot, as an anchored offset/limit
37
- window. The moment evidence determines the answer, edit, or deliverable,
38
- stop retrieving; patch if needed.
38
+ window. A conclusive result ends its facet; evidence that determines the
39
+ answer, edit, or deliverable ends retrieval — patch if needed.
39
40
  - Once the edit or deliverable is determined, finish in one assistant turn:
40
41
  before `apply_patch`, obtain every target hunk's exact current content and
41
42
  anchor from `grep`, `code_graph`, or `read`; never infer patch context from
42
43
  another file, a sample, or expected text. Then issue `apply_patch` calls
43
44
  serially, never in parallel; use one cohesive call with one file section per
44
- target, all patches first, and their one
45
- batched verification `shell` in the same turn, running the real required
46
- postconditions on every changed file and produced artifact, never echoing
47
- a claim;
48
- runtime waits for every patch and skips the shell
49
- if any fails. Retry only failed envelopes; rerun a failed check only
50
- after a fix that can change its result, else report it unresolved.
45
+ target, all patches first, then their one batched verification `shell` in
46
+ the same turn the runtime runs it after every patch and only if all
47
+ succeeded, so verification never needs its own turn. It runs the real
48
+ required postconditions on every changed file and produced artifact,
49
+ never echoing a claim; a postcondition that did not actually run is
50
+ unresolved, not passed. Retry only failed envelopes; rerun a failed check only
51
+ after a change that can alter its outcome commands alike; else switch
52
+ route or report it unresolved.
51
53
  Hand-authored text is edited only with `apply_patch`; computed artifacts
52
54
  (data/reports/derived values) come from `shell` computation, never
53
55
  hand-transcribed numbers. Earlier `shell` is only for runtime/state
54
56
  evidence unavailable to file tools—an independent facet, batched with
55
57
  the rest; independent probes are parallel shell calls, never one
56
58
  serial script per round.
57
- - A background `task_id` ends the turn; completion resumes work. Never poll;
58
- use task control only for recovery or a required blocking result.
59
+ - A long or uncertain command runs async never nohup — and its
60
+ `task_id` ends the turn; completion resumes work. Never poll in any
61
+ form — sleep/status probes included; task control is for recovery or a
62
+ required blocking result only.
@@ -19,8 +19,6 @@ const WORKER_DENIED_TOOLS = new Set([
19
19
  // (type=spawn|send|close|list). Denying the one name blocks all worker
20
20
  // session control.
21
21
  'agent',
22
- // channels module (owner/Discord-facing)
23
- 'reply', 'fetch',
24
22
  // host input injection
25
23
  'inject_input',
26
24
  ]);
@@ -32,7 +32,7 @@ export const BUILTIN_TOOLS = [
32
32
  name: 'read',
33
33
  title: 'Mixdog Read',
34
34
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
35
- description: 'Known-file contents or line ranges; not directories. Replaces cat/head/tail.',
35
+ description: 'Known-file contents or line ranges; images render for viewing; not directories. Replaces cat/head/tail.',
36
36
  inputSchema: {
37
37
  type: 'object',
38
38
  properties: {
@@ -75,7 +75,7 @@ export const BUILTIN_TOOLS = [
75
75
  name: 'shell',
76
76
  title: 'Mixdog Shell',
77
77
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
78
- description: 'Run a shell command. Sync inline by default; async returns task_id and sends a completion notification. Executable/runtime/state evidence only — never file exploration in any command segment: NOT ls/find/cat/head/tail/grep/rg/sed; dedicated file tools cover those.',
78
+ description: 'Run a shell command; async returns task_id and sends a completion notification. Executable/runtime/state evidence only — never file exploration in any command segment: NOT ls/find/cat/head/tail/grep/rg/sed; dedicated file tools cover those.',
79
79
  inputSchema: {
80
80
  type: 'object',
81
81
  properties: {
@@ -194,7 +194,7 @@ export const BUILTIN_TOOLS = [
194
194
  description: 'Filename or directory path fragments matched against path strings; query[] batches.',
195
195
  },
196
196
  path: { type: 'string', description: 'Project-relative base; omit for project root; absolute only outside.' },
197
- limit: { type: 'number', description: 'Max paths across the call. Defaults to 25.' },
197
+ limit: { type: 'number', description: 'Max paths; default 25; 0 unlimited.' },
198
198
  include_noise: { type: 'boolean', description: 'Also search gitignored/dependency trees.' },
199
199
  },
200
200
  required: ['query'],
@@ -13,6 +13,12 @@
13
13
  import { stat } from 'node:fs/promises';
14
14
 
15
15
  const FS_REACHABILITY_DEADLINE_MS = 5000;
16
+ // Under burst load the libuv threadpool can queue a healthy local stat past
17
+ // the base deadline (slow ≠ dead). Before declaring the path unreachable,
18
+ // keep waiting for the SAME pending probe up to this extended ceiling: a
19
+ // loaded-but-alive filesystem answers within it, a dead mount stays silent.
20
+ const FS_REACHABILITY_EXTENDED_MS = 15_000;
21
+ let _lastSlowStatWarnAt = 0;
16
22
 
17
23
  // Resolve true when the path is reachable (exists OR cleanly absent — ENOENT,
18
24
  // EACCES, etc. are "the FS answered", let the real sync logic produce its own
@@ -34,8 +40,25 @@ export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEA
34
40
  deadline,
35
41
  ]);
36
42
  if (result === 'TIMEOUT') {
43
+ // Grace window: distinguish threadpool queueing from a dead mount by
44
+ // waiting longer for the probe that is still in flight.
45
+ let extTimer = null;
46
+ const extended = await Promise.race([
47
+ probe.finally(() => { if (extTimer) clearTimeout(extTimer); }),
48
+ new Promise((resolve) => {
49
+ extTimer = setTimeout(() => resolve('TIMEOUT'), Math.max(ms, FS_REACHABILITY_EXTENDED_MS - ms));
50
+ }),
51
+ ]);
52
+ if (extended !== 'TIMEOUT') {
53
+ const now = Date.now();
54
+ if (now - _lastSlowStatWarnAt > 30_000) {
55
+ _lastSlowStatWarnAt = now;
56
+ process.stderr.write(`[fs-reachability] slow stat >${ms}ms under load (resolved in grace window): ${path}\n`);
57
+ }
58
+ return extended;
59
+ }
37
60
  const err = new Error(
38
- `path unreachable: stat exceeded ${ms}ms (possible dead mount / hung filesystem): ${path}`,
61
+ `path unreachable: stat exceeded ${FS_REACHABILITY_EXTENDED_MS}ms (possible dead mount / hung filesystem): ${path}`,
39
62
  );
40
63
  err.code = 'EFSUNREACHABLE';
41
64
  throw err;
@@ -19,6 +19,13 @@ let _server = null; // { child, pending: Map, sequence }
19
19
  let _binaryPath = undefined; // undefined = unresolved, null = unavailable
20
20
  let _lastFailureAt = 0;
21
21
 
22
+ function _setServerReferenced(server, referenced) {
23
+ const method = referenced ? 'ref' : 'unref';
24
+ try { server?.child?.[method]?.(); } catch {}
25
+ try { server?.child?.stdin?.[method]?.(); } catch {}
26
+ try { server?.child?.stdout?.[method]?.(); } catch {}
27
+ }
28
+
22
29
  function _resolveBinary() {
23
30
  if (_binaryPath !== undefined) return _binaryPath;
24
31
  const explicit = String(process.env.MIXDOG_SEARCH_SERVER_BIN || '').trim();
@@ -77,7 +84,10 @@ function _ensureServer() {
77
84
  });
78
85
  child.on('error', (error) => { if (_server === server) _teardown(error); });
79
86
  child.on('exit', () => { if (_server === server) _teardown(); });
80
- child.unref?.();
87
+ // A detached child can still pin a one-shot CLI/test through its pipe
88
+ // handles. Keep the resident server idle-unreferenced, then ref all handles
89
+ // only while a request is awaiting a response.
90
+ _setServerReferenced(server, false);
81
91
  _server = server;
82
92
  return server;
83
93
  }
@@ -85,11 +95,30 @@ function _ensureServer() {
85
95
  /** rg-runner seam: returns a runRgWindowedLines-shaped result, or null when
86
96
  * the server is unavailable / the request shape is unsupported (caller then
87
97
  * spawns rg exactly as before). */
98
+ // Boot-time prewarm: binary resolution is async (dynamic import), so the
99
+ // first search of a cold session otherwise races it and falls back to a
100
+ // spawn. Long-lived hosts call this fire-and-forget to have the resident
101
+ // server up before the first tool call. Honors the same kill switch.
102
+ export async function warmNativeSearchServer() {
103
+ try {
104
+ if (process.env.MIXDOG_SEARCH_SERVER === '0') return false;
105
+ if (_resolveBinary() === null) {
106
+ const mod = await import('../code-graph/graph-binary.mjs');
107
+ const candidate = mod.graphBinaryPath?.() || mod.resolveGraphBinaryPath?.() || null;
108
+ if (candidate && existsSync(candidate)) _binaryPath = candidate;
109
+ }
110
+ return Boolean(_ensureServer());
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
88
116
  export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
89
117
  if (process.env.MIXDOG_SEARCH_SERVER === '0') return null;
90
118
  const server = _ensureServer();
91
119
  if (!server) return null;
92
120
  const id = ++server.sequence;
121
+ _setServerReferenced(server, true);
93
122
  const request = {
94
123
  id,
95
124
  cwd: String(execOptions.cwd || process.cwd()),
@@ -100,17 +129,37 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
100
129
  : 0,
101
130
  };
102
131
  const response = await new Promise((resolve) => {
132
+ let settled = false;
133
+ let onAbort = null;
134
+ const cancelServerWork = () => {
135
+ try { server.child.stdin.write(`${JSON.stringify({ cancel: id })}\n`); } catch {}
136
+ };
137
+ const settle = (value, cancel = false) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ clearTimeout(timer);
141
+ if (onAbort) {
142
+ try { execOptions.signal?.removeEventListener?.('abort', onAbort); } catch {}
143
+ onAbort = null;
144
+ }
145
+ if (cancel) cancelServerWork();
146
+ resolve(value);
147
+ if (server.pending.size === 0) _setServerReferenced(server, false);
148
+ };
103
149
  const timer = setTimeout(() => {
104
150
  server.pending.delete(id);
105
- resolve(null);
151
+ settle(null, true);
106
152
  }, REQUEST_TIMEOUT_MS);
107
153
  timer.unref?.();
108
- const settle = (value) => { clearTimeout(timer); resolve(value); };
109
154
  server.pending.set(id, { resolve: settle, reject: () => settle(null) });
110
- const onAbort = () => {
155
+ onAbort = () => {
111
156
  server.pending.delete(id);
112
- settle(null);
157
+ settle(null, true);
113
158
  };
159
+ if (execOptions.signal?.aborted) {
160
+ onAbort();
161
+ return;
162
+ }
114
163
  execOptions.signal?.addEventListener?.('abort', onAbort, { once: true });
115
164
  try {
116
165
  server.child.stdin.write(`${JSON.stringify(request)}\n`);
@@ -582,15 +582,14 @@ function spawnRg(argsList, execOptions) {
582
582
  }
583
583
 
584
584
  export async function runRg(argsList, execOptions = {}) {
585
- // `rg --files` (glob tool) has the same spawn-dominated cost profile as
586
- // content grep; the resident server answers it without a process. Only a
587
- // COMPLETE served listing substitutes for the buffered-stdout contract.
588
- if (argsList.includes('--files')) {
589
- try {
590
- const served = await tryServeSearch(argsList, execOptions, { offset: 0, limit: 0 });
591
- if (served && served.complete) return served.lines.join('\n');
592
- } catch { /* server is an accelerator only */ }
593
- }
585
+ // Resident server first for BOTH profiles — `rg --files` (glob tool) and
586
+ // buffered content grep share the spawn-dominated cost. Unsupported args
587
+ // answer null and fall through to the real spawn. Only a COMPLETE served
588
+ // result substitutes for the buffered-stdout contract.
589
+ try {
590
+ const served = await tryServeSearch(argsList, execOptions, { offset: 0, limit: 0 });
591
+ if (served && served.complete) return served.lines.join('\n');
592
+ } catch { /* server is an accelerator only */ }
594
593
  await assertRgAvailable();
595
594
  try {
596
595
  return await spawnRg(argsList, execOptions);
@@ -63,6 +63,17 @@ import {
63
63
  psSingleQuote,
64
64
  isPowerShellShell,
65
65
  } from './lib/shell-spawn-helpers.mjs';
66
+ import os from 'node:os';
67
+
68
+ // Background work yields CPU to interactive tool calls: demote the job's
69
+ // process priority best-effort (below-normal). Children inherit it. Purely a
70
+ // scheduling hint — failures (permissions, dead pid) are ignored.
71
+ // MIXDOG_BG_PRIORITY=0 disables.
72
+ export function demoteBackgroundShellPriority(pid) {
73
+ if (process.env.MIXDOG_BG_PRIORITY === '0') return;
74
+ if (!Number.isFinite(pid) || pid <= 0) return;
75
+ try { os.setPriority(pid, os.constants.priority.PRIORITY_BELOW_NORMAL); } catch { /* best-effort */ }
76
+ }
66
77
 
67
78
  // Facade re-exports: path/detail helpers and the job-not-found message moved
68
79
  // to sibling modules; keep existing importers of shell-jobs.mjs resolving.
@@ -186,6 +197,7 @@ export async function _startBackgroundShellJobImpl({
186
197
  try { unlinkSync(wrappedTempPath); } catch {}
187
198
  return { jobId, kind: 'bash', status: 'failed', error: `failed to spawn shell background task: ${e?.message || e}` };
188
199
  }
200
+ demoteBackgroundShellPriority(child.pid);
189
201
  const detail = {
190
202
  jobId,
191
203
  kind: 'bash',
@@ -429,6 +441,7 @@ async function startBackgroundPowerShellJob({
429
441
  if (!Number.isFinite(childPid) || childPid <= 0) {
430
442
  return { jobId, kind: 'bash', status: 'failed', error: 'PowerShell background task spawn returned no pid' };
431
443
  }
444
+ demoteBackgroundShellPriority(childPid);
432
445
  const detail = {
433
446
  jobId,
434
447
  kind: 'bash',
@@ -18,7 +18,7 @@ import {
18
18
  getBackgroundTask,
19
19
  } from '../../../../shared/background-tasks.mjs';
20
20
  import { startChildGuardian } from '../../../../shared/child-guardian.mjs';
21
- import { _startBackgroundShellJobImpl } from './shell-job-spawn.mjs';
21
+ import { _startBackgroundShellJobImpl, demoteBackgroundShellPriority } from './shell-job-spawn.mjs';
22
22
  import { detachedSpawnOpts } from '../../../../shared/spawn-flags.mjs';
23
23
  import {
24
24
  getShellJobsDir,
@@ -804,6 +804,9 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
804
804
  // 'running' detail.
805
805
  export function adoptForegroundShellJob({ command, cwd, pid, timeoutMs, mergeStderr, stdoutPath, stderrPath, clientHostPid, ownerSessionId }) {
806
806
  const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
807
+ // Auto-backgrounded work is by definition long-running: yield CPU to
808
+ // interactive tool calls from here on (children inherit the class).
809
+ demoteBackgroundShellPriority(pid);
807
810
  const exitPath = shellJobExitPath(jobId);
808
811
  const donePath = shellJobDonePath(jobId);
809
812
  const detail = {
@@ -33,6 +33,13 @@ function _graphBinaryPath() {
33
33
  try { return findCachedGraphBinary(getPluginData()); } catch { return null; }
34
34
  }
35
35
 
36
+ // Public resolver for consumers outside the graph build (the resident
37
+ // native-search-client duck-types this exact name). Returns an absolute
38
+ // path or null; never throws.
39
+ export function graphBinaryPath() {
40
+ try { return _graphBinaryPath() || null; } catch { return null; }
41
+ }
42
+
36
43
  async function _runGraphBinaryJsonl(absRoot, extraArgs, stdinLines = null, signal = null) {
37
44
  let binPath = _graphBinaryPath();
38
45
  if (!binPath) {
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.3",
2
+ "version": "0.1.4",
3
3
  "_comment": "Synced from immutable graph-v release assets.",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.3/mixdog-graph-darwin-arm64",
7
- "sha256": "16b46d7d6c11adc252ccbd645c2af483f1460facf27b91a90d80580850c3bfe7"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.4/mixdog-graph-darwin-arm64",
7
+ "sha256": "1531098011a3c32ad9a6d002e7240b829352fda60afc5708699dd0d335405201"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.3/mixdog-graph-darwin-x64",
11
- "sha256": "0242a4f449ae4d913699f7e40972c5521e09289371da02190a8193a7818cb871"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.4/mixdog-graph-darwin-x64",
11
+ "sha256": "ead5684bebc2ef001724b2d71f23f3e7fc6e52d60d6deb5cea17b73fc3768a1b"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.3/mixdog-graph-linux-arm64",
15
- "sha256": "4e77e1f7f920e4eb99abadd189fe153e73c3eff994c31185a895425a82c4eb1d"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.4/mixdog-graph-linux-arm64",
15
+ "sha256": "8d6d24490dfff31f66967b8384f4afe8f85051f9352c3866a2da32e6ef52d644"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.3/mixdog-graph-linux-x64",
19
- "sha256": "c57fa8f870602e72777bb64bd64e339571f6936cece24c25d940b4d820d67ef3"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.4/mixdog-graph-linux-x64",
19
+ "sha256": "ed26c40956d90d9863d18c028cc7766e9abf5af33405a9cab02f7cbc4a3c7eac"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.3/mixdog-graph-win32-x64.exe",
23
- "sha256": "48f1b2ba11a0d4fd380eda1770fbcb88307370829337d0e65d188619732cb2ef"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.4/mixdog-graph-win32-x64.exe",
23
+ "sha256": "f87bd63e70ecc0a7639f0143b5954a2b673d84e6c59e1ca8f7a8e5cdcc50a21a"
24
24
  }
25
25
  }
26
26
  }
@@ -48,6 +48,7 @@ const APPLY_PATCH_JSON_DESCRIPTION = [
48
48
  'Hunks start with @@ or @@ <symbol|1-based line>; lines start space, -, or +; every Update hunk needs >=1 +/- line; optional *** End of File.',
49
49
  'Use 3 verbatim context lines from newest output (post-patch body after edits); avoid overlap; stack @@ only if ambiguous.',
50
50
  'Project-relative paths; explicit absolute paths only outside the project; + every added line. Never send compacted-history markers; re-read first.',
51
+ 'A same-turn shell runs after all patches succeed.',
51
52
  ].join('\n');
52
53
 
53
54
  export const PATCH_TOOL_DEFS = [
@@ -72,12 +72,6 @@ export function formatToolStartProgress(name, args = {}) {
72
72
  case 'memory':
73
73
  return 'managing memory';
74
74
 
75
- // ── channels module ──────────────────────────────────────────────
76
- case 'reply':
77
- return 'replying';
78
- case 'fetch':
79
- return 'fetching messages';
80
-
81
75
  // ── host_input / cwd ─────────────────────────────────────────────
82
76
  case 'inject_input':
83
77
  return 'injecting input';
@@ -35,6 +35,7 @@ import * as nodeUtil from 'node:util';
35
35
  import { getPluginData } from '../config.mjs';
36
36
  import { startChildGuardian } from '../../../shared/child-guardian.mjs';
37
37
  import { resourceAdmission } from '../../../shared/resource-admission.mjs';
38
+ import { acquire as acquireChildSpawnSlot } from '../../../shared/child-spawn-gate.mjs';
38
39
  // Runtime-only import (used inside execShellCommand's auto-background
39
40
  // transition). shell-jobs.mjs imports stripAnsi from this module, so this is
40
41
  // a static cycle — safe because neither binding is touched at module-eval
@@ -415,7 +416,17 @@ export function execShellCommand({
415
416
  // 'close' fires with 'exit' and grandchildren cannot hold the capture
416
417
  // open. Falls back to pipe capture if the files cannot be opened.
417
418
  const _directCapture = SHELL_DIRECT_CAPTURE ? taskOutput.openDirectCapture() : null;
418
- const spawned = await _spawnShellWithRetry({
419
+ // Spawn-burst gate: hold a 'process-spawn' slot only across process
420
+ // creation (CreateProcess + AV scan + EPERM retries), released the
421
+ // moment the child exists. Bounds the Defender convoy a shell burst
422
+ // creates without limiting how many commands RUN concurrently — the
423
+ // full-lifetime gating concern in the note below stays true.
424
+ const _releaseSpawnSlot = await acquireChildSpawnSlot(abortSignal || null, 'process-spawn', {
425
+ ownerKey: ownerSessionId,
426
+ });
427
+ let spawned;
428
+ try {
429
+ spawned = await _spawnShellWithRetry({
419
430
  shell,
420
431
  argv,
421
432
  shellArg,
@@ -427,20 +438,19 @@ export function execShellCommand({
427
438
  stdio: _directCapture
428
439
  ? ['ignore', _directCapture.stdoutFd, _directCapture.stderrFd]
429
440
  : ['ignore', 'pipe', 'pipe'],
430
- // NOTE (child-spawn-gate): intentionally NOT routed through
431
- // src/runtime/shared/child-spawn-gate.mjs. bash/pwsh commands can run
432
- // for minutes (or auto-background), so holding a finite gate slot for
433
- // the whole lifetime would let a few long shells starve rg/code_graph —
434
- // the opposite of the gate's intent. TODO: if shell saturation becomes
435
- // a problem, gate only the brief spawn burst (release on first output /
436
- // adoption), not the full run.
441
+ // NOTE (child-spawn-gate): the full command lifetime is intentionally
442
+ // NOT gated — bash/pwsh commands can run for minutes and would starve
443
+ // rg/code_graph. Only the spawn window above holds a slot.
437
444
  // POSIX: detached gives the child its own process group so treeKill can
438
445
  // signal the whole group. The child is still CLI-owned because we do
439
446
  // not unref it after adoption. Windows detached has different console
440
447
  // semantics, so it stays off there.
441
448
  detached: process.platform !== 'win32',
442
449
  },
443
- });
450
+ });
451
+ } finally {
452
+ try { _releaseSpawnSlot(); } catch { /* idempotent */ }
453
+ }
444
454
  child = spawned.child;
445
455
  spawned.adoptErrorHandler(_onChildError);
446
456
  }
@@ -10,10 +10,7 @@ import { OutputForwarder } from "./output-forwarder.mjs";
10
10
  // call time — matching the original in-file closure semantics.
11
11
  function createToolDispatch({
12
12
  getForwarder,
13
- PROVIDER_TOOLS,
14
13
  isChannelsDegraded,
15
- dispatchReply,
16
- dispatchFetch,
17
14
  lifecycle,
18
15
  }) {
19
16
  const {
@@ -39,12 +36,6 @@ function createToolDispatch({
39
36
  let result;
40
37
  try {
41
38
  switch (name) {
42
- case "reply":
43
- result = await dispatchReply(args);
44
- break;
45
- case "fetch":
46
- result = await dispatchFetch(args);
47
- break;
48
39
  case "activate_channel_bridge": {
49
40
  const active = args.active === true;
50
41
  const wasActive = getChannelBridgeActive();
@@ -152,23 +143,6 @@ function createToolDispatch({
152
143
  _lastForwardMs = now;
153
144
  await forwarder.forwardNewText();
154
145
  }
155
- if (PROVIDER_TOOLS.has(toolName) && !getBridgeRuntimeConnected()) {
156
- // Remote-owner startup: ensure this owner's provider is connected.
157
- for (let i = 0; i < 2 && !getBridgeRuntimeConnected(); i++) {
158
- try {
159
- // Auto-connect this owner's provider (daemon singleton — no seat claim).
160
- await refreshBridgeOwnership();
161
- } catch {
162
- }
163
- if (!getBridgeRuntimeConnected()) await new Promise((r) => setTimeout(r, 300));
164
- }
165
- if (!getBridgeRuntimeConnected()) {
166
- return {
167
- content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
168
- isError: true
169
- };
170
- }
171
- }
172
146
  const result = await handleToolCall(toolName, args, signal);
173
147
  const toolLine = OutputForwarder.buildToolLine(toolName, args);
174
148
  if (toolLine) {
@@ -61,7 +61,6 @@ import {
61
61
  } from "./crash-log.mjs";
62
62
  import { dropTrace, preview, _dtIdxFlush } from "./index-drop-trace.mjs";
63
63
  import { createVoiceTranscription } from "./voice-transcription.mjs";
64
- import { createProviderDispatch } from "./provider-dispatch.mjs";
65
64
  import { createParentBridge } from "./parent-bridge.mjs";
66
65
  import { createInboundRouting } from "./inbound-routing.mjs";
67
66
  import { createToolDispatch } from "./tool-dispatch.mjs";
@@ -500,7 +499,6 @@ import { TOOL_DEFS } from '../tool-defs.mjs';
500
499
  // bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
501
500
  // orphan worker-level MCP Server: the parent (server.mjs) owns the single
502
501
  // connected transport and routes CallTool through the IPC `call` path.
503
- const PROVIDER_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"]);
504
502
  // ── Inbound routing / dedup / ownership helpers ─────────────────────────────
505
503
  // Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
506
504
  const {
@@ -512,19 +510,6 @@ const {
512
510
  getInstanceId: () => INSTANCE_ID,
513
511
  getChannelOwnerPath,
514
512
  });
515
- // ── Provider-tool dispatch helpers ───────────────────────────────────────────
516
- // Each helper dispatches through the local provider (this process is always the
517
- // owner in opt-in remote mode). Extracted → lib/provider-dispatch.mjs. Bound to
518
- // live config/provider getters so runtime reloads keep the original file-level
519
- // reference semantics.
520
- const {
521
- dispatchReply,
522
- dispatchFetch,
523
- } = createProviderDispatch({
524
- getConfig: () => config,
525
- getProvider: () => provider,
526
- scheduler,
527
- });
528
513
  // ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
529
514
  // handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
530
515
  // lib/tool-dispatch.mjs. The switch is entangled with ~8 runtime-lifecycle
@@ -538,10 +523,7 @@ const {
538
523
  handleToolCallWithBridgeRetry,
539
524
  } = createToolDispatch({
540
525
  getForwarder: () => forwarder,
541
- PROVIDER_TOOLS,
542
526
  isChannelsDegraded,
543
- dispatchReply,
544
- dispatchFetch,
545
527
  lifecycle: {
546
528
  getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
547
529
  getChannelBridgeActive: () => channelBridgeActive,
@@ -1,49 +1,7 @@
1
1
  export const TOOL_DEFS = [
2
- {
3
- name: "reply",
4
- title: "Discord Reply",
5
- annotations: { title: "Discord Reply", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
6
- description: "Send message to configured channel. files are local paths.",
7
- inputSchema: {
8
- type: "object",
9
- properties: {
10
- message: { type: "string", description: "Message text for the configured channel." },
11
- reply_to: { type: "string", description: "Reply message id." },
12
- files: {
13
- type: "array",
14
- items: { type: "string" },
15
- description: "Local file paths."
16
- },
17
- embeds: {
18
- type: "array",
19
- items: { type: "object", additionalProperties: true },
20
- description: "Discord embeds."
21
- },
22
- components: {
23
- type: "array",
24
- items: { type: "object", additionalProperties: true },
25
- description: "Discord components."
26
- }
27
- },
28
- required: ["message"],
29
- additionalProperties: false
30
- }
31
- },
32
- {
33
- name: "fetch",
34
- title: "Fetch",
35
- annotations: { title: "Fetch", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
36
- description: "Discord-only: read recent messages from a channel.",
37
- inputSchema: {
38
- type: "object",
39
- properties: {
40
- channel: { type: "string", description: "Discord channel id." },
41
- limit: { type: "number", description: "Max messages." }
42
- },
43
- required: ["channel"],
44
- additionalProperties: false
45
- }
46
- },
2
+ // reply/fetch model-facing tools removed 2026-08: the output forwarder
3
+ // delivers session output to the channel and the inbound bridge injects
4
+ // incoming messages, so the model never needed to call them directly.
47
5
  // memory and recall_memory tools are now provided by memory-service.mjs via MCP
48
6
  // react/edit_message/download_attachment tools removed (no remaining
49
7
  // callers); provider editMessage/downloadAttachment/react methods stay for
@@ -40,13 +40,24 @@ export function resolveDefaultChildSpawnLaneMaxInflight(
40
40
  if (Number.isFinite(laneOverride) && laneOverride >= 1) return Math.floor(laneOverride);
41
41
  const sharedOverride = Number(env.MIXDOG_CHILD_SPAWN_MAX_INFLIGHT);
42
42
  if (Number.isFinite(sharedOverride) && sharedOverride >= 1) return Math.floor(sharedOverride);
43
- if (platform !== 'win32') return Infinity;
43
+ const cpus = Math.max(1, Math.floor(Number(parallelism) || 1));
44
+ if (platform !== 'win32') {
45
+ // No AV amplification, but disk/CPU saturation is platform-neutral: an
46
+ // unbounded multi-agent burst still convoys. Bound search generously.
47
+ return lane === 'search' ? Math.max(4, Math.min(12, Math.ceil(cpus / 2))) : Infinity;
48
+ }
49
+ // Shell/child process creation window (CreateProcess + Defender scan).
50
+ // Slots are held only across the spawn itself, so a small cap smooths the
51
+ // AV convoy without limiting how many commands run concurrently.
52
+ if (lane === 'process-spawn') return Math.max(2, Math.min(4, Math.floor(cpus / 6) || 2));
53
+ // One cold graph build saturates memory/disk; allow a second only on big hosts.
54
+ if (lane === 'code-graph') return cpus >= 12 ? 2 : 1;
44
55
  if (lane !== 'search') return 1;
45
56
  // Multiple rg processes beat one under multi-session load, but each process
46
- // is itself threaded and Defender amplifies excess fan-out. Scale slowly and
47
- // cap at four: 4 cores→1, 8→2, 12→3, 16+→4.
48
- const cpus = Math.max(1, Math.floor(Number(parallelism) || 1));
49
- return Math.max(1, Math.min(4, Math.floor(cpus / 4)));
57
+ // is itself threaded and Defender amplifies excess fan-out. Burst stress
58
+ // measured cap-4 starvation (find p50 15s at 8 concurrent sessions), so
59
+ // scale with cores and cap at eight: 4→2, 8→3, 12→4, 18→6, 24+→8.
60
+ return Math.max(2, Math.min(8, Math.ceil(cpus / 3)));
50
61
  }
51
62
 
52
63
  const DEFAULT_MAX_INFLIGHT = resolveDefaultChildSpawnMaxInflight();
@@ -400,7 +400,6 @@ export function summarizeToolResult(name, args, resultText, isError = false) {
400
400
  case 'remember':
401
401
  case 'save_memory':
402
402
  case 'update_memory':
403
- case 'reply':
404
403
  case 'request_user_input':
405
404
  case 'update_plan':
406
405
  case 'cwd':
@@ -175,8 +175,6 @@ export function displayToolName(name, args = {}) {
175
175
  return 'Agent';
176
176
  case 'code_graph':
177
177
  return codeGraphLabel(parseToolArgs(args));
178
- case 'reply':
179
- return 'Channel';
180
178
  default:
181
179
  return titleizeToolName(name);
182
180
  }
@@ -308,8 +306,6 @@ export function summarizeToolArgs(name, args, { max = DEFAULT_SUMMARY_MAX } = {}
308
306
  }
309
307
  case 'code_graph':
310
308
  return codeGraphSummary(a, max);
311
- case 'reply':
312
- return truncateToolText(a.channel || a.channelId || a.messageId || a.emoji || '', max);
313
309
  case 'skill':
314
310
  case 'skill_execute':
315
311
  case 'skill_view':
@@ -384,7 +380,6 @@ const TOOL_CATEGORY = new Map([
384
380
  ['job_wait', 'Shell'],
385
381
  ['task', 'Task'],
386
382
  ['agent', 'Agent'],
387
- ['reply', 'Channel'],
388
383
  ['list_mcp_resources', 'Setup'],
389
384
  ['list_mcp_resource_templates', 'Setup'],
390
385
  ['cwd', 'Setup'],
@@ -576,8 +571,6 @@ export function toolWorkUnit(name, args = {}, category = '') {
576
571
  case 'skills_list':
577
572
  case 'use_skill':
578
573
  return unitDescriptor('Skill', { count: queryCount(a, 'name', 'skill', 'skill_name', 'query', 'q') || 1, noun: 'skill' });
579
- case 'reply':
580
- return unitDescriptor('Channel', { count: queryCount(a, 'messages', 'messageId', 'text') || 1, noun: 'message' });
581
574
  case 'code_graph': {
582
575
  const mode = String(a.mode || a.action || '').toLowerCase();
583
576
  const searching = mode === 'search' || mode === 'find_symbol' || mode === 'references' || mode === 'callers' || mode === 'callees';
@@ -0,0 +1,13 @@
1
+ // Evaluate BEFORE the first libuv threadpool consumer (async fs/dns/zlib/
2
+ // crypto): Node sizes the pool lazily on first use from UV_THREADPOOL_SIZE.
3
+ // The default of 4 starves bursty multi-session tool execution — dozens of
4
+ // concurrent async stats/reads queue behind 4 workers and simple stats take
5
+ // seconds (observed >5s, tripping the dead-mount reachability guard on live
6
+ // local paths). Size to the host, bounded: operators override by setting the
7
+ // env var themselves.
8
+ import { availableParallelism } from 'node:os';
9
+
10
+ if (!process.env.UV_THREADPOOL_SIZE) {
11
+ const cpus = Math.max(1, Number(availableParallelism()) || 1);
12
+ process.env.UV_THREADPOOL_SIZE = String(Math.min(16, Math.max(8, cpus)));
13
+ }
@@ -1,3 +1,4 @@
1
+ import '../runtime/shared/uv-threadpool-boot.mjs';
1
2
  import { createSessionLifecycle } from './session-lifecycle.mjs';
2
3
  import { createSessionTitleController } from './session-title.mjs';
3
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
@@ -42,7 +42,7 @@ export const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
42
42
 
43
43
  export const READONLY_TOOL_NAMES = new Set([
44
44
  'read', 'list', 'grep', 'find', 'glob', 'code_graph', 'search',
45
- 'web_fetch', 'recall', 'memory', 'fetch', 'Skill',
45
+ 'web_fetch', 'recall', 'memory', 'Skill',
46
46
  ]);
47
47
 
48
48
  export const DEFERRED_SELECT_ALIASES = {
@@ -50,8 +50,6 @@ export const DEFERRED_SELECT_ALIASES = {
50
50
  search: ['search', 'web_fetch'],
51
51
  web: ['web_fetch', 'search'],
52
52
  memory: ['memory', 'recall'],
53
- channels: ['reply', 'fetch'],
54
- discord: ['reply', 'fetch'],
55
53
  agent: ['agent'],
56
54
  graph: ['code_graph'],
57
55
  code: ['code_graph'],
@@ -145,6 +145,14 @@ export function createWarmupSchedulers({
145
145
  void warmCatalogsInBackground()
146
146
  .then(() => bootProfile('model-catalog:warm-ready'))
147
147
  .catch((error) => bootProfile('model-catalog:warm-failed', { error: error?.message || String(error) }));
148
+ // Resident native search server: pre-resolve the binary and start the
149
+ // warm process so the session's FIRST grep/glob already skips the rg
150
+ // spawn instead of racing the async binary resolution. Best-effort;
151
+ // honors MIXDOG_SEARCH_SERVER=0 inside the warm call.
152
+ void import('../runtime/agent/orchestrator/tools/builtin/native-search-client.mjs')
153
+ .then((mod) => mod.warmNativeSearchServer?.())
154
+ .then((up) => bootProfile('native-search:warm', { up: up === true }))
155
+ .catch(() => {});
148
156
  }, delayMs);
149
157
  timers.modelCatalogWarmupTimer.unref?.();
150
158
  }
@@ -24,6 +24,11 @@ const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
24
24
  const BUILTIN_SLOT_AGENT_IDS = new Set(
25
25
  FIXED_AGENT_SLOTS.filter((agent) => agent.workflowSlot).map((agent) => agent.id),
26
26
  );
27
+ const STARTER_AGENT_ORDER = new Map([
28
+ ['worker', 0],
29
+ ['heavy-worker', 1],
30
+ ['reviewer', 2],
31
+ ]);
27
32
  export const DEFAULT_WORKFLOW_ID = 'default';
28
33
 
29
34
  const SEARCH_CAPABLE_PROVIDERS = new Set([
@@ -157,7 +162,14 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
157
162
  ids.add(id);
158
163
  }
159
164
  }
160
- return [...ids].sort();
165
+ return [...ids].sort((left, right) => {
166
+ const leftRank = STARTER_AGENT_ORDER.get(left);
167
+ const rightRank = STARTER_AGENT_ORDER.get(right);
168
+ if (leftRank !== undefined || rightRank !== undefined) {
169
+ return (leftRank ?? Number.MAX_SAFE_INTEGER) - (rightRank ?? Number.MAX_SAFE_INTEGER);
170
+ }
171
+ return left.localeCompare(right);
172
+ });
161
173
  }
162
174
 
163
175
  function readWorkflowPackFromDir(dir, source = 'built-in', dirName = '') {
@@ -11,8 +11,6 @@ import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '.
11
11
  import { attachChannel, readChannelDiscovery, probeChannelHealth } from './channel-client.mjs';
12
12
 
13
13
  const CHANNEL_TOOLS = new Set([
14
- 'reply',
15
- 'fetch',
16
14
  'activate_channel_bridge',
17
15
  'reload_config',
18
16
  'rebind_current_transcript',
@@ -17,6 +17,9 @@
17
17
  process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
18
18
  // This process owns session runtimes and must never proxy back into itself.
19
19
  process.env.MIXDOG_DAEMON_HOST = '1';
20
+ // Size the libuv threadpool before any async fs work spins it up (see
21
+ // uv-threadpool-boot.mjs) — imports below already touch fs.
22
+ await import('../runtime/shared/uv-threadpool-boot.mjs');
20
23
 
21
24
  // V8 compile cache: the daemon is a standalone child entry (not via cli.mjs);
22
25
  // caching compiled bytecode across restarts removes the channels+memory
@@ -3719,7 +3719,6 @@ function summarizeToolResult(name, args, resultText, isError = false) {
3719
3719
  case "remember":
3720
3720
  case "save_memory":
3721
3721
  case "update_memory":
3722
- case "reply":
3723
3722
  case "request_user_input":
3724
3723
  case "update_plan":
3725
3724
  case "cwd":
@@ -3914,8 +3913,6 @@ function displayToolName(name, args = {}) {
3914
3913
  return "Agent";
3915
3914
  case "code_graph":
3916
3915
  return codeGraphLabel(parseToolArgs(args));
3917
- case "reply":
3918
- return "Channel";
3919
3916
  default:
3920
3917
  return titleizeToolName(name);
3921
3918
  }
@@ -4045,8 +4042,6 @@ function summarizeToolArgs(name, args, { max = DEFAULT_SUMMARY_MAX } = {}) {
4045
4042
  }
4046
4043
  case "code_graph":
4047
4044
  return codeGraphSummary(a, max);
4048
- case "reply":
4049
- return truncateToolText(a.channel || a.channelId || a.messageId || a.emoji || "", max);
4050
4045
  case "skill":
4051
4046
  case "skill_execute":
4052
4047
  case "skill_view":
@@ -4125,7 +4120,6 @@ var TOOL_CATEGORY = /* @__PURE__ */ new Map([
4125
4120
  ["job_wait", "Shell"],
4126
4121
  ["task", "Task"],
4127
4122
  ["agent", "Agent"],
4128
- ["reply", "Channel"],
4129
4123
  ["list_mcp_resources", "Setup"],
4130
4124
  ["list_mcp_resource_templates", "Setup"],
4131
4125
  ["cwd", "Setup"],
@@ -4293,8 +4287,6 @@ function toolWorkUnit(name, args = {}, category = "") {
4293
4287
  case "skills_list":
4294
4288
  case "use_skill":
4295
4289
  return unitDescriptor("Skill", { count: queryCount(a, "name", "skill", "skill_name", "query", "q") || 1, noun: "skill" });
4296
- case "reply":
4297
- return unitDescriptor("Channel", { count: queryCount(a, "messages", "messageId", "text") || 1, noun: "message" });
4298
4290
  case "code_graph": {
4299
4291
  const mode = String(a.mode || a.action || "").toLowerCase();
4300
4292
  const searching = mode === "search" || mode === "find_symbol" || mode === "references" || mode === "callers" || mode === "callees";
@@ -50,6 +50,10 @@ export function createSessionApiA(bag) {
50
50
  const {
51
51
  runtime, nextId, flags, pending, listeners, getState, getPublishedState = getState, set, flushEmitImmediate, pushItem, patchItem, replaceItems, restoreOlderTranscript, restoreNewerTranscript, settleStreamingTail, clearStreamingTail, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, prioritizeQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick, discardExecutionPendingResume,
52
52
  } = bag;
53
+ // submitAsync may be awaiting auto-clear while the renderer already owns an
54
+ // optimistic user row. Keep that intake addressable so Esc can reclaim it
55
+ // before enqueue()/busy publication without racing a delayed snapshot.
56
+ const acceptingSubmissions = new Map();
53
57
  const submission = (text, options = {}) => {
54
58
  const t = promptDisplayText(text, options).trim();
55
59
  if (!t) return null;
@@ -58,7 +62,7 @@ export function createSessionApiA(bag) {
58
62
  // priority, so it is injected at the next tool/model boundary. Explicit
59
63
  // options.priority still wins.
60
64
  const priority = options.priority;
61
- return {
65
+ const intake = {
62
66
  text,
63
67
  queueOptions: {
64
68
  ...options,
@@ -70,6 +74,14 @@ export function createSessionApiA(bag) {
70
74
  priority,
71
75
  },
72
76
  };
77
+ acceptingSubmissions.set(intake.queueOptions.id, intake);
78
+ return intake;
79
+ };
80
+ const enqueueSubmission = (intake) => {
81
+ const submissionId = String(intake?.queueOptions?.id || '').trim();
82
+ if (submissionId) acceptingSubmissions.delete(submissionId);
83
+ if (intake?.cancelled === true) return false;
84
+ return enqueue(intake.text, intake.queueOptions);
73
85
  };
74
86
  const submit = (text, options = {}) => {
75
87
  const intake = submission(text, options);
@@ -77,7 +89,7 @@ export function createSessionApiA(bag) {
77
89
  // A running clear (idle auto-clear or session_manage) sets commandBusy;
78
90
  // queue the prompt instead of dropping it — it drains after the clear.
79
91
  if (flags.autoClearRunning) {
80
- return enqueue(intake.text, intake.queueOptions) !== false;
92
+ return enqueueSubmission(intake) !== false;
81
93
  }
82
94
  // Any in-flight session command (clear/setModel/newSession/resume/...)
83
95
  // holds commandBusy. Previously the prompt was dropped here and only the
@@ -85,16 +97,16 @@ export function createSessionApiA(bag) {
85
97
  // commandBusy, and the central release hook re-kicks drain once the
86
98
  // command settles, so the prompt runs afterwards rather than vanishing.
87
99
  if (getState().commandBusy) {
88
- return enqueue(intake.text, intake.queueOptions) !== false;
100
+ return enqueueSubmission(intake) !== false;
89
101
  }
90
102
  if (getState().busy) {
91
- return enqueue(intake.text, intake.queueOptions) !== false;
103
+ return enqueueSubmission(intake) !== false;
92
104
  }
93
105
  // If autoClearBeforeSubmit rejects (e.g. compaction timeout throws), the
94
106
  // prompt must still be queued — swallow the rejection so enqueue always
95
107
  // runs and the submit is never silently lost.
96
108
  void autoClearBeforeSubmit().catch(() => {}).then(
97
- () => enqueue(intake.text, intake.queueOptions),
109
+ () => enqueueSubmission(intake),
98
110
  );
99
111
  return true;
100
112
  };
@@ -108,7 +120,7 @@ export function createSessionApiA(bag) {
108
120
  if (!flags.autoClearRunning && !getState().commandBusy && !getState().busy) {
109
121
  await autoClearBeforeSubmit().catch(() => {});
110
122
  }
111
- return enqueue(intake.text, intake.queueOptions) !== false;
123
+ return enqueueSubmission(intake) !== false;
112
124
  };
113
125
  return {
114
126
  getState: () => getPublishedState(),
@@ -634,11 +646,33 @@ export function createSessionApiA(bag) {
634
646
  set({ commandBusy: false, commandStatus: null });
635
647
  }
636
648
  },
637
- compact: async () => {
649
+ compact: async function compactCommand() {
638
650
  if (getState().commandBusy) return null;
639
651
  if (getState().busy) {
640
- pushNotice('Compact skipped: turn in progress', 'info');
641
- return { changed: false, reason: 'compact skipped: turn in progress' };
652
+ // Schedule instead of dropping: /compact mid-turn runs once the turn
653
+ // AND the queued follow-ups finish (compacting between queued turns
654
+ // would interleave a summary pass into the user's planned sequence).
655
+ // One pending schedule at a time; the timer is unref'd so it never
656
+ // holds the process open, and any error path clears it.
657
+ if (bag._scheduledCompactTimer) {
658
+ pushNotice('Compact already scheduled for turn end', 'info');
659
+ return { changed: false, scheduled: true };
660
+ }
661
+ pushNotice('Compact scheduled: runs when the current turn finishes', 'info');
662
+ bag._scheduledCompactTimer = setInterval(() => {
663
+ try {
664
+ const s = getState();
665
+ if (s.busy || s.commandBusy || (s.queued || []).length > 0) return;
666
+ clearInterval(bag._scheduledCompactTimer);
667
+ bag._scheduledCompactTimer = null;
668
+ void compactCommand();
669
+ } catch {
670
+ try { clearInterval(bag._scheduledCompactTimer); } catch { /* gone */ }
671
+ bag._scheduledCompactTimer = null;
672
+ }
673
+ }, 750);
674
+ bag._scheduledCompactTimer.unref?.();
675
+ return { changed: false, scheduled: true };
642
676
  }
643
677
  const startedAt = Date.now();
644
678
  set({ commandBusy: true, commandStatus: { active: true, verb: 'Compacting conversation', startedAt, mode: 'compacting' } });
@@ -690,7 +724,34 @@ export function createSessionApiA(bag) {
690
724
  }
691
725
  },
692
726
  abort: (options = {}) => {
693
- if (!getState().busy) return false;
727
+ const submissionId = String(options?.submissionId || '').trim();
728
+ if (!getState().busy) {
729
+ if (!submissionId) return false;
730
+ const restored = restoreQueued('', submissionId);
731
+ if (!restored || Number(restored.count) < 1) {
732
+ const intake = acceptingSubmissions.get(submissionId);
733
+ if (!intake) return false;
734
+ intake.cancelled = true;
735
+ const attachments = hydratePastedAttachments(
736
+ intake.queueOptions.pastedImages,
737
+ intake.queueOptions.pastedTexts,
738
+ );
739
+ return {
740
+ aborted: false,
741
+ restoreText: String(intake.queueOptions.displayText || '').trim(),
742
+ pastedImages: attachments.pastedImages,
743
+ pastedTexts: attachments.pastedTexts,
744
+ restoredSubmissionIds: [submissionId],
745
+ };
746
+ }
747
+ return {
748
+ aborted: false,
749
+ restoreText: restored.text,
750
+ pastedImages: restored.pastedImages,
751
+ pastedTexts: restored.pastedTexts,
752
+ restoredSubmissionIds: restored.ids,
753
+ };
754
+ }
694
755
  denyAllToolApprovals('interrupted by user');
695
756
  const restoreState = flags.activePromptRestore;
696
757
  // A queued steering prompt means the user already redirected the turn:
@@ -771,6 +832,9 @@ export function createSessionApiA(bag) {
771
832
  discardPastedImages,
772
833
  pastedTexts: restored.pastedTexts,
773
834
  discardPastedTexts,
835
+ restoredSubmissionIds: restoreText
836
+ ? (restoreState?.submittedIds || []).map(String).filter(Boolean)
837
+ : [],
774
838
  };
775
839
  },
776
840
  };
@@ -1,61 +0,0 @@
1
- import { recordFetchedMessages } from "./status-snapshot.mjs";
2
-
3
- // Provider-tool dispatch helpers. Extracted verbatim from channels/index.mjs
4
- // (behavior-preserving). Bound to live getters so runtime config/provider
5
- // reloads keep the original file-level reference semantics.
6
- function createProviderDispatch({ getConfig, getProvider, scheduler }) {
7
- async function dispatchReply(args) {
8
- const config = getConfig();
9
- const channelId = String(args?.chat_id || args?.channel_id || args?.channel || config.channelId || '').trim();
10
- const message = args?.message ?? args?.text;
11
- if (!channelId) throw new Error('reply requires a configured channel id');
12
- if (typeof message !== 'string' || !message.trim()) throw new Error('reply requires message text');
13
- let files = args?.files ?? [];
14
- if (typeof files === 'string') {
15
- try {
16
- const parsed = JSON.parse(files);
17
- files = Array.isArray(parsed) ? parsed : [files];
18
- } catch {
19
- files = [files];
20
- }
21
- }
22
- if (!Array.isArray(files)) files = [files];
23
- const sendOpts = {
24
- replyTo: args?.reply_to ?? args?.replyTo,
25
- files,
26
- embeds: args?.embeds ?? [],
27
- components: args?.components ?? []
28
- };
29
- let ids;
30
- // Pre-send activity bump keeps idle gating consistent during the await.
31
- scheduler.noteActivity();
32
- const sendResult = await getProvider().sendMessage(channelId, message, sendOpts);
33
- scheduler.noteActivity();
34
- ids = sendResult.sentIds;
35
- const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
36
- return { content: [{ type: "text", text }] };
37
- }
38
-
39
- async function dispatchFetch(args) {
40
- // `args.channel` is a raw channel id (no label resolution anymore); when
41
- // omitted, fall back to the single configured main channel id.
42
- const config = getConfig();
43
- const channelId = args.channel || config.channelId || "";
44
- const limit = args.limit ?? 20;
45
- let msgs;
46
- msgs = await getProvider().fetchMessages(channelId, limit);
47
- recordFetchedMessages(channelId, channelId, msgs);
48
- const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
49
- const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
50
- return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
51
- }).join("\n");
52
- return { content: [{ type: "text", text }] };
53
- }
54
-
55
- return {
56
- dispatchReply,
57
- dispatchFetch,
58
- };
59
- }
60
-
61
- export { createProviderDispatch };