@lazyingart/agintiflow 0.20.181 → 0.20.182

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.
@@ -138,6 +138,22 @@ aginti "start a tmux session named demo, run ls in it, keep it open, and tell me
138
138
 
139
139
  The agent should use `tmux_start_session`, `tmux_send_keys`, and `tmux_capture_pane`, not Docker `run_command`. In Docker sandbox mode, tmux commands must stay inside the project. In host mode, tmux command text is still governed by host shell policy; if a tmux command is blocked, present the suggested rerun path instead of trying tmux as a workaround. For trusted whole-host tmux work, use the host recipe above.
140
140
 
141
+ Long download or long command:
142
+
143
+ ```bash
144
+ aginti "download this large zip with wget -c, verify the final byte count, and do not burn model steps polling it"
145
+ ```
146
+
147
+ The agent should use `start_long_job` once instead of repeatedly calling `wait`, `run_command`, or `tmux_capture_pane`. The tool creates `.aginti/long-jobs/<job-id>/status.json`, stdout/stderr logs, a supervisor log, and an optional status card. For resumable downloads, the agent should first determine `Content-Length` when practical, then call `start_long_job` with:
148
+
149
+ - `command`: a resumable transfer such as `wget -c URL -O file.zip`
150
+ - `expectedOutputPath`: the downloaded file
151
+ - `expectedSizeBytes`: the expected byte count
152
+ - `verifyCommand`: a deterministic check such as `unzip -t file.zip` or `sha256sum -c`
153
+ - `restartOnFailure`: `true` when the transfer command is safe to resume
154
+
155
+ After `start_long_job` succeeds, the model loop should finish with the job id and status path. Later status checks should use `long_job_status` or direct shell inspection of the status JSON. This keeps multi-hour I/O under a shell supervisor instead of consuming model tokens and step budget.
156
+
141
157
  ## Future Persistent Container Mode
142
158
 
143
159
  A useful next runtime mode is a service container:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.181",
3
+ "version": "0.20.182",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -63,6 +63,7 @@
63
63
  "scripts/smoke-capabilities.js",
64
64
  "scripts/smoke-auto-update.js",
65
65
  "scripts/smoke-inbox.js",
66
+ "scripts/smoke-long-jobs.js",
66
67
  "scripts/smoke-mcp.js",
67
68
  "scripts/fixtures/mcp-stdio-smoke-server.mjs",
68
69
  "scripts/smoke-model-roles.js",
@@ -106,6 +107,7 @@
106
107
  "smoke:skillmesh": "node scripts/smoke-skillmesh.js",
107
108
  "smoke:toolchain-docker": "node scripts/smoke-toolchain-docker.js",
108
109
  "smoke:inbox": "node scripts/smoke-inbox.js",
110
+ "smoke:long-jobs": "node scripts/smoke-long-jobs.js",
109
111
  "smoke:mcp": "node scripts/smoke-mcp.js",
110
112
  "smoke:model-roles": "node scripts/smoke-model-roles.js",
111
113
  "smoke:platform": "node scripts/smoke-platform.js",
@@ -125,7 +127,7 @@
125
127
  "storage:migrate": "node bin/aginti-cli.js storage migrate",
126
128
  "publish:env": "node scripts/npm-publish-from-env.js publish --access public",
127
129
  "publish:env:whoami": "node scripts/npm-publish-from-env.js whoami",
128
- "test": "npm run check && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-ui && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
130
+ "test": "npm run check && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-ui && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:long-jobs && npm run smoke:cli-chat && npm run smoke:inbox",
129
131
  "pack:dry-run": "npm pack --dry-run",
130
132
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
131
133
  },
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { checkToolUse } from "../src/guardrails.js";
7
+ import { longJobStatus, startLongJob } from "../src/long-job-tools.js";
8
+ import { tmuxAvailable } from "../src/tmux-tools.js";
9
+
10
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-long-job-"));
11
+ const config = {
12
+ allowShellTool: true,
13
+ allowDestructive: true,
14
+ allowPasswords: true,
15
+ commandCwd: workspace,
16
+ sandboxMode: "host",
17
+ useDockerSandbox: false,
18
+ };
19
+ const dockerConfig = {
20
+ ...config,
21
+ allowDestructive: false,
22
+ allowPasswords: false,
23
+ useDockerSandbox: true,
24
+ sandboxMode: "docker-workspace",
25
+ packageInstallPolicy: "allow",
26
+ };
27
+
28
+ function sleep(ms) {
29
+ return new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+
32
+ try {
33
+ if (!(await tmuxAvailable())) {
34
+ console.log(JSON.stringify({ ok: true, skipped: true, reason: "tmux is not installed" }, null, 2));
35
+ process.exit(0);
36
+ }
37
+
38
+ const noShell = checkToolUse({
39
+ toolName: "start_long_job",
40
+ args: { command: "printf ok > out.txt" },
41
+ config: { ...config, allowShellTool: false },
42
+ });
43
+ assert.equal(noShell.allowed, false, "start_long_job should require shell capability");
44
+
45
+ const outsidePath = path.join(os.tmpdir(), "aginti-long-job-outside.txt");
46
+ const outsideGuard = checkToolUse({
47
+ toolName: "start_long_job",
48
+ args: { name: "outside", command: `cat ${outsidePath}` },
49
+ config: dockerConfig,
50
+ });
51
+ assert.equal(outsideGuard.allowed, false, "Docker-mode long jobs should block outside host absolute paths");
52
+
53
+ const start = await startLongJob(
54
+ {
55
+ name: "smoke-download-style",
56
+ command: "printf 'abc123' > download.bin",
57
+ expectedOutputPath: "download.bin",
58
+ expectedSizeBytes: 6,
59
+ verifyCommand: "grep -q abc download.bin",
60
+ pollIntervalSeconds: 5,
61
+ note: "Smoke test for durable long command handoff.",
62
+ },
63
+ config
64
+ );
65
+ assert.equal(start.ok, true, start.error || start.reason);
66
+ assert.equal(start.background, true, "long job should return as a background handoff");
67
+ assert.match(start.statusPath, /^\.aginti\/long-jobs\//, "status path should be project-local");
68
+
69
+ let status = null;
70
+ for (let i = 0; i < 30; i += 1) {
71
+ await sleep(250);
72
+ status = await longJobStatus({ jobId: start.jobId }, config);
73
+ assert.equal(status.ok, true, status.reason);
74
+ if (["completed", "failed"].includes(status.state)) break;
75
+ }
76
+ assert.equal(status?.state, "completed", `long job did not complete: ${JSON.stringify(status)}`);
77
+ assert.equal(status.outputBytes, 6, "expected-size progress was not recorded");
78
+ assert.equal(status.verifyExitCode, 0, "verify command did not pass");
79
+
80
+ const output = await fs.readFile(path.join(workspace, "download.bin"), "utf8");
81
+ assert.equal(output, "abc123", "job output content mismatch");
82
+ await fs.access(path.join(workspace, start.statusPath));
83
+ await fs.access(path.join(workspace, start.stdoutPath));
84
+ await fs.access(path.join(workspace, start.stderrPath));
85
+ await fs.access(path.join(workspace, start.supervisorLogPath));
86
+
87
+ console.log(
88
+ JSON.stringify(
89
+ {
90
+ ok: true,
91
+ workspace,
92
+ jobId: start.jobId,
93
+ statusPath: start.statusPath,
94
+ state: status.state,
95
+ outputBytes: status.outputBytes,
96
+ verifyExitCode: status.verifyExitCode,
97
+ },
98
+ null,
99
+ 2
100
+ )
101
+ );
102
+ } finally {
103
+ await fs.rm(workspace, { recursive: true, force: true }).catch(() => {});
104
+ }
@@ -41,6 +41,7 @@ import { browserStateReconciliationGuidance } from "./browser-automation-guidanc
41
41
  import { summarizeMcpConfig } from "./mcp/config.js";
42
42
  import { isMcpBridgeTool } from "./mcp/policy.js";
43
43
  import { executeMcpBridgeTool } from "./mcp/tool-bridge.js";
44
+ import { longJobStatus, startLongJob } from "./long-job-tools.js";
44
45
  import {
45
46
  buildSupervisorInstruction,
46
47
  createScsPlan,
@@ -612,7 +613,7 @@ async function createInitialState(config, sessionId) {
612
613
  "If an operation fails but a directory, artifact, or file already exists, treat it as pre-existing unless you have evidence this run created or updated it. Verify expected outputs before claiming success.",
613
614
  "For validation/evidence commands, remember that grep exits 1 on zero matches. If zero matches is the expected clean result, use `grep -c PATTERN file || true`, split evidence checks into independent commands, or use awk/python so a clean zero count does not stop an `&&` chain.",
614
615
  config.allowShellTool
615
- ? "Host tmux tools are available for long-running terminals: list sessions, capture panes, send safe keys/text, and start detached sessions. Prefer these tools for monitoring long installs/tests/dev servers without blocking; capture before sending input and never send secrets or sudo passwords. Do not start or install tmux inside Docker run_command containers because those containers are short-lived. In Docker sandbox mode, tmux start/send commands must stay workspace-write-bound; prefer run_command for read-only host absolute path inspection through read-only mounts, and ask for --sandbox-mode host for trusted whole-host write/system work." +
616
+ ? "For downloads, long I/O, long tests/builds, model jobs, or any command with an ETA of minutes or hours, prefer start_long_job over wait loops. start_long_job creates a durable tmux-backed status ledger, stdout/stderr logs, optional expected-size verification, and returns immediately; after it starts, report the job id/status path and finish instead of polling with model steps. Host tmux tools are also available for interactive terminals: list sessions, capture panes, send safe keys/text, and start detached sessions. Capture before sending input and never send secrets or sudo passwords. Do not start or install tmux inside Docker run_command containers because those containers are short-lived. In Docker sandbox mode, tmux and long-job commands must stay workspace-write-bound; prefer run_command for read-only host absolute path inspection through read-only mounts, and ask for --sandbox-mode host for trusted whole-host write/system work." +
616
617
  " For one-shot tmux commands, redirect stdout/stderr and exit status to a durable workspace log or keep the pane alive for capture; if capture fails because the session ended, do not infer output or exit status."
617
618
  : "",
618
619
  config.allowShellTool && config.useDockerSandbox
@@ -653,7 +654,7 @@ async function createInitialState(config, sessionId) {
653
654
  browserStateReconciliationGuidance(),
654
655
  "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. When no save path is specified, choose a descriptive non-conflicting workspace path near the working directory and keep it there.",
655
656
  "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.",
656
- "For long-running work, create a durable checkpoint or artifact at each completed phase, then continue with the next concrete phase until the requested outcome is actually complete or blocked by a real dependency.",
657
+ "For long-running work, create durable checkpoints. If a single command will run for minutes or hours, hand it to start_long_job with verification hooks and finish with the status path instead of keeping the model loop alive.",
657
658
  "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.",
658
659
  "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.",
659
660
  "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.",
@@ -1402,6 +1403,16 @@ function sanitizeToolArgs(toolName, args) {
1402
1403
  : safeArgs.referenceImages,
1403
1404
  };
1404
1405
  }
1406
+ if (toolName === "start_long_job") {
1407
+ return {
1408
+ ...safeArgs,
1409
+ command: typeof args.command === "string" ? `[${Buffer.byteLength(args.command, "utf8")} bytes sha256=${hashForLog(args.command)}]` : safeArgs.command,
1410
+ verifyCommand:
1411
+ typeof args.verifyCommand === "string"
1412
+ ? `[${Buffer.byteLength(args.verifyCommand, "utf8")} bytes sha256=${hashForLog(args.verifyCommand)}]`
1413
+ : safeArgs.verifyCommand,
1414
+ };
1415
+ }
1405
1416
  return safeArgs;
1406
1417
  }
1407
1418
 
@@ -1589,7 +1600,7 @@ async function captureSyntheticSnapshot(store, step, config) {
1589
1600
  : `Shell tool available in: ${config.commandCwd} on ${platformLabel(platform)}. Use OS-compatible commands; prefer WSL/Docker for bash-heavy workflows on Windows. If a broad host command is blocked, split it into narrow allowed probes or existing helper scripts before treating the task as blocked.`
1590
1601
  : "Shell tool disabled.",
1591
1602
  config.allowShellTool
1592
- ? "Host tmux tools available: tmux_list_sessions, tmux_capture_pane, tmux_send_keys, tmux_start_session. Use them for long-running jobs and agent terminals; capture before sending input. Tmux captures include old scrollback, so after a restart require a fresh run marker, heartbeat, PID, or log/status timestamp before treating capture text as current evidence. Docker run_command containers are ephemeral, so tmux there will not persist. In Docker sandbox mode, tmux start/send commands must stay workspace-write-bound; when sending text into a shell pane, tmux follows the same Docker workspace command policy as run_command and is not a bypass for package installs, destructive git history rewrites, or broad shell text. Prefer run_command for read-only host absolute path inspection through read-only mounts. Use --sandbox-mode host for trusted whole-host write/system work. In host mode, tmux startup/send command text follows the same host shell policy as run_command; if a broad host command is blocked, present the approval/rerun path instead of trying tmux as a workaround. For one-shot tmux commands, redirect output and exit status to a durable workspace log or keep the pane alive for capture; if capture fails because the session ended, do not infer output or exit status."
1603
+ ? "Long-job tool available: start_long_job for downloads, long I/O, long tests/builds, model jobs, and any command with an ETA of minutes or hours. It starts a durable tmux-backed supervisor, writes status/log files, supports expected-size and verifyCommand checks, and returns immediately; do not keep the model loop alive to poll it. Use long_job_status later for explicit status requests. Host tmux tools are also available: tmux_list_sessions, tmux_capture_pane, tmux_send_keys, tmux_start_session. Use tmux for interactive terminals; capture before sending input. Tmux captures include old scrollback, so after a restart require a fresh run marker, heartbeat, PID, or log/status timestamp before treating capture text as current evidence. Docker run_command containers are ephemeral, so tmux there will not persist. In Docker sandbox mode, tmux start/send commands must stay workspace-write-bound; when sending text into a shell pane, tmux follows the same Docker workspace command policy as run_command and is not a bypass for package installs, destructive git history rewrites, or broad shell text. Prefer run_command for read-only host absolute path inspection through read-only mounts. Use --sandbox-mode host for trusted whole-host write/system work. In host mode, tmux startup/send command text follows the same host shell policy as run_command; if a broad host command is blocked, present the approval/rerun path instead of trying tmux as a workaround. For one-shot tmux commands, redirect output and exit status to a durable workspace log or keep the pane alive for capture; if capture fails because the session ended, do not infer output or exit status."
1593
1604
  : "",
1594
1605
  config.allowFileTools
1595
1606
  ? `Workspace file tools available in: ${config.commandCwd}. Use inspect_project first for large or unfamiliar codebases, then search/read exact files before editing. Use workspace-relative paths. Use apply_patch for code edits; it supports exact single-file replacement and multi-file Codex-style/unified patches. For new standalone generated content, pick a descriptive non-conflicting filename and avoid overwriting unless explicitly requested.`
@@ -2017,6 +2028,48 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
2017
2028
  observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
2018
2029
  return result;
2019
2030
  }
2031
+ case "start_long_job": {
2032
+ const result = await startLongJob(args, config);
2033
+ const eventResult = sanitizeToolResult(result);
2034
+ await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
2035
+ observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
2036
+ if (result.ok) {
2037
+ await store.appendEvent("long_job.started", eventResult);
2038
+ observers.event("long_job.started", eventResult);
2039
+ if (result.statusMarkdownPath) {
2040
+ const normalized = normalizeCanvasPayload(
2041
+ {
2042
+ title: `Long job: ${result.name || result.jobId}`,
2043
+ kind: "markdown",
2044
+ path: result.statusMarkdownPath,
2045
+ note: `Background job ${result.jobId} is running; status: ${result.statusPath}`,
2046
+ selected: false,
2047
+ },
2048
+ config
2049
+ );
2050
+ if (normalized.ok) {
2051
+ const persisted = await persistCanvasPayloadFile(normalized.payload, { config, store });
2052
+ if (persisted.ok) {
2053
+ const canvasItem = {
2054
+ ...persisted.payload,
2055
+ toolName: "start_long_job",
2056
+ commandCwd: config.commandCwd,
2057
+ };
2058
+ await store.appendEvent("canvas.item", canvasItem);
2059
+ observers.event("canvas.item", canvasItem);
2060
+ }
2061
+ }
2062
+ }
2063
+ }
2064
+ return result;
2065
+ }
2066
+ case "long_job_status": {
2067
+ const result = await longJobStatus(args, config);
2068
+ const eventResult = sanitizeToolResult(result);
2069
+ await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
2070
+ observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
2071
+ return result;
2072
+ }
2020
2073
  case "delegate_agent": {
2021
2074
  const wrapperResult = await runAgentWrapper(
2022
2075
  {
package/src/guardrails.js CHANGED
@@ -2,6 +2,7 @@ import { evaluateCommandPolicy } from "./command-policy.js";
2
2
  import { checkWorkspaceToolUse, WORKSPACE_TOOL_NAMES } from "./workspace-tools.js";
3
3
  import { normalizeWrapperName } from "./tool-wrappers.js";
4
4
  import { checkTmuxToolUse, TMUX_TOOL_NAMES } from "./tmux-tools.js";
5
+ import { checkLongJobToolUse, LONG_JOB_TOOL_NAMES } from "./long-job-tools.js";
5
6
  import { checkMcpToolUse, isMcpBridgeTool } from "./mcp/policy.js";
6
7
 
7
8
  const DESTRUCTIVE_KEYWORDS = [
@@ -88,6 +89,10 @@ export function checkToolUse({ toolName, args, snapshot, config }) {
88
89
  return checkTmuxToolUse(toolName, args, config);
89
90
  }
90
91
 
92
+ if (LONG_JOB_TOOL_NAMES.includes(toolName)) {
93
+ return checkLongJobToolUse(toolName, args, config);
94
+ }
95
+
91
96
  if (toolName === "open_url") {
92
97
  if (!/^https?:\/\//.test(String(args.url || ""))) {
93
98
  return { allowed: false, reason: "Only http and https URLs are allowed." };
@@ -0,0 +1,581 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { execFile as execFileCallback } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { evaluateCommandPolicy } from "./command-policy.js";
8
+ import { redactSensitiveText } from "./redaction.js";
9
+
10
+ const execFile = promisify(execFileCallback);
11
+ const JOB_NAME_PATTERN = /^[A-Za-z0-9_.+-]{1,80}$/;
12
+ const JOB_ID_PATTERN = /^[A-Za-z0-9_.+-]{8,140}$/;
13
+ const SECRET_PATTERN = /(api[_-]?key|auth[_-]?token|npm[_-]?token|_authToken|password|passwd|secret|bearer\s+[A-Za-z0-9._-]+)/i;
14
+ const ABSOLUTE_PATH_PATTERN = /(^|[\s"'`=(:])((?:\/[A-Za-z0-9._@%+~:-]+)+\/?)/g;
15
+ const ALWAYS_ALLOWED_ABSOLUTE_PATHS = new Set(["/dev/null"]);
16
+ const MAX_COMMAND_BYTES = 12000;
17
+ const DEFAULT_POLL_INTERVAL_SECONDS = 60;
18
+
19
+ export const LONG_JOB_TOOL_NAMES = ["start_long_job", "long_job_status"];
20
+
21
+ function safeEnv() {
22
+ return {
23
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
24
+ HOME: process.env.HOME || "/tmp",
25
+ TERM: process.env.TERM || "xterm-256color",
26
+ SHELL: process.env.SHELL || "/bin/bash",
27
+ };
28
+ }
29
+
30
+ function clampInteger(value, fallback, min, max) {
31
+ const parsed = Number(value);
32
+ if (!Number.isFinite(parsed)) return fallback;
33
+ return Math.min(Math.max(Math.trunc(parsed), min), max);
34
+ }
35
+
36
+ function isInsideDirectory(root, candidate) {
37
+ const relative = path.relative(root, candidate);
38
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
39
+ }
40
+
41
+ function workspaceRoot(config = {}) {
42
+ return path.resolve(config.commandCwd || process.cwd());
43
+ }
44
+
45
+ function isTrustedWholeHost(config = {}) {
46
+ return config.sandboxMode === "host" && Boolean(config.allowDestructive);
47
+ }
48
+
49
+ function normalizeJobName(raw = "") {
50
+ const value = String(raw || "aginti-long-job")
51
+ .trim()
52
+ .replace(/[^A-Za-z0-9_.+-]+/g, "-")
53
+ .replace(/^-+|-+$/g, "")
54
+ .slice(0, 48);
55
+ return value || "aginti-long-job";
56
+ }
57
+
58
+ function makeJobId(name = "") {
59
+ const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
60
+ const suffix = crypto.randomBytes(4).toString("hex");
61
+ return `${normalizeJobName(name)}-${stamp}-${suffix}`;
62
+ }
63
+
64
+ function tmuxSessionName(jobId = "") {
65
+ const base = `aginti-job-${jobId}`.replace(/[^A-Za-z0-9_.+-]+/g, "-");
66
+ if (base.length <= 80) return base;
67
+ const hash = crypto.createHash("sha1").update(base).digest("hex").slice(0, 10);
68
+ return `${base.slice(0, 68)}-${hash}`;
69
+ }
70
+
71
+ function uniqueAbsolutePaths(text = "") {
72
+ const paths = new Set();
73
+ for (const match of String(text || "").matchAll(ABSOLUTE_PATH_PATTERN)) {
74
+ const candidate = match[2];
75
+ if (!candidate || candidate.startsWith("//")) continue;
76
+ paths.add(candidate.replace(/[),.;]+$/g, ""));
77
+ }
78
+ return [...paths].filter(Boolean);
79
+ }
80
+
81
+ function checkWorkspaceBoundText(text = "", config = {}, label = "long job command") {
82
+ if (!config.useDockerSandbox && isTrustedWholeHost(config)) return { ok: true };
83
+ const root = workspaceRoot(config);
84
+ for (const candidate of uniqueAbsolutePaths(text)) {
85
+ if (ALWAYS_ALLOWED_ABSOLUTE_PATHS.has(candidate)) continue;
86
+ const resolved = path.resolve(candidate);
87
+ if (!isInsideDirectory(root, resolved)) {
88
+ return {
89
+ ok: false,
90
+ reason: `${label} references an absolute host path outside the configured workspace: ${candidate}. Use a workspace-relative path or trusted host mode for whole-host work.`,
91
+ };
92
+ }
93
+ }
94
+ return { ok: true };
95
+ }
96
+
97
+ function resolveCwd(config = {}, rawCwd = ".") {
98
+ const root = workspaceRoot(config);
99
+ const requested = String(rawCwd || ".").trim() || ".";
100
+ const cwd = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(root, requested);
101
+ if (!isTrustedWholeHost(config) && !isInsideDirectory(root, cwd)) {
102
+ return { ok: false, reason: "Long-job cwd must stay inside the configured workspace unless trusted host mode is enabled." };
103
+ }
104
+ return { ok: true, cwd, root };
105
+ }
106
+
107
+ function resolveExpectedOutput(config = {}, cwd = "", rawPath = "") {
108
+ const value = String(rawPath || "").trim();
109
+ if (!value) return { ok: true, outputPath: "", outputPathAbs: "" };
110
+ const root = workspaceRoot(config);
111
+ const absolute = path.isAbsolute(value) ? path.resolve(value) : path.resolve(cwd || root, value);
112
+ if (!isTrustedWholeHost(config) && !isInsideDirectory(root, absolute)) {
113
+ return {
114
+ ok: false,
115
+ reason: "Expected output path must stay inside the configured workspace unless trusted host mode is enabled.",
116
+ };
117
+ }
118
+ return {
119
+ ok: true,
120
+ outputPath: isInsideDirectory(root, absolute) ? path.relative(root, absolute) || "." : absolute,
121
+ outputPathAbs: absolute,
122
+ };
123
+ }
124
+
125
+ async function runTmux(args, options = {}) {
126
+ try {
127
+ const result = await execFile("tmux", args, {
128
+ timeout: options.timeout ?? 12000,
129
+ maxBuffer: options.maxBuffer ?? 128 * 1024,
130
+ env: safeEnv(),
131
+ signal: options.signal,
132
+ });
133
+ return {
134
+ ok: true,
135
+ stdout: redactSensitiveText(result.stdout || ""),
136
+ stderr: redactSensitiveText(result.stderr || ""),
137
+ };
138
+ } catch (error) {
139
+ const message = redactSensitiveText(error instanceof Error ? error.message : String(error));
140
+ return {
141
+ ok: false,
142
+ stdout: redactSensitiveText(String(error?.stdout || "")),
143
+ stderr: redactSensitiveText(String(error?.stderr || message)),
144
+ error: message,
145
+ exitCode: Number.isInteger(error?.code) ? error.code : 1,
146
+ };
147
+ }
148
+ }
149
+
150
+ function shellQuote(value = "") {
151
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
152
+ }
153
+
154
+ function supervisorSource() {
155
+ return String.raw`import fs from "node:fs";
156
+ import fsp from "node:fs/promises";
157
+ import path from "node:path";
158
+ import { spawn } from "node:child_process";
159
+ import { fileURLToPath } from "node:url";
160
+
161
+ const jobDir = path.dirname(fileURLToPath(import.meta.url));
162
+ const jobPath = path.join(jobDir, "job.json");
163
+ const statusPath = path.join(jobDir, "status.json");
164
+ const eventsPath = path.join(jobDir, "events.jsonl");
165
+ const stdoutPath = path.join(jobDir, "stdout.log");
166
+ const stderrPath = path.join(jobDir, "stderr.log");
167
+ const verifyStdoutPath = path.join(jobDir, "verify_stdout.log");
168
+ const verifyStderrPath = path.join(jobDir, "verify_stderr.log");
169
+ const supervisorLogPath = path.join(jobDir, "supervisor.log");
170
+ const job = JSON.parse(await fsp.readFile(jobPath, "utf8"));
171
+ let status = {
172
+ jobId: job.jobId,
173
+ name: job.name,
174
+ state: "starting",
175
+ createdAt: job.createdAt,
176
+ updatedAt: new Date().toISOString(),
177
+ cwd: job.cwd,
178
+ session: job.session,
179
+ command: job.displayCommand || job.command,
180
+ expectedOutputPath: job.expectedOutputPath || "",
181
+ expectedSizeBytes: job.expectedSizeBytes || 0,
182
+ restartOnFailure: Boolean(job.restartOnFailure),
183
+ pollIntervalSeconds: job.pollIntervalSeconds,
184
+ attempt: 0,
185
+ pid: null,
186
+ exitCode: null,
187
+ verifyExitCode: null,
188
+ outputBytes: 0,
189
+ percent: null,
190
+ reason: "",
191
+ };
192
+
193
+ function now() {
194
+ return new Date().toISOString();
195
+ }
196
+
197
+ async function appendLog(message) {
198
+ await fsp.appendFile(supervisorLogPath, now() + " " + message + "\n");
199
+ }
200
+
201
+ async function appendEvent(type, data = {}) {
202
+ await fsp.appendFile(eventsPath, JSON.stringify({ type, at: now(), ...data }) + "\n");
203
+ }
204
+
205
+ function outputStats() {
206
+ if (!job.expectedOutputPathAbs) return { exists: false, bytes: 0, percent: null };
207
+ try {
208
+ const stat = fs.statSync(job.expectedOutputPathAbs);
209
+ const bytes = stat.size;
210
+ const expected = Number(job.expectedSizeBytes || 0);
211
+ return {
212
+ exists: true,
213
+ bytes,
214
+ percent: expected > 0 ? Math.min(100, (bytes / expected) * 100) : null,
215
+ };
216
+ } catch {
217
+ return { exists: false, bytes: 0, percent: null };
218
+ }
219
+ }
220
+
221
+ async function writeStatus(patch = {}) {
222
+ const stats = outputStats();
223
+ status = {
224
+ ...status,
225
+ ...patch,
226
+ outputExists: stats.exists,
227
+ outputBytes: stats.bytes,
228
+ percent: stats.percent,
229
+ updatedAt: now(),
230
+ };
231
+ const tmp = statusPath + ".tmp";
232
+ await fsp.writeFile(tmp, JSON.stringify(status, null, 2));
233
+ await fsp.rename(tmp, statusPath);
234
+ }
235
+
236
+ function outputComplete() {
237
+ if (!job.expectedOutputPathAbs) return true;
238
+ const stats = outputStats();
239
+ if (!stats.exists) return false;
240
+ const expected = Number(job.expectedSizeBytes || 0);
241
+ return expected > 0 ? stats.bytes === expected : stats.bytes > 0;
242
+ }
243
+
244
+ function deadlineExceeded() {
245
+ return Number(job.timeoutSeconds || 0) > 0 && Date.now() >= job.startedEpochMs + Number(job.timeoutSeconds) * 1000;
246
+ }
247
+
248
+ async function delay(ms) {
249
+ await new Promise((resolve) => setTimeout(resolve, ms));
250
+ }
251
+
252
+ async function runShell(command, { stdout, stderr, label }) {
253
+ await appendEvent(label + ".started", { command });
254
+ const shell = process.env.SHELL || "/bin/bash";
255
+ return await new Promise((resolve) => {
256
+ let timedOut = false;
257
+ const child = spawn(shell, ["-lc", command], {
258
+ cwd: job.cwd,
259
+ env: { ...process.env, AGINTI_LONG_JOB_ID: job.jobId, AGINTI_LONG_JOB_DIR: jobDir },
260
+ stdio: ["ignore", "pipe", "pipe"],
261
+ });
262
+ status.pid = child.pid || null;
263
+ writeStatus({ state: label === "verify" ? "verifying" : "running", pid: status.pid }).catch(() => {});
264
+ const out = fs.createWriteStream(stdout, { flags: "a" });
265
+ const err = fs.createWriteStream(stderr, { flags: "a" });
266
+ child.stdout.on("data", (chunk) => out.write(chunk));
267
+ child.stderr.on("data", (chunk) => err.write(chunk));
268
+ const timeoutMs = Number(job.timeoutSeconds || 0) > 0 ? Math.max(1, job.startedEpochMs + Number(job.timeoutSeconds) * 1000 - Date.now()) : 0;
269
+ const timer = timeoutMs
270
+ ? setTimeout(() => {
271
+ timedOut = true;
272
+ try {
273
+ child.kill("SIGTERM");
274
+ } catch {}
275
+ setTimeout(() => {
276
+ try {
277
+ child.kill("SIGKILL");
278
+ } catch {}
279
+ }, 5000).unref?.();
280
+ }, timeoutMs)
281
+ : null;
282
+ child.on("close", async (code, signal) => {
283
+ if (timer) clearTimeout(timer);
284
+ out.end();
285
+ err.end();
286
+ await appendEvent(label + ".finished", { exitCode: code, signal: signal || "", timedOut });
287
+ resolve({ exitCode: Number.isInteger(code) ? code : 1, signal: signal || "", timedOut });
288
+ });
289
+ child.on("error", async (error) => {
290
+ if (timer) clearTimeout(timer);
291
+ out.end();
292
+ err.end();
293
+ await appendEvent(label + ".failed", { error: error.message || String(error) });
294
+ resolve({ exitCode: 1, signal: "", timedOut, error: error.message || String(error) });
295
+ });
296
+ });
297
+ }
298
+
299
+ await fsp.mkdir(jobDir, { recursive: true });
300
+ await appendLog("long job supervisor starting: " + job.jobId);
301
+ await appendEvent("job.started", { jobId: job.jobId, session: job.session });
302
+ job.startedEpochMs = Date.now();
303
+ await writeStatus({ state: "starting", startedAt: now() });
304
+
305
+ const interval = setInterval(() => {
306
+ if (status.state === "running" || status.state === "verifying" || status.state === "restarting") {
307
+ writeStatus({ heartbeatAt: now() }).catch(() => {});
308
+ }
309
+ }, Math.max(1, Number(job.pollIntervalSeconds || 60)) * 1000);
310
+ interval.unref?.();
311
+
312
+ let attempt = 0;
313
+ while (true) {
314
+ attempt += 1;
315
+ await writeStatus({ state: "running", attempt, pid: null, exitCode: null, reason: "" });
316
+ await appendLog("attempt " + attempt + " started");
317
+ const result = await runShell(job.command, { stdout: stdoutPath, stderr: stderrPath, label: "command" });
318
+ await writeStatus({ exitCode: result.exitCode, pid: null });
319
+
320
+ const complete = result.exitCode === 0 && outputComplete();
321
+ if (complete) {
322
+ if (job.verifyCommand) {
323
+ const verify = await runShell(job.verifyCommand, { stdout: verifyStdoutPath, stderr: verifyStderrPath, label: "verify" });
324
+ await writeStatus({ verifyExitCode: verify.exitCode, pid: null });
325
+ if (verify.exitCode === 0) {
326
+ await writeStatus({ state: "completed", completedAt: now(), reason: "command and verification succeeded" });
327
+ await appendEvent("job.completed", { verifyExitCode: verify.exitCode });
328
+ await appendLog("job completed");
329
+ process.exit(0);
330
+ }
331
+ await writeStatus({ state: "failed", failedAt: now(), reason: "verify command exited " + verify.exitCode });
332
+ await appendEvent("job.failed", { reason: "verify command exited " + verify.exitCode });
333
+ await appendLog("job failed: verify exit " + verify.exitCode);
334
+ process.exit(1);
335
+ }
336
+ await writeStatus({ state: "completed", completedAt: now(), reason: "command succeeded" });
337
+ await appendEvent("job.completed", { exitCode: result.exitCode });
338
+ await appendLog("job completed");
339
+ process.exit(0);
340
+ }
341
+
342
+ const reason = result.exitCode !== 0 ? "command exited " + result.exitCode : "expected output is missing or incomplete";
343
+ if (job.restartOnFailure && !deadlineExceeded()) {
344
+ await writeStatus({ state: "restarting", reason, restartedAt: now() });
345
+ await appendEvent("job.restarting", { attempt, reason });
346
+ await appendLog("restarting after attempt " + attempt + ": " + reason);
347
+ await delay(Math.max(1, Number(job.pollIntervalSeconds || 60)) * 1000);
348
+ continue;
349
+ }
350
+
351
+ await writeStatus({ state: "failed", failedAt: now(), reason });
352
+ await appendEvent("job.failed", { attempt, reason });
353
+ await appendLog("job failed: " + reason);
354
+ process.exit(1);
355
+ }`;
356
+ }
357
+
358
+ function buildStatusMarkdown(job = {}) {
359
+ return [
360
+ `# Long Job: ${job.name}`,
361
+ "",
362
+ `- Job ID: \`${job.jobId}\``,
363
+ `- State file: \`${job.statusPath}\``,
364
+ `- Session: \`${job.session}\``,
365
+ `- CWD: \`${job.cwd}\``,
366
+ job.expectedOutputPath ? `- Expected output: \`${job.expectedOutputPath}\`` : "",
367
+ job.expectedSizeBytes ? `- Expected size: ${job.expectedSizeBytes} bytes` : "",
368
+ `- Stdout log: \`${job.stdoutPath}\``,
369
+ `- Stderr log: \`${job.stderrPath}\``,
370
+ `- Supervisor log: \`${job.supervisorLogPath}\``,
371
+ "",
372
+ "This is a durable background job. The model loop should not poll it. Use `long_job_status` or read the status JSON later.",
373
+ ]
374
+ .filter(Boolean)
375
+ .join("\n");
376
+ }
377
+
378
+ export function checkLongJobToolUse(toolName, args = {}, config = {}) {
379
+ if (!config.allowShellTool) {
380
+ return { allowed: false, reason: "Long jobs require the shell tool to be enabled.", category: "long-job" };
381
+ }
382
+ if (toolName === "long_job_status") {
383
+ const jobId = String(args.jobId || "").trim();
384
+ if (!jobId) return { allowed: false, reason: "long_job_status requires jobId.", category: "long-job" };
385
+ if (!JOB_ID_PATTERN.test(jobId)) return { allowed: false, reason: "Invalid long job id.", category: "long-job" };
386
+ return { allowed: true, category: "long-job" };
387
+ }
388
+ if (toolName !== "start_long_job") return { allowed: true, category: "long-job" };
389
+
390
+ const name = normalizeJobName(args.name || "aginti-long-job");
391
+ if (!JOB_NAME_PATTERN.test(name)) {
392
+ return { allowed: false, reason: "Long job name must use letters, numbers, dot, underscore, plus, or dash.", category: "long-job" };
393
+ }
394
+ const command = String(args.command || "").trim();
395
+ if (!command) return { allowed: false, reason: "start_long_job requires command.", category: "long-job" };
396
+ if (Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) {
397
+ return { allowed: false, reason: "Long job command is too large.", category: "long-job" };
398
+ }
399
+ if (SECRET_PATTERN.test(command)) {
400
+ return { allowed: false, reason: "Long job command appears to contain a secret.", category: "long-job" };
401
+ }
402
+ const cwd = resolveCwd(config, args.cwd || ".");
403
+ if (!cwd.ok) return { allowed: false, reason: cwd.reason, category: "long-job" };
404
+ const workspaceBound = checkWorkspaceBoundText(command, config, "Long job command");
405
+ if (!workspaceBound.ok) return { allowed: false, reason: workspaceBound.reason, category: "long-job" };
406
+ const policy = evaluateCommandPolicy(command, config);
407
+ if (!policy.allowed) {
408
+ return {
409
+ allowed: false,
410
+ reason: `Long job command is blocked by shell policy: ${policy.reason}`,
411
+ category: policy.category || "long-job",
412
+ needsApproval: policy.needsApproval,
413
+ };
414
+ }
415
+ const verifyCommand = String(args.verifyCommand || "").trim();
416
+ if (verifyCommand) {
417
+ if (SECRET_PATTERN.test(verifyCommand)) {
418
+ return { allowed: false, reason: "Long job verify command appears to contain a secret.", category: "long-job" };
419
+ }
420
+ const verifyWorkspaceBound = checkWorkspaceBoundText(verifyCommand, config, "Long job verify command");
421
+ if (!verifyWorkspaceBound.ok) return { allowed: false, reason: verifyWorkspaceBound.reason, category: "long-job" };
422
+ const verifyPolicy = evaluateCommandPolicy(verifyCommand, config);
423
+ if (!verifyPolicy.allowed) {
424
+ return {
425
+ allowed: false,
426
+ reason: `Long job verify command is blocked by shell policy: ${verifyPolicy.reason}`,
427
+ category: verifyPolicy.category || "long-job",
428
+ needsApproval: verifyPolicy.needsApproval,
429
+ };
430
+ }
431
+ }
432
+ const output = resolveExpectedOutput(config, cwd.cwd, args.expectedOutputPath || "");
433
+ if (!output.ok) return { allowed: false, reason: output.reason, category: "long-job" };
434
+ return { allowed: true, category: "long-job" };
435
+ }
436
+
437
+ export async function startLongJob(args = {}, config = {}) {
438
+ const guard = checkLongJobToolUse("start_long_job", args, config);
439
+ if (!guard.allowed) {
440
+ return {
441
+ ok: false,
442
+ blocked: true,
443
+ toolName: "start_long_job",
444
+ reason: guard.reason,
445
+ category: guard.category,
446
+ needsApproval: guard.needsApproval,
447
+ };
448
+ }
449
+
450
+ const name = normalizeJobName(args.name || "aginti-long-job");
451
+ const command = String(args.command || "").trim();
452
+ const verifyCommand = String(args.verifyCommand || "").trim();
453
+ const cwd = resolveCwd(config, args.cwd || ".");
454
+ const output = resolveExpectedOutput(config, cwd.cwd, args.expectedOutputPath || "");
455
+ const root = cwd.root;
456
+ const jobId = makeJobId(name);
457
+ const session = tmuxSessionName(jobId);
458
+ const jobDir = path.join(root, ".aginti", "long-jobs", jobId);
459
+ const relativeJobDir = path.relative(root, jobDir) || ".";
460
+ const statusPath = path.join(relativeJobDir, "status.json");
461
+ const statusMarkdownPath = path.join(relativeJobDir, "status.md");
462
+ const supervisorPath = path.join(jobDir, "supervisor.mjs");
463
+ const stdoutPath = path.join(relativeJobDir, "stdout.log");
464
+ const stderrPath = path.join(relativeJobDir, "stderr.log");
465
+ const supervisorLogPath = path.join(relativeJobDir, "supervisor.log");
466
+ const expectedSizeBytes = clampInteger(args.expectedSizeBytes, 0, 0, Number.MAX_SAFE_INTEGER);
467
+ const pollIntervalSeconds = clampInteger(
468
+ args.pollIntervalSeconds,
469
+ DEFAULT_POLL_INTERVAL_SECONDS,
470
+ 5,
471
+ 3600
472
+ );
473
+ const timeoutSeconds = clampInteger(args.timeoutSeconds, 0, 0, 60 * 60 * 24 * 14);
474
+
475
+ await fsp.mkdir(jobDir, { recursive: true });
476
+ const metadata = {
477
+ jobId,
478
+ name,
479
+ session,
480
+ createdAt: new Date().toISOString(),
481
+ cwd: cwd.cwd,
482
+ command: redactSensitiveText(command),
483
+ displayCommand: redactSensitiveText(command),
484
+ rawCommandSha256: crypto.createHash("sha256").update(command).digest("hex"),
485
+ verifyCommand: verifyCommand ? redactSensitiveText(verifyCommand) : "",
486
+ expectedOutputPath: output.outputPath,
487
+ expectedOutputPathAbs: output.outputPathAbs,
488
+ expectedSizeBytes,
489
+ restartOnFailure: Boolean(args.restartOnFailure),
490
+ pollIntervalSeconds,
491
+ timeoutSeconds,
492
+ statusPath,
493
+ stdoutPath,
494
+ stderrPath,
495
+ supervisorLogPath,
496
+ note: redactSensitiveText(String(args.note || "")),
497
+ };
498
+ await fsp.writeFile(path.join(jobDir, "job.json"), JSON.stringify({ ...metadata, command, verifyCommand }, null, 2));
499
+ await fsp.writeFile(supervisorPath, supervisorSource());
500
+ await fsp.writeFile(path.join(jobDir, "command.txt"), `${command}\n`);
501
+ if (verifyCommand) await fsp.writeFile(path.join(jobDir, "verify.txt"), `${verifyCommand}\n`);
502
+ await fsp.writeFile(path.join(jobDir, "status.md"), buildStatusMarkdown(metadata));
503
+ await fsp.writeFile(path.join(jobDir, "events.jsonl"), "");
504
+ await fsp.writeFile(
505
+ path.join(jobDir, "status.json"),
506
+ JSON.stringify({ ...metadata, state: "created", outputBytes: 0, percent: null, updatedAt: new Date().toISOString() }, null, 2)
507
+ );
508
+
509
+ const tmux = await runTmux(
510
+ ["new-session", "-d", "-s", session, "-c", cwd.cwd, `${shellQuote(process.execPath)} ${shellQuote(supervisorPath)}`],
511
+ { timeout: 12000, signal: config.abortSignal }
512
+ );
513
+ if (!tmux.ok) {
514
+ return {
515
+ ok: false,
516
+ toolName: "start_long_job",
517
+ error: tmux.stderr || tmux.error,
518
+ reason: "Failed to start durable tmux long job. Ensure tmux is installed or use a shorter blocking command.",
519
+ jobId,
520
+ statusPath,
521
+ };
522
+ }
523
+
524
+ return {
525
+ ok: true,
526
+ toolName: "start_long_job",
527
+ jobId,
528
+ name,
529
+ session,
530
+ target: `${session}:0.0`,
531
+ cwd: cwd.cwd,
532
+ statusPath,
533
+ statusMarkdownPath,
534
+ eventsPath: path.join(relativeJobDir, "events.jsonl"),
535
+ stdoutPath,
536
+ stderrPath,
537
+ supervisorLogPath,
538
+ expectedOutputPath: output.outputPath,
539
+ expectedSizeBytes,
540
+ restartOnFailure: Boolean(args.restartOnFailure),
541
+ pollIntervalSeconds,
542
+ timeoutSeconds,
543
+ background: true,
544
+ deferredVerification: true,
545
+ result: `Started durable background job ${jobId}. Status: ${statusPath}. Logs: ${stdoutPath}, ${stderrPath}.`,
546
+ instruction:
547
+ "Long job is running in tmux. Do not keep the model loop alive to poll it; report the job id and status path, then finish unless the user explicitly asked for an interactive status check.",
548
+ };
549
+ }
550
+
551
+ export async function longJobStatus(args = {}, config = {}) {
552
+ const jobId = String(args.jobId || "").trim();
553
+ if (!JOB_ID_PATTERN.test(jobId)) {
554
+ return { ok: false, blocked: true, toolName: "long_job_status", reason: "Invalid or missing long job id." };
555
+ }
556
+ const root = workspaceRoot(config);
557
+ const statusAbs = path.join(root, ".aginti", "long-jobs", jobId, "status.json");
558
+ try {
559
+ const status = JSON.parse(await fsp.readFile(statusAbs, "utf8"));
560
+ let sessionAlive = null;
561
+ if (status.session) {
562
+ const alive = await runTmux(["has-session", "-t", status.session], { timeout: 4000, signal: config.abortSignal });
563
+ sessionAlive = Boolean(alive.ok);
564
+ }
565
+ return {
566
+ ok: true,
567
+ toolName: "long_job_status",
568
+ jobId,
569
+ statusPath: path.relative(root, statusAbs),
570
+ sessionAlive,
571
+ ...status,
572
+ };
573
+ } catch (error) {
574
+ return {
575
+ ok: false,
576
+ toolName: "long_job_status",
577
+ jobId,
578
+ reason: `Could not read long job status: ${error instanceof Error ? error.message : String(error)}`,
579
+ };
580
+ }
581
+ }
@@ -463,13 +463,26 @@ function mockCommandForGoal(goal = "") {
463
463
  function mockShouldPreferShellForGoal(goal = "") {
464
464
  const text = String(goal || "");
465
465
  return (
466
- /\b(?:command|shell|terminal|pwd|working directory|current working directory|safe command|run command|review focus|code review|git status|git diff|changed files)\b/i.test(
466
+ /\b(?:command|shell|terminal|pwd|working directory|current working directory|safe command|run command|review focus|code review|git status|git diff|changed files|download|wget|curl|long[- ]running|background job|large file)\b/i.test(
467
467
  text
468
468
  ) ||
469
- /当前工作目录|工作目录|命令|终端/.test(text)
469
+ /当前工作目录|工作目录|命令|终端|下载|后台|长时间/.test(text)
470
470
  );
471
471
  }
472
472
 
473
+ function mockLongJobToolForGoal(goal = "") {
474
+ const text = String(goal || "").toLowerCase();
475
+ if (!/\b(download|wget|curl|long[- ]running|hours?|background|durable job|large file)\b|下载|后台|长时间/.test(text)) return null;
476
+ return mockToolCall("start_long_job", {
477
+ name: "mock-long-job",
478
+ command: "printf 'mock long job complete\\n' > mock-long-job-output.txt",
479
+ expectedOutputPath: "mock-long-job-output.txt",
480
+ verifyCommand: "grep -q complete mock-long-job-output.txt",
481
+ pollIntervalSeconds: 5,
482
+ note: "Mock long-job handoff smoke.",
483
+ });
484
+ }
485
+
473
486
  function mockPathForGoal(goal = "") {
474
487
  const text = String(goal);
475
488
  if (/\bAGINTI\.md\b|project instructions|remember (?:that|this)|durable preference/i.test(text)) return "AGINTI.md";
@@ -693,7 +706,7 @@ export async function createPlan(client, config, state) {
693
706
  ? `Shell tool is enabled in ${config.commandCwd}. Host platform: ${platformLabel(platform)}. In Docker, this path is mounted as /workspace with persistent /aginti-env and /aginti-cache mounts, and common host data roots such as the user's home parent are mounted read-only at their original absolute paths for inspection. Use relative paths or /workspace for outputs and writes. Absolute host paths are acceptable for read-only inspection when visible, but do not write outside /workspace unless the user approves host mode. Permission mode: ${config.permissionMode || "normal"}. 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. Do not run npx aginti, npm exec aginti, or nested aginti diagnostics from this shell; they may resolve stale project packages or create recursive agent sessions. On native Windows host mode, prefer PowerShell/cmd-compatible commands or WSL/Docker for bash-like toolchains.`
694
707
  : "",
695
708
  config.allowShellTool
696
- ? "Host tmux tools are enabled for long-running sessions. Plan to use tmux_start_session for durable jobs, tmux_capture_pane to monitor, tmux_send_keys to interact after capture, and tmux_list_sessions to discover existing sessions. Tmux captures include old scrollback, so after a restart require a fresh run marker, heartbeat, PID, or log/status timestamp before treating capture text as current evidence. Do not install or run tmux inside Docker run_command containers; those containers are short-lived and cannot preserve tmux servers. In Docker sandbox mode, tmux startup/send commands are still workspace-write-bound and shell-pane text follows the same Docker workspace command policy as run_command: use relative project paths for writes and outputs. Read-only inspection of visible host absolute paths is allowed through run_command's read-only mounts; do not use tmux as a workaround for package installs, destructive git rewrites, or broad shell commands. In host mode, tmux startup/send command text follows the same host shell policy as run_command; if blocked, present the suggested approval/rerun path instead of trying tmux as a workaround. Ask for --sandbox-mode host --allow-destructive before trusted whole-host write/system work."
709
+ ? "Long-job tools are enabled for downloads, long I/O, long tests/builds, model jobs, and any command likely to run for minutes or hours. Plan to use start_long_job with expectedOutputPath/expectedSizeBytes/verifyCommand when applicable; it creates a durable tmux-backed supervisor and returns immediately, so do not keep the model loop alive with wait/poll steps. Use long_job_status only when the user explicitly asks for status later. Host tmux tools are also enabled for interactive durable sessions. Use tmux_start_session for manual terminals, tmux_capture_pane to monitor, tmux_send_keys to interact after capture, and tmux_list_sessions to discover existing sessions. Tmux captures include old scrollback, so after a restart require a fresh run marker, heartbeat, PID, or log/status timestamp before treating capture text as current evidence. Do not install or run tmux inside Docker run_command containers; those containers are short-lived and cannot preserve tmux servers. In Docker sandbox mode, tmux/long-job commands are still workspace-write-bound and shell-pane text follows the same Docker workspace command policy as run_command: use relative project paths for writes and outputs. Read-only inspection of visible host absolute paths is allowed through run_command's read-only mounts; do not use tmux as a workaround for package installs, destructive git rewrites, or broad shell commands. In host mode, tmux startup/send command text follows the same host shell policy as run_command; if blocked, present the suggested approval/rerun path instead of trying tmux as a workaround. Ask for --sandbox-mode host --allow-destructive before trusted whole-host write/system work."
697
710
  : "",
698
711
  config.allowFileTools
699
712
  ? `Workspace file tools are enabled in ${config.commandCwd}: inspect_project, list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, preview_workspace. For large or unfamiliar repos, plan to call inspect_project first, then search/read AGINTI.md/AGENTS.md/README/manifests and exact files. apply_patch supports exact single-file replacements and Codex-style/unified multi-file patches; prefer it for edits after reading relevant context. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets. For newly generated standalone prose/docs/stories/assets, choose a descriptive non-conflicting filename from the topic/language instead of generic names like story.txt or output.txt; do not overwrite existing files unless the user explicitly asked to update/replace/overwrite that file. 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.`
@@ -742,7 +755,7 @@ export async function createPlan(client, config, state) {
742
755
  "For environment or system-maintenance work, prefer project-local dry-run plans/scripts unless the configured policy explicitly allows stronger actions.",
743
756
  "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.",
744
757
  "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.",
745
- "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.",
758
+ "If a command will take minutes or hours, hand it to start_long_job and finish with the durable status path instead of burning model steps. 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.",
746
759
  "Plan for a complete result, not endless exploration; finish once the request is satisfied and checks have passed or been honestly skipped.",
747
760
  "Return a numbered plan only.",
748
761
  ]
@@ -1239,6 +1252,65 @@ export async function requestNextStep(client, config, messages) {
1239
1252
  tools.splice(
1240
1253
  -1,
1241
1254
  0,
1255
+ {
1256
+ type: "function",
1257
+ function: {
1258
+ name: "start_long_job",
1259
+ description:
1260
+ "Start a durable tmux-backed background job for downloads, long I/O, long tests/builds, model jobs, or any command expected to take minutes or hours. This tool writes .aginti/long-jobs/<jobId>/status.json plus stdout/stderr/supervisor logs and returns immediately. Use expectedOutputPath, expectedSizeBytes, restartOnFailure, and verifyCommand for resumable downloads. After this tool succeeds, do not keep the model loop alive with wait/poll calls; finish with the job id and status path unless the user explicitly asked for an immediate one-time status check.",
1261
+ parameters: {
1262
+ type: "object",
1263
+ properties: {
1264
+ name: { type: "string", description: "Short safe job name, e.g. dataset-download or latex-build." },
1265
+ command: { type: "string", description: "Shell command to run under the active shell policy. Do not include secrets." },
1266
+ cwd: { type: "string", description: "Workspace-relative cwd for the job. Defaults to ." },
1267
+ expectedOutputPath: {
1268
+ type: "string",
1269
+ description: "Optional expected output file path. Relative paths are resolved from cwd. Used for progress and completion checks.",
1270
+ },
1271
+ expectedSizeBytes: {
1272
+ type: "integer",
1273
+ description: "Optional exact expected output size in bytes, e.g. Content-Length for downloads.",
1274
+ },
1275
+ verifyCommand: {
1276
+ type: "string",
1277
+ description: "Optional shell verification after the main command succeeds, e.g. unzip -t file.zip or sha256sum -c checksums.txt.",
1278
+ },
1279
+ restartOnFailure: {
1280
+ type: "boolean",
1281
+ description: "Restart command when it exits before expected output is complete. Useful with wget -c/curl -C resumable downloads.",
1282
+ },
1283
+ pollIntervalSeconds: {
1284
+ type: "integer",
1285
+ description: "Shell supervisor status update and restart delay, 5 to 3600 seconds. Defaults to 60.",
1286
+ },
1287
+ timeoutSeconds: {
1288
+ type: "integer",
1289
+ description: "Optional overall timeout. 0 means no timeout.",
1290
+ },
1291
+ note: { type: "string", description: "Short human note for the status card." },
1292
+ },
1293
+ required: ["command"],
1294
+ additionalProperties: false,
1295
+ },
1296
+ },
1297
+ },
1298
+ {
1299
+ type: "function",
1300
+ function: {
1301
+ name: "long_job_status",
1302
+ description:
1303
+ "Read the deterministic status JSON for a previously started start_long_job. Use only when the user asks for status or when a later run explicitly needs to verify a background job; do not poll this repeatedly inside one model loop.",
1304
+ parameters: {
1305
+ type: "object",
1306
+ properties: {
1307
+ jobId: { type: "string", description: "Job id returned by start_long_job." },
1308
+ },
1309
+ required: ["jobId"],
1310
+ additionalProperties: false,
1311
+ },
1312
+ },
1313
+ },
1242
1314
  {
1243
1315
  type: "function",
1244
1316
  function: {
@@ -1637,6 +1709,10 @@ export async function requestNextStep(client, config, messages) {
1637
1709
  }
1638
1710
 
1639
1711
  if (config.allowShellTool && mockShouldPreferShellForGoal(config.goal)) {
1712
+ const longJobTool = mockLongJobToolForGoal(config.goal);
1713
+ if (longJobTool) {
1714
+ return mockChatResponse("Mock mode will start a durable long job instead of polling in the model loop.", [longJobTool]);
1715
+ }
1640
1716
  return mockChatResponse("Mock mode will use the guarded shell tool for an explicitly local command task.", [
1641
1717
  mockToolCall("run_command", { command: mockCommandForGoal(config.goal) }),
1642
1718
  ]);
@@ -625,6 +625,18 @@ function toolPayloadToEvidence(payload = {}, source = "tool") {
625
625
  if (toolName === "run_command" || payload.stdout || Number.isInteger(payload.exitCode)) {
626
626
  push("command", `exit=${payload.exitCode ?? 0} stdout=${compact(payload.stdout || "", 260)}`, args.command || "");
627
627
  }
628
+ if (["start_long_job", "long_job_status"].includes(toolName)) {
629
+ push(
630
+ "command",
631
+ `${toolName} ${payload.state ? `state=${payload.state}` : payload.background ? "background=true" : ""} status=${payload.statusPath || ""}`,
632
+ payload.statusPath || payload.expectedOutputPath || args.command || ""
633
+ );
634
+ push(
635
+ "artifact",
636
+ `${toolName} produced durable status/log artifact paths`,
637
+ payload.statusMarkdownPath || payload.statusPath || payload.expectedOutputPath || ""
638
+ );
639
+ }
628
640
  if (["open_url", "click", "type", "scroll", "press", "back"].includes(toolName) || /\b(browser|chrome|cdp|playwright|selenium|upload|attach|submit|click|tab|page)\b/.test(text)) {
629
641
  push("browser", `${toolName || "browser tool"} affected or inspected browser/UI state`, payload.url || args.url || args.command || "");
630
642
  }