mixdog 0.9.23 → 0.9.24
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/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +38 -30
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +209 -44
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- package/src/runtime/channels/lib/seat-lock.mjs +0 -196
|
@@ -291,8 +291,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
291
291
|
if (iterations >= maxLoopIterations) {
|
|
292
292
|
// Final-answer turn: instead of breaking mid-transcript (which
|
|
293
293
|
// yields an empty final for locator-style agents that never got to
|
|
294
|
-
// answer), give the model ONE
|
|
295
|
-
//
|
|
294
|
+
// answer), give the model ONE text-only turn to wrap up, then stop.
|
|
295
|
+
// Tool DEFINITIONS stay in-request (stable cache prefix) but tool
|
|
296
|
+
// USE is forbidden via tool_choice:'none'; any tool call a
|
|
297
|
+
// toolChoice-ignoring provider still emits gets a refusal stub.
|
|
296
298
|
if (_capFinalTurnUsed) {
|
|
297
299
|
process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
|
|
298
300
|
terminatedByCap = true;
|
|
@@ -362,15 +364,22 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
362
364
|
const nextIteration = iterations + 1;
|
|
363
365
|
opts.iteration = nextIteration;
|
|
364
366
|
opts.providerState = providerState;
|
|
365
|
-
if (
|
|
367
|
+
if (_capFinalToolsDisabled) {
|
|
368
|
+
// Hard-cap final turn: forbid tool USE (tool_choice:'none') instead
|
|
369
|
+
// of stripping tool DEFINITIONS. Sending tools:[] changed the
|
|
370
|
+
// tools→system→messages prefix chain, so Anthropic could no longer
|
|
371
|
+
// prefix-match and re-prefilled the whole prompt (~10k, cache
|
|
372
|
+
// read=0) on the final capped turn. Keeping the tools in-request
|
|
373
|
+
// holds the prefix byte-stable; 'none' makes the model emit text
|
|
374
|
+
// only. Overrides the forced-first-tool path below.
|
|
375
|
+
opts.toolChoice = 'none';
|
|
376
|
+
} else if (forcedFirstTool && toolCallsTotal === 0) {
|
|
366
377
|
opts.toolChoice = 'required';
|
|
367
378
|
} else {
|
|
368
379
|
delete opts.toolChoice;
|
|
369
380
|
}
|
|
370
|
-
// Hard-cap final turn: send NO tool definitions so the provider can
|
|
371
|
-
// only emit text. Overrides the forced-first-tool path.
|
|
372
381
|
const sendTools = _capFinalToolsDisabled
|
|
373
|
-
?
|
|
382
|
+
? tools
|
|
374
383
|
: (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
|
|
375
384
|
// Eager-dispatch queue: when the provider streams a tool-call event,
|
|
376
385
|
// start read-only tools immediately so execution overlaps with the
|
|
@@ -387,7 +396,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
387
396
|
getNextIteration: () => nextIteration,
|
|
388
397
|
repeatFailLimit: REPEAT_FAIL_LIMIT,
|
|
389
398
|
});
|
|
390
|
-
|
|
399
|
+
// Hard-cap final turn: forbid eager dispatch. Tools are still sent (to
|
|
400
|
+
// hold the cache prefix) but tool_choice:'none' means Anthropic emits
|
|
401
|
+
// no calls; a toolChoice-IGNORING provider could still stream calls,
|
|
402
|
+
// and an attached onToolCall would eager-run read-only tools mid-stream
|
|
403
|
+
// (real cost/UI/network side effects) whose results are then discarded
|
|
404
|
+
// by the refusal-stub path. Leave it unset so nothing dispatches; opts
|
|
405
|
+
// is reused across iterations but onToolCall is cleared to undefined
|
|
406
|
+
// after send() below, so non-cap iterations are unaffected.
|
|
407
|
+
opts.onToolCall = _capFinalToolsDisabled ? undefined : eager.onToolCall;
|
|
391
408
|
// Reattach separated tool results, then drop only truly dangling
|
|
392
409
|
// assistant/orphan pairs before the provider sees the transcript.
|
|
393
410
|
repairTranscriptBeforeProviderSend(messages, sessionId);
|
|
@@ -108,6 +108,7 @@ export function markSessionAskStart(id) {
|
|
|
108
108
|
entry.lastStreamDeltaAt = null;
|
|
109
109
|
entry.lastToolCall = null;
|
|
110
110
|
entry.toolStartedAt = null;
|
|
111
|
+
entry.toolSelfDeadlineMs = null;
|
|
111
112
|
entry.lastError = null;
|
|
112
113
|
// A new ask starts a fresh turn lifecycle — clear any stale empty-final
|
|
113
114
|
// classification from the prior turn so inspectBridgeEntry doesn't keep
|
|
@@ -166,11 +167,18 @@ export async function markSessionStreamDelta(id) {
|
|
|
166
167
|
}
|
|
167
168
|
entry.updatedAt = now;
|
|
168
169
|
}
|
|
169
|
-
export function markSessionToolCall(id, toolName) {
|
|
170
|
+
export function markSessionToolCall(id, toolName, selfDeadlineMs) {
|
|
170
171
|
if (!id) return;
|
|
171
172
|
const entry = _touchRuntime(id);
|
|
172
173
|
entry.stage = 'tool_running';
|
|
173
174
|
entry.lastToolCall = toolName || null;
|
|
175
|
+
// Self-enforced deadline (ms) for tools that kill themselves at a known
|
|
176
|
+
// budget (shell timeout / task wait). The watchdog raises the tool-running
|
|
177
|
+
// ceiling to this + grace instead of aborting at toolRunningMs. Null/<=0
|
|
178
|
+
// means unknown -> plain toolRunningMs behavior.
|
|
179
|
+
entry.toolSelfDeadlineMs = (typeof selfDeadlineMs === 'number' && selfDeadlineMs > 0)
|
|
180
|
+
? selfDeadlineMs
|
|
181
|
+
: null;
|
|
174
182
|
entry.toolStartedAt = Date.now();
|
|
175
183
|
entry.lastProgressAt = entry.toolStartedAt;
|
|
176
184
|
entry.updatedAt = entry.toolStartedAt;
|
|
@@ -289,6 +297,8 @@ export function getSessionProgressSnapshot(sessionId) {
|
|
|
289
297
|
firstActivityAt,
|
|
290
298
|
lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
|
|
291
299
|
toolStartedAt: entry.toolStartedAt || 0,
|
|
300
|
+
currentTool: entry.lastToolCall || null,
|
|
301
|
+
toolSelfDeadlineMs: entry.toolSelfDeadlineMs || 0,
|
|
292
302
|
lastProgressAt: entry.lastProgressAt || 0,
|
|
293
303
|
updatedAt: entry.updatedAt || 0,
|
|
294
304
|
hasFirstActivity: Boolean(firstActivityAt && (!askStartedAt || firstActivityAt >= askStartedAt)),
|
|
@@ -167,8 +167,9 @@ export function createSession(opts) {
|
|
|
167
167
|
// - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
|
|
168
168
|
// session resolving to permission 'read') -> 'readonly' bundle:
|
|
169
169
|
// read builtins (code_graph/find/glob/list/grep/read) + retrieval
|
|
170
|
-
// (explore/search/web_fetch/Skill)
|
|
171
|
-
//
|
|
170
|
+
// (explore/search/web_fetch/Skill) + shell/task for self-verification
|
|
171
|
+
// (agent-owned readonly bundle only), no apply_patch, no MCP-write.
|
|
172
|
+
// applyToolPermissionNarrowing('read') below trims the
|
|
172
173
|
// bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
|
|
173
174
|
// bit-identical across these roles regardless of MCP registry state.
|
|
174
175
|
// - write roles (worker / heavy-worker / maintainer / …) -> 'full'
|
|
@@ -188,7 +189,10 @@ export function createSession(opts) {
|
|
|
188
189
|
// fail closed (zero tools) rather than silently falling back to the full
|
|
189
190
|
// preset, which would grant the role more surface than declared.
|
|
190
191
|
if (ownerIsAgent) {
|
|
191
|
-
|
|
192
|
+
// Pass the RESOLVED agent (opts.agent || opts.role): narrowing keys
|
|
193
|
+
// retrieval/locator roles (explore etc.) off this name to strip
|
|
194
|
+
// shell/task; verifying read roles (reviewer/debugger) keep them.
|
|
195
|
+
toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, resolvedAgent);
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
|
|
@@ -74,14 +74,39 @@ const AGENT_STRING_PERMISSION_READ_ALLOW = Object.freeze([
|
|
|
74
74
|
'list',
|
|
75
75
|
'grep',
|
|
76
76
|
'read',
|
|
77
|
+
// shell/task: read-role agents (reviewer/debugger) must run their own
|
|
78
|
+
// verification (tests/repro). task is required because shell's
|
|
79
|
+
// auto-background transition settles with a bare jobId — agent sessions
|
|
80
|
+
// get no completion notification, so `task wait/read` is the only way to
|
|
81
|
+
// collect a long-running result. apply_patch stays excluded: read roles
|
|
82
|
+
// keep the no-edit contract.
|
|
83
|
+
'shell',
|
|
84
|
+
'task',
|
|
77
85
|
'explore',
|
|
78
86
|
'search',
|
|
79
87
|
'web_fetch',
|
|
80
88
|
'Skill',
|
|
81
89
|
]);
|
|
82
90
|
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
// Retrieval/locator roles never self-verify, so they keep the historical
|
|
92
|
+
// no-shell read surface. Covers hidden retrieval agents (kind 'retrieval')
|
|
93
|
+
// AND public wrapper roles (explore + any invokedBy wrapper) — the same set
|
|
94
|
+
// pre-dispatch-deny treats as recursive wrappers. Only verifying read roles
|
|
95
|
+
// (reviewer/debugger) get shell/task.
|
|
96
|
+
const AGENT_STRING_PERMISSION_READ_RETRIEVAL_ALLOW = Object.freeze(
|
|
97
|
+
AGENT_STRING_PERMISSION_READ_ALLOW.filter((n) => n !== 'shell' && n !== 'task'),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
function isRetrievalToolRole(agent) {
|
|
101
|
+
if (!agent) return false;
|
|
102
|
+
if (getHiddenAgent(agent)?.kind === 'retrieval') return true;
|
|
103
|
+
return Boolean(recursiveWrapperToolNameForPublicAgent(agent));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stringToolPermissionAllowList(toolPermission, { retrieval = false } = {}) {
|
|
107
|
+
if (toolPermission === 'read') {
|
|
108
|
+
return retrieval ? AGENT_STRING_PERMISSION_READ_RETRIEVAL_ALLOW : AGENT_STRING_PERMISSION_READ_ALLOW;
|
|
109
|
+
}
|
|
85
110
|
if (toolPermission === 'read-write') return AGENT_STRING_PERMISSION_READ_WRITE_ALLOW;
|
|
86
111
|
if (toolPermission === 'none') return [];
|
|
87
112
|
return null;
|
|
@@ -109,7 +134,7 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
|
|
|
109
134
|
|
|
110
135
|
export function applyToolPermissionNarrowing(tools, toolPermission, warnRole = null) {
|
|
111
136
|
if (toolPermission === 'none') return [];
|
|
112
|
-
const allowList = stringToolPermissionAllowList(toolPermission);
|
|
137
|
+
const allowList = stringToolPermissionAllowList(toolPermission, { retrieval: isRetrievalToolRole(warnRole) });
|
|
113
138
|
if (allowList) {
|
|
114
139
|
const allowSet = new Set(allowList.map((n) => String(n).toLowerCase()));
|
|
115
140
|
return tools.filter((t) => allowSet.has(String(t?.name || '').toLowerCase()));
|
|
@@ -203,7 +228,7 @@ export function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = fa
|
|
|
203
228
|
// time (loop.mjs), so the schema bytes stay bit-identical across roles /
|
|
204
229
|
// cwds and the provider cache shard does not fragment.
|
|
205
230
|
const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
|
|
206
|
-
return _computeBaseTools(toolSpec, mcp, skillTools);
|
|
231
|
+
return _computeBaseTools(toolSpec, mcp, skillTools, { ownerIsAgentSession });
|
|
207
232
|
}
|
|
208
233
|
|
|
209
234
|
export function previewSessionTools(toolSpec, skills = [], options = {}) {
|
|
@@ -230,7 +255,7 @@ function _dedupByName(tools) {
|
|
|
230
255
|
// Tools with agentHidden:true are stripped from agent sessions at schema
|
|
231
256
|
// build time (see deny filtering below). No code-level name list needed.
|
|
232
257
|
|
|
233
|
-
function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
258
|
+
function _computeBaseTools(toolSpec, mcp, skillTools, { ownerIsAgentSession = false } = {}) {
|
|
234
259
|
if (Array.isArray(toolSpec)) {
|
|
235
260
|
if (toolSpec.length === 0) {
|
|
236
261
|
// Explicit "no tools" — skill meta tools still travel so the model
|
|
@@ -285,7 +310,16 @@ function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
|
285
310
|
return _dedupByName([...mcp, ...skillTools]);
|
|
286
311
|
case 'readonly': {
|
|
287
312
|
const readTools = ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name));
|
|
288
|
-
|
|
313
|
+
// Read-ROLE agent sessions (reviewer/debugger) must self-verify, so
|
|
314
|
+
// their base bundle carries shell/task defs. The 'read' permission
|
|
315
|
+
// allowlist (AGENT_STRING_PERMISSION_READ_ALLOW) is a pure FILTER —
|
|
316
|
+
// it can only keep tools already assembled here, so without this the
|
|
317
|
+
// allowlist's shell/task entries were dead letters. Non-agent
|
|
318
|
+
// 'readonly' profiles stay strictly read-only.
|
|
319
|
+
const verifyTools = ownerIsAgentSession
|
|
320
|
+
? ALL_BUILTIN_SESSION_TOOLS.filter(t => t.name === 'shell' || t.name === 'task')
|
|
321
|
+
: [];
|
|
322
|
+
return _dedupByName([...readTools, ...verifyTools, ...mcp, ...skillTools]);
|
|
289
323
|
}
|
|
290
324
|
case 'full':
|
|
291
325
|
default:
|
|
@@ -11,6 +11,7 @@ import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
|
|
|
11
11
|
import { compressToolResult } from '../tools/result-compression.mjs';
|
|
12
12
|
import { appendAgentTrace, traceAgentTool, traceAgentToolFailure } from '../agent-trace.mjs';
|
|
13
13
|
import { markSessionToolCall, updateSessionStage } from './manager.mjs';
|
|
14
|
+
import { resolveToolSelfDeadlineMs } from '../agent-runtime/agent-progress-watchdog.mjs';
|
|
14
15
|
import { classifyResultKind } from './result-classification.mjs';
|
|
15
16
|
import { normalizeToolEnvelope } from './tool-envelope.mjs';
|
|
16
17
|
import { maybeOffloadToolResult } from './tool-result-offload.mjs';
|
|
@@ -150,7 +151,7 @@ export async function processToolBatch(ctx) {
|
|
|
150
151
|
continue;
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
|
-
if (sessionId) markSessionToolCall(sessionId, call.name);
|
|
154
|
+
if (sessionId) markSessionToolCall(sessionId, call.name, resolveToolSelfDeadlineMs(call.name, call.arguments));
|
|
154
155
|
let result;
|
|
155
156
|
let toolStartedAt;
|
|
156
157
|
let toolEndedAt;
|
|
@@ -54,12 +54,16 @@ globalThis.__mixdogBashSessionRuntimeLoaded = true;
|
|
|
54
54
|
// Claude Code parity (refs/claude-code src/utils/timeouts.ts): default 120 s
|
|
55
55
|
// (2 min), max 600 s (10 min), BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS
|
|
56
56
|
// env overrides (max floored at default). Matches the one-shot bash tool
|
|
57
|
-
// (builtin/bash-tool.mjs)
|
|
58
|
-
// MAX_TIMEOUT_MS
|
|
57
|
+
// (builtin/bash-tool.mjs): an omitted `timeout` uses the 120 s default bounded
|
|
58
|
+
// by MAX_TIMEOUT_MS; an explicit per-call `timeout` is honored uncapped, clamped
|
|
59
|
+
// only by TIMER_MAX_MS so JS/PS 32-bit timers stay valid.
|
|
59
60
|
const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
|
|
60
61
|
const DEFAULT_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
|
|
61
62
|
const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
|
|
62
63
|
const MAX_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, DEFAULT_TIMEOUT_MS);
|
|
64
|
+
// JS setTimeout / PS WaitForExit(ms) are 32-bit: a delay above 2^31-1 wraps and
|
|
65
|
+
// fires immediately. Hard ceiling (~24.8 days) for an uncapped explicit timeout.
|
|
66
|
+
const TIMER_MAX_MS = 2_147_483_647;
|
|
63
67
|
const IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
64
68
|
const MAX_SESSIONS = 10;
|
|
65
69
|
const STDERR_DRAIN_MS = 25;
|
|
@@ -645,9 +649,13 @@ async function bash_session(args, cwd = process.cwd(), opts = {}) {
|
|
|
645
649
|
}
|
|
646
650
|
return requestedCwd;
|
|
647
651
|
})();
|
|
648
|
-
const
|
|
649
|
-
const timeoutMs =
|
|
650
|
-
|
|
652
|
+
const hasExplicitTimeout = typeof args?.timeout === 'number' && args.timeout > 0;
|
|
653
|
+
const timeoutMs = hasExplicitTimeout ? args.timeout : DEFAULT_TIMEOUT_MS;
|
|
654
|
+
// Explicit per-call timeout uncapped (only the wmic-rewrite ceiling or the
|
|
655
|
+
// 32-bit TIMER_MAX_MS still bound it); omitted default keeps MAX_TIMEOUT_MS.
|
|
656
|
+
const effectiveTimeout = hasExplicitTimeout
|
|
657
|
+
? Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || TIMER_MAX_MS)
|
|
658
|
+
: Math.min(Math.max(timeoutMs, 1), wmicRewrite?.timeoutMs || MAX_TIMEOUT_MS);
|
|
651
659
|
const resolved = _getOrCreate(requestedSessionId || args?.session_id, baseCwd, { create: args?.create === true });
|
|
652
660
|
if (resolved.error) return resolved.error;
|
|
653
661
|
const { id, entry } = resolved;
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import {
|
|
22
22
|
analyzeShellCommandEffects,
|
|
23
23
|
foregroundLongCommandHint,
|
|
24
|
+
isAutobackgroundingAllowed,
|
|
24
25
|
} from './shell-analysis.mjs';
|
|
25
26
|
import {
|
|
26
27
|
cancelBackgroundTask,
|
|
@@ -208,41 +209,56 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
208
209
|
}
|
|
209
210
|
// Keep foreground commands on a long tool-owned timeout. The MCP dispatch
|
|
210
211
|
// layer must not add a shorter fallback ceiling when timeout is omitted.
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// BASH_MAX_TIMEOUT_MS env overrides
|
|
212
|
+
// Reference-CLI parity (opencode/codex/claude-code): sync-first, no hard
|
|
213
|
+
// upper ceiling on a caller-provided timeout. Default 120 s (2 min) when
|
|
214
|
+
// omitted; BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS env overrides keep
|
|
215
|
+
// their semantics (max floored at default) but only bound the *omitted*
|
|
216
|
+
// default — an explicit args.timeout is honored UNCAPPED.
|
|
214
217
|
const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
|
|
215
218
|
const DEFAULT_BASH_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
|
|
216
|
-
|
|
219
|
+
// Background (async / run_in_background) jobs get NO omitted default: 0
|
|
220
|
+
// means "unlimited" and flows unchanged through startBackgroundShellJob →
|
|
221
|
+
// task meta (detail.timeoutMs 0). An explicit args.timeout is still honored
|
|
222
|
+
// and enforced exactly as before. Sync path keeps the 120s omitted default.
|
|
223
|
+
const DEFAULT_BACKGROUND_BASH_TIMEOUT_MS = 0;
|
|
217
224
|
const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
|
|
218
225
|
const MAX_BASH_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, DEFAULT_BASH_TIMEOUT_MS);
|
|
219
226
|
const defaultTimeoutMs = runInBackground
|
|
220
227
|
? DEFAULT_BACKGROUND_BASH_TIMEOUT_MS
|
|
221
228
|
: DEFAULT_BASH_TIMEOUT_MS;
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
const hasExplicitTimeout = typeof args.timeout === 'number' && args.timeout > 0;
|
|
230
|
+
const timeoutMs = hasExplicitTimeout ? args.timeout : defaultTimeoutMs;
|
|
231
|
+
// Explicit caller timeout is uncapped (only the wmic-rewrite ceiling, when
|
|
232
|
+
// present, still bounds it); the omitted default keeps the MAX ceiling.
|
|
233
|
+
// JS timers (setTimeout) and PS WaitForExit(ms) are 32-bit: a delay above
|
|
234
|
+
// 2^31-1 wraps to a tiny/negative value and fires immediately. Clamp the
|
|
235
|
+
// uncapped explicit timeout once here (~24.8 days ceiling) so every
|
|
236
|
+
// downstream timer — foreground, background job, hard-stop watcher — stays
|
|
237
|
+
// valid without per-site guards.
|
|
238
|
+
const TIMER_MAX_MS = 2_147_483_647;
|
|
239
|
+
// timeoutMs <= 0 (omitted background default) means unlimited: pass it
|
|
240
|
+
// through untouched — the min() clamps below must not turn 0 into a bound.
|
|
241
|
+
const timeout = timeoutMs <= 0
|
|
242
|
+
? 0
|
|
243
|
+
: (hasExplicitTimeout
|
|
244
|
+
? Math.min(timeoutMs, wmicRewrite?.timeoutMs || TIMER_MAX_MS)
|
|
245
|
+
: Math.min(timeoutMs, wmicRewrite?.timeoutMs || MAX_BASH_TIMEOUT_MS));
|
|
226
246
|
const mergeStderr = args.merge_stderr === true;
|
|
227
247
|
const longForegroundHint = foregroundLongCommandHint(command, timeout, { ...args, run_in_background: runInBackground });
|
|
228
248
|
if (longForegroundHint) return longForegroundHint;
|
|
229
|
-
// Auto-background threshold
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
// default in executeTaskTool) as the default promotion threshold. Override
|
|
238
|
-
// with MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms). This is a soft hint,
|
|
239
|
-
// not a hard cap — the value is clamped below `timeout` so the hard ceiling
|
|
240
|
-
// stays a separate, later bound.
|
|
249
|
+
// Auto-background threshold. Reference-CLI parity: sync commands run to
|
|
250
|
+
// their timeout without any default auto-promotion, so the default is 0
|
|
251
|
+
// (disabled) for ALL callers. It is an explicit opt-in only: set
|
|
252
|
+
// MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms) to re-enable detaching a
|
|
253
|
+
// still-running foreground one-shot into a tracked shell-job. When enabled,
|
|
254
|
+
// the value stays a soft hint clamped below `timeout` so the hard ceiling
|
|
255
|
+
// remains a separate, later bound. Never applies to run_in_background
|
|
256
|
+
// (already detached) or persistent sessions (handled far above).
|
|
241
257
|
const _autoBgEnvMs = Number(process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS);
|
|
242
258
|
const DEFAULT_AUTO_BACKGROUND_MS = Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs > 0
|
|
243
259
|
? Math.floor(_autoBgEnvMs)
|
|
244
|
-
:
|
|
245
|
-
const autoBackgroundMs = runInBackground
|
|
260
|
+
: 0;
|
|
261
|
+
const autoBackgroundMs = (runInBackground || DEFAULT_AUTO_BACKGROUND_MS <= 0)
|
|
246
262
|
? 0
|
|
247
263
|
: Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
|
|
248
264
|
|
|
@@ -348,6 +364,19 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
348
364
|
: wrapBashWithCwdProbe(wrappedCommand, _stateFile);
|
|
349
365
|
}
|
|
350
366
|
} catch { syncCommand = wrappedCommand; }
|
|
367
|
+
// Promote-at-timeout (CC shouldAutoBackground parity). When a
|
|
368
|
+
// foreground one-shot hits its timeout and is still running, adopt it
|
|
369
|
+
// as a background job (task_id + notify) instead of tree-killing it.
|
|
370
|
+
// Opt-outs restore the old kill behavior: (a) disallowed sleep-like
|
|
371
|
+
// base commands (isAutobackgroundingAllowed), (b) the truthy
|
|
372
|
+
// MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS env. Never applies to
|
|
373
|
+
// run_in_background (already detached, handled above).
|
|
374
|
+
const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
|
|
375
|
+
String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
|
|
376
|
+
);
|
|
377
|
+
const backgroundOnTimeout = !runInBackground
|
|
378
|
+
&& !_bgTasksDisabled
|
|
379
|
+
&& isAutobackgroundingAllowed(command, shellType);
|
|
351
380
|
const result = await execShellCommand({
|
|
352
381
|
shell, shellArg, shellArgs, command: syncCommand,
|
|
353
382
|
env: spawnEnv,
|
|
@@ -355,6 +384,9 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
355
384
|
timeoutMs: timeout,
|
|
356
385
|
abortSignal: combinedBashAbort,
|
|
357
386
|
autoBackgroundMs,
|
|
387
|
+
// On a foreground timeout, promote the still-running child to a
|
|
388
|
+
// tracked background job (unlimited) instead of killing it.
|
|
389
|
+
backgroundOnTimeout,
|
|
358
390
|
// Threaded so an auto-backgrounded foreground job is stamped with
|
|
359
391
|
// the dispatching terminal's claude.exe pid (per-terminal scope).
|
|
360
392
|
clientHostPid: options?.clientHostPid,
|
|
@@ -388,7 +420,10 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
388
420
|
stdout: result.stdoutPath ? normalizeOutputPath(result.stdoutPath) : null,
|
|
389
421
|
stderr: (!mergeStderr && result.stderrPath) ? normalizeOutputPath(result.stderrPath) : null,
|
|
390
422
|
cwd: bashWorkDir,
|
|
391
|
-
timeoutMs
|
|
423
|
+
// Adopted foreground jobs run unlimited (timeoutMs 0)
|
|
424
|
+
// — matches the async default; the original
|
|
425
|
+
// foreground timeout no longer bounds the promoted job.
|
|
426
|
+
timeoutMs: 0,
|
|
392
427
|
},
|
|
393
428
|
resultType: 'shell_task_result',
|
|
394
429
|
cancel: () => killShellJob(result.jobId),
|
|
@@ -430,8 +465,11 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
430
465
|
// Timeout marker carries an inline recovery hint so the caller can
|
|
431
466
|
// act in one round (increase ceiling or detach) instead of repeating
|
|
432
467
|
// the same command and hitting the same wall.
|
|
468
|
+
const timeoutHint = result.timedOut
|
|
469
|
+
? ` — command killed after ${timeout} ms; if it legitimately needs longer, retry with a larger timeout`
|
|
470
|
+
: '';
|
|
433
471
|
const statusMarker = result.timedOut
|
|
434
|
-
? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]`
|
|
472
|
+
? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]${timeoutHint}`
|
|
435
473
|
: (signal
|
|
436
474
|
? `[signal: ${signal}]`
|
|
437
475
|
: (isReallyErrored ? `[exit code: ${exitCode}]` : ''));
|
|
@@ -12,10 +12,11 @@ import {
|
|
|
12
12
|
executionModeSchemaDescription,
|
|
13
13
|
} from '../../../../shared/background-tasks.mjs';
|
|
14
14
|
|
|
15
|
-
// Shell timeout envelope surfaced in the tool schema.
|
|
16
|
-
// default 120 s
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// Shell timeout envelope surfaced in the tool schema. Reference-CLI parity:
|
|
16
|
+
// default 120 s when omitted; an explicit timeout is honored uncapped.
|
|
17
|
+
// BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS env overrides only bound the
|
|
18
|
+
// omitted default (max floored at default). Keep in sync with
|
|
19
|
+
// builtin/bash-tool.mjs.
|
|
19
20
|
function _shellDefaultTimeoutMs() {
|
|
20
21
|
const parsed = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
|
|
21
22
|
return parsed > 0 ? parsed : 120_000;
|
|
@@ -72,7 +73,7 @@ export const BUILTIN_TOOLS = [
|
|
|
72
73
|
properties: {
|
|
73
74
|
command: { type: 'string', description: 'Command.' },
|
|
74
75
|
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.' },
|
|
75
|
-
timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min); long-running commands may
|
|
76
|
+
timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min) when omitted; an explicit value is honored uncapped — long-running commands may set it higher. On timeout the command is MOVED TO BACKGROUND (a task_id you can wait/status/read/cancel) and keeps running instead of being killed; sleep-like commands and MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS opt out (killed with a [timeout] marker). async/background runs with timeout omitted have NO timeout (run until done/cancelled); an explicit timeout is still enforced.` },
|
|
76
77
|
merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
|
|
77
78
|
mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
|
|
78
79
|
shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell. Windows defaults to PowerShell; bash = Git Bash/POSIX.' },
|
|
@@ -167,7 +168,7 @@ export const BUILTIN_TOOLS = [
|
|
|
167
168
|
name: 'find',
|
|
168
169
|
title: 'Mixdog Find Files',
|
|
169
170
|
annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
170
|
-
description: 'Find files by partial path/name. Returns verified paths. Batch names as query[].',
|
|
171
|
+
description: 'Find files by partial path/name, searching dot-directories (e.g. .git-adjacent, .mixdog) too. The right tool for locating a file at an unknown, possibly machine-wide path — run it from a verified broad root. Returns verified paths. Batch names as query[].',
|
|
171
172
|
inputSchema: {
|
|
172
173
|
type: 'object',
|
|
173
174
|
properties: {
|
|
@@ -357,6 +357,22 @@ export async function lstatPathsForMtime(paths, workDir, concurrency = 64, opts
|
|
|
357
357
|
return out;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
// Extra invalidation listeners: sibling modules with their own derived caches
|
|
361
|
+
// (e.g. the broad find-enumeration cache in list-tool) register a clear
|
|
362
|
+
// callback here so every write-invalidation event that drops the result/stat/
|
|
363
|
+
// raw caches also drops theirs. Full clear is intentional — those entries are
|
|
364
|
+
// cheap to rebuild and a path-scoped diff is not worth the coupling.
|
|
365
|
+
const EXTRA_INVALIDATION_LISTENERS = new Set();
|
|
366
|
+
export function registerCacheInvalidationListener(fn) {
|
|
367
|
+
if (typeof fn === 'function') EXTRA_INVALIDATION_LISTENERS.add(fn);
|
|
368
|
+
return () => EXTRA_INVALIDATION_LISTENERS.delete(fn);
|
|
369
|
+
}
|
|
370
|
+
function runExtraInvalidationListeners() {
|
|
371
|
+
for (const fn of EXTRA_INVALIDATION_LISTENERS) {
|
|
372
|
+
try { fn(); } catch { /* best-effort: one listener must not block others */ }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
360
376
|
function cacheInvalidateAll() {
|
|
361
377
|
RESULT_CACHE.clear();
|
|
362
378
|
RESULT_CACHE_INFLIGHT.clear();
|
|
@@ -366,6 +382,7 @@ function cacheInvalidateAll() {
|
|
|
366
382
|
RAW_CONTENT_CACHE_BYTES = 0;
|
|
367
383
|
PATH_MUTATION_GENERATIONS.clear();
|
|
368
384
|
PATH_MUTATION_GLOBAL_GENERATION += 1;
|
|
385
|
+
runExtraInvalidationListeners();
|
|
369
386
|
}
|
|
370
387
|
|
|
371
388
|
function cacheInvalidatePaths(paths) {
|
|
@@ -394,6 +411,9 @@ function cacheInvalidatePaths(paths) {
|
|
|
394
411
|
deleteReadRangeIndexForPath(affected);
|
|
395
412
|
bumpPathMutationGeneration(affected);
|
|
396
413
|
}
|
|
414
|
+
// Broad enumeration entries are not path-scoped, so any partial
|
|
415
|
+
// invalidation still fully drops them (cheap to rebuild).
|
|
416
|
+
runExtraInvalidationListeners();
|
|
397
417
|
}
|
|
398
418
|
|
|
399
419
|
export function invalidateBuiltinResultCache(paths = null) {
|
|
@@ -83,6 +83,20 @@ export function fuzzyScore(query, str) {
|
|
|
83
83
|
return score;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Below this per-query-char score, a subsequence-only match (query chars in
|
|
87
|
+
// order but scattered mid-word, no contiguity/boundary structure) is treated
|
|
88
|
+
// as noise rather than a real hit. Contiguous substring / basename matches
|
|
89
|
+
// bypass the floor entirely — an exact hit must never be filtered.
|
|
90
|
+
const SUBSEQUENCE_MIN_PER_CHAR = 4;
|
|
91
|
+
|
|
92
|
+
// Separator/case-insensitive normalization used for the "contiguous substring"
|
|
93
|
+
// strong-match test. Mirrors the query normalization in fuzzyScore so
|
|
94
|
+
// "tool-events.log" matches a "tool-events.log" basename or a ".../tool-events.log"
|
|
95
|
+
// path regardless of separators.
|
|
96
|
+
function normalizeForContains(s) {
|
|
97
|
+
return String(s || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
/**
|
|
87
101
|
* Rank candidates by fuzzy score against `query`, dropping non-matches.
|
|
88
102
|
* @param {string} query
|
|
@@ -91,13 +105,30 @@ export function fuzzyScore(query, str) {
|
|
|
91
105
|
* @returns {Array<{item:object, score:number}>} sorted desc, then path asc
|
|
92
106
|
*/
|
|
93
107
|
export function fuzzyRank(query, items, limit = 0) {
|
|
108
|
+
const normQuery = normalizeForContains(query);
|
|
109
|
+
// Floor scales with query length: a scattered subsequence earns ~1 point
|
|
110
|
+
// per char, while any contiguous run (+5/char) or word-boundary hit
|
|
111
|
+
// (+8) pushes a genuine match well past 4/char.
|
|
112
|
+
const floor = normQuery.length * SUBSEQUENCE_MIN_PER_CHAR;
|
|
94
113
|
const scored = [];
|
|
95
114
|
for (const item of items) {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
115
|
+
const p = String(item.path || '');
|
|
116
|
+
const pathScore = fuzzyScore(query, p);
|
|
117
|
+
const base = p.split(/[\\/]/).pop() || '';
|
|
98
118
|
const baseScore = fuzzyScore(query, base);
|
|
99
119
|
const sc = Math.max(pathScore ?? -Infinity, baseScore === null ? -Infinity : baseScore + 40);
|
|
100
|
-
if (Number.isFinite(sc))
|
|
120
|
+
if (!Number.isFinite(sc)) continue;
|
|
121
|
+
// Strong match: the query (separators stripped) is a contiguous
|
|
122
|
+
// substring of the basename or the full path. These ALWAYS pass so an
|
|
123
|
+
// exact substring/basename hit can never be starved out as noise.
|
|
124
|
+
const strong = normQuery.length > 0
|
|
125
|
+
&& (normalizeForContains(base).includes(normQuery)
|
|
126
|
+
|| normalizeForContains(p).includes(normQuery));
|
|
127
|
+
// Otherwise it is subsequence-only: keep it only if it clears the
|
|
128
|
+
// per-char floor. Weak scattered matches (the pgAdmin-style junk that
|
|
129
|
+
// merely contains the query chars in order) fall below it and drop out.
|
|
130
|
+
if (!strong && sc < floor) continue;
|
|
131
|
+
scored.push({ item, score: sc });
|
|
101
132
|
}
|
|
102
133
|
scored.sort((a, b) => (b.score - a.score) || (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0));
|
|
103
134
|
return limit > 0 ? scored.slice(0, limit) : scored;
|