@lazyingart/agintiflow 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/docs/skills-and-tools.md +2 -2
- package/package.json +4 -2
- package/public/app.js +3 -0
- package/public/index.html +6 -0
- package/public/styles.css +17 -0
- package/scripts/smoke-auth.js +4 -2
- package/scripts/smoke-capabilities.js +4 -0
- package/scripts/smoke-skills.js +2 -0
- package/scripts/smoke-tmux-tools.js +82 -0
- package/skills/tmux-session/SKILL.md +32 -0
- package/src/agent-runner.js +35 -0
- package/src/auth-onboarding.js +22 -2
- package/src/capabilities.js +5 -3
- package/src/guardrails.js +5 -0
- package/src/model-client.js +95 -13
- package/src/tmux-tools.js +315 -0
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ It is designed for workflows where an AI agent should act, but every tool, log,
|
|
|
28
28
|
| Core loop | Plan -> use tools -> log events -> finish or resume |
|
|
29
29
|
| Browser control | Playwright, lazy browser startup, domain allowlists |
|
|
30
30
|
| Model layer | Smart routing over DeepSeek fast/pro presets with manual OpenAI-compatible fallback |
|
|
31
|
-
| Local tools | Guarded workspace file tools, Codex-style patching, optional shell commands, Docker sandbox support, and advisory agent wrappers |
|
|
31
|
+
| Local tools | Guarded workspace file tools, Codex-style patching, optional shell commands, tmux session control, Docker sandbox support, and advisory agent wrappers |
|
|
32
32
|
| Memory | Session state, persisted web settings, chat continuation |
|
|
33
33
|
| Operator UX | Multilingual web UI with provider selection, run output, and conversation history |
|
|
34
34
|
|
|
@@ -46,7 +46,7 @@ aginti --list-profiles
|
|
|
46
46
|
aginti --sandbox-status
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
On first interactive use, if no main model key is detected, `aginti` opens an auth wizard. Use Up/Down to choose DeepSeek, OpenAI, or Qwen, paste the key, and press Enter to save it to the project-local ignored file `.aginti/.env` with `0600` permissions. The wizard then offers the optional auxiliary image key; press Esc to skip. You can rerun it even when keys already exist:
|
|
49
|
+
On first interactive use, if no main model key is detected, `aginti` opens an auth wizard. Use Up/Down to choose DeepSeek, OpenAI, or Qwen, paste the key, and press Enter to save it to the project-local ignored file `.aginti/.env` with `0600` permissions. The wizard points to DeepSeek keys at `https://platform.deepseek.com/api_keys` and OpenAI keys at `https://platform.openai.com/api-keys`. It then offers the optional auxiliary image key; press Esc to skip. You can rerun it even when keys already exist:
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
aginti auth
|
|
@@ -85,6 +85,8 @@ For current docs, install errors, package/toolchain setup, and source discovery,
|
|
|
85
85
|
|
|
86
86
|
For raster image work, AgInTiFlow has an optional `image_generation` skill backed by the `generate_image` tool and a local `GRSAI` key. The skill tells DeepSeek when image generation is appropriate; the tool calls GRS AI Nano Banana, saves manifests/images under `artifacts/images`, and sends the result to the canvas. See [docs/auxiliary-image-generation.md](docs/auxiliary-image-generation.md).
|
|
87
87
|
|
|
88
|
+
For long-running shell work, AgInTiFlow exposes host-side tmux tools when the shell tool is enabled: `tmux_list_sessions`, `tmux_capture_pane`, `tmux_send_keys`, and `tmux_start_session`. Use normal prompts such as `start this test server in tmux and monitor it` or `check my tmux session`. The agent captures panes before interacting, redacts outputs, blocks secret-like sends, and avoids sending sudo passwords or destructive commands.
|
|
89
|
+
|
|
88
90
|
AgInTiFlow now ships a Markdown skill library in `skills/<id>/SKILL.md`. Skills are prompt playbooks, while tools are deterministic actions such as `apply_patch`, `run_command`, `web_search`, `generate_image`, and `send_to_canvas`. Built-in skills cover code, websites/apps, LaTeX manuscripts, books, Word documents, image generation, GitHub, system maintenance, Android, R/Stan, Python, C/C++, shell, AAPS, and novel writing. See [docs/skills-and-tools.md](docs/skills-and-tools.md).
|
|
89
91
|
|
|
90
92
|
```bash
|
package/docs/skills-and-tools.md
CHANGED
|
@@ -6,13 +6,13 @@ AgInTiFlow separates **skills** from **tools** so the agent can stay general whi
|
|
|
6
6
|
|
|
7
7
|
**Skill**: Markdown guidance stored at `skills/<id>/SKILL.md`. A skill describes when to use a workflow, what to inspect first, which outputs matter, and which tools are usually useful. Skills are prompt context, not executable code.
|
|
8
8
|
|
|
9
|
-
**Tool**: A deterministic callable capability exposed to the model, such as `inspect_project`, `read_file`, `apply_patch`, `run_command`, `web_search`, `generate_image`, `preview_workspace`, or `send_to_canvas`.
|
|
9
|
+
**Tool**: A deterministic callable capability exposed to the model, such as `inspect_project`, `read_file`, `apply_patch`, `run_command`, `web_search`, `generate_image`, `preview_workspace`, `tmux_capture_pane`, or `send_to_canvas`.
|
|
10
10
|
|
|
11
11
|
**Profile**: A broad runtime mode such as `auto`, `code`, `latex`, or `maintenance`. Profiles tune routing, max steps, and general behavior. Skills can combine across profiles.
|
|
12
12
|
|
|
13
13
|
## Built-In Skills
|
|
14
14
|
|
|
15
|
-
The package ships built-in skills for code engineering, website/app building, LaTeX manuscripts, books, Microsoft Word documents, image generation, GitHub maintenance, system maintenance, Android, R/Stan, Python, C/C++, shell scripting, AAPS, and novel writing.
|
|
15
|
+
The package ships built-in skills for code engineering, website/app building, LaTeX manuscripts, books, Microsoft Word documents, image generation, GitHub maintenance, system maintenance, tmux session control, Android, R/Stan, Python, C/C++, shell scripting, AAPS, and novel writing.
|
|
16
16
|
|
|
17
17
|
List them from a project:
|
|
18
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazyingart/agintiflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"scripts/smoke-inbox.js",
|
|
50
50
|
"scripts/smoke-platform.js",
|
|
51
51
|
"scripts/smoke-skills.js",
|
|
52
|
+
"scripts/smoke-tmux-tools.js",
|
|
52
53
|
"scripts/smoke-toolchain-docker.js",
|
|
53
54
|
"scripts/smoke-web-api.js",
|
|
54
55
|
"src/",
|
|
@@ -73,9 +74,10 @@
|
|
|
73
74
|
"smoke:toolchain-docker": "node scripts/smoke-toolchain-docker.js",
|
|
74
75
|
"smoke:inbox": "node scripts/smoke-inbox.js",
|
|
75
76
|
"smoke:platform": "node scripts/smoke-platform.js",
|
|
77
|
+
"smoke:tmux-tools": "node scripts/smoke-tmux-tools.js",
|
|
76
78
|
"smoke:web-api": "node scripts/smoke-web-api.js",
|
|
77
79
|
"real:deepseek": "node scripts/real-deepseek-capabilities.js",
|
|
78
|
-
"test": "npm run check && npm run smoke:web-api && npm run smoke:coding-tools && npm run smoke:auxiliary-tools && npm run smoke:auth && npm run smoke:capabilities && npm run smoke:platform && npm run smoke:skills && npm run smoke:cli-chat && npm run smoke:inbox",
|
|
80
|
+
"test": "npm run check && npm run smoke:web-api && npm run smoke:coding-tools && npm run smoke:auxiliary-tools && npm run smoke:auth && npm run smoke:capabilities && npm run smoke:platform && npm run smoke:skills && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
|
|
79
81
|
"pack:dry-run": "npm pack --dry-run",
|
|
80
82
|
"smoke:capabilities": "node scripts/smoke-capabilities.js"
|
|
81
83
|
},
|
package/public/app.js
CHANGED
|
@@ -25,6 +25,9 @@ const translations = {
|
|
|
25
25
|
"DeepSeek/OpenAI/Qwen keys are missing. Use mock mode, export an env var, or save a project-local model key.",
|
|
26
26
|
setupEnvHelp:
|
|
27
27
|
"Env vars: DEEPSEEK_API_KEY, OPENAI_API_KEY, QWEN_API_KEY, LLM_API_KEY, and optional GRSAI for image generation. Mock mode remains available for local tests.",
|
|
28
|
+
setupKeyLinksLabel: "Get keys:",
|
|
29
|
+
deepseekKeyLink: "DeepSeek API keys",
|
|
30
|
+
openaiKeyLink: "OpenAI API keys",
|
|
28
31
|
setupProviderLabel: "Provider",
|
|
29
32
|
setupKeyLabel: "API key",
|
|
30
33
|
saveKeyButton: "Save local key",
|
package/public/index.html
CHANGED
|
@@ -50,6 +50,12 @@
|
|
|
50
50
|
<p class="subtle" data-i18n="setupEnvHelp">
|
|
51
51
|
Env vars: DEEPSEEK_API_KEY, OPENAI_API_KEY, QWEN_API_KEY, or LLM_API_KEY. Mock mode remains available for local tests.
|
|
52
52
|
</p>
|
|
53
|
+
<p class="subtle key-links">
|
|
54
|
+
<span data-i18n="setupKeyLinksLabel">Get keys:</span>
|
|
55
|
+
<a href="https://platform.deepseek.com/api_keys" target="_blank" rel="noreferrer" data-i18n="deepseekKeyLink">DeepSeek API keys</a>
|
|
56
|
+
<span aria-hidden="true">·</span>
|
|
57
|
+
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noreferrer" data-i18n="openaiKeyLink">OpenAI API keys</a>
|
|
58
|
+
</p>
|
|
53
59
|
</div>
|
|
54
60
|
<div class="grid">
|
|
55
61
|
<label>
|
package/public/styles.css
CHANGED
|
@@ -137,6 +137,23 @@ h1 {
|
|
|
137
137
|
overflow-wrap: anywhere;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
.setup-card .key-links {
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-wrap: wrap;
|
|
143
|
+
gap: 6px;
|
|
144
|
+
align-items: center;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.setup-card .key-links a {
|
|
148
|
+
color: #075985;
|
|
149
|
+
font-weight: 750;
|
|
150
|
+
text-decoration: none;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.setup-card .key-links a:hover {
|
|
154
|
+
text-decoration: underline;
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
.setup-card[hidden] {
|
|
141
158
|
display: none;
|
|
142
159
|
}
|
package/scripts/smoke-auth.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { normalizeAuthProvider } from "../src/auth-onboarding.js";
|
|
7
|
+
import { authProviderKeyHelp, authProviderKeyUrl, normalizeAuthProvider } from "../src/auth-onboarding.js";
|
|
8
8
|
import { getProviderDefaults } from "../src/model-routing.js";
|
|
9
9
|
import { providerKeyStatus, setProviderKey } from "../src/project.js";
|
|
10
10
|
|
|
@@ -53,6 +53,8 @@ async function runCli(args, stdin = "") {
|
|
|
53
53
|
try {
|
|
54
54
|
assert(normalizeAuthProvider("auxilliary") === "grsai", "auxilliary alias did not normalize to grsai");
|
|
55
55
|
assert(normalizeAuthProvider("qwen") === "qwen", "qwen provider did not normalize");
|
|
56
|
+
assert(authProviderKeyUrl("deepseek") === "https://platform.deepseek.com/api_keys", "DeepSeek key URL is missing");
|
|
57
|
+
assert(authProviderKeyHelp("openai").includes("https://platform.openai.com/api-keys"), "OpenAI key help is missing");
|
|
56
58
|
const qwenDefaults = getProviderDefaults("qwen");
|
|
57
59
|
assert(qwenDefaults.provider === "qwen" && qwenDefaults.model, "qwen provider defaults are not available");
|
|
58
60
|
|
|
@@ -75,7 +77,7 @@ try {
|
|
|
75
77
|
{
|
|
76
78
|
ok: true,
|
|
77
79
|
projectRoot: tempRoot,
|
|
78
|
-
checks: ["normalize-auth-provider", "qwen-defaults", "qwen-key-status", "cli-key-status-redacted"],
|
|
80
|
+
checks: ["normalize-auth-provider", "provider-key-links", "qwen-defaults", "qwen-key-status", "cli-key-status-redacted"],
|
|
79
81
|
},
|
|
80
82
|
null,
|
|
81
83
|
2
|
|
@@ -56,6 +56,10 @@ try {
|
|
|
56
56
|
capabilities.checks.some((check) => check.name === "bash-syntax-policy" && check.ok),
|
|
57
57
|
"bash -n maintenance script policy is not allowed"
|
|
58
58
|
);
|
|
59
|
+
assert(
|
|
60
|
+
capabilities.checks.some((check) => check.name === "tmux"),
|
|
61
|
+
"capabilities did not report tmux availability"
|
|
62
|
+
);
|
|
59
63
|
assert(
|
|
60
64
|
capabilities.checks.some((check) => check.name === "git-status-policy" && check.ok),
|
|
61
65
|
"git status policy is not allowed"
|
package/scripts/smoke-skills.js
CHANGED
|
@@ -26,6 +26,7 @@ for (const required of [
|
|
|
26
26
|
"image-generation",
|
|
27
27
|
"latex-manuscript",
|
|
28
28
|
"system-maintenance",
|
|
29
|
+
"tmux-session",
|
|
29
30
|
"website-app",
|
|
30
31
|
"word-documents",
|
|
31
32
|
]) {
|
|
@@ -37,6 +38,7 @@ assert(selectedIds("write a LaTeX paper and compile a PDF").includes("latex-manu
|
|
|
37
38
|
assert(selectedIds("edit a Microsoft Word docx and preserve the original").includes("word-documents"), "docx prompt did not select word-documents");
|
|
38
39
|
assert(selectedIds("generate a logo image with grsai nanobanana").includes("image-generation"), "image prompt did not select image-generation");
|
|
39
40
|
assert(selectedIds("git status commit push with gh").includes("github-maintenance"), "git prompt did not select github-maintenance");
|
|
41
|
+
assert(selectedIds("monitor a long running tmux session").includes("tmux-session"), "tmux prompt did not select tmux-session");
|
|
40
42
|
assert(selectedIds("create an .aaps example for @lazyingart/aaps").includes("aaps"), "AAPS prompt did not select aaps");
|
|
41
43
|
assert(selectedIds("debug a C++ CMake build").includes("c-cpp"), "C++ prompt did not select c-cpp");
|
|
42
44
|
assert(selectedIds("set up Stan and CmdStanR reproducibly").includes("r-stan"), "Stan prompt did not select r-stan");
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import {
|
|
9
|
+
captureTmuxPane,
|
|
10
|
+
checkTmuxToolUse,
|
|
11
|
+
listTmuxSessions,
|
|
12
|
+
sendTmuxKeys,
|
|
13
|
+
startTmuxSession,
|
|
14
|
+
tmuxAvailable,
|
|
15
|
+
} from "../src/tmux-tools.js";
|
|
16
|
+
|
|
17
|
+
const execFile = promisify(execFileCallback);
|
|
18
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-tmux-"));
|
|
19
|
+
const session = `aginti-smoke-${process.pid}`;
|
|
20
|
+
const config = {
|
|
21
|
+
allowShellTool: true,
|
|
22
|
+
commandCwd: workspace,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (!(await tmuxAvailable())) {
|
|
31
|
+
console.log(JSON.stringify({ ok: true, skipped: true, reason: "tmux is not installed" }, null, 2));
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const start = await startTmuxSession({ name: session }, config);
|
|
36
|
+
assert.equal(start.ok, true, start.error || start.reason);
|
|
37
|
+
await sleep(250);
|
|
38
|
+
|
|
39
|
+
const send = await sendTmuxKeys({
|
|
40
|
+
target: start.target,
|
|
41
|
+
text: "printf 'aginti tmux smoke\\n'; pwd",
|
|
42
|
+
enter: true,
|
|
43
|
+
});
|
|
44
|
+
assert.equal(send.ok, true, send.error || send.reason);
|
|
45
|
+
await sleep(500);
|
|
46
|
+
|
|
47
|
+
const capture = await captureTmuxPane({ target: start.target, lines: 40 });
|
|
48
|
+
assert.equal(capture.ok, true, capture.error || capture.reason);
|
|
49
|
+
assert.match(capture.content, /aginti tmux smoke/, "tmux capture did not include command output");
|
|
50
|
+
assert.match(capture.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "tmux cwd was not workspace");
|
|
51
|
+
|
|
52
|
+
const list = await listTmuxSessions({ includePanes: true });
|
|
53
|
+
assert.equal(list.ok, true, list.error || list.reason);
|
|
54
|
+
assert(list.sessions.some((item) => item.name === session), "tmux list did not include smoke session");
|
|
55
|
+
assert(list.panes.some((item) => item.target === start.target), "tmux list did not include smoke pane");
|
|
56
|
+
|
|
57
|
+
const blocked = checkTmuxToolUse(
|
|
58
|
+
"tmux_send_keys",
|
|
59
|
+
{ target: start.target, text: "OPENAI_API_KEY=secret-value" },
|
|
60
|
+
config
|
|
61
|
+
);
|
|
62
|
+
assert.equal(blocked.allowed, false, "tmux guardrail did not block secret-like text");
|
|
63
|
+
|
|
64
|
+
const destructive = checkTmuxToolUse("tmux_send_keys", { target: start.target, text: "rm -rf /" }, config);
|
|
65
|
+
assert.equal(destructive.allowed, false, "tmux guardrail did not block destructive text");
|
|
66
|
+
|
|
67
|
+
console.log(
|
|
68
|
+
JSON.stringify(
|
|
69
|
+
{
|
|
70
|
+
ok: true,
|
|
71
|
+
session,
|
|
72
|
+
workspace,
|
|
73
|
+
checks: ["start-session", "send-keys", "capture-pane", "list-sessions", "secret-guardrail", "destructive-guardrail"],
|
|
74
|
+
},
|
|
75
|
+
null,
|
|
76
|
+
2
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
await execFile("tmux", ["kill-session", "-t", session]).catch(() => {});
|
|
81
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
82
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: tmux-session
|
|
3
|
+
label: Tmux Session Control
|
|
4
|
+
description: Monitor and interact with long-running tmux terminals, agent sessions, dev servers, installs, and test jobs.
|
|
5
|
+
triggers:
|
|
6
|
+
- tmux
|
|
7
|
+
- terminal session
|
|
8
|
+
- long running
|
|
9
|
+
- monitor
|
|
10
|
+
- background job
|
|
11
|
+
- pane
|
|
12
|
+
- session
|
|
13
|
+
tools:
|
|
14
|
+
- tmux_list_sessions
|
|
15
|
+
- tmux_capture_pane
|
|
16
|
+
- tmux_send_keys
|
|
17
|
+
- tmux_start_session
|
|
18
|
+
- run_command
|
|
19
|
+
---
|
|
20
|
+
# Tmux Session Control
|
|
21
|
+
|
|
22
|
+
Use tmux tools for work that should keep running while the agent remains responsive: installs, builds, tests, dev servers, external agents, and monitored shells.
|
|
23
|
+
|
|
24
|
+
Workflow:
|
|
25
|
+
|
|
26
|
+
1. Discover with `tmux_list_sessions` unless the user gave an exact target.
|
|
27
|
+
2. Capture with `tmux_capture_pane` before sending input so context is current.
|
|
28
|
+
3. Use `tmux_start_session` for new durable jobs rooted in the workspace.
|
|
29
|
+
4. Use `tmux_send_keys` sparingly and never send secrets, sudo passwords, destructive commands, or unreviewed pasted scripts.
|
|
30
|
+
5. For long commands, capture progress periodically and summarize the latest useful lines instead of flooding the chat.
|
|
31
|
+
|
|
32
|
+
If a package or sudo install is missing, report the exact command and whether it should run in Docker, host, or a user-owned tmux session.
|
package/src/agent-runner.js
CHANGED
|
@@ -24,6 +24,7 @@ import { runParallelScouts, shouldRunParallelScouts } from "./parallel-scouts.js
|
|
|
24
24
|
import { readProjectInstructions } from "./project.js";
|
|
25
25
|
import { formatSkillsForPrompt, selectSkillsForGoal } from "./skill-library.js";
|
|
26
26
|
import { hostShellOption, platformInfo, platformLabel } from "./platform.js";
|
|
27
|
+
import { captureTmuxPane, listTmuxSessions, sendTmuxKeys, startTmuxSession } from "./tmux-tools.js";
|
|
27
28
|
|
|
28
29
|
const exec = promisify(execCallback);
|
|
29
30
|
const BROWSER_TOOLS = new Set(["open_url", "open_workspace_file", "preview_workspace", "click", "type", "scroll", "press", "back"]);
|
|
@@ -306,6 +307,9 @@ async function createInitialState(config, sessionId) {
|
|
|
306
307
|
? `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.`
|
|
307
308
|
: `A host shell command tool is available under the configured trust policy on ${platformLabel(platform)}. On native Windows, prefer PowerShell/cmd-compatible commands or switch to WSL/Docker for bash-like toolchains.`
|
|
308
309
|
: "No shell command tool is available.",
|
|
310
|
+
config.allowShellTool
|
|
311
|
+
? "Host tmux tools are available for long-running terminals: list sessions, capture panes, send safe keys/text, and start detached sessions. Prefer tmux for monitoring long installs/tests/dev servers without blocking; capture before sending input and never send secrets or sudo passwords."
|
|
312
|
+
: "",
|
|
309
313
|
config.allowFileTools
|
|
310
314
|
? `Workspace file tools are available in ${config.commandCwd}: inspect_project, list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, and preview_workspace. For large or unfamiliar repositories, call inspect_project first, then search/read AGINTI.md/AGENTS.md/README/manifests as relevant before editing. apply_patch supports exact single-file replacements plus Codex-style/unified multi-file patches; prefer it for source edits after reading/searching the relevant context. Always use workspace-relative paths such as plot_fx.svg or docs/report.tex, never absolute host paths. Secret paths, .git internals, node_modules writes, and huge files are blocked. For generated local websites/pages, use open_workspace_file or preview_workspace instead of starting a localhost server inside Docker.`
|
|
311
315
|
: "No workspace file tools are available.",
|
|
@@ -697,6 +701,9 @@ async function captureSyntheticSnapshot(store, step, config) {
|
|
|
697
701
|
? `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}.`
|
|
698
702
|
: `Shell tool available in: ${config.commandCwd} on ${platformLabel(platform)}. Use OS-compatible commands; prefer WSL/Docker for bash-heavy workflows on Windows.`
|
|
699
703
|
: "Shell tool disabled.",
|
|
704
|
+
config.allowShellTool
|
|
705
|
+
? "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."
|
|
706
|
+
: "",
|
|
700
707
|
config.allowFileTools
|
|
701
708
|
? `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.`
|
|
702
709
|
: "Workspace file tools disabled.",
|
|
@@ -955,6 +962,34 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
|
|
|
955
962
|
observers.event("tool.completed", result);
|
|
956
963
|
return result;
|
|
957
964
|
}
|
|
965
|
+
case "tmux_list_sessions": {
|
|
966
|
+
const result = await listTmuxSessions(args);
|
|
967
|
+
const eventResult = sanitizeToolResult(result);
|
|
968
|
+
await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
969
|
+
observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
970
|
+
return result;
|
|
971
|
+
}
|
|
972
|
+
case "tmux_capture_pane": {
|
|
973
|
+
const result = await captureTmuxPane(args);
|
|
974
|
+
const eventResult = sanitizeToolResult(result);
|
|
975
|
+
await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
976
|
+
observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
977
|
+
return result;
|
|
978
|
+
}
|
|
979
|
+
case "tmux_send_keys": {
|
|
980
|
+
const result = await sendTmuxKeys(args);
|
|
981
|
+
const eventResult = sanitizeToolResult(result);
|
|
982
|
+
await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
983
|
+
observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
984
|
+
return result;
|
|
985
|
+
}
|
|
986
|
+
case "tmux_start_session": {
|
|
987
|
+
const result = await startTmuxSession(args, config);
|
|
988
|
+
const eventResult = sanitizeToolResult(result);
|
|
989
|
+
await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
990
|
+
observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
958
993
|
case "delegate_agent": {
|
|
959
994
|
const wrapperResult = await runAgentWrapper(
|
|
960
995
|
{
|
package/src/auth-onboarding.js
CHANGED
|
@@ -10,12 +10,14 @@ export const MAIN_AUTH_PROVIDERS = [
|
|
|
10
10
|
label: "DeepSeek",
|
|
11
11
|
keyName: "DEEPSEEK_API_KEY",
|
|
12
12
|
description: "default fast/pro route",
|
|
13
|
+
keyUrl: "https://platform.deepseek.com/api_keys",
|
|
13
14
|
},
|
|
14
15
|
{
|
|
15
16
|
id: "openai",
|
|
16
17
|
label: "OpenAI",
|
|
17
18
|
keyName: "OPENAI_API_KEY",
|
|
18
19
|
description: "OpenAI-compatible fallback",
|
|
20
|
+
keyUrl: "https://platform.openai.com/api-keys",
|
|
19
21
|
},
|
|
20
22
|
{
|
|
21
23
|
id: "qwen",
|
|
@@ -55,6 +57,19 @@ function providerLabel(provider = "") {
|
|
|
55
57
|
return match?.label || provider;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
export function authProviderKeyUrl(provider = "") {
|
|
61
|
+
const normalized = normalizeAuthProvider(provider, "");
|
|
62
|
+
const match = [...MAIN_AUTH_PROVIDERS, AUXILIARY_AUTH_PROVIDER].find((item) => item.id === normalized);
|
|
63
|
+
return match?.keyUrl || "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function authProviderKeyHelp(provider = "") {
|
|
67
|
+
const normalized = normalizeAuthProvider(provider, "");
|
|
68
|
+
const label = providerLabel(normalized);
|
|
69
|
+
const url = authProviderKeyUrl(normalized);
|
|
70
|
+
return url ? `Get ${label} API key: ${url}` : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
class MutedWritable extends Writable {
|
|
59
74
|
constructor(target) {
|
|
60
75
|
super();
|
|
@@ -144,17 +159,21 @@ export async function promptHidden(promptText) {
|
|
|
144
159
|
}
|
|
145
160
|
|
|
146
161
|
function renderProviderPicker({ providers, selected, title, status }) {
|
|
162
|
+
const keyLinks = providers
|
|
163
|
+
.filter((provider) => provider.keyUrl)
|
|
164
|
+
.map((provider) => `${provider.label}: ${provider.keyUrl}`);
|
|
147
165
|
const lines = [
|
|
148
166
|
`\n${title}`,
|
|
149
167
|
"Use Up/Down to choose, Enter to confirm, Esc to go back/skip.",
|
|
150
168
|
`Current key status: ${status}`,
|
|
169
|
+
keyLinks.length ? `API key pages: ${keyLinks.join(" · ")}` : "",
|
|
151
170
|
"",
|
|
152
171
|
...providers.map((provider, index) => {
|
|
153
172
|
const cursor = index === selected ? ">" : " ";
|
|
154
173
|
return `${cursor} ${provider.label.padEnd(10)} ${provider.keyName.padEnd(16)} ${provider.description}`;
|
|
155
174
|
}),
|
|
156
175
|
];
|
|
157
|
-
return lines.join("\n");
|
|
176
|
+
return lines.filter((line, index) => line || index > 2).join("\n");
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
export async function chooseAuthProvider({
|
|
@@ -295,7 +314,8 @@ export async function runAuthWizard(projectRoot = process.cwd(), options = {}) {
|
|
|
295
314
|
|
|
296
315
|
if (mainProvider) {
|
|
297
316
|
const current = status[mainProvider] ? "currently available; paste a new key to replace, or Esc to keep existing" : "missing";
|
|
298
|
-
const
|
|
317
|
+
const keyHelp = authProviderKeyHelp(mainProvider);
|
|
318
|
+
const prompt = `${keyHelp ? `${keyHelp}\n` : ""}${providerLabel(mainProvider)} main API key (${current}) [hidden]: `;
|
|
299
319
|
const secret = await promptSecret(prompt, { allowEscape: true });
|
|
300
320
|
if (secret.value) {
|
|
301
321
|
const result = await setProviderKey(projectRoot, mainProvider, secret.value);
|
package/src/capabilities.js
CHANGED
|
@@ -122,6 +122,7 @@ export async function buildCapabilityReport(projectRoot, packageVersion, config)
|
|
|
122
122
|
commandAvailable("R", ["--version"]),
|
|
123
123
|
commandAvailable("pdflatex", ["--version"]),
|
|
124
124
|
commandAvailable("latexmk", ["--version"]),
|
|
125
|
+
commandAvailable("tmux", ["-V"]),
|
|
125
126
|
];
|
|
126
127
|
if (platform.isMac) commandChecks.push(commandAvailable("brew", ["--version"]));
|
|
127
128
|
if (platform.isWindows) commandChecks.push(commandAvailable("wsl", ["--status"]));
|
|
@@ -132,9 +133,9 @@ export async function buildCapabilityReport(projectRoot, packageVersion, config)
|
|
|
132
133
|
readProjectInstructions(projectRoot, { maxBytes: 1 }),
|
|
133
134
|
readCodebaseMap(projectRoot),
|
|
134
135
|
]);
|
|
135
|
-
const [node, npm, python, conda, r, pdflatex, latexmk] = commands;
|
|
136
|
-
const homebrew = platform.isMac ? commands[
|
|
137
|
-
const wsl = platform.isWindows ? commands[
|
|
136
|
+
const [node, npm, python, conda, r, pdflatex, latexmk, tmux] = commands;
|
|
137
|
+
const homebrew = platform.isMac ? commands[8] : undefined;
|
|
138
|
+
const wsl = platform.isWindows ? commands[8] : undefined;
|
|
138
139
|
|
|
139
140
|
const npmPrefixPolicy = evaluateCommandPolicy("npm --prefix round9-node-app test", config);
|
|
140
141
|
const cdNpmTestPolicy = evaluateCommandPolicy("cd round9-node-app && npm test", config);
|
|
@@ -154,6 +155,7 @@ export async function buildCapabilityReport(projectRoot, packageVersion, config)
|
|
|
154
155
|
capability("R", r.available, r.available ? r : { ...r, setup: "Optional. Generate a project-local R setup plan; do not install globally from the agent." }),
|
|
155
156
|
capability("pdflatex", pdflatex.available, pdflatex.available ? pdflatex : { ...pdflatex, setup: "LaTeX tasks should create .tex source and an honest setup report when TeX is unavailable." }),
|
|
156
157
|
capability("latexmk", latexmk.available, latexmk.available ? latexmk : { ...latexmk, setup: "latexmk is optional if pdflatex is available." }),
|
|
158
|
+
capability("tmux", tmux.available, tmux.available ? tmux : { ...tmux, setup: "Optional for monitoring long-running host sessions. Install tmux or use normal run_command." }),
|
|
157
159
|
...(platform.isMac ? [capability("homebrew", homebrew?.available, homebrew?.available ? homebrew : { ...homebrew, setup: "Optional on macOS for host-mode tool installs." })] : []),
|
|
158
160
|
...(platform.isWindows ? [capability("wsl", wsl?.available, wsl?.available ? wsl : { ...wsl, setup: "Recommended for Windows. Install WSL2 for the most compatible shell and Docker workflow." })] : []),
|
|
159
161
|
capability("docker", Boolean(dockerStatus?.dockerAvailable), dockerStatus || {}),
|
package/src/guardrails.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { checkTmuxToolUse, TMUX_TOOL_NAMES } from "./tmux-tools.js";
|
|
4
5
|
|
|
5
6
|
const DESTRUCTIVE_KEYWORDS = [
|
|
6
7
|
"delete",
|
|
@@ -57,6 +58,10 @@ export function checkToolUse({ toolName, args, snapshot, config }) {
|
|
|
57
58
|
return checkWorkspaceToolUse(toolName, args, config);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
if (TMUX_TOOL_NAMES.includes(toolName)) {
|
|
62
|
+
return checkTmuxToolUse(toolName, args, config);
|
|
63
|
+
}
|
|
64
|
+
|
|
60
65
|
if (toolName === "open_url") {
|
|
61
66
|
if (!/^https?:\/\//.test(String(args.url || ""))) {
|
|
62
67
|
return { allowed: false, reason: "Only http and https URLs are allowed." };
|
package/src/model-client.js
CHANGED
|
@@ -226,6 +226,9 @@ export async function createPlan(client, config, state) {
|
|
|
226
226
|
config.allowShellTool
|
|
227
227
|
? `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. 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. On native Windows host mode, prefer PowerShell/cmd-compatible commands or WSL/Docker for bash-like toolchains.`
|
|
228
228
|
: "",
|
|
229
|
+
config.allowShellTool
|
|
230
|
+
? "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."
|
|
231
|
+
: "",
|
|
229
232
|
config.allowFileTools
|
|
230
233
|
? `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 generated local HTML/SVG/PDF/static sites, plan to use open_workspace_file or preview_workspace rather than starting a localhost server inside Docker.`
|
|
231
234
|
: "",
|
|
@@ -457,22 +460,101 @@ export async function requestNextStep(client, config, messages) {
|
|
|
457
460
|
}
|
|
458
461
|
|
|
459
462
|
if (config.allowShellTool) {
|
|
460
|
-
tools.splice(
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
463
|
+
tools.splice(
|
|
464
|
+
-1,
|
|
465
|
+
0,
|
|
466
|
+
{
|
|
467
|
+
type: "function",
|
|
468
|
+
function: {
|
|
469
|
+
name: "tmux_list_sessions",
|
|
470
|
+
description:
|
|
471
|
+
"List host tmux sessions and panes. Use this to discover long-running terminals, agent sessions, dev servers, or jobs before interacting with them. tmux tools run host-side even when command execution is Docker-sandboxed.",
|
|
472
|
+
parameters: {
|
|
473
|
+
type: "object",
|
|
474
|
+
properties: {
|
|
475
|
+
includePanes: { type: "boolean", description: "Include pane targets, current paths, and running commands. Defaults to true." },
|
|
476
|
+
},
|
|
477
|
+
additionalProperties: false,
|
|
470
478
|
},
|
|
471
|
-
required: ["command"],
|
|
472
|
-
additionalProperties: false,
|
|
473
479
|
},
|
|
474
480
|
},
|
|
475
|
-
|
|
481
|
+
{
|
|
482
|
+
type: "function",
|
|
483
|
+
function: {
|
|
484
|
+
name: "tmux_capture_pane",
|
|
485
|
+
description:
|
|
486
|
+
"Capture recent text from a host tmux pane by target such as session:0.0. Use this to monitor progress or inspect a long-running job without interrupting it.",
|
|
487
|
+
parameters: {
|
|
488
|
+
type: "object",
|
|
489
|
+
properties: {
|
|
490
|
+
target: { type: "string", description: "tmux pane target, for example aginti-test:0.0 or %12." },
|
|
491
|
+
lines: { type: "integer", description: "Recent lines to capture, 1 to 500. Defaults to 80." },
|
|
492
|
+
},
|
|
493
|
+
required: ["target"],
|
|
494
|
+
additionalProperties: false,
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
type: "function",
|
|
500
|
+
function: {
|
|
501
|
+
name: "tmux_send_keys",
|
|
502
|
+
description:
|
|
503
|
+
"Send literal text and/or safe control keys to a host tmux pane. Use for interacting with known shells or agent sessions after capturing context. Do not send secrets, passwords, sudo passwords, or destructive commands.",
|
|
504
|
+
parameters: {
|
|
505
|
+
type: "object",
|
|
506
|
+
properties: {
|
|
507
|
+
target: { type: "string", description: "tmux pane target, for example aginti-test:0.0 or %12." },
|
|
508
|
+
text: { type: "string", description: "Literal text to send before keys." },
|
|
509
|
+
enter: { type: "boolean", description: "Append Enter after text/keys. Defaults to true." },
|
|
510
|
+
keys: {
|
|
511
|
+
type: "array",
|
|
512
|
+
items: {
|
|
513
|
+
type: "string",
|
|
514
|
+
enum: ["Enter", "C-c", "C-d", "Escape", "Tab", "Up", "Down", "Left", "Right", "Backspace", "C-a", "C-e", "C-u", "C-k"],
|
|
515
|
+
},
|
|
516
|
+
description: "Optional safe tmux key names.",
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
required: ["target"],
|
|
520
|
+
additionalProperties: false,
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
type: "function",
|
|
526
|
+
function: {
|
|
527
|
+
name: "tmux_start_session",
|
|
528
|
+
description:
|
|
529
|
+
"Start a detached host tmux session rooted inside the workspace, optionally with a startup command. Use for long-running local jobs that should be monitored with tmux_capture_pane instead of blocking the agent.",
|
|
530
|
+
parameters: {
|
|
531
|
+
type: "object",
|
|
532
|
+
properties: {
|
|
533
|
+
name: { type: "string", description: "Safe tmux session name. Defaults to aginti-<timestamp>." },
|
|
534
|
+
cwd: { type: "string", description: "Workspace-relative cwd. Defaults to ." },
|
|
535
|
+
command: { type: "string", description: "Optional non-secret startup command." },
|
|
536
|
+
},
|
|
537
|
+
additionalProperties: false,
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
type: "function",
|
|
543
|
+
function: {
|
|
544
|
+
name: "run_command",
|
|
545
|
+
description:
|
|
546
|
+
"Run a terminal command in the configured working directory under the active shell policy. Secrets and npm publishing are always blocked; Docker workspace mode with approved package installs supports broader network/setup commands, while host destructive or privileged work requires explicit trust.",
|
|
547
|
+
parameters: {
|
|
548
|
+
type: "object",
|
|
549
|
+
properties: {
|
|
550
|
+
command: { type: "string" },
|
|
551
|
+
},
|
|
552
|
+
required: ["command"],
|
|
553
|
+
additionalProperties: false,
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
}
|
|
557
|
+
);
|
|
476
558
|
}
|
|
477
559
|
|
|
478
560
|
if (config.allowFileTools) {
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { redactSensitiveText } from "./redaction.js";
|
|
6
|
+
|
|
7
|
+
const execFile = promisify(execFileCallback);
|
|
8
|
+
const MAX_CAPTURE_LINES = 500;
|
|
9
|
+
const MAX_SEND_BYTES = 12000;
|
|
10
|
+
const MAX_COMMAND_BYTES = 4000;
|
|
11
|
+
const TARGET_PATTERN = /^[A-Za-z0-9_.:@%+-]{1,120}$/;
|
|
12
|
+
const SESSION_PATTERN = /^[A-Za-z0-9_.+-]{1,80}$/;
|
|
13
|
+
const FIELD_SEPARATOR = "|";
|
|
14
|
+
const ALLOWED_KEYS = new Set([
|
|
15
|
+
"Enter",
|
|
16
|
+
"C-c",
|
|
17
|
+
"C-d",
|
|
18
|
+
"Escape",
|
|
19
|
+
"Tab",
|
|
20
|
+
"Up",
|
|
21
|
+
"Down",
|
|
22
|
+
"Left",
|
|
23
|
+
"Right",
|
|
24
|
+
"Backspace",
|
|
25
|
+
"C-a",
|
|
26
|
+
"C-e",
|
|
27
|
+
"C-u",
|
|
28
|
+
"C-k",
|
|
29
|
+
]);
|
|
30
|
+
const SECRET_PATTERN = /(api[_-]?key|auth[_-]?token|npm[_-]?token|_authToken|password|passwd|secret|bearer\s+[A-Za-z0-9._-]+)/i;
|
|
31
|
+
const DESTRUCTIVE_PATTERN =
|
|
32
|
+
/\b(rm\s+-[^\n;]*[rf][^\n;]*(\/|\*|~|\$HOME)|mkfs(?:\.[a-z0-9]+)?\b|dd\s+if=.*\s+of=\/dev\/|shutdown\b|reboot\b|poweroff\b)/i;
|
|
33
|
+
|
|
34
|
+
export const TMUX_TOOL_NAMES = ["tmux_list_sessions", "tmux_capture_pane", "tmux_send_keys", "tmux_start_session"];
|
|
35
|
+
|
|
36
|
+
function safeEnv() {
|
|
37
|
+
return {
|
|
38
|
+
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
|
39
|
+
HOME: process.env.HOME || "/tmp",
|
|
40
|
+
TERM: process.env.TERM || "xterm-256color",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runTmux(args, options = {}) {
|
|
45
|
+
try {
|
|
46
|
+
const result = await execFile("tmux", args, {
|
|
47
|
+
timeout: options.timeout ?? 12000,
|
|
48
|
+
maxBuffer: options.maxBuffer ?? 220 * 1024,
|
|
49
|
+
env: safeEnv(),
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
ok: true,
|
|
53
|
+
stdout: redactSensitiveText(result.stdout || ""),
|
|
54
|
+
stderr: redactSensitiveText(result.stderr || ""),
|
|
55
|
+
};
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message = redactSensitiveText(error instanceof Error ? error.message : String(error));
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
stdout: redactSensitiveText(String(error?.stdout || "")),
|
|
61
|
+
stderr: redactSensitiveText(String(error?.stderr || message)),
|
|
62
|
+
error: message,
|
|
63
|
+
exitCode: Number.isInteger(error?.code) ? error.code : 1,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function tmuxAvailable() {
|
|
69
|
+
const result = await runTmux(["-V"], { timeout: 4000, maxBuffer: 16 * 1024 });
|
|
70
|
+
return Boolean(result.ok);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clampInteger(value, fallback, min, max) {
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
76
|
+
return Math.min(Math.max(Math.trunc(parsed), min), max);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateTarget(target) {
|
|
80
|
+
const normalized = String(target || "").trim();
|
|
81
|
+
if (!normalized) return { ok: false, reason: "tmux target is required." };
|
|
82
|
+
if (!TARGET_PATTERN.test(normalized)) {
|
|
83
|
+
return { ok: false, reason: "tmux target contains unsupported characters." };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, target: normalized };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateSessionName(name) {
|
|
89
|
+
const normalized = String(name || `aginti-${Date.now()}`).trim();
|
|
90
|
+
if (!SESSION_PATTERN.test(normalized)) {
|
|
91
|
+
return { ok: false, reason: "tmux session name must use letters, numbers, dot, underscore, plus, or dash." };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, name: normalized };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveCwd(config, cwd = ".") {
|
|
97
|
+
const root = path.resolve(config.commandCwd || process.cwd());
|
|
98
|
+
const requested = path.resolve(root, String(cwd || "."));
|
|
99
|
+
const relative = path.relative(root, requested);
|
|
100
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
101
|
+
return { ok: false, reason: "tmux cwd must stay inside the configured workspace." };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, cwd: requested };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseSessions(stdout = "") {
|
|
107
|
+
return String(stdout || "")
|
|
108
|
+
.split(/\r?\n/)
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.map((line) => {
|
|
111
|
+
const [name, windows, attached, created, activity] = line.split(FIELD_SEPARATOR);
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
windows: Number(windows) || 0,
|
|
115
|
+
attached: Number(attached) || 0,
|
|
116
|
+
created: Number(created) || 0,
|
|
117
|
+
activity: Number(activity) || 0,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parsePanes(stdout = "") {
|
|
123
|
+
return String(stdout || "")
|
|
124
|
+
.split(/\r?\n/)
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.map((line) => {
|
|
127
|
+
const [target, cwd, command, active, title] = line.split(FIELD_SEPARATOR);
|
|
128
|
+
return {
|
|
129
|
+
target,
|
|
130
|
+
cwd,
|
|
131
|
+
command,
|
|
132
|
+
active: active === "1",
|
|
133
|
+
title,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function listTmuxSessions(args = {}) {
|
|
139
|
+
const sessionsResult = await runTmux([
|
|
140
|
+
"list-sessions",
|
|
141
|
+
"-F",
|
|
142
|
+
"#{session_name}|#{session_windows}|#{session_attached}|#{session_created}|#{session_activity}",
|
|
143
|
+
]);
|
|
144
|
+
if (!sessionsResult.ok && /no server running|failed to connect/i.test(`${sessionsResult.stderr} ${sessionsResult.error}`)) {
|
|
145
|
+
return { ok: true, toolName: "tmux_list_sessions", sessions: [], panes: [], summary: "No tmux server is running." };
|
|
146
|
+
}
|
|
147
|
+
if (!sessionsResult.ok) return { ok: false, toolName: "tmux_list_sessions", error: sessionsResult.stderr || sessionsResult.error };
|
|
148
|
+
|
|
149
|
+
let panes = [];
|
|
150
|
+
if (args.includePanes !== false) {
|
|
151
|
+
const panesResult = await runTmux([
|
|
152
|
+
"list-panes",
|
|
153
|
+
"-a",
|
|
154
|
+
"-F",
|
|
155
|
+
"#{session_name}:#{window_index}.#{pane_index}|#{pane_current_path}|#{pane_current_command}|#{pane_active}|#{pane_title}",
|
|
156
|
+
]);
|
|
157
|
+
if (panesResult.ok) panes = parsePanes(panesResult.stdout);
|
|
158
|
+
}
|
|
159
|
+
const sessions = parseSessions(sessionsResult.stdout);
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
toolName: "tmux_list_sessions",
|
|
163
|
+
sessions,
|
|
164
|
+
panes,
|
|
165
|
+
summary: `${sessions.length} tmux session(s), ${panes.length} pane(s).`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function captureTmuxPane(args = {}) {
|
|
170
|
+
const target = validateTarget(args.target);
|
|
171
|
+
if (!target.ok) return { ok: false, toolName: "tmux_capture_pane", blocked: true, reason: target.reason };
|
|
172
|
+
const lines = clampInteger(args.lines, 80, 1, MAX_CAPTURE_LINES);
|
|
173
|
+
const result = await runTmux(["capture-pane", "-t", target.target, "-p", "-S", `-${lines}`], {
|
|
174
|
+
timeout: 8000,
|
|
175
|
+
maxBuffer: 260 * 1024,
|
|
176
|
+
});
|
|
177
|
+
if (!result.ok) return { ok: false, toolName: "tmux_capture_pane", target: target.target, error: result.stderr || result.error };
|
|
178
|
+
const content = redactSensitiveText(result.stdout || "").replace(/\s+$/g, "");
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
toolName: "tmux_capture_pane",
|
|
182
|
+
target: target.target,
|
|
183
|
+
lines,
|
|
184
|
+
content,
|
|
185
|
+
contentBytes: Buffer.byteLength(content, "utf8"),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function sendTmuxKeys(args = {}) {
|
|
190
|
+
const target = validateTarget(args.target);
|
|
191
|
+
if (!target.ok) return { ok: false, toolName: "tmux_send_keys", blocked: true, reason: target.reason };
|
|
192
|
+
const text = String(args.text || "");
|
|
193
|
+
const keys = Array.isArray(args.keys) ? args.keys.map(String).filter(Boolean) : [];
|
|
194
|
+
const enter = args.enter !== false;
|
|
195
|
+
if (text && Buffer.byteLength(text, "utf8") > MAX_SEND_BYTES) {
|
|
196
|
+
return { ok: false, toolName: "tmux_send_keys", blocked: true, reason: "tmux text payload is too large." };
|
|
197
|
+
}
|
|
198
|
+
if (SECRET_PATTERN.test(text)) {
|
|
199
|
+
return { ok: false, toolName: "tmux_send_keys", blocked: true, reason: "tmux text appears to contain a secret." };
|
|
200
|
+
}
|
|
201
|
+
if (DESTRUCTIVE_PATTERN.test(text)) {
|
|
202
|
+
return { ok: false, toolName: "tmux_send_keys", blocked: true, reason: "tmux text appears destructive; ask the user before sending it." };
|
|
203
|
+
}
|
|
204
|
+
for (const key of keys) {
|
|
205
|
+
if (!ALLOWED_KEYS.has(key)) {
|
|
206
|
+
return { ok: false, toolName: "tmux_send_keys", blocked: true, reason: `Unsupported tmux key: ${key}` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const steps = [];
|
|
211
|
+
if (text) {
|
|
212
|
+
const sent = await runTmux(["send-keys", "-t", target.target, "-l", text], { timeout: 8000 });
|
|
213
|
+
if (!sent.ok) return { ok: false, toolName: "tmux_send_keys", target: target.target, error: sent.stderr || sent.error };
|
|
214
|
+
steps.push("literal-text");
|
|
215
|
+
}
|
|
216
|
+
const keyArgs = [...keys];
|
|
217
|
+
if (enter) keyArgs.push("Enter");
|
|
218
|
+
if (keyArgs.length > 0) {
|
|
219
|
+
const sent = await runTmux(["send-keys", "-t", target.target, ...keyArgs], { timeout: 8000 });
|
|
220
|
+
if (!sent.ok) return { ok: false, toolName: "tmux_send_keys", target: target.target, error: sent.stderr || sent.error };
|
|
221
|
+
steps.push(...keyArgs);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
toolName: "tmux_send_keys",
|
|
227
|
+
target: target.target,
|
|
228
|
+
sentTextBytes: Buffer.byteLength(text, "utf8"),
|
|
229
|
+
sentTextSha256: text ? crypto.createHash("sha256").update(text).digest("hex") : "",
|
|
230
|
+
keys: keyArgs,
|
|
231
|
+
steps,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function startTmuxSession(args = {}, config = {}) {
|
|
236
|
+
const name = validateSessionName(args.name);
|
|
237
|
+
if (!name.ok) return { ok: false, toolName: "tmux_start_session", blocked: true, reason: name.reason };
|
|
238
|
+
const cwd = resolveCwd(config, args.cwd || ".");
|
|
239
|
+
if (!cwd.ok) return { ok: false, toolName: "tmux_start_session", blocked: true, reason: cwd.reason };
|
|
240
|
+
const command = String(args.command || "").trim();
|
|
241
|
+
if (command && Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) {
|
|
242
|
+
return { ok: false, toolName: "tmux_start_session", blocked: true, reason: "tmux startup command is too large." };
|
|
243
|
+
}
|
|
244
|
+
if (SECRET_PATTERN.test(command)) {
|
|
245
|
+
return { ok: false, toolName: "tmux_start_session", blocked: true, reason: "tmux startup command appears to contain a secret." };
|
|
246
|
+
}
|
|
247
|
+
if (DESTRUCTIVE_PATTERN.test(command)) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
toolName: "tmux_start_session",
|
|
251
|
+
blocked: true,
|
|
252
|
+
reason: "tmux startup command appears destructive; ask the user before starting it.",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const tmuxArgs = ["new-session", "-d", "-s", name.name, "-c", cwd.cwd];
|
|
257
|
+
if (command) tmuxArgs.push(command);
|
|
258
|
+
const result = await runTmux(tmuxArgs, { timeout: 10000 });
|
|
259
|
+
if (!result.ok) return { ok: false, toolName: "tmux_start_session", session: name.name, error: result.stderr || result.error };
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
toolName: "tmux_start_session",
|
|
263
|
+
session: name.name,
|
|
264
|
+
target: `${name.name}:0.0`,
|
|
265
|
+
cwd: cwd.cwd,
|
|
266
|
+
command: command ? redactSensitiveText(command) : "",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function checkTmuxToolUse(toolName, args = {}, config = {}) {
|
|
271
|
+
if (!config.allowShellTool) {
|
|
272
|
+
return { allowed: false, reason: "tmux tools require the shell tool to be enabled.", category: "tmux" };
|
|
273
|
+
}
|
|
274
|
+
if (toolName === "tmux_list_sessions") return { allowed: true, category: "tmux" };
|
|
275
|
+
if (toolName === "tmux_capture_pane") {
|
|
276
|
+
const target = validateTarget(args.target);
|
|
277
|
+
return target.ok ? { allowed: true, category: "tmux" } : { allowed: false, reason: target.reason, category: "tmux" };
|
|
278
|
+
}
|
|
279
|
+
if (toolName === "tmux_send_keys") {
|
|
280
|
+
const target = validateTarget(args.target);
|
|
281
|
+
if (!target.ok) return { allowed: false, reason: target.reason, category: "tmux" };
|
|
282
|
+
const text = String(args.text || "");
|
|
283
|
+
if (Buffer.byteLength(text, "utf8") > MAX_SEND_BYTES) {
|
|
284
|
+
return { allowed: false, reason: "tmux text payload is too large.", category: "tmux" };
|
|
285
|
+
}
|
|
286
|
+
if (SECRET_PATTERN.test(text)) {
|
|
287
|
+
return { allowed: false, reason: "tmux text appears to contain a secret.", category: "tmux" };
|
|
288
|
+
}
|
|
289
|
+
if (DESTRUCTIVE_PATTERN.test(text)) {
|
|
290
|
+
return { allowed: false, reason: "tmux text appears destructive; ask the user before sending it.", category: "tmux" };
|
|
291
|
+
}
|
|
292
|
+
for (const key of Array.isArray(args.keys) ? args.keys : []) {
|
|
293
|
+
if (!ALLOWED_KEYS.has(String(key))) return { allowed: false, reason: `Unsupported tmux key: ${key}`, category: "tmux" };
|
|
294
|
+
}
|
|
295
|
+
return { allowed: true, category: "tmux" };
|
|
296
|
+
}
|
|
297
|
+
if (toolName === "tmux_start_session") {
|
|
298
|
+
const name = validateSessionName(args.name);
|
|
299
|
+
if (!name.ok) return { allowed: false, reason: name.reason, category: "tmux" };
|
|
300
|
+
const cwd = resolveCwd(config, args.cwd || ".");
|
|
301
|
+
if (!cwd.ok) return { allowed: false, reason: cwd.reason, category: "tmux" };
|
|
302
|
+
const command = String(args.command || "");
|
|
303
|
+
if (Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) {
|
|
304
|
+
return { allowed: false, reason: "tmux startup command is too large.", category: "tmux" };
|
|
305
|
+
}
|
|
306
|
+
if (SECRET_PATTERN.test(command)) {
|
|
307
|
+
return { allowed: false, reason: "tmux startup command appears to contain a secret.", category: "tmux" };
|
|
308
|
+
}
|
|
309
|
+
if (DESTRUCTIVE_PATTERN.test(command)) {
|
|
310
|
+
return { allowed: false, reason: "tmux startup command appears destructive; ask the user before starting it.", category: "tmux" };
|
|
311
|
+
}
|
|
312
|
+
return { allowed: true, category: "tmux" };
|
|
313
|
+
}
|
|
314
|
+
return { allowed: true, category: "tmux" };
|
|
315
|
+
}
|