mixdog 0.9.134 → 0.9.136

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 (133) hide show
  1. package/package.json +3 -1
  2. package/scripts/tool-stress.mjs +8 -1
  3. package/src/agents/maintainer/agent.json +1 -1
  4. package/src/defaults/skills/setup/SKILL.md +8 -43
  5. package/src/headless-exec.mjs +26 -9
  6. package/src/headless-exec.test.mjs +55 -2
  7. package/src/help.mjs +0 -2
  8. package/src/rules/lead/01-general.md +0 -4
  9. package/src/rules/shared/01-tool.md +12 -4
  10. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +30 -4
  11. package/src/runtime/agent/orchestrator/agent-trace-timing.test.mjs +57 -0
  12. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +3 -3
  13. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.test.mjs +40 -0
  14. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +5 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +15 -6
  18. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +25 -4
  19. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +40 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +30 -7
  21. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +150 -41
  23. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +60 -0
  25. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +18 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +10 -9
  27. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +138 -19
  28. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-top-k.test.mjs +39 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +7 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +4 -1
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +80 -3
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +82 -5
  33. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +2 -2
  34. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +3 -19
  35. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +25 -0
  36. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  37. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +165 -17
  38. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +162 -0
  39. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +36 -8
  40. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +87 -0
  41. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  42. package/src/runtime/channels/lib/config.mjs +14 -101
  43. package/src/runtime/channels/lib/index-drop-trace.mjs +2 -5
  44. package/src/runtime/channels/lib/owned-runtime.mjs +4 -80
  45. package/src/runtime/channels/lib/scheduler.mjs +75 -18
  46. package/src/runtime/channels/lib/status-snapshot.mjs +5 -68
  47. package/src/runtime/channels/lib/tool-dispatch.mjs +12 -55
  48. package/src/runtime/channels/lib/worker-ipc.mjs +1 -16
  49. package/src/runtime/channels/lib/worker-main.mjs +22 -307
  50. package/src/runtime/media/renditions.mjs +68 -5
  51. package/src/runtime/media/renditions.test.mjs +45 -0
  52. package/src/runtime/media/store.mjs +1 -0
  53. package/src/runtime/memory/index.mjs +4 -3
  54. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -6
  55. package/src/runtime/memory/lib/memory-cycle-packets.test.mjs +7 -1
  56. package/src/runtime/memory/lib/memory-cycle-requests.mjs +13 -0
  57. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  58. package/src/runtime/shared/config.mjs +8 -49
  59. package/src/runtime/shared/schedule-time.mjs +25 -0
  60. package/src/runtime/shared/schedule-time.test.mjs +29 -0
  61. package/src/runtime/shared/schedules-db.mjs +73 -1
  62. package/src/runtime/shared/tool-card-model.mjs +1 -1
  63. package/src/runtime/shared/transcript-writer.mjs +10 -17
  64. package/src/runtime/shared/update-checker.mjs +7 -2
  65. package/src/runtime/shared/update-checker.test.mjs +33 -0
  66. package/src/session-runtime/channel-config-api.mjs +3 -20
  67. package/src/session-runtime/config-lifecycle.mjs +1 -42
  68. package/src/session-runtime/lifecycle-api.mjs +10 -21
  69. package/src/session-runtime/model-route-api.mjs +0 -2
  70. package/src/session-runtime/model-settings-persist.test.mjs +0 -1
  71. package/src/session-runtime/prewarm.mjs +11 -7
  72. package/src/session-runtime/prewarm.test.mjs +0 -1
  73. package/src/session-runtime/runtime-core.mjs +20 -186
  74. package/src/session-runtime/session-lifecycle.mjs +4 -17
  75. package/src/session-runtime/session-transcript.mjs +54 -0
  76. package/src/session-runtime/session-turn-api.mjs +4 -44
  77. package/src/session-runtime/settings-api.mjs +0 -45
  78. package/src/session-runtime/tool-catalog-schema.mjs +1 -1
  79. package/src/session-runtime/tool-policy-surface.test.mjs +4 -0
  80. package/src/standalone/channel-admin.mjs +8 -150
  81. package/src/standalone/channel-transport.mjs +13 -0
  82. package/src/standalone/daemon.mjs +19 -49
  83. package/src/standalone/seeds.mjs +18 -1
  84. package/src/standalone/session-protocol.mjs +0 -8
  85. package/src/standalone/session-service.mjs +1 -3
  86. package/src/tui/App.jsx +0 -5
  87. package/src/tui/app/app-view.jsx +1 -2
  88. package/src/tui/app/create-app-pickers.mjs +0 -20
  89. package/src/tui/app/doctor.mjs +2 -6
  90. package/src/tui/app/prompt-submit.mjs +2 -49
  91. package/src/tui/app/settings-picker.mjs +22 -96
  92. package/src/tui/app/slash-commands.mjs +0 -2
  93. package/src/tui/app/slash-dispatch.mjs +0 -17
  94. package/src/tui/app/use-prompt-handlers.mjs +4 -1
  95. package/src/tui/components/StatusLine.jsx +4 -4
  96. package/src/tui/dist/index.mjs +1003 -1461
  97. package/src/tui/session/context-state.mjs +0 -1
  98. package/src/tui/session/queue-helpers.mjs +1 -1
  99. package/src/tui/session/queue-helpers.test.mjs +10 -1
  100. package/src/tui/session/session-api-ext.mjs +0 -89
  101. package/src/tui/session/session-api.mjs +0 -2
  102. package/src/tui/session/tool-card-results.mjs +1 -1
  103. package/src/tui/session/tool-result-status.mjs +3 -3
  104. package/src/tui/session/tool-result-status.test.mjs +3 -3
  105. package/src/tui/session-local.mjs +0 -26
  106. package/src/workflows/default/WORKFLOW.md +13 -15
  107. package/src/workflows/solo/WORKFLOW.md +6 -7
  108. package/src/runtime/channels/lib/config-lock.mjs +0 -13
  109. package/src/runtime/channels/lib/drop-trace.mjs +0 -71
  110. package/src/runtime/channels/lib/format.mjs +0 -190
  111. package/src/runtime/channels/lib/inbound-handler.mjs +0 -387
  112. package/src/runtime/channels/lib/inbound-routing.mjs +0 -81
  113. package/src/runtime/channels/lib/interaction-handlers.mjs +0 -70
  114. package/src/runtime/channels/lib/interaction-workflows.mjs +0 -76
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -289
  116. package/src/runtime/channels/lib/output-forwarder.mjs +0 -934
  117. package/src/runtime/channels/lib/session-control.mjs +0 -36
  118. package/src/runtime/channels/lib/session-discovery.mjs +0 -217
  119. package/src/runtime/channels/lib/session-discovery.test.mjs +0 -32
  120. package/src/runtime/channels/lib/telegram-format.mjs +0 -280
  121. package/src/runtime/channels/lib/tool-format.mjs +0 -125
  122. package/src/runtime/channels/lib/transcript-binding.mjs +0 -336
  123. package/src/runtime/channels/lib/transcript-discovery.mjs +0 -208
  124. package/src/runtime/channels/providers/discord-access.mjs +0 -30
  125. package/src/runtime/channels/providers/discord-attachments.mjs +0 -69
  126. package/src/runtime/channels/providers/discord-gateway.mjs +0 -230
  127. package/src/runtime/channels/providers/discord.mjs +0 -730
  128. package/src/runtime/channels/providers/telegram.mjs +0 -598
  129. package/src/runtime/channels/providers/telegram.test.mjs +0 -75
  130. package/src/session-runtime/remote-control.mjs +0 -126
  131. package/src/session-runtime/remote-transcript.mjs +0 -134
  132. package/src/session-runtime/remote-transition-queue.mjs +0 -14
  133. package/src/tui/app/channel-pickers.mjs +0 -384
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.16",
2
+ "version": "0.1.18",
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.16/mixdog-graph-darwin-arm64",
7
- "sha256": "29ff99fd358759b78ddd2df4096e56e80e7302c7e2b173c5a851d3198ad39e29"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.18/mixdog-graph-darwin-arm64",
7
+ "sha256": "c89403d4a4cd08aa27427b65e9f0efe4885fb35cbe788527b81279e03edc82eb"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.16/mixdog-graph-darwin-x64",
11
- "sha256": "c65ecd85d3d51b061972a10ee57eae0b1b6bd0a8760eb3c260e044c10d83bd60"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.18/mixdog-graph-darwin-x64",
11
+ "sha256": "43765bb6598f0b7db3ba5df8348bb7221fc0e4fec5f8ad9d4d53887d4951e42f"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.16/mixdog-graph-linux-arm64",
15
- "sha256": "4af49fb83e3f95230849c3c8c4a959f52053e81e317d038febc662ec3241fa53"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.18/mixdog-graph-linux-arm64",
15
+ "sha256": "ada18e0d9ca3ab8fe65098b81c8856d01992eb2f33a2f2923e886280e2b635a4"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.16/mixdog-graph-linux-x64",
19
- "sha256": "723efc22949442abe9c60733b78ad3f59ccf15778debbfb98efe167b2d8de506"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.18/mixdog-graph-linux-x64",
19
+ "sha256": "724d219ec72828662f34ba7b0edf00938e8f9f00df76cb6c41675551e2c0137e"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.16/mixdog-graph-win32-x64.exe",
23
- "sha256": "685914bc3e56c545e23c63fe236cf260c5784707720bc29d63431b923e00a83a"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.18/mixdog-graph-win32-x64.exe",
23
+ "sha256": "c24f4ffb0beec3f355bc4e33c02adafce2ba966df34922ec0ff2ce2cde23fe65"
24
24
  }
25
25
  }
26
26
  }
@@ -1,13 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
+ import { dirname, resolve as pathResolve } from 'node:path';
3
4
  import { createInterface } from 'node:readline';
4
5
  import { EventEmitter } from 'node:events';
6
+ import { fileURLToPath } from 'node:url';
5
7
  import { hiddenSpawnOpts } from '../../../../shared/spawn-flags.mjs';
6
8
  import { packageNativeToolPath } from '../../../../shared/native-tool-paths.mjs';
7
9
  import { getPluginData } from '../../config.mjs';
8
10
  import { ensureSpawnBinary, findCachedSpawnBinary } from '../spawn-binary-fetcher.mjs';
9
11
 
10
12
  const RESTART_BACKOFF_MS = 30_000;
13
+ const SERVER_READY_TIMEOUT_MS = 5_000;
11
14
 
12
15
  let _server = null;
13
16
  let _binaryPath = undefined;
@@ -57,29 +60,31 @@ function acceptTask(message) {
57
60
  class LineStream extends EventEmitter {
58
61
  constructor() {
59
62
  super();
60
- this._pending = '';
63
+ this._pending = [];
61
64
  }
62
65
  setEncoding() { return this; }
63
66
  emit(event, ...args) {
64
67
  if (event === 'data' && this.listenerCount('data') === 0) {
65
- this._pending += args[0] == null ? '' : String(args[0]);
68
+ if (args[0] != null) this._pending.push(args[0]);
66
69
  return false;
67
70
  }
68
71
  return super.emit(event, ...args);
69
72
  }
70
73
  on(event, listener) {
71
74
  const ret = super.on(event, listener);
72
- if (event === 'data' && this._pending) {
75
+ if (event === 'data' && this._pending.length > 0) {
73
76
  const pending = this._pending;
74
- this._pending = '';
75
- queueMicrotask(() => listener(pending));
77
+ this._pending = [];
78
+ queueMicrotask(() => {
79
+ for (const chunk of pending) listener(chunk);
80
+ });
76
81
  }
77
82
  return ret;
78
83
  }
79
84
  }
80
85
 
81
86
  export class NativeSpawnChild extends EventEmitter {
82
- constructor(id, cancel) {
87
+ constructor(id, cancel, send = null) {
83
88
  super();
84
89
  this.pid = undefined;
85
90
  this.killed = false;
@@ -91,9 +96,27 @@ export class NativeSpawnChild extends EventEmitter {
91
96
  this.__nativeSpawn = true;
92
97
  this._id = id;
93
98
  this._cancel = cancel;
99
+ this._send = send;
94
100
  this._closed = false;
95
101
  }
96
102
 
103
+ // stdinPipe children only (warm shell standby): forward script text to the
104
+ // child's stdin via the spawn server. No-ops (false) when the child was
105
+ // spawned without stdinPipe or the server link is gone.
106
+ writeStdin(text, { close = false } = {}) {
107
+ if (this._closed || this.killed || typeof this._send !== 'function') return false;
108
+ return this._send({
109
+ stdinWrite: this._id,
110
+ data: String(text ?? ''),
111
+ ...(close ? { close: true } : {}),
112
+ });
113
+ }
114
+
115
+ endStdin() {
116
+ if (typeof this._send !== 'function') return false;
117
+ return this._send({ stdinClose: this._id });
118
+ }
119
+
97
120
  kill() {
98
121
  this.killed = true;
99
122
  this._cancel?.(this._id);
@@ -124,6 +147,17 @@ function _resolveBinary() {
124
147
  _binaryPath = explicit;
125
148
  return _binaryPath;
126
149
  }
150
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
151
+ const binName = process.platform === 'win32' ? 'mixdog-spawn.exe' : 'mixdog-spawn';
152
+ const localBuild = pathResolve(
153
+ moduleDir,
154
+ '../../../../../../native/mixdog-spawn/target/release',
155
+ binName,
156
+ );
157
+ if (existsSync(localBuild)) {
158
+ _binaryPath = localBuild;
159
+ return _binaryPath;
160
+ }
127
161
  const installed = packageNativeToolPath('spawn');
128
162
  if (existsSync(installed)) {
129
163
  _binaryPath = installed;
@@ -140,6 +174,28 @@ function _setServerReferenced(server, referenced) {
140
174
  try { server?.child?.stdout?.[method]?.(); } catch {}
141
175
  }
142
176
 
177
+ // Referenced only while a NON-idle request is pending. Idle requests (parked
178
+ // warm shell standbys) live for minutes-to-hours; counting them would pin the
179
+ // host event loop — a one-shot CLI or test runner could never exit.
180
+ function _recomputeServerRef(server) {
181
+ let active = false;
182
+ for (const entry of server.pending.values()) {
183
+ if (!entry.idle) { active = true; break; }
184
+ }
185
+ _setServerReferenced(server, active);
186
+ }
187
+
188
+ /** Mark a pending spawn request idle (parked) or active. Idle requests do not
189
+ * keep the host process alive. Returns false when the request is gone. */
190
+ export function setNativeSpawnRequestIdle(child, idle = true) {
191
+ const server = _server;
192
+ const entry = server?.pending?.get?.(Number(child?._id));
193
+ if (!entry) return false;
194
+ entry.idle = idle === true;
195
+ _recomputeServerRef(server);
196
+ return true;
197
+ }
198
+
143
199
  function _teardown(error) {
144
200
  const server = _server;
145
201
  _server = null;
@@ -164,6 +220,30 @@ function _teardown(error) {
164
220
  try { server.child.kill(); } catch {}
165
221
  }
166
222
 
223
+ export async function shutdownNativeSpawnServer(reason = 'process-exit', timeoutMs = 1_000) {
224
+ const server = _server;
225
+ if (!server) return true;
226
+ const child = server.child;
227
+ const exited = new Promise((resolve) => {
228
+ child.once('exit', () => resolve(true));
229
+ child.once('error', () => resolve(true));
230
+ });
231
+ try { child.stdin?.end?.(); } catch {}
232
+ _teardown(new Error(`native spawn server shutdown (${reason})`));
233
+ let timer;
234
+ const stopped = await Promise.race([
235
+ exited,
236
+ new Promise((resolve) => {
237
+ timer = setTimeout(() => resolve(false), Math.max(10, Number(timeoutMs) || 1_000));
238
+ }),
239
+ ]);
240
+ if (timer) clearTimeout(timer);
241
+ if (!stopped) {
242
+ try { child.kill('SIGKILL'); } catch {}
243
+ }
244
+ return stopped;
245
+ }
246
+
167
247
  function _ensureServer() {
168
248
  if (_server) return _server;
169
249
  if (Date.now() - _lastFailureAt < RESTART_BACKOFF_MS) return null;
@@ -179,19 +259,38 @@ function _ensureServer() {
179
259
  _lastFailureAt = Date.now();
180
260
  return null;
181
261
  }
182
- const server = { child, pending: new Map(), sequence: 0 };
262
+ const server = { child, pending: new Map(), sequence: 0, ready: false, caps: {} };
263
+ let resolveReady;
264
+ server.readyPromise = new Promise((resolve) => { resolveReady = resolve; });
265
+ const settleReady = (value) => {
266
+ if (!resolveReady) return;
267
+ const resolve = resolveReady;
268
+ resolveReady = null;
269
+ resolve(value);
270
+ };
183
271
  const lines = createInterface({ input: child.stdout });
184
272
  lines.on('line', (line) => {
185
273
  let message;
186
274
  try { message = JSON.parse(line); } catch { return; }
187
- if (message?.ready === true) return;
275
+ if (message?.ready === true) {
276
+ server.caps = (message.caps && typeof message.caps === 'object') ? message.caps : {};
277
+ server.ready = true;
278
+ settleReady(true);
279
+ return;
280
+ }
188
281
  if (String(message?.event || '').startsWith('task_')) acceptTask(message);
189
282
  const pending = server.pending.get(Number(message?.id));
190
283
  if (!pending) return;
191
284
  pending.onMessage(message);
192
285
  });
193
- child.on('error', (error) => { if (_server === server) _teardown(error); });
194
- child.on('exit', () => { if (_server === server) _teardown(); });
286
+ child.on('error', (error) => {
287
+ settleReady(false);
288
+ if (_server === server) _teardown(error);
289
+ });
290
+ child.on('exit', () => {
291
+ settleReady(false);
292
+ if (_server === server) _teardown();
293
+ });
195
294
  child.stdin.on('error', (error) => { if (_server === server) _teardown(error); });
196
295
  _setServerReferenced(server, false);
197
296
  _server = server;
@@ -207,17 +306,40 @@ export async function warmNativeSpawnServer() {
207
306
  }
208
307
 
209
308
  export async function ensureNativeSpawnServer() {
210
- if (_server) return true;
309
+ if (_server?.ready) return true;
211
310
  let binary = _resolveBinary();
212
311
  if (!binary) {
213
312
  binary = await ensureSpawnBinary(getPluginData());
214
313
  _binaryPath = binary;
215
314
  }
216
- if (!_ensureServer()) {
315
+ const server = _ensureServer();
316
+ if (!server) {
217
317
  throw Object.assign(new Error('verified native spawn server could not be started'), {
218
318
  code: 'NATIVE_SPAWN_UNAVAILABLE',
219
319
  });
220
320
  }
321
+ if (!server.ready) {
322
+ let timer;
323
+ let ready;
324
+ _setServerReferenced(server, true);
325
+ try {
326
+ ready = await Promise.race([
327
+ server.readyPromise,
328
+ new Promise((resolve) => {
329
+ timer = setTimeout(() => resolve(false), SERVER_READY_TIMEOUT_MS);
330
+ }),
331
+ ]);
332
+ } finally {
333
+ if (timer) clearTimeout(timer);
334
+ if (_server === server) _recomputeServerRef(server);
335
+ }
336
+ if (!ready) {
337
+ if (_server === server) _teardown(new Error('native spawn server ready timeout'));
338
+ throw Object.assign(new Error('verified native spawn server did not become ready'), {
339
+ code: 'NATIVE_SPAWN_UNAVAILABLE',
340
+ });
341
+ }
342
+ }
221
343
  return true;
222
344
  }
223
345
 
@@ -227,6 +349,11 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
227
349
  const id = ++server.sequence;
228
350
  const fake = new NativeSpawnChild(id, (cancelId) => {
229
351
  try { server.child.stdin.write(`${JSON.stringify({ cancel: cancelId })}\n`); } catch {}
352
+ }, (payload) => {
353
+ try {
354
+ server.child.stdin.write(`${JSON.stringify({ id: ++server.sequence, ...payload })}\n`);
355
+ return true;
356
+ } catch { return false; }
230
357
  });
231
358
  const request = {
232
359
  id,
@@ -239,6 +366,8 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
239
366
  timeoutMs: Math.max(0, Number(spawnOptions.timeoutMs) || 0),
240
367
  outputLimit: Math.max(0, Number(spawnOptions.outputLimit) || 0),
241
368
  mergeStderr: spawnOptions.mergeStderr === true,
369
+ rawOutput: spawnOptions.rawOutput === true,
370
+ ...(spawnOptions.stdinPipe === true ? { stdinPipe: true } : {}),
242
371
  command: spawnOptions.command || undefined,
243
372
  shellType: spawnOptions.shellType || undefined,
244
373
  ownerSessionId: spawnOptions.ownerSessionId || undefined,
@@ -255,7 +384,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
255
384
  fake.emit('error', err);
256
385
  fake.emit('close', 1, null);
257
386
  }
258
- if (server.pending.size === 0) _setServerReferenced(server, false);
387
+ _recomputeServerRef(server);
259
388
  },
260
389
  onMessage(message) {
261
390
  const event = String(message?.event || '');
@@ -265,8 +394,18 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
265
394
  fake.emit('spawn');
266
395
  return;
267
396
  }
268
- if (event === 'stdout' && message.text) fake.stdout.emit('data', String(message.text));
269
- else if (event === 'stderr' && message.text) fake.stderr.emit('data', String(message.text));
397
+ if (event === 'stdout' && (message.dataBase64 || message.text)) {
398
+ const chunk = message.dataBase64
399
+ ? Buffer.from(String(message.dataBase64), 'base64')
400
+ : String(message.text);
401
+ fake.stdout.emit('data', chunk);
402
+ }
403
+ else if (event === 'stderr' && (message.dataBase64 || message.text)) {
404
+ const chunk = message.dataBase64
405
+ ? Buffer.from(String(message.dataBase64), 'base64')
406
+ : String(message.text);
407
+ fake.stderr.emit('data', chunk);
408
+ }
270
409
  else if (event === 'root_exit') {
271
410
  fake.exitCode = message.code == null ? null : Number(message.code);
272
411
  fake.signalCode = message.signal || null;
@@ -279,7 +418,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
279
418
  fake.signalCode = message.signal || null;
280
419
  fake._closed = true;
281
420
  fake.emit('close', fake.exitCode, fake.signalCode);
282
- if (server.pending.size === 0) _setServerReferenced(server, false);
421
+ _recomputeServerRef(server);
283
422
  } else if (event === 'error') {
284
423
  const detail = String(message.message || 'native spawn failed');
285
424
  const err = new Error(message.code ? `${message.code}: ${detail}` : detail);
@@ -425,7 +564,7 @@ export function adoptNativeTaskByPid({
425
564
  const finish = (task, error = null) => {
426
565
  if (timer) clearTimeout(timer);
427
566
  server.pending.delete(id);
428
- if (server.pending.size === 0) _setServerReferenced(server, false);
567
+ _recomputeServerRef(server);
429
568
  if (error) reject(error);
430
569
  else resolve(task);
431
570
  };
@@ -468,6 +607,15 @@ export function adoptNativeTaskByPid({
468
607
  });
469
608
  }
470
609
 
610
+ // Capability handshake: true only after the connected spawn server announced
611
+ // stdinPipe support in its ready line. An older binary silently ignores the
612
+ // unknown stdinPipe field and gives the child a null stdin — a standby taken
613
+ // in that state would run an EMPTY script and report exit 0 without ever
614
+ // executing the command. Gate the warm-standby feature on this.
615
+ export function nativeSpawnSupportsStdinPipe() {
616
+ return _server?.caps?.stdinPipe === true;
617
+ }
618
+
471
619
  export function _resetNativeSpawnClientForTest() {
472
620
  _teardown(new Error('test reset'));
473
621
  _binaryPath = undefined;
@@ -0,0 +1,162 @@
1
+ // Warm pwsh standby: one pre-spawned PowerShell per (shell path, env
2
+ // signature) waits with a stdin-reading bootstrap. Taking it skips
3
+ // CreateProcess + Defender scan (~300ms warm, ~1.8s cold) for the next shell
4
+ // call; the script arrives via the spawn server's stdinWrite message and runs
5
+ // with identical exit-code/stdout/stderr semantics. Any miss falls back to
6
+ // the regular gated spawn. MIXDOG_SHELL_WARM_STANDBY=0 disables.
7
+ import { createHash } from 'node:crypto';
8
+ import { nativeSpawnSupportsStdinPipe, setNativeSpawnRequestIdle, tryNativeSpawn } from './native-spawn-client.mjs';
9
+ import { SHELL_OUTPUT_DISK_CAP } from '../shell-exec-output.mjs';
10
+
11
+ // Bootstrap: pre-warm scriptblock compilation while parked, re-decode stdin
12
+ // as UTF-8 (console input encoding would mangle non-ASCII), then dot-source
13
+ // the fed script so exit codes, $LASTEXITCODE and terminating errors behave
14
+ // exactly like `-Command <script>`.
15
+ const STANDBY_BOOTSTRAP = "$null = . ([scriptblock]::Create('$null')); [Console]::InputEncoding=[System.Text.UTF8Encoding]::new($false); . ([scriptblock]::Create([Console]::In.ReadToEnd()))";
16
+ const STANDBY_ARGS = ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', STANDBY_BOOTSTRAP];
17
+ const _configuredIdleMs = Number(process.env.MIXDOG_SHELL_WARM_STANDBY_IDLE_MS);
18
+ const STANDBY_TTL_MS = Number.isFinite(_configuredIdleMs) && _configuredIdleMs >= 100
19
+ ? Math.floor(_configuredIdleMs)
20
+ : 2 * 60_000;
21
+
22
+ let _slot = null; // { native, shell, envSig, createdAt }
23
+ let _slotIdleTimer = null;
24
+
25
+ function _clearSlotIdleTimer() {
26
+ if (!_slotIdleTimer) return;
27
+ clearTimeout(_slotIdleTimer);
28
+ _slotIdleTimer = null;
29
+ }
30
+
31
+ function _armSlotIdleTimer(slot) {
32
+ _clearSlotIdleTimer();
33
+ _slotIdleTimer = setTimeout(() => {
34
+ _slotIdleTimer = null;
35
+ if (_slot !== slot) return;
36
+ _slot = null;
37
+ try { slot.native.child.kill(); } catch { /* best-effort */ }
38
+ }, STANDBY_TTL_MS);
39
+ _slotIdleTimer.unref?.();
40
+ }
41
+
42
+ function _disabled() {
43
+ return process.env.MIXDOG_SHELL_WARM_STANDBY === '0';
44
+ }
45
+
46
+ function envSignature(env) {
47
+ const entries = Object.entries(env || {})
48
+ .map(([key, value]) => `${key}=${value}`)
49
+ .sort()
50
+ .join('\n');
51
+ return createHash('sha256').update(entries).digest('hex');
52
+ }
53
+
54
+ function _slotAlive(slot) {
55
+ return Boolean(slot
56
+ && slot.native?.child
57
+ && slot.native.child.exitCode == null
58
+ && !slot.native.child.killed);
59
+ }
60
+
61
+ function psQuote(value) {
62
+ return String(value).replace(/'/g, "''");
63
+ }
64
+
65
+ /** Pre-spawn the next standby (fire-and-forget; a live one is kept). */
66
+ export function ensureWarmShellStandby({ shell, env }) {
67
+ if (_disabled() || !shell) return;
68
+ // Old spawn binaries ignore stdinPipe (null stdin → instant-EOF standby
69
+ // that would ack commands without running them); require the handshake.
70
+ if (!nativeSpawnSupportsStdinPipe()) return;
71
+ if (_slotAlive(_slot)) return;
72
+ let native = null;
73
+ try {
74
+ native = tryNativeSpawn({
75
+ shell: String(shell),
76
+ argv: STANDBY_ARGS,
77
+ spawnOptions: {
78
+ env,
79
+ cwd: process.cwd(),
80
+ outputLimit: SHELL_OUTPUT_DISK_CAP,
81
+ rawOutput: true,
82
+ stdinPipe: true,
83
+ shellType: 'powershell',
84
+ command: '(warm shell standby)',
85
+ },
86
+ });
87
+ } catch { native = null; }
88
+ if (!native?.child) return;
89
+ // Parked standby must not pin the host event loop (a one-shot CLI or test
90
+ // runner would otherwise never exit); reactivated at take time.
91
+ setNativeSpawnRequestIdle(native.child, true);
92
+ const slot = {
93
+ native,
94
+ shell: String(shell),
95
+ envSig: envSignature(env),
96
+ createdAt: Date.now(),
97
+ };
98
+ const release = () => {
99
+ if (_slot !== slot) return;
100
+ _slot = null;
101
+ _clearSlotIdleTimer();
102
+ };
103
+ native.child.once('close', release);
104
+ native.child.once('error', release);
105
+ _slot = slot;
106
+ _armSlotIdleTimer(slot);
107
+ }
108
+
109
+ /** Take the parked standby for immediate use, or null on any mismatch.
110
+ * Always refills so the NEXT call finds a warm one. */
111
+ export function takeWarmShellStandby({ shell, env, cwd }) {
112
+ if (_disabled() || !shell) return null;
113
+ const slot = _slot;
114
+ const refill = () => { try { ensureWarmShellStandby({ shell, env }); } catch { /* best-effort */ } };
115
+ const usable = _slotAlive(slot)
116
+ && slot.shell === String(shell)
117
+ && typeof slot.native.child.writeStdin === 'function'
118
+ && Number.isFinite(slot.native.child.pid) && slot.native.child.pid > 0
119
+ && Date.now() - slot.createdAt <= STANDBY_TTL_MS
120
+ && slot.envSig === envSignature(env);
121
+ if (!usable) {
122
+ // Stale (TTL/env/shell drift): kill so it cannot linger; a still-
123
+ // warming slot (no pid yet) is left in place for a later call.
124
+ if (_slotAlive(slot) && Number.isFinite(slot.native.child.pid)
125
+ && (slot.shell !== String(shell)
126
+ || Date.now() - slot.createdAt > STANDBY_TTL_MS
127
+ || slot.envSig !== envSignature(env))) {
128
+ _slot = null;
129
+ _clearSlotIdleTimer();
130
+ try { slot.native.child.kill(); } catch { /* best-effort */ }
131
+ }
132
+ refill();
133
+ return null;
134
+ }
135
+ _slot = null;
136
+ _clearSlotIdleTimer();
137
+ // Back to active: the caller is about to run a real command on it and the
138
+ // host must stay alive until that command settles.
139
+ setNativeSpawnRequestIdle(slot.native.child, false);
140
+ refill();
141
+ const feed = (commandText, workDir) => {
142
+ // cwd prelude: the standby was spawned in the daemon cwd. Set-Location
143
+ // fixes $PWD and native-child working dirs; Environment.CurrentDirectory
144
+ // fixes .NET relative-path APIs.
145
+ const prelude = workDir
146
+ ? `Set-Location -LiteralPath '${psQuote(workDir)}'; [System.Environment]::CurrentDirectory = '${psQuote(workDir)}'; `
147
+ : '';
148
+ // Single atomic write+EOF: a separate close message can race ahead
149
+ // of the server's async write thread and feed an empty script.
150
+ slot.native.child.writeStdin(prelude + String(commandText ?? ''), { close: true });
151
+ };
152
+ return { spawned: slot.native, feed };
153
+ }
154
+
155
+ export function _resetWarmShellStandbyForTest() {
156
+ const slot = _slot;
157
+ _slot = null;
158
+ _clearSlotIdleTimer();
159
+ if (slot?.native?.child) {
160
+ try { slot.native.child.kill(); } catch { /* best-effort */ }
161
+ }
162
+ }
@@ -24,6 +24,7 @@ import {
24
24
  extractPowerShellCommandInner,
25
25
  } from './shell-powershell.mjs';
26
26
  import { spawnShellWithRetry as _spawnShellWithRetry } from './lib/shell-spawn-retry.mjs';
27
+ import { takeWarmShellStandby } from './lib/shell-warm-standby.mjs';
27
28
 
28
29
  export {
29
30
  _maybeEncodePowerShellCommand,
@@ -34,7 +35,7 @@ export {
34
35
  // renders a path marker instead of pasting the tail. Matches the
35
36
  // SHELL_OUTPUT_MAX_CHARS used by the smart-truncate renderer in
36
37
  // builtin.mjs so spilled output and inline output share the same boundary.
37
- import { SHELL_OUTPUT_INLINE_CAP, SHELL_OUTPUT_DISK_CAP, stripAnsi, treeKill, TaskOutput, ExecResult } from './shell-exec-output.mjs';
38
+ import { SHELL_OUTPUT_INLINE_CAP, SHELL_OUTPUT_DISK_CAP, stripAnsi, treeKill, TaskOutput, ExecResult, ShellTextDecoder } from './shell-exec-output.mjs';
38
39
  export { stripAnsi, ExecResult } from './shell-exec-output.mjs';
39
40
 
40
41
  async function _execPolicyBlockMessage(command) {
@@ -199,6 +200,7 @@ export function execShellCommand({
199
200
  timeoutMs,
200
201
  abortSignal,
201
202
  autoBackgroundMs,
203
+ startInBackground = false,
202
204
  onProgress,
203
205
  onOutputTail,
204
206
  clientHostPid,
@@ -219,6 +221,8 @@ export function execShellCommand({
219
221
  };
220
222
  const taskId = `shell_${randomUUID().slice(0, 8)}`;
221
223
  const taskOutput = new TaskOutput(taskId);
224
+ const stdoutDecoder = new ShellTextDecoder();
225
+ const stderrDecoder = new ShellTextDecoder();
222
226
  let timedOut = false;
223
227
  let killed = false;
224
228
  let killCause = null;
@@ -340,11 +344,23 @@ export function execShellCommand({
340
344
  else pendingChildError = pendingChildError || err;
341
345
  };
342
346
  _onChildErrorRef = _onChildError;
347
+ // Warm-standby fast path (pwsh only): a pre-spawned bootstrap pwsh
348
+ // reads the script from stdin, skipping CreateProcess + Defender scan
349
+ // for this call. Any miss (env drift, TTL, dead/warming standby,
350
+ // MIXDOG_SHELL_WARM_STANDBY=0) falls through to the gated spawn below.
351
+ let _standby = null;
352
+ if (!_useDirectArgv && shellArg === '-Command') {
353
+ try { _standby = takeWarmShellStandby({ shell, env, cwd }); } catch { _standby = null; }
354
+ }
343
355
  // Spawn-burst gate: hold a 'process-spawn' slot only across process
344
356
  // creation (CreateProcess + AV scan + EPERM retries), released the
345
357
  // moment the child exists. Bounds the Defender convoy a shell burst
346
358
  // creates without limiting how many commands RUN concurrently — the
347
359
  // full-lifetime gating concern in the note below stays true.
360
+ let spawned = null;
361
+ if (_standby) {
362
+ spawned = _standby.spawned;
363
+ } else {
348
364
  const _releaseSpawnSlot = await acquireChildSpawnSlot(abortSignal || null, 'process-spawn', {
349
365
  ownerKey: ownerSessionId,
350
366
  });
@@ -355,7 +371,6 @@ export function execShellCommand({
355
371
  try { _releaseSpawnSlot(); } catch { /* idempotent */ }
356
372
  throw abortSignal.reason || new Error('aborted');
357
373
  }
358
- let spawned;
359
374
  try {
360
375
  spawned = await _spawnShellWithRetry({
361
376
  shell,
@@ -366,6 +381,7 @@ export function execShellCommand({
366
381
  env,
367
382
  cwd,
368
383
  outputLimit: SHELL_OUTPUT_DISK_CAP,
384
+ rawOutput: true,
369
385
  // NOTE (child-spawn-gate): the full command lifetime is intentionally
370
386
  // NOT gated — bash/pwsh commands can run for minutes and would starve
371
387
  // rg/code_graph. Only the spawn window above holds a slot.
@@ -378,8 +394,12 @@ export function execShellCommand({
378
394
  } finally {
379
395
  try { _releaseSpawnSlot(); } catch { /* idempotent */ }
380
396
  }
397
+ }
381
398
  child = spawned.child;
382
399
  spawned.adoptErrorHandler(_onChildError);
400
+ // Feed the standby only after the error handler is attached; server
401
+ // messages cannot be processed before this synchronous block yields.
402
+ if (_standby) _standby.feed(_spawnCommand, cwd);
383
403
  } catch (err) {
384
404
  const cleanupError = await releaseResourceLease();
385
405
  const spawnText = String((err && err.message) || err);
@@ -412,21 +432,21 @@ export function execShellCommand({
412
432
  }
413
433
 
414
434
  const _stdoutData = (chunk) => {
415
- taskOutput.writeStdout(chunk);
435
+ const text = stdoutDecoder.write(chunk);
436
+ if (text) taskOutput.writeStdout(text);
416
437
  if (taskOutput.binaryOutput && !settled && !autoBackgrounded) _treeKillForceSettle('binary-output');
417
438
  if (taskOutput.writeError && !settled && !autoBackgrounded) _treeKillForceSettle('output-capture-error');
418
439
  };
419
440
  const _stderrData = (chunk) => {
420
- taskOutput.writeStderr(chunk);
441
+ const text = stderrDecoder.write(chunk);
442
+ if (text) taskOutput.writeStderr(text);
421
443
  if (taskOutput.binaryOutput && !settled && !autoBackgrounded) _treeKillForceSettle('binary-output');
422
444
  if (taskOutput.writeError && !settled && !autoBackgrounded) _treeKillForceSettle('output-capture-error');
423
445
  };
424
446
  if (child.stdout) {
425
- child.stdout.setEncoding('utf-8');
426
447
  child.stdout.on('data', _stdoutData);
427
448
  }
428
449
  if (child.stderr) {
429
- child.stderr.setEncoding('utf-8');
430
450
  child.stderr.on('data', _stderrData);
431
451
  }
432
452
 
@@ -443,6 +463,10 @@ export function execShellCommand({
443
463
  autoBgTimer = null;
444
464
  }
445
465
  detachAbortHandler();
466
+ const stdoutTail = stdoutDecoder.end();
467
+ const stderrTail = stderrDecoder.end();
468
+ if (stdoutTail) taskOutput.writeStdout(stdoutTail);
469
+ if (stderrTail) taskOutput.writeStderr(stderrTail);
446
470
  // getStdout/getStderr can throw on a spilled-file read failure (EBADF
447
471
  // after unlink race, EACCES). Without this catch the rejection bubbles
448
472
  // up and leaves the outer settle promise unresolved, hanging the call.
@@ -684,7 +708,9 @@ export function execShellCommand({
684
708
  const secs = Math.max(0, Math.round((Date.now() - _startMs) / 1000));
685
709
  const _verb = reason === 'timeout'
686
710
  ? `moved to background at timeout after ${secs}s`
687
- : `auto-backgrounded after ${secs}s`;
711
+ : (reason === 'explicit'
712
+ ? 'started in background'
713
+ : `auto-backgrounded after ${secs}s`);
688
714
  resolveResult(
689
715
  new ExecResult({
690
716
  stdout,
@@ -795,7 +821,9 @@ export function execShellCommand({
795
821
  // Arm the auto-background timer only for the genuine foreground one-shot
796
822
  // path: a positive threshold strictly below the hard timeout, and not a
797
823
  // trailing-`&` background command (those already detach + settle on exit).
798
- if (
824
+ if (startInBackground && !_isBackground) {
825
+ setImmediate(() => { fireAutoBackground({ reason: 'explicit' }); });
826
+ } else if (
799
827
  typeof autoBackgroundMs === 'number' &&
800
828
  autoBackgroundMs > 0 &&
801
829
  !_isBackground &&