mixdog 0.9.22 → 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 (91) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +165 -0
  5. package/scripts/channel-daemon-stub.mjs +69 -0
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  9. package/scripts/tool-efficiency-diag.mjs +1 -1
  10. package/scripts/tool-smoke.mjs +30 -17
  11. package/src/rules/lead/02-channels.md +3 -3
  12. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  20. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  21. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  28. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  29. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  30. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  31. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  32. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  33. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  34. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  35. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  36. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  37. package/src/runtime/memory/index.mjs +54 -7
  38. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  39. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  40. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  41. package/src/runtime/shared/atomic-file.mjs +20 -4
  42. package/src/runtime/shared/tool-primitives.mjs +31 -1
  43. package/src/runtime/shared/tool-surface.mjs +23 -13
  44. package/src/session-runtime/config-lifecycle.mjs +48 -7
  45. package/src/session-runtime/lifecycle-api.mjs +9 -0
  46. package/src/session-runtime/mcp-glue.mjs +63 -1
  47. package/src/session-runtime/resource-api.mjs +62 -8
  48. package/src/session-runtime/runtime-core.mjs +32 -2
  49. package/src/session-runtime/session-text.mjs +41 -0
  50. package/src/session-runtime/session-turn-api.mjs +24 -0
  51. package/src/session-runtime/settings-api.mjs +8 -1
  52. package/src/session-runtime/tool-catalog.mjs +306 -38
  53. package/src/session-runtime/tool-defs.mjs +7 -7
  54. package/src/session-runtime/workflow.mjs +2 -1
  55. package/src/standalone/channel-daemon-client.mjs +224 -0
  56. package/src/standalone/channel-daemon-transport.mjs +351 -0
  57. package/src/standalone/channel-daemon.mjs +139 -0
  58. package/src/standalone/channel-worker.mjs +213 -4
  59. package/src/standalone/hook-bus.mjs +71 -3
  60. package/src/tui/App.jsx +105 -17
  61. package/src/tui/app/clipboard.mjs +39 -19
  62. package/src/tui/app/doctor.mjs +57 -0
  63. package/src/tui/app/extension-pickers.mjs +53 -9
  64. package/src/tui/app/maintenance-pickers.mjs +0 -5
  65. package/src/tui/app/slash-dispatch.mjs +4 -4
  66. package/src/tui/app/text-layout.mjs +11 -0
  67. package/src/tui/app/use-mouse-input.mjs +235 -51
  68. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  69. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  70. package/src/tui/app/use-transcript-window.mjs +55 -1
  71. package/src/tui/components/Message.jsx +1 -1
  72. package/src/tui/components/PromptInput.jsx +3 -1
  73. package/src/tui/components/QueuedCommands.jsx +21 -10
  74. package/src/tui/components/ToolExecution.jsx +2 -2
  75. package/src/tui/dist/index.mjs +635 -306
  76. package/src/tui/engine/session-api.mjs +17 -7
  77. package/src/tui/engine/session-flow.mjs +6 -0
  78. package/src/tui/engine.mjs +8 -0
  79. package/src/tui/index.jsx +62 -18
  80. package/src/tui/paste-attachments.mjs +26 -0
  81. package/src/ui/statusline.mjs +45 -5
  82. package/src/ui/tool-card.mjs +8 -1
  83. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  84. package/src/workflows/bench/WORKFLOW.md +46 -0
  85. package/src/workflows/default/WORKFLOW.md +6 -0
  86. package/src/workflows/solo/WORKFLOW.md +5 -0
  87. package/vendor/ink/build/ink.js +23 -1
  88. package/vendor/ink/build/output.js +154 -71
  89. package/vendor/ink/build/render-node-to-output.js +44 -2
  90. package/vendor/ink/build/render.js +4 -0
  91. package/vendor/ink/build/renderer.js +4 -1
@@ -0,0 +1,187 @@
1
+ // Full-tree shutdown for stdio MCP child processes.
2
+ //
3
+ // The MCP SDK's StdioClientTransport.close() closes stdin, waits ~2s, then
4
+ // SIGTERMs / SIGKILLs the *direct* child only. On Windows that maps to
5
+ // TerminateProcess on the spawned wrapper (uvx/npx/uv) and leaves its
6
+ // grandchildren (uv.exe -> mcp-for-unity.exe -> python.exe) orphaned. This
7
+ // module implements the MCP spec shutdown order over the whole process tree:
8
+ // close stdin -> grace wait for voluntary exit -> force-kill the recorded
9
+ // tree (taskkill /T /F on Windows, SIGTERM->SIGKILL of every descendant on
10
+ // POSIX). No external dependencies.
11
+ import { spawn } from 'node:child_process';
12
+
13
+ const isWin = process.platform === 'win32';
14
+
15
+ function delay(ms) {
16
+ return new Promise((resolve) => {
17
+ const t = setTimeout(resolve, ms);
18
+ if (t.unref) t.unref();
19
+ });
20
+ }
21
+
22
+ // Spawn a helper command and resolve its stdout (empty string on any error).
23
+ function run(cmd, args) {
24
+ return new Promise((resolve) => {
25
+ let out = '';
26
+ let cp;
27
+ try {
28
+ cp = spawn(cmd, args, { windowsHide: true });
29
+ }
30
+ catch {
31
+ resolve('');
32
+ return;
33
+ }
34
+ cp.stdout?.on('data', (d) => { out += String(d); });
35
+ cp.on('error', () => resolve(out));
36
+ cp.on('close', () => resolve(out));
37
+ });
38
+ }
39
+
40
+ // Snapshot the whole process table once: parent map + a per-pid identity
41
+ // token (Windows CreationDate, POSIX lstart) so a later kill can prove a pid
42
+ // number was not recycled onto a different process before signalling it.
43
+ async function enumerate() {
44
+ const childrenOf = new Map();
45
+ const tokenOf = new Map();
46
+ const out = isWin
47
+ ? await run('powershell.exe', [
48
+ '-NoProfile', '-NonInteractive', '-Command',
49
+ `Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o'))" }`,
50
+ ])
51
+ : await run('ps', ['-A', '-o', 'pid=,ppid=,lstart=']);
52
+ for (const line of out.split(/\r?\n/)) {
53
+ const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.+))?$/);
54
+ if (!m) continue;
55
+ const pid = Number(m[1]);
56
+ const ppid = Number(m[2]);
57
+ const token = (m[3] || '').trim() || null;
58
+ if (!childrenOf.has(ppid)) childrenOf.set(ppid, []);
59
+ childrenOf.get(ppid).push(pid);
60
+ tokenOf.set(pid, token);
61
+ }
62
+ return { childrenOf, tokenOf };
63
+ }
64
+
65
+ // BFS every descendant pid of rootPid (excludes rootPid).
66
+ function descendantsOf(childrenOf, rootPid) {
67
+ const result = [];
68
+ const seen = new Set([rootPid]);
69
+ const stack = [rootPid];
70
+ while (stack.length) {
71
+ const cur = stack.pop();
72
+ for (const child of childrenOf.get(cur) ?? []) {
73
+ if (!seen.has(child)) {
74
+ seen.add(child);
75
+ result.push(child);
76
+ stack.push(child);
77
+ }
78
+ }
79
+ }
80
+ return result;
81
+ }
82
+
83
+ // Exposed for tests: descendant pid list captured while the tree is intact.
84
+ export async function collectDescendants(rootPid) {
85
+ const { childrenOf } = await enumerate();
86
+ return descendantsOf(childrenOf, rootPid);
87
+ }
88
+
89
+ function isAlive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ }
94
+ catch (e) {
95
+ // EPERM => exists but not signalable by us; ESRCH => gone.
96
+ return Boolean(e && e.code === 'EPERM');
97
+ }
98
+ }
99
+
100
+ function waitExit(proc, ms) {
101
+ if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
102
+ return Promise.resolve(true);
103
+ }
104
+ return new Promise((resolve) => {
105
+ let done = false;
106
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
107
+ const t = setTimeout(() => finish(false), ms);
108
+ if (t.unref) t.unref();
109
+ proc.once('exit', () => { clearTimeout(t); finish(true); });
110
+ proc.once('close', () => { clearTimeout(t); finish(true); });
111
+ });
112
+ }
113
+
114
+ // A pid is safe to signal only if its CURRENT identity token equals the one
115
+ // recorded at snapshot time. Both must be present; a gone/recycled pid has a
116
+ // missing or mismatched token and is skipped. Tokens come from batched table
117
+ // reads (Map), so verification is O(1) with zero extra process spawns.
118
+ function stillSame(pid, snapshotToken, currentTokens) {
119
+ const want = snapshotToken.get(pid);
120
+ const cur = currentTokens.get(pid);
121
+ return Boolean(want && cur && want === cur);
122
+ }
123
+
124
+ // Force-kill the given pids, identity-checked against a batched current-token
125
+ // table captured immediately before signalling so a recycled pid is never hit.
126
+ async function killVerified(pids, snapshotToken, currentTokens) {
127
+ const verified = pids.filter((pid) => stillSame(pid, snapshotToken, currentTokens));
128
+ if (verified.length === 0) return;
129
+ if (isWin) {
130
+ // /T also reaps any still-live children spawned since enumeration;
131
+ // every listed /PID was identity-checked just above.
132
+ const args = ['/F', '/T'];
133
+ for (const p of verified) args.push('/PID', String(p));
134
+ await run('taskkill', args);
135
+ return;
136
+ }
137
+ // POSIX: terminate, brief wait, then hard-kill survivors. Re-verify with a
138
+ // single fresh batched table read (not per-pid) before the SIGKILL pass.
139
+ for (const p of verified) { try { process.kill(p, 'SIGTERM'); } catch { /* ignore */ } }
140
+ await delay(500);
141
+ const { tokenOf: fresh } = await enumerate().catch(() => ({ tokenOf: new Map() }));
142
+ for (const p of verified) {
143
+ if (isAlive(p) && stillSame(p, snapshotToken, fresh)) {
144
+ try { process.kill(p, 'SIGKILL'); } catch { /* ignore */ }
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Shut down an stdio MCP transport's child process tree per MCP spec order.
151
+ * Returns false when the transport has no live child (non-stdio / already
152
+ * exited); true once the tree has been shut down.
153
+ */
154
+ export async function shutdownStdioChild(transport, { graceMs = 2000 } = {}) {
155
+ const proc = transport?._process;
156
+ const pid = (typeof transport?.pid === 'number' ? transport.pid : null) ?? proc?.pid ?? null;
157
+ if (!pid) return false;
158
+ const empty = { childrenOf: new Map(), tokenOf: new Map() };
159
+ const snapshotToken = new Map();
160
+ // Snapshot #1: tree + identity tokens while it is still fully intact.
161
+ // Covers children that later get re-parented/orphaned during shutdown.
162
+ const before = await enumerate().catch(() => empty);
163
+ const initial = descendantsOf(before.childrenOf, pid);
164
+ snapshotToken.set(pid, before.tokenOf.get(pid) ?? null);
165
+ for (const d of initial) snapshotToken.set(d, before.tokenOf.get(d) ?? null);
166
+ // 1. Close stdin to request a voluntary shutdown.
167
+ try { proc?.stdin?.end(); } catch { /* ignore */ }
168
+ // 2. Grace period for the server to exit on its own.
169
+ const exited = await waitExit(proc, graceMs);
170
+ // 3. Snapshot #2 after the grace: catches children spawned late during
171
+ // shutdown that were absent from the first snapshot.
172
+ const after = await enumerate().catch(() => empty);
173
+ const fresh = descendantsOf(after.childrenOf, pid);
174
+ for (const d of fresh) {
175
+ if (!snapshotToken.has(d)) snapshotToken.set(d, after.tokenOf.get(d) ?? null);
176
+ }
177
+ // 4. Candidate set = union of both snapshots' descendants; add the root
178
+ // only when it did NOT exit on its own (skip stale-root kill per spec).
179
+ const candidates = new Set(initial);
180
+ for (const d of fresh) candidates.add(d);
181
+ if (!exited) candidates.add(pid);
182
+ if (candidates.size === 0) return true;
183
+ // 5. Force-kill. Snapshot #2 is the pre-kill verify table (captured just
184
+ // now, immediately before signalling) — no per-pid queries.
185
+ await killVerified([...candidates], snapshotToken, after.tokenOf);
186
+ return true;
187
+ }
@@ -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.
@@ -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
  ),