mixdog 0.9.134 → 0.9.135

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/headless-exec.mjs +26 -9
  3. package/src/headless-exec.test.mjs +55 -2
  4. package/src/rules/shared/01-tool.md +2 -0
  5. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -2
  6. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +5 -0
  7. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +15 -1
  8. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +8 -0
  9. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +30 -7
  10. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  11. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +26 -0
  12. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +14 -1
  13. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +80 -3
  14. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +3 -19
  15. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +25 -0
  16. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  17. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +87 -5
  18. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +131 -0
  19. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +24 -3
  20. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  21. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  22. package/src/session-runtime/lifecycle-api.mjs +9 -2
  23. package/src/session-runtime/prewarm.mjs +9 -0
  24. package/src/session-runtime/runtime-core.mjs +14 -1
  25. package/src/session-runtime/tool-policy-surface.test.mjs +4 -0
  26. package/src/standalone/channel-transport.mjs +13 -0
  27. package/src/standalone/daemon.mjs +14 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.134",
3
+ "version": "0.9.135",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -583,6 +583,7 @@ export async function runHeadlessExec({
583
583
  let runtime = null;
584
584
  let signalCleanup = null;
585
585
  let unsubscribeNotification = null;
586
+ let completionPending = false;
586
587
  let cleanupPromise = null;
587
588
  let result = null;
588
589
  let resultText = '';
@@ -644,18 +645,25 @@ export async function runHeadlessExec({
644
645
  toolMode: 'full',
645
646
  approvalMode: 'implicit',
646
647
  disallowDelegation: true,
648
+ autoWakeCompletions: false,
647
649
  initialConfig: boundary.loadConfig(),
648
650
  });
649
651
  if (lifecycle && !clean(runtime?.id) && typeof runtime?.reserveSessionId === 'function') {
650
652
  runtime.reserveSessionId(lifecycle.threadId);
651
653
  }
652
654
  lifecycle?.start(runtime);
653
- if (lifecycle && typeof runtime?.onNotification === 'function') {
655
+ if (typeof runtime?.onNotification === 'function') {
654
656
  unsubscribeNotification = runtime.onNotification(
655
- (event) => lifecycle.onNotification(event),
657
+ (event) => {
658
+ lifecycle?.onNotification(event);
659
+ const status = clean(event?.meta?.status).toLowerCase();
660
+ if (['completed', 'failed', 'cancelled', 'canceled', 'timed_out'].includes(status)) {
661
+ completionPending = true;
662
+ }
663
+ },
656
664
  );
657
665
  }
658
- ({ result } = await runtime.ask(prompt, {
666
+ const askOptions = {
659
667
  onTextReset: () => true,
660
668
  onUsageDelta: (delta) => {
661
669
  applyUsageDelta(stats, delta);
@@ -672,13 +680,22 @@ export async function runHeadlessExec({
672
680
  onToolPhaseCompleted: (detail) => lifecycle.onToolBatchCompleted(detail),
673
681
  onStageChange: (stage, detail) => lifecycle.onStageChange(stage, detail),
674
682
  } : {}),
675
- }));
676
- await waitForTrackedTasks({
677
- sessionId: runtime.id,
683
+ };
684
+ ({ result } = await runtime.ask(prompt, askOptions));
685
+ const taskScope = {
686
+ ...(clean(runtime.id) ? { callerSessionId: clean(runtime.id) } : {}),
678
687
  clientHostPid: runtime.clientHostPid,
679
- hasActiveTasks,
680
- pollMs: idlePollMs,
681
- });
688
+ };
689
+ while (completionPending || hasActiveTasks(taskScope)) {
690
+ await waitForTrackedTasks({
691
+ sessionId: runtime.id,
692
+ clientHostPid: runtime.clientHostPid,
693
+ hasActiveTasks,
694
+ pollMs: idlePollMs,
695
+ });
696
+ completionPending = false;
697
+ ({ result } = await runtime.ask('', askOptions));
698
+ }
682
699
  resultText = String(result?.content ?? result?.text ?? '');
683
700
  if (!json && resultText) {
684
701
  write(resultText.endsWith('\n') ? resultText : `${resultText}\n`);
@@ -90,7 +90,7 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
90
90
  hasActiveTasks: (scope) => {
91
91
  activeScopes.push(scope);
92
92
  activeChecks += 1;
93
- return activeChecks === 1;
93
+ return false;
94
94
  },
95
95
  installSignalCleanupFn: () => ({ uninstall() {} }),
96
96
  });
@@ -100,6 +100,7 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
100
100
  assert.deepEqual(errors, []);
101
101
  assert.equal(runtimeOptions[0].approvalMode, 'implicit');
102
102
  assert.equal(runtimeOptions[0].disallowDelegation, true);
103
+ assert.equal(runtimeOptions[0].autoWakeCompletions, false);
103
104
  assert.equal(runtimeOptions[0].toolMode, 'full');
104
105
  assert.deepEqual(activeScopes[0], {
105
106
  callerSessionId: 'sess_exec_test',
@@ -121,6 +122,58 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
121
122
  }
122
123
  });
123
124
 
125
+ test('headless exec drains tracked work and returns the completion follow-up answer', async () => {
126
+ const output = [];
127
+ const errors = [];
128
+ const prompts = [];
129
+ let active = true;
130
+ let notificationListener = null;
131
+ const code = await runHeadlessExec({
132
+ message: 'start the long task',
133
+ provider: 'openai-oauth',
134
+ model: 'gpt-test',
135
+ usageLogPath: '',
136
+ idlePollMs: 1,
137
+ write: (text) => output.push(text),
138
+ writeErr: (text) => errors.push(text),
139
+ boundaryFactory: () => ({
140
+ loadConfig: () => ({ providers: { 'openai-oauth': { enabled: true } } }),
141
+ cleanup() {},
142
+ }),
143
+ runtimeFactory: async () => ({
144
+ id: 'sess_headless_drain',
145
+ model: 'gpt-test',
146
+ clientHostPid: 123,
147
+ onNotification(listener) {
148
+ notificationListener = listener;
149
+ return () => { notificationListener = null; };
150
+ },
151
+ async ask(prompt) {
152
+ prompts.push(prompt);
153
+ if (prompts.length === 1) {
154
+ setTimeout(() => {
155
+ active = false;
156
+ notificationListener?.({
157
+ content: 'background task completed',
158
+ meta: { status: 'completed', execution_id: 'job_drain' },
159
+ });
160
+ }, 10);
161
+ return { result: { content: 'task started' } };
162
+ }
163
+ return { result: { content: 'final result' } };
164
+ },
165
+ async close() {},
166
+ }),
167
+ hasActiveTasks: () => active,
168
+ installSignalCleanupFn: () => ({ uninstall() {} }),
169
+ });
170
+
171
+ assert.equal(code, 0);
172
+ assert.deepEqual(errors, []);
173
+ assert.deepEqual(prompts, ['start the long task', '']);
174
+ assert.deepEqual(output, ['final result\n']);
175
+ });
176
+
124
177
  test('headless exec emits a timestamped JSONL lifecycle and exact tool count', async () => {
125
178
  const root = mkdtempSync(join(tmpdir(), 'mixdog-headless-json-test-'));
126
179
  const usageLogPath = join(root, 'usage.json');
@@ -199,7 +252,7 @@ test('headless exec emits a timestamped JSONL lifecycle and exact tool count', a
199
252
  options.onToolPhaseCompleted({ iteration: 1, calls: 1, elapsedMs: 2 });
200
253
  notificationListener?.({
201
254
  content: 'background task completed',
202
- meta: { status: 'completed' },
255
+ meta: { status: 'progress' },
203
256
  });
204
257
  options.onProviderSendStarted();
205
258
  options.onAssistantText('done');
@@ -1,5 +1,7 @@
1
1
  # Tool Use
2
2
 
3
+ - When an internal Mixdog rule conflicts with the user's latest explicit
4
+ request, follow the user's request.
3
5
  - Baseline routing assigns each facet directly by the evidence needed to
4
6
  determine the complete answer or edit:
5
7
  path/name only→`find`; wildcard/recursive paths→`glob` (including known-root
@@ -655,10 +655,10 @@ function guardRead(a) {
655
655
  }
656
656
 
657
657
  function guardShell(a) {
658
- const allowed = new Set(['command', 'timeout_ms']);
658
+ const allowed = new Set(['command', 'timeout_ms', 'run_in_background']);
659
659
  const unsupported = Object.keys(a).find((key) => !allowed.has(key));
660
660
  if (unsupported) {
661
- return `Error: shell arg "${unsupported}" is unsupported; use only command and timeout_ms`;
661
+ return `Error: shell arg "${unsupported}" is unsupported; use only command, timeout_ms, and run_in_background`;
662
662
  }
663
663
  if (!hasOwn(a, 'command')) {
664
664
  return 'Error: shell requires "command"';
@@ -672,6 +672,9 @@ function guardShell(a) {
672
672
  if (hasOwn(a, 'timeout_ms') && (typeof a.timeout_ms !== 'number' || !Number.isFinite(a.timeout_ms) || a.timeout_ms < 0)) {
673
673
  return `Error: shell arg "timeout_ms" must be a non-negative number (got ${describeType(a.timeout_ms)})`;
674
674
  }
675
+ if (hasOwn(a, 'run_in_background') && typeof a.run_in_background !== 'boolean') {
676
+ return `Error: shell arg "run_in_background" must be a boolean (got ${describeType(a.run_in_background)})`;
677
+ }
675
678
  return null;
676
679
  }
677
680
 
@@ -469,6 +469,10 @@ export async function executeBashTool(args, workDir, options = {}) {
469
469
  const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
470
470
  String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
471
471
  );
472
+ const runInBackground = args.run_in_background === true;
473
+ if (runInBackground && _bgTasksDisabled) {
474
+ return formatShellToolFailure('background tasks are disabled for this process');
475
+ }
472
476
 
473
477
  let shellEffects;
474
478
  let combinedBashAbort = null;
@@ -603,6 +607,7 @@ export async function executeBashTool(args, workDir, options = {}) {
603
607
  timeoutMs: timeout,
604
608
  abortSignal: combinedBashAbort.signal,
605
609
  autoBackgroundMs,
610
+ startInBackground: runInBackground,
606
611
  // On a foreground timeout, promote the still-running child to a
607
612
  // tracked background job only when an explicit deadline has
608
613
  // remaining budget; omitted deadlines may stay unlimited.
@@ -14,6 +14,13 @@ const _shellSyntaxCheat =
14
14
  const _shellToolRouting = process.platform === 'win32'
15
15
  ? 'Use read, NOT cat/Get-Content/head/tail; list, NOT ls/dir; find/glob, NOT find; grep, NOT grep/rg/Select-String; edit/apply_patch, NOT sed/awk/heredocs/echo/Set-Content.'
16
16
  : 'Use read, NOT cat/head/tail; list, NOT ls; find/glob, NOT find; grep, NOT grep/rg; edit/apply_patch, NOT sed/awk/heredocs/echo.';
17
+ // CC parity: when background tasks are disabled for this process, drop the
18
+ // run_in_background field from the schema entirely so the model cannot burn a
19
+ // failure turn attempting it. Mirrors bash-tool's runtime guard, which stays
20
+ // as defense in depth. Process-stable env, evaluated once at module load.
21
+ const _shellBackgroundDisabled = /^(1|true|yes|on)$/i.test(
22
+ String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
23
+ );
17
24
 
18
25
  export const BUILTIN_TOOLS = [
19
26
  {
@@ -77,7 +84,7 @@ export const BUILTIN_TOOLS = [
77
84
  name: 'shell',
78
85
  title: 'Shell',
79
86
  annotations: { title: 'Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
80
- description: `Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. Avoid file operations covered by dedicated tools unless explicitly instructed or after verifying that a dedicated tool cannot do the job. ${_shellToolRouting} Commands start in the foreground; after 15s, a still-running command continues as a tracked task_id and completes by notification.`,
87
+ description: `Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. Avoid file operations covered by dedicated tools unless explicitly instructed or after verifying that a dedicated tool cannot do the job. ${_shellToolRouting} Commands start in the foreground${_shellBackgroundDisabled ? '' : ' unless run_in_background is true'}; after 15s, a still-running foreground command continues as a tracked task_id and completes by notification.`,
81
88
  inputSchema: {
82
89
  type: 'object',
83
90
  properties: {
@@ -87,6 +94,13 @@ export const BUILTIN_TOOLS = [
87
94
  minimum: 0,
88
95
  description: 'Hard total deadline in milliseconds; omit or use 0 to allow unlimited runtime after task promotion.',
89
96
  },
97
+ ...(_shellBackgroundDisabled ? {} : {
98
+ run_in_background: {
99
+ type: 'boolean',
100
+ default: false,
101
+ description: 'Start as a tracked background task immediately; use only when the result is not needed for the next step — completion arrives by notification. Default false.',
102
+ },
103
+ }),
90
104
  },
91
105
  required: ['command'],
92
106
  additionalProperties: false,
@@ -191,6 +191,7 @@ async function subscribeResultCacheInFlight(entry, signal) {
191
191
 
192
192
  export async function runResultCacheInFlight(key, compute, options = {}) {
193
193
  const subscriberSignal = options?.signal || options?.abortSignal || null;
194
+ let invalidationRetries = 0;
194
195
  for (;;) {
195
196
  const cached = cacheGet(key);
196
197
  if (cached !== null) return cached;
@@ -233,6 +234,13 @@ export async function runResultCacheInFlight(key, compute, options = {}) {
233
234
  // freshness retry, not a user-visible abort: start a new generation
234
235
  // unless this subscriber itself was cancelled.
235
236
  if (!entry.invalidated || subscriberSignal?.aborted) throw error;
237
+ // Continuous invalidation (watcher churn, eviction broadcasts)
238
+ // must not spin forever: after a few generations run the compute
239
+ // directly — uncached and no longer abortable by invalidation —
240
+ // mirroring the server-side MAX_WALK_RESTARTS cap.
241
+ if (++invalidationRetries >= 3) {
242
+ return await compute({ signal: subscriberSignal });
243
+ }
236
244
  }
237
245
  }
238
246
  }
@@ -11,6 +11,7 @@ import { runRgWindowedLines } from '../native-search-runner.mjs';
11
11
  import { statReachable } from '../fs-reachability.mjs';
12
12
  import { dedupeFanoutMatchLines, formatGrepOutput } from './grep-output.mjs';
13
13
  import { expandGrepAnchorContextOutput } from './grep-context-expander.mjs';
14
+ import { markScopedCacheIncomplete } from '../../../session/cache/scoped-cache-outcome.mjs';
14
15
 
15
16
  export async function runGrepPatternFanout({
16
17
  args,
@@ -77,7 +78,9 @@ export async function runGrepPatternFanout({
77
78
  const pre = await runRgWindowedLines(
78
79
  prefilterArgs,
79
80
  { cwd: preSpawnCwd, signal: options.signal },
80
- { offset: 0, limit: GREP_FANOUT_PREFILTER_FILE_CAP, summaryLimit: 0 },
81
+ // Speculative whole-scope pass: bulk lane keeps it from
82
+ // competing with interactive searches for disk bandwidth.
83
+ { offset: 0, limit: GREP_FANOUT_PREFILTER_FILE_CAP, summaryLimit: 0, bulkHint: true },
81
84
  );
82
85
  return pre.complete && !pre.partial ? pre.lines : null;
83
86
  } catch { return null; }
@@ -130,15 +133,34 @@ export async function runGrepPatternFanout({
130
133
  });
131
134
  const perPatternWindow = headLimit === Infinity ? 300 : (offset + headLimit + 4);
132
135
  const combinedCap = Math.min(4000, Math.max(400, perPatternWindow * patterns.length));
136
+ // Unfiltered multi-pattern directory scans are the broad-scope shape
137
+ // that saturated the interactive pool; route them to the bulk lane.
138
+ const combinedBulkHint = normalizedGlobPatterns.length === 0 && !fileType;
133
139
  let streamed;
134
140
  try {
135
141
  streamed = await runRgWindowedLines(
136
142
  combinedArgs,
137
143
  { cwd: rgCwd, signal: options.signal },
138
- { offset: 0, limit: combinedCap, summaryLimit: 0 },
144
+ { offset: 0, limit: combinedCap, summaryLimit: 0, bulkHint: combinedBulkHint },
139
145
  );
140
146
  } catch { break combined; }
141
- if (!streamed.complete || streamed.partial) break combined;
147
+ // Cap overflow (complete:false without partial) still falls back: the
148
+ // per-pattern rescan restores correct per-pattern windows. Timeout and
149
+ // scan-error partials keep their collected lines instead — the legacy
150
+ // fallback would rescan the same scope from scratch and usually time
151
+ // out again, discarding everything the first pass already found.
152
+ if (streamed.partial ? streamed.lines.length === 0 : !streamed.complete) break combined;
153
+ const combinedPartial = streamed.partial === true;
154
+ const combinedPartialSuffix = !combinedPartial
155
+ ? ''
156
+ : streamed.timeout
157
+ ? '\n[warning] rg timed out; partial results shown. Narrow path/glob/pattern for a complete result.'
158
+ : streamed.rgStderr
159
+ ? `\n[warning] rg exit 2 (partial results): ${String(streamed.rgStderr).trim().slice(0, 300)}`
160
+ : '\n[warning] rg exit 2 (partial results)';
161
+ if (combinedPartial && options?.scopedCacheOutcome) {
162
+ markScopedCacheIncomplete(options.scopedCacheOutcome);
163
+ }
142
164
  const adaptive = contextN > 0 && !(beforeN > 0) && !(afterN > 0);
143
165
  const byPattern = patterns.map(() => []);
144
166
  const residual = [];
@@ -175,7 +197,7 @@ export async function runGrepPatternFanout({
175
197
  filenameOmitted: false,
176
198
  headLimit,
177
199
  offset,
178
- totalKnown: true,
200
+ totalKnown: !combinedPartial,
179
201
  requestedContext: contextN,
180
202
  maxContext: GREP_AUTO_CONTEXT_LINES,
181
203
  patterns: [p],
@@ -190,7 +212,7 @@ export async function runGrepPatternFanout({
190
212
  body = formatGrepOutput({
191
213
  windowed: windowedLines,
192
214
  totalWindowed: post.length,
193
- totalKnown: true,
215
+ totalKnown: !combinedPartial,
194
216
  headLimit,
195
217
  offset,
196
218
  outputMode,
@@ -211,14 +233,15 @@ export async function runGrepPatternFanout({
211
233
  sections.push(`# grep pattern:${JSON.stringify(p)}\n${dedupeFanoutMatchLines(body, seenCombined)}`);
212
234
  }
213
235
  if (noMatchPatterns.length > 0) {
214
- sections.push(`(no matches) pattern=${JSON.stringify(noMatchPatterns)} path=${searchPath}${globStr}; path exists`);
236
+ // Under a partial scan a zero-hit pattern is NOT a proven no-match.
237
+ sections.push(`(no matches${combinedPartial ? ' in partial results' : ''}) pattern=${JSON.stringify(noMatchPatterns)} path=${searchPath}${globStr}; path exists`);
215
238
  }
216
239
  if (residual.length > 0) {
217
240
  // Rust/JS regex divergence or --max-columns truncation left
218
241
  // matches no pattern claimed; surface them rather than drop.
219
242
  sections.push(`# grep (unattributed matches)\n${residual.slice(0, 40).join('\n')}`);
220
243
  }
221
- return patternCapNote + sections.join('\n\n');
244
+ return patternCapNote + sections.join('\n\n') + combinedPartialSuffix;
222
245
  }
223
246
  // Prefilter result (started above, overlapped with the combined
224
247
  // attempt): when it completed under the cap, each per-pattern grep
@@ -33,7 +33,7 @@ function dataDir() {
33
33
  || join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data');
34
34
  }
35
35
 
36
- function pidAlive(pid) {
36
+ export function pidAlive(pid) {
37
37
  if (!Number.isInteger(pid) || pid <= 0) return false;
38
38
  try { process.kill(pid, 0); return true; }
39
39
  catch (error) { return error?.code === 'EPERM'; } // EPERM = alive, not ours
@@ -194,6 +194,31 @@ function _teardown(error, { countFailure = true, detail = '' } = {}) {
194
194
  try { server.child.kill(); } catch {}
195
195
  }
196
196
 
197
+ export async function shutdownNativeSearchServer(reason = 'process-exit', timeoutMs = 1_000) {
198
+ const server = _server;
199
+ if (!server) return true;
200
+ const child = server.child;
201
+ const exited = new Promise((resolve) => {
202
+ child.once('exit', () => resolve(true));
203
+ child.once('error', () => resolve(true));
204
+ });
205
+ try { child.stdin?.end?.(); } catch {}
206
+ _teardown(new Error(`native search server shutdown (${reason})`), { countFailure: false });
207
+ _warmPromise = null;
208
+ let timer;
209
+ const stopped = await Promise.race([
210
+ exited,
211
+ new Promise((resolve) => {
212
+ timer = setTimeout(() => resolve(false), Math.max(10, Number(timeoutMs) || 1_000));
213
+ }),
214
+ ]);
215
+ if (timer) clearTimeout(timer);
216
+ if (!stopped) {
217
+ try { child.kill('SIGKILL'); } catch {}
218
+ }
219
+ return stopped;
220
+ }
221
+
197
222
  export function _bindNativeSearchServerLifecycle(child, { onError, onExit } = {}) {
198
223
  if (!child?.on) return;
199
224
  child.on('error', onError);
@@ -541,6 +566,7 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
541
566
  : 0,
542
567
  deadlineMs: softDeadlineMs(remaining),
543
568
  keepWarm: opts.keepWarm === true,
569
+ ...(opts.bulkHint === true ? { bulkHint: true } : {}),
544
570
  });
545
571
  let response = await requestNativeWithRestart(buildRequest, execOptions, deadlineMs);
546
572
  // Deadline-swallow defense (binaries before the serve_search response-level
@@ -342,7 +342,20 @@ export async function executeGlobTool(args, workDir, options = {}) {
342
342
  const canWindowNatural = sortMode === 'natural' && headLimit !== Infinity;
343
343
  const groupRuns = await Promise.all(globGroups.map(async ([root, rels]) => {
344
344
  const rgArgs = ['--files', '--hidden'];
345
- for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
345
+ // Explicit literal basenames (no glob magic in the final segment)
346
+ // name a concrete file: honor rg's later-glob-wins contract and let
347
+ // the lookup descend dependency-noise dirs, which the native walker
348
+ // would otherwise prune before the pattern could ever match. Wildcard
349
+ // basenames keep the noise prunes; device-name globs (no trailing
350
+ // /**) always apply.
351
+ const explicitBasenames = rels.length > 0 && rels.every((rel) => {
352
+ const base = String(rel).split('/').filter(Boolean).pop() || '';
353
+ return base !== '' && !hasGlobMagic(base);
354
+ });
355
+ for (const ex of DEFAULT_IGNORE_GLOBS) {
356
+ if (explicitBasenames && /^!\*\*\/[^/]+\/\*\*$/.test(ex)) continue;
357
+ rgArgs.push('--glob', ex);
358
+ }
346
359
  for (const ex of extraIgnoreGlobs) rgArgs.push('--glob', ex);
347
360
  for (const rel of rels) rgArgs.push('--glob', rel);
348
361
  const rgCwd = resolvedForSearchRoot(root);
@@ -1,8 +1,16 @@
1
1
  import os from 'node:os';
2
2
  import {
3
+ modelVisibleToolCompletionMessage,
3
4
  normalizeToolNotifyContext,
4
5
  notifyToolCompletion,
5
6
  } from '../../../../shared/tool-execution-contract.mjs';
7
+ // Runtime-only bindings (used inside completion delivery, never at module
8
+ // eval), so the static cycle through session/manager.mjs is safe — same
9
+ // pattern as loop/tool-exec.mjs.
10
+ import {
11
+ enqueuePendingMessage,
12
+ markCompletionEntry,
13
+ } from '../../session/manager.mjs';
6
14
  import {
7
15
  completeBackgroundTask,
8
16
  } from '../../../../shared/background-tasks.mjs';
@@ -22,6 +30,8 @@ import {
22
30
  } from './lib/shell-job-insights.mjs';
23
31
  import {
24
32
  completeShellJobRecord,
33
+ listShellJobRecords,
34
+ pidAlive,
25
35
  publishShellJobRecord,
26
36
  } from './lib/shell-job-records.mjs';
27
37
  import {
@@ -86,7 +96,7 @@ function shellJobTaskStatus(status) {
86
96
  return 'failed';
87
97
  }
88
98
 
89
- function buildShellCompletion(jobId, detail) {
99
+ export function buildShellCompletion(jobId, detail) {
90
100
  const startedAtMs = Date.parse(detail?.startedAt || '');
91
101
  const finishedAtMs = Date.parse(detail?.finishedAt || '') || Date.now();
92
102
  const elapsedMs = Number.isFinite(startedAtMs)
@@ -247,7 +257,12 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
247
257
  instruction: completion.instruction,
248
258
  terminalReason: 'shell-native-event',
249
259
  });
250
- if (!completedTask && ctx && typeof ctx.notifyFn === 'function') {
260
+ if (!completedTask) {
261
+ // Delivery order: owner notifyFn when present, else the owner
262
+ // session's pending queue. A ctx-less finish (daemon restarted
263
+ // between job start and completion, or a watcher armed without a
264
+ // notify context) previously dropped the completion silently.
265
+ const owner = String(ctx?.callerSessionId || detail.ownerSessionId || '');
251
266
  notifyToolCompletion({
252
267
  surface: 'shell',
253
268
  id: jobId,
@@ -255,7 +270,19 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
255
270
  text: completion.body,
256
271
  resultType: 'shell_task_result',
257
272
  instruction: completion.instruction,
258
- context: ctx,
273
+ context: ctx || { callerSessionId: owner },
274
+ enqueueFallback: (sessionId, message, meta) => {
275
+ let visible = modelVisibleToolCompletionMessage(message, meta);
276
+ // Bodyless envelopes (a finished command with no output)
277
+ // fail the persistence gate's result-body requirement;
278
+ // retry with an explicit "(no output)" section rather
279
+ // than dropping the completion.
280
+ if (!visible && !/\n\s*\n/.test(String(message || ''))) {
281
+ visible = modelVisibleToolCompletionMessage(`${message}\n\n(no output)`, meta);
282
+ }
283
+ if (!visible) return false;
284
+ return enqueuePendingMessage(sessionId, markCompletionEntry(visible)) > 0;
285
+ },
259
286
  logPrefix: 'shell-jobs',
260
287
  });
261
288
  }
@@ -287,6 +314,56 @@ export async function adoptForegroundShellJob({
287
314
  return native;
288
315
  }
289
316
 
317
+ // Daemon-restart recovery: records left 'running' by a dead daemon can never
318
+ // finish through the live watcher path — their completion (and output) used to
319
+ // vanish silently. Finalize each dead-pid record and push one completion
320
+ // notice to the owner session's pending queue. Content-addressed completion
321
+ // ids keep multi-shard reconciliation idempotent.
322
+ let _recoveredCompletionsReconciled = false;
323
+ export async function reconcileRecoveredShellJobCompletions() {
324
+ if (_recoveredCompletionsReconciled) return 0;
325
+ _recoveredCompletionsReconciled = true;
326
+ let records = [];
327
+ try { records = await listShellJobRecords(); } catch { return 0; }
328
+ let notified = 0;
329
+ for (const record of records) {
330
+ if (!record || record.terminal) continue;
331
+ if (getNativeTask(record.jobId)) continue; // live in this daemon
332
+ if (pidAlive(Number(record.pid) || 0)) continue; // survived the restart
333
+ const detail = {
334
+ ...record,
335
+ status: 'failed',
336
+ error: 'daemon restarted while this shell task was running; its outcome was lost. Re-run the command if the result is still needed.',
337
+ };
338
+ try { await completeShellJobRecord(record.jobId, detail); } catch { /* best-effort */ }
339
+ const owner = String(record.ownerSessionId || '');
340
+ if (!owner) continue;
341
+ try {
342
+ const completion = buildShellCompletion(record.jobId, detail);
343
+ const delivered = notifyToolCompletion({
344
+ surface: 'shell',
345
+ id: record.jobId,
346
+ status: completion.taskStatus,
347
+ // The pending-queue persistence gate requires a blank-line
348
+ // separated result body; a bodyless bracketed envelope would
349
+ // be dropped. The restart notice IS the result here.
350
+ text: `${completion.body}\n\n${detail.error}`,
351
+ resultType: 'shell_task_result',
352
+ instruction: completion.instruction,
353
+ context: { callerSessionId: owner },
354
+ enqueueFallback: (sessionId, message, meta) => {
355
+ const visible = modelVisibleToolCompletionMessage(message, meta);
356
+ if (!visible) return false;
357
+ return enqueuePendingMessage(sessionId, markCompletionEntry(visible)) > 0;
358
+ },
359
+ logPrefix: 'shell-jobs-recovery',
360
+ });
361
+ if (delivered) notified += 1;
362
+ } catch { /* best-effort */ }
363
+ }
364
+ return notified;
365
+ }
366
+
290
367
  export async function shutdownShellJobs(_reason = 'runtime-close', { scope = null } = {}) {
291
368
  const scoped = Boolean(scope);
292
369
  const ownerSessionId = scoped ? String(scope.ownerSessionId || '') : '';
@@ -78,26 +78,10 @@ export function scrubLoaderVars(env) {
78
78
  return _continueScrubLoaderVars(env);
79
79
  }
80
80
 
81
- // Headless "web off" reaches shell children too: `mixdog exec` pins
82
- // MIXDOG_FEATURE_WEB_SEARCH=0 (default; --web-search opts back in) and every
83
- // model-spawned subprocess then receives the standard proxy variables pointed
84
- // at a dead loopback endpoint. curl/wget/git-http/pip/apt honor these and
85
- // fail fast on public hosts, while NO_PROXY keeps loopback traffic
86
- // (task-local servers) working. Raw-socket clients bypass proxy variables —
87
- // this is a uniform egress policy for the common tooling path, not a sandbox.
88
- // The runtime's own provider calls are unaffected: this mutates only the
89
- // child spawn env, never process.env.
90
- const SHELL_EGRESS_DEAD_PROXY = 'http://127.0.0.1:1';
91
- const SHELL_EGRESS_PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'FTP_PROXY', 'RSYNC_PROXY'];
81
+ // Web-search availability is independent from ordinary shell networking.
82
+ // Keep this boundary explicit so disabling the search tool never blocks
83
+ // package managers, source-control clients, or user-configured proxies.
92
84
  export function applyShellEgressPolicy(env) {
93
- if (!env || typeof env !== 'object') return env;
94
- if (String(process.env.MIXDOG_FEATURE_WEB_SEARCH ?? '') !== '0') return env;
95
- for (const name of SHELL_EGRESS_PROXY_VARS) {
96
- env[name] = SHELL_EGRESS_DEAD_PROXY;
97
- env[name.toLowerCase()] = SHELL_EGRESS_DEAD_PROXY;
98
- }
99
- env.NO_PROXY = 'localhost,127.0.0.1,::1';
100
- env.no_proxy = env.NO_PROXY;
101
85
  return env;
102
86
  }
103
87
 
@@ -0,0 +1,25 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { applyShellEgressPolicy } from './env-scrub.mjs';
5
+
6
+ test('web-search disabled preserves ordinary shell network environment', () => {
7
+ const previous = process.env.MIXDOG_FEATURE_WEB_SEARCH;
8
+ process.env.MIXDOG_FEATURE_WEB_SEARCH = '0';
9
+ try {
10
+ const env = {
11
+ HTTP_PROXY: 'http://proxy.example:8080',
12
+ HTTPS_PROXY: 'http://proxy.example:8080',
13
+ NO_PROXY: 'localhost',
14
+ };
15
+ assert.equal(applyShellEgressPolicy(env), env);
16
+ assert.deepEqual(env, {
17
+ HTTP_PROXY: 'http://proxy.example:8080',
18
+ HTTPS_PROXY: 'http://proxy.example:8080',
19
+ NO_PROXY: 'localhost',
20
+ });
21
+ } finally {
22
+ if (previous === undefined) delete process.env.MIXDOG_FEATURE_WEB_SEARCH;
23
+ else process.env.MIXDOG_FEATURE_WEB_SEARCH = previous;
24
+ }
25
+ });
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.16",
2
+ "version": "0.1.17",
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.17/mixdog-graph-darwin-arm64",
7
+ "sha256": "3b8850422ad267b45572ceef7993fb57c2b7b9c71cc4c026056335828a28a65a"
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.17/mixdog-graph-darwin-x64",
11
+ "sha256": "0d793977ddd1d84851d1a9e3244415e7dc5b35f2f636a43f17af4745ea4485a8"
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.17/mixdog-graph-linux-arm64",
15
+ "sha256": "036dcc5f16a5cfc72e95de198de8b5d2ad56d62e88677bb90222206624268a6a"
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.17/mixdog-graph-linux-x64",
19
+ "sha256": "7bd4325dc1761f3fcf77cc8926a45e007f85d1b6954bfea819fafb6625635904"
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.17/mixdog-graph-win32-x64.exe",
23
+ "sha256": "c0441052d0a09ddb3a3c291c7e7bffcf767dac9b6353e2f2e3e6954be2994712"
24
24
  }
25
25
  }
26
26
  }
@@ -79,7 +79,7 @@ class LineStream extends EventEmitter {
79
79
  }
80
80
 
81
81
  export class NativeSpawnChild extends EventEmitter {
82
- constructor(id, cancel) {
82
+ constructor(id, cancel, send = null) {
83
83
  super();
84
84
  this.pid = undefined;
85
85
  this.killed = false;
@@ -91,9 +91,27 @@ export class NativeSpawnChild extends EventEmitter {
91
91
  this.__nativeSpawn = true;
92
92
  this._id = id;
93
93
  this._cancel = cancel;
94
+ this._send = send;
94
95
  this._closed = false;
95
96
  }
96
97
 
98
+ // stdinPipe children only (warm shell standby): forward script text to the
99
+ // child's stdin via the spawn server. No-ops (false) when the child was
100
+ // spawned without stdinPipe or the server link is gone.
101
+ writeStdin(text, { close = false } = {}) {
102
+ if (this._closed || this.killed || typeof this._send !== 'function') return false;
103
+ return this._send({
104
+ stdinWrite: this._id,
105
+ data: String(text ?? ''),
106
+ ...(close ? { close: true } : {}),
107
+ });
108
+ }
109
+
110
+ endStdin() {
111
+ if (typeof this._send !== 'function') return false;
112
+ return this._send({ stdinClose: this._id });
113
+ }
114
+
97
115
  kill() {
98
116
  this.killed = true;
99
117
  this._cancel?.(this._id);
@@ -140,6 +158,28 @@ function _setServerReferenced(server, referenced) {
140
158
  try { server?.child?.stdout?.[method]?.(); } catch {}
141
159
  }
142
160
 
161
+ // Referenced only while a NON-idle request is pending. Idle requests (parked
162
+ // warm shell standbys) live for minutes-to-hours; counting them would pin the
163
+ // host event loop — a one-shot CLI or test runner could never exit.
164
+ function _recomputeServerRef(server) {
165
+ let active = false;
166
+ for (const entry of server.pending.values()) {
167
+ if (!entry.idle) { active = true; break; }
168
+ }
169
+ _setServerReferenced(server, active);
170
+ }
171
+
172
+ /** Mark a pending spawn request idle (parked) or active. Idle requests do not
173
+ * keep the host process alive. Returns false when the request is gone. */
174
+ export function setNativeSpawnRequestIdle(child, idle = true) {
175
+ const server = _server;
176
+ const entry = server?.pending?.get?.(Number(child?._id));
177
+ if (!entry) return false;
178
+ entry.idle = idle === true;
179
+ _recomputeServerRef(server);
180
+ return true;
181
+ }
182
+
143
183
  function _teardown(error) {
144
184
  const server = _server;
145
185
  _server = null;
@@ -164,6 +204,30 @@ function _teardown(error) {
164
204
  try { server.child.kill(); } catch {}
165
205
  }
166
206
 
207
+ export async function shutdownNativeSpawnServer(reason = 'process-exit', timeoutMs = 1_000) {
208
+ const server = _server;
209
+ if (!server) return true;
210
+ const child = server.child;
211
+ const exited = new Promise((resolve) => {
212
+ child.once('exit', () => resolve(true));
213
+ child.once('error', () => resolve(true));
214
+ });
215
+ try { child.stdin?.end?.(); } catch {}
216
+ _teardown(new Error(`native spawn server shutdown (${reason})`));
217
+ let timer;
218
+ const stopped = await Promise.race([
219
+ exited,
220
+ new Promise((resolve) => {
221
+ timer = setTimeout(() => resolve(false), Math.max(10, Number(timeoutMs) || 1_000));
222
+ }),
223
+ ]);
224
+ if (timer) clearTimeout(timer);
225
+ if (!stopped) {
226
+ try { child.kill('SIGKILL'); } catch {}
227
+ }
228
+ return stopped;
229
+ }
230
+
167
231
  function _ensureServer() {
168
232
  if (_server) return _server;
169
233
  if (Date.now() - _lastFailureAt < RESTART_BACKOFF_MS) return null;
@@ -184,7 +248,10 @@ function _ensureServer() {
184
248
  lines.on('line', (line) => {
185
249
  let message;
186
250
  try { message = JSON.parse(line); } catch { return; }
187
- if (message?.ready === true) return;
251
+ if (message?.ready === true) {
252
+ server.caps = (message.caps && typeof message.caps === 'object') ? message.caps : {};
253
+ return;
254
+ }
188
255
  if (String(message?.event || '').startsWith('task_')) acceptTask(message);
189
256
  const pending = server.pending.get(Number(message?.id));
190
257
  if (!pending) return;
@@ -227,6 +294,11 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
227
294
  const id = ++server.sequence;
228
295
  const fake = new NativeSpawnChild(id, (cancelId) => {
229
296
  try { server.child.stdin.write(`${JSON.stringify({ cancel: cancelId })}\n`); } catch {}
297
+ }, (payload) => {
298
+ try {
299
+ server.child.stdin.write(`${JSON.stringify({ id: ++server.sequence, ...payload })}\n`);
300
+ return true;
301
+ } catch { return false; }
230
302
  });
231
303
  const request = {
232
304
  id,
@@ -239,6 +311,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
239
311
  timeoutMs: Math.max(0, Number(spawnOptions.timeoutMs) || 0),
240
312
  outputLimit: Math.max(0, Number(spawnOptions.outputLimit) || 0),
241
313
  mergeStderr: spawnOptions.mergeStderr === true,
314
+ ...(spawnOptions.stdinPipe === true ? { stdinPipe: true } : {}),
242
315
  command: spawnOptions.command || undefined,
243
316
  shellType: spawnOptions.shellType || undefined,
244
317
  ownerSessionId: spawnOptions.ownerSessionId || undefined,
@@ -255,7 +328,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
255
328
  fake.emit('error', err);
256
329
  fake.emit('close', 1, null);
257
330
  }
258
- if (server.pending.size === 0) _setServerReferenced(server, false);
331
+ _recomputeServerRef(server);
259
332
  },
260
333
  onMessage(message) {
261
334
  const event = String(message?.event || '');
@@ -279,7 +352,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
279
352
  fake.signalCode = message.signal || null;
280
353
  fake._closed = true;
281
354
  fake.emit('close', fake.exitCode, fake.signalCode);
282
- if (server.pending.size === 0) _setServerReferenced(server, false);
355
+ _recomputeServerRef(server);
283
356
  } else if (event === 'error') {
284
357
  const detail = String(message.message || 'native spawn failed');
285
358
  const err = new Error(message.code ? `${message.code}: ${detail}` : detail);
@@ -425,7 +498,7 @@ export function adoptNativeTaskByPid({
425
498
  const finish = (task, error = null) => {
426
499
  if (timer) clearTimeout(timer);
427
500
  server.pending.delete(id);
428
- if (server.pending.size === 0) _setServerReferenced(server, false);
501
+ _recomputeServerRef(server);
429
502
  if (error) reject(error);
430
503
  else resolve(task);
431
504
  };
@@ -468,6 +541,15 @@ export function adoptNativeTaskByPid({
468
541
  });
469
542
  }
470
543
 
544
+ // Capability handshake: true only after the connected spawn server announced
545
+ // stdinPipe support in its ready line. An older binary silently ignores the
546
+ // unknown stdinPipe field and gives the child a null stdin — a standby taken
547
+ // in that state would run an EMPTY script and report exit 0 without ever
548
+ // executing the command. Gate the warm-standby feature on this.
549
+ export function nativeSpawnSupportsStdinPipe() {
550
+ return _server?.caps?.stdinPipe === true;
551
+ }
552
+
471
553
  export function _resetNativeSpawnClientForTest() {
472
554
  _teardown(new Error('test reset'));
473
555
  _binaryPath = undefined;
@@ -0,0 +1,131 @@
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 STANDBY_TTL_MS = 10 * 60_000;
18
+
19
+ let _slot = null; // { native, shell, envSig, createdAt }
20
+
21
+ function _disabled() {
22
+ return process.env.MIXDOG_SHELL_WARM_STANDBY === '0';
23
+ }
24
+
25
+ function envSignature(env) {
26
+ const entries = Object.entries(env || {})
27
+ .map(([key, value]) => `${key}=${value}`)
28
+ .sort()
29
+ .join('\n');
30
+ return createHash('sha256').update(entries).digest('hex');
31
+ }
32
+
33
+ function _slotAlive(slot) {
34
+ return Boolean(slot
35
+ && slot.native?.child
36
+ && slot.native.child.exitCode == null
37
+ && !slot.native.child.killed);
38
+ }
39
+
40
+ function psQuote(value) {
41
+ return String(value).replace(/'/g, "''");
42
+ }
43
+
44
+ /** Pre-spawn the next standby (fire-and-forget; a live one is kept). */
45
+ export function ensureWarmShellStandby({ shell, env }) {
46
+ if (_disabled() || !shell) return;
47
+ // Old spawn binaries ignore stdinPipe (null stdin → instant-EOF standby
48
+ // that would ack commands without running them); require the handshake.
49
+ if (!nativeSpawnSupportsStdinPipe()) return;
50
+ if (_slotAlive(_slot)) return;
51
+ let native = null;
52
+ try {
53
+ native = tryNativeSpawn({
54
+ shell: String(shell),
55
+ argv: STANDBY_ARGS,
56
+ spawnOptions: {
57
+ env,
58
+ cwd: process.cwd(),
59
+ outputLimit: SHELL_OUTPUT_DISK_CAP,
60
+ stdinPipe: true,
61
+ shellType: 'powershell',
62
+ command: '(warm shell standby)',
63
+ },
64
+ });
65
+ } catch { native = null; }
66
+ if (!native?.child) return;
67
+ // Parked standby must not pin the host event loop (a one-shot CLI or test
68
+ // runner would otherwise never exit); reactivated at take time.
69
+ setNativeSpawnRequestIdle(native.child, true);
70
+ const slot = {
71
+ native,
72
+ shell: String(shell),
73
+ envSig: envSignature(env),
74
+ createdAt: Date.now(),
75
+ };
76
+ native.child.once('close', () => { if (_slot === slot) _slot = null; });
77
+ native.child.once('error', () => { if (_slot === slot) _slot = null; });
78
+ _slot = slot;
79
+ }
80
+
81
+ /** Take the parked standby for immediate use, or null on any mismatch.
82
+ * Always refills so the NEXT call finds a warm one. */
83
+ export function takeWarmShellStandby({ shell, env, cwd }) {
84
+ if (_disabled() || !shell) return null;
85
+ const slot = _slot;
86
+ const refill = () => { try { ensureWarmShellStandby({ shell, env }); } catch { /* best-effort */ } };
87
+ const usable = _slotAlive(slot)
88
+ && slot.shell === String(shell)
89
+ && typeof slot.native.child.writeStdin === 'function'
90
+ && Number.isFinite(slot.native.child.pid) && slot.native.child.pid > 0
91
+ && Date.now() - slot.createdAt <= STANDBY_TTL_MS
92
+ && slot.envSig === envSignature(env);
93
+ if (!usable) {
94
+ // Stale (TTL/env/shell drift): kill so it cannot linger; a still-
95
+ // warming slot (no pid yet) is left in place for a later call.
96
+ if (_slotAlive(slot) && Number.isFinite(slot.native.child.pid)
97
+ && (slot.shell !== String(shell)
98
+ || Date.now() - slot.createdAt > STANDBY_TTL_MS
99
+ || slot.envSig !== envSignature(env))) {
100
+ _slot = null;
101
+ try { slot.native.child.kill(); } catch { /* best-effort */ }
102
+ }
103
+ refill();
104
+ return null;
105
+ }
106
+ _slot = null;
107
+ // Back to active: the caller is about to run a real command on it and the
108
+ // host must stay alive until that command settles.
109
+ setNativeSpawnRequestIdle(slot.native.child, false);
110
+ refill();
111
+ const feed = (commandText, workDir) => {
112
+ // cwd prelude: the standby was spawned in the daemon cwd. Set-Location
113
+ // fixes $PWD and native-child working dirs; Environment.CurrentDirectory
114
+ // fixes .NET relative-path APIs.
115
+ const prelude = workDir
116
+ ? `Set-Location -LiteralPath '${psQuote(workDir)}'; [System.Environment]::CurrentDirectory = '${psQuote(workDir)}'; `
117
+ : '';
118
+ // Single atomic write+EOF: a separate close message can race ahead
119
+ // of the server's async write thread and feed an empty script.
120
+ slot.native.child.writeStdin(prelude + String(commandText ?? ''), { close: true });
121
+ };
122
+ return { spawned: slot.native, feed };
123
+ }
124
+
125
+ export function _resetWarmShellStandbyForTest() {
126
+ const slot = _slot;
127
+ _slot = null;
128
+ if (slot?.native?.child) {
129
+ try { slot.native.child.kill(); } catch { /* best-effort */ }
130
+ }
131
+ }
@@ -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,
@@ -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,
@@ -340,11 +342,23 @@ export function execShellCommand({
340
342
  else pendingChildError = pendingChildError || err;
341
343
  };
342
344
  _onChildErrorRef = _onChildError;
345
+ // Warm-standby fast path (pwsh only): a pre-spawned bootstrap pwsh
346
+ // reads the script from stdin, skipping CreateProcess + Defender scan
347
+ // for this call. Any miss (env drift, TTL, dead/warming standby,
348
+ // MIXDOG_SHELL_WARM_STANDBY=0) falls through to the gated spawn below.
349
+ let _standby = null;
350
+ if (!_useDirectArgv && shellArg === '-Command') {
351
+ try { _standby = takeWarmShellStandby({ shell, env, cwd }); } catch { _standby = null; }
352
+ }
343
353
  // Spawn-burst gate: hold a 'process-spawn' slot only across process
344
354
  // creation (CreateProcess + AV scan + EPERM retries), released the
345
355
  // moment the child exists. Bounds the Defender convoy a shell burst
346
356
  // creates without limiting how many commands RUN concurrently — the
347
357
  // full-lifetime gating concern in the note below stays true.
358
+ let spawned = null;
359
+ if (_standby) {
360
+ spawned = _standby.spawned;
361
+ } else {
348
362
  const _releaseSpawnSlot = await acquireChildSpawnSlot(abortSignal || null, 'process-spawn', {
349
363
  ownerKey: ownerSessionId,
350
364
  });
@@ -355,7 +369,6 @@ export function execShellCommand({
355
369
  try { _releaseSpawnSlot(); } catch { /* idempotent */ }
356
370
  throw abortSignal.reason || new Error('aborted');
357
371
  }
358
- let spawned;
359
372
  try {
360
373
  spawned = await _spawnShellWithRetry({
361
374
  shell,
@@ -378,8 +391,12 @@ export function execShellCommand({
378
391
  } finally {
379
392
  try { _releaseSpawnSlot(); } catch { /* idempotent */ }
380
393
  }
394
+ }
381
395
  child = spawned.child;
382
396
  spawned.adoptErrorHandler(_onChildError);
397
+ // Feed the standby only after the error handler is attached; server
398
+ // messages cannot be processed before this synchronous block yields.
399
+ if (_standby) _standby.feed(_spawnCommand, cwd);
383
400
  } catch (err) {
384
401
  const cleanupError = await releaseResourceLease();
385
402
  const spawnText = String((err && err.message) || err);
@@ -684,7 +701,9 @@ export function execShellCommand({
684
701
  const secs = Math.max(0, Math.round((Date.now() - _startMs) / 1000));
685
702
  const _verb = reason === 'timeout'
686
703
  ? `moved to background at timeout after ${secs}s`
687
- : `auto-backgrounded after ${secs}s`;
704
+ : (reason === 'explicit'
705
+ ? 'started in background'
706
+ : `auto-backgrounded after ${secs}s`);
688
707
  resolveResult(
689
708
  new ExecResult({
690
709
  stdout,
@@ -795,7 +814,9 @@ export function execShellCommand({
795
814
  // Arm the auto-background timer only for the genuine foreground one-shot
796
815
  // path: a positive threshold strictly below the hard timeout, and not a
797
816
  // trailing-`&` background command (those already detach + settle on exit).
798
- if (
817
+ if (startInBackground && !_isBackground) {
818
+ setImmediate(() => { fireAutoBackground({ reason: 'explicit' }); });
819
+ } else if (
799
820
  typeof autoBackgroundMs === 'number' &&
800
821
  autoBackgroundMs > 0 &&
801
822
  !_isBackground &&
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.1",
2
+ "version": "0.1.2",
3
3
  "_comment": "Synced from immutable spawn-v release assets.",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.1/mixdog-spawn-darwin-arm64",
7
- "sha256": "0f793fbacae03ded349ae7884fc1c6d9177378a9e3045706ea3ea0f4aa64a765"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-darwin-arm64",
7
+ "sha256": "3c2eb5ecf47dccb4aeea113f422ede24f6dde22dceab7c9c77f6963ef9aded7d"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.1/mixdog-spawn-darwin-x64",
11
- "sha256": "c654a9d55ebf679a39e4d9b53e776f9c2f43b3f362be8d719989d19f8901b06a"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-darwin-x64",
11
+ "sha256": "7288b073b940d82b64e9e888e723ae95204a28472053676fb6650d5c68362836"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.1/mixdog-spawn-linux-arm64",
15
- "sha256": "f9116f828428067e746edef3d39c300a91e0c1763ec1f38bf1471cc4912ab2c3"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-linux-arm64",
15
+ "sha256": "308a53b7c9dbae5ce8c36dc98ab35ac869f0251671829cbdcb09162261b063bf"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.1/mixdog-spawn-linux-x64",
19
- "sha256": "565dddf81959fe73e4a8e973eaa24317862afcbde6ac84ef10cef6b893ef9fbc"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-linux-x64",
19
+ "sha256": "9f619c17d13e2786e38a2a6017a5e9b1e2f88370132d2c0ff8ee80245b64d0c3"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.1/mixdog-spawn-win32-x64.exe",
23
- "sha256": "1130ee6f47da4e11a644e5c56a6bc29a4887201bfd42a3df3ab0f50acb9abcb6"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-win32-x64.exe",
23
+ "sha256": "ee229f2ee2066b2c1a7c984b2ac4cd5bcfed5758173115de366defb1f97c7b51"
24
24
  }
25
25
  }
26
26
  }
@@ -40,6 +40,7 @@ import {
40
40
  hasTimelineIntent,
41
41
  hasVagueLatestWorkIntent,
42
42
  latestRecallSearchTerms,
43
+ latestRecallTopicTerms,
43
44
  mergeHistoricalRecallRows,
44
45
  preserveLatestConceptRows,
45
46
  prioritizeHistoricalRootEvidence,
@@ -47,7 +47,8 @@ export function createLifecycleApi(deps) {
47
47
  hooks, hookCommonPayload, mgr, statusRoutes, channels, agentTool, mcpClient,
48
48
  warmupTimers, prewarmTimers,
49
49
  flushAllConfigSavesAsync,
50
- withTeardownDeadline, closePatchRuntimeIfLoaded, stopSelfUpdateBootCheck,
50
+ withTeardownDeadline, closePatchRuntimeIfLoaded, closeNativeToolTransports,
51
+ stopSelfUpdateBootCheck,
51
52
  createCurrentSession, refreshRouteEffort,
52
53
  invalidateContextStatusCache, invalidatePreSessionToolSurface,
53
54
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, getStandaloneTools,
@@ -328,11 +329,16 @@ export function createLifecycleApi(deps) {
328
329
  }))
329
330
  .catch(() => {})
330
331
  : null;
332
+ const nativeToolStop = isProcessExit
333
+ ? Promise.resolve(shellJobsStop)
334
+ .then(() => closeNativeToolTransports?.(reason))
335
+ .catch(() => {})
336
+ : null;
331
337
  if (detach) {
332
338
  try { await withTeardownDeadline(channelStop, 300, false); } catch {}
333
339
  try { await withTeardownDeadline(shellJobsStop, 300, false); } catch {}
334
340
  try { await withTeardownDeadline(memoryStop, 1500, false); } catch {}
335
- for (const stop of [mcpStop, openaiWsStop, patchStop]) {
341
+ for (const stop of [mcpStop, openaiWsStop, patchStop, nativeToolStop]) {
336
342
  Promise.resolve(stop).catch(() => {});
337
343
  }
338
344
  onProcessExit();
@@ -345,6 +351,7 @@ export function createLifecycleApi(deps) {
345
351
  withTeardownDeadline(patchStop, 1500, false),
346
352
  withTeardownDeadline(memoryStop, 5500, false),
347
353
  withTeardownDeadline(shellJobsStop, 1500, false),
354
+ withTeardownDeadline(nativeToolStop, 1500, false),
348
355
  ]);
349
356
  onProcessExit();
350
357
  return ok;
@@ -103,6 +103,15 @@ export function createPrewarmSchedulers({
103
103
  } catch (error) {
104
104
  bootProfile('tool-runtime:native-shell-failed', { error: error?.message || String(error) });
105
105
  }
106
+ try {
107
+ // Shell jobs orphaned by a daemon restart: finalize their records and
108
+ // deliver one completion notice to each owner session so the outcome
109
+ // is never silently dropped.
110
+ const { reconcileRecoveredShellJobCompletions } = await import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs');
111
+ bootProfile('tool-runtime:shell-job-recovery', { notified: await reconcileRecoveredShellJobCompletions() });
112
+ } catch (error) {
113
+ bootProfile('tool-runtime:shell-job-recovery-failed', { error: error?.message || String(error) });
114
+ }
106
115
  try {
107
116
  const { prewarmTokenEstimator } = await import('../runtime/agent/orchestrator/session/context-utils.mjs');
108
117
  bootProfile('tool-runtime:token-estimator', { warmed: prewarmTokenEstimator() === true });
@@ -326,6 +326,7 @@ export async function createMixdogSessionRuntime({
326
326
  toolMode = 'full',
327
327
  approvalMode = null,
328
328
  disallowDelegation = false,
329
+ autoWakeCompletions = true,
329
330
  initialConfig = null,
330
331
  remote = false,
331
332
  desktopSession: initialDesktopSession = null,
@@ -510,6 +511,17 @@ export async function createMixdogSessionRuntime({
510
511
  }
511
512
  }
512
513
 
514
+ async function closeNativeToolTransports(reason = 'process-exit') {
515
+ const [spawnClient, searchClient] = await Promise.all([
516
+ import('../runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs'),
517
+ import('../runtime/agent/orchestrator/tools/builtin/native-search-client.mjs'),
518
+ ]);
519
+ await Promise.allSettled([
520
+ spawnClient.shutdownNativeSpawnServer?.(reason),
521
+ searchClient.shutdownNativeSearchServer?.(reason),
522
+ ]);
523
+ }
524
+
513
525
  const configStartedAt = performance.now();
514
526
  rt.config = initialConfig && typeof initialConfig === 'object'
515
527
  ? initialConfig
@@ -726,7 +738,7 @@ export async function createMixdogSessionRuntime({
726
738
  } = createNotificationBus({
727
739
  listeners: notificationListeners,
728
740
  mgr,
729
- onCompletionQueued: wakeQueuedCompletion,
741
+ onCompletionQueued: autoWakeCompletions ? wakeQueuedCompletion : null,
730
742
  });
731
743
  // Adopt a session as this runtime's identity wherever setSession is
732
744
  // injected (lifecycle resume, model-route swap, workflow swap, turn api).
@@ -1585,6 +1597,7 @@ export async function createMixdogSessionRuntime({
1585
1597
  flushAllConfigSavesAsync,
1586
1598
  withTeardownDeadline,
1587
1599
  closePatchRuntimeIfLoaded,
1600
+ closeNativeToolTransports,
1588
1601
  stopSelfUpdateBootCheck: () => selfUpdate.stopBootCheck(),
1589
1602
  createCurrentSession,
1590
1603
  refreshRouteEffort,
@@ -35,6 +35,10 @@ test('omitToolRoutes drops search and memory clauses independently', () => {
35
35
  test('shared tool rules omit disabled search and memory routes', () => {
36
36
  const pluginRoot = join(process.cwd(), 'src');
37
37
  const full = buildSharedToolContent({ PLUGIN_ROOT: pluginRoot });
38
+ assert.match(
39
+ full,
40
+ /^# Tool Use\s+- When an internal Mixdog rule conflicts with the user's latest explicit\s+request, follow the user's request\./,
41
+ );
38
42
  assert.match(full, /`search`/);
39
43
  assert.match(full, /`memory`/);
40
44
  const omitted = buildSharedToolContent({
@@ -365,6 +365,9 @@ export function createChannelTransport({
365
365
  return 'cancelled';
366
366
  }
367
367
 
368
+ let emptyFireBackoffMs = 0;
369
+ let nextEmptyFireAt = 0;
370
+
368
371
  function cancelGrace() {
369
372
  if (graceTimer) { try { clearTimeout(graceTimer); } catch {} graceTimer = null; }
370
373
  }
@@ -373,10 +376,18 @@ export function createChannelTransport({
373
376
  if (closed || graceTimer) return;
374
377
  if (!everHadClient || clients.size > 0) return;
375
378
  if (typeof onClientsEmpty !== 'function' || clientGraceMs <= 0) return;
379
+ // No-op-fire backoff: when onClientsEmpty() repeatedly declines to shut
380
+ // the daemon down (a session client is still alive on the other front
381
+ // door), the sweep would otherwise re-fire every grace period and spam
382
+ // the log with an elapsed→deferred pair for hours. Double the re-fire
383
+ // interval up to 10 minutes; any client registration resets it.
384
+ if (Date.now() < nextEmptyFireAt) return;
376
385
  graceTimer = setTimeout(() => {
377
386
  graceTimer = null;
378
387
  pruneDeadClients();
379
388
  if (clients.size > 0) return;
389
+ emptyFireBackoffMs = Math.min(Math.max(clientGraceMs, emptyFireBackoffMs * 2), 600_000);
390
+ nextEmptyFireAt = Date.now() + emptyFireBackoffMs;
380
391
  log(`client grace elapsed (${reason}); no live clients — self-shutdown`);
381
392
  try { onClientsEmpty(); } catch {}
382
393
  }, clientGraceMs);
@@ -553,6 +564,8 @@ export function createChannelTransport({
553
564
  }
554
565
  everHadClient = true;
555
566
  cancelGrace();
567
+ emptyFireBackoffMs = 0;
568
+ nextEmptyFireAt = 0;
556
569
  startSweep();
557
570
  log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'}`);
558
571
  // The unified daemon starts the channels runtime (automation, webhooks,
@@ -309,6 +309,7 @@ async function shutdown(reason, code = 0) {
309
309
 
310
310
  /** One process, two front doors (channels + sessions): an idle side must never
311
311
  * evict a busy one, so every self-shutdown trigger checks BOTH registries. */
312
+ let _lastDeferredLog = { key: '', at: 0, suppressed: 0 };
312
313
  function maybeSelfShutdown(reason) {
313
314
  const channelClients = transport?.clientCount ?? 0;
314
315
  const sessionClients = sessionTransport?.clientCount ?? 0;
@@ -319,7 +320,19 @@ function maybeSelfShutdown(reason) {
319
320
  clearTimeout(shutdownRecheckTimer);
320
321
  shutdownRecheckTimer = null;
321
322
  }
322
- log(`shutdown deferred (${reason}): channels=${channelClients} sessionClients=${sessionClients} remote=${remoteClients}`);
323
+ // Identical defers repeat for hours while one front door stays occupied;
324
+ // log the first occurrence, then one summary line per minute.
325
+ const deferKey = `${reason}|${channelClients}|${sessionClients}|${remoteClients}`;
326
+ const now = Date.now();
327
+ if (deferKey === _lastDeferredLog.key && now - _lastDeferredLog.at < 60_000) {
328
+ _lastDeferredLog.suppressed += 1;
329
+ } else {
330
+ const suffix = _lastDeferredLog.suppressed > 0
331
+ ? ` (+${_lastDeferredLog.suppressed} identical defers suppressed)`
332
+ : '';
333
+ log(`shutdown deferred (${reason}): channels=${channelClients} sessionClients=${sessionClients} remote=${remoteClients}${suffix}`);
334
+ _lastDeferredLog = { key: deferKey, at: now, suppressed: 0 };
335
+ }
323
336
  return;
324
337
  }
325
338
  const activeCalls = (transport?.activeCount ?? 0) + (sessionTransport?.activeCount ?? 0);