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.
- package/README.md +1 -4
- package/package.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +30 -17
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/shared/atomic-file.mjs +20 -4
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +635 -306
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- 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
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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 /
|
|
28
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// serialized
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
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 (!
|
|
58
|
-
const { liveTurnMessages:
|
|
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 (!
|
|
70
|
-
const { liveTurnMessages:
|
|
72
|
+
if (!hasTransient) return session;
|
|
73
|
+
const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
|
|
71
74
|
return rest;
|
|
72
75
|
}
|
|
73
|
-
const { liveTurnMessages:
|
|
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
|
),
|