@lazyingart/agintiflow 0.20.118 → 0.20.120
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/smoke-cli-chat.js +90 -3
- package/scripts/smoke-coding-tools.js +8 -0
- package/scripts/smoke-toolchain-docker.js +19 -0
- package/src/agent-runner.js +127 -22
- package/src/auxiliary-tools.js +49 -9
- package/src/docker-sandbox.js +29 -2
- package/src/interactive-cli.js +157 -21
- package/src/model-client.js +1 -0
- package/src/tmux-tools.js +13 -9
- package/src/tool-wrappers.js +91 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazyingart/agintiflow",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.120",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
@@ -110,6 +110,88 @@ function runCli(args, inputText) {
|
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
function shellQuote(value = "") {
|
|
114
|
+
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tmux(args, options = {}) {
|
|
118
|
+
return execFileSync("tmux", args, {
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
timeout: options.timeout || 12000,
|
|
121
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tmuxCapture(session) {
|
|
126
|
+
try {
|
|
127
|
+
return tmux(["capture-pane", "-t", session, "-p", "-S", "-200"], { timeout: 5000 });
|
|
128
|
+
} catch {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function waitForTmuxText(session, pattern, timeoutMs = 12000) {
|
|
134
|
+
const started = Date.now();
|
|
135
|
+
while (Date.now() - started < timeoutMs) {
|
|
136
|
+
const pane = tmuxCapture(session);
|
|
137
|
+
if (pattern.test(pane)) return pane;
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
139
|
+
}
|
|
140
|
+
return tmuxCapture(session);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function runTmuxInterruptSmoke({ key, expected }) {
|
|
144
|
+
const session = `aginti-interrupt-${process.pid}-${key.toLowerCase().replace(/[^a-z0-9]+/g, "")}`;
|
|
145
|
+
const command = [
|
|
146
|
+
"env",
|
|
147
|
+
`AGINTIFLOW_HOME=${shellQuote(agintiflowHome)}`,
|
|
148
|
+
"AGINTIFLOW_RUNTIME_DIR=",
|
|
149
|
+
"AGINTI_LANGUAGE=en",
|
|
150
|
+
"AGINTI_INTERRUPT_FORCE_EXIT_MS=2500",
|
|
151
|
+
shellQuote(process.execPath),
|
|
152
|
+
shellQuote(binPath),
|
|
153
|
+
"chat",
|
|
154
|
+
"--provider",
|
|
155
|
+
"mock",
|
|
156
|
+
"--routing",
|
|
157
|
+
"manual",
|
|
158
|
+
"--profile",
|
|
159
|
+
"code",
|
|
160
|
+
"--allow-shell",
|
|
161
|
+
"-s",
|
|
162
|
+
"normal",
|
|
163
|
+
].join(" ");
|
|
164
|
+
const shellCommand = `cd ${shellQuote(tempRoot)} && ${command}`;
|
|
165
|
+
try {
|
|
166
|
+
tmux(["kill-session", "-t", session], { timeout: 2000 });
|
|
167
|
+
} catch {
|
|
168
|
+
// Session does not exist.
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
tmux(["new-session", "-d", "-s", session, "bash", "-lc", `${shellCommand}; printf '\\nEXIT:%s\\n' "$?"; sleep 3`]);
|
|
172
|
+
let pane = await waitForTmuxText(session, /user>/, 10000);
|
|
173
|
+
if (!/user>/.test(pane)) throw new Error(`interrupt smoke did not reach prompt before ${key}\n${pane}`);
|
|
174
|
+
tmux(["send-keys", "-t", session, "-l", "sleep interruption smoke"]);
|
|
175
|
+
tmux(["send-keys", "-t", session, "Enter"]);
|
|
176
|
+
pane = await waitForTmuxText(session, /run_command|model_wait|Docker:/, 12000);
|
|
177
|
+
if (!/run_command|model_wait|Docker:/.test(pane)) throw new Error(`interrupt smoke did not start a run before ${key}\n${pane}`);
|
|
178
|
+
tmux(["send-keys", "-t", session, key]);
|
|
179
|
+
pane = await waitForTmuxText(session, expected, 10000);
|
|
180
|
+
if (!expected.test(pane)) throw new Error(`interrupt smoke did not observe ${expected} after ${key}\n${pane}`);
|
|
181
|
+
if (key === "Escape" && !/EXIT:/.test(pane)) {
|
|
182
|
+
tmux(["send-keys", "-t", session, "-l", "/exit"]);
|
|
183
|
+
tmux(["send-keys", "-t", session, "Enter"]);
|
|
184
|
+
await waitForTmuxText(session, /EXIT:0/, 8000);
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
try {
|
|
188
|
+
tmux(["kill-session", "-t", session], { timeout: 2000 });
|
|
189
|
+
} catch {
|
|
190
|
+
// Session may already have exited.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
113
195
|
try {
|
|
114
196
|
const translatedHelpKeys = [
|
|
115
197
|
"helpHelp",
|
|
@@ -363,8 +445,8 @@ try {
|
|
|
363
445
|
if (classifyEscapeAction({ active: false }) !== "noop") {
|
|
364
446
|
throw new Error("idle Esc should not redraw or clear the prompt");
|
|
365
447
|
}
|
|
366
|
-
if (classifyEscapeAction({ active: true, pendingAsap: [{ content: "apply now" }] }) !== "
|
|
367
|
-
throw new Error("active Esc should
|
|
448
|
+
if (classifyEscapeAction({ active: true, pendingAsap: [{ content: "apply now" }] }) !== "abort") {
|
|
449
|
+
throw new Error("active Esc should always abort, even when ASAP pipe messages are pending");
|
|
368
450
|
}
|
|
369
451
|
if (classifyEscapeAction({ active: true, pendingAsap: [] }) !== "abort") {
|
|
370
452
|
throw new Error("active Esc should abort when no ASAP pipe messages are pending");
|
|
@@ -664,6 +746,9 @@ try {
|
|
|
664
746
|
throw new Error("resume history should render full saved messages instead of compact previews");
|
|
665
747
|
}
|
|
666
748
|
|
|
749
|
+
await runTmuxInterruptSmoke({ key: "Escape", expected: /Active run stopped|Session saved/ });
|
|
750
|
+
await runTmuxInterruptSmoke({ key: "C-c", expected: /EXIT:130|Session stopped by ctrl-c|Interrupted\. Session saved/ });
|
|
751
|
+
|
|
667
752
|
console.log(
|
|
668
753
|
JSON.stringify(
|
|
669
754
|
{
|
|
@@ -702,6 +787,8 @@ try {
|
|
|
702
787
|
"interactive-chat",
|
|
703
788
|
"mock-file-write",
|
|
704
789
|
"run-status",
|
|
790
|
+
"tmux-escape-active-run-stop",
|
|
791
|
+
"tmux-ctrl-c-session-stop",
|
|
705
792
|
"resume-latest",
|
|
706
793
|
"resume-history-metadata",
|
|
707
794
|
"resume-history-prompt-labels",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
runAgent,
|
|
10
10
|
sanitizeToolResult,
|
|
11
11
|
shouldShortCircuitToolBatch,
|
|
12
|
+
shellDiagnosticHint,
|
|
12
13
|
skippedAfterBlockedToolResult,
|
|
13
14
|
} from "../src/agent-runner.js";
|
|
14
15
|
import { formatBehaviorContractForPrompt } from "../src/behavior-contract.js";
|
|
@@ -410,6 +411,13 @@ try {
|
|
|
410
411
|
quotedDangerSearchPolicy.category !== "destructive",
|
|
411
412
|
"quoted destructive strings in grep pattern should not classify as a destructive command"
|
|
412
413
|
);
|
|
414
|
+
const grepCountHint = shellDiagnosticHint("grep -c 'Fatal\\|Emergency' article.log && echo done", {
|
|
415
|
+
ok: false,
|
|
416
|
+
exitCode: 1,
|
|
417
|
+
stdout: "0\n",
|
|
418
|
+
stderr: "",
|
|
419
|
+
});
|
|
420
|
+
assert(grepCountHint.includes("grep -c exits 1"), "failed grep-count validation should explain clean zero-match exit behavior");
|
|
413
421
|
const actualDangerAfterQuotePolicy = evaluateCommandPolicy('echo "rm -rf is text" && rm -rf reports', dockerWorkspacePolicy);
|
|
414
422
|
assert(!actualDangerAfterQuotePolicy.allowed, "actual destructive command after quoted text should still be blocked");
|
|
415
423
|
assert(actualDangerAfterQuotePolicy.category === "destructive", "actual destructive command after quoted text was not classified as destructive");
|
|
@@ -65,6 +65,25 @@ try {
|
|
|
65
65
|
const outsideRead = await runToolchainCommand(`cat ${path.join(outsideData, "source-note.txt")}`, config);
|
|
66
66
|
assert(outsideRead.stdout.includes("READ_ONLY_HOST_MOUNT_OK"), "Docker normal mode could not read the configured outside data root");
|
|
67
67
|
|
|
68
|
+
const abortController = new AbortController();
|
|
69
|
+
const abortCommand = `python3 -c 'import time; time.sleep(20)'`;
|
|
70
|
+
const abortPolicy = evaluateCommandPolicy(abortCommand, { ...config, packageInstallPolicy: "allow" });
|
|
71
|
+
assert(abortPolicy.allowed, "long-running Docker command should be allowed for interrupt smoke");
|
|
72
|
+
const abortStartedAt = Date.now();
|
|
73
|
+
const abortRun = runDockerSandboxCommand(abortCommand, { ...config, packageInstallPolicy: "allow" }, abortPolicy, {
|
|
74
|
+
signal: abortController.signal,
|
|
75
|
+
});
|
|
76
|
+
setTimeout(() => abortController.abort(new Error("smoke interrupt")), 500);
|
|
77
|
+
let interrupted = false;
|
|
78
|
+
try {
|
|
79
|
+
await abortRun;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
interrupted = /abort|interrupt|cancel/i.test(String(error?.name || "")) || /abort|interrupt|cancel/i.test(String(error?.message || ""));
|
|
82
|
+
}
|
|
83
|
+
const abortElapsed = Date.now() - abortStartedAt;
|
|
84
|
+
assert(interrupted, "Docker command did not reject as interrupted after AbortController abort");
|
|
85
|
+
assert(abortElapsed < 8000, `Docker interrupt took too long: ${abortElapsed}ms`);
|
|
86
|
+
|
|
68
87
|
await fs.writeFile(
|
|
69
88
|
path.join(workspace, "plot_fx.py"),
|
|
70
89
|
[
|
package/src/agent-runner.js
CHANGED
|
@@ -2,9 +2,8 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
7
|
import { chromium } from "playwright";
|
|
9
8
|
import { createClient, createPlan, requestNextStep } from "./model-client.js";
|
|
10
9
|
import { SessionStore } from "./session-store.js";
|
|
@@ -54,7 +53,6 @@ import {
|
|
|
54
53
|
serializeStepBudgetState,
|
|
55
54
|
} from "./step-budget-controller.js";
|
|
56
55
|
|
|
57
|
-
const exec = promisify(execCallback);
|
|
58
56
|
const BROWSER_TOOLS = new Set(["open_url", "open_workspace_file", "preview_workspace", "click", "type", "scroll", "press", "back"]);
|
|
59
57
|
const WORKSPACE_TOOLS = new Set(WORKSPACE_TOOL_NAMES);
|
|
60
58
|
const STATIC_PREVIEW_SERVER_PATH = fileURLToPath(new URL("./static-preview-server.js", import.meta.url));
|
|
@@ -585,6 +583,7 @@ async function createInitialState(config, sessionId) {
|
|
|
585
583
|
: "No shell command tool is available.",
|
|
586
584
|
`Permission contract: permission mode is ${config.permissionMode || "normal"}. Safe mode asks before workspace writes/setup. Normal mode allows current-project writes, read-only inspection of visible host paths, and approved Docker setup, but outside-workspace writes and host-system changes require approval. Danger mode is trusted host/full-access mode. Do not bypass blockers by retrying variants. If a tool result includes permissionAdvice or suggestedCommand, stop, explain the blocker, copy the exact suggestedCommand when giving a rerun path, and ask the user to approve/rerun that mode or choose a safer workspace-relative path. Never invent legacy AgInTi syntax such as \`aginti run --sandbox host\`; use the exact flags from permissionAdvice.`,
|
|
587
585
|
"If an operation fails but a directory, artifact, or file already exists, treat it as pre-existing unless you have evidence this run created or updated it. Verify expected outputs before claiming success.",
|
|
586
|
+
"For validation/evidence commands, remember that grep exits 1 on zero matches. If zero matches is the expected clean result, use `grep -c PATTERN file || true`, split evidence checks into independent commands, or use awk/python so a clean zero count does not stop an `&&` chain.",
|
|
588
587
|
config.allowShellTool
|
|
589
588
|
? "Host tmux tools are available for long-running terminals: list sessions, capture panes, send safe keys/text, and start detached sessions. Prefer these tools for monitoring long installs/tests/dev servers without blocking; capture before sending input and never send secrets or sudo passwords. Do not start or install tmux inside Docker run_command containers because those containers are short-lived. In Docker sandbox mode, tmux start/send commands must stay workspace-write-bound; prefer run_command for read-only host absolute path inspection through read-only mounts, and ask for --sandbox-mode host for trusted whole-host write/system work."
|
|
590
589
|
+ " For one-shot tmux commands, redirect stdout/stderr and exit status to a durable workspace log or keep the pane alive for capture; if capture fails because the session ended, do not infer output or exit status."
|
|
@@ -1006,6 +1005,7 @@ async function applyContinuationPrompt(state, config, observers) {
|
|
|
1006
1005
|
temporalContext,
|
|
1007
1006
|
config.startUrl ? `Suggested start URL: ${config.startUrl}` : "",
|
|
1008
1007
|
config.allowedDomains.length > 0 ? `Allowed domains: ${config.allowedDomains.join(", ")}` : "",
|
|
1008
|
+
"Validation reminder: grep exits 1 on zero matches. For clean-zero checks, guard `grep -c` with `|| true` or split evidence commands so the validation can continue.",
|
|
1009
1009
|
config.allowShellTool
|
|
1010
1010
|
? config.useDockerSandbox
|
|
1011
1011
|
? `Shell working directory mounted into Docker as /workspace from ${config.commandCwd}. Use relative paths or /workspace paths for outputs/writes; common host data roots are read-only at original absolute paths for inspection. Persistent Docker env: /aginti-env, caches: /aginti-cache. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}.`
|
|
@@ -1076,6 +1076,112 @@ function safeExecutionEnv() {
|
|
|
1076
1076
|
};
|
|
1077
1077
|
}
|
|
1078
1078
|
|
|
1079
|
+
function trimOutput(value = "", limit = 8000) {
|
|
1080
|
+
const text = redactSensitiveText(String(value || ""));
|
|
1081
|
+
return text.trim().slice(0, limit);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function killChildTree(child, signal = "SIGTERM") {
|
|
1085
|
+
if (!child || child.killed) return;
|
|
1086
|
+
try {
|
|
1087
|
+
if (process.platform === "win32") child.kill(signal);
|
|
1088
|
+
else process.kill(-child.pid, signal);
|
|
1089
|
+
} catch {
|
|
1090
|
+
try {
|
|
1091
|
+
child.kill(signal);
|
|
1092
|
+
} catch {
|
|
1093
|
+
// Best effort; process may already be gone.
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function runHostShellCommand(command, config) {
|
|
1099
|
+
return new Promise((resolve, reject) => {
|
|
1100
|
+
const shell = hostShellOption();
|
|
1101
|
+
const child = spawn(String(command || ""), {
|
|
1102
|
+
cwd: config.commandCwd,
|
|
1103
|
+
env: safeExecutionEnv(),
|
|
1104
|
+
shell,
|
|
1105
|
+
detached: process.platform !== "win32",
|
|
1106
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1107
|
+
});
|
|
1108
|
+
let stdout = "";
|
|
1109
|
+
let stderr = "";
|
|
1110
|
+
let settled = false;
|
|
1111
|
+
let timedOut = false;
|
|
1112
|
+
const timeoutMs = Number(config.shellTimeoutMs || process.env.AGINTI_SHELL_TIMEOUT_MS || 30000);
|
|
1113
|
+
const maxStdout = 220 * 1024;
|
|
1114
|
+
const maxStderr = 120 * 1024;
|
|
1115
|
+
|
|
1116
|
+
const settle = (callback) => {
|
|
1117
|
+
if (settled) return;
|
|
1118
|
+
settled = true;
|
|
1119
|
+
if (timer) clearTimeout(timer);
|
|
1120
|
+
if (config.abortSignal && onAbort) config.abortSignal.removeEventListener("abort", onAbort);
|
|
1121
|
+
callback();
|
|
1122
|
+
};
|
|
1123
|
+
const onAbort = () => {
|
|
1124
|
+
killChildTree(child, "SIGTERM");
|
|
1125
|
+
setTimeout(() => killChildTree(child, "SIGKILL"), 1200).unref?.();
|
|
1126
|
+
const error = new Error("Run interrupted by user.");
|
|
1127
|
+
error.name = "AbortError";
|
|
1128
|
+
error.code = "ABORT_ERR";
|
|
1129
|
+
settle(() => reject(error));
|
|
1130
|
+
};
|
|
1131
|
+
const timer =
|
|
1132
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
1133
|
+
? setTimeout(() => {
|
|
1134
|
+
timedOut = true;
|
|
1135
|
+
killChildTree(child, "SIGTERM");
|
|
1136
|
+
setTimeout(() => killChildTree(child, "SIGKILL"), 1200).unref?.();
|
|
1137
|
+
}, timeoutMs)
|
|
1138
|
+
: null;
|
|
1139
|
+
timer?.unref?.();
|
|
1140
|
+
|
|
1141
|
+
if (config.abortSignal?.aborted) return onAbort();
|
|
1142
|
+
if (config.abortSignal) config.abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
1143
|
+
|
|
1144
|
+
child.stdout?.on("data", (chunk) => {
|
|
1145
|
+
if (stdout.length < maxStdout) stdout += chunk.toString();
|
|
1146
|
+
});
|
|
1147
|
+
child.stderr?.on("data", (chunk) => {
|
|
1148
|
+
if (stderr.length < maxStderr) stderr += chunk.toString();
|
|
1149
|
+
});
|
|
1150
|
+
child.on("error", (error) => {
|
|
1151
|
+
settle(() =>
|
|
1152
|
+
resolve({
|
|
1153
|
+
ok: false,
|
|
1154
|
+
exitCode: 1,
|
|
1155
|
+
stdout,
|
|
1156
|
+
stderr: `${stderr}${stderr ? "\n" : ""}${error instanceof Error ? error.message : String(error)}`,
|
|
1157
|
+
})
|
|
1158
|
+
);
|
|
1159
|
+
});
|
|
1160
|
+
child.on("close", (code, signalName) => {
|
|
1161
|
+
settle(() =>
|
|
1162
|
+
resolve({
|
|
1163
|
+
ok: !timedOut && Number(code || 0) === 0,
|
|
1164
|
+
exitCode: timedOut ? 124 : Number.isInteger(code) ? code : signalName ? 130 : 1,
|
|
1165
|
+
stdout,
|
|
1166
|
+
stderr: `${stderr}${timedOut ? `\nCommand timed out after ${timeoutMs}ms.` : ""}`,
|
|
1167
|
+
})
|
|
1168
|
+
);
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export function shellDiagnosticHint(command = "", result = {}) {
|
|
1174
|
+
if (result?.ok !== false) return "";
|
|
1175
|
+
const text = String(command || "");
|
|
1176
|
+
if (/\bgrep\b[\s\S]*\s-c\b|\bgrep\s+-c\b/.test(text)) {
|
|
1177
|
+
return "grep -c exits 1 when it finds zero matches even though it prints 0. For count-only validation, use `grep -c PATTERN file || true`, split evidence commands, or use awk/python when zero matches is the expected clean state.";
|
|
1178
|
+
}
|
|
1179
|
+
if (/\bgrep\b/.test(text) && /&&/.test(text)) {
|
|
1180
|
+
return "grep exits 1 when it finds no matches, so an `&&` evidence chain can stop early. If no matches is acceptable, guard that grep with `|| true` or run independent validation commands.";
|
|
1181
|
+
}
|
|
1182
|
+
return "";
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1079
1185
|
function hashForLog(value) {
|
|
1080
1186
|
return crypto.createHash("sha256").update(String(value ?? "")).digest("hex");
|
|
1081
1187
|
}
|
|
@@ -1261,29 +1367,26 @@ async function runShellCommand(command, config, policy = evaluateCommandPolicy(c
|
|
|
1261
1367
|
throwIfAborted(config);
|
|
1262
1368
|
const result = config.useDockerSandbox
|
|
1263
1369
|
? await runDockerSandboxCommand(command, config, policy, { signal: config.abortSignal })
|
|
1264
|
-
: await
|
|
1265
|
-
|
|
1266
|
-
timeout: 30000,
|
|
1267
|
-
maxBuffer: 200 * 1024,
|
|
1268
|
-
shell: hostShellOption(),
|
|
1269
|
-
env: safeExecutionEnv(),
|
|
1270
|
-
signal: config.abortSignal,
|
|
1271
|
-
});
|
|
1370
|
+
: await runHostShellCommand(command, config);
|
|
1371
|
+
const diagnosticHint = shellDiagnosticHint(command, result);
|
|
1272
1372
|
|
|
1273
1373
|
return {
|
|
1274
|
-
ok:
|
|
1275
|
-
exitCode: 0,
|
|
1276
|
-
stdout:
|
|
1277
|
-
stderr:
|
|
1374
|
+
ok: result.ok !== false,
|
|
1375
|
+
exitCode: Number.isInteger(result.exitCode) ? result.exitCode : 0,
|
|
1376
|
+
stdout: trimOutput(result.stdout, 8000),
|
|
1377
|
+
stderr: trimOutput(result.stderr, 4000),
|
|
1378
|
+
...(diagnosticHint ? { diagnosticHint } : {}),
|
|
1278
1379
|
};
|
|
1279
1380
|
} catch (error) {
|
|
1280
1381
|
if (isAbortError(error, config)) throw error;
|
|
1281
|
-
|
|
1382
|
+
const failedResult = {
|
|
1282
1383
|
ok: false,
|
|
1283
1384
|
exitCode: Number.isInteger(error?.code) ? error.code : 1,
|
|
1284
|
-
stdout:
|
|
1285
|
-
stderr:
|
|
1385
|
+
stdout: trimOutput(error?.stdout || "", 8000),
|
|
1386
|
+
stderr: trimOutput(error?.stderr || error?.message || "", 4000),
|
|
1286
1387
|
};
|
|
1388
|
+
const diagnosticHint = shellDiagnosticHint(command, failedResult);
|
|
1389
|
+
return diagnosticHint ? { ...failedResult, diagnosticHint } : failedResult;
|
|
1287
1390
|
}
|
|
1288
1391
|
}
|
|
1289
1392
|
|
|
@@ -1295,6 +1398,7 @@ async function captureSyntheticSnapshot(store, step, config) {
|
|
|
1295
1398
|
pageText: [
|
|
1296
1399
|
"No browser page is currently open.",
|
|
1297
1400
|
config.startUrl ? `Suggested start URL: ${config.startUrl}` : "",
|
|
1401
|
+
"Validation reminder: grep exits 1 on zero matches; guard expected clean-zero grep checks or split evidence commands.",
|
|
1298
1402
|
config.allowShellTool
|
|
1299
1403
|
? config.useDockerSandbox
|
|
1300
1404
|
? `Shell tool available in Docker with mounted workspace /workspace from ${config.commandCwd}. Use relative paths or /workspace paths for outputs/writes; common host data roots are read-only at original absolute paths for inspection. Persistent Docker env: /aginti-env, caches: /aginti-cache. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}.`
|
|
@@ -1557,9 +1661,9 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
|
|
|
1557
1661
|
break;
|
|
1558
1662
|
case "wait":
|
|
1559
1663
|
if (browserState.page) {
|
|
1560
|
-
await browserState.page.waitForTimeout(Number.isFinite(args.ms) ? Number(args.ms) : 1000);
|
|
1664
|
+
await abortable(browserState.page.waitForTimeout(Number.isFinite(args.ms) ? Number(args.ms) : 1000), config.abortSignal);
|
|
1561
1665
|
} else {
|
|
1562
|
-
await new Promise((resolve) => setTimeout(resolve, Number.isFinite(args.ms) ? Number(args.ms) : 1000));
|
|
1666
|
+
await abortable(new Promise((resolve) => setTimeout(resolve, Number.isFinite(args.ms) ? Number(args.ms) : 1000)), config.abortSignal);
|
|
1563
1667
|
}
|
|
1564
1668
|
break;
|
|
1565
1669
|
case "inspect_project":
|
|
@@ -1645,14 +1749,14 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
|
|
|
1645
1749
|
return result;
|
|
1646
1750
|
}
|
|
1647
1751
|
case "tmux_list_sessions": {
|
|
1648
|
-
const result = await listTmuxSessions(args);
|
|
1752
|
+
const result = await listTmuxSessions(args, config);
|
|
1649
1753
|
const eventResult = sanitizeToolResult(result);
|
|
1650
1754
|
await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
1651
1755
|
observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
1652
1756
|
return result;
|
|
1653
1757
|
}
|
|
1654
1758
|
case "tmux_capture_pane": {
|
|
1655
|
-
const result = await captureTmuxPane(args);
|
|
1759
|
+
const result = await captureTmuxPane(args, config);
|
|
1656
1760
|
const eventResult = sanitizeToolResult(result);
|
|
1657
1761
|
await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
1658
1762
|
observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
@@ -2377,6 +2481,7 @@ export async function runAgent(config) {
|
|
|
2377
2481
|
command: redactSensitiveText(toolResult.args?.command || ""),
|
|
2378
2482
|
stdout: toolResult.stdout || "",
|
|
2379
2483
|
stderr: toolResult.stderr || "",
|
|
2484
|
+
diagnosticHint: toolResult.diagnosticHint || "",
|
|
2380
2485
|
commandPolicy: toolResult.commandPolicy,
|
|
2381
2486
|
blocked: Boolean(toolResult.blocked),
|
|
2382
2487
|
error: toolResult.error || toolResult.reason || "",
|
package/src/auxiliary-tools.js
CHANGED
|
@@ -164,11 +164,43 @@ async function writeJson(filePath, payload) {
|
|
|
164
164
|
await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
function throwIfAborted(signal) {
|
|
168
|
+
if (!signal?.aborted) return;
|
|
169
|
+
const error = signal.reason instanceof Error ? signal.reason : new Error("Operation interrupted by user.");
|
|
170
|
+
error.name = error.name || "AbortError";
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sleepAbortable(ms, signal) {
|
|
175
|
+
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
|
176
|
+
throwIfAborted(signal);
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const onAbort = () => {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("Operation interrupted by user."));
|
|
181
|
+
};
|
|
182
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
183
|
+
const timer = setTimeout(() => {
|
|
184
|
+
cleanup();
|
|
185
|
+
resolve();
|
|
186
|
+
}, ms);
|
|
187
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function requestJson(url, payload, apiKey, { timeoutMs = 300000, retries = 2, signal = null } = {}) {
|
|
168
192
|
let lastError = null;
|
|
169
193
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
194
|
+
throwIfAborted(signal);
|
|
170
195
|
const controller = new AbortController();
|
|
171
|
-
const
|
|
196
|
+
const onAbort = () => controller.abort(signal.reason || new Error("Operation interrupted by user."));
|
|
197
|
+
let timedOut = false;
|
|
198
|
+
const timer = setTimeout(() => {
|
|
199
|
+
timedOut = true;
|
|
200
|
+
controller.abort();
|
|
201
|
+
}, timeoutMs);
|
|
202
|
+
if (signal?.aborted) onAbort();
|
|
203
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
172
204
|
try {
|
|
173
205
|
const response = await fetch(url, {
|
|
174
206
|
method: "POST",
|
|
@@ -187,25 +219,29 @@ async function requestJson(url, payload, apiKey, { timeoutMs = 300000, retries =
|
|
|
187
219
|
}
|
|
188
220
|
return parseJsonResponse(text);
|
|
189
221
|
} catch (error) {
|
|
222
|
+
if (signal?.aborted || error?.code === "ABORT_ERR" || (error?.name === "AbortError" && !timedOut)) throw error;
|
|
190
223
|
lastError = error;
|
|
191
224
|
const retryable = TRANSIENT_HTTP_CODES.has(Number(error?.status)) || /aborted|timeout|fetch failed/i.test(String(error?.message || ""));
|
|
192
225
|
if (!retryable || attempt >= retries) throw error;
|
|
193
|
-
await
|
|
226
|
+
await sleepAbortable(2000 + attempt * 2000, signal);
|
|
194
227
|
} finally {
|
|
195
228
|
clearTimeout(timer);
|
|
229
|
+
signal?.removeEventListener("abort", onAbort);
|
|
196
230
|
}
|
|
197
231
|
}
|
|
198
232
|
throw lastError || new Error("Image API request failed.");
|
|
199
233
|
}
|
|
200
234
|
|
|
201
|
-
async function pollResult({ host, taskId, apiKey, outputDir, intervalMs = 5000, timeoutMs = 900000, requestTimeoutMs = 300000 }) {
|
|
235
|
+
async function pollResult({ host, taskId, apiKey, outputDir, intervalMs = 5000, timeoutMs = 900000, requestTimeoutMs = 300000, signal = null }) {
|
|
202
236
|
const started = Date.now();
|
|
203
237
|
const pollUrl = `${host.replace(/\/+$/, "")}/v1/draw/result`;
|
|
204
238
|
let attempt = 0;
|
|
205
239
|
while (true) {
|
|
240
|
+
throwIfAborted(signal);
|
|
206
241
|
const result = await requestJson(pollUrl, { id: taskId }, apiKey, {
|
|
207
242
|
timeoutMs: requestTimeoutMs,
|
|
208
243
|
retries: 4,
|
|
244
|
+
signal,
|
|
209
245
|
});
|
|
210
246
|
await writeJson(path.join(outputDir, "result_response.json"), result);
|
|
211
247
|
|
|
@@ -214,15 +250,16 @@ async function pollResult({ host, taskId, apiKey, outputDir, intervalMs = 5000,
|
|
|
214
250
|
if (status === "succeeded" || status === "failed") return result;
|
|
215
251
|
if (Date.now() - started > timeoutMs) throw new Error(`Image generation polling timed out for task ${taskId}.`);
|
|
216
252
|
attempt += 1;
|
|
217
|
-
await
|
|
253
|
+
await sleepAbortable(Math.min(intervalMs + attempt * 250, 12000), signal);
|
|
218
254
|
}
|
|
219
255
|
}
|
|
220
256
|
|
|
221
|
-
async function downloadImage(url, destination) {
|
|
257
|
+
async function downloadImage(url, destination, signal = null) {
|
|
222
258
|
const response = await fetch(url, {
|
|
223
259
|
headers: {
|
|
224
260
|
"User-Agent": "AgInTiFlow/1.0",
|
|
225
261
|
},
|
|
262
|
+
signal,
|
|
226
263
|
});
|
|
227
264
|
if (!response.ok) throw new Error(`Image download failed with HTTP ${response.status}.`);
|
|
228
265
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
@@ -268,7 +305,7 @@ function veniceBaseUrl(value = "") {
|
|
|
268
305
|
.replace(/\/+$/, "");
|
|
269
306
|
}
|
|
270
307
|
|
|
271
|
-
async function generateVeniceImages({ prompt, args, target, outputStem, manifest, manifestPath }) {
|
|
308
|
+
async function generateVeniceImages({ prompt, args, target, outputStem, manifest, manifestPath, signal = null }) {
|
|
272
309
|
const apiKey = veniceKey();
|
|
273
310
|
if (!apiKey) {
|
|
274
311
|
throw new Error("Missing VENICE_API_KEY. Run `aginti login venice` or `aginti keys set venice --stdin`.");
|
|
@@ -295,6 +332,7 @@ async function generateVeniceImages({ prompt, args, target, outputStem, manifest
|
|
|
295
332
|
const resultPayload = await requestJson(`${base}/image/generate`, payload, apiKey, {
|
|
296
333
|
timeoutMs: Number(args.requestTimeoutMs) || 300000,
|
|
297
334
|
retries: 2,
|
|
335
|
+
signal,
|
|
298
336
|
});
|
|
299
337
|
await writeJson(path.join(target.absolutePath, "venice_result_response.json"), {
|
|
300
338
|
id: resultPayload.id || "",
|
|
@@ -419,7 +457,7 @@ export async function generateImage(args = {}, config = {}) {
|
|
|
419
457
|
}
|
|
420
458
|
|
|
421
459
|
if (provider === "venice") {
|
|
422
|
-
return generateVeniceImages({ prompt, args, target, outputStem, manifest, manifestPath });
|
|
460
|
+
return generateVeniceImages({ prompt, args, target, outputStem, manifest, manifestPath, signal: config.abortSignal });
|
|
423
461
|
}
|
|
424
462
|
|
|
425
463
|
const apiKey = grsaiKey();
|
|
@@ -431,6 +469,7 @@ export async function generateImage(args = {}, config = {}) {
|
|
|
431
469
|
const submitPayload = await requestJson(submitUrl, payload, apiKey, {
|
|
432
470
|
timeoutMs: Number(args.requestTimeoutMs) || 300000,
|
|
433
471
|
retries: 2,
|
|
472
|
+
signal: config.abortSignal,
|
|
434
473
|
});
|
|
435
474
|
await writeJson(path.join(target.absolutePath, "submit_response.json"), submitPayload);
|
|
436
475
|
const taskId = submitPayload?.data?.id || submitPayload?.id;
|
|
@@ -449,6 +488,7 @@ export async function generateImage(args = {}, config = {}) {
|
|
|
449
488
|
intervalMs: Number(args.pollIntervalMs) || 5000,
|
|
450
489
|
timeoutMs: Number(args.pollTimeoutMs) || 900000,
|
|
451
490
|
requestTimeoutMs: Number(args.requestTimeoutMs) || 300000,
|
|
491
|
+
signal: config.abortSignal,
|
|
452
492
|
});
|
|
453
493
|
|
|
454
494
|
const status = resultPayload.status || resultPayload?.data?.status;
|
|
@@ -468,7 +508,7 @@ export async function generateImage(args = {}, config = {}) {
|
|
|
468
508
|
const suffix = path.extname(parsed.pathname) || ".png";
|
|
469
509
|
const filename = urls.length === 1 ? `${outputStem}${suffix}` : `${outputStem}_${String(index + 1).padStart(2, "0")}${suffix}`;
|
|
470
510
|
const absolutePath = path.join(target.absolutePath, filename);
|
|
471
|
-
const info = await downloadImage(urls[index], absolutePath);
|
|
511
|
+
const info = await downloadImage(urls[index], absolutePath, config.abortSignal);
|
|
472
512
|
const relativePath = path.posix.join(target.relativePath, filename);
|
|
473
513
|
imagePaths.push(relativePath);
|
|
474
514
|
downloads.push({ path: relativePath, ...info });
|
package/src/docker-sandbox.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fsSync, { constants as fsConstants } from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
@@ -63,6 +64,23 @@ async function execDocker(args, options = {}) {
|
|
|
63
64
|
signal: options.signal,
|
|
64
65
|
};
|
|
65
66
|
recordSandboxLog("docker.command", { args });
|
|
67
|
+
const containerName = options.containerName || "";
|
|
68
|
+
let abortHandler = null;
|
|
69
|
+
if (options.signal && containerName) {
|
|
70
|
+
abortHandler = () => {
|
|
71
|
+
void execFile("docker", ["kill", containerName], {
|
|
72
|
+
timeout: 8000,
|
|
73
|
+
maxBuffer: 32 * 1024,
|
|
74
|
+
}).catch((error) => {
|
|
75
|
+
recordSandboxLog("docker.kill.failed", {
|
|
76
|
+
containerName,
|
|
77
|
+
error: error instanceof Error ? error.message : String(error),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
if (options.signal.aborted) abortHandler();
|
|
82
|
+
else options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
83
|
+
}
|
|
66
84
|
|
|
67
85
|
try {
|
|
68
86
|
const result = await execFile("docker", args, execOptions);
|
|
@@ -81,6 +99,8 @@ async function execDocker(args, options = {}) {
|
|
|
81
99
|
stdout: redactSensitiveText(result.stdout),
|
|
82
100
|
stderr: redactSensitiveText(result.stderr),
|
|
83
101
|
};
|
|
102
|
+
} finally {
|
|
103
|
+
if (options.signal && abortHandler) options.signal.removeEventListener("abort", abortHandler);
|
|
84
104
|
}
|
|
85
105
|
}
|
|
86
106
|
|
|
@@ -282,7 +302,11 @@ function dockerCommand(command, policy) {
|
|
|
282
302
|
return [...envLines, dockerUserCommand(command, policy)].join("\n");
|
|
283
303
|
}
|
|
284
304
|
|
|
285
|
-
function
|
|
305
|
+
function dockerContainerName() {
|
|
306
|
+
return `aginti-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`.slice(0, 63);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command, config), persistentDirs = persistentDockerDirs(config), options = {}) {
|
|
286
310
|
const uid = typeof process.getuid === "function" ? String(process.getuid()) : "";
|
|
287
311
|
const gid = typeof process.getgid === "function" ? String(process.getgid()) : "";
|
|
288
312
|
const userArgs = uid && gid && !policy.requiresDockerRoot ? ["--user", `${uid}:${gid}`] : [];
|
|
@@ -299,6 +323,7 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
|
|
|
299
323
|
return [
|
|
300
324
|
"run",
|
|
301
325
|
"--rm",
|
|
326
|
+
...(options.containerName ? ["--name", options.containerName] : []),
|
|
302
327
|
"--network",
|
|
303
328
|
networkMode,
|
|
304
329
|
"--cap-drop",
|
|
@@ -337,10 +362,12 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
|
|
|
337
362
|
|
|
338
363
|
export async function runDockerSandboxCommand(command, config, policy = evaluateCommandPolicy(command, config), options = {}) {
|
|
339
364
|
const persistentDirs = await ensurePersistentDockerDirs(config);
|
|
340
|
-
const
|
|
365
|
+
const containerName = dockerContainerName();
|
|
366
|
+
const result = await execDocker(dockerRunArgs(command, config, policy, persistentDirs, { containerName }), {
|
|
341
367
|
timeout: dockerExecTimeoutMs(policy),
|
|
342
368
|
maxBuffer: 300 * 1024,
|
|
343
369
|
signal: options.signal,
|
|
370
|
+
containerName,
|
|
344
371
|
});
|
|
345
372
|
|
|
346
373
|
const payload = {
|
package/src/interactive-cli.js
CHANGED
|
@@ -210,6 +210,10 @@ export class ComposerHistory {
|
|
|
210
210
|
|
|
211
211
|
const promptHistory = new ComposerHistory();
|
|
212
212
|
let activeRunInput = null;
|
|
213
|
+
let activeRunController = null;
|
|
214
|
+
let activeRunExitAfterStop = false;
|
|
215
|
+
let activeRunAbortSource = "";
|
|
216
|
+
let activeRunForceExitTimer = null;
|
|
213
217
|
let cliLanguage = resolveLanguage();
|
|
214
218
|
|
|
215
219
|
function setCliLanguage(language = "") {
|
|
@@ -230,6 +234,24 @@ function sleep(ms) {
|
|
|
230
234
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
function interruptForceExitMs() {
|
|
238
|
+
const value = Number(process.env.AGINTI_INTERRUPT_FORCE_EXIT_MS || 8000);
|
|
239
|
+
return Number.isFinite(value) && value > 0 ? value : 8000;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function restoreTerminalForExit() {
|
|
243
|
+
try {
|
|
244
|
+
if (typeof input.setRawMode === "function" && input.isRaw) input.setRawMode(false);
|
|
245
|
+
} catch {
|
|
246
|
+
// Terminal may already be closed.
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
output.write(ansi.cursorShow);
|
|
250
|
+
} catch {
|
|
251
|
+
// Ignore closed stdout.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
233
255
|
const ROLE_LABEL_WIDTH = "aginti".length;
|
|
234
256
|
const PROMPT_LABEL_WIDTH = "aginti>".length;
|
|
235
257
|
|
|
@@ -720,6 +742,9 @@ function printCommandOutputLog(data = {}) {
|
|
|
720
742
|
if (advice.suggestedCommand) outputLine(`${color(" | ", ansi.red)} rerun: ${compactLine(advice.suggestedCommand, 120)}`);
|
|
721
743
|
if (advice.trustedHostCommand) outputLine(`${color(" | ", ansi.red)} host: ${compactLine(advice.trustedHostCommand, 120)}`);
|
|
722
744
|
}
|
|
745
|
+
if (data.diagnosticHint) {
|
|
746
|
+
outputLine(`${label("hint", ansi.yellow)} ${compactLine(data.diagnosticHint, 112)}`);
|
|
747
|
+
}
|
|
723
748
|
}
|
|
724
749
|
|
|
725
750
|
function printHeading(text) {
|
|
@@ -1170,16 +1195,83 @@ function removeAt(buffer, cursor) {
|
|
|
1170
1195
|
};
|
|
1171
1196
|
}
|
|
1172
1197
|
|
|
1173
|
-
function createAbortError(message = "Aborted with Ctrl+C") {
|
|
1198
|
+
function createAbortError(message = "Aborted with Ctrl+C", options = {}) {
|
|
1174
1199
|
const error = new Error(message);
|
|
1175
1200
|
error.code = "ABORT_ERR";
|
|
1176
1201
|
error.name = "AbortError";
|
|
1202
|
+
error.source = options.source || "";
|
|
1203
|
+
error.exitSession = Boolean(options.exitSession);
|
|
1177
1204
|
return error;
|
|
1178
1205
|
}
|
|
1179
1206
|
|
|
1180
1207
|
export function classifyEscapeAction({ active = false, pendingAsap = [] } = {}) {
|
|
1181
1208
|
if (!active) return "noop";
|
|
1182
|
-
return
|
|
1209
|
+
return "abort";
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function scheduleRunForceExit(reason = "interrupt", forceExitMs = interruptForceExitMs()) {
|
|
1213
|
+
if (activeRunForceExitTimer || !Number.isFinite(forceExitMs) || forceExitMs <= 0) return;
|
|
1214
|
+
activeRunForceExitTimer = setTimeout(() => {
|
|
1215
|
+
restoreTerminalForExit();
|
|
1216
|
+
printSystemLine(`status=force_exit reason=${reason}`);
|
|
1217
|
+
process.exit(130);
|
|
1218
|
+
}, forceExitMs);
|
|
1219
|
+
activeRunForceExitTimer.unref?.();
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function clearRunForceExit() {
|
|
1223
|
+
if (!activeRunForceExitTimer) return;
|
|
1224
|
+
clearTimeout(activeRunForceExitTimer);
|
|
1225
|
+
activeRunForceExitTimer = null;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function requestActiveRunStop({ source = "escape", exitSession = false, forceExitMs = interruptForceExitMs() } = {}) {
|
|
1229
|
+
if (!activeRunController) return false;
|
|
1230
|
+
activeRunExitAfterStop = activeRunExitAfterStop || Boolean(exitSession);
|
|
1231
|
+
activeRunAbortSource = activeRunAbortSource || source;
|
|
1232
|
+
const reason = source === "ctrl-c" ? "ctrl-c" : source === "sigterm" ? "sigterm" : "escape";
|
|
1233
|
+
const message =
|
|
1234
|
+
exitSession || source === "ctrl-c"
|
|
1235
|
+
? "Interrupted by ctrl-c; stopping the session."
|
|
1236
|
+
: `Interrupted by ${reason}; stopping the active run.`;
|
|
1237
|
+
if (!activeRunController.signal.aborted) {
|
|
1238
|
+
printStatusEvent({ status: "stopping" }, "interrupt", `${reason}; hard stop in ${Math.ceil(forceExitMs / 1000)}s`);
|
|
1239
|
+
activeRunController.abort(createAbortError(message, { source, exitSession }));
|
|
1240
|
+
} else if (exitSession) {
|
|
1241
|
+
scheduleRunForceExit("second-interrupt", 100);
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
scheduleRunForceExit(reason, forceExitMs);
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function clearActiveRunControl(controller) {
|
|
1249
|
+
if (activeRunController !== controller) return;
|
|
1250
|
+
activeRunController = null;
|
|
1251
|
+
activeRunAbortSource = "";
|
|
1252
|
+
activeRunExitAfterStop = false;
|
|
1253
|
+
clearRunForceExit();
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function installProcessInterruptHandlers() {
|
|
1257
|
+
const onSigint = () => {
|
|
1258
|
+
if (requestActiveRunStop({ source: "ctrl-c", exitSession: true, forceExitMs: interruptForceExitMs() })) return;
|
|
1259
|
+
restoreTerminalForExit();
|
|
1260
|
+
printSystemLine("status=exit reason=ctrl-c");
|
|
1261
|
+
process.exit(130);
|
|
1262
|
+
};
|
|
1263
|
+
const onSigterm = () => {
|
|
1264
|
+
if (requestActiveRunStop({ source: "sigterm", exitSession: true, forceExitMs: 1000 })) return;
|
|
1265
|
+
restoreTerminalForExit();
|
|
1266
|
+
printSystemLine("status=exit reason=sigterm");
|
|
1267
|
+
process.exit(143);
|
|
1268
|
+
};
|
|
1269
|
+
process.on("SIGINT", onSigint);
|
|
1270
|
+
process.on("SIGTERM", onSigterm);
|
|
1271
|
+
return () => {
|
|
1272
|
+
process.off("SIGINT", onSigint);
|
|
1273
|
+
process.off("SIGTERM", onSigterm);
|
|
1274
|
+
};
|
|
1183
1275
|
}
|
|
1184
1276
|
|
|
1185
1277
|
export function activeRunSlashCommandAction(value = "") {
|
|
@@ -1304,7 +1396,7 @@ function readTtyPrompt(options = {}) {
|
|
|
1304
1396
|
if (key.ctrl && key.name === "c") {
|
|
1305
1397
|
clearPromptPanel();
|
|
1306
1398
|
cleanup();
|
|
1307
|
-
reject(createAbortError());
|
|
1399
|
+
reject(createAbortError("Interrupted by ctrl-c.", { source: "ctrl-c", exitSession: true }));
|
|
1308
1400
|
return;
|
|
1309
1401
|
}
|
|
1310
1402
|
if ((key.ctrl && key.name === "j") || key.sequence === "\n") {
|
|
@@ -1675,18 +1767,14 @@ class LiveRunInput {
|
|
|
1675
1767
|
|
|
1676
1768
|
handleKey(str = "", key = {}) {
|
|
1677
1769
|
if (key.ctrl && key.name === "c") {
|
|
1678
|
-
this.
|
|
1770
|
+
this.setStatus("stopping · ctrl-c");
|
|
1771
|
+
requestActiveRunStop({ source: "ctrl-c", exitSession: true });
|
|
1679
1772
|
return;
|
|
1680
1773
|
}
|
|
1681
1774
|
if (key.name === "escape") {
|
|
1682
1775
|
const action = classifyEscapeAction({ active: true, pendingAsap: this.pendingAsap });
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
this.setStatus(`running · waiting to apply ${count} asap pipe message${count === 1 ? "" : "s"}`);
|
|
1686
|
-
this.redraw();
|
|
1687
|
-
return;
|
|
1688
|
-
}
|
|
1689
|
-
this.controller.abort(new Error("Interrupted by escape."));
|
|
1776
|
+
this.setStatus("stopping · escape");
|
|
1777
|
+
if (action === "abort") requestActiveRunStop({ source: "escape", exitSession: false });
|
|
1690
1778
|
return;
|
|
1691
1779
|
}
|
|
1692
1780
|
if (key.meta && key.name === "up") {
|
|
@@ -1784,7 +1872,9 @@ function printStatus(state) {
|
|
|
1784
1872
|
printSystemLine(`project=${process.cwd()}`);
|
|
1785
1873
|
printSystemLine(`cwd=${state.commandCwd || process.cwd()}`);
|
|
1786
1874
|
printSystemLine(`session=${state.sessionId || "new"}`);
|
|
1787
|
-
|
|
1875
|
+
const progress =
|
|
1876
|
+
state.currentStep && state.currentMaxSteps ? ` progress=${state.currentStep}/${state.currentMaxSteps}` : state.currentStep ? ` progress=${state.currentStep}` : "";
|
|
1877
|
+
printSystemLine(`status=${state.status || "idle"}${progress}${state.activeGoal ? ` workingOn=${state.activeGoal}` : ""}`);
|
|
1788
1878
|
printSystemLine(`language=${state.language || cliLanguage} (${languageLabel(state.language || cliLanguage)})`);
|
|
1789
1879
|
if (state.lastEvent) printSystemLine(`last=${state.lastEvent}`);
|
|
1790
1880
|
printSystemLine(`provider=${state.provider || "auto"} routing=${state.routingMode} model=${state.model || "auto"}`);
|
|
@@ -1954,9 +2044,26 @@ async function printResumeHistory(state, { limit = 0 } = {}) {
|
|
|
1954
2044
|
for (const entry of shown) printHistoryEntry(entry);
|
|
1955
2045
|
}
|
|
1956
2046
|
|
|
1957
|
-
function
|
|
2047
|
+
function toolStatusDetails(data = {}) {
|
|
2048
|
+
const tool = data.toolName || "unknown";
|
|
2049
|
+
const args = data.args || {};
|
|
2050
|
+
if (tool === "run_command" && args.command) return `${tool}: ${compactLine(args.command, 58)}`;
|
|
2051
|
+
if ((tool === "write_file" || tool === "apply_patch" || tool === "read_file" || tool === "open_workspace_file") && args.path) {
|
|
2052
|
+
return `${tool}: ${compactLine(args.path, 58)}`;
|
|
2053
|
+
}
|
|
2054
|
+
if ((tool === "open_url" || tool === "web_research" || tool === "web_search") && (args.url || args.query || args.q)) {
|
|
2055
|
+
return `${tool}: ${compactLine(args.url || args.query || args.q, 58)}`;
|
|
2056
|
+
}
|
|
2057
|
+
return tool;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function printStatusEvent(state, label, details = "", meta = {}) {
|
|
1958
2061
|
const safeDetails = compactLine(details, 72);
|
|
1959
|
-
state.
|
|
2062
|
+
if (meta.step) state.currentStep = meta.step;
|
|
2063
|
+
if (meta.maxSteps) state.currentMaxSteps = meta.maxSteps;
|
|
2064
|
+
const progress =
|
|
2065
|
+
state.currentStep && state.currentMaxSteps ? `step ${state.currentStep}/${state.currentMaxSteps} · ` : state.currentStep ? `step ${state.currentStep} · ` : "";
|
|
2066
|
+
state.lastEvent = `${progress}${safeDetails ? `${label}: ${safeDetails}` : label}`;
|
|
1960
2067
|
const statusText = `${state.status || "running"} · ${state.lastEvent}`;
|
|
1961
2068
|
if (activeRunInput) {
|
|
1962
2069
|
activeRunInput.setStatus(statusText);
|
|
@@ -1979,7 +2086,7 @@ function attachRunInterrupts(controller) {
|
|
|
1979
2086
|
if (controller.signal.aborted) return;
|
|
1980
2087
|
const reason = isEscape ? "escape" : "ctrl-c";
|
|
1981
2088
|
printSystemLine(`status=stopping reason=${reason}`);
|
|
1982
|
-
|
|
2089
|
+
requestActiveRunStop({ source: reason, exitSession: isCtrlC });
|
|
1983
2090
|
};
|
|
1984
2091
|
input.on("keypress", handler);
|
|
1985
2092
|
return () => {
|
|
@@ -3575,6 +3682,10 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
|
|
|
3575
3682
|
projectSessionsDir: config.projectSessionsDir,
|
|
3576
3683
|
});
|
|
3577
3684
|
const liveInput = new LiveRunInput({ state, store, controller });
|
|
3685
|
+
activeRunController = controller;
|
|
3686
|
+
activeRunExitAfterStop = false;
|
|
3687
|
+
activeRunAbortSource = "";
|
|
3688
|
+
clearRunForceExit();
|
|
3578
3689
|
const liveStarted = liveInput.start();
|
|
3579
3690
|
const detachInterrupts = liveStarted ? () => {} : attachRunInterrupts(controller);
|
|
3580
3691
|
if (liveStarted) {
|
|
@@ -3613,12 +3724,18 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
|
|
|
3613
3724
|
onEvent: (type, data = {}) => {
|
|
3614
3725
|
if (type === "plan.created") {
|
|
3615
3726
|
printStatusEvent(state, "planned");
|
|
3727
|
+
} else if (type === "budget.initialized") {
|
|
3728
|
+
state.currentMaxSteps = data.currentMaxSteps || data.initialMaxSteps || state.maxSteps;
|
|
3729
|
+
printStatusEvent(state, "budget", `${state.currentMaxSteps} steps`);
|
|
3616
3730
|
} else if (type === "model.requested") {
|
|
3617
|
-
printStatusEvent(state, "model_wait", `${data.provider || "model"}/${data.model || ""}
|
|
3731
|
+
printStatusEvent(state, "model_wait", `${data.provider || "model"}/${data.model || ""}`, {
|
|
3732
|
+
step: data.step,
|
|
3733
|
+
maxSteps: state.currentMaxSteps,
|
|
3734
|
+
});
|
|
3618
3735
|
} else if (type === "tool.started") {
|
|
3619
|
-
printStatusEvent(state, "tool", data
|
|
3620
|
-
} else if (type === "tool.completed") {
|
|
3621
|
-
printStatusEvent(state, "
|
|
3736
|
+
printStatusEvent(state, "tool", toolStatusDetails(data));
|
|
3737
|
+
} else if (type === "tool.completed" || type === "tool.failed") {
|
|
3738
|
+
printStatusEvent(state, type === "tool.failed" || data.ok === false ? "tool_failed" : "tool_done", toolStatusDetails(data));
|
|
3622
3739
|
} else if (type === "file.changed") {
|
|
3623
3740
|
printWorkspaceChange(data);
|
|
3624
3741
|
} else if (type === "tool.blocked") {
|
|
@@ -3643,7 +3760,10 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
|
|
|
3643
3760
|
} else if (type === "session.stopped") {
|
|
3644
3761
|
printStatusEvent(state, "stopped", data.reason || "");
|
|
3645
3762
|
} else if (type === "model.responded") {
|
|
3646
|
-
printStatusEvent(state, "model_responded", data.content ? data.content.slice(0, 80).replace(/\s+/g, " ") : ""
|
|
3763
|
+
printStatusEvent(state, "model_responded", data.content ? data.content.slice(0, 80).replace(/\s+/g, " ") : "", {
|
|
3764
|
+
step: data.step,
|
|
3765
|
+
maxSteps: state.currentMaxSteps,
|
|
3766
|
+
});
|
|
3647
3767
|
}
|
|
3648
3768
|
},
|
|
3649
3769
|
});
|
|
@@ -3652,11 +3772,15 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
|
|
|
3652
3772
|
} finally {
|
|
3653
3773
|
detachInterrupts();
|
|
3654
3774
|
queuedAfterFinish = await liveInput.stop();
|
|
3775
|
+
clearRunForceExit();
|
|
3655
3776
|
}
|
|
3656
3777
|
if (runError) {
|
|
3657
3778
|
state.status = isAbortError(runError) ? "stopped" : "failed";
|
|
3658
3779
|
state.activeGoal = "";
|
|
3659
3780
|
printSystemLine(`status=${state.status} session=${state.sessionId}`);
|
|
3781
|
+
const exitSession = Boolean(runError.exitSession || activeRunExitAfterStop);
|
|
3782
|
+
clearActiveRunControl(controller);
|
|
3783
|
+
if (isAbortError(runError) && !exitSession) return queuedAfterFinish;
|
|
3660
3784
|
throw runError;
|
|
3661
3785
|
}
|
|
3662
3786
|
state.sessionId = result.sessionId || state.sessionId;
|
|
@@ -3664,9 +3788,16 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
|
|
|
3664
3788
|
state.activeGoal = "";
|
|
3665
3789
|
printSystemLine(`status=${state.status} session=${state.sessionId}`);
|
|
3666
3790
|
if (result.stopped && result.reason === "user_interrupt") {
|
|
3667
|
-
|
|
3791
|
+
const exitSession = activeRunExitAfterStop;
|
|
3792
|
+
const abortSource = activeRunAbortSource || "ctrl-c";
|
|
3793
|
+
clearActiveRunControl(controller);
|
|
3794
|
+
if (exitSession) {
|
|
3795
|
+
throw createAbortError("Session stopped by ctrl-c.", { source: abortSource, exitSession: true });
|
|
3796
|
+
}
|
|
3797
|
+
printAgentMessage("Active run stopped. Session saved; enter a new prompt or use /status.");
|
|
3668
3798
|
return [];
|
|
3669
3799
|
}
|
|
3800
|
+
clearActiveRunControl(controller);
|
|
3670
3801
|
if (!result.stopped && latestPermissionAdvice) {
|
|
3671
3802
|
const approvalQueued = await maybeContinueAfterPermissionApproval(latestPermissionAdvice, state, packageDir, {
|
|
3672
3803
|
approvalDepth,
|
|
@@ -3679,6 +3810,7 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
|
|
|
3679
3810
|
|
|
3680
3811
|
export async function startInteractiveCli(args = {}, { packageDir, packageVersion } = {}) {
|
|
3681
3812
|
const state = createState(args);
|
|
3813
|
+
const detachProcessInterrupts = installProcessInterruptHandlers();
|
|
3682
3814
|
setCliLanguage(state.language);
|
|
3683
3815
|
const rl =
|
|
3684
3816
|
input.isTTY && output.isTTY
|
|
@@ -3705,6 +3837,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
|
|
|
3705
3837
|
} catch (error) {
|
|
3706
3838
|
if (error?.code === "ERR_USE_AFTER_CLOSE") break;
|
|
3707
3839
|
if (isAbortError(error)) {
|
|
3840
|
+
if (error.exitSession) process.exitCode = 130;
|
|
3708
3841
|
await printResumeHint(state);
|
|
3709
3842
|
break;
|
|
3710
3843
|
}
|
|
@@ -3734,6 +3867,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
|
|
|
3734
3867
|
}
|
|
3735
3868
|
} catch (error) {
|
|
3736
3869
|
if (isAbortError(error)) {
|
|
3870
|
+
if (error.exitSession) process.exitCode = 130;
|
|
3737
3871
|
await printResumeHint(state);
|
|
3738
3872
|
break;
|
|
3739
3873
|
}
|
|
@@ -3741,6 +3875,8 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
|
|
|
3741
3875
|
}
|
|
3742
3876
|
}
|
|
3743
3877
|
} finally {
|
|
3878
|
+
detachProcessInterrupts();
|
|
3744
3879
|
rl?.close();
|
|
3880
|
+
restoreTerminalForExit();
|
|
3745
3881
|
}
|
|
3746
3882
|
}
|
package/src/model-client.js
CHANGED
|
@@ -405,6 +405,7 @@ export function normalizeTextToolCallResponse(response) {
|
|
|
405
405
|
|
|
406
406
|
function mockCommandForGoal(goal = "") {
|
|
407
407
|
const text = String(goal).toLowerCase();
|
|
408
|
+
if (/\bsleep\b|\binterrupt\b|\bstall\b|\blong[- ]running\b/.test(text)) return "python3 -c 'import time; time.sleep(20)'";
|
|
408
409
|
if (/\blist\b|folder contents|directory contents|files?/.test(text)) return "ls -la";
|
|
409
410
|
return "pwd";
|
|
410
411
|
}
|
package/src/tmux-tools.js
CHANGED
|
@@ -50,6 +50,7 @@ async function runTmux(args, options = {}) {
|
|
|
50
50
|
timeout: options.timeout ?? 12000,
|
|
51
51
|
maxBuffer: options.maxBuffer ?? 220 * 1024,
|
|
52
52
|
env: safeEnv(),
|
|
53
|
+
signal: options.signal,
|
|
53
54
|
});
|
|
54
55
|
return {
|
|
55
56
|
ok: true,
|
|
@@ -57,6 +58,7 @@ async function runTmux(args, options = {}) {
|
|
|
57
58
|
stderr: redactSensitiveText(result.stderr || ""),
|
|
58
59
|
};
|
|
59
60
|
} catch (error) {
|
|
61
|
+
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") throw error;
|
|
60
62
|
const message = redactSensitiveText(error instanceof Error ? error.message : String(error));
|
|
61
63
|
return {
|
|
62
64
|
ok: false,
|
|
@@ -161,10 +163,11 @@ function isShellPaneCommand(command = "") {
|
|
|
161
163
|
return /^(?:ba|z|fi|da|k)?sh$|^fish$/.test(String(command || "").trim());
|
|
162
164
|
}
|
|
163
165
|
|
|
164
|
-
async function getPaneCurrentCommand(target) {
|
|
166
|
+
async function getPaneCurrentCommand(target, config = {}) {
|
|
165
167
|
const result = await runTmux(["display-message", "-p", "-t", target, "#{pane_current_command}"], {
|
|
166
168
|
timeout: 4000,
|
|
167
169
|
maxBuffer: 16 * 1024,
|
|
170
|
+
signal: config.abortSignal,
|
|
168
171
|
});
|
|
169
172
|
if (!result.ok) return "";
|
|
170
173
|
return String(result.stdout || "").trim();
|
|
@@ -215,12 +218,12 @@ function parsePanes(stdout = "") {
|
|
|
215
218
|
});
|
|
216
219
|
}
|
|
217
220
|
|
|
218
|
-
export async function listTmuxSessions(args = {}) {
|
|
221
|
+
export async function listTmuxSessions(args = {}, config = {}) {
|
|
219
222
|
const sessionsResult = await runTmux([
|
|
220
223
|
"list-sessions",
|
|
221
224
|
"-F",
|
|
222
225
|
"#{session_name}|#{session_windows}|#{session_attached}|#{session_created}|#{session_activity}",
|
|
223
|
-
]);
|
|
226
|
+
], { signal: config.abortSignal });
|
|
224
227
|
if (!sessionsResult.ok && /no server running|failed to connect/i.test(`${sessionsResult.stderr} ${sessionsResult.error}`)) {
|
|
225
228
|
return { ok: true, toolName: "tmux_list_sessions", sessions: [], panes: [], summary: "No tmux server is running." };
|
|
226
229
|
}
|
|
@@ -233,7 +236,7 @@ export async function listTmuxSessions(args = {}) {
|
|
|
233
236
|
"-a",
|
|
234
237
|
"-F",
|
|
235
238
|
"#{session_name}:#{window_index}.#{pane_index}|#{pane_current_path}|#{pane_current_command}|#{pane_active}|#{pane_title}",
|
|
236
|
-
]);
|
|
239
|
+
], { signal: config.abortSignal });
|
|
237
240
|
if (panesResult.ok) panes = parsePanes(panesResult.stdout);
|
|
238
241
|
}
|
|
239
242
|
const sessions = parseSessions(sessionsResult.stdout);
|
|
@@ -246,13 +249,14 @@ export async function listTmuxSessions(args = {}) {
|
|
|
246
249
|
};
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
export async function captureTmuxPane(args = {}) {
|
|
252
|
+
export async function captureTmuxPane(args = {}, config = {}) {
|
|
250
253
|
const target = validateTarget(args.target);
|
|
251
254
|
if (!target.ok) return { ok: false, toolName: "tmux_capture_pane", blocked: true, reason: target.reason };
|
|
252
255
|
const lines = clampInteger(args.lines, 80, 1, MAX_CAPTURE_LINES);
|
|
253
256
|
const result = await runTmux(["capture-pane", "-t", target.target, "-p", "-S", `-${lines}`], {
|
|
254
257
|
timeout: 8000,
|
|
255
258
|
maxBuffer: 260 * 1024,
|
|
259
|
+
signal: config.abortSignal,
|
|
256
260
|
});
|
|
257
261
|
if (!result.ok) return { ok: false, toolName: "tmux_capture_pane", target: target.target, error: result.stderr || result.error };
|
|
258
262
|
const content = redactSensitiveText(result.stdout || "").replace(/\s+$/g, "");
|
|
@@ -299,7 +303,7 @@ export async function sendTmuxKeys(args = {}, config = {}) {
|
|
|
299
303
|
const keyArgsForPolicy = [...keys];
|
|
300
304
|
if (enter) keyArgsForPolicy.push("Enter");
|
|
301
305
|
if (text && keyArgsForPolicy.includes("Enter") && shouldApplyDockerShellPolicy(config)) {
|
|
302
|
-
|
|
306
|
+
const paneCommand = await getPaneCurrentCommand(target.target, config);
|
|
303
307
|
if (isShellPaneCommand(paneCommand)) {
|
|
304
308
|
const dockerPolicy = checkDockerShellPolicyForTmuxText(text, config, "tmux text");
|
|
305
309
|
if (!dockerPolicy.ok) {
|
|
@@ -323,14 +327,14 @@ export async function sendTmuxKeys(args = {}, config = {}) {
|
|
|
323
327
|
|
|
324
328
|
const steps = [];
|
|
325
329
|
if (text) {
|
|
326
|
-
const sent = await runTmux(["send-keys", "-t", target.target, "-l", text], { timeout: 8000 });
|
|
330
|
+
const sent = await runTmux(["send-keys", "-t", target.target, "-l", text], { timeout: 8000, signal: config.abortSignal });
|
|
327
331
|
if (!sent.ok) return { ok: false, toolName: "tmux_send_keys", target: target.target, error: sent.stderr || sent.error };
|
|
328
332
|
steps.push("literal-text");
|
|
329
333
|
}
|
|
330
334
|
const keyArgs = [...keys];
|
|
331
335
|
if (enter) keyArgs.push("Enter");
|
|
332
336
|
if (keyArgs.length > 0) {
|
|
333
|
-
const sent = await runTmux(["send-keys", "-t", target.target, ...keyArgs], { timeout: 8000 });
|
|
337
|
+
const sent = await runTmux(["send-keys", "-t", target.target, ...keyArgs], { timeout: 8000, signal: config.abortSignal });
|
|
334
338
|
if (!sent.ok) return { ok: false, toolName: "tmux_send_keys", target: target.target, error: sent.stderr || sent.error };
|
|
335
339
|
steps.push(...keyArgs);
|
|
336
340
|
}
|
|
@@ -395,7 +399,7 @@ export async function startTmuxSession(args = {}, config = {}) {
|
|
|
395
399
|
|
|
396
400
|
const tmuxArgs = ["new-session", "-d", "-s", name.name, "-c", cwd.cwd];
|
|
397
401
|
if (command) tmuxArgs.push(command);
|
|
398
|
-
const result = await runTmux(tmuxArgs, { timeout: 10000 });
|
|
402
|
+
const result = await runTmux(tmuxArgs, { timeout: 10000, signal: config.abortSignal });
|
|
399
403
|
if (!result.ok) return { ok: false, toolName: "tmux_start_session", session: name.name, error: result.stderr || result.error };
|
|
400
404
|
return {
|
|
401
405
|
ok: true,
|
package/src/tool-wrappers.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promisify } from "node:util";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
2
|
import { getModelPresets } from "./model-routing.js";
|
|
4
3
|
import { redactSensitiveText } from "./redaction.js";
|
|
5
4
|
|
|
6
|
-
const execFile = promisify(execFileCallback);
|
|
7
|
-
|
|
8
5
|
export const WRAPPER_NAMES = ["codex", "claude", "gemini", "copilot", "qwen"];
|
|
9
6
|
export const DEFAULT_WRAPPER_NAME = "codex";
|
|
10
7
|
|
|
@@ -31,6 +28,93 @@ function cleanOutput(value, limit) {
|
|
|
31
28
|
return redactSensitiveText(value).trim().slice(0, limit);
|
|
32
29
|
}
|
|
33
30
|
|
|
31
|
+
function killChildTree(child, signal = "SIGTERM") {
|
|
32
|
+
if (!child || child.killed) return;
|
|
33
|
+
try {
|
|
34
|
+
if (process.platform === "win32") child.kill(signal);
|
|
35
|
+
else process.kill(-child.pid, signal);
|
|
36
|
+
} catch {
|
|
37
|
+
try {
|
|
38
|
+
child.kill(signal);
|
|
39
|
+
} catch {
|
|
40
|
+
// Process may already have exited.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runWrapperProcess(spec, config) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const child = spawn(spec.command, spec.args, {
|
|
48
|
+
cwd: config.commandCwd,
|
|
49
|
+
env: process.env,
|
|
50
|
+
detached: process.platform !== "win32",
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
const timeoutMs = Number(config.wrapperTimeoutMs) || 120000;
|
|
54
|
+
const maxStdout = 512 * 1024;
|
|
55
|
+
const maxStderr = 128 * 1024;
|
|
56
|
+
let stdout = "";
|
|
57
|
+
let stderr = "";
|
|
58
|
+
let settled = false;
|
|
59
|
+
let timedOut = false;
|
|
60
|
+
|
|
61
|
+
const settle = (callback) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
if (timer) clearTimeout(timer);
|
|
65
|
+
if (config.abortSignal && onAbort) config.abortSignal.removeEventListener("abort", onAbort);
|
|
66
|
+
callback();
|
|
67
|
+
};
|
|
68
|
+
const makeError = (message, code = 1) => {
|
|
69
|
+
const error = new Error(message);
|
|
70
|
+
error.code = code;
|
|
71
|
+
error.stdout = stdout;
|
|
72
|
+
error.stderr = stderr;
|
|
73
|
+
return error;
|
|
74
|
+
};
|
|
75
|
+
const onAbort = () => {
|
|
76
|
+
killChildTree(child, "SIGTERM");
|
|
77
|
+
setTimeout(() => killChildTree(child, "SIGKILL"), 1200).unref?.();
|
|
78
|
+
const error = makeError("Wrapper interrupted by user.", "ABORT_ERR");
|
|
79
|
+
error.name = "AbortError";
|
|
80
|
+
settle(() => reject(error));
|
|
81
|
+
};
|
|
82
|
+
const timer =
|
|
83
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
84
|
+
? setTimeout(() => {
|
|
85
|
+
timedOut = true;
|
|
86
|
+
killChildTree(child, "SIGTERM");
|
|
87
|
+
setTimeout(() => killChildTree(child, "SIGKILL"), 1200).unref?.();
|
|
88
|
+
}, timeoutMs)
|
|
89
|
+
: null;
|
|
90
|
+
timer?.unref?.();
|
|
91
|
+
|
|
92
|
+
if (config.abortSignal?.aborted) return onAbort();
|
|
93
|
+
if (config.abortSignal) config.abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
94
|
+
|
|
95
|
+
child.stdout?.on("data", (chunk) => {
|
|
96
|
+
if (stdout.length < maxStdout) stdout += chunk.toString();
|
|
97
|
+
});
|
|
98
|
+
child.stderr?.on("data", (chunk) => {
|
|
99
|
+
if (stderr.length < maxStderr) stderr += chunk.toString();
|
|
100
|
+
});
|
|
101
|
+
child.on("error", (error) => {
|
|
102
|
+
settle(() => reject(makeError(error instanceof Error ? error.message : String(error), 1)));
|
|
103
|
+
});
|
|
104
|
+
child.on("close", (code, signal) => {
|
|
105
|
+
if (timedOut) {
|
|
106
|
+
settle(() => reject(makeError(`Wrapper timed out after ${timeoutMs}ms.`, 124)));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (Number(code || 0) === 0) {
|
|
110
|
+
settle(() => resolve({ stdout, stderr }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
settle(() => reject(makeError(`Wrapper exited with ${signal || code}.`, Number.isInteger(code) ? code : 1)));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
34
118
|
function buildPrompt(prompt) {
|
|
35
119
|
return `${BASE_ADVISORY_PROMPT}\n\nTask:\n${prompt}`;
|
|
36
120
|
}
|
|
@@ -157,13 +241,7 @@ export async function runAgentWrapper({ wrapper, prompt }, config) {
|
|
|
157
241
|
return { ok: false, wrapper, error: `Wrapper command is not available: ${wrapper}` };
|
|
158
242
|
}
|
|
159
243
|
|
|
160
|
-
const runOnce = async (spec) =>
|
|
161
|
-
execFile(spec.command, spec.args, {
|
|
162
|
-
cwd: config.commandCwd,
|
|
163
|
-
timeout: Number(config.wrapperTimeoutMs) || 120000,
|
|
164
|
-
maxBuffer: 512 * 1024,
|
|
165
|
-
env: process.env,
|
|
166
|
-
});
|
|
244
|
+
const runOnce = async (spec) => runWrapperProcess(spec, config);
|
|
167
245
|
|
|
168
246
|
try {
|
|
169
247
|
const result = await runOnce(commandSpec);
|
|
@@ -174,6 +252,7 @@ export async function runAgentWrapper({ wrapper, prompt }, config) {
|
|
|
174
252
|
stderr: cleanOutput(result.stderr, 4000),
|
|
175
253
|
};
|
|
176
254
|
} catch (error) {
|
|
255
|
+
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") throw error;
|
|
177
256
|
if (wrapper === "codex") {
|
|
178
257
|
const fallbackSpec = wrapperCommand(wrapper, prompt, config, { fallback: true });
|
|
179
258
|
try {
|
|
@@ -186,6 +265,7 @@ export async function runAgentWrapper({ wrapper, prompt }, config) {
|
|
|
186
265
|
stderr: cleanOutput(fallback.stderr, 4000),
|
|
187
266
|
};
|
|
188
267
|
} catch (fallbackError) {
|
|
268
|
+
if (fallbackError?.name === "AbortError" || fallbackError?.code === "ABORT_ERR") throw fallbackError;
|
|
189
269
|
return {
|
|
190
270
|
ok: false,
|
|
191
271
|
wrapper,
|