@lazyingart/agintiflow 0.8.4 → 0.8.6

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.
@@ -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
  : "",
@@ -204,6 +329,16 @@ function createObservers(config) {
204
329
  };
205
330
  }
206
331
 
332
+ function emitConsole(config, value = "", options = {}) {
333
+ if (typeof config.onConsole === "function") {
334
+ config.onConsole(String(value), options);
335
+ return;
336
+ }
337
+
338
+ if (options.error) console.error(value);
339
+ else console.log(value);
340
+ }
341
+
207
342
  function createBrowserState() {
208
343
  return {
209
344
  browser: null,
@@ -278,7 +413,9 @@ function applyContinuationPrompt(state, config, observers) {
278
413
  ? `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
414
  : `Shell working directory: ${config.commandCwd}`
280
415
  : "",
281
- config.allowFileTools ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths.` : "",
416
+ config.allowFileTools
417
+ ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths. For generated local files/sites, use open_workspace_file or preview_workspace.`
418
+ : "",
282
419
  config.allowWrapperTools
283
420
  ? `Agent wrappers: selected=${normalizeWrapperName(config.preferredWrapper)}; ${wrapperStatusText()}`
284
421
  : "",
@@ -374,16 +511,68 @@ function sanitizeToolResult(result) {
374
511
  return safeResult;
375
512
  }
376
513
 
514
+ function stableStringify(value) {
515
+ if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
516
+ if (value && typeof value === "object") {
517
+ return `{${Object.keys(value)
518
+ .sort()
519
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
520
+ .join(",")}}`;
521
+ }
522
+ return JSON.stringify(value);
523
+ }
524
+
525
+ async function applyToolLoopGuard(state, toolResult, store, observers) {
526
+ if (!toolResult || toolResult.done || toolResult.ok !== false) return;
527
+ state.meta.toolLoop = state.meta.toolLoop || { recent: [], warned: [] };
528
+ const signature = `${toolResult.toolName}:${stableStringify(toolResult.args || {})}`;
529
+ const entry = {
530
+ signature,
531
+ toolName: toolResult.toolName,
532
+ ok: Boolean(toolResult.ok),
533
+ blocked: Boolean(toolResult.blocked),
534
+ error: toolResult.error || toolResult.reason || "",
535
+ at: new Date().toISOString(),
536
+ };
537
+ state.meta.toolLoop.recent.push(entry);
538
+ state.meta.toolLoop.recent = state.meta.toolLoop.recent.slice(-20);
539
+
540
+ const failures = state.meta.toolLoop.recent.filter((item) => item.signature === signature && item.ok === false).length;
541
+ if (failures < 2 || state.meta.toolLoop.warned.includes(signature)) return;
542
+
543
+ state.meta.toolLoop.warned.push(signature);
544
+ state.meta.toolLoop.warned = state.meta.toolLoop.warned.slice(-20);
545
+ const message = [
546
+ `Loop guard: ${toolResult.toolName} with the same arguments has failed or been blocked ${failures} times.`,
547
+ "Do not repeat that exact call.",
548
+ "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.",
549
+ "If enough work is complete, call finish with the usable local path or preview URL.",
550
+ ].join(" ");
551
+ state.messages.push({ role: "user", content: message });
552
+ await store.appendEvent("loop.guard", {
553
+ toolName: toolResult.toolName,
554
+ failures,
555
+ message,
556
+ });
557
+ observers.event("loop.guard", {
558
+ toolName: toolResult.toolName,
559
+ failures,
560
+ message,
561
+ });
562
+ }
563
+
377
564
  async function runShellCommand(command, config, policy = evaluateCommandPolicy(command, config)) {
378
565
  try {
566
+ throwIfAborted(config);
379
567
  const result = config.useDockerSandbox
380
- ? await runDockerSandboxCommand(command, config, policy)
568
+ ? await runDockerSandboxCommand(command, config, policy, { signal: config.abortSignal })
381
569
  : await exec(command, {
382
570
  cwd: config.commandCwd,
383
571
  timeout: 30000,
384
572
  maxBuffer: 200 * 1024,
385
573
  shell: "/bin/bash",
386
574
  env: safeExecutionEnv(),
575
+ signal: config.abortSignal,
387
576
  });
388
577
 
389
578
  return {
@@ -393,6 +582,7 @@ async function runShellCommand(command, config, policy = evaluateCommandPolicy(c
393
582
  stderr: redactSensitiveText(result.stderr).trim().slice(0, 4000),
394
583
  };
395
584
  } catch (error) {
585
+ if (isAbortError(error, config)) throw error;
396
586
  return {
397
587
  ok: false,
398
588
  exitCode: Number.isInteger(error?.code) ? error.code : 1,
@@ -424,6 +614,7 @@ async function captureSyntheticSnapshot(store, step, config) {
424
614
  "For draw/plot/graph/chart/diagram/figure requests, publish a canvas artifact proactively.",
425
615
  "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
616
  "Use open_url only if the task actually needs the web.",
617
+ "For generated local HTML/SVG/PDF/site output, use open_workspace_file or preview_workspace instead of shelling a transient local server.",
427
618
  ]
428
619
  .filter(Boolean)
429
620
  .join(" "),
@@ -469,6 +660,7 @@ async function injectQueuedUserMessages(store, state, observers) {
469
660
  }
470
661
 
471
662
  async function executeTool(browserState, toolCall, snapshot, config, store, observers, state) {
663
+ throwIfAborted(config);
472
664
  const args = JSON.parse(toolCall.function.arguments || "{}");
473
665
  const safeArgs = sanitizeToolArgs(toolCall.function.name, args);
474
666
  const guard = checkToolUse({
@@ -520,8 +712,45 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
520
712
 
521
713
  switch (toolCall.function.name) {
522
714
  case "open_url":
523
- await browserState.page.goto(String(args.url), { waitUntil: "domcontentloaded" });
715
+ await abortable(browserState.page.goto(String(args.url), { waitUntil: "domcontentloaded" }), config.abortSignal);
524
716
  break;
717
+ case "open_workspace_file": {
718
+ const target = resolveWorkspacePath(config, args.path || args.file || ".");
719
+ const stat = await fs.stat(target.absolutePath);
720
+ if (!stat.isFile()) throw new Error(`Workspace preview target is not a file: ${target.relativePath}`);
721
+ const fileUrl = pathToFileURL(target.absolutePath).href;
722
+ await abortable(browserState.page.goto(fileUrl, { waitUntil: "domcontentloaded" }), config.abortSignal);
723
+ const result = {
724
+ ok: true,
725
+ toolName: "open_workspace_file",
726
+ args: safeArgs,
727
+ path: target.relativePath,
728
+ url: browserState.page.url(),
729
+ };
730
+ await store.appendEvent("tool.completed", result);
731
+ observers.event("tool.completed", result);
732
+ return result;
733
+ }
734
+ case "preview_workspace": {
735
+ const target = resolveWorkspacePath(config, args.path || args.file || ".");
736
+ const stat = await fs.stat(target.absolutePath);
737
+ const server = await startPreviewServer(config.commandCwd, args.port || 8765, config.abortSignal);
738
+ const urlPath = stat.isDirectory() ? normalizeUrlPath(target.relativePath === "." ? "" : `${target.relativePath}/`) : normalizeUrlPath(target.relativePath);
739
+ const previewUrl = `${server.url}${urlPath}`;
740
+ await abortable(browserState.page.goto(previewUrl, { waitUntil: "domcontentloaded" }), config.abortSignal);
741
+ const result = {
742
+ ok: true,
743
+ toolName: "preview_workspace",
744
+ args: safeArgs,
745
+ path: target.relativePath,
746
+ url: browserState.page.url(),
747
+ port: server.port,
748
+ root: server.root,
749
+ };
750
+ await store.appendEvent("tool.completed", result);
751
+ observers.event("tool.completed", result);
752
+ return result;
753
+ }
525
754
  case "click": {
526
755
  const locator = browserState.page.locator(`[data-agent-id="${args.id}"]`).first();
527
756
  await locator.scrollIntoViewIfNeeded();
@@ -714,6 +943,7 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
714
943
  observers.event("tool.completed", result);
715
944
  return result;
716
945
  } catch (error) {
946
+ if (isAbortError(error, config)) throw error;
717
947
  const result = {
718
948
  ok: false,
719
949
  toolName: toolCall.function.name,
@@ -776,6 +1006,7 @@ export async function runAgent(config) {
776
1006
  });
777
1007
 
778
1008
  try {
1009
+ throwIfAborted(config);
779
1010
  if (!state.plan) {
780
1011
  const plan = await createPlan(client, config, state);
781
1012
  state.plan = plan;
@@ -812,28 +1043,31 @@ export async function runAgent(config) {
812
1043
  startUrl: config.startUrl,
813
1044
  });
814
1045
 
815
- console.log(`Session: ${sessionId}`);
816
- console.log(`Provider: ${config.provider}`);
817
- console.log(`Model: ${config.model}`);
818
- console.log(`Routing: ${config.routingMode} (${config.routeReason})`);
819
- console.log(`Workspace: ${config.commandCwd}`);
820
- console.log(`Sessions: ${config.sessionsDir}`);
1046
+ emitConsole(config, `Session: ${sessionId}`, { kind: "meta" });
1047
+ emitConsole(config, `Provider: ${config.provider}`, { kind: "meta" });
1048
+ emitConsole(config, `Model: ${config.model}`, { kind: "meta" });
1049
+ emitConsole(config, `Routing: ${config.routingMode} (${config.routeReason})`, { kind: "meta" });
1050
+ emitConsole(config, `Workspace: ${config.commandCwd}`, { kind: "meta" });
1051
+ emitConsole(config, `Sessions: ${config.sessionsDir}`, { kind: "meta" });
821
1052
  if (config.useDockerSandbox) {
822
- console.log(
823
- `Docker: image=${config.dockerSandboxImage} mode=${config.sandboxMode} packagePolicy=${config.packageInstallPolicy}`
1053
+ emitConsole(
1054
+ config,
1055
+ `Docker: image=${config.dockerSandboxImage} mode=${config.sandboxMode} packagePolicy=${config.packageInstallPolicy}`,
1056
+ { kind: "meta" }
824
1057
  );
825
- console.log(`Docker workspace: /workspace -> ${config.commandCwd}`);
826
- console.log("Docker env: /aginti-env persistent toolchain; /aginti-cache persistent caches");
1058
+ emitConsole(config, `Docker workspace: /workspace -> ${config.commandCwd}`, { kind: "meta" });
1059
+ emitConsole(config, "Docker env: /aginti-env persistent toolchain; /aginti-cache persistent caches", { kind: "meta" });
827
1060
  } else if (config.allowShellTool) {
828
- console.log(`Shell: host policy=${config.packageInstallPolicy}`);
1061
+ emitConsole(config, `Shell: host policy=${config.packageInstallPolicy}`, { kind: "meta" });
829
1062
  }
830
1063
  if (state.plan) {
831
- console.log("\nPlan:");
832
- console.log(state.plan);
833
- console.log("");
1064
+ emitConsole(config, "\nPlan:", { kind: "heading" });
1065
+ emitConsole(config, state.plan, { kind: "plan", markdown: true });
1066
+ emitConsole(config, "", { kind: "meta" });
834
1067
  }
835
1068
 
836
1069
  for (let step = state.stepsCompleted + 1; step <= config.maxSteps; step += 1) {
1070
+ throwIfAborted(config);
837
1071
  await injectQueuedUserMessages(store, state, observers);
838
1072
  const snapshot = await buildSnapshot(browserState, store, step, config);
839
1073
  state.meta.lastUrl = snapshot.url || state.meta.lastUrl;
@@ -881,6 +1115,7 @@ export async function runAgent(config) {
881
1115
  })}`,
882
1116
  });
883
1117
 
1118
+ throwIfAborted(config);
884
1119
  const response = await requestNextStep(client, config, state.messages);
885
1120
  const assistantMessage = response.choices[0]?.message;
886
1121
  if (!assistantMessage) {
@@ -916,7 +1151,7 @@ export async function runAgent(config) {
916
1151
  result: fallback,
917
1152
  sessionId,
918
1153
  });
919
- console.log(fallback);
1154
+ emitConsole(config, fallback, { kind: "assistant", markdown: true });
920
1155
  state.stepsCompleted = step;
921
1156
  state.updatedAt = new Date().toISOString();
922
1157
  await store.saveState(state);
@@ -927,12 +1162,14 @@ export async function runAgent(config) {
927
1162
  }
928
1163
 
929
1164
  for (const toolCall of toolCalls) {
1165
+ throwIfAborted(config);
930
1166
  const toolResult = await executeTool(browserState, toolCall, snapshot, config, store, observers, state);
931
1167
  state.messages.push({
932
1168
  role: "tool",
933
1169
  tool_call_id: toolCall.id,
934
1170
  content: JSON.stringify(toolResult),
935
1171
  });
1172
+ await applyToolLoopGuard(state, toolResult, store, observers);
936
1173
 
937
1174
  if (toolResult.toolName === "run_command") {
938
1175
  observers.log("command.output", {
@@ -984,7 +1221,7 @@ export async function runAgent(config) {
984
1221
  result: toolResult.result,
985
1222
  sessionId,
986
1223
  });
987
- console.log(toolResult.result);
1224
+ emitConsole(config, toolResult.result, { kind: "assistant", markdown: true });
988
1225
  return {
989
1226
  sessionId,
990
1227
  result: toolResult.result,
@@ -1007,13 +1244,31 @@ export async function runAgent(config) {
1007
1244
  reason: "max_steps_reached",
1008
1245
  sessionId,
1009
1246
  });
1010
- console.error(`Stopped after ${config.maxSteps} steps without finish().`);
1247
+ emitConsole(config, `Stopped after ${config.maxSteps} steps without finish().`, { kind: "error", error: true });
1011
1248
  return {
1012
1249
  sessionId,
1013
1250
  result: "",
1014
1251
  stopped: true,
1015
1252
  reason: "max_steps_reached",
1016
1253
  };
1254
+ } catch (error) {
1255
+ if (!isAbortError(error, config)) throw error;
1256
+ state.stepsCompleted = state.stepsCompleted || 0;
1257
+ state.updatedAt = new Date().toISOString();
1258
+ await store.saveState(state).catch(() => {});
1259
+ await store.appendEvent("session.stopped", {
1260
+ reason: "user_interrupt",
1261
+ });
1262
+ observers.event("session.stopped", {
1263
+ reason: "user_interrupt",
1264
+ sessionId,
1265
+ });
1266
+ return {
1267
+ sessionId,
1268
+ result: "",
1269
+ stopped: true,
1270
+ reason: "user_interrupt",
1271
+ };
1017
1272
  } finally {
1018
1273
  await closeBrowser(browserState, store);
1019
1274
  }
@@ -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