mixdog 0.9.130 → 0.9.131
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/release-timing-report.mjs +112 -0
- package/src/rules/shared/01-tool.md +7 -8
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -14
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +31 -170
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +19 -21
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +0 -106
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +12 -57
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/shared/background-tasks.mjs +0 -4
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/standalone/agent-tool/tag-registry.mjs +2 -0
- package/src/standalone/agent-tool/worker-index.mjs +11 -0
- package/src/standalone/agent-tool.mjs +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { appendFile, readFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
function payloadJobs(payload) {
|
|
8
|
+
const pages = Array.isArray(payload) ? payload : [payload];
|
|
9
|
+
return pages.flatMap((page) => Array.isArray(page?.jobs) ? page.jobs : []);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function elapsedSeconds(startedAt, completedAt) {
|
|
13
|
+
const start = Date.parse(startedAt || '');
|
|
14
|
+
const end = Date.parse(completedAt || '');
|
|
15
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
|
|
16
|
+
return Math.round((end - start) / 1000);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function timingRows(payload) {
|
|
20
|
+
return payloadJobs(payload).flatMap((job) => (job.steps || []).flatMap((step) => {
|
|
21
|
+
const seconds = elapsedSeconds(step.started_at, step.completed_at);
|
|
22
|
+
if (seconds === null) return [];
|
|
23
|
+
return [{
|
|
24
|
+
key: `${job.name} / ${step.name}`,
|
|
25
|
+
job: job.name,
|
|
26
|
+
step: step.name,
|
|
27
|
+
seconds,
|
|
28
|
+
}];
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function workflowSeconds(payload) {
|
|
33
|
+
const jobs = payloadJobs(payload);
|
|
34
|
+
const starts = jobs.map((job) => Date.parse(job.started_at || '')).filter(Number.isFinite);
|
|
35
|
+
const ends = jobs.map((job) => Date.parse(job.completed_at || '')).filter(Number.isFinite);
|
|
36
|
+
if (starts.length === 0 || ends.length === 0) return null;
|
|
37
|
+
return Math.round((Math.max(...ends) - Math.min(...starts)) / 1000);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function percentChange(current, baseline) {
|
|
41
|
+
if (!(baseline > 0)) return null;
|
|
42
|
+
return Math.round(((current - baseline) / baseline) * 100);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildReleaseTimingReport(currentPayload, baselinePayload = []) {
|
|
46
|
+
const currentRows = timingRows(currentPayload);
|
|
47
|
+
const baselineRows = timingRows(baselinePayload);
|
|
48
|
+
const baselineByKey = new Map(baselineRows.map((row) => [row.key, row]));
|
|
49
|
+
const regressions = currentRows.flatMap((row) => {
|
|
50
|
+
const baseline = baselineByKey.get(row.key);
|
|
51
|
+
if (!baseline) return [];
|
|
52
|
+
const percent = percentChange(row.seconds, baseline.seconds);
|
|
53
|
+
if (percent === null || percent <= 10 || row.seconds - baseline.seconds < 15) return [];
|
|
54
|
+
return [{ ...row, baselineSeconds: baseline.seconds, percent }];
|
|
55
|
+
}).sort((a, b) => b.percent - a.percent || b.seconds - a.seconds);
|
|
56
|
+
|
|
57
|
+
const currentWorkflowSeconds = workflowSeconds(currentPayload);
|
|
58
|
+
const baselineWorkflowSeconds = workflowSeconds(baselinePayload);
|
|
59
|
+
const workflowPercent = currentWorkflowSeconds !== null && baselineWorkflowSeconds !== null
|
|
60
|
+
? percentChange(currentWorkflowSeconds, baselineWorkflowSeconds)
|
|
61
|
+
: null;
|
|
62
|
+
const slowest = [...currentRows].sort((a, b) => b.seconds - a.seconds).slice(0, 15);
|
|
63
|
+
const markdown = [
|
|
64
|
+
'## Release timing',
|
|
65
|
+
'',
|
|
66
|
+
`- Current workflow span: ${currentWorkflowSeconds ?? 'n/a'}s`,
|
|
67
|
+
`- Previous successful span: ${baselineWorkflowSeconds ?? 'n/a'}s`,
|
|
68
|
+
`- Material step regressions (>10% and ≥15s): ${regressions.length}`,
|
|
69
|
+
'',
|
|
70
|
+
'| Slowest step | Current | Previous | Change |',
|
|
71
|
+
'| --- | ---: | ---: | ---: |',
|
|
72
|
+
...slowest.map((row) => {
|
|
73
|
+
const baseline = baselineByKey.get(row.key);
|
|
74
|
+
const change = baseline ? percentChange(row.seconds, baseline.seconds) : null;
|
|
75
|
+
return `| ${row.key.replaceAll('|', '\\|')} | ${row.seconds}s | ${baseline ? `${baseline.seconds}s` : 'n/a'} | ${change === null ? 'n/a' : `${change > 0 ? '+' : ''}${change}%`} |`;
|
|
76
|
+
}),
|
|
77
|
+
'',
|
|
78
|
+
].join('\n');
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
currentWorkflowSeconds,
|
|
82
|
+
baselineWorkflowSeconds,
|
|
83
|
+
workflowPercent,
|
|
84
|
+
regressions,
|
|
85
|
+
markdown,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function commandValue(value) {
|
|
90
|
+
return String(value).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
|
|
94
|
+
if (invokedPath === import.meta.url) {
|
|
95
|
+
const currentPath = process.argv[2];
|
|
96
|
+
const baselinePath = process.argv[3];
|
|
97
|
+
if (!currentPath) throw new Error('Usage: release-timing-report.mjs <current-jobs.json> [baseline-jobs.json]');
|
|
98
|
+
const current = JSON.parse(await readFile(currentPath, 'utf8'));
|
|
99
|
+
const baseline = baselinePath ? JSON.parse(await readFile(baselinePath, 'utf8')) : [];
|
|
100
|
+
const report = buildReleaseTimingReport(current, baseline);
|
|
101
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
102
|
+
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${report.markdown}\n`);
|
|
103
|
+
}
|
|
104
|
+
if (report.workflowPercent > 10
|
|
105
|
+
&& report.currentWorkflowSeconds - report.baselineWorkflowSeconds >= 30) {
|
|
106
|
+
console.log(`::warning title=Release duration regression::Workflow span increased ${report.workflowPercent}% to ${report.currentWorkflowSeconds}s`);
|
|
107
|
+
}
|
|
108
|
+
for (const row of report.regressions) {
|
|
109
|
+
console.log(`::warning title=Release step regression::${commandValue(row.key)} increased ${row.percent}% (${row.baselineSeconds}s to ${row.seconds}s)`);
|
|
110
|
+
}
|
|
111
|
+
console.log(`Release timing recorded: ${report.currentWorkflowSeconds ?? 'n/a'}s, ${report.regressions.length} material step regressions.`);
|
|
112
|
+
}
|
|
@@ -62,17 +62,16 @@
|
|
|
62
62
|
execution, runtime/state operations, calculations, data transformation, file
|
|
63
63
|
generation, or formats unsupported by file tools. Do not use `shell` instead
|
|
64
64
|
of an available file tool for ordinary file-content inspection.
|
|
65
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
- Shell commands start in the foreground. If still running after 10 seconds,
|
|
66
|
+
the call returns a tracked `task_id` and completion arrives by notification.
|
|
67
|
+
Only when the request explicitly requires
|
|
68
68
|
a service to survive after the run exits, detach it at shell level (for
|
|
69
69
|
example, `nohup ... &`); never detach ordinary jobs merely to avoid tracking.
|
|
70
70
|
A sync call may return a `task_id` and partial output after its blocking
|
|
71
71
|
budget. Do not poll: completion resumes automatically. When intermediate output must drive
|
|
72
|
-
decisions or the user requests monitoring,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
check.
|
|
72
|
+
decisions or the user explicitly requests monitoring, call `task read` once
|
|
73
|
+
to return the current status and output snapshot. If it is still running,
|
|
74
|
+
await the completion notification; do not
|
|
75
|
+
call `task` again unless the user explicitly asks for another snapshot.
|
|
77
76
|
Omit timeout by default, including for long jobs; set it only for a real total
|
|
78
77
|
deadline, since it kills even async jobs.
|
|
@@ -138,8 +138,8 @@ const TOOL_ARG_KEYS = {
|
|
|
138
138
|
recall: ['query', 'limit', 'session_id', 'cwd'],
|
|
139
139
|
search: ['query', 'limit', 'cwd'],
|
|
140
140
|
code_graph: ['mode', 'file', 'files', 'symbol', 'symbols', 'body', 'language', 'limit', 'depth', 'page', 'cwd'],
|
|
141
|
-
shell: ['command', '
|
|
142
|
-
task: ['
|
|
141
|
+
shell: ['command', 'timeout_ms'],
|
|
142
|
+
task: ['action', 'task_id'],
|
|
143
143
|
edit: ['path', 'replace_all', 'edits'],
|
|
144
144
|
edit_many: ['edits'],
|
|
145
145
|
write: ['path'],
|
|
@@ -633,10 +633,10 @@ function guardRead(a) {
|
|
|
633
633
|
}
|
|
634
634
|
|
|
635
635
|
function guardShell(a) {
|
|
636
|
-
const allowed = new Set(['command', 'timeout_ms'
|
|
636
|
+
const allowed = new Set(['command', 'timeout_ms']);
|
|
637
637
|
const unsupported = Object.keys(a).find((key) => !allowed.has(key));
|
|
638
638
|
if (unsupported) {
|
|
639
|
-
return `Error: shell arg "${unsupported}" is unsupported; use only command
|
|
639
|
+
return `Error: shell arg "${unsupported}" is unsupported; use only command and timeout_ms`;
|
|
640
640
|
}
|
|
641
641
|
if (!hasOwn(a, 'command')) {
|
|
642
642
|
return 'Error: shell requires "command"';
|
|
@@ -650,18 +650,18 @@ function guardShell(a) {
|
|
|
650
650
|
if (hasOwn(a, 'timeout_ms') && (typeof a.timeout_ms !== 'number' || !Number.isFinite(a.timeout_ms) || a.timeout_ms < 0)) {
|
|
651
651
|
return `Error: shell arg "timeout_ms" must be a non-negative number (got ${describeType(a.timeout_ms)})`;
|
|
652
652
|
}
|
|
653
|
-
if (hasOwn(a, 'run_in_background') && typeof a.run_in_background !== 'boolean') {
|
|
654
|
-
return `Error: shell arg "run_in_background" must be a boolean (got ${describeType(a.run_in_background)})`;
|
|
655
|
-
}
|
|
656
653
|
return null;
|
|
657
654
|
}
|
|
658
655
|
|
|
659
656
|
function guardTask(a) {
|
|
660
657
|
const action = typeof a.action === 'string'
|
|
661
658
|
? a.action.trim().toLowerCase()
|
|
662
|
-
: (hasOwn(a, 'action') ? a.action :
|
|
663
|
-
if (hasOwn(a, 'action')
|
|
664
|
-
return
|
|
659
|
+
: (hasOwn(a, 'action') ? a.action : '');
|
|
660
|
+
if (!hasOwn(a, 'action')) {
|
|
661
|
+
return 'Error: task requires explicit "action"';
|
|
662
|
+
}
|
|
663
|
+
if (!['list', 'read', 'cancel'].includes(action)) {
|
|
664
|
+
return `Error: task arg "action" must be one of list|read|cancel (got ${JSON.stringify(a.action)})`;
|
|
665
665
|
}
|
|
666
666
|
if (action === 'list') return null;
|
|
667
667
|
if (!hasOwn(a, 'task_id')) {
|
|
@@ -670,12 +670,6 @@ function guardTask(a) {
|
|
|
670
670
|
if (typeof a.task_id !== 'string' || a.task_id.trim().length === 0) {
|
|
671
671
|
return `Error: task arg "task_id" must be a non-empty string (got ${describeType(a.task_id)})`;
|
|
672
672
|
}
|
|
673
|
-
if (action === 'check_after') {
|
|
674
|
-
if (!hasOwn(a, 'after_ms')) {
|
|
675
|
-
return 'Error: task action "check_after" requires explicit "after_ms"';
|
|
676
|
-
}
|
|
677
|
-
return checkIntInRange(a, 'after_ms', 1, 2_147_483_647);
|
|
678
|
-
}
|
|
679
673
|
return null;
|
|
680
674
|
}
|
|
681
675
|
|
|
@@ -5,7 +5,7 @@ import { delimiter as pathDelimiter } from 'node:path';
|
|
|
5
5
|
import { join as pathJoin } from 'node:path';
|
|
6
6
|
import { isLegitimateShellExit } from '../../session/result-classification.mjs';
|
|
7
7
|
import { makeToolEnvelope } from '../../session/tool-envelope.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { execShellCommand, stripAnsi } from '../shell-command.mjs';
|
|
9
9
|
import { wrapCommandWithSnapshot } from '../shell-snapshot.mjs';
|
|
10
10
|
import { getDestructiveCommandWarning } from '../destructive-warning.mjs';
|
|
11
11
|
import { maybeRewriteWmicProcessCommand } from '../shell-policy.mjs';
|
|
@@ -13,7 +13,6 @@ import { buildBashPolicyScanTargets, checkExecPolicyMessage } from '../bash-poli
|
|
|
13
13
|
import { markCodeGraphDirtyPaths, drainCodeGraphCache } from '../code-graph-state.mjs';
|
|
14
14
|
import {
|
|
15
15
|
buildJobNotFoundMessage,
|
|
16
|
-
startBackgroundShellJob,
|
|
17
16
|
waitForShellJob,
|
|
18
17
|
peekShellJob,
|
|
19
18
|
killShellJob,
|
|
@@ -23,16 +22,13 @@ import {
|
|
|
23
22
|
endShellJobWait,
|
|
24
23
|
clearShellJobNotifyCtx,
|
|
25
24
|
shellJobPublicTaskResult,
|
|
26
|
-
attachShellJobResourceLease,
|
|
27
25
|
} from './shell-jobs.mjs';
|
|
28
26
|
import {
|
|
29
27
|
analyzeShellCommandEffects,
|
|
30
28
|
buildPowerShellFilterTeePlan,
|
|
31
29
|
consumeFilterTeeCapture,
|
|
32
30
|
extractShellApplyPatchInvocation,
|
|
33
|
-
foregroundLongCommandHint,
|
|
34
31
|
hasPowerShellOnlySyntax,
|
|
35
|
-
isAutobackgroundingAllowed,
|
|
36
32
|
planInlineScriptHoist,
|
|
37
33
|
preflightPowerShellHygiene,
|
|
38
34
|
shellSplitSegments,
|
|
@@ -41,7 +37,6 @@ import {
|
|
|
41
37
|
stripShellProbeWrappers,
|
|
42
38
|
} from './shell-analysis.mjs';
|
|
43
39
|
import {
|
|
44
|
-
cancelBackgroundTask,
|
|
45
40
|
completeBackgroundTask,
|
|
46
41
|
getBackgroundTask,
|
|
47
42
|
registerBackgroundTask,
|
|
@@ -62,16 +57,15 @@ import { normalizeOutputPath } from './path-utils.mjs';
|
|
|
62
57
|
import { normalizeErrorMessage } from './path-diagnostics.mjs';
|
|
63
58
|
import { invalidateBuiltinResultCache } from './cache-layers.mjs';
|
|
64
59
|
import { applyShellEgressPolicy, scrubLoaderVars, scrubProviderSecrets, scrubRuntimeRootVars } from '../env-scrub.mjs';
|
|
65
|
-
import { resourceAdmission } from '../../../../shared/resource-admission.mjs';
|
|
66
60
|
import {
|
|
67
61
|
findPathExecutable,
|
|
68
62
|
SHELL_RUNTIME_CANDIDATES,
|
|
69
63
|
} from './runtime-capabilities.mjs';
|
|
70
64
|
import { planDirectExeSpawn } from './shell-direct-exe.mjs';
|
|
71
65
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
export const DEFAULT_SHELL_AUTO_BACKGROUND_MS =
|
|
66
|
+
// Commands start in the foreground. Only work still running after the
|
|
67
|
+
// 10 s coordination budget is promoted to a tracked background task.
|
|
68
|
+
export const DEFAULT_SHELL_AUTO_BACKGROUND_MS = 10_000;
|
|
75
69
|
|
|
76
70
|
// Post-exec drift detection. After a foreground shell command, compare the
|
|
77
71
|
// live mtime+size of files mixdog has already read this session against their
|
|
@@ -351,8 +345,6 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
351
345
|
// never creates a second session cwd authority beside the dedicated cwd tool.
|
|
352
346
|
const bashWorkDir = workDir;
|
|
353
347
|
const _readStateScope = options?.readStateScope ?? options?.sessionId ?? null;
|
|
354
|
-
let runInBackground = args?.run_in_background === true;
|
|
355
|
-
|
|
356
348
|
// Run hard-block policy before any shell dispatch.
|
|
357
349
|
const _rawCmd = String(args && args.command != null ? args.command : '');
|
|
358
350
|
// `apply_patch` typed into the shell (heredoc/argument/bare
|
|
@@ -428,25 +420,22 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
428
420
|
if (_execPolicyBlock) {
|
|
429
421
|
return formatShellToolFailure(_execPolicyBlock);
|
|
430
422
|
}
|
|
431
|
-
// Inline-script hoisting
|
|
432
|
-
// the temp file). The body is written verbatim and the invocation becomes a
|
|
423
|
+
// Inline-script hoisting. The body is written verbatim and the invocation becomes a
|
|
433
424
|
// file run, so the host shell never has to carry the script through its
|
|
434
425
|
// quoting layer. planInlineScriptHoist refuses every case where file
|
|
435
426
|
// semantics would differ, so this is a transport change only.
|
|
436
427
|
let _inlineHoistPath = null;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
} catch { _inlineHoistPath = null; }
|
|
449
|
-
}
|
|
428
|
+
const hoist = planInlineScriptHoist(command);
|
|
429
|
+
if (hoist) {
|
|
430
|
+
try {
|
|
431
|
+
const file = pathJoin(
|
|
432
|
+
tmpdir(),
|
|
433
|
+
`mixdog-inline-${process.pid}-${Date.now().toString(36)}${hoist.extension}`,
|
|
434
|
+
);
|
|
435
|
+
writeFileSync(file, hoist.body, 'utf8');
|
|
436
|
+
_inlineHoistPath = file;
|
|
437
|
+
command = hoist.replace(file.replace(/\\/g, '/'));
|
|
438
|
+
} catch { _inlineHoistPath = null; }
|
|
450
439
|
}
|
|
451
440
|
|
|
452
441
|
const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
|
|
@@ -468,11 +457,6 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
468
457
|
// bound the blocking window when timeout promotion is available.
|
|
469
458
|
const _envDefaultTimeout = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
|
|
470
459
|
const DEFAULT_BASH_TIMEOUT_MS = _envDefaultTimeout > 0 ? _envDefaultTimeout : 120_000;
|
|
471
|
-
// Background (async / run_in_background) jobs get NO omitted default: 0
|
|
472
|
-
// means "unlimited" and flows unchanged through startBackgroundShellJob →
|
|
473
|
-
// task meta (detail.timeoutMs 0). An explicit args.timeout_ms is still honored
|
|
474
|
-
// and enforced exactly as before. Sync path keeps the 120s omitted default.
|
|
475
|
-
const DEFAULT_BACKGROUND_BASH_TIMEOUT_MS = 0;
|
|
476
460
|
const _envMaxTimeout = parseInt(process.env.BASH_MAX_TIMEOUT_MS ?? '', 10);
|
|
477
461
|
// Foreground blocking cap when timeout promotion is available. 600s let a
|
|
478
462
|
// caller-supplied 10-15 min timeout hold the conversation synchronously
|
|
@@ -481,14 +465,10 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
481
465
|
// detaches as a tracked job with the REMAINDER of the explicit timeout as
|
|
482
466
|
// its background deadline (user decision: 2 minutes).
|
|
483
467
|
const MAX_BASH_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 120_000, DEFAULT_BASH_TIMEOUT_MS);
|
|
484
|
-
const defaultTimeoutMs =
|
|
485
|
-
? DEFAULT_BACKGROUND_BASH_TIMEOUT_MS
|
|
486
|
-
: DEFAULT_BASH_TIMEOUT_MS;
|
|
468
|
+
const defaultTimeoutMs = DEFAULT_BASH_TIMEOUT_MS;
|
|
487
469
|
const hasExplicitTimeout = typeof args.timeout_ms === 'number' && args.timeout_ms > 0;
|
|
488
470
|
const timeoutMs = hasExplicitTimeout ? args.timeout_ms : defaultTimeoutMs;
|
|
489
|
-
const backgroundOnTimeout = !
|
|
490
|
-
&& !_bgTasksDisabled
|
|
491
|
-
&& isAutobackgroundingAllowed(command, resolvedSpec.shellType);
|
|
471
|
+
const backgroundOnTimeout = !_bgTasksDisabled;
|
|
492
472
|
// Explicit caller timeout remains the total deadline. When promotion is
|
|
493
473
|
// available, cap only its foreground blocking portion at MAX.
|
|
494
474
|
// JS timers (setTimeout) and PS WaitForExit(ms) are 32-bit: a delay above
|
|
@@ -514,19 +494,11 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
514
494
|
const promoteAtTimeout = backgroundOnTimeout
|
|
515
495
|
&& (!hasExplicitTimeout || promotedTimeoutMs > 0);
|
|
516
496
|
const mergeStderr = true;
|
|
517
|
-
const longForegroundHint = foregroundLongCommandHint(
|
|
518
|
-
command,
|
|
519
|
-
timeout,
|
|
520
|
-
{ run_in_background: runInBackground },
|
|
521
|
-
{ backgroundTasksDisabled: _bgTasksDisabled },
|
|
522
|
-
);
|
|
523
|
-
if (longForegroundHint) return formatShellToolFailure(longForegroundHint);
|
|
524
497
|
// Main-agent blocking budget. A timeout is the command's total deadline,
|
|
525
498
|
// not permission to hold the conversation open for that whole duration:
|
|
526
|
-
// after
|
|
499
|
+
// after 10 s a still-running command becomes a tracked background task and
|
|
527
500
|
// completion is pushed to the owner. Explicit timeouts keep their remaining
|
|
528
|
-
// deadline after promotion.
|
|
529
|
-
// detached) or commands barred from backgrounding.
|
|
501
|
+
// deadline after promotion.
|
|
530
502
|
// MIXDOG_SHELL_AUTO_BACKGROUND_MS overrides; an explicit 0 disables.
|
|
531
503
|
const _autoBgEnvRaw = process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS;
|
|
532
504
|
const _autoBgEnvMs = Number(_autoBgEnvRaw);
|
|
@@ -534,10 +506,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
534
506
|
&& Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs >= 0)
|
|
535
507
|
? Math.floor(_autoBgEnvMs)
|
|
536
508
|
: DEFAULT_SHELL_AUTO_BACKGROUND_MS;
|
|
537
|
-
// Gate on backgroundOnTimeout
|
|
538
|
-
// encodes "promotion is available for this command" (not detached, background
|
|
539
|
-
// tasks enabled, command shape allows it), so the soft threshold can never
|
|
540
|
-
// promote something the hard timeout would refuse to.
|
|
509
|
+
// Gate on backgroundOnTimeout so disabled background tasks remain foreground.
|
|
541
510
|
const autoBackgroundMs = (!backgroundOnTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
|
|
542
511
|
? 0
|
|
543
512
|
: Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
|
|
@@ -556,14 +525,12 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
556
525
|
let execShellArg = shellArg;
|
|
557
526
|
let execShellArgs = shellArgs;
|
|
558
527
|
let directArgv = null;
|
|
559
|
-
const directPlan =
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
})
|
|
566
|
-
: null;
|
|
528
|
+
const directPlan = planDirectExeSpawn(command, {
|
|
529
|
+
shellType,
|
|
530
|
+
cwd: bashWorkDir,
|
|
531
|
+
pathValue: spawnEnv.PATH,
|
|
532
|
+
env: spawnEnv,
|
|
533
|
+
});
|
|
567
534
|
// PowerShell UTF-8 prefix is PS-only: the Windows Git Bash path
|
|
568
535
|
// (shellType==='posix') must NOT receive it. Snapshot wrapper stays
|
|
569
536
|
// POSIX-host-only for now — no snapshot for Windows Git Bash initially.
|
|
@@ -574,14 +541,12 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
574
541
|
execShellArgs = [];
|
|
575
542
|
directArgv = directPlan.argv;
|
|
576
543
|
} else if (process.platform === 'win32' && shellType === 'powershell') {
|
|
577
|
-
// Filter-swallow rescue
|
|
544
|
+
// Filter-swallow rescue: tee the unfiltered
|
|
578
545
|
// producer stream of an exactly-recognized filter pipeline so a
|
|
579
546
|
// failing run can attach the original output tail in THIS call
|
|
580
547
|
// instead of returning `[exit code: N]` + `(no output)`. Any
|
|
581
548
|
// ambiguity yields a null plan and the command runs untouched.
|
|
582
|
-
|
|
583
|
-
try { _teePlan = buildPowerShellFilterTeePlan(command); } catch { _teePlan = null; }
|
|
584
|
-
}
|
|
549
|
+
try { _teePlan = buildPowerShellFilterTeePlan(command); } catch { _teePlan = null; }
|
|
585
550
|
wrappedCommand = _prefixPowerShellUtf8(_teePlan ? _teePlan.command : command);
|
|
586
551
|
} else if (process.platform !== 'win32' && (shell.includes('bash') || shell.includes('zsh'))) {
|
|
587
552
|
try {
|
|
@@ -592,108 +557,6 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
592
557
|
} else {
|
|
593
558
|
wrappedCommand = command;
|
|
594
559
|
}
|
|
595
|
-
if (runInBackground) {
|
|
596
|
-
let asyncAbortSignal = null;
|
|
597
|
-
try { asyncAbortSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
|
|
598
|
-
catch { asyncAbortSignal = null; }
|
|
599
|
-
const combinedAsyncAbort = _combineAbortSignals(asyncAbortSignal, options?.abortSignal || null);
|
|
600
|
-
let asyncLease = null;
|
|
601
|
-
let job;
|
|
602
|
-
try {
|
|
603
|
-
asyncLease = await acquireShellLeaseBounded(options?.resourceAdmission || resourceAdmission, {
|
|
604
|
-
abortSignal: combinedAsyncAbort.signal,
|
|
605
|
-
label: String(command).replace(/\s+/g, ' ').slice(0, 120),
|
|
606
|
-
dependency: 'detached',
|
|
607
|
-
ownerKey: options?.callerSessionId || options?.sessionId || null,
|
|
608
|
-
});
|
|
609
|
-
if (combinedAsyncAbort.signal?.aborted) {
|
|
610
|
-
throw combinedAsyncAbort.signal.reason || new Error('shell background task cancelled before spawn');
|
|
611
|
-
}
|
|
612
|
-
job = await startBackgroundShellJob({
|
|
613
|
-
command: wrappedCommand,
|
|
614
|
-
timeoutMs: timeout,
|
|
615
|
-
workDir: bashWorkDir,
|
|
616
|
-
mergeStderr,
|
|
617
|
-
spawnEnv,
|
|
618
|
-
shell,
|
|
619
|
-
shellArg,
|
|
620
|
-
shellArgs,
|
|
621
|
-
shellType,
|
|
622
|
-
// Per-terminal session stamp: the dispatching terminal's
|
|
623
|
-
// claude.exe pid (server-main threads callerSession.clientHostPid).
|
|
624
|
-
clientHostPid: options?.clientHostPid,
|
|
625
|
-
// Dispatching session: hosts that pool many sessions in one
|
|
626
|
-
// process (desktop) scope the job to its own pane with this.
|
|
627
|
-
ownerSessionId: options?.callerSessionId || options?.sessionId || null,
|
|
628
|
-
...(options?.shellJobRuntime || {}),
|
|
629
|
-
});
|
|
630
|
-
if (job && job.error) {
|
|
631
|
-
if (job.rollbackPending && attachShellJobResourceLease(job.jobId, asyncLease, { allowUnpersisted: true })) {
|
|
632
|
-
asyncLease = null;
|
|
633
|
-
}
|
|
634
|
-
return formatShellToolFailure(job.error);
|
|
635
|
-
}
|
|
636
|
-
if (combinedAsyncAbort.signal?.aborted) {
|
|
637
|
-
try { killShellJob(job.jobId); } catch {}
|
|
638
|
-
throw combinedAsyncAbort.signal.reason || new Error('shell background task cancelled before registration');
|
|
639
|
-
}
|
|
640
|
-
if (job && !job.error && attachShellJobResourceLease(job.jobId, asyncLease)) {
|
|
641
|
-
asyncLease = null;
|
|
642
|
-
}
|
|
643
|
-
const task = registerBackgroundTask({
|
|
644
|
-
taskId: job.jobId,
|
|
645
|
-
surface: 'shell',
|
|
646
|
-
operation: 'shell',
|
|
647
|
-
label: String(command).replace(/\s+/g, ' ').slice(0, 120),
|
|
648
|
-
input: { command, cwd: bashWorkDir },
|
|
649
|
-
context: {
|
|
650
|
-
notifyFn: typeof options?.notifyFn === 'function' ? options.notifyFn : null,
|
|
651
|
-
callerSessionId: options?.callerSessionId || options?.sessionId || null,
|
|
652
|
-
routingSessionId: options?.routingSessionId || options?.sessionId || null,
|
|
653
|
-
clientHostPid: options?.clientHostPid,
|
|
654
|
-
},
|
|
655
|
-
meta: {
|
|
656
|
-
task_id: job.jobId,
|
|
657
|
-
pid: job.pid,
|
|
658
|
-
stdout: normalizeOutputPath(job.stdoutPath),
|
|
659
|
-
stderr: mergeStderr ? null : normalizeOutputPath(job.stderrPath),
|
|
660
|
-
cwd: bashWorkDir,
|
|
661
|
-
timeoutMs: timeout,
|
|
662
|
-
},
|
|
663
|
-
resultType: 'shell_task_result',
|
|
664
|
-
cancel: () => killShellJob(job.jobId),
|
|
665
|
-
});
|
|
666
|
-
if (combinedAsyncAbort.signal?.aborted) {
|
|
667
|
-
try { killShellJob(job.jobId); } catch {}
|
|
668
|
-
cancelBackgroundTask(job.jobId, 'cancelled before background registration completed');
|
|
669
|
-
throw combinedAsyncAbort.signal.reason || new Error('shell background task cancelled during registration');
|
|
670
|
-
}
|
|
671
|
-
// Wire a one-shot completion push so the dispatching session learns
|
|
672
|
-
// the background task finished (no polling tool is auto-driven).
|
|
673
|
-
try {
|
|
674
|
-
watchBackgroundShellJob(job.jobId, {
|
|
675
|
-
notifyFn: typeof options?.notifyFn === 'function' ? options.notifyFn : null,
|
|
676
|
-
callerSessionId: options?.callerSessionId || options?.sessionId,
|
|
677
|
-
routingSessionId: options?.routingSessionId,
|
|
678
|
-
clientHostPid: options?.clientHostPid,
|
|
679
|
-
});
|
|
680
|
-
} catch { /* watcher arm is best-effort; never blocks the spawn */ }
|
|
681
|
-
return _prependDestructiveWarning(command, [
|
|
682
|
-
renderBackgroundTask(task),
|
|
683
|
-
'',
|
|
684
|
-
'You will be notified when it completes; do not poll.',
|
|
685
|
-
].join('\n'));
|
|
686
|
-
} catch (error) {
|
|
687
|
-
if (job?.jobId && !job.error) {
|
|
688
|
-
try { killShellJob(job.jobId); } catch {}
|
|
689
|
-
}
|
|
690
|
-
return formatShellToolFailure(normalizeErrorMessage(error instanceof Error ? error.message : String(error)));
|
|
691
|
-
} finally {
|
|
692
|
-
combinedAsyncAbort.cleanup();
|
|
693
|
-
try { await asyncLease?.release(); } catch {}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
560
|
let bashAbortSignal = null;
|
|
698
561
|
try { bashAbortSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
|
|
699
562
|
catch { bashAbortSignal = null; }
|
|
@@ -701,10 +564,8 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
701
564
|
// Promote-at-timeout (CC shouldAutoBackground parity). When a
|
|
702
565
|
// foreground one-shot hits its timeout and is still running, adopt it
|
|
703
566
|
// as a background job (task_id + notify) instead of tree-killing it.
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
// MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS env. Never applies to
|
|
707
|
-
// run_in_background (already detached, handled above).
|
|
567
|
+
// The truthy MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS env restores the old
|
|
568
|
+
// foreground-only behavior.
|
|
708
569
|
const foregroundStartedAtMs = Date.now();
|
|
709
570
|
const result = await execShellCommand({
|
|
710
571
|
shell: execShell, shellArg: execShellArg, shellArgs: execShellArgs,
|
|
@@ -12,8 +12,8 @@ const _shellSyntaxCheat =
|
|
|
12
12
|
export const BUILTIN_TOOLS = [
|
|
13
13
|
{
|
|
14
14
|
name: 'read',
|
|
15
|
-
title: '
|
|
16
|
-
annotations: { title: '
|
|
15
|
+
title: 'Read',
|
|
16
|
+
annotations: { title: 'Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
|
|
17
17
|
description: 'Known-file contents or line ranges. Images render for viewing; not directories. Replaces cat/head/tail.',
|
|
18
18
|
inputSchema: {
|
|
19
19
|
type: 'object',
|
|
@@ -39,9 +39,9 @@ export const BUILTIN_TOOLS = [
|
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
name: 'shell',
|
|
42
|
-
title: '
|
|
43
|
-
annotations: { title: '
|
|
44
|
-
description: 'Run programs
|
|
42
|
+
title: 'Shell',
|
|
43
|
+
annotations: { title: 'Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
|
|
44
|
+
description: 'Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. After 10s, a running command returns task_id and completes by notification.',
|
|
45
45
|
inputSchema: {
|
|
46
46
|
type: 'object',
|
|
47
47
|
properties: {
|
|
@@ -50,7 +50,6 @@ export const BUILTIN_TOOLS = [
|
|
|
50
50
|
type: 'number',
|
|
51
51
|
description: 'Optional total deadline.',
|
|
52
52
|
},
|
|
53
|
-
run_in_background: { type: 'boolean', description: 'Run immediately as a tracked background task; returns task_id and sends a completion notification.' },
|
|
54
53
|
},
|
|
55
54
|
required: ['command'],
|
|
56
55
|
additionalProperties: false,
|
|
@@ -58,24 +57,23 @@ export const BUILTIN_TOOLS = [
|
|
|
58
57
|
},
|
|
59
58
|
{
|
|
60
59
|
name: 'task',
|
|
61
|
-
title: '
|
|
62
|
-
annotations: { title: '
|
|
63
|
-
description: '
|
|
60
|
+
title: 'Task',
|
|
61
|
+
annotations: { title: 'Task', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
62
|
+
description: 'List shell tasks, read one current output snapshot, or cancel by task_id; completion arrives by notification.',
|
|
64
63
|
inputSchema: {
|
|
65
64
|
type: 'object',
|
|
66
65
|
properties: {
|
|
67
|
-
task_id: { type: 'string', description: 'Shell task_id.' },
|
|
68
|
-
action: { type: 'string', enum: ['list', '
|
|
69
|
-
after_ms: { type: 'number', description: 'Required explicitly for check_after; one-shot delay before the progress snapshot, not the task deadline.' },
|
|
66
|
+
task_id: { type: 'string', description: 'Shell task_id; required for read/cancel.' },
|
|
67
|
+
action: { type: 'string', enum: ['list', 'read', 'cancel'], description: 'list all; read snapshot; cancel task.' },
|
|
70
68
|
},
|
|
71
|
-
required: [],
|
|
69
|
+
required: ['action'],
|
|
72
70
|
additionalProperties: false,
|
|
73
71
|
},
|
|
74
72
|
},
|
|
75
73
|
{
|
|
76
74
|
name: 'grep',
|
|
77
|
-
title: '
|
|
78
|
-
annotations: { title: '
|
|
75
|
+
title: 'Grep',
|
|
76
|
+
annotations: { title: 'Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
79
77
|
description: 'Search file contents for literal or regex matches; contextual path:line blocks are directly usable—read only omitted lines. Replaces grep/rg.',
|
|
80
78
|
inputSchema: {
|
|
81
79
|
type: 'object',
|
|
@@ -103,8 +101,8 @@ export const BUILTIN_TOOLS = [
|
|
|
103
101
|
},
|
|
104
102
|
{
|
|
105
103
|
name: 'glob',
|
|
106
|
-
title: '
|
|
107
|
-
annotations: { title: '
|
|
104
|
+
title: 'Glob',
|
|
105
|
+
annotations: { title: 'Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
108
106
|
description: 'Return wildcard-matching paths under a known base when those paths are needed. Replaces find -name.',
|
|
109
107
|
inputSchema: {
|
|
110
108
|
type: 'object',
|
|
@@ -126,8 +124,8 @@ export const BUILTIN_TOOLS = [
|
|
|
126
124
|
},
|
|
127
125
|
{
|
|
128
126
|
name: 'find',
|
|
129
|
-
title: '
|
|
130
|
-
annotations: { title: '
|
|
127
|
+
title: 'Find Files',
|
|
128
|
+
annotations: { title: 'Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
131
129
|
description: 'Fuzzy filename/directory path lookup when the location itself is unknown; returns paths only. No source-content, symbol, value, or line search.',
|
|
132
130
|
inputSchema: {
|
|
133
131
|
type: 'object',
|
|
@@ -146,8 +144,8 @@ export const BUILTIN_TOOLS = [
|
|
|
146
144
|
},
|
|
147
145
|
{
|
|
148
146
|
name: 'list',
|
|
149
|
-
title: '
|
|
150
|
-
annotations: { title: '
|
|
147
|
+
title: 'List Directory',
|
|
148
|
+
annotations: { title: 'List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
151
149
|
description: "Return a known directory's immediate entries (path + type) when the entry list itself is needed; not a prerequisite for another tool on that directory. No wildcard; meta:true adds size/mtime/mode.",
|
|
152
150
|
inputSchema: {
|
|
153
151
|
type: 'object',
|
|
@@ -3,7 +3,6 @@ import { assertPathReachable, assertPathsReachable } from './fs-reachability.mjs
|
|
|
3
3
|
import { isAbsolute, join, resolve } from 'path';
|
|
4
4
|
import { tmpdir } from 'os';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
|
-
import { WRAPPER_NAMES } from '../shell-policy.mjs';
|
|
7
6
|
import {
|
|
8
7
|
cwdRelativePath,
|
|
9
8
|
normalizeInputPath,
|
|
@@ -830,36 +829,6 @@ export async function analyzeShellCommandEffects(command, cwd) {
|
|
|
830
829
|
return { mutationMode: 'none', paths: [], finalCwd: localCwd };
|
|
831
830
|
}
|
|
832
831
|
|
|
833
|
-
// CC detectBlockedSleepPattern parity: a LEADING integer `sleep N` (or the
|
|
834
|
-
// PowerShell `Start-Sleep`/`sleep` alias forms) of 2s or more is blocked
|
|
835
|
-
// preflight with an instructive error instead of running and being killed at
|
|
836
|
-
// its deadline. Only the first top-level segment is considered — sleeps inside
|
|
837
|
-
// pipelines/subshells/scripts are legitimate pacing and pass through. Float
|
|
838
|
-
// durations (sleep 0.5) are allowed, mirroring the reference CLI.
|
|
839
|
-
// Explicit async execution bypasses this validation. Sync callers receive the
|
|
840
|
-
// same corrective direction as the reference CLI instead of being silently
|
|
841
|
-
// changed to a different execution mode.
|
|
842
|
-
export function detectBlockedSleepPattern(command, minSecs = 2) {
|
|
843
|
-
const cmd = String(command || '').trim();
|
|
844
|
-
if (!cmd) return null;
|
|
845
|
-
const sep = cmd.match(/&&|\|\||;|\|/);
|
|
846
|
-
const first = (sep ? cmd.slice(0, sep.index) : cmd).trim();
|
|
847
|
-
let secs = null;
|
|
848
|
-
const posix = /^sleep\s+(\d+)\s*$/.exec(first);
|
|
849
|
-
if (posix) secs = Number.parseInt(posix[1], 10);
|
|
850
|
-
if (secs == null) {
|
|
851
|
-
const ps = /^(?:start-sleep|sleep)\s+(?:-(?:seconds|s)\s+)?(\d+)\s*$/i.exec(first);
|
|
852
|
-
if (ps) secs = Number.parseInt(ps[1], 10);
|
|
853
|
-
}
|
|
854
|
-
if (secs == null) {
|
|
855
|
-
const psMs = /^start-sleep\s+-(?:milliseconds|m)\s+(\d+)\s*$/i.exec(first);
|
|
856
|
-
if (psMs) secs = Math.floor(Number.parseInt(psMs[1], 10) / 1000);
|
|
857
|
-
}
|
|
858
|
-
if (secs == null || secs < minSecs) return null;
|
|
859
|
-
const rest = sep ? cmd.slice(sep.index).replace(/^(?:&&|\|\||;|\|)\s*/, '').trim() : '';
|
|
860
|
-
return rest ? `sleep ${secs} followed by: ${rest.slice(0, 80)}` : `standalone sleep ${secs}`;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
832
|
// Shell interception: patch-trained models type `apply_patch <<'EOF' … EOF`
|
|
864
833
|
// INTO THE SHELL. No
|
|
865
834
|
// such binary exists here, so the invocation is extracted and routed to the
|
|
@@ -903,81 +872,6 @@ export function extractShellApplyPatchInvocation(command) {
|
|
|
903
872
|
return { error: 'apply_patch requires the patch text (heredoc or single argument)' };
|
|
904
873
|
}
|
|
905
874
|
|
|
906
|
-
export function foregroundLongCommandHint(command, timeoutMs, args = {}, opts = {}) {
|
|
907
|
-
if (args.run_in_background === true) return '';
|
|
908
|
-
const cmd = String(command || '').trim();
|
|
909
|
-
if (!cmd) return '';
|
|
910
|
-
// CC validateInput parity: block only while background tasks are enabled —
|
|
911
|
-
// the remedy we point at (background execution / completion notification) must exist.
|
|
912
|
-
if (opts.backgroundTasksDisabled !== true) {
|
|
913
|
-
const blocked = detectBlockedSleepPattern(cmd);
|
|
914
|
-
if (blocked) {
|
|
915
|
-
return `Error: blocked — ${blocked}. Set run_in_background:true and act on the completion notification. If you genuinely need a delay (rate limiting, pacing), keep it under 2 seconds.`;
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
return '';
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// Commands that must NOT be promoted to background on a foreground timeout —
|
|
922
|
-
// they run in the foreground and are killed at their deadline instead. Mirrors
|
|
923
|
-
// the reference CLI DISALLOWED_AUTO_BACKGROUND_COMMANDS: `sleep` on posix,
|
|
924
|
-
// `start-sleep`/`sleep` (the PS built-in alias) on PowerShell. A `sleep`/
|
|
925
|
-
// `Start-Sleep` the caller wants to survive must be launched with
|
|
926
|
-
// run_in_background:true explicitly.
|
|
927
|
-
const _DISALLOWED_AUTO_BACKGROUND_POSIX = ['sleep'];
|
|
928
|
-
const _DISALLOWED_AUTO_BACKGROUND_PS = ['start-sleep', 'sleep'];
|
|
929
|
-
|
|
930
|
-
// Whether one shell segment reaches a disallowed sleep-like command. Strips a
|
|
931
|
-
// leading subshell/paren/brace/`&`, then walks tokens skipping VAR=val and
|
|
932
|
-
// option flags (quotes stripped so `'Start-Sleep'` resolves). A disallowed base
|
|
933
|
-
// name found anywhere reachable blocks promotion. Once a command-runner wrapper
|
|
934
|
-
// (env/sudo/timeout/nice/…, shell-policy.mjs's WRAPPER_NAMES) has been seen we
|
|
935
|
-
// do NOT model that wrapper's flag arity — we keep scanning EVERY later bare
|
|
936
|
-
// token for a hidden `sleep`, so `sudo -u nobody sleep 5` / `setpriv --reuid
|
|
937
|
-
// user sleep` are caught (erring toward NOT promoting). Without a wrapper the
|
|
938
|
-
// first real command token decides the segment.
|
|
939
|
-
function _segmentDisallowed(segment, disallowed) {
|
|
940
|
-
const stripped = String(segment || '').replace(/^[\s(){}&]+/, '');
|
|
941
|
-
const tokens = stripped.split(/\s+/).filter(Boolean);
|
|
942
|
-
let sawWrapper = false;
|
|
943
|
-
for (let tok of tokens) {
|
|
944
|
-
tok = tok.replace(/^['"]+|['"]+$/g, '');
|
|
945
|
-
if (!tok) continue;
|
|
946
|
-
if (/^[A-Za-z_]\w*=/.test(tok)) continue; // VAR=val assignment
|
|
947
|
-
if (tok.startsWith('-')) continue; // option flag
|
|
948
|
-
const stem = tok.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '');
|
|
949
|
-
if (disallowed.includes(stem)) return true; // sleep reachable here
|
|
950
|
-
if (WRAPPER_NAMES.has(stem)) { sawWrapper = true; continue; }
|
|
951
|
-
if (/^\d+(?:\.\d+)?[smhd]?$/i.test(tok)) continue; // numeric/duration arg
|
|
952
|
-
// First real command token. Without a preceding wrapper it IS the base
|
|
953
|
-
// command and it wasn't disallowed → segment is safe. With a wrapper,
|
|
954
|
-
// this may be a wrapper value arg (`sudo -u nobody …`); keep scanning
|
|
955
|
-
// for a hidden sleep instead of stopping here.
|
|
956
|
-
if (!sawWrapper) return false;
|
|
957
|
-
}
|
|
958
|
-
return false;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// Whether a still-running foreground one-shot may be promoted to a background
|
|
962
|
-
// job when it hits its timeout (CC isAutobackgroundingAllowed analogue).
|
|
963
|
-
// Scans EVERY segment of a &&/||/;/|/& chain and, per segment, peels wrappers
|
|
964
|
-
// before reading the base command — so a sleep-like hidden behind a chain
|
|
965
|
-
// (`cd x && sleep 5`) or a runner wrapper (`timeout 5 sleep`, `env sleep`) is
|
|
966
|
-
// still caught. Errs toward NOT promoting: any disallowed base command in any
|
|
967
|
-
// segment blocks promotion. shellType 'powershell' uses the PS disallow list.
|
|
968
|
-
export function isAutobackgroundingAllowed(command, shellType) {
|
|
969
|
-
const cmd = String(command || '').trim();
|
|
970
|
-
if (!cmd) return true;
|
|
971
|
-
const disallowed = shellType === 'powershell'
|
|
972
|
-
? _DISALLOWED_AUTO_BACKGROUND_PS
|
|
973
|
-
: _DISALLOWED_AUTO_BACKGROUND_POSIX;
|
|
974
|
-
const segments = cmd.split(/(?:&&|\|\||[;\n|&])/);
|
|
975
|
-
for (const seg of segments) {
|
|
976
|
-
if (_segmentDisallowed(seg, disallowed)) return false;
|
|
977
|
-
}
|
|
978
|
-
return true;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
875
|
// ---------------------------------------------------------------------------
|
|
982
876
|
// Filter-swallow rescue (PowerShell one-shot path). Measured 2026-08: ~37
|
|
983
877
|
// failures/14d were `<producer> 2>&1 | Select-String … | Select-Object …`
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
cancelBackgroundTask,
|
|
12
12
|
completeBackgroundTask,
|
|
13
13
|
getBackgroundTask,
|
|
14
|
-
notifyBackgroundTaskProgress,
|
|
15
14
|
renderBackgroundTask,
|
|
16
15
|
renderBackgroundTaskList,
|
|
17
16
|
} from '../../../../shared/background-tasks.mjs';
|
|
@@ -54,38 +53,9 @@ function renderTaskCancelSuccess(taskId, task) {
|
|
|
54
53
|
].join('\n');
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
function scheduleShellProgressCheck(task, afterMs) {
|
|
58
|
-
const replaced = Boolean(task.progressCheckTimer);
|
|
59
|
-
if (task.progressCheckTimer) {
|
|
60
|
-
try { clearTimeout(task.progressCheckTimer); } catch {}
|
|
61
|
-
}
|
|
62
|
-
const scheduledAt = Date.now();
|
|
63
|
-
const timer = setTimeout(() => {
|
|
64
|
-
const current = getBackgroundTask(task.taskId);
|
|
65
|
-
if (!current || current.progressCheckTimer !== timer) return;
|
|
66
|
-
current.progressCheckTimer = null;
|
|
67
|
-
if (current.status !== 'running') return;
|
|
68
|
-
const job = peekShellJob(task.taskId);
|
|
69
|
-
if (!job || job.status !== 'running') return;
|
|
70
|
-
const snapshot = shellJobPublicTaskResult(job);
|
|
71
|
-
notifyBackgroundTaskProgress(current, {
|
|
72
|
-
text: [
|
|
73
|
-
renderBackgroundTask(current),
|
|
74
|
-
'',
|
|
75
|
-
JSON.stringify(snapshot, null, 2),
|
|
76
|
-
].join('\n'),
|
|
77
|
-
resultType: 'shell_task_progress',
|
|
78
|
-
instruction: `The scheduled progress check for shell task ${task.taskId} is ready; inspect this snapshot and schedule another check_after only if needed.`,
|
|
79
|
-
key: `scheduled-progress-${scheduledAt}`,
|
|
80
|
-
});
|
|
81
|
-
}, afterMs);
|
|
82
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
83
|
-
task.progressCheckTimer = timer;
|
|
84
|
-
return replaced;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
56
|
export async function executeTaskTool(args, options = {}) {
|
|
88
|
-
const action = typeof args.action === 'string' ? args.action.toLowerCase() :
|
|
57
|
+
const action = typeof args.action === 'string' ? args.action.toLowerCase() : '';
|
|
58
|
+
if (!action) return 'Error: task action is required';
|
|
89
59
|
if (action === 'list') return renderBackgroundTaskList({ context: options });
|
|
90
60
|
|
|
91
61
|
const taskId = typeof args.task_id === 'string' ? args.task_id.trim() : '';
|
|
@@ -102,10 +72,16 @@ export async function executeTaskTool(args, options = {}) {
|
|
|
102
72
|
if (!task) return `Error: task not found: ${taskId}`;
|
|
103
73
|
const isShellTask = task.surface === 'shell';
|
|
104
74
|
|
|
105
|
-
if (action === '
|
|
106
|
-
if (isShellTask) refreshShellTask(taskId, { includeRunning:
|
|
75
|
+
if (action === 'read') {
|
|
76
|
+
if (isShellTask) refreshShellTask(taskId, { includeRunning: true });
|
|
107
77
|
const latest = getBackgroundTask(taskId, { context: options }) || task;
|
|
108
|
-
|
|
78
|
+
const rendered = renderBackgroundTask(latest, { includeResult: true });
|
|
79
|
+
if (latest.status !== 'running') return rendered;
|
|
80
|
+
return [
|
|
81
|
+
rendered,
|
|
82
|
+
'',
|
|
83
|
+
'Still running. Completion will be delivered automatically; do not poll or call task again unless the user explicitly asks for another snapshot.',
|
|
84
|
+
].join('\n');
|
|
109
85
|
}
|
|
110
86
|
|
|
111
87
|
if (action === 'cancel') {
|
|
@@ -120,26 +96,5 @@ export async function executeTaskTool(args, options = {}) {
|
|
|
120
96
|
return renderTaskCancelSuccess(taskId, getBackgroundTask(taskId, { context: options }) || task);
|
|
121
97
|
}
|
|
122
98
|
|
|
123
|
-
|
|
124
|
-
if (!Number.isInteger(args.after_ms) || args.after_ms <= 0 || args.after_ms > 2_147_483_647) {
|
|
125
|
-
return 'Error: task action "check_after" requires explicit positive integer "after_ms"';
|
|
126
|
-
}
|
|
127
|
-
if (!isShellTask) return 'Error: task action "check_after" supports shell task_id values only';
|
|
128
|
-
const job = peekShellJob(taskId);
|
|
129
|
-
if (!job) return buildJobNotFoundMessage(taskId);
|
|
130
|
-
if (job.status !== 'running') {
|
|
131
|
-
refreshShellTask(taskId);
|
|
132
|
-
return renderBackgroundTask(getBackgroundTask(taskId, { context: options }) || task, { includeResult: true });
|
|
133
|
-
}
|
|
134
|
-
const replaced = scheduleShellProgressCheck(task, args.after_ms);
|
|
135
|
-
return [
|
|
136
|
-
'status: running',
|
|
137
|
-
`task_id: ${taskId}`,
|
|
138
|
-
'progress_check_scheduled: true',
|
|
139
|
-
`after_ms: ${args.after_ms}`,
|
|
140
|
-
replaced ? 'replaced_previous_check: true' : null,
|
|
141
|
-
].filter(Boolean).join('\n');
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return `Error: task action must be one of list|status|read|check_after|cancel (got ${JSON.stringify(args.action)})`;
|
|
99
|
+
return `Error: task action must be one of list|read|cancel (got ${JSON.stringify(args.action)})`;
|
|
145
100
|
}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.13",
|
|
3
3
|
"_comment": "Synced from immutable graph-v release assets.",
|
|
4
4
|
"assets": {
|
|
5
5
|
"darwin-arm64": {
|
|
6
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
7
|
-
"sha256": "
|
|
6
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.13/mixdog-graph-darwin-arm64",
|
|
7
|
+
"sha256": "356ddd4c591ff96f6dea32af360798568db7cf474215692a218622e25276b923"
|
|
8
8
|
},
|
|
9
9
|
"darwin-x64": {
|
|
10
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
11
|
-
"sha256": "
|
|
10
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.13/mixdog-graph-darwin-x64",
|
|
11
|
+
"sha256": "54cb3aaaa3c0b0d8e5d5defc4c8f6f701a2e4e574095f5d377773692ce33fbeb"
|
|
12
12
|
},
|
|
13
13
|
"linux-arm64": {
|
|
14
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
15
|
-
"sha256": "
|
|
14
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.13/mixdog-graph-linux-arm64",
|
|
15
|
+
"sha256": "c14a2df85d881af3436dc56622fbd4a2078bd2e3d1a615a23332e92efefc7f0f"
|
|
16
16
|
},
|
|
17
17
|
"linux-x64": {
|
|
18
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
19
|
-
"sha256": "
|
|
18
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.13/mixdog-graph-linux-x64",
|
|
19
|
+
"sha256": "c616be0a16547ea9568b487d2ca0e6e4515c9a740f27b73ec09e6bc368461b3f"
|
|
20
20
|
},
|
|
21
21
|
"win32-x64": {
|
|
22
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
23
|
-
"sha256": "
|
|
22
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.13/mixdog-graph-win32-x64.exe",
|
|
23
|
+
"sha256": "132cab2c467800a87f0ac05cd5ef5023db8f3b0c12ad2047b5368ee49a8d1e60"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -320,10 +320,6 @@ export function completeBackgroundTask(taskId, {
|
|
|
320
320
|
const task = getBackgroundTask(taskId);
|
|
321
321
|
if (!task) return null;
|
|
322
322
|
if (TERMINAL_STATUSES.has(task.status)) return task;
|
|
323
|
-
if (task.progressCheckTimer) {
|
|
324
|
-
try { clearTimeout(task.progressCheckTimer); } catch {}
|
|
325
|
-
task.progressCheckTimer = null;
|
|
326
|
-
}
|
|
327
323
|
const now = Date.now();
|
|
328
324
|
task.status = normalizeStatus(status);
|
|
329
325
|
task.finishedAtMs = now;
|
|
@@ -5,7 +5,7 @@ export const TOOL_ASYNC_EXECUTION_CONTRACT =
|
|
|
5
5
|
'Runs sync inline by default; async returns a background task_id and delivers a completion notification.';
|
|
6
6
|
|
|
7
7
|
export const TOOL_MANUAL_CONTROL_CONTRACT =
|
|
8
|
-
'
|
|
8
|
+
'read returns one current output snapshot; cancel is for manual recovery, while normal completion arrives by notification.';
|
|
9
9
|
|
|
10
10
|
function clean(value) {
|
|
11
11
|
return String(value ?? '').trim();
|
|
@@ -82,6 +82,7 @@ export function createTagRegistry({
|
|
|
82
82
|
const add = (session, fallbackTag = '') => {
|
|
83
83
|
const tag = agentTagOf(session) || clean(fallbackTag);
|
|
84
84
|
if (!tag || !session?.id || session.closed === true) return;
|
|
85
|
+
if (isLeadPoolAgent(session.agent)) return;
|
|
85
86
|
if (!sessionMatchesContext(session, context)) return;
|
|
86
87
|
if (seen.has(session.id)) return;
|
|
87
88
|
seen.add(session.id);
|
|
@@ -158,6 +159,7 @@ export function createTagRegistry({
|
|
|
158
159
|
}
|
|
159
160
|
if (!scanSessions) return;
|
|
160
161
|
for (const session of mgr.listSessions({ includeClosed: false }) || []) {
|
|
162
|
+
if (isLeadPoolAgent(session?.agent)) continue;
|
|
161
163
|
const tag = agentTagOf(session);
|
|
162
164
|
if (!tag || tags.has(tag)) continue;
|
|
163
165
|
if (!sessionMatchesContext(session, context)) continue;
|
|
@@ -282,6 +282,17 @@ export function createWorkerIndex({ dataDir, cfgMod, mgr, tags, tagAgents, tagCw
|
|
|
282
282
|
const rows = readWorkerRows(context);
|
|
283
283
|
for (const row of rows) {
|
|
284
284
|
if (!row.tag || !row.sessionId) continue;
|
|
285
|
+
if (isLeadPoolAgent(row.agent)) {
|
|
286
|
+
// Lead pool rows are status projections, not agent-tool children.
|
|
287
|
+
// Purge a projection left by older code so closeAll cannot mistake
|
|
288
|
+
// the owning desktop session for a worker and close it.
|
|
289
|
+
if (tags.get(row.tag) === row.sessionId) {
|
|
290
|
+
tags.delete(row.tag);
|
|
291
|
+
tagAgents.delete(row.tag);
|
|
292
|
+
tagCwds.delete(row.tag);
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
285
296
|
tags.set(row.tag, row.sessionId);
|
|
286
297
|
if (row.agent) tagAgents.set(row.tag, row.agent);
|
|
287
298
|
if (row.cwd) tagCwds.set(row.tag, row.cwd);
|
|
@@ -450,6 +450,9 @@ export function createStandaloneAgent({
|
|
|
450
450
|
}
|
|
451
451
|
throw new Error(`agent close: target "${target}" not found`);
|
|
452
452
|
}
|
|
453
|
+
if (isLeadPoolAgent(getLiveSession(sessionId)?.agent)) {
|
|
454
|
+
throw new Error(`agent close: target "${target}" is a Lead session`);
|
|
455
|
+
}
|
|
453
456
|
cancelReap(sessionId);
|
|
454
457
|
const tag = tagForSession(sessionId);
|
|
455
458
|
forgetTerminalSession(tag, sessionId);
|