@lazyingart/agintiflow 0.8.3 → 0.8.5

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/README.md CHANGED
@@ -54,7 +54,7 @@ aginti
54
54
  aginti chat
55
55
  ```
56
56
 
57
- Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is now Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume <session-id>` to continue work. Ctrl+C exits cleanly and prints a resume command when a session exists.
57
+ Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Esc or Ctrl+C stops the active run cleanly and prints the resume command.
58
58
 
59
59
  Launch the local web UI from an installed package:
60
60
 
@@ -80,6 +80,8 @@ aginti capabilities
80
80
  aginti doctor --capabilities
81
81
  aginti sessions list
82
82
  aginti sessions show <session-id>
83
+ aginti resume
84
+ aginti resume latest
83
85
  aginti resume <session-id> "continue with a short follow-up"
84
86
  aginti queue <session-id> "extra instruction for the running agent"
85
87
  aginti --profile code "write a small Python CLI app with tests"
@@ -338,7 +340,7 @@ Package policy values:
338
340
 
339
341
  Toolchain commands such as `python3 plot.py`, `latexmk -pdf paper.tex`, and `pdflatex -interaction=nonstopmode -halt-on-error paper.tex` are allowlisted only when the shell tool is enabled. In Docker mode the project folder is mounted as `/workspace`; any file written to `/workspace/report.pdf` appears on the host as `<your-project>/report.pdf`. CLI runs print both the host workspace and the Docker mapping before execution. File and canvas tools accept both normal relative paths and Docker virtual paths like `/workspace/report.pdf`, while other absolute host paths remain blocked.
340
342
 
341
- The web chat mirrors the CLI session store. Enter sends, Ctrl+J inserts a newline, and Tab submits/queues the message. Queued input is written to `.sessions/<session-id>/inbox.jsonl`; the running agent drains that pipe between steps and after tool calls.
343
+ The web chat mirrors the CLI session store. Enter sends, Ctrl+J inserts a newline, Tab submits/queues the message, and Esc stops the active run. Queued input is written to `.sessions/<session-id>/inbox.jsonl`; the running agent drains that pipe between steps and after tool calls. Generated local sites should use the built-in `preview_workspace` or `open_workspace_file` tools; AgInTiFlow avoids transient localhost servers inside Docker because those containers stop between commands and their ports are not host-published.
342
344
 
343
345
  Safe preflight endpoints:
344
346
 
@@ -9,6 +9,10 @@ AgInTiFlow keeps CLI and web runs equivalent by using the project folder as the
9
9
 
10
10
  When a run is active, the web chat and `aginti queue <session-id> "..."` append messages to the inbox instead of trying to mutate the running process directly. The runner drains the inbox at safe boundaries: before each model step and after tool execution. This mirrors the event-queue style used by mature agent UIs while keeping the backend decoupled from any specific frontend.
11
11
 
12
+ Runs can be stopped without corrupting session state. The CLI listens for Esc or Ctrl+C during an active run, and the web UI exposes a Stop button plus Esc. Both paths send an abort signal, persist `session.stopped`, and leave the session resumable through `aginti resume <session-id>`.
13
+
12
14
  Default execution is Docker workspace mode with package installs approved inside the sandbox. The project is mounted at `/workspace`; persistent agent toolchain folders are mounted at `/aginti-home`, `/aginti-cache`, and `/aginti-env` from `~/.agintiflow/docker/`. Python, conda, and other language-level environments should be installed under `/aginti-env` so they survive across runs. Apt/apk package changes are ephemeral unless the Docker image is rebuilt.
13
15
 
16
+ Generated local websites should use `preview_workspace` or `open_workspace_file`. The preview tool serves the host workspace on an automatically selected `127.0.0.1` port and opens it in the browser. AgInTiFlow blocks common transient Docker preview commands such as `python -m http.server` because each shell tool call runs in a short-lived container with no published host port.
17
+
14
18
  The failed `f(f(x)) = f'(x)` LaTeX task exposed three issues: host-mode command policy blocked setup/path commands, a 15-step budget was too small for iterative numerical work plus TeX output, and follow-up input could not be queued while the agent was running. The current runtime defaults and inbox pipe address those without hardcoding that specific math task.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
6
6
  "license": "Apache-2.0",
package/public/app.js CHANGED
@@ -76,6 +76,9 @@ const translations = {
76
76
  dockerImageLabel: "Docker image",
77
77
  dockerImagePlaceholder: "agintiflow-sandbox:latest",
78
78
  startRunButton: "Start run",
79
+ stopRunButton: "Stop",
80
+ stoppingRun: "Stopping...",
81
+ stopRunFailed: "Failed to stop run.",
79
82
  runOutputTitle: "Run output",
80
83
  noRunStarted: "No run started.",
81
84
  conversationTitle: "Conversation",
@@ -643,6 +646,7 @@ const packageInstallPolicyField = document.querySelector("#packageInstallPolicy"
643
646
  const packageWarningEl = document.querySelector("#package-warning");
644
647
  const logsEl = document.querySelector("#logs");
645
648
  const runMetaEl = document.querySelector("#run-meta");
649
+ const stopRunButton = document.querySelector("#stop-run");
646
650
  const keyStatusEl = document.querySelector("#key-status");
647
651
  const allowWrapperToolsField = document.querySelector("#allowWrapperTools");
648
652
  const preferredWrapperField = document.querySelector("#preferredWrapper");
@@ -697,6 +701,7 @@ let routingPresets = {};
697
701
  let taskProfiles = [];
698
702
  let projectInfo = null;
699
703
  let currentSessionId = "";
704
+ let currentRunStatus = "";
700
705
  let pollTimer = null;
701
706
  let saveTimer = null;
702
707
  let lastChatEntries = [];
@@ -728,6 +733,12 @@ function setLogs(text, mode = "active") {
728
733
  logsEl.textContent = text;
729
734
  }
730
735
 
736
+ function updateStopRunButton() {
737
+ if (!stopRunButton) return;
738
+ stopRunButton.hidden = currentRunStatus !== "running";
739
+ stopRunButton.disabled = currentRunStatus !== "running";
740
+ }
741
+
731
742
  function renderKeyStatus(status = lastKeyStatus) {
732
743
  lastKeyStatus = status;
733
744
  if (!status) return;
@@ -949,6 +960,7 @@ function applyLanguage(language, { persist = true } = {}) {
949
960
  renderSessionManager();
950
961
  renderChat(lastChatEntries);
951
962
  renderArtifactShell();
963
+ updateStopRunButton();
952
964
  if (persist) schedulePreferenceSave();
953
965
  }
954
966
 
@@ -1429,11 +1441,13 @@ async function refreshRun() {
1429
1441
  const response = await fetch(`/api/runs/${encodeURIComponent(currentSessionId)}`);
1430
1442
  if (!response.ok) return;
1431
1443
  const run = await response.json();
1444
+ currentRunStatus = run.status || "";
1445
+ updateStopRunButton();
1432
1446
  runMetaEl.textContent = `${run.status} · ${run.sessionId}`;
1433
1447
  renderLogs(run);
1434
1448
  await refreshArtifacts({ loadSelected: artifactTunnelDialog?.open });
1435
1449
 
1436
- if (run.status === "finished" || run.status === "failed") {
1450
+ if (run.status === "finished" || run.status === "failed" || run.status === "stopped") {
1437
1451
  clearInterval(pollTimer);
1438
1452
  pollTimer = null;
1439
1453
  await refreshChat();
@@ -1443,6 +1457,22 @@ async function refreshRun() {
1443
1457
  }
1444
1458
  }
1445
1459
 
1460
+ async function stopCurrentRun() {
1461
+ if (!currentSessionId || currentRunStatus !== "running") return;
1462
+ stopRunButton.disabled = true;
1463
+ chatStatusEl.textContent = t("stoppingRun");
1464
+ const response = await fetch(`/api/runs/${encodeURIComponent(currentSessionId)}/stop`, {
1465
+ method: "POST",
1466
+ });
1467
+ if (!response.ok) {
1468
+ const data = await response.json().catch(() => ({}));
1469
+ chatStatusEl.textContent = data.error || t("stopRunFailed");
1470
+ updateStopRunButton();
1471
+ return;
1472
+ }
1473
+ await refreshRun();
1474
+ }
1475
+
1446
1476
  async function refreshChat() {
1447
1477
  if (!currentSessionId) {
1448
1478
  renderChat([]);
@@ -1771,6 +1801,24 @@ chatInputEl.addEventListener("keydown", (event) => {
1771
1801
  }
1772
1802
  });
1773
1803
 
1804
+ stopRunButton?.addEventListener("click", () => {
1805
+ stopCurrentRun().catch((error) => {
1806
+ chatStatusEl.textContent = String(error);
1807
+ updateStopRunButton();
1808
+ });
1809
+ });
1810
+
1811
+ window.addEventListener("keydown", (event) => {
1812
+ if (event.key !== "Escape" || currentRunStatus !== "running") return;
1813
+ const tag = event.target?.tagName?.toLowerCase();
1814
+ if (tag === "select" || tag === "button") return;
1815
+ event.preventDefault();
1816
+ stopCurrentRun().catch((error) => {
1817
+ chatStatusEl.textContent = String(error);
1818
+ updateStopRunButton();
1819
+ });
1820
+ });
1821
+
1774
1822
  form.addEventListener("submit", async (event) => {
1775
1823
  event.preventDefault();
1776
1824
 
@@ -1802,6 +1850,8 @@ form.addEventListener("submit", async (event) => {
1802
1850
  }
1803
1851
 
1804
1852
  currentSessionId = data.sessionId;
1853
+ currentRunStatus = "running";
1854
+ updateStopRunButton();
1805
1855
  sessionSelectEl.value = currentSessionId;
1806
1856
  runMetaEl.textContent = `${t("runningStatus").replace("...", "")} · ${currentSessionId}`;
1807
1857
  await refreshSessions();
@@ -1817,6 +1867,8 @@ form.addEventListener("submit", async (event) => {
1817
1867
  sessionSelectEl.addEventListener("change", async () => {
1818
1868
  currentSessionId = sessionSelectEl.value;
1819
1869
  if (!currentSessionId) {
1870
+ currentRunStatus = "";
1871
+ updateStopRunButton();
1820
1872
  runMetaEl.textContent = "";
1821
1873
  setLogs(t("noRunSelected"), "empty");
1822
1874
  renderChat([]);
@@ -1862,6 +1914,8 @@ chatFormEl.addEventListener("submit", async (event) => {
1862
1914
  }
1863
1915
 
1864
1916
  currentSessionId = data.sessionId;
1917
+ currentRunStatus = data.queued ? currentRunStatus : "running";
1918
+ updateStopRunButton();
1865
1919
  chatInputEl.value = "";
1866
1920
  chatStatusEl.textContent = data.queued ? t("queuedStatus") : t("runningStatus");
1867
1921
  if (data.queued) {
package/public/index.html CHANGED
@@ -307,7 +307,10 @@
307
307
  <section class="panel output-panel">
308
308
  <div class="header-row">
309
309
  <h2 data-i18n="runOutputTitle">Run output</h2>
310
- <div id="run-meta" class="subtle"></div>
310
+ <div class="run-header-actions">
311
+ <button id="stop-run" type="button" class="secondary danger" data-i18n="stopRunButton" hidden>Stop</button>
312
+ <div id="run-meta" class="subtle"></div>
313
+ </div>
311
314
  </div>
312
315
  <pre id="logs" class="logs" data-i18n="noRunStarted">No run started.</pre>
313
316
  </section>
package/public/styles.css CHANGED
@@ -346,6 +346,15 @@ button.danger {
346
346
  justify-content: space-between;
347
347
  }
348
348
 
349
+ .run-header-actions {
350
+ display: flex;
351
+ min-width: 0;
352
+ flex-wrap: wrap;
353
+ gap: 10px;
354
+ align-items: center;
355
+ justify-content: flex-end;
356
+ }
357
+
349
358
  .sandbox-card {
350
359
  display: grid;
351
360
  gap: 12px;
@@ -10,19 +10,19 @@ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-cli-chat-")
10
10
  const binPath = path.join(repoRoot, "bin/aginti-cli.js");
11
11
 
12
12
  function runChat(inputText) {
13
+ return runCli(["chat", "--provider", "mock", "--routing", "manual", "--profile", "code"], inputText);
14
+ }
15
+
16
+ function runCli(args, inputText) {
13
17
  return new Promise((resolve, reject) => {
14
- const child = spawn(
15
- process.execPath,
16
- [binPath, "chat", "--provider", "mock", "--routing", "manual", "--profile", "code"],
17
- {
18
- cwd: tempRoot,
19
- stdio: ["pipe", "pipe", "pipe"],
20
- env: {
21
- ...process.env,
22
- AGINTIFLOW_RUNTIME_DIR: "",
23
- },
24
- }
25
- );
18
+ const child = spawn(process.execPath, [binPath, ...args], {
19
+ cwd: tempRoot,
20
+ stdio: ["pipe", "pipe", "pipe"],
21
+ env: {
22
+ ...process.env,
23
+ AGINTIFLOW_RUNTIME_DIR: "",
24
+ },
25
+ });
26
26
 
27
27
  let stdout = "";
28
28
  let stderr = "";
@@ -60,12 +60,21 @@ try {
60
60
  if (!result.stdout.includes("Interactive agent chat")) {
61
61
  throw new Error("interactive chat did not print its banner");
62
62
  }
63
+ if (!result.stdout.includes("status=running workingOn=") || !result.stdout.includes("status=idle session=")) {
64
+ throw new Error("interactive chat did not print simple run status updates");
65
+ }
66
+
67
+ const latest = await runCli(["resume"], "/exit\n");
68
+ if (!latest.stdout.includes("session=") || !latest.stdout.includes("Interactive agent chat")) {
69
+ throw new Error("bare aginti resume did not open the latest session interactively");
70
+ }
71
+
63
72
  console.log(
64
73
  JSON.stringify(
65
74
  {
66
75
  ok: true,
67
76
  projectRoot: tempRoot,
68
- checks: ["interactive-chat", "mock-file-write"],
77
+ checks: ["interactive-chat", "mock-file-write", "run-status", "resume-latest"],
69
78
  },
70
79
  null,
71
80
  2
@@ -1,6 +1,9 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
- import { exec as execCallback } from "node:child_process";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import { exec as execCallback, spawn } from "node:child_process";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
7
  import { promisify } from "node:util";
5
8
  import { chromium } from "playwright";
6
9
  import { createClient, createPlan, requestNextStep } from "./model-client.js";
@@ -11,13 +14,132 @@ import { ensureDockerSandboxReady, runDockerSandboxCommand } from "./docker-sand
11
14
  import { normalizeWrapperName, runAgentWrapper, wrapperStatusText } from "./tool-wrappers.js";
12
15
  import { evaluateCommandPolicy } from "./command-policy.js";
13
16
  import { redactSensitiveText, redactValue } from "./redaction.js";
14
- import { executeWorkspaceTool, summarizeWorkspaceTools, WORKSPACE_TOOL_NAMES } from "./workspace-tools.js";
17
+ import { executeWorkspaceTool, resolveWorkspacePath, summarizeWorkspaceTools, WORKSPACE_TOOL_NAMES } from "./workspace-tools.js";
15
18
  import { normalizeCanvasPayload } from "./artifact-tunnel.js";
16
19
  import { getTaskProfile } from "./task-profiles.js";
17
20
 
18
21
  const exec = promisify(execCallback);
19
- const BROWSER_TOOLS = new Set(["open_url", "click", "type", "scroll", "press", "back"]);
22
+ const BROWSER_TOOLS = new Set(["open_url", "open_workspace_file", "preview_workspace", "click", "type", "scroll", "press", "back"]);
20
23
  const WORKSPACE_TOOLS = new Set(WORKSPACE_TOOL_NAMES);
24
+ const STATIC_PREVIEW_SERVER_PATH = fileURLToPath(new URL("./static-preview-server.js", import.meta.url));
25
+ const previewServers = new Map();
26
+
27
+ function sleep(ms) {
28
+ return new Promise((resolve) => setTimeout(resolve, ms));
29
+ }
30
+
31
+ function throwIfAborted(config) {
32
+ if (config.abortSignal?.aborted) {
33
+ const reason = config.abortSignal.reason;
34
+ const error = reason instanceof Error ? reason : new Error("Run interrupted by user.");
35
+ error.name = error.name || "AbortError";
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ function isAbortError(error, config = {}) {
41
+ return Boolean(
42
+ config.abortSignal?.aborted ||
43
+ error?.name === "AbortError" ||
44
+ error?.code === "ABORT_ERR" ||
45
+ /aborted|interrupted/i.test(String(error?.message || ""))
46
+ );
47
+ }
48
+
49
+ function abortable(promise, signal) {
50
+ if (!signal) return promise;
51
+ if (signal.aborted) {
52
+ return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error("Run interrupted by user."));
53
+ }
54
+
55
+ return new Promise((resolve, reject) => {
56
+ const onAbort = () => reject(signal.reason instanceof Error ? signal.reason : new Error("Run interrupted by user."));
57
+ signal.addEventListener("abort", onAbort, { once: true });
58
+ promise.then(
59
+ (value) => {
60
+ signal.removeEventListener("abort", onAbort);
61
+ resolve(value);
62
+ },
63
+ (error) => {
64
+ signal.removeEventListener("abort", onAbort);
65
+ reject(error);
66
+ }
67
+ );
68
+ });
69
+ }
70
+
71
+ async function isPortAvailable(port) {
72
+ return new Promise((resolve) => {
73
+ const server = net.createServer();
74
+ server.once("error", () => resolve(false));
75
+ server.once("listening", () => {
76
+ server.close(() => resolve(true));
77
+ });
78
+ server.listen(port, "127.0.0.1");
79
+ });
80
+ }
81
+
82
+ async function findAvailablePort(preferredPort = 8765) {
83
+ const preferred = Number(preferredPort);
84
+ const start = Number.isFinite(preferred) && preferred > 0 ? preferred : 8765;
85
+ for (let port = start; port < start + 80; port += 1) {
86
+ if (await isPortAvailable(port)) return port;
87
+ }
88
+ throw new Error(`No available preview port found near ${start}.`);
89
+ }
90
+
91
+ async function waitForPort(port, signal) {
92
+ for (let attempt = 0; attempt < 30; attempt += 1) {
93
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("Preview interrupted.");
94
+ const connected = await new Promise((resolve) => {
95
+ const socket = net.connect({ host: "127.0.0.1", port });
96
+ socket.once("connect", () => {
97
+ socket.destroy();
98
+ resolve(true);
99
+ });
100
+ socket.once("error", () => {
101
+ socket.destroy();
102
+ resolve(false);
103
+ });
104
+ socket.setTimeout(500, () => {
105
+ socket.destroy();
106
+ resolve(false);
107
+ });
108
+ });
109
+ if (connected) return;
110
+ await sleep(100);
111
+ }
112
+ throw new Error(`Preview server did not become ready on port ${port}.`);
113
+ }
114
+
115
+ async function startPreviewServer(root, preferredPort, signal) {
116
+ const key = path.resolve(root);
117
+ const existing = previewServers.get(key);
118
+ if (existing && existing.child.exitCode === null) {
119
+ return existing;
120
+ }
121
+
122
+ const port = await findAvailablePort(preferredPort);
123
+ const child = spawn(process.execPath, [STATIC_PREVIEW_SERVER_PATH, key, String(port)], {
124
+ detached: true,
125
+ stdio: "ignore",
126
+ });
127
+ child.unref();
128
+ const server = { root: key, port, child, url: `http://127.0.0.1:${port}/` };
129
+ previewServers.set(key, server);
130
+ await waitForPort(port, signal);
131
+ return server;
132
+ }
133
+
134
+ function normalizeUrlPath(relativePath) {
135
+ const normalized = String(relativePath || ".").replace(/\\/g, "/").replace(/^\/+/, "");
136
+ if (!normalized || normalized === ".") return "";
137
+ return normalized
138
+ .split("/")
139
+ .filter(Boolean)
140
+ .map((part) => encodeURIComponent(part))
141
+ .join("/");
142
+ }
21
143
 
22
144
  function preserveAssistantMessage(message) {
23
145
  const preserved = {
@@ -148,7 +270,7 @@ function createInitialState(config, sessionId) {
148
270
  : "A host shell command tool is available under the configured trust policy."
149
271
  : "No shell command tool is available.",
150
272
  config.allowFileTools
151
- ? `Workspace file tools are available in ${config.commandCwd}: list_files, read_file, search_files, write_file, and apply_patch. Always use workspace-relative paths such as plot_fx.svg or docs/report.tex, never absolute host paths. Secret paths, .git internals, node_modules writes, and huge files are blocked.`
273
+ ? `Workspace file tools are available in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, and preview_workspace. Always use workspace-relative paths such as plot_fx.svg or docs/report.tex, never absolute host paths. Secret paths, .git internals, node_modules writes, and huge files are blocked. For generated local websites/pages, use open_workspace_file or preview_workspace instead of starting a localhost server inside Docker.`
152
274
  : "No workspace file tools are available.",
153
275
  config.allowWrapperTools
154
276
  ? `External coding-agent wrappers are available as advisory tools only. Use the selected wrapper only: ${normalizeWrapperName(config.preferredWrapper)}. Wrapper status: ${wrapperStatusText()}.`
@@ -159,6 +281,7 @@ function createInitialState(config, sessionId) {
159
281
  "Work like a practical coding agent: inspect when useful, edit with file tools, run safe checks when they add confidence, and keep outputs inside the workspace.",
160
282
  "Use the canvas tunnel for outputs the user would likely want to inspect visually, such as figures, PDFs, screenshots, images, important markdown, or generated files.",
161
283
  "For environment or system-maintenance work, use the configured sandbox and package policy; Docker workspace mode is the preferred place for installs and toolchain setup.",
284
+ "If the user asks to open a generated local website or file, use open_workspace_file for a file or preview_workspace for a static site. Do not keep retrying the same localhost URL when a preview fails.",
162
285
  "Docker language/toolchain installs should prefer /aginti-env or project files so they persist across runs; apt/apk changes are ephemeral unless the image is rebuilt.",
163
286
  "If the run is close to the max-step limit, finish with the best complete artifact and honest limitations instead of starting a new approach.",
164
287
  "When the requested outcome is complete and a useful check has passed or been honestly skipped, stop and call finish.",
@@ -176,7 +299,9 @@ function createInitialState(config, sessionId) {
176
299
  ? `Shell working directory mounted into Docker as /workspace from ${config.commandCwd}. Use relative paths or /workspace paths, not absolute host temp paths. Persistent Docker env: /aginti-env, caches: /aginti-cache. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}.`
177
300
  : `Shell working directory: ${config.commandCwd}`
178
301
  : "",
179
- config.allowFileTools ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths.` : "",
302
+ config.allowFileTools
303
+ ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths. Local preview tools available: open_workspace_file and preview_workspace.`
304
+ : "",
180
305
  config.allowWrapperTools
181
306
  ? `Agent wrappers: selected=${normalizeWrapperName(config.preferredWrapper)}; ${wrapperStatusText()}`
182
307
  : "",
@@ -278,7 +403,9 @@ function applyContinuationPrompt(state, config, observers) {
278
403
  ? `Shell working directory mounted into Docker as /workspace from ${config.commandCwd}. Use relative paths or /workspace paths. Persistent Docker env: /aginti-env, caches: /aginti-cache. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}.`
279
404
  : `Shell working directory: ${config.commandCwd}`
280
405
  : "",
281
- config.allowFileTools ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths.` : "",
406
+ config.allowFileTools
407
+ ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths. For generated local files/sites, use open_workspace_file or preview_workspace.`
408
+ : "",
282
409
  config.allowWrapperTools
283
410
  ? `Agent wrappers: selected=${normalizeWrapperName(config.preferredWrapper)}; ${wrapperStatusText()}`
284
411
  : "",
@@ -374,16 +501,68 @@ function sanitizeToolResult(result) {
374
501
  return safeResult;
375
502
  }
376
503
 
504
+ function stableStringify(value) {
505
+ if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
506
+ if (value && typeof value === "object") {
507
+ return `{${Object.keys(value)
508
+ .sort()
509
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
510
+ .join(",")}}`;
511
+ }
512
+ return JSON.stringify(value);
513
+ }
514
+
515
+ async function applyToolLoopGuard(state, toolResult, store, observers) {
516
+ if (!toolResult || toolResult.done || toolResult.ok !== false) return;
517
+ state.meta.toolLoop = state.meta.toolLoop || { recent: [], warned: [] };
518
+ const signature = `${toolResult.toolName}:${stableStringify(toolResult.args || {})}`;
519
+ const entry = {
520
+ signature,
521
+ toolName: toolResult.toolName,
522
+ ok: Boolean(toolResult.ok),
523
+ blocked: Boolean(toolResult.blocked),
524
+ error: toolResult.error || toolResult.reason || "",
525
+ at: new Date().toISOString(),
526
+ };
527
+ state.meta.toolLoop.recent.push(entry);
528
+ state.meta.toolLoop.recent = state.meta.toolLoop.recent.slice(-20);
529
+
530
+ const failures = state.meta.toolLoop.recent.filter((item) => item.signature === signature && item.ok === false).length;
531
+ if (failures < 2 || state.meta.toolLoop.warned.includes(signature)) return;
532
+
533
+ state.meta.toolLoop.warned.push(signature);
534
+ state.meta.toolLoop.warned = state.meta.toolLoop.warned.slice(-20);
535
+ const message = [
536
+ `Loop guard: ${toolResult.toolName} with the same arguments has failed or been blocked ${failures} times.`,
537
+ "Do not repeat that exact call.",
538
+ "If this is a local workspace preview, use open_workspace_file or preview_workspace instead of repeatedly starting localhost servers or opening the same URL.",
539
+ "If enough work is complete, call finish with the usable local path or preview URL.",
540
+ ].join(" ");
541
+ state.messages.push({ role: "user", content: message });
542
+ await store.appendEvent("loop.guard", {
543
+ toolName: toolResult.toolName,
544
+ failures,
545
+ message,
546
+ });
547
+ observers.event("loop.guard", {
548
+ toolName: toolResult.toolName,
549
+ failures,
550
+ message,
551
+ });
552
+ }
553
+
377
554
  async function runShellCommand(command, config, policy = evaluateCommandPolicy(command, config)) {
378
555
  try {
556
+ throwIfAborted(config);
379
557
  const result = config.useDockerSandbox
380
- ? await runDockerSandboxCommand(command, config, policy)
558
+ ? await runDockerSandboxCommand(command, config, policy, { signal: config.abortSignal })
381
559
  : await exec(command, {
382
560
  cwd: config.commandCwd,
383
561
  timeout: 30000,
384
562
  maxBuffer: 200 * 1024,
385
563
  shell: "/bin/bash",
386
564
  env: safeExecutionEnv(),
565
+ signal: config.abortSignal,
387
566
  });
388
567
 
389
568
  return {
@@ -393,6 +572,7 @@ async function runShellCommand(command, config, policy = evaluateCommandPolicy(c
393
572
  stderr: redactSensitiveText(result.stderr).trim().slice(0, 4000),
394
573
  };
395
574
  } catch (error) {
575
+ if (isAbortError(error, config)) throw error;
396
576
  return {
397
577
  ok: false,
398
578
  exitCode: Number.isInteger(error?.code) ? error.code : 1,
@@ -424,6 +604,7 @@ async function captureSyntheticSnapshot(store, step, config) {
424
604
  "For draw/plot/graph/chart/diagram/figure requests, publish a canvas artifact proactively.",
425
605
  "For LaTeX/PDF requests, publish the source and compiled PDF artifacts when available. Keep subfolder outputs beside their source and use pdflatex-compatible figure formats.",
426
606
  "Use open_url only if the task actually needs the web.",
607
+ "For generated local HTML/SVG/PDF/site output, use open_workspace_file or preview_workspace instead of shelling a transient local server.",
427
608
  ]
428
609
  .filter(Boolean)
429
610
  .join(" "),
@@ -469,6 +650,7 @@ async function injectQueuedUserMessages(store, state, observers) {
469
650
  }
470
651
 
471
652
  async function executeTool(browserState, toolCall, snapshot, config, store, observers, state) {
653
+ throwIfAborted(config);
472
654
  const args = JSON.parse(toolCall.function.arguments || "{}");
473
655
  const safeArgs = sanitizeToolArgs(toolCall.function.name, args);
474
656
  const guard = checkToolUse({
@@ -520,8 +702,45 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
520
702
 
521
703
  switch (toolCall.function.name) {
522
704
  case "open_url":
523
- await browserState.page.goto(String(args.url), { waitUntil: "domcontentloaded" });
705
+ await abortable(browserState.page.goto(String(args.url), { waitUntil: "domcontentloaded" }), config.abortSignal);
524
706
  break;
707
+ case "open_workspace_file": {
708
+ const target = resolveWorkspacePath(config, args.path || args.file || ".");
709
+ const stat = await fs.stat(target.absolutePath);
710
+ if (!stat.isFile()) throw new Error(`Workspace preview target is not a file: ${target.relativePath}`);
711
+ const fileUrl = pathToFileURL(target.absolutePath).href;
712
+ await abortable(browserState.page.goto(fileUrl, { waitUntil: "domcontentloaded" }), config.abortSignal);
713
+ const result = {
714
+ ok: true,
715
+ toolName: "open_workspace_file",
716
+ args: safeArgs,
717
+ path: target.relativePath,
718
+ url: browserState.page.url(),
719
+ };
720
+ await store.appendEvent("tool.completed", result);
721
+ observers.event("tool.completed", result);
722
+ return result;
723
+ }
724
+ case "preview_workspace": {
725
+ const target = resolveWorkspacePath(config, args.path || args.file || ".");
726
+ const stat = await fs.stat(target.absolutePath);
727
+ const server = await startPreviewServer(config.commandCwd, args.port || 8765, config.abortSignal);
728
+ const urlPath = stat.isDirectory() ? normalizeUrlPath(target.relativePath === "." ? "" : `${target.relativePath}/`) : normalizeUrlPath(target.relativePath);
729
+ const previewUrl = `${server.url}${urlPath}`;
730
+ await abortable(browserState.page.goto(previewUrl, { waitUntil: "domcontentloaded" }), config.abortSignal);
731
+ const result = {
732
+ ok: true,
733
+ toolName: "preview_workspace",
734
+ args: safeArgs,
735
+ path: target.relativePath,
736
+ url: browserState.page.url(),
737
+ port: server.port,
738
+ root: server.root,
739
+ };
740
+ await store.appendEvent("tool.completed", result);
741
+ observers.event("tool.completed", result);
742
+ return result;
743
+ }
525
744
  case "click": {
526
745
  const locator = browserState.page.locator(`[data-agent-id="${args.id}"]`).first();
527
746
  await locator.scrollIntoViewIfNeeded();
@@ -714,6 +933,7 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
714
933
  observers.event("tool.completed", result);
715
934
  return result;
716
935
  } catch (error) {
936
+ if (isAbortError(error, config)) throw error;
717
937
  const result = {
718
938
  ok: false,
719
939
  toolName: toolCall.function.name,
@@ -776,6 +996,7 @@ export async function runAgent(config) {
776
996
  });
777
997
 
778
998
  try {
999
+ throwIfAborted(config);
779
1000
  if (!state.plan) {
780
1001
  const plan = await createPlan(client, config, state);
781
1002
  state.plan = plan;
@@ -834,6 +1055,7 @@ export async function runAgent(config) {
834
1055
  }
835
1056
 
836
1057
  for (let step = state.stepsCompleted + 1; step <= config.maxSteps; step += 1) {
1058
+ throwIfAborted(config);
837
1059
  await injectQueuedUserMessages(store, state, observers);
838
1060
  const snapshot = await buildSnapshot(browserState, store, step, config);
839
1061
  state.meta.lastUrl = snapshot.url || state.meta.lastUrl;
@@ -881,6 +1103,7 @@ export async function runAgent(config) {
881
1103
  })}`,
882
1104
  });
883
1105
 
1106
+ throwIfAborted(config);
884
1107
  const response = await requestNextStep(client, config, state.messages);
885
1108
  const assistantMessage = response.choices[0]?.message;
886
1109
  if (!assistantMessage) {
@@ -927,12 +1150,14 @@ export async function runAgent(config) {
927
1150
  }
928
1151
 
929
1152
  for (const toolCall of toolCalls) {
1153
+ throwIfAborted(config);
930
1154
  const toolResult = await executeTool(browserState, toolCall, snapshot, config, store, observers, state);
931
1155
  state.messages.push({
932
1156
  role: "tool",
933
1157
  tool_call_id: toolCall.id,
934
1158
  content: JSON.stringify(toolResult),
935
1159
  });
1160
+ await applyToolLoopGuard(state, toolResult, store, observers);
936
1161
 
937
1162
  if (toolResult.toolName === "run_command") {
938
1163
  observers.log("command.output", {
@@ -1014,6 +1239,24 @@ export async function runAgent(config) {
1014
1239
  stopped: true,
1015
1240
  reason: "max_steps_reached",
1016
1241
  };
1242
+ } catch (error) {
1243
+ if (!isAbortError(error, config)) throw error;
1244
+ state.stepsCompleted = state.stepsCompleted || 0;
1245
+ state.updatedAt = new Date().toISOString();
1246
+ await store.saveState(state).catch(() => {});
1247
+ await store.appendEvent("session.stopped", {
1248
+ reason: "user_interrupt",
1249
+ });
1250
+ observers.event("session.stopped", {
1251
+ reason: "user_interrupt",
1252
+ sessionId,
1253
+ });
1254
+ return {
1255
+ sessionId,
1256
+ result: "",
1257
+ stopped: true,
1258
+ reason: "user_interrupt",
1259
+ };
1017
1260
  } finally {
1018
1261
  await closeBrowser(browserState, store);
1019
1262
  }
package/src/cli.js CHANGED
@@ -219,7 +219,7 @@ export function parseArgs(argv) {
219
219
 
220
220
  function printUsage() {
221
221
  console.log(
222
- 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti resume <session-id> ["prompt"] OR aginti queue <session-id> "message" OR aginti [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|mock] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-destructive] [--allow-file-tools|--no-file-tools] [--allow-wrappers --wrapper codex] [--sandbox-status|--sandbox-preflight] "your task"'
222
+ 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti resume [latest|<session-id>] ["prompt"] OR aginti queue <session-id> "message" OR aginti [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|mock] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-destructive] [--allow-file-tools|--no-file-tools] [--allow-wrappers --wrapper codex] [--sandbox-status|--sandbox-preflight] "your task"'
223
223
  );
224
224
  }
225
225
 
@@ -356,6 +356,13 @@ async function handleSessionsCommand(argv) {
356
356
  process.exit(1);
357
357
  }
358
358
 
359
+ async function resolveResumeSessionId(sessionId) {
360
+ if (sessionId && sessionId !== "latest") return sessionId;
361
+ const sessions = await listProjectSessions(process.cwd(), 1);
362
+ if (sessions[0]?.sessionId) return sessions[0].sessionId;
363
+ throw new Error("No project-local sessions found. Run `aginti sessions list` to check this folder.");
364
+ }
365
+
359
366
  async function handleQueueCommand(argv) {
360
367
  const sessionId = argv[0] || "";
361
368
  const content = argv.slice(1).join(" ").trim();
@@ -456,23 +463,21 @@ export async function main(argv = process.argv.slice(2)) {
456
463
  }
457
464
 
458
465
  if (argv[0] === "resume") {
459
- const sessionId = argv[1] || "";
466
+ let sessionId = argv[1] || "";
460
467
  const prompt = argv.slice(2).join(" ").trim();
461
- if (!sessionId) {
462
- console.error('Usage: aginti resume <session-id> ["new prompt"]');
468
+ try {
469
+ sessionId = await resolveResumeSessionId(sessionId);
470
+ } catch (error) {
471
+ console.error(error instanceof Error ? error.message : String(error));
463
472
  process.exit(1);
464
473
  }
465
- if (!prompt && process.stdin.isTTY) {
474
+ if (!prompt) {
466
475
  await startInteractiveCli(agentDefaults({ ...parseArgs([]), resume: sessionId }), {
467
476
  packageDir,
468
477
  packageVersion: packageJson.version,
469
478
  });
470
479
  return;
471
480
  }
472
- if (!prompt) {
473
- console.error('Usage: aginti resume <session-id> "new prompt"');
474
- process.exit(1);
475
- }
476
481
  const config = loadConfig(agentDefaults({ ...parseArgs([prompt]), resume: sessionId, goal: prompt }), { packageDir });
477
482
  await runAgent(config);
478
483
  return;
@@ -37,6 +37,7 @@ async function execDocker(args, options = {}) {
37
37
  const execOptions = {
38
38
  timeout: options.timeout ?? 30000,
39
39
  maxBuffer: options.maxBuffer ?? 200 * 1024,
40
+ signal: options.signal,
40
41
  };
41
42
  recordSandboxLog("docker.command", { args });
42
43
 
@@ -283,11 +284,12 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
283
284
  ];
284
285
  }
285
286
 
286
- export async function runDockerSandboxCommand(command, config, policy = evaluateCommandPolicy(command, config)) {
287
+ export async function runDockerSandboxCommand(command, config, policy = evaluateCommandPolicy(command, config), options = {}) {
287
288
  const persistentDirs = await ensurePersistentDockerDirs(config);
288
289
  const result = await execDocker(dockerRunArgs(command, config, policy, persistentDirs), {
289
290
  timeout: policy.needsNetwork ? 120000 : policy.category === "toolchain" ? 90000 : 15000,
290
291
  maxBuffer: 300 * 1024,
292
+ signal: options.signal,
291
293
  });
292
294
 
293
295
  const payload = {
package/src/guardrails.js CHANGED
@@ -31,6 +31,12 @@ const DESTRUCTIVE_PROMPT_HINTS = [
31
31
  "publish",
32
32
  ];
33
33
 
34
+ function isTransientDockerPreviewCommand(command) {
35
+ return /\bpython3?\s+-m\s+http\.server\b|\bnpx\s+(?:--yes\s+)?(?:serve|http-server)\b|\bnpm\s+exec\s+(?:serve|http-server)\b|\bphp\s+-S\s+127\.0\.0\.1:/i.test(
36
+ command
37
+ );
38
+ }
39
+
34
40
  function normalizeDomain(hostname) {
35
41
  return hostname.replace(/^www\./, "").toLowerCase();
36
42
  }
@@ -66,6 +72,13 @@ export function checkToolUse({ toolName, args, snapshot, config }) {
66
72
  return { allowed: true };
67
73
  }
68
74
 
75
+ if (toolName === "open_workspace_file" || toolName === "preview_workspace") {
76
+ if (!config.allowFileTools) {
77
+ return { allowed: false, reason: "Workspace preview tools require file tools to be enabled.", category: "workspace-tools" };
78
+ }
79
+ return checkWorkspaceToolUse("read_file", { path: args.path || args.file || "." }, config);
80
+ }
81
+
69
82
  if (toolName === "click") {
70
83
  const element = snapshot.elements.find((item) => item.id === String(args.id));
71
84
  if (!element) return { allowed: false, reason: `Element ${args.id} is not in the latest snapshot.` };
@@ -96,6 +109,14 @@ export function checkToolUse({ toolName, args, snapshot, config }) {
96
109
 
97
110
  if (toolName === "run_command") {
98
111
  const command = String(args.command || "").trim();
112
+ if (config.useDockerSandbox && isTransientDockerPreviewCommand(command)) {
113
+ return {
114
+ allowed: false,
115
+ reason:
116
+ "Transient localhost preview servers inside Docker are not useful because command containers stop and ports are not published. Use preview_workspace/open_workspace_file, or switch to host mode for a persistent dev server.",
117
+ category: "preview-server",
118
+ };
119
+ }
99
120
  return evaluateCommandPolicy(command, config);
100
121
  }
101
122
 
@@ -1,4 +1,5 @@
1
1
  import readline from "node:readline/promises";
2
+ import { emitKeypressEvents } from "node:readline";
2
3
  import { stdin as input, stdout as output } from "node:process";
3
4
  import { runAgent } from "./agent-runner.js";
4
5
  import { loadConfig } from "./config.js";
@@ -27,6 +28,7 @@ function printHelp() {
27
28
  " /exit Quit.",
28
29
  "",
29
30
  "Type a normal request to run the agent. Example: write a Python CLI app with tests",
31
+ "While a run is active, press Esc or Ctrl+C once to stop gracefully and print a resume command.",
30
32
  ].join("\n")
31
33
  );
32
34
  }
@@ -35,6 +37,8 @@ function printStatus(state) {
35
37
  console.log(`project=${process.cwd()}`);
36
38
  console.log(`cwd=${state.commandCwd || process.cwd()}`);
37
39
  console.log(`session=${state.sessionId || "new"}`);
40
+ console.log(`status=${state.status || "idle"}${state.activeGoal ? ` workingOn=${state.activeGoal}` : ""}`);
41
+ if (state.lastEvent) console.log(`last=${state.lastEvent}`);
38
42
  console.log(`provider=${state.provider || "auto"} routing=${state.routingMode} model=${state.model || "auto"}`);
39
43
  console.log(`profile=${state.taskProfile} maxSteps=${state.maxSteps}`);
40
44
  console.log(
@@ -49,7 +53,46 @@ function isAbortError(error) {
49
53
  return error?.code === "ABORT_ERR" || error?.name === "AbortError";
50
54
  }
51
55
 
52
- function printResumeHint(state) {
56
+ function formatSessionLine(session) {
57
+ const goal = session.goal ? ` ${session.goal.slice(0, 72)}` : "";
58
+ return `${session.sessionId} ${session.provider || "unknown"}/${session.model || "unknown"} ${session.updatedAt || ""}${goal}`.trim();
59
+ }
60
+
61
+ async function latestSession() {
62
+ const sessions = await listProjectSessions(process.cwd(), 1);
63
+ return sessions[0] || null;
64
+ }
65
+
66
+ function printStatusEvent(state, label, details = "") {
67
+ state.lastEvent = details ? `${label}: ${details}` : label;
68
+ console.log(`status=${state.status || "running"} ${state.lastEvent}`);
69
+ }
70
+
71
+ function attachRunInterrupts(controller) {
72
+ if (!input.isTTY || typeof input.setRawMode !== "function") return () => {};
73
+
74
+ emitKeypressEvents(input);
75
+ const wasRaw = Boolean(input.isRaw);
76
+ input.setRawMode(true);
77
+ const handler = (_str, key = {}) => {
78
+ const isEscape = key.name === "escape";
79
+ const isCtrlC = key.ctrl && key.name === "c";
80
+ if (!isEscape && !isCtrlC) return;
81
+ if (controller.signal.aborted) return;
82
+ const reason = isEscape ? "escape" : "ctrl-c";
83
+ console.log(`\nstatus=stopping reason=${reason}`);
84
+ controller.abort(new Error(`Interrupted by ${reason}.`));
85
+ };
86
+ input.on("keypress", handler);
87
+ return () => {
88
+ input.off("keypress", handler);
89
+ if (typeof input.setRawMode === "function") {
90
+ input.setRawMode(wasRaw);
91
+ }
92
+ };
93
+ }
94
+
95
+ async function printResumeHint(state) {
53
96
  const sessionId = state.sessionId || "";
54
97
  console.log("");
55
98
  if (sessionId) {
@@ -58,7 +101,15 @@ function printResumeHint(state) {
58
101
  console.log(`One-shot: aginti resume ${sessionId} "continue"`);
59
102
  } else {
60
103
  console.log("Interrupted. No active session yet.");
61
- console.log("Restart: aginti");
104
+ const sessions = await listProjectSessions(process.cwd(), 5).catch(() => []);
105
+ if (sessions.length > 0) {
106
+ console.log("Recent sessions:");
107
+ for (const session of sessions) console.log(` ${formatSessionLine(session)}`);
108
+ console.log(`Resume latest: aginti resume ${sessions[0].sessionId}`);
109
+ console.log("List all: aginti sessions list");
110
+ } else {
111
+ console.log("Restart: aginti");
112
+ }
62
113
  }
63
114
  }
64
115
 
@@ -101,8 +152,15 @@ async function handleCommand(line, state, packageDir) {
101
152
  return true;
102
153
  }
103
154
  if (command === "resume") {
104
- if (!value) console.log("Usage: /resume <session-id>");
105
- else {
155
+ if (!value || value === "latest") {
156
+ const latest = await latestSession();
157
+ if (!latest) {
158
+ console.log("No project-local sessions found. Use /new or type a request to start one.");
159
+ } else {
160
+ state.sessionId = latest.sessionId;
161
+ console.log(`Resuming latest ${formatSessionLine(latest)}`);
162
+ }
163
+ } else {
106
164
  state.sessionId = value;
107
165
  console.log(`Resuming ${state.sessionId}`);
108
166
  }
@@ -194,6 +252,7 @@ async function handleCommand(line, state, packageDir) {
194
252
  }
195
253
 
196
254
  async function runPrompt(prompt, state, packageDir) {
255
+ const controller = new AbortController();
197
256
  const config = loadConfig(
198
257
  {
199
258
  provider: state.provider,
@@ -216,8 +275,51 @@ async function runPrompt(prompt, state, packageDir) {
216
275
  { packageDir, baseDir: process.cwd() }
217
276
  );
218
277
 
219
- const result = await runAgent(config);
278
+ state.sessionId = config.resume || config.sessionId || state.sessionId;
279
+ state.status = "running";
280
+ state.activeGoal = prompt.replace(/\s+/g, " ").slice(0, 120);
281
+ state.lastEvent = "";
282
+ console.log(`session=${state.sessionId}`);
283
+ console.log(`status=running workingOn=${state.activeGoal}`);
284
+
285
+ const detachInterrupts = attachRunInterrupts(controller);
286
+ let result;
287
+ try {
288
+ result = await runAgent({
289
+ ...config,
290
+ abortSignal: controller.signal,
291
+ onEvent: (type, data = {}) => {
292
+ if (type === "plan.created") {
293
+ printStatusEvent(state, "planned");
294
+ } else if (type === "tool.started") {
295
+ printStatusEvent(state, "tool", data.toolName || "unknown");
296
+ } else if (type === "tool.completed") {
297
+ printStatusEvent(state, "tool_done", data.toolName || "unknown");
298
+ } else if (type === "tool.blocked") {
299
+ printStatusEvent(state, "tool_blocked", data.toolName || data.reason || "unknown");
300
+ } else if (type === "loop.guard") {
301
+ printStatusEvent(state, "loop_guard", data.toolName || "");
302
+ } else if (type === "conversation.queued_input_applied") {
303
+ printStatusEvent(state, "queued_input_applied");
304
+ } else if (type === "session.finished") {
305
+ printStatusEvent(state, "finished");
306
+ } else if (type === "session.stopped") {
307
+ printStatusEvent(state, "stopped", data.reason || "");
308
+ } else if (type === "model.responded") {
309
+ printStatusEvent(state, "model_responded", data.content ? data.content.slice(0, 80).replace(/\s+/g, " ") : "");
310
+ }
311
+ },
312
+ });
313
+ } finally {
314
+ detachInterrupts();
315
+ }
220
316
  state.sessionId = result.sessionId || state.sessionId;
317
+ state.status = result.stopped ? "stopped" : "idle";
318
+ state.activeGoal = "";
319
+ console.log(`status=${state.status} session=${state.sessionId}`);
320
+ if (result.stopped && result.reason === "user_interrupt") {
321
+ await printResumeHint(state);
322
+ }
221
323
  }
222
324
 
223
325
  export async function startInteractiveCli(args = {}, { packageDir, packageVersion } = {}) {
@@ -237,7 +339,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
237
339
  } catch (error) {
238
340
  if (error?.code === "ERR_USE_AFTER_CLOSE") break;
239
341
  if (isAbortError(error)) {
240
- printResumeHint(state);
342
+ await printResumeHint(state);
241
343
  break;
242
344
  }
243
345
  throw error;
@@ -254,7 +356,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
254
356
  await runPrompt(line, state, packageDir);
255
357
  } catch (error) {
256
358
  if (isAbortError(error)) {
257
- printResumeHint(state);
359
+ await printResumeHint(state);
258
360
  break;
259
361
  }
260
362
  console.error(`error: ${error.message}`);
@@ -53,6 +53,10 @@ function prepareMessages(config, messages) {
53
53
  });
54
54
  }
55
55
 
56
+ function requestOptions(config) {
57
+ return config.abortSignal ? { signal: config.abortSignal } : undefined;
58
+ }
59
+
56
60
  function mockCommandForGoal(goal = "") {
57
61
  const text = String(goal).toLowerCase();
58
62
  if (/\blist\b|folder contents|directory contents|files?/.test(text)) return "ls -la";
@@ -94,6 +98,18 @@ function mockWorkspaceToolForGoal(goal = "") {
94
98
  return null;
95
99
  }
96
100
 
101
+ function mockPreviewToolForGoal(goal = "") {
102
+ const text = String(goal).toLowerCase();
103
+ if (!/(open|preview|view|browser|website|web\s*site)/.test(text)) return null;
104
+ const targetPath = mockPathForGoal(goal);
105
+ if (targetPath === "mock-output.txt") return null;
106
+ if (!/\.(html|htm|svg|png|jpe?g|webp|pdf|txt|md)$/i.test(targetPath)) return null;
107
+ return mockToolCall("preview_workspace", {
108
+ path: targetPath,
109
+ port: 8765,
110
+ });
111
+ }
112
+
97
113
  function mockCanvasToolForGoal(goal = "") {
98
114
  const text = String(goal).toLowerCase();
99
115
  if (!/canvas|artifact|image|figure|visual|preview|render/.test(text)) return null;
@@ -130,10 +146,11 @@ export async function createPlan(client, config, state) {
130
146
  ].join("\n");
131
147
  }
132
148
 
133
- const response = await client.chat.completions.create({
134
- model: config.model,
135
- temperature: 0,
136
- messages: [
149
+ const response = await client.chat.completions.create(
150
+ {
151
+ model: config.model,
152
+ temperature: 0,
153
+ messages: [
137
154
  {
138
155
  role: "system",
139
156
  content:
@@ -149,7 +166,7 @@ export async function createPlan(client, config, state) {
149
166
  ? `Shell tool is enabled in ${config.commandCwd}. In Docker, this path is mounted as /workspace with persistent /aginti-env and /aginti-cache mounts. Use relative paths or /workspace paths, not absolute host temp paths. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}. For npm/pip/conda/venv setup, explain the need and wait for approval unless policy is allow.`
150
167
  : "",
151
168
  config.allowFileTools
152
- ? `Workspace file tools are enabled in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets.`
169
+ ? `Workspace file tools are enabled in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, preview_workspace. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets. For generated local HTML/SVG/PDF/static sites, plan to use open_workspace_file or preview_workspace rather than starting a localhost server inside Docker.`
153
170
  : "",
154
171
  config.allowWrapperTools
155
172
  ? `Agent wrappers are enabled. Use the selected wrapper only: ${normalizeWrapperName(config.preferredWrapper)}. Status: ${wrapperStatusText()}.`
@@ -160,6 +177,7 @@ export async function createPlan(client, config, state) {
160
177
  "Use the canvas tunnel for outputs the user would likely want to inspect visually, such as figures, PDFs, screenshots, images, important markdown, or generated files.",
161
178
  "For environment or system-maintenance work, prefer project-local dry-run plans/scripts unless the configured policy explicitly allows stronger actions.",
162
179
  "Docker language/toolchain installs should prefer /aginti-env or project files so they persist across runs; apt/apk changes are ephemeral unless the image is rebuilt.",
180
+ "If a localhost/browser preview fails, do not loop on the same URL. Switch to open_workspace_file or preview_workspace, or finish with the local path and honest limitation.",
163
181
  "If the run is close to the max-step limit, finish with the best complete artifact and honest limitations instead of starting a new approach.",
164
182
  "Plan for a complete result, not endless exploration; finish once the request is satisfied and checks have passed or been honestly skipped.",
165
183
  "Return a numbered plan only.",
@@ -167,8 +185,10 @@ export async function createPlan(client, config, state) {
167
185
  .filter(Boolean)
168
186
  .join("\n"),
169
187
  },
170
- ],
171
- });
188
+ ],
189
+ },
190
+ requestOptions(config)
191
+ );
172
192
 
173
193
  return response.choices[0]?.message?.content?.trim() || "1. Inspect the page.\n2. Use the smallest safe action.\n3. Finish with a concise answer.";
174
194
  }
@@ -179,7 +199,8 @@ export async function requestNextStep(client, config, messages) {
179
199
  type: "function",
180
200
  function: {
181
201
  name: "open_url",
182
- description: "Open an absolute http or https URL in the browser.",
202
+ description:
203
+ "Open a remote absolute http or https URL in the browser. Do not use this for generated local workspace files or localhost preview loops; use open_workspace_file or preview_workspace when available.",
183
204
  parameters: {
184
205
  type: "object",
185
206
  properties: {
@@ -296,6 +317,45 @@ export async function requestNextStep(client, config, messages) {
296
317
  },
297
318
  ];
298
319
 
320
+ if (config.allowFileTools) {
321
+ tools.splice(
322
+ 0,
323
+ 0,
324
+ {
325
+ type: "function",
326
+ function: {
327
+ name: "open_workspace_file",
328
+ description:
329
+ "Open a workspace-local file directly in the browser, such as generated HTML, SVG, PNG, PDF, or text. Prefer this over starting a localhost server when the user asks to open a generated local page.",
330
+ parameters: {
331
+ type: "object",
332
+ properties: {
333
+ path: { type: "string", description: "Workspace-relative file path to open." },
334
+ },
335
+ required: ["path"],
336
+ additionalProperties: false,
337
+ },
338
+ },
339
+ },
340
+ {
341
+ type: "function",
342
+ function: {
343
+ name: "preview_workspace",
344
+ description:
345
+ "Start a persistent host-side static preview server for a workspace file or directory, automatically choosing a free port, then open it in the browser. Use this for generated local websites instead of running python -m http.server inside Docker or repeatedly opening localhost URLs.",
346
+ parameters: {
347
+ type: "object",
348
+ properties: {
349
+ path: { type: "string", description: "Workspace-relative file or directory to preview. Defaults to ." },
350
+ port: { type: "integer", description: "Preferred localhost port. The runtime chooses another if busy." },
351
+ },
352
+ additionalProperties: false,
353
+ },
354
+ },
355
+ }
356
+ );
357
+ }
358
+
299
359
  if (config.allowShellTool) {
300
360
  tools.splice(-1, 0, {
301
361
  type: "function",
@@ -495,6 +555,13 @@ export async function requestNextStep(client, config, messages) {
495
555
  ]);
496
556
  }
497
557
 
558
+ if (config.allowFileTools) {
559
+ const previewTool = mockPreviewToolForGoal(config.goal);
560
+ if (previewTool) {
561
+ return mockChatResponse("Mock mode will exercise the workspace preview tool.", [previewTool]);
562
+ }
563
+ }
564
+
498
565
  const canvasTool = mockCanvasToolForGoal(config.goal);
499
566
  if (canvasTool) {
500
567
  return mockChatResponse("Mock mode will publish a canvas artifact for the UI tunnel.", [canvasTool]);
@@ -520,12 +587,15 @@ export async function requestNextStep(client, config, messages) {
520
587
  ]);
521
588
  }
522
589
 
523
- return client.chat.completions.create({
524
- model: config.model,
525
- temperature: 0,
526
- tool_choice: "auto",
527
- parallel_tool_calls: false,
528
- messages: prepareMessages(config, messages),
529
- tools,
530
- });
590
+ return client.chat.completions.create(
591
+ {
592
+ model: config.model,
593
+ temperature: 0,
594
+ tool_choice: "auto",
595
+ parallel_tool_calls: false,
596
+ messages: prepareMessages(config, messages),
597
+ tools,
598
+ },
599
+ requestOptions(config)
600
+ );
531
601
  }
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs/promises";
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+
5
+ const root = path.resolve(process.argv[2] || process.cwd());
6
+ const port = Number(process.argv[3] || 0);
7
+ const host = "127.0.0.1";
8
+
9
+ const MIME_TYPES = new Map([
10
+ [".html", "text/html; charset=utf-8"],
11
+ [".htm", "text/html; charset=utf-8"],
12
+ [".css", "text/css; charset=utf-8"],
13
+ [".js", "text/javascript; charset=utf-8"],
14
+ [".mjs", "text/javascript; charset=utf-8"],
15
+ [".json", "application/json; charset=utf-8"],
16
+ [".svg", "image/svg+xml"],
17
+ [".png", "image/png"],
18
+ [".jpg", "image/jpeg"],
19
+ [".jpeg", "image/jpeg"],
20
+ [".gif", "image/gif"],
21
+ [".webp", "image/webp"],
22
+ [".pdf", "application/pdf"],
23
+ [".txt", "text/plain; charset=utf-8"],
24
+ [".md", "text/markdown; charset=utf-8"],
25
+ ]);
26
+
27
+ function send(res, statusCode, body, headers = {}) {
28
+ res.writeHead(statusCode, {
29
+ "Cache-Control": "no-store",
30
+ "X-Content-Type-Options": "nosniff",
31
+ ...headers,
32
+ });
33
+ res.end(body);
34
+ }
35
+
36
+ function normalizeUrlPath(urlPath) {
37
+ const decoded = decodeURIComponent(urlPath.split("?")[0] || "/");
38
+ const withoutLeadingSlash = decoded.replace(/^\/+/, "") || "index.html";
39
+ return path.normalize(withoutLeadingSlash);
40
+ }
41
+
42
+ function isInsideRoot(target) {
43
+ const relative = path.relative(root, target);
44
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
45
+ }
46
+
47
+ function isBlockedPath(relativePath) {
48
+ const segments = relativePath.split(path.sep).filter(Boolean);
49
+ const lowerBase = (segments.at(-1) || "").toLowerCase();
50
+ const lowerPath = segments.join("/").toLowerCase();
51
+ if (segments.includes(".git") || segments.includes("node_modules") || segments.includes(".sessions")) return true;
52
+ if (lowerBase === ".env" || lowerBase.startsWith(".env.")) return true;
53
+ if (lowerBase === ".npmrc" || lowerBase === ".pypirc") return true;
54
+ return /(^|\/)(secrets?|tokens?|passwords?|private[-_]?keys?|credentials?)(\/|\.|$)/i.test(lowerPath);
55
+ }
56
+
57
+ const server = http.createServer(async (req, res) => {
58
+ if (!["GET", "HEAD"].includes(req.method || "")) {
59
+ send(res, 405, "Method Not Allowed");
60
+ return;
61
+ }
62
+
63
+ try {
64
+ const relative = normalizeUrlPath(req.url || "/");
65
+ const target = path.resolve(root, relative);
66
+ if (!isInsideRoot(target) || isBlockedPath(path.relative(root, target))) {
67
+ send(res, 403, "Forbidden");
68
+ return;
69
+ }
70
+
71
+ let filePath = target;
72
+ const stat = await fs.stat(filePath).catch(() => null);
73
+ if (stat?.isDirectory()) {
74
+ filePath = path.join(filePath, "index.html");
75
+ }
76
+
77
+ const finalStat = await fs.stat(filePath).catch(() => null);
78
+ if (!finalStat?.isFile()) {
79
+ send(res, 404, "Not Found");
80
+ return;
81
+ }
82
+
83
+ const contentType = MIME_TYPES.get(path.extname(filePath).toLowerCase()) || "application/octet-stream";
84
+ res.writeHead(200, {
85
+ "Content-Type": contentType,
86
+ "Content-Length": finalStat.size,
87
+ "Cache-Control": "no-store",
88
+ "X-Content-Type-Options": "nosniff",
89
+ });
90
+ if (req.method === "HEAD") {
91
+ res.end();
92
+ return;
93
+ }
94
+ const content = await fs.readFile(filePath);
95
+ res.end(content);
96
+ } catch (error) {
97
+ send(res, 500, error instanceof Error ? error.message : String(error));
98
+ }
99
+ });
100
+
101
+ server.listen(port, host, () => {
102
+ const address = server.address();
103
+ const actualPort = typeof address === "object" && address ? address.port : port;
104
+ console.log(`AgInTiFlow static preview http://${host}:${actualPort}/ root=${root}`);
105
+ });
@@ -145,7 +145,7 @@ export function summarizeWorkspaceTools(config) {
145
145
  return {
146
146
  enabled: Boolean(config.allowFileTools),
147
147
  workspace: workspaceRoot(config),
148
- tools: WORKSPACE_TOOL_NAMES,
148
+ tools: [...WORKSPACE_TOOL_NAMES, "open_workspace_file", "preview_workspace"],
149
149
  writeTools: WORKSPACE_WRITE_TOOL_NAMES,
150
150
  limits: {
151
151
  maxReadBytes: MAX_READ_BYTES,
package/web.js CHANGED
@@ -455,6 +455,8 @@ function createRunRecord(config, goal, existingLogs = []) {
455
455
  }
456
456
 
457
457
  function wireRun(record, config) {
458
+ const abortController = new AbortController();
459
+ record.abortController = abortController;
458
460
  const push = (kind, message, data = {}) => {
459
461
  record.logs.push({
460
462
  at: new Date().toISOString(),
@@ -470,6 +472,7 @@ function wireRun(record, config) {
470
472
 
471
473
  void runAgent({
472
474
  ...config,
475
+ abortSignal: abortController.signal,
473
476
  onLog: (message, data = {}) => push("log", message, data),
474
477
  onEvent: (type, data = {}) => push("event", type, data),
475
478
  })
@@ -846,6 +849,30 @@ app.get("/api/runs/:sessionId", async (req, res) => {
846
849
  res.json(stored);
847
850
  });
848
851
 
852
+ app.post("/api/runs/:sessionId/stop", async (req, res) => {
853
+ const run = runs.get(req.params.sessionId);
854
+ if (!run) {
855
+ res.status(404).json({ error: "Run not found." });
856
+ return;
857
+ }
858
+ if (run.status !== "running") {
859
+ res.status(409).json({ error: "Run is not active.", status: run.status });
860
+ return;
861
+ }
862
+
863
+ run.abortController?.abort(new Error("Stopped from web UI."));
864
+ run.logs.push({
865
+ at: new Date().toISOString(),
866
+ kind: "event",
867
+ message: "session.stop_requested",
868
+ data: { source: "web" },
869
+ });
870
+ run.updatedAt = new Date().toISOString();
871
+ db.upsertSession(run);
872
+ await sessionStore(req.params.sessionId).appendEvent("session.stop_requested", { source: "web" }).catch(() => {});
873
+ res.json({ ok: true, sessionId: req.params.sessionId });
874
+ });
875
+
849
876
  app.get("/health", (_req, res) => {
850
877
  res.json({ ok: true, port });
851
878
  });