@lazyingart/agintiflow 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ aginti init
43
43
  aginti doctor
44
44
  aginti --list-routes
45
45
  aginti --list-profiles
46
- aginti --sandbox-status --sandbox-mode docker-readonly
46
+ aginti --sandbox-status
47
47
  ```
48
48
 
49
49
  Start an interactive Codex-style CLI chat from any project folder:
@@ -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`. Use `/help` for commands, `/docker on` to switch to Docker workspace mode with approved package installs, `/sessions` to list project runs, and `/resume <session-id>` to continue work.
57
+ Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is now Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume <session-id>` to continue work.
58
58
 
59
59
  Launch the local web UI from an installed package:
60
60
 
@@ -81,8 +81,10 @@ aginti doctor --capabilities
81
81
  aginti sessions list
82
82
  aginti sessions show <session-id>
83
83
  aginti resume <session-id> "continue with a short follow-up"
84
+ aginti queue <session-id> "extra instruction for the running agent"
84
85
  aginti --profile code "write a small Python CLI app with tests"
85
- aginti --sandbox-mode docker-workspace --approve-package-installs "set up this project and run the tests"
86
+ aginti --latex "draw a figure, write a short LaTeX report, and compile the PDF"
87
+ aginti "set up this project and run the tests"
86
88
  ```
87
89
 
88
90
  Run from a source checkout:
@@ -194,9 +196,9 @@ AgInTiFlow is intentionally conservative:
194
196
  - File writes record before/after SHA-256 hashes and compact redacted diffs.
195
197
  - Guarded shell mode is policy-based: normal inspection/tests are available, Docker workspace mode can run broader setup/network commands when package installs are approved, and host privileged/destructive work requires explicit trust.
196
198
  - Docker read-only mode mounts the workspace read-only and disables container network access.
197
- - Docker workspace-write mode is the web UI default so plot, PDF, and test outputs can be written inside the mounted workspace.
198
- - Docker workspace-write mode is the preferred place for approved package or environment setup, including `apt-get`, `npm`, `pip`, `curl`, and `wget` workflows inside the container.
199
- - Package installs default to `prompt`, so npm/pip/conda/venv setup is blocked until explicitly approved.
199
+ - Docker workspace-write mode is the CLI/web default so plot, PDF, and test outputs can be written inside the mounted workspace.
200
+ - Docker workspace-write mode with package policy `allow` is the default for normal agent work. It permits practical setup commands such as `npm`, `pip`, `conda`, `curl`, `wget`, and `chmod` inside Docker while keeping host sudo/global installs blocked.
201
+ - Host mode falls back to `prompt`, so npm/pip/conda/venv setup and privileged/destructive commands require explicit trust.
200
202
  - NPM publishing, npm token commands, API-key reads, and credential files stay blocked. Broader shell commands require either Docker workspace mode with package policy `allow`, or explicit host trust.
201
203
  - NPM tokens and API keys are redacted from tool logs and API responses.
202
204
  - Every tool request and result is written to structured logs.
@@ -316,7 +318,7 @@ Build the companion agent toolchain sandbox:
316
318
  npm run smoke:toolchain-docker
317
319
  ```
318
320
 
319
- The setup script idempotently builds `agintiflow-sandbox:latest` from `docker/sandbox.Dockerfile` and verifies Node, npm, Python, NumPy, Matplotlib, `latexmk`, and `pdflatex` inside Docker. The smoke script runs Python plot generation, LaTeX PDF compilation, and PDF artifact preview against the same workspace-mounted sandbox path used by the agent.
321
+ The setup script idempotently builds `agintiflow-sandbox:latest` from `docker/sandbox.Dockerfile` and verifies Node, npm, Python, NumPy, Matplotlib, `latexmk`, and `pdflatex` inside Docker. It also creates the persistent companion folders under `~/.agintiflow/docker/`: `home/` maps to `/aginti-home`, `cache/` maps to `/aginti-cache`, and `env/` maps to `/aginti-env`. Python/conda-style toolchains should live under `/aginti-env` so they survive across agent runs. OS package changes from `apt-get` are container-ephemeral unless you rebuild the image.
320
322
 
321
323
  ## Sandbox Modes
322
324
 
@@ -336,6 +338,8 @@ Package policy values:
336
338
 
337
339
  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.
338
340
 
341
+ The web chat mirrors the CLI session store. Enter sends, Ctrl+J inserts a newline, and Tab submits/queues the message. Queued input is written to `.sessions/<session-id>/inbox.jsonl`; the running agent drains that pipe between steps and after tool calls.
342
+
339
343
  Safe preflight endpoints:
340
344
 
341
345
  ```bash
@@ -21,6 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
21
21
  python3 \
22
22
  ripgrep \
23
23
  sed \
24
+ sudo \
24
25
  texlive-fonts-recommended \
25
26
  texlive-latex-base \
26
27
  texlive-latex-extra \
@@ -0,0 +1,14 @@
1
+ # Agent Runtime Pipe
2
+
3
+ AgInTiFlow keeps CLI and web runs equivalent by using the project folder as the source of truth.
4
+
5
+ - Project root: the folder where `aginti` or `aginti web` is launched.
6
+ - Session store: `<project>/.sessions/`.
7
+ - Web settings database: `<project>/.sessions/web-state.sqlite`.
8
+ - Runtime inbox: `<project>/.sessions/<session-id>/inbox.jsonl`.
9
+
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
+
12
+ 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
+
14
+ The failed `f(f(x)) = f'(x)` LaTeX task exposed three issues: host-mode command policy blocked setup/path commands, a 15-step budget was too small for iterative numerical work plus TeX output, and follow-up input could not be queued while the agent was running. The current runtime defaults and inbox pipe address those without hardcoding that specific math task.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
6
6
  "license": "Apache-2.0",
@@ -44,6 +44,7 @@
44
44
  "scripts/smoke-cli-chat.js",
45
45
  "scripts/smoke-coding-tools.js",
46
46
  "scripts/smoke-capabilities.js",
47
+ "scripts/smoke-inbox.js",
47
48
  "scripts/smoke-toolchain-docker.js",
48
49
  "scripts/smoke-web-api.js",
49
50
  "src/",
@@ -62,9 +63,10 @@
62
63
  "smoke:coding-tools": "node scripts/smoke-coding-tools.js",
63
64
  "smoke:cli-chat": "node scripts/smoke-cli-chat.js",
64
65
  "smoke:toolchain-docker": "node scripts/smoke-toolchain-docker.js",
66
+ "smoke:inbox": "node scripts/smoke-inbox.js",
65
67
  "smoke:web-api": "node scripts/smoke-web-api.js",
66
68
  "real:deepseek": "node scripts/real-deepseek-capabilities.js",
67
- "test": "npm run check && npm run smoke:web-api && npm run smoke:coding-tools && npm run smoke:capabilities && npm run smoke:cli-chat",
69
+ "test": "npm run check && npm run smoke:web-api && npm run smoke:coding-tools && npm run smoke:capabilities && npm run smoke:cli-chat && npm run smoke:inbox",
68
70
  "pack:dry-run": "npm pack --dry-run",
69
71
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
70
72
  },
package/public/app.js CHANGED
@@ -114,7 +114,9 @@ const translations = {
114
114
  messageRequired: "Message is required.",
115
115
  sendingStatus: "Sending...",
116
116
  runningStatus: "Running...",
117
+ queuedStatus: "Queued for the running agent.",
117
118
  failedContinue: "Failed to continue the conversation.",
119
+ chatShortcutHelp: "Enter sends · Ctrl+J inserts a newline · Tab queues while the agent is running",
118
120
  conversationMenuLabel: "Manage conversations",
119
121
  manageConversationsTitle: "Manage conversations",
120
122
  manageConversationsHelp: "Rename, auto-rename, or delete saved chat history.",
@@ -862,8 +864,9 @@ function renderSandboxStatus(status = lastSandbox) {
862
864
  `package=${status.packageInstallPolicy}`,
863
865
  `docker=${status.dockerAvailable ? t("availableLabel") : t("missingLabel")}`,
864
866
  `imageReady=${status.imageReady}`,
867
+ status.persistentDocker?.env ? `env=${status.persistentDocker.env}` : "",
865
868
  ];
866
- sandboxStatusEl.textContent = parts.join(" · ");
869
+ sandboxStatusEl.textContent = parts.filter(Boolean).join(" · ");
867
870
  renderSandboxLogs(status.logs || []);
868
871
  }
869
872
 
@@ -958,7 +961,7 @@ function formPayload() {
958
961
  startUrl: document.querySelector("#startUrl").value.trim(),
959
962
  allowedDomains: document.querySelector("#allowedDomains").value.trim(),
960
963
  commandCwd: document.querySelector("#commandCwd").value.trim(),
961
- maxSteps: Number(document.querySelector("#maxSteps").value) || 15,
964
+ maxSteps: Number(document.querySelector("#maxSteps").value) || 24,
962
965
  sandboxMode: sandboxModeField.value,
963
966
  packageInstallPolicy: packageInstallPolicyField.value,
964
967
  headless: document.querySelector("#headless").checked,
@@ -1746,6 +1749,28 @@ saveApiKeyButton?.addEventListener("click", async () => {
1746
1749
  form.addEventListener("input", schedulePreferenceSave);
1747
1750
  form.addEventListener("change", schedulePreferenceSave);
1748
1751
 
1752
+ chatInputEl.addEventListener("keydown", (event) => {
1753
+ if (event.key === "j" && event.ctrlKey && !event.metaKey && !event.altKey) {
1754
+ event.preventDefault();
1755
+ const start = chatInputEl.selectionStart ?? chatInputEl.value.length;
1756
+ const end = chatInputEl.selectionEnd ?? chatInputEl.value.length;
1757
+ chatInputEl.value = `${chatInputEl.value.slice(0, start)}\n${chatInputEl.value.slice(end)}`;
1758
+ chatInputEl.selectionStart = chatInputEl.selectionEnd = start + 1;
1759
+ return;
1760
+ }
1761
+
1762
+ if (event.key === "Tab") {
1763
+ event.preventDefault();
1764
+ chatFormEl.requestSubmit();
1765
+ return;
1766
+ }
1767
+
1768
+ if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
1769
+ event.preventDefault();
1770
+ chatFormEl.requestSubmit();
1771
+ }
1772
+ });
1773
+
1749
1774
  form.addEventListener("submit", async (event) => {
1750
1775
  event.preventDefault();
1751
1776
 
@@ -1838,9 +1863,19 @@ chatFormEl.addEventListener("submit", async (event) => {
1838
1863
 
1839
1864
  currentSessionId = data.sessionId;
1840
1865
  chatInputEl.value = "";
1841
- chatStatusEl.textContent = t("runningStatus");
1866
+ chatStatusEl.textContent = data.queued ? t("queuedStatus") : t("runningStatus");
1867
+ if (data.queued) {
1868
+ renderChat([
1869
+ ...lastChatEntries,
1870
+ {
1871
+ role: "user",
1872
+ content,
1873
+ at: new Date().toISOString(),
1874
+ },
1875
+ ]);
1876
+ }
1842
1877
  await refreshSessions();
1843
- await refreshChat();
1878
+ if (!data.queued) await refreshChat();
1844
1879
  await refreshWorkspaceChanges();
1845
1880
  await refreshArtifacts({ loadSelected: artifactTunnelDialog?.open });
1846
1881
 
@@ -1872,7 +1907,7 @@ async function loadConfig() {
1872
1907
  document.querySelector("#headless").checked = prefs.headless ?? data.defaults.headless;
1873
1908
  document.querySelector("#maxSteps").value = prefs.maxSteps ?? data.defaults.maxSteps;
1874
1909
  sandboxModeField.value = prefs.sandboxMode || (prefs.useDockerSandbox ? "docker-workspace" : "host");
1875
- packageInstallPolicyField.value = prefs.packageInstallPolicy || "prompt";
1910
+ packageInstallPolicyField.value = prefs.packageInstallPolicy || "allow";
1876
1911
  document.querySelector("#allowShellTool").checked = prefs.allowShellTool ?? true;
1877
1912
  document.querySelector("#allowFileTools").checked = prefs.allowFileTools ?? true;
1878
1913
  allowWrapperToolsField.checked = prefs.allowWrapperTools ?? false;
package/public/index.html CHANGED
@@ -135,7 +135,7 @@
135
135
  </label>
136
136
  <label>
137
137
  <span data-i18n="maxStepsLabel">Max steps</span>
138
- <input id="maxSteps" name="maxSteps" type="number" min="1" max="50" value="15" />
138
+ <input id="maxSteps" name="maxSteps" type="number" min="1" max="50" value="24" />
139
139
  </label>
140
140
  </div>
141
141
  <p id="package-warning" class="subtle package-warning"></p>
@@ -291,6 +291,9 @@
291
291
  data-i18n-placeholder="chatPlaceholder"
292
292
  placeholder="Continue the selected conversation here."
293
293
  ></textarea>
294
+ <p class="chat-shortcuts subtle" data-i18n="chatShortcutHelp">
295
+ Enter sends · Ctrl+J inserts a newline · Tab queues while the agent is running
296
+ </p>
294
297
  <div class="actions">
295
298
  <button type="submit" data-i18n="sendMessageButton">Send message</button>
296
299
  <span id="chat-status" class="subtle"></span>
package/public/styles.css CHANGED
@@ -524,6 +524,10 @@ button.danger {
524
524
  .chat-panel {
525
525
  display: grid;
526
526
  gap: 12px;
527
+ border-color: rgba(15, 118, 110, 0.2);
528
+ background:
529
+ linear-gradient(135deg, rgba(255, 253, 250, 0.95), rgba(236, 253, 245, 0.66)),
530
+ var(--panel);
527
531
  }
528
532
 
529
533
  .chat-thread {
@@ -561,7 +565,25 @@ button.danger {
561
565
  }
562
566
 
563
567
  .chat-form {
568
+ display: grid;
569
+ gap: 8px;
564
570
  margin: 0;
571
+ padding: 12px;
572
+ border: 1px solid rgba(15, 118, 110, 0.18);
573
+ border-radius: 16px;
574
+ background: rgba(255, 253, 250, 0.82);
575
+ }
576
+
577
+ #chat-input {
578
+ min-height: 92px;
579
+ border-color: rgba(15, 118, 110, 0.24);
580
+ background: white;
581
+ }
582
+
583
+ .chat-shortcuts {
584
+ margin: -2px 0 0;
585
+ font-size: 0.78rem;
586
+ line-height: 1.35;
565
587
  }
566
588
 
567
589
  .modal {
@@ -6,6 +6,7 @@ REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
6
6
  IMAGE="${AGINTIFLOW_TOOLCHAIN_IMAGE:-agintiflow-sandbox:latest}"
7
7
  WORKSPACE="${AGINTIFLOW_TOOLCHAIN_WORKSPACE:-${REPO_ROOT}}"
8
8
  DOCKERFILE="${AGINTIFLOW_TOOLCHAIN_DOCKERFILE:-${REPO_ROOT}/docker/sandbox.Dockerfile}"
9
+ STATE_DIR="${AGINTIFLOW_DOCKER_STATE_DIR:-${HOME:-/tmp}/.agintiflow/docker}"
9
10
 
10
11
  if ! command -v docker >/dev/null 2>&1; then
11
12
  echo "Docker is not installed or not on PATH. Run scripts/install-docker-ubuntu.sh first." >&2
@@ -13,11 +14,13 @@ if ! command -v docker >/dev/null 2>&1; then
13
14
  fi
14
15
 
15
16
  mkdir -p "${WORKSPACE}"
17
+ mkdir -p "${STATE_DIR}/home" "${STATE_DIR}/cache" "${STATE_DIR}/env"
16
18
 
17
19
  echo "Building AgInTiFlow toolchain sandbox image:"
18
20
  echo " image=${IMAGE}"
19
21
  echo " dockerfile=${DOCKERFILE}"
20
22
  echo " context=${REPO_ROOT}"
23
+ echo " persistent=${STATE_DIR}"
21
24
  docker build -t "${IMAGE}" -f "${DOCKERFILE}" "${REPO_ROOT}"
22
25
 
23
26
  echo
@@ -30,11 +33,20 @@ docker run --rm \
30
33
  --memory 2g \
31
34
  --cpus 2 \
32
35
  --tmpfs /tmp:rw,nosuid,nodev,size=512m \
33
- -e HOME=/tmp \
36
+ -e HOME=/aginti-home \
37
+ -e XDG_CACHE_HOME=/aginti-cache \
38
+ -e PIP_CACHE_DIR=/aginti-cache/pip \
39
+ -e NPM_CONFIG_CACHE=/aginti-cache/npm \
40
+ -e CONDA_PKGS_DIRS=/aginti-cache/conda-pkgs \
34
41
  -v "${WORKSPACE}:/workspace:rw" \
42
+ -v "${STATE_DIR}/home:/aginti-home:rw" \
43
+ -v "${STATE_DIR}/cache:/aginti-cache:rw" \
44
+ -v "${STATE_DIR}/env:/aginti-env:rw" \
35
45
  -w /workspace \
36
46
  "${IMAGE}" \
37
47
  bash -lc 'set -Eeuo pipefail
48
+ if [ ! -x /aginti-env/python/bin/python ]; then python3 -m venv --system-site-packages /aginti-env/python; fi
49
+ . /aginti-env/python/bin/activate
38
50
  node -v
39
51
  npm -v
40
52
  python3 --version
@@ -62,6 +62,10 @@ try {
62
62
  capabilities.trustedDockerPolicy.some((check) => check.command.startsWith("wget") && check.allowed),
63
63
  "trusted Docker policy did not allow wget"
64
64
  );
65
+ assert(
66
+ capabilities.trustedDockerPolicy.some((check) => check.command.startsWith("chmod") && check.allowed),
67
+ "trusted Docker policy did not allow chmod"
68
+ );
65
69
 
66
70
  const doctor = JSON.parse(await runCli(["doctor", "--capabilities", "--json"]));
67
71
  assert(doctor.project.root === tempRoot, "doctor --capabilities used the wrong project root");
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { SessionStore } from "../src/session-store.js";
6
+
7
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-inbox-"));
8
+
9
+ function assert(condition, message) {
10
+ if (!condition) throw new Error(message);
11
+ }
12
+
13
+ try {
14
+ const store = new SessionStore(path.join(tempRoot, ".sessions"), "inbox-smoke");
15
+ await store.appendInbox("first queued message", { source: "test" });
16
+ await store.appendInbox("second queued message", { source: "test" });
17
+
18
+ const drained = await store.drainInbox();
19
+ assert(drained.length === 2, "inbox did not drain both messages");
20
+ assert(drained[0].content === "first queued message", "first queued message mismatch");
21
+ assert(drained[1].content === "second queued message", "second queued message mismatch");
22
+ assert((await store.drainInbox()).length === 0, "inbox was not cleared after drain");
23
+
24
+ console.log(
25
+ JSON.stringify(
26
+ {
27
+ ok: true,
28
+ checks: ["session-inbox-append", "session-inbox-drain"],
29
+ },
30
+ null,
31
+ 2
32
+ )
33
+ );
34
+ } finally {
35
+ await fs.rm(tempRoot, { recursive: true, force: true });
36
+ }
@@ -76,6 +76,9 @@ try {
76
76
  if (config.preferences?.preferredWrapper !== "codex") throw new Error("Codex is not the default preferred wrapper");
77
77
  if (config.project?.root !== runtimeDir) throw new Error("web project root did not default to launch directory");
78
78
  if (config.preferences?.commandCwd !== runtimeDir) throw new Error("commandCwd did not default to project root");
79
+ if (config.preferences?.sandboxMode !== "docker-workspace") throw new Error("web did not default to docker workspace");
80
+ if (config.preferences?.packageInstallPolicy !== "allow") throw new Error("web did not default to Docker package installs");
81
+ if (Number(config.preferences?.maxSteps) < 24) throw new Error("web default max steps is too low");
79
82
  if (!Array.isArray(config.taskProfiles) || !config.taskProfiles.some((profile) => profile.id === "latex")) {
80
83
  throw new Error("task profiles are not advertised by /api/config");
81
84
  }
@@ -144,7 +144,7 @@ function createInitialState(config, sessionId) {
144
144
  "Avoid destructive actions, purchases, account changes, and sensitive workflows.",
145
145
  config.allowShellTool
146
146
  ? config.useDockerSandbox
147
- ? `A shell command tool is available inside Docker sandbox mode ${config.sandboxMode}. Docker workspace mode with approved package installs supports broader setup and network commands.`
147
+ ? `A shell command tool is available inside Docker sandbox mode ${config.sandboxMode}. Docker workspace mode with approved package installs supports broader setup and network commands. The project is mounted at /workspace and the persistent agent toolchain is mounted at /aginti-env with caches under /aginti-cache.`
148
148
  : "A host shell command tool is available under the configured trust policy."
149
149
  : "No shell command tool is available.",
150
150
  config.allowFileTools
@@ -159,6 +159,8 @@ function createInitialState(config, sessionId) {
159
159
  "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
160
  "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
161
  "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.",
162
+ "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
+ "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.",
162
164
  "When the requested outcome is complete and a useful check has passed or been honestly skipped, stop and call finish.",
163
165
  "When done, call finish with a concise result.",
164
166
  ].join(" "),
@@ -171,7 +173,7 @@ function createInitialState(config, sessionId) {
171
173
  config.allowedDomains.length > 0 ? `Allowed domains: ${config.allowedDomains.join(", ")}` : "",
172
174
  config.allowShellTool
173
175
  ? config.useDockerSandbox
174
- ? `Shell working directory mounted into Docker as /workspace from ${config.commandCwd}. Use relative paths or /workspace paths, not absolute host temp paths. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}.`
176
+ ? `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}.`
175
177
  : `Shell working directory: ${config.commandCwd}`
176
178
  : "",
177
179
  config.allowFileTools ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths.` : "",
@@ -273,7 +275,7 @@ function applyContinuationPrompt(state, config, observers) {
273
275
  config.allowedDomains.length > 0 ? `Allowed domains: ${config.allowedDomains.join(", ")}` : "",
274
276
  config.allowShellTool
275
277
  ? config.useDockerSandbox
276
- ? `Shell working directory mounted into Docker as /workspace from ${config.commandCwd}. Use relative paths or /workspace paths. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}.`
278
+ ? `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}.`
277
279
  : `Shell working directory: ${config.commandCwd}`
278
280
  : "",
279
281
  config.allowFileTools ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths.` : "",
@@ -409,7 +411,7 @@ async function captureSyntheticSnapshot(store, step, config) {
409
411
  config.startUrl ? `Suggested start URL: ${config.startUrl}` : "",
410
412
  config.allowShellTool
411
413
  ? config.useDockerSandbox
412
- ? `Shell tool available in Docker with mounted workspace /workspace from ${config.commandCwd}. Use relative paths or /workspace paths. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}.`
414
+ ? `Shell tool available in Docker with mounted workspace /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}.`
413
415
  : `Shell tool available in: ${config.commandCwd}`
414
416
  : "Shell tool disabled.",
415
417
  config.allowFileTools
@@ -443,6 +445,29 @@ async function buildSnapshot(browserState, store, step, config) {
443
445
  return captureSyntheticSnapshot(store, step, config);
444
446
  }
445
447
 
448
+ async function injectQueuedUserMessages(store, state, observers) {
449
+ const inbox = await store.drainInbox();
450
+ if (inbox.length === 0) return;
451
+
452
+ for (const item of inbox) {
453
+ const content = String(item.content || "").trim();
454
+ if (!content) continue;
455
+ appendChatEntry(state, "user", content);
456
+ state.messages.push({
457
+ role: "user",
458
+ content: `Additional user message received while this run was active:\n${content}`,
459
+ });
460
+ await store.appendEvent("conversation.queued_input_applied", {
461
+ prompt: content,
462
+ source: item.source || "inbox",
463
+ });
464
+ observers.event("conversation.queued_input_applied", {
465
+ prompt: content,
466
+ source: item.source || "inbox",
467
+ });
468
+ }
469
+ }
470
+
446
471
  async function executeTool(browserState, toolCall, snapshot, config, store, observers, state) {
447
472
  const args = JSON.parse(toolCall.function.arguments || "{}");
448
473
  const safeArgs = sanitizeToolArgs(toolCall.function.name, args);
@@ -798,6 +823,7 @@ export async function runAgent(config) {
798
823
  `Docker: image=${config.dockerSandboxImage} mode=${config.sandboxMode} packagePolicy=${config.packageInstallPolicy}`
799
824
  );
800
825
  console.log(`Docker workspace: /workspace -> ${config.commandCwd}`);
826
+ console.log("Docker env: /aginti-env persistent toolchain; /aginti-cache persistent caches");
801
827
  } else if (config.allowShellTool) {
802
828
  console.log(`Shell: host policy=${config.packageInstallPolicy}`);
803
829
  }
@@ -808,6 +834,7 @@ export async function runAgent(config) {
808
834
  }
809
835
 
810
836
  for (let step = state.stepsCompleted + 1; step <= config.maxSteps; step += 1) {
837
+ await injectQueuedUserMessages(store, state, observers);
811
838
  const snapshot = await buildSnapshot(browserState, store, step, config);
812
839
  state.meta.lastUrl = snapshot.url || state.meta.lastUrl;
813
840
  await saveBrowserState(browserState, store).catch(() => {});
@@ -828,7 +855,7 @@ export async function runAgent(config) {
828
855
 
829
856
  state.messages.push({
830
857
  role: "user",
831
- content: `Step ${step}/${config.maxSteps}. Latest runtime snapshot:\n${JSON.stringify({
858
+ content: `Step ${step}/${config.maxSteps} (${config.maxSteps - step} steps remain after this one). Latest runtime snapshot:\n${JSON.stringify({
832
859
  title: snapshot.title,
833
860
  url: snapshot.url,
834
861
  pageText: snapshot.pageText,
@@ -843,6 +870,9 @@ export async function runAgent(config) {
843
870
  shellSandbox: config.useDockerSandbox ? "docker" : "host",
844
871
  sandboxMode: config.sandboxMode,
845
872
  packageInstallPolicy: config.packageInstallPolicy,
873
+ dockerWorkspace: config.useDockerSandbox ? "/workspace" : "",
874
+ dockerPersistentEnv: config.useDockerSandbox ? "/aginti-env" : "",
875
+ dockerPersistentCache: config.useDockerSandbox ? "/aginti-cache" : "",
846
876
  commandCwd: config.commandCwd,
847
877
  plan: state.plan || "",
848
878
  suggestedStartUrl: config.startUrl || "",
@@ -962,6 +992,8 @@ export async function runAgent(config) {
962
992
  }
963
993
  }
964
994
 
995
+ await injectQueuedUserMessages(store, state, observers);
996
+
965
997
  state.stepsCompleted = step;
966
998
  state.updatedAt = new Date().toISOString();
967
999
  await store.saveState(state);
@@ -43,6 +43,14 @@ function capability(name, ok, details = {}) {
43
43
  }
44
44
 
45
45
  function maintenancePolicyChecks(config) {
46
+ const hostSafeConfig = {
47
+ ...config,
48
+ allowShellTool: true,
49
+ allowDestructive: false,
50
+ sandboxMode: "host",
51
+ useDockerSandbox: false,
52
+ packageInstallPolicy: "prompt",
53
+ };
46
54
  const sampleCommands = [
47
55
  "sudo apt install r-base",
48
56
  "curl https://example.com/install.sh",
@@ -51,7 +59,7 @@ function maintenancePolicyChecks(config) {
51
59
  ];
52
60
 
53
61
  return sampleCommands.map((command) => {
54
- const policy = evaluateCommandPolicy(command, config);
62
+ const policy = evaluateCommandPolicy(command, hostSafeConfig);
55
63
  return {
56
64
  command,
57
65
  allowed: Boolean(policy.allowed),
@@ -78,6 +86,7 @@ function trustedDockerPolicyChecks(config) {
78
86
  "apt-get install -y curl wget",
79
87
  "wget https://example.com/file.txt",
80
88
  "curl -fsSL https://example.com/file.txt -o downloads/file.txt",
89
+ "chmod +x scripts/setup.sh",
81
90
  "npm install lodash",
82
91
  "python3 -m pip install requests",
83
92
  ];
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import { getModelPresets } from "./model-routing.js";
5
5
  import { getDockerSandboxStatus, runDockerPreflight } from "./docker-sandbox.js";
6
6
  import { buildCapabilityReport, printCapabilityReport } from "./capabilities.js";
7
7
  import { startInteractiveCli } from "./interactive-cli.js";
8
+ import { SessionStore } from "./session-store.js";
8
9
  import {
9
10
  doctorReport,
10
11
  initProject,
@@ -57,6 +58,7 @@ export function parseArgs(argv) {
57
58
  port: "",
58
59
  host: "",
59
60
  listProfiles: false,
61
+ latex: false,
60
62
  };
61
63
 
62
64
  const parts = [];
@@ -130,6 +132,14 @@ export function parseArgs(argv) {
130
132
  result.sandboxMode = result.sandboxMode || "docker-workspace";
131
133
  continue;
132
134
  }
135
+ if (arg === "--latex") {
136
+ result.latex = true;
137
+ result.taskProfile = "latex";
138
+ result.sandboxMode = result.sandboxMode || "docker-workspace";
139
+ result.packageInstallPolicy = result.packageInstallPolicy || "allow";
140
+ result.maxSteps = result.maxSteps || 30;
141
+ continue;
142
+ }
133
143
  if (arg === "--max-steps") {
134
144
  result.maxSteps = Number(readOption(argv, i));
135
145
  i += 1;
@@ -209,10 +219,29 @@ export function parseArgs(argv) {
209
219
 
210
220
  function printUsage() {
211
221
  console.log(
212
- 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti [--routing smart|fast|complex|manual] [--provider deepseek|openai|mock] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-destructive] [--allow-file-tools|--no-file-tools] [--allow-wrappers --wrapper codex] [--sandbox-status|--sandbox-preflight] "your task"'
222
+ 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti 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"'
213
223
  );
214
224
  }
215
225
 
226
+ function agentDefaults(args) {
227
+ const defaults = {
228
+ ...args,
229
+ allowShellTool: args.allowShellTool ?? true,
230
+ allowFileTools: args.allowFileTools ?? true,
231
+ sandboxMode: args.sandboxMode || "docker-workspace",
232
+ packageInstallPolicy: args.packageInstallPolicy || "allow",
233
+ useDockerSandbox: args.useDockerSandbox ?? true,
234
+ maxSteps: args.maxSteps || (args.latex || args.taskProfile === "latex" ? 30 : 24),
235
+ };
236
+
237
+ if (defaults.sandboxMode === "host") {
238
+ defaults.useDockerSandbox = false;
239
+ defaults.packageInstallPolicy = args.packageInstallPolicy || "prompt";
240
+ }
241
+
242
+ return defaults;
243
+ }
244
+
216
245
  function printRoutes() {
217
246
  const presets = getModelPresets();
218
247
  for (const preset of Object.values(presets)) {
@@ -327,6 +356,20 @@ async function handleSessionsCommand(argv) {
327
356
  process.exit(1);
328
357
  }
329
358
 
359
+ async function handleQueueCommand(argv) {
360
+ const sessionId = argv[0] || "";
361
+ const content = argv.slice(1).join(" ").trim();
362
+ if (!/^[A-Za-z0-9._:-]+$/.test(sessionId) || !content) {
363
+ console.error('Usage: aginti queue <session-id> "message to apply during the next agent step"');
364
+ process.exit(1);
365
+ }
366
+
367
+ const store = new SessionStore(path.resolve(process.cwd(), ".sessions"), sessionId);
368
+ await store.appendInbox(content, { source: "cli" });
369
+ await store.appendEvent("conversation.queued_input", { prompt: content, source: "cli" }).catch(() => {});
370
+ console.log(`queued message for ${sessionId}`);
371
+ }
372
+
330
373
  export async function main(argv = process.argv.slice(2)) {
331
374
  if (argv[0] === "--help" || argv[0] === "help" || argv[0] === "-h") {
332
375
  printUsage();
@@ -407,6 +450,11 @@ export async function main(argv = process.argv.slice(2)) {
407
450
  return;
408
451
  }
409
452
 
453
+ if (argv[0] === "queue") {
454
+ await handleQueueCommand(argv.slice(1));
455
+ return;
456
+ }
457
+
410
458
  if (argv[0] === "resume") {
411
459
  const sessionId = argv[1] || "";
412
460
  const prompt = argv.slice(2).join(" ").trim();
@@ -414,10 +462,7 @@ export async function main(argv = process.argv.slice(2)) {
414
462
  console.error('Usage: aginti resume <session-id> "new prompt"');
415
463
  process.exit(1);
416
464
  }
417
- const config = loadConfig(
418
- { ...parseArgs([prompt]), resume: sessionId, goal: prompt, allowShellTool: true, allowFileTools: true },
419
- { packageDir }
420
- );
465
+ const config = loadConfig(agentDefaults({ ...parseArgs([prompt]), resume: sessionId, goal: prompt }), { packageDir });
421
466
  await runAgent(config);
422
467
  return;
423
468
  }
@@ -425,7 +470,7 @@ export async function main(argv = process.argv.slice(2)) {
425
470
  const args = parseArgs(argv);
426
471
 
427
472
  if (args.interactive || (!args.goal && !args.resume && process.stdin.isTTY)) {
428
- await startInteractiveCli(args, { packageDir, packageVersion: packageJson.version });
473
+ await startInteractiveCli(agentDefaults(args), { packageDir, packageVersion: packageJson.version });
429
474
  return;
430
475
  }
431
476
 
@@ -466,13 +511,6 @@ export async function main(argv = process.argv.slice(2)) {
466
511
  process.exit(1);
467
512
  }
468
513
 
469
- const config = loadConfig(
470
- {
471
- ...args,
472
- allowShellTool: args.allowShellTool ?? true,
473
- allowFileTools: args.allowFileTools ?? true,
474
- },
475
- { packageDir }
476
- );
514
+ const config = loadConfig(agentDefaults(args), { packageDir });
477
515
  await runAgent(config);
478
516
  }
@@ -39,6 +39,7 @@ const TEST_PATTERNS = [
39
39
  ];
40
40
 
41
41
  const SAFE_WORKSPACE_WRITE_PATTERNS = [/^mkdir\s+-p\s+[-\w./]+$/];
42
+ const PERMISSION_CHANGE_PATTERNS = [/^(?:sudo\s+)?chmod\s+[-+=,rwxugoXst0-7]+\s+[-\w./]+$/];
42
43
 
43
44
  const NETWORK_FETCH_PATTERNS = [
44
45
  /^curl\s+(?:-[A-Za-z0-9]*\s+)*https?:\/\/\S+(?:\s+-o\s+[-\w./]+)?$/,
@@ -153,15 +154,6 @@ function classifySimpleCommand(normalized) {
153
154
  return { category: "blocked", reason: "Command is blocked because it references secrets or credential files." };
154
155
  }
155
156
 
156
- const lowered = ` ${normalized.toLowerCase()} `;
157
- if (BLOCKED_SHELL_TOKENS.some((part) => normalized.includes(part))) {
158
- return {
159
- category: "general-shell",
160
- needsNetwork: true,
161
- writesWorkspace: true,
162
- reason: `Command uses general shell syntax: ${normalized}`,
163
- };
164
- }
165
157
  if (matchAny(SAFE_WORKSPACE_WRITE_PATTERNS, normalized)) {
166
158
  const target = normalized.replace(/^mkdir\s+-p\s+/, "");
167
159
  const virtualWorkspacePath = isSafeVirtualWorkspaceDir(target);
@@ -170,6 +162,16 @@ function classifySimpleCommand(normalized) {
170
162
  }
171
163
  return { category: "workspace-write", needsNetwork: false, writesWorkspace: true, virtualWorkspacePath };
172
164
  }
165
+ if (matchAny(PERMISSION_CHANGE_PATTERNS, normalized)) {
166
+ return {
167
+ category: "permission-change",
168
+ needsNetwork: false,
169
+ writesWorkspace: true,
170
+ reason: `Command changes workspace file mode: ${normalized}`,
171
+ };
172
+ }
173
+
174
+ const lowered = ` ${normalized.toLowerCase()} `;
173
175
  if (BLOCKED_WRITE_TOKENS.some((part) => lowered.includes(part))) {
174
176
  return {
175
177
  category: "destructive",
@@ -178,6 +180,14 @@ function classifySimpleCommand(normalized) {
178
180
  reason: `Command contains a write-capable or destructive token: ${normalized}`,
179
181
  };
180
182
  }
183
+ if (BLOCKED_SHELL_TOKENS.some((part) => normalized.includes(part))) {
184
+ return {
185
+ category: "general-shell",
186
+ needsNetwork: true,
187
+ writesWorkspace: true,
188
+ reason: `Command uses general shell syntax: ${normalized}`,
189
+ };
190
+ }
181
191
 
182
192
  if (matchAny(READ_ONLY_PATTERNS, normalized)) {
183
193
  return { category: "read-only", needsNetwork: false, writesWorkspace: false };
@@ -306,6 +316,20 @@ export function evaluateCommandPolicy(command, config) {
306
316
  };
307
317
  }
308
318
 
319
+ if (classification.category === "permission-change" && !trustedDockerShell && !trustedHostShell) {
320
+ return {
321
+ allowed: false,
322
+ ...classification,
323
+ needsApproval: true,
324
+ reason:
325
+ sandboxMode === "host"
326
+ ? "Workspace permission changes on the host require Allow destructive actions. Prefer Docker workspace mode."
327
+ : "Docker permission changes require docker-workspace mode with Package installs = allow.",
328
+ sandboxMode,
329
+ packageInstallPolicy,
330
+ };
331
+ }
332
+
309
333
  if (classification.category === "destructive" && !config.allowDestructive) {
310
334
  return {
311
335
  allowed: false,
package/src/config.js CHANGED
@@ -42,9 +42,9 @@ export function resolveRuntimeConfig(args, overrides = {}) {
42
42
 
43
43
  const defaults = getProviderDefaults(route.provider);
44
44
  const packageDir = path.resolve(overrides.packageDir || process.env.AGINTIFLOW_PACKAGE_DIR || baseDir);
45
- const dockerRequested = parseBoolean(overrides.useDockerSandbox ?? args.useDockerSandbox ?? process.env.USE_DOCKER_SANDBOX, false);
45
+ const dockerRequested = parseBoolean(overrides.useDockerSandbox ?? args.useDockerSandbox ?? process.env.USE_DOCKER_SANDBOX, true);
46
46
  const requestedSandboxMode =
47
- overrides.sandboxMode || args.sandboxMode || process.env.SANDBOX_MODE || (dockerRequested ? "docker-readonly" : "host");
47
+ overrides.sandboxMode || args.sandboxMode || process.env.SANDBOX_MODE || (dockerRequested ? "docker-workspace" : "host");
48
48
  const sandboxMode = normalizeSandboxMode(requestedSandboxMode);
49
49
 
50
50
  return {
@@ -65,14 +65,14 @@ export function resolveRuntimeConfig(args, overrides = {}) {
65
65
  apiKey: overrides.apiKey || defaults.apiKey,
66
66
  baseURL: overrides.baseURL || defaults.baseURL,
67
67
  model: route.model || defaults.model,
68
- maxSteps: parseNumber(overrides.maxSteps ?? args.maxSteps ?? process.env.MAX_STEPS, 15),
68
+ maxSteps: parseNumber(overrides.maxSteps ?? args.maxSteps ?? process.env.MAX_STEPS, 24),
69
69
  headless: parseBoolean(overrides.headless ?? args.headless ?? process.env.HEADLESS, false),
70
70
  allowedDomains: Array.isArray(overrides.allowedDomains)
71
71
  ? overrides.allowedDomains
72
72
  : parseList(process.env.ALLOWED_DOMAINS),
73
73
  allowPasswords: parseBoolean(overrides.allowPasswords ?? process.env.ALLOW_PASSWORDS, false),
74
74
  allowDestructive: parseBoolean(overrides.allowDestructive ?? args.allowDestructive ?? process.env.ALLOW_DESTRUCTIVE, false),
75
- allowShellTool: parseBoolean(overrides.allowShellTool ?? args.allowShellTool ?? process.env.ALLOW_SHELL_TOOL, false),
75
+ allowShellTool: parseBoolean(overrides.allowShellTool ?? args.allowShellTool ?? process.env.ALLOW_SHELL_TOOL, true),
76
76
  allowFileTools: parseBoolean(overrides.allowFileTools ?? args.allowFileTools ?? process.env.ALLOW_FILE_TOOLS, true),
77
77
  allowWrapperTools: parseBoolean(
78
78
  overrides.allowWrapperTools ?? args.allowWrapperTools ?? process.env.ALLOW_WRAPPER_TOOLS,
@@ -84,9 +84,9 @@ export function resolveRuntimeConfig(args, overrides = {}) {
84
84
  wrapperTimeoutMs: parseNumber(overrides.wrapperTimeoutMs ?? process.env.WRAPPER_TIMEOUT_MS, 120000),
85
85
  sandboxMode,
86
86
  packageInstallPolicy: normalizePackageInstallPolicy(
87
- overrides.packageInstallPolicy || args.packageInstallPolicy || process.env.PACKAGE_INSTALL_POLICY || "prompt"
87
+ overrides.packageInstallPolicy || args.packageInstallPolicy || process.env.PACKAGE_INSTALL_POLICY || (sandboxMode === "host" ? "prompt" : "allow")
88
88
  ),
89
- useDockerSandbox: sandboxMode !== "host" || dockerRequested,
89
+ useDockerSandbox: sandboxMode !== "host",
90
90
  dockerSandboxImage: overrides.dockerSandboxImage || process.env.DOCKER_SANDBOX_IMAGE || "agintiflow-sandbox:latest",
91
91
  commandCwd: path.resolve(overrides.commandCwd || args.commandCwd || process.env.COMMAND_CWD || baseDir),
92
92
  sessionsDir: path.resolve(baseDir, ".sessions"),
@@ -1,5 +1,6 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { execFile as execFileCallback } from "node:child_process";
5
6
  import { promisify } from "node:util";
@@ -8,6 +9,9 @@ import { redactSensitiveText, redactValue } from "./redaction.js";
8
9
 
9
10
  const execFile = promisify(execFileCallback);
10
11
  const DOCKER_WORKSPACE = "/workspace";
12
+ const DOCKER_HOME = "/aginti-home";
13
+ const DOCKER_CACHE = "/aginti-cache";
14
+ const DOCKER_ENV = "/aginti-env";
11
15
  const READY_IMAGES = new Set();
12
16
  const SANDBOX_LOG_LIMIT = 80;
13
17
  const sandboxLogs = [];
@@ -101,10 +105,31 @@ function sandboxDockerfilePath(config) {
101
105
  return path.join(sandboxPackageDir(config), "docker", "sandbox.Dockerfile");
102
106
  }
103
107
 
108
+ function persistentDockerDirs(config) {
109
+ const root = path.resolve(
110
+ config.dockerStateDir || process.env.AGINTIFLOW_DOCKER_STATE_DIR || path.join(os.homedir(), ".agintiflow", "docker")
111
+ );
112
+ return {
113
+ root,
114
+ home: path.join(root, "home"),
115
+ cache: path.join(root, "cache"),
116
+ env: path.join(root, "env"),
117
+ };
118
+ }
119
+
120
+ async function ensurePersistentDockerDirs(config) {
121
+ const dirs = persistentDockerDirs(config);
122
+ await Promise.all([fs.mkdir(dirs.home, { recursive: true }), fs.mkdir(dirs.cache, { recursive: true }), fs.mkdir(dirs.env, { recursive: true })]);
123
+ return dirs;
124
+ }
125
+
104
126
  export async function getDockerSandboxStatus(config) {
105
127
  const image = config.dockerSandboxImage;
106
128
  const dockerfilePath = sandboxDockerfilePath(config);
107
129
  const workspace = config.commandCwd;
130
+ if (config.useDockerSandbox) {
131
+ await ensurePersistentDockerDirs(config).catch(() => null);
132
+ }
108
133
  const [dockerReady, dockerfileExists, workspaceExists, workspaceReadable, workspaceWritable] = await Promise.all([
109
134
  dockerAvailable(),
110
135
  pathAccess(dockerfilePath, fsConstants.R_OK),
@@ -114,6 +139,12 @@ export async function getDockerSandboxStatus(config) {
114
139
  ]);
115
140
 
116
141
  const imageReady = dockerReady ? await dockerImageExists(image) : false;
142
+ const persistentDirs = persistentDockerDirs(config);
143
+ const [persistentHomeExists, persistentCacheExists, persistentEnvExists] = await Promise.all([
144
+ pathAccess(persistentDirs.home, fsConstants.F_OK),
145
+ pathAccess(persistentDirs.cache, fsConstants.F_OK),
146
+ pathAccess(persistentDirs.env, fsConstants.F_OK),
147
+ ]);
117
148
  return {
118
149
  sandboxMode: normalizeSandboxMode(config.sandboxMode),
119
150
  useDockerSandbox: Boolean(config.useDockerSandbox),
@@ -127,6 +158,18 @@ export async function getDockerSandboxStatus(config) {
127
158
  workspaceReadable,
128
159
  workspaceWritable,
129
160
  dockerfileExists,
161
+ persistentDocker: {
162
+ home: persistentDirs.home,
163
+ cache: persistentDirs.cache,
164
+ env: persistentDirs.env,
165
+ homeExists: persistentHomeExists,
166
+ cacheExists: persistentCacheExists,
167
+ envExists: persistentEnvExists,
168
+ containerHome: DOCKER_HOME,
169
+ containerCache: DOCKER_CACHE,
170
+ containerEnv: DOCKER_ENV,
171
+ pythonVenv: `${DOCKER_ENV}/python`,
172
+ },
130
173
  logs: getSandboxLogs(),
131
174
  };
132
175
  }
@@ -165,7 +208,31 @@ export async function ensureDockerSandboxReady(config, observers, options = {})
165
208
  await buildDockerSandboxImage(config, observers);
166
209
  }
167
210
 
168
- function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command, config)) {
211
+ function dockerCommand(command, policy) {
212
+ const envLines = [
213
+ `export HOME=${DOCKER_HOME}`,
214
+ `export XDG_CACHE_HOME=${DOCKER_CACHE}`,
215
+ `export PIP_CACHE_DIR=${DOCKER_CACHE}/pip`,
216
+ `export UV_CACHE_DIR=${DOCKER_CACHE}/uv`,
217
+ `export NPM_CONFIG_CACHE=${DOCKER_CACHE}/npm`,
218
+ `export CONDA_PKGS_DIRS=${DOCKER_CACHE}/conda-pkgs`,
219
+ `export PYTHONUSERBASE=${DOCKER_ENV}/python-user`,
220
+ `export PATH=${DOCKER_ENV}/python/bin:${DOCKER_ENV}/python-user/bin:${DOCKER_ENV}/miniforge/bin:${DOCKER_ENV}/bin:$PATH`,
221
+ `cd ${DOCKER_WORKSPACE}`,
222
+ `if ! command -v sudo >/dev/null 2>&1; then sudo() { "$@"; }; export -f sudo; fi`,
223
+ ];
224
+
225
+ if (!policy.requiresDockerRoot) {
226
+ envLines.push(
227
+ `if command -v python3 >/dev/null 2>&1 && [ ! -x ${DOCKER_ENV}/python/bin/python ]; then python3 -m venv --system-site-packages ${DOCKER_ENV}/python >/dev/null 2>&1 || true; fi`,
228
+ `if [ -f ${DOCKER_ENV}/python/bin/activate ]; then . ${DOCKER_ENV}/python/bin/activate; fi`
229
+ );
230
+ }
231
+
232
+ return [...envLines, String(command)].join("\n");
233
+ }
234
+
235
+ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command, config), persistentDirs = persistentDockerDirs(config)) {
169
236
  const uid = typeof process.getuid === "function" ? String(process.getuid()) : "";
170
237
  const gid = typeof process.getgid === "function" ? String(process.getgid()) : "";
171
238
  const userArgs = uid && gid && !policy.requiresDockerRoot ? ["--user", `${uid}:${gid}`] : [];
@@ -196,24 +263,29 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
196
263
  ...readOnlyArgs,
197
264
  ...userArgs,
198
265
  "-e",
199
- "HOME=/tmp",
200
- "-e",
201
266
  "NPM_CONFIG_USERCONFIG=/tmp/.npmrc",
202
267
  "-e",
203
268
  "PIP_DISABLE_PIP_VERSION_CHECK=1",
204
269
  "-v",
205
270
  `${config.commandCwd}:${DOCKER_WORKSPACE}:${mountMode}`,
271
+ "-v",
272
+ `${persistentDirs.home}:${DOCKER_HOME}:rw`,
273
+ "-v",
274
+ `${persistentDirs.cache}:${DOCKER_CACHE}:rw`,
275
+ "-v",
276
+ `${persistentDirs.env}:${DOCKER_ENV}:rw`,
206
277
  "-w",
207
278
  DOCKER_WORKSPACE,
208
279
  config.dockerSandboxImage,
209
280
  "bash",
210
281
  "-lc",
211
- String(command),
282
+ dockerCommand(command, policy),
212
283
  ];
213
284
  }
214
285
 
215
286
  export async function runDockerSandboxCommand(command, config, policy = evaluateCommandPolicy(command, config)) {
216
- const result = await execDocker(dockerRunArgs(command, config, policy), {
287
+ const persistentDirs = await ensurePersistentDockerDirs(config);
288
+ const result = await execDocker(dockerRunArgs(command, config, policy, persistentDirs), {
217
289
  timeout: policy.needsNetwork ? 120000 : policy.category === "toolchain" ? 90000 : 15000,
218
290
  maxBuffer: 300 * 1024,
219
291
  });
@@ -227,6 +299,7 @@ export async function runDockerSandboxCommand(command, config, policy = evaluate
227
299
  category: policy.category,
228
300
  sandboxMode: policy.sandboxMode,
229
301
  network: policy.needsNetwork ? "bridge" : "none",
302
+ persistentDocker: persistentDirs,
230
303
  stdout: payload.stdout.slice(0, 1200),
231
304
  stderr: payload.stderr.slice(0, 1200),
232
305
  });
@@ -274,6 +347,7 @@ export async function runDockerPreflight(config, options = {}) {
274
347
 
275
348
  let status = await getDockerSandboxStatus(config);
276
349
  const runChecks = async () => {
350
+ const persistentDirs = await ensurePersistentDockerDirs(config);
277
351
  const results = [];
278
352
  for (const command of [
279
353
  "node -v",
@@ -287,7 +361,7 @@ export async function runDockerPreflight(config, options = {}) {
287
361
  "rg --version",
288
362
  ]) {
289
363
  try {
290
- const result = await execDocker(dockerRunArgs(command, config, { needsNetwork: false, category: "preflight" }), {
364
+ const result = await execDocker(dockerRunArgs(command, config, { needsNetwork: false, category: "preflight" }, persistentDirs), {
291
365
  timeout: 15000,
292
366
  maxBuffer: 100 * 1024,
293
367
  });
@@ -21,6 +21,7 @@ function printHelp() {
21
21
  " /model <name> Set an explicit model, or /model auto.",
22
22
  " /docker on Use docker-workspace with approved package installs.",
23
23
  " /docker off Use host shell policy.",
24
+ " /latex on Use the LaTeX/PDF profile in Docker with a larger step budget.",
24
25
  " /installs block|prompt|allow",
25
26
  " /cwd <path> Change command workspace.",
26
27
  " /exit Quit.",
@@ -50,8 +51,8 @@ function createState(args = {}) {
50
51
  model: args.model || "",
51
52
  routingMode: args.routingMode || "smart",
52
53
  commandCwd: args.commandCwd || process.cwd(),
53
- sandboxMode: normalizeSandboxMode(args.sandboxMode || "host"),
54
- packageInstallPolicy: normalizePackageInstallPolicy(args.packageInstallPolicy || "prompt"),
54
+ sandboxMode: normalizeSandboxMode(args.sandboxMode || "docker-workspace"),
55
+ packageInstallPolicy: normalizePackageInstallPolicy(args.packageInstallPolicy || "allow"),
55
56
  allowShellTool: args.allowShellTool ?? true,
56
57
  allowFileTools: args.allowFileTools ?? true,
57
58
  allowWrapperTools: args.allowWrapperTools ?? false,
@@ -59,7 +60,7 @@ function createState(args = {}) {
59
60
  preferredWrapper: args.preferredWrapper || "codex",
60
61
  taskProfile: normalizeTaskProfile(args.taskProfile || "auto"),
61
62
  headless: args.headless ?? false,
62
- maxSteps: Number.isFinite(args.maxSteps) && args.maxSteps > 0 ? args.maxSteps : 15,
63
+ maxSteps: Number.isFinite(args.maxSteps) && args.maxSteps > 0 ? args.maxSteps : args.latex ? 30 : 24,
63
64
  sessionId: args.resume || "",
64
65
  };
65
66
  }
@@ -140,6 +141,21 @@ async function handleCommand(line, state, packageDir) {
140
141
  }
141
142
  return true;
142
143
  }
144
+ if (command === "latex") {
145
+ if (value === "on" || value === "") {
146
+ state.taskProfile = "latex";
147
+ state.sandboxMode = "docker-workspace";
148
+ state.packageInstallPolicy = "allow";
149
+ state.maxSteps = Math.max(state.maxSteps, 30);
150
+ console.log("latex=on profile=latex sandbox=docker-workspace installs=allow maxSteps=30");
151
+ } else if (value === "off") {
152
+ state.taskProfile = "auto";
153
+ console.log("latex=off profile=auto");
154
+ } else {
155
+ console.log("Usage: /latex on OR /latex off");
156
+ }
157
+ return true;
158
+ }
143
159
  if (command === "cwd") {
144
160
  state.commandCwd = value || process.cwd();
145
161
  console.log(`cwd=${state.commandCwd}`);
@@ -146,7 +146,7 @@ export async function createPlan(client, config, state) {
146
146
  state.startUrl ? `Suggested start URL: ${state.startUrl}` : "",
147
147
  config.allowedDomains.length > 0 ? `Allowed domains: ${config.allowedDomains.join(", ")}` : "",
148
148
  config.allowShellTool
149
- ? `Shell tool is enabled in ${config.commandCwd}. In Docker, this path is mounted as /workspace. 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.`
149
+ ? `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
150
  : "",
151
151
  config.allowFileTools
152
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.`
@@ -159,6 +159,8 @@ export async function createPlan(client, config, state) {
159
159
  "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
160
  "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
161
  "For environment or system-maintenance work, prefer project-local dry-run plans/scripts unless the configured policy explicitly allows stronger actions.",
162
+ "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
+ "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.",
162
164
  "Plan for a complete result, not endless exploration; finish once the request is satisfied and checks have passed or been honestly skipped.",
163
165
  "Return a numbered plan only.",
164
166
  ]
@@ -10,6 +10,7 @@ export class SessionStore {
10
10
  this.statePath = path.join(this.sessionDir, "state.json");
11
11
  this.planPath = path.join(this.sessionDir, "plan.md");
12
12
  this.eventsPath = path.join(this.sessionDir, "events.jsonl");
13
+ this.inboxPath = path.join(this.sessionDir, "inbox.jsonl");
13
14
  this.storageStatePath = path.join(this.sessionDir, "storage-state.json");
14
15
  }
15
16
 
@@ -59,6 +60,34 @@ export class SessionStore {
59
60
  }
60
61
  }
61
62
 
63
+ async appendInbox(content, metadata = {}) {
64
+ await this.ensure();
65
+ const text = String(content || "").trim();
66
+ if (!text) return;
67
+ const line = JSON.stringify({
68
+ timestamp: new Date().toISOString(),
69
+ content: text,
70
+ ...metadata,
71
+ });
72
+ await fs.appendFile(this.inboxPath, `${line}\n`, "utf8");
73
+ }
74
+
75
+ async drainInbox() {
76
+ await this.ensure();
77
+ let raw = "";
78
+ try {
79
+ raw = await fs.readFile(this.inboxPath, "utf8");
80
+ } catch {
81
+ return [];
82
+ }
83
+ await fs.writeFile(this.inboxPath, "", "utf8");
84
+ return raw
85
+ .split("\n")
86
+ .map((line) => line.trim())
87
+ .filter(Boolean)
88
+ .map((line) => JSON.parse(line));
89
+ }
90
+
62
91
  async saveSnapshot(step, snapshot) {
63
92
  await this.ensure();
64
93
  const filename = `step-${String(step).padStart(3, "0")}.snapshot.json`;
@@ -66,7 +66,7 @@ export const TASK_PROFILES = {
66
66
  id: "latex",
67
67
  label: "LaTeX",
68
68
  prompt:
69
- "For LaTeX/PDF tasks, create source and figures in a subfolder, compile when a TeX toolchain is available, and send the PDF through the canvas tunnel.",
69
+ "For LaTeX/PDF tasks, create source and figures in a subfolder, compile when a TeX toolchain is available, and send the PDF through the canvas tunnel. In Docker, use /workspace for project outputs and the persistent Python/conda/tool cache under /aginti-env when setup is needed.",
70
70
  tools: ["files", "shell", "canvas", "sandbox"],
71
71
  },
72
72
  maintenance: {
package/src/web-db.js CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { getModelPresets } from "./model-routing.js";
5
5
 
6
- const PREFERENCES_SCHEMA_VERSION = 3;
6
+ const PREFERENCES_SCHEMA_VERSION = 4;
7
7
 
8
8
  function defaultPreferences(baseDir) {
9
9
  const presets = getModelPresets();
@@ -13,7 +13,7 @@ function defaultPreferences(baseDir) {
13
13
  provider: "deepseek",
14
14
  model: presets.fast.model,
15
15
  headless: true,
16
- maxSteps: 15,
16
+ maxSteps: 24,
17
17
  startUrl: "",
18
18
  allowedDomains: "",
19
19
  commandCwd: path.resolve(baseDir),
@@ -23,7 +23,7 @@ function defaultPreferences(baseDir) {
23
23
  preferredWrapper: "codex",
24
24
  wrapperTimeoutMs: 120000,
25
25
  sandboxMode: "docker-workspace",
26
- packageInstallPolicy: "prompt",
26
+ packageInstallPolicy: "allow",
27
27
  useDockerSandbox: true,
28
28
  dockerSandboxImage: "agintiflow-sandbox:latest",
29
29
  allowPasswords: false,
@@ -89,8 +89,11 @@ export class WebDatabase {
89
89
  preferences.sandboxMode = "docker-workspace";
90
90
  preferences.useDockerSandbox = true;
91
91
  }
92
- if (parsed.packageInstallPolicy === "block") {
93
- preferences.packageInstallPolicy = "prompt";
92
+ if ((parsed.packageInstallPolicy || "") !== "allow") {
93
+ preferences.packageInstallPolicy = "allow";
94
+ }
95
+ if (!Number.isFinite(Number(parsed.maxSteps)) || Number(parsed.maxSteps) < 24) {
96
+ preferences.maxSteps = 24;
94
97
  }
95
98
  this.savePreferences(preferences);
96
99
  }
package/web.js CHANGED
@@ -130,7 +130,7 @@ function normalizePreferencePayload(body = {}, current = db.getPreferences()) {
130
130
  ? providerDefaults.model
131
131
  : current.model || modelPresets.fast.model || providerDefaults.model,
132
132
  headless: typeof body.headless === "boolean" ? body.headless : Boolean(current.headless),
133
- maxSteps: Number.isFinite(parsedMaxSteps) && parsedMaxSteps > 0 ? parsedMaxSteps : Number(current.maxSteps) || 15,
133
+ maxSteps: Number.isFinite(parsedMaxSteps) && parsedMaxSteps > 0 ? parsedMaxSteps : Number(current.maxSteps) || 24,
134
134
  startUrl: typeof body.startUrl === "string" ? body.startUrl.trim() : current.startUrl || "",
135
135
  allowedDomains:
136
136
  typeof body.allowedDomains === "string" ? body.allowedDomains.trim() : current.allowedDomains || "",
@@ -156,7 +156,7 @@ function normalizePreferencePayload(body = {}, current = db.getPreferences()) {
156
156
  : Boolean(current.useDockerSandbox),
157
157
  sandboxMode,
158
158
  packageInstallPolicy: normalizePackageInstallPolicy(
159
- body.packageInstallPolicy || current.packageInstallPolicy || "prompt"
159
+ body.packageInstallPolicy || current.packageInstallPolicy || (sandboxMode === "host" ? "prompt" : "allow")
160
160
  ),
161
161
  dockerSandboxImage:
162
162
  typeof body.dockerSandboxImage === "string" && body.dockerSandboxImage.trim()
@@ -519,7 +519,7 @@ app.get("/api/config", async (_req, res) => {
519
519
  deepseek: publicProviderDefault("deepseek"),
520
520
  mock: publicProviderDefault("mock"),
521
521
  headless: true,
522
- maxSteps: 15,
522
+ maxSteps: 24,
523
523
  },
524
524
  taskProfiles: listTaskProfiles(),
525
525
  routing: {
@@ -768,6 +768,28 @@ app.post("/api/sessions/:sessionId/messages", async (req, res) => {
768
768
  return;
769
769
  }
770
770
 
771
+ if (!isSafeSessionId(sessionId)) {
772
+ res.status(400).json({ error: "Invalid session id." });
773
+ return;
774
+ }
775
+
776
+ const active = runs.get(sessionId);
777
+ if (active?.status === "running") {
778
+ const store = sessionStore(sessionId);
779
+ await store.appendInbox(content, { source: "web" });
780
+ await store.appendEvent("conversation.queued_input", { prompt: content, source: "web" }).catch(() => {});
781
+ active.logs.push({
782
+ at: new Date().toISOString(),
783
+ kind: "event",
784
+ message: "conversation.queued_input",
785
+ data: { prompt: content, source: "web" },
786
+ });
787
+ active.updatedAt = new Date().toISOString();
788
+ db.upsertSession(active);
789
+ res.json({ sessionId, queued: true });
790
+ return;
791
+ }
792
+
771
793
  try {
772
794
  await ensureNotRunning(sessionId);
773
795
  } catch (error) {