@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.118",
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" }] }) !== "wait-for-asap") {
367
- throw new Error("active Esc should wait when ASAP pipe messages are pending");
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
  [
@@ -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 { exec as execCallback, spawn } from "node:child_process";
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 exec(command, {
1265
- cwd: config.commandCwd,
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: true,
1275
- exitCode: 0,
1276
- stdout: redactSensitiveText(result.stdout).trim().slice(0, 8000),
1277
- stderr: redactSensitiveText(result.stderr).trim().slice(0, 4000),
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
- return {
1382
+ const failedResult = {
1282
1383
  ok: false,
1283
1384
  exitCode: Number.isInteger(error?.code) ? error.code : 1,
1284
- stdout: redactSensitiveText(String(error?.stdout || "")).trim().slice(0, 8000),
1285
- stderr: redactSensitiveText(String(error?.stderr || error?.message || "")).trim().slice(0, 4000),
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 || "",
@@ -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
- async function requestJson(url, payload, apiKey, { timeoutMs = 300000, retries = 2 } = {}) {
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 timer = setTimeout(() => controller.abort(), timeoutMs);
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 new Promise((resolve) => setTimeout(resolve, 2000 + attempt * 2000));
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 new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs + attempt * 250, 12000)));
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 });
@@ -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 dockerRunArgs(command, config, policy = evaluateCommandPolicy(command, config), persistentDirs = persistentDockerDirs(config)) {
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 result = await execDocker(dockerRunArgs(command, config, policy, persistentDirs), {
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 = {
@@ -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 Array.isArray(pendingAsap) && pendingAsap.length > 0 ? "wait-for-asap" : "abort";
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.controller.abort(new Error("Interrupted by ctrl-c."));
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
- if (action === "wait-for-asap") {
1684
- const count = this.pendingAsap.length;
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
- printSystemLine(`status=${state.status || "idle"}${state.activeGoal ? ` workingOn=${state.activeGoal}` : ""}`);
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 printStatusEvent(state, label, details = "") {
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.lastEvent = safeDetails ? `${label}: ${safeDetails}` : label;
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
- controller.abort(new Error(`Interrupted by ${reason}.`));
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.toolName || "unknown");
3620
- } else if (type === "tool.completed") {
3621
- printStatusEvent(state, "tool_done", data.toolName || "unknown");
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
- await printResumeHint(state);
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
  }
@@ -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
- const paneCommand = await getPaneCurrentCommand(target.target);
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,
@@ -1,10 +1,7 @@
1
- import { execFile as execFileCallback, execFileSync } from "node:child_process";
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,