mixdog 0.9.21 → 0.9.23

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 (90) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +165 -0
  4. package/scripts/channel-daemon-stub.mjs +69 -0
  5. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  6. package/scripts/tool-smoke.mjs +30 -17
  7. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  8. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  9. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  10. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  11. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  15. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  16. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  18. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  20. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +10 -10
  21. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
  22. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
  23. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  26. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +23 -13
  27. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  28. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  29. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  30. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  31. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  32. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  33. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  34. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  35. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  36. package/src/runtime/memory/index.mjs +54 -7
  37. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  38. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  39. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  40. package/src/runtime/shared/atomic-file.mjs +44 -10
  41. package/src/runtime/shared/tool-primitives.mjs +31 -1
  42. package/src/runtime/shared/tool-surface.mjs +23 -13
  43. package/src/session-runtime/config-lifecycle.mjs +48 -7
  44. package/src/session-runtime/lifecycle-api.mjs +9 -0
  45. package/src/session-runtime/mcp-glue.mjs +63 -1
  46. package/src/session-runtime/resource-api.mjs +62 -8
  47. package/src/session-runtime/runtime-core.mjs +32 -2
  48. package/src/session-runtime/session-text.mjs +41 -0
  49. package/src/session-runtime/session-turn-api.mjs +24 -0
  50. package/src/session-runtime/settings-api.mjs +8 -1
  51. package/src/session-runtime/tool-catalog.mjs +306 -38
  52. package/src/session-runtime/tool-defs.mjs +7 -7
  53. package/src/session-runtime/workflow.mjs +2 -1
  54. package/src/standalone/channel-daemon-client.mjs +224 -0
  55. package/src/standalone/channel-daemon-transport.mjs +351 -0
  56. package/src/standalone/channel-daemon.mjs +139 -0
  57. package/src/standalone/channel-worker.mjs +213 -4
  58. package/src/standalone/hook-bus.mjs +71 -3
  59. package/src/tui/App.jsx +105 -17
  60. package/src/tui/app/clipboard.mjs +39 -19
  61. package/src/tui/app/doctor.mjs +57 -0
  62. package/src/tui/app/extension-pickers.mjs +53 -9
  63. package/src/tui/app/maintenance-pickers.mjs +0 -5
  64. package/src/tui/app/slash-dispatch.mjs +4 -4
  65. package/src/tui/app/text-layout.mjs +11 -0
  66. package/src/tui/app/use-mouse-input.mjs +235 -51
  67. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  68. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  69. package/src/tui/app/use-transcript-window.mjs +55 -1
  70. package/src/tui/components/Message.jsx +1 -1
  71. package/src/tui/components/PromptInput.jsx +3 -1
  72. package/src/tui/components/QueuedCommands.jsx +21 -10
  73. package/src/tui/components/ToolExecution.jsx +2 -2
  74. package/src/tui/dist/index.mjs +653 -310
  75. package/src/tui/engine/session-api.mjs +17 -7
  76. package/src/tui/engine/session-flow.mjs +6 -0
  77. package/src/tui/engine.mjs +8 -0
  78. package/src/tui/index.jsx +62 -18
  79. package/src/tui/paste-attachments.mjs +26 -0
  80. package/src/ui/statusline.mjs +45 -5
  81. package/src/ui/tool-card.mjs +8 -1
  82. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  83. package/src/workflows/bench/WORKFLOW.md +46 -0
  84. package/src/workflows/default/WORKFLOW.md +6 -0
  85. package/src/workflows/solo/WORKFLOW.md +5 -0
  86. package/vendor/ink/build/ink.js +23 -1
  87. package/vendor/ink/build/output.js +154 -71
  88. package/vendor/ink/build/render-node-to-output.js +44 -2
  89. package/vendor/ink/build/render.js +4 -0
  90. package/vendor/ink/build/renderer.js +4 -1
@@ -4,6 +4,7 @@ import { join } from 'path';
4
4
  import { tmpdir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { smartReadTruncate } from '../tools/builtin/read-formatting.mjs';
7
+ import { shutdownStdioChild } from './child-tree.mjs';
7
8
  // --- Types ---
8
9
  /** Known auto-detect targets: port file path relative to tmpdir.
9
10
  * Note: `mixdog` used to self-loopback via active-instance.json's
@@ -182,7 +183,7 @@ export async function executeMcpTool(name, args) {
182
183
  mcpLog(`[mcp-client] Tool call failed, attempting reconnect...\n`);
183
184
  await new Promise(r => setTimeout(r, 500));
184
185
  try {
185
- await server.client.close();
186
+ await _closeServer(server);
186
187
  } catch { /* ignore close error */ }
187
188
  try {
188
189
  await connectServer(serverName, server.cfg);
@@ -241,7 +242,9 @@ async function _callToolWithTimeout(server, toolName, args) {
241
242
  }
242
243
  const timeout = new Promise((_, rej) => {
243
244
  timer = setTimeout(() => {
244
- try { server.client.close().catch(() => {}); } catch { /* ignore */ }
245
+ // Route through the full tree-shutdown path so a timed-out stdio
246
+ // server never orphans grandchildren. Fire-and-forget.
247
+ try { _closeServer(server).catch(() => {}); } catch { /* ignore */ }
245
248
  const err = new Error(`MCP tool call timed out after ${timeoutMs}ms (server="${server.name}", tool="${toolName}")`);
246
249
  err.code = 'EMCPTOOLTIMEOUT';
247
250
  err.serverName = server.name;
@@ -338,13 +341,47 @@ export function mcpToolHasField(name, field) {
338
341
  export async function disconnectAll() {
339
342
  for (const [name, server] of servers) {
340
343
  try {
341
- await server.client.close();
344
+ await _closeServer(server);
342
345
  }
343
346
  catch { /* ignore */ }
344
347
  servers.delete(name);
345
348
  }
346
349
  _invalidateMcpToolFieldMemo();
347
350
  }
351
+ /**
352
+ * Disconnect a single MCP server by name. No-op (returns false) when the
353
+ * server is not in the live registry; otherwise closes its transport, removes
354
+ * it, and invalidates the tool-field memo. Lets callers toggle one server
355
+ * without a full disconnectAll()/reconnect cycle.
356
+ */
357
+ export async function disconnectMcpServer(name) {
358
+ const server = servers.get(name);
359
+ if (!server) return false;
360
+ try {
361
+ await _closeServer(server);
362
+ }
363
+ catch { /* ignore */ }
364
+ servers.delete(name);
365
+ _invalidateMcpToolFieldMemo();
366
+ return true;
367
+ }
368
+ /**
369
+ * Close a single server: for stdio transports first shut down the full child
370
+ * process tree (close stdin -> grace -> tree kill) so wrapper chains such as
371
+ * uvx/npx/uv never orphan grandchildren, then release the SDK client. The
372
+ * tree teardown runs before client.close() because the SDK's own close()
373
+ * only kills the direct child and discards the pid we need to walk the tree.
374
+ */
375
+ async function _closeServer(server) {
376
+ const transport = server?.transport;
377
+ // Live stdio transports expose the spawned ChildProcess on _process.
378
+ if (transport && transport._process) {
379
+ try { await shutdownStdioChild(transport); }
380
+ catch { /* ignore */ }
381
+ }
382
+ try { await server.client.close(); }
383
+ catch { /* ignore */ }
384
+ }
348
385
  async function connectServer(name, cfg) {
349
386
  const {
350
387
  Client,
@@ -514,7 +514,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
514
514
  : parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: callId, name: 'tool_search', finishReason: 'done' });
515
515
  const call = {
516
516
  id: callId,
517
- name: 'tool_search',
517
+ name: 'load_tool',
518
518
  // Schema is a plain object ({query,select,limit}); an array must
519
519
  // never pass through as args.
520
520
  arguments: (_tsArgs && typeof _tsArgs === 'object' && !Array.isArray(_tsArgs)) ? _tsArgs : {},
@@ -107,7 +107,9 @@ export function toOpenAITools(tools) {
107
107
 
108
108
  export function toResponsesTools(tools) {
109
109
  return tools.map((t) => {
110
- if (t?.name === 'tool_search') {
110
+ // load_tool advertises as the OpenAI-native `tool_search` wire type
111
+ // (legacy 'tool_search' name still accepted for back-compat).
112
+ if (t?.name === 'load_tool' || t?.name === 'tool_search') {
111
113
  return {
112
114
  type: 'tool_search',
113
115
  execution: 'client',
@@ -193,7 +195,7 @@ export function parseResponsesToolCalls(response, label) {
193
195
  : parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: item.call_id || item.id, name: 'tool_search', finishReason });
194
196
  out.push({
195
197
  id: item.call_id || item.id,
196
- name: 'tool_search',
198
+ name: 'load_tool',
197
199
  // Schema is a plain object ({query,select,limit}); an array
198
200
  // (parsed JSON or passthrough) must never pass through as args.
199
201
  arguments: (_tsArgs && typeof _tsArgs === 'object' && !Array.isArray(_tsArgs)) ? _tsArgs : {},
@@ -294,7 +296,7 @@ export function toResponsesInputMessage(m, pendingToolMedia = null, customToolCa
294
296
  const items = [];
295
297
  if (m.content) items.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
296
298
  for (const tc of m.toolCalls) {
297
- if (tc.nativeType === 'tool_search_call' || tc.name === 'tool_search') {
299
+ if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
298
300
  items.push({
299
301
  type: 'tool_search_call',
300
302
  call_id: tc.id,
@@ -451,7 +451,7 @@ export async function sendViaHttpSse({
451
451
  }
452
452
  const call = {
453
453
  id: callId,
454
- name: 'tool_search',
454
+ name: 'load_tool',
455
455
  arguments: args,
456
456
  nativeType: 'tool_search_call',
457
457
  };
@@ -501,7 +501,7 @@ export async function sendViaHttpSse({
501
501
  // mistakes it for a function call by id collision/empty id.
502
502
  if (event.item.id) {
503
503
  pendingCalls.set(event.item.id, {
504
- name: 'tool_search',
504
+ name: 'load_tool',
505
505
  callId: event.item.call_id || '',
506
506
  kind: 'tool_search',
507
507
  });
@@ -444,7 +444,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
444
444
  // reasoning in `input` triggers "Duplicate item".
445
445
  if (m.content) out.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
446
446
  for (const tc of m.toolCalls) {
447
- if (tc.nativeType === 'tool_search_call' || tc.name === 'tool_search') {
447
+ if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
448
448
  out.push({
449
449
  type: 'tool_search_call',
450
450
  call_id: tc.id,
@@ -480,7 +480,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
480
480
  }
481
481
 
482
482
  function toOpenAIResponsesTool(t) {
483
- if (t?.name === 'tool_search') {
483
+ if (t?.name === 'load_tool' || t?.name === 'tool_search') {
484
484
  return {
485
485
  type: 'tool_search',
486
486
  execution: 'client',
@@ -355,7 +355,7 @@ export async function _streamResponse({
355
355
  if (!callId || toolCalls.some((call) => call.id === callId)) return;
356
356
  const call = {
357
357
  id: callId,
358
- name: 'tool_search',
358
+ name: 'load_tool',
359
359
  arguments: parseToolSearchArgs(item.arguments),
360
360
  nativeType: 'tool_search_call',
361
361
  };
@@ -24,8 +24,9 @@ export function crossTurnSignature(name, args) {
24
24
 
25
25
  // Tool names that are non-eager (no readOnlyHint) but are NOT edits/progress —
26
26
  // they must not reset the escalation ladder's "zero edit" condition. Skill /
27
- // recall / agent / task / cwd / tool_search are exploration/meta plumbing.
28
- const NON_PROGRESS_TOOLS = new Set(['Skill', 'recall', 'agent', 'task', 'cwd', 'tool_search']);
27
+ // recall / agent / task / cwd / load_tool are exploration/meta plumbing.
28
+ // (tool_search kept as legacy alias for old transcripts.)
29
+ const NON_PROGRESS_TOOLS = new Set(['Skill', 'recall', 'agent', 'task', 'cwd', 'load_tool', 'tool_search']);
29
30
 
30
31
  // True when a successfully-executed tool represents real edit/progress. A tool
31
32
  // counts as progress only if its def lacks readOnlyHint (not eager) AND it is
@@ -1,7 +1,7 @@
1
1
  // Deferred catalog call-through: inactive catalog tool_use → discovery bookkeeping
2
2
  // then normal executeTool routing (runtime errors only; no pre-dispatch schema).
3
3
  import { clean } from '../../../../../session-runtime/session-text.mjs';
4
- import { isReadonlySelectable, selectDeferredTools } from '../../../../../session-runtime/tool-catalog.mjs';
4
+ import { deferredCatalogUnion, isReadonlySelectable, selectDeferredTools } from '../../../../../session-runtime/tool-catalog.mjs';
5
5
 
6
6
  /** Skill-list plumbing only; mutation/MCP/builtins must use the catalog+mode gate. */
7
7
  const INACTIVE_INFRA_BYPASS = new Set(['skills_list', 'skill_view']);
@@ -13,7 +13,10 @@ function isActiveSessionTool(session, name) {
13
13
  }
14
14
 
15
15
  function lookupDeferredCatalogTool(session, name) {
16
- const catalog = Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : [];
16
+ // Union of the boot-frozen catalog and the late-connected MCP catalog so a
17
+ // direct call to a tool whose server connected after boot resolves and
18
+ // auto-loads (selectDeferredTools promotes it onto session.tools).
19
+ const catalog = deferredCatalogUnion(session);
17
20
  const key = clean(name);
18
21
  if (!key) return null;
19
22
  for (const tool of catalog) {
@@ -66,7 +69,7 @@ export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
66
69
  if (resolvedMode === null) {
67
70
  if (!isReadonlySelectable(tool)) {
68
71
  return denyDeferredCallThrough(
69
- `Error: tool "${toolLabel}" is deferred and cannot be auto-loaded without a resolved tool mode; use tool_search or load it explicitly.`,
72
+ `Error: tool "${toolLabel}" is deferred and cannot be auto-loaded without a resolved tool mode; load it with load_tool names:["${toolLabel}"].`,
70
73
  );
71
74
  }
72
75
  } else if (resolvedMode === 'readonly' && !isReadonlySelectable(tool)) {
@@ -81,7 +81,7 @@ export function resolveToolResultAfterHook(originalResult, hookResult) {
81
81
  }
82
82
 
83
83
  export function parseNativeToolSearchPayload(toolName, result) {
84
- if (toolName !== 'tool_search' || typeof result !== 'string') return null;
84
+ if ((toolName !== 'load_tool' && toolName !== 'tool_search') || typeof result !== 'string') return null;
85
85
  try {
86
86
  const parsed = JSON.parse(result);
87
87
  const native = parsed?.nativeToolSearch;
@@ -74,6 +74,39 @@ function recallMemoryTimeoutMs(session) {
74
74
  // misconfigured tiny value can't turn the bound into a busy no-wait.
75
75
  return Math.max(250, configured || RECALL_MEMORY_CALL_TIMEOUT_MS);
76
76
  }
77
+ // Cold-start allowance (clear/manual path only): a booting memory daemon can
78
+ // miss the tight first bound (waitForPort + first-RPC warmup ~2-10s). On a
79
+ // timeout we retry ONCE with a longer bound before honoring the bail-to-
80
+ // semantic contract, so a rebooting runtime succeeds instead of instantly
81
+ // failing. Non-timeout errors and outer aborts propagate immediately.
82
+ // 15s: memory boot is ~2-4s warm-cache / ~10s worst since the PG fast-start
83
+ // fix; keeping this tight bounds the clear path's worst case (2 memory calls
84
+ // retried + 120s semantic) under the TUI auto-clear watchdog (180s).
85
+ const RECALL_COLD_START_TIMEOUT_MS = 15_000;
86
+ function isTimeoutError(err) {
87
+ return typeof err?.message === 'string' && err.message.includes('timed out after');
88
+ }
89
+ async function callMemoryColdStart(args, callerCtx, timeoutMs) {
90
+ try {
91
+ return await callMemoryBounded(args, callerCtx, timeoutMs);
92
+ } catch (err) {
93
+ if (!isTimeoutError(err) || callerCtx?.signal?.aborted) throw err;
94
+ const coldMs = Math.max(timeoutMs, RECALL_COLD_START_TIMEOUT_MS);
95
+ if (coldMs <= timeoutMs) throw err;
96
+ try { process.stderr.write(`[session] recall-fasttrack ${args?.action || 'call'} cold-start retry (${timeoutMs}ms -> ${coldMs}ms)\n`); } catch {}
97
+ return await callMemoryBounded(args, callerCtx, coldMs);
98
+ }
99
+ }
100
+ // Semantic-compact timeout scales with transcript size (clear/manual path):
101
+ // default max(30s, ~10s per 25k estimated message tokens) capped at 120s, so a
102
+ // large (~100k-token) transcript no longer dies on a fixed 30s bound.
103
+ // session.compaction.timeoutMs still overrides.
104
+ function semanticCompactTimeoutMs(session, messageTokens) {
105
+ const override = positiveContextWindow(session?.compaction?.timeoutMs);
106
+ if (override) return override;
107
+ const scaled = Math.ceil((messageTokens || 0) / 25_000) * 10_000;
108
+ return Math.min(120_000, Math.max(30_000, scaled));
109
+ }
77
110
  async function callMemoryBounded(args, callerCtx, timeoutMs) {
78
111
  const ac = new AbortController();
79
112
  const outer = callerCtx?.signal;
@@ -118,7 +151,7 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
118
151
  || Math.max(500, Math.min(5000, messages.length || 0));
119
152
  const memoryTimeoutMs = recallMemoryTimeoutMs(session);
120
153
  try {
121
- await callMemoryBounded({
154
+ await callMemoryColdStart({
122
155
  action: 'ingest_session',
123
156
  sessionId,
124
157
  messages,
@@ -140,7 +173,7 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
140
173
  // digest.
141
174
  let recallText = '';
142
175
  try {
143
- const browsed = await callMemoryBounded({
176
+ const browsed = await callMemoryColdStart({
144
177
  action: 'search',
145
178
  sessionId,
146
179
  limit: positiveContextWindow(session?.compaction?.recallDigestLimit) || 30,
@@ -298,7 +331,7 @@ export async function runSessionCompaction(session, opts = {}) {
298
331
  signal: opts.signal || null,
299
332
  promptCacheKey: session.promptCacheKey || null,
300
333
  providerCacheKey: session.promptCacheKey || null,
301
- timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
334
+ timeoutMs: semanticCompactTimeoutMs(session, beforeMessageTokens),
302
335
  tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
303
336
  keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
304
337
  preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
@@ -341,7 +374,7 @@ export async function runSessionCompaction(session, opts = {}) {
341
374
  signal: opts.signal || null,
342
375
  promptCacheKey: session.promptCacheKey || null,
343
376
  providerCacheKey: session.promptCacheKey || null,
344
- timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
377
+ timeoutMs: semanticCompactTimeoutMs(session, beforeMessageTokens),
345
378
  tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
346
379
  keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
347
380
  preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
@@ -314,6 +314,11 @@ export function createSession(opts) {
314
314
  },
315
315
  tools,
316
316
  preset: toolPreset,
317
+ // Persisted so the deferred call-through gate (deferred-call-through.mjs
318
+ // resolveDeferredSelectMode) can resolve the session's tool mode; without
319
+ // this every session read `undefined` and write-capable deferred tools
320
+ // (e.g. MCP) were permanently denied auto-promotion.
321
+ toolSpec,
317
322
  presetName: presetObj?.name || null,
318
323
  effort,
319
324
  fast,
@@ -459,6 +464,8 @@ export async function resumeSession(sessionId, preset) {
459
464
  if (ownerIsAgent) {
460
465
  toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.agent || null);
461
466
  }
467
+ // Keep the persisted tool mode in sync on resume (see createSession note).
468
+ session.toolSpec = toolSpec;
462
469
  session.tools = finalizeSessionToolList(toolsForRouting, {
463
470
  schemaAllowedTools: Array.isArray(session.schemaAllowedTools) ? session.schemaAllowedTools : null,
464
471
  disallowedTools: getHiddenAgent(session.agent || null) ? ['Skill'] : null,
@@ -45,17 +45,20 @@ const _lastSaveError = new Map(); // id -> { message, at }
45
45
  // starving the model of the image mid-conversation. Returns the same object
46
46
  // reference when nothing changed (no-image sessions pay only a shallow scan).
47
47
  function _sessionForDisk(session) {
48
- // Strip the in-flight live-turn alias (askSession sets session.liveTurnMessages
49
- // for the turn duration so contextStatus() can estimate live context growth).
50
- // It is a transient duplicate of the working transcript and must never be
51
- // serialized: mid-turn saves (persistIterationMetrics) would otherwise bloat
52
- // the session file and persist a non-canonical message array.
53
- const hasLiveTurn = session && typeof session === 'object'
54
- && Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages');
48
+ // Strip transient in-flight aliases askSession sets for the turn duration:
49
+ // - liveTurnMessages: live working transcript (so contextStatus() can
50
+ // estimate live context growth) — a duplicate of the working transcript
51
+ // that must never be serialized (mid-turn saves would bloat the file and
52
+ // persist a non-canonical message array).
53
+ // - toolApprovalHook: the askOpts.onToolApproval callback wired for the
54
+ // turn — a function that must never be serialized.
55
+ const hasTransient = session && typeof session === 'object'
56
+ && (Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages')
57
+ || Object.prototype.hasOwnProperty.call(session, 'toolApprovalHook'));
55
58
  const messages = Array.isArray(session?.messages) ? session.messages : null;
56
59
  if (!messages || messages.length === 0) {
57
- if (!hasLiveTurn) return session;
58
- const { liveTurnMessages: _drop, ...rest } = session;
60
+ if (!hasTransient) return session;
61
+ const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
59
62
  return rest;
60
63
  }
61
64
  let changed = false;
@@ -66,11 +69,11 @@ function _sessionForDisk(session) {
66
69
  return m;
67
70
  });
68
71
  if (!changed) {
69
- if (!hasLiveTurn) return session;
70
- const { liveTurnMessages: _drop, ...rest } = session;
72
+ if (!hasTransient) return session;
73
+ const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
71
74
  return rest;
72
75
  }
73
- const { liveTurnMessages: _drop, ...rest } = session;
76
+ const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
74
77
  return { ...rest, messages: out };
75
78
  }
76
79
 
@@ -305,13 +308,26 @@ export function saveSessionAsync(session, opts) {
305
308
  setLiveSession(session);
306
309
  const reqId = ++_saveWorkerReqId;
307
310
  const safeOpts = opts || null;
311
+ // The Worker `postMessage` below structured-clones the whole session on the
312
+ // main thread. `session.liveTurnMessages` (live working transcript) and
313
+ // `session.toolApprovalHook` (askOpts.onToolApproval callback) are transient
314
+ // in-flight aliases askSession sets for the turn duration; both carry
315
+ // non-cloneable values (a function, and raw messages that can hold functions),
316
+ // which makes structuredClone throw "could not be cloned" for every mid-turn
317
+ // iteration save. The worker strips both via _sessionForDisk anyway, so drop
318
+ // them from the cloned payload here WITHOUT mutating the live session object.
319
+ const clonePayload = (session && typeof session === 'object'
320
+ && (Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages')
321
+ || Object.prototype.hasOwnProperty.call(session, 'toolApprovalHook')))
322
+ ? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
323
+ : session;
308
324
  return new Promise((resolve, reject) => {
309
325
  // Persist {session, opts} so drainSessionStore can sync-flush
310
326
  // outstanding writes if process exit interrupts the worker queue.
311
- _saveWorkerPending.set(reqId, { resolve, reject, session, opts: safeOpts });
327
+ _saveWorkerPending.set(reqId, { resolve, reject, session: clonePayload, opts: safeOpts });
312
328
  try {
313
329
  const w = _getOrSpawnWorker();
314
- w.postMessage({ session, opts: safeOpts, reqId });
330
+ w.postMessage({ session: clonePayload, opts: safeOpts, reqId });
315
331
  // Ref AFTER successful postMessage so a queue/throw failure path
316
332
  // does not leave the worker held alive with no pending message.
317
333
  // Paired with the unref in the message handler when count hits 0.
@@ -40,7 +40,7 @@ import { spawn } from 'node:child_process';
40
40
  import * as nodeUtil from 'node:util';
41
41
  import { existsSync } from 'node:fs';
42
42
  import { randomUUID } from 'node:crypto';
43
- import { invalidateBuiltinResultCache, analyzeShellCommandEffects, preflightShellLargeFileProbe } from './builtin.mjs';
43
+ import { invalidateBuiltinResultCache, analyzeShellCommandEffects } from './builtin.mjs';
44
44
  import { markCodeGraphDirtyPaths, drainCodeGraphCache } from './code-graph-state.mjs';
45
45
  import { maybeRewriteWmicProcessCommand } from './shell-policy.mjs';
46
46
  import { _maybeEncodePowerShellCommand } from './shell-command.mjs';
@@ -51,13 +51,15 @@ import { startChildGuardian } from '../../../shared/child-guardian.mjs';
51
51
 
52
52
  globalThis.__mixdogBashSessionRuntimeLoaded = true;
53
53
 
54
- // Default 600 s (10 min), max 1800 s. Aligned with the one-shot bash tool's
55
- // 600 s default (builtin/bash-tool.mjs); the persistent shell carries
56
- // longer-running pipelines (build/test/install) than one-shot calls, so a
57
- // 120 s default left those workflows timing out at 2 min. Per-call `timeout`
58
- // still overrides, capped at MAX_TIMEOUT_MS.
59
- const DEFAULT_TIMEOUT_MS = 600_000;
60
- const MAX_TIMEOUT_MS = 1_800_000;
54
+ // Claude Code parity (refs/claude-code src/utils/timeouts.ts): default 120 s
55
+ // (2 min), max 600 s (10 min), BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS
56
+ // env overrides (max floored at default). Matches the one-shot bash tool
57
+ // (builtin/bash-tool.mjs); per-call `timeout` still overrides, capped at
58
+ // MAX_TIMEOUT_MS.
59
+ const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
60
+ const DEFAULT_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
61
+ const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
62
+ const MAX_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, DEFAULT_TIMEOUT_MS);
61
63
  const IDLE_TIMEOUT_MS = 5 * 60_000;
62
64
  const MAX_SESSIONS = 10;
63
65
  const STDERR_DRAIN_MS = 25;
@@ -643,8 +645,6 @@ async function bash_session(args, cwd = process.cwd(), opts = {}) {
643
645
  }
644
646
  return requestedCwd;
645
647
  })();
646
- const largeProbe = await preflightShellLargeFileProbe(command, baseCwd);
647
- if (largeProbe) return `Error: ${largeProbe.message}`;
648
648
  const rawTimeout = typeof args?.timeout === 'number' ? args.timeout : DEFAULT_TIMEOUT_MS;
649
649
  const timeoutMs = rawTimeout;
650
650
  const effectiveTimeout = Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || MAX_TIMEOUT_MS);
@@ -21,7 +21,6 @@ import {
21
21
  import {
22
22
  analyzeShellCommandEffects,
23
23
  foregroundLongCommandHint,
24
- preflightShellLargeFileProbe,
25
24
  } from './shell-analysis.mjs';
26
25
  import {
27
26
  cancelBackgroundTask,
@@ -201,8 +200,6 @@ export async function executeBashTool(args, workDir, options = {}) {
201
200
  return _execPolicyBlock;
202
201
  }
203
202
 
204
- const largeProbe = await preflightShellLargeFileProbe(command, bashWorkDir);
205
- if (largeProbe) return `Error: ${largeProbe.message}`;
206
203
  let shellEffects;
207
204
  try {
208
205
  shellEffects = await analyzeShellCommandEffects(command, bashWorkDir);
@@ -211,9 +208,14 @@ export async function executeBashTool(args, workDir, options = {}) {
211
208
  }
212
209
  // Keep foreground commands on a long tool-owned timeout. The MCP dispatch
213
210
  // layer must not add a shorter fallback ceiling when timeout is omitted.
214
- const DEFAULT_BASH_TIMEOUT_MS = 600_000;
215
- const DEFAULT_BACKGROUND_BASH_TIMEOUT_MS = 600_000;
216
- const MAX_BASH_TIMEOUT_MS = 1_800_000;
211
+ // Claude Code parity (refs/claude-code src/utils/timeouts.ts): default
212
+ // 120 s (2 min), max 600 s (10 min); BASH_DEFAULT_TIMEOUT_MS /
213
+ // BASH_MAX_TIMEOUT_MS env overrides, max floored at default.
214
+ const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
215
+ const DEFAULT_BASH_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
216
+ const DEFAULT_BACKGROUND_BASH_TIMEOUT_MS = DEFAULT_BASH_TIMEOUT_MS;
217
+ const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
218
+ const MAX_BASH_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, DEFAULT_BASH_TIMEOUT_MS);
217
219
  const defaultTimeoutMs = runInBackground
218
220
  ? DEFAULT_BACKGROUND_BASH_TIMEOUT_MS
219
221
  : DEFAULT_BASH_TIMEOUT_MS;
@@ -12,6 +12,19 @@ import {
12
12
  executionModeSchemaDescription,
13
13
  } from '../../../../shared/background-tasks.mjs';
14
14
 
15
+ // Shell timeout envelope surfaced in the tool schema. Mirrors Claude Code:
16
+ // default 120 s / max 600 s, BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS
17
+ // env overrides, max floored at default. Keep in sync with
18
+ // builtin/bash-tool.mjs and bash-session.mjs.
19
+ function _shellDefaultTimeoutMs() {
20
+ const parsed = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
21
+ return parsed > 0 ? parsed : 120_000;
22
+ }
23
+ function _shellMaxTimeoutMs() {
24
+ const parsed = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
25
+ return Math.max(parsed > 0 ? parsed : 600_000, _shellDefaultTimeoutMs());
26
+ }
27
+
15
28
  export const BUILTIN_TOOLS = [
16
29
  {
17
30
  name: 'read',
@@ -59,7 +72,7 @@ export const BUILTIN_TOOLS = [
59
72
  properties: {
60
73
  command: { type: 'string', description: 'Command.' },
61
74
  cwd: { type: 'string', description: 'Working directory. Persists across shell calls in a session — omit to reuse the previous command\'s directory (e.g. after cd); pass an absolute path to change it.' },
62
- timeout: { type: 'number', description: 'Timeout ms.' },
75
+ timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min); long-running commands may raise it up to ${_shellMaxTimeoutMs()} (${_shellMaxTimeoutMs() / 60000} min).` },
63
76
  merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
64
77
  mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
65
78
  shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell. Windows defaults to PowerShell; bash = Git Bash/POSIX.' },
@@ -109,7 +109,7 @@ async function sweepStaleShellJobs(dir) {
109
109
  });
110
110
  await Promise.all([
111
111
  ...expired.flatMap((jobId) =>
112
- ['.json', '.done', '.exit', '.enforced', '.exit.cmd.sh', '.exit.cmd.ps1', '.stdout.log', '.stderr.log'].map((ext) =>
112
+ ['.json', '.done', '.exit', '.enforced', '.exit.cmd.sh', '.exit.cmd.ps1', '.exit.user.ps1', '.stdout.log', '.stderr.log'].map((ext) =>
113
113
  fsPromises.unlink(join(dir, jobId + ext)).catch(() => {}),
114
114
  ),
115
115
  ),
@@ -86,10 +86,6 @@ function psSingleQuote(s) {
86
86
  return `'${String(s).replace(/'/g, "''")}'`;
87
87
  }
88
88
 
89
- function powerShellEncodedCommand(command) {
90
- return Buffer.from(String(command || ''), 'utf16le').toString('base64');
91
- }
92
-
93
89
  function isPowerShellShell(shell, shellType) {
94
90
  if (shellType === 'powershell') return true;
95
91
  const stem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
@@ -857,14 +853,24 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
857
853
  const exitPath = shellJobExitPath(jobId);
858
854
  const donePath = shellJobDonePath(jobId);
859
855
  const wrappedTempPath = `${exitPath}.cmd.ps1`;
860
- const encodedCommand = powerShellEncodedCommand(command);
856
+ // Stage the USER command as its own .ps1 and run it via `-File` instead of
857
+ // `-EncodedCommand <base64>`: the base64 token landed on the grandchild's
858
+ // visible command line, which is a prime Defender obfuscation signature
859
+ // (same family as the PowhidSubExec false positive). A file path on the
860
+ // command line carries no such signature, and the payload stays opaque to
861
+ // outer-shell quoting exactly as base64 did. UTF-8 BOM so Windows
862
+ // PowerShell 5.1 doesn't misread non-ASCII as ANSI.
863
+ // Trailer mirrors -Command/-EncodedCommand exit semantics (-File alone
864
+ // does NOT propagate the last native command's exit code).
865
+ const innerTempPath = `${exitPath}.user.ps1`;
866
+ const innerScript = `\ufeff${command}\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\n`;
861
867
  const mergeLiteral = mergeStderr ? '$true' : '$false';
862
868
  const wrapper = [
863
869
  "$ErrorActionPreference = 'Continue'",
864
870
  '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8',
865
871
  '$OutputEncoding=[System.Text.Encoding]::UTF8',
866
872
  '$exe = (Get-Process -Id $PID).Path',
867
- `$encoded = ${psSingleQuote(encodedCommand)}`,
873
+ `$innerPath = ${psSingleQuote(innerTempPath)}`,
868
874
  `$stdoutPath = ${psSingleQuote(stdoutPath)}`,
869
875
  `$stderrPath = ${psSingleQuote(rawStderrPath)}`,
870
876
  `$exitPath = ${psSingleQuote(exitPath)}`,
@@ -873,7 +879,10 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
873
879
  `$timeoutMs = ${Math.max(1, Math.floor(timeoutMs || 0))}`,
874
880
  '$code = 1',
875
881
  'try {',
876
- " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded)",
882
+ // -ExecutionPolicy Bypass: unlike -EncodedCommand, -File is subject to
883
+ // the execution policy on Windows PowerShell; pwsh on macOS/Linux
884
+ // accepts the parameter as a no-op, so it is safe unconditionally.
885
+ " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $innerPath)",
877
886
  // -WindowStyle is a Windows-only Start-Process parameter; pwsh on
878
887
  // macOS/Linux throws "not supported on this platform". Add it only on win32.
879
888
  ' $spArgs = @{ FilePath = $exe; ArgumentList = $argList; RedirectStandardOutput = $stdoutPath; RedirectStandardError = $stderrPath; PassThru = $true }',
@@ -912,23 +921,27 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
912
921
  '}',
913
922
  'try { Set-Content -LiteralPath $exitPath -Value ([string]$code) -NoNewline -Encoding ascii } catch {}',
914
923
  'try { Set-Content -LiteralPath $donePath -Value "" -NoNewline -Encoding ascii } catch {}',
924
+ 'try { Remove-Item -LiteralPath $innerPath -Force -ErrorAction SilentlyContinue } catch {}',
915
925
  'try { Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue } catch {}',
916
926
  'exit $code',
917
927
  '',
918
928
  ].join('\n');
919
929
  try {
920
930
  writeFileSync(wrappedTempPath, wrapper, 'utf-8');
931
+ writeFileSync(innerTempPath, innerScript, 'utf-8');
921
932
  } catch (e) {
922
933
  return { jobId, kind: 'bash', status: 'failed', error: `failed to stage PowerShell background task: ${e?.message || e}` };
923
934
  }
924
935
 
925
936
  const shellStem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
926
- // `-WindowStyle Hidden` is a Windows-only CLI switch; pwsh on macOS/Linux
927
- // rejects it. `-ExecutionPolicy` likewise only applies to Windows
928
- // PowerShell. Build args per-platform so cross-OS pwsh background tasks run.
937
+ // No `-WindowStyle Hidden` CLI switch: windowsHide:true on the spawn below
938
+ // already gives CREATE_NO_WINDOW, and the visible command-line token trips
939
+ // Defender's hidden-PowerShell dropper signature (PowhidSubExec). The
940
+ // in-wrapper Start-Process WindowStyle=Hidden (above) stays — it lives in
941
+ // the staged .ps1, not on the command line, and is still needed there.
942
+ // `-ExecutionPolicy` only applies to Windows PowerShell; build per-platform.
929
943
  const isWin = process.platform === 'win32';
930
944
  const wrapperArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
931
- if (isWin) wrapperArgs.push('-WindowStyle', 'Hidden');
932
945
  if (isWin && shellStem === 'powershell') wrapperArgs.push('-ExecutionPolicy', 'Bypass');
933
946
  wrapperArgs.push('-File', wrappedTempPath);
934
947
  // Spawn the staged wrapper directly. detached MUST be false on Windows:
@@ -27,11 +27,12 @@ function shellTypeFor(shell) {
27
27
 
28
28
  function shellSpec(shell, shellType = shellTypeFor(shell)) {
29
29
  if (shellType === 'powershell') {
30
- // `-WindowStyle Hidden` is a Windows-only switch. PowerShell Core
31
- // (pwsh) on macOS/Linux rejects it, so only append it on win32.
32
- const psArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
33
- if (process.platform === 'win32') psArgs.push('-WindowStyle', 'Hidden');
34
- psArgs.push('-Command');
30
+ // Deliberately NO `-WindowStyle Hidden` here: every spawn site passes
31
+ // windowsHide:true (CREATE_NO_WINDOW), which suppresses the console at
32
+ // the OS level without leaving a token on the command line. The CLI
33
+ // switch is redundant AND matches Defender's node→hidden-PowerShell
34
+ // dropper signature (Trojan:Win32/PowhidSubExec false positive).
35
+ const psArgs = ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'];
35
36
  return {
36
37
  shell,
37
38
  shellArg: '-Command',