@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 +4 -2
- package/docs/agent-runtime-pipe.md +4 -0
- package/package.json +1 -1
- package/public/app.js +55 -1
- package/public/index.html +4 -1
- package/public/styles.css +9 -0
- package/scripts/smoke-cli-chat.js +22 -13
- package/src/agent-runner.js +251 -8
- package/src/cli.js +14 -9
- package/src/docker-sandbox.js +3 -1
- package/src/guardrails.js +21 -0
- package/src/interactive-cli.js +109 -7
- package/src/model-client.js +86 -16
- package/src/static-preview-server.js +105 -0
- package/src/workspace-tools.js +1 -1
- package/web.js +27 -0
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
|
|
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,
|
|
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
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
|
|
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
|
-
|
|
16
|
-
[
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
package/src/agent-runner.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
466
|
+
let sessionId = argv[1] || "";
|
|
460
467
|
const prompt = argv.slice(2).join(" ").trim();
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
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;
|
package/src/docker-sandbox.js
CHANGED
|
@@ -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
|
|
package/src/interactive-cli.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
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}`);
|
package/src/model-client.js
CHANGED
|
@@ -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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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:
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
+
});
|
package/src/workspace-tools.js
CHANGED
|
@@ -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
|
});
|