@lazyingart/agintiflow 0.20.119 → 0.20.121

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.119",
3
+ "version": "0.20.121",
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",
@@ -568,6 +568,18 @@ try {
568
568
  assert(failedOutsidePathAdvice?.failureKind === "workspace-path", "outside host path failure advice was not generated");
569
569
  assert(failedOutsidePathAdvice.suggestedCommand.includes("--sandbox-mode host"), "outside path advice did not suggest host mode");
570
570
  assert(!failedOutsidePathAdvice.suggestedCommand.includes("aginti run --sandbox host"), "outside path advice used legacy sandbox syntax");
571
+ const missingReadonlyHostPathAdvice = buildFailedCommandAdvice({
572
+ args: { command: "ls /home/lachlan/ProjectsLFS/ProteinStructure" },
573
+ commandPolicy: evaluateCommandPolicy("ls /home/lachlan/ProjectsLFS/ProteinStructure", dockerWorkspacePolicy),
574
+ commandResult: {
575
+ ok: false,
576
+ stdout: "",
577
+ stderr: "ls: cannot access '/home/lachlan/ProjectsLFS/ProteinStructure': No such file or directory",
578
+ },
579
+ config: dockerWorkspacePolicy,
580
+ state: { sessionId: "coding-readonly-missing-host-path-smoke" },
581
+ });
582
+ assert(missingReadonlyHostPathAdvice === null, "missing read-only host path should not trigger danger-mode permission advice");
571
583
  assert(
572
584
  shouldRunParallelScouts(
573
585
  {
@@ -1661,9 +1661,9 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
1661
1661
  break;
1662
1662
  case "wait":
1663
1663
  if (browserState.page) {
1664
- 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);
1665
1665
  } else {
1666
- 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);
1667
1667
  }
1668
1668
  break;
1669
1669
  case "inspect_project":
@@ -1749,14 +1749,14 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
1749
1749
  return result;
1750
1750
  }
1751
1751
  case "tmux_list_sessions": {
1752
- const result = await listTmuxSessions(args);
1752
+ const result = await listTmuxSessions(args, config);
1753
1753
  const eventResult = sanitizeToolResult(result);
1754
1754
  await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
1755
1755
  observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
1756
1756
  return result;
1757
1757
  }
1758
1758
  case "tmux_capture_pane": {
1759
- const result = await captureTmuxPane(args);
1759
+ const result = await captureTmuxPane(args, config);
1760
1760
  const eventResult = sanitizeToolResult(result);
1761
1761
  await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
1762
1762
  observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
@@ -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 });
@@ -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
 
@@ -1173,16 +1195,83 @@ function removeAt(buffer, cursor) {
1173
1195
  };
1174
1196
  }
1175
1197
 
1176
- function createAbortError(message = "Aborted with Ctrl+C") {
1198
+ function createAbortError(message = "Aborted with Ctrl+C", options = {}) {
1177
1199
  const error = new Error(message);
1178
1200
  error.code = "ABORT_ERR";
1179
1201
  error.name = "AbortError";
1202
+ error.source = options.source || "";
1203
+ error.exitSession = Boolean(options.exitSession);
1180
1204
  return error;
1181
1205
  }
1182
1206
 
1183
1207
  export function classifyEscapeAction({ active = false, pendingAsap = [] } = {}) {
1184
1208
  if (!active) return "noop";
1185
- 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
+ };
1186
1275
  }
1187
1276
 
1188
1277
  export function activeRunSlashCommandAction(value = "") {
@@ -1307,7 +1396,7 @@ function readTtyPrompt(options = {}) {
1307
1396
  if (key.ctrl && key.name === "c") {
1308
1397
  clearPromptPanel();
1309
1398
  cleanup();
1310
- reject(createAbortError());
1399
+ reject(createAbortError("Interrupted by ctrl-c.", { source: "ctrl-c", exitSession: true }));
1311
1400
  return;
1312
1401
  }
1313
1402
  if ((key.ctrl && key.name === "j") || key.sequence === "\n") {
@@ -1679,19 +1768,13 @@ class LiveRunInput {
1679
1768
  handleKey(str = "", key = {}) {
1680
1769
  if (key.ctrl && key.name === "c") {
1681
1770
  this.setStatus("stopping · ctrl-c");
1682
- this.controller.abort(createAbortError("Interrupted by ctrl-c."));
1771
+ requestActiveRunStop({ source: "ctrl-c", exitSession: true });
1683
1772
  return;
1684
1773
  }
1685
1774
  if (key.name === "escape") {
1686
1775
  const action = classifyEscapeAction({ active: true, pendingAsap: this.pendingAsap });
1687
- if (action === "wait-for-asap") {
1688
- const count = this.pendingAsap.length;
1689
- this.setStatus(`running · waiting to apply ${count} asap pipe message${count === 1 ? "" : "s"}`);
1690
- this.redraw();
1691
- return;
1692
- }
1693
1776
  this.setStatus("stopping · escape");
1694
- this.controller.abort(createAbortError("Interrupted by escape."));
1777
+ if (action === "abort") requestActiveRunStop({ source: "escape", exitSession: false });
1695
1778
  return;
1696
1779
  }
1697
1780
  if (key.meta && key.name === "up") {
@@ -2003,7 +2086,7 @@ function attachRunInterrupts(controller) {
2003
2086
  if (controller.signal.aborted) return;
2004
2087
  const reason = isEscape ? "escape" : "ctrl-c";
2005
2088
  printSystemLine(`status=stopping reason=${reason}`);
2006
- controller.abort(createAbortError(`Interrupted by ${reason}.`));
2089
+ requestActiveRunStop({ source: reason, exitSession: isCtrlC });
2007
2090
  };
2008
2091
  input.on("keypress", handler);
2009
2092
  return () => {
@@ -3599,6 +3682,10 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
3599
3682
  projectSessionsDir: config.projectSessionsDir,
3600
3683
  });
3601
3684
  const liveInput = new LiveRunInput({ state, store, controller });
3685
+ activeRunController = controller;
3686
+ activeRunExitAfterStop = false;
3687
+ activeRunAbortSource = "";
3688
+ clearRunForceExit();
3602
3689
  const liveStarted = liveInput.start();
3603
3690
  const detachInterrupts = liveStarted ? () => {} : attachRunInterrupts(controller);
3604
3691
  if (liveStarted) {
@@ -3685,11 +3772,15 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
3685
3772
  } finally {
3686
3773
  detachInterrupts();
3687
3774
  queuedAfterFinish = await liveInput.stop();
3775
+ clearRunForceExit();
3688
3776
  }
3689
3777
  if (runError) {
3690
3778
  state.status = isAbortError(runError) ? "stopped" : "failed";
3691
3779
  state.activeGoal = "";
3692
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;
3693
3784
  throw runError;
3694
3785
  }
3695
3786
  state.sessionId = result.sessionId || state.sessionId;
@@ -3697,9 +3788,16 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
3697
3788
  state.activeGoal = "";
3698
3789
  printSystemLine(`status=${state.status} session=${state.sessionId}`);
3699
3790
  if (result.stopped && result.reason === "user_interrupt") {
3700
- 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.");
3701
3798
  return [];
3702
3799
  }
3800
+ clearActiveRunControl(controller);
3703
3801
  if (!result.stopped && latestPermissionAdvice) {
3704
3802
  const approvalQueued = await maybeContinueAfterPermissionApproval(latestPermissionAdvice, state, packageDir, {
3705
3803
  approvalDepth,
@@ -3712,6 +3810,7 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
3712
3810
 
3713
3811
  export async function startInteractiveCli(args = {}, { packageDir, packageVersion } = {}) {
3714
3812
  const state = createState(args);
3813
+ const detachProcessInterrupts = installProcessInterruptHandlers();
3715
3814
  setCliLanguage(state.language);
3716
3815
  const rl =
3717
3816
  input.isTTY && output.isTTY
@@ -3738,6 +3837,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
3738
3837
  } catch (error) {
3739
3838
  if (error?.code === "ERR_USE_AFTER_CLOSE") break;
3740
3839
  if (isAbortError(error)) {
3840
+ if (error.exitSession) process.exitCode = 130;
3741
3841
  await printResumeHint(state);
3742
3842
  break;
3743
3843
  }
@@ -3767,6 +3867,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
3767
3867
  }
3768
3868
  } catch (error) {
3769
3869
  if (isAbortError(error)) {
3870
+ if (error.exitSession) process.exitCode = 130;
3770
3871
  await printResumeHint(state);
3771
3872
  break;
3772
3873
  }
@@ -3774,6 +3875,8 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
3774
3875
  }
3775
3876
  }
3776
3877
  } finally {
3878
+ detachProcessInterrupts();
3777
3879
  rl?.close();
3880
+ restoreTerminalForExit();
3778
3881
  }
3779
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
  }
@@ -249,7 +249,7 @@ export function looksLikeDockerWorkspacePathFailure(result = {}, config = {}) {
249
249
  }
250
250
 
251
251
  export function buildFailedCommandAdvice({ args = {}, commandPolicy = {}, commandResult = {}, config = {}, state = {} } = {}) {
252
- if (looksLikeDockerWorkspacePathFailure(commandResult, config)) {
252
+ if (looksLikeDockerWorkspacePathFailure(commandResult, config) && commandPolicy.category !== "read-only") {
253
253
  return {
254
254
  ...adviceForCategory("workspace-path", {
255
255
  toolName: "run_command",
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,