@lazyingart/agintiflow 0.16.2 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/docs/skills-and-tools.md +2 -2
- package/package.json +4 -2
- package/scripts/smoke-capabilities.js +4 -0
- package/scripts/smoke-cli-chat.js +5 -1
- 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/capabilities.js +5 -3
- package/src/guardrails.js +5 -0
- package/src/interactive-cli.js +11 -1
- 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
|
|
|
@@ -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.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -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
|
},
|
|
@@ -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"
|
|
@@ -144,6 +144,10 @@ try {
|
|
|
144
144
|
if (!skillsResult.stdout.includes("website-app") || !skillsResult.stdout.includes("Website And App Builder")) {
|
|
145
145
|
throw new Error("interactive /skills did not show matching built-in skills");
|
|
146
146
|
}
|
|
147
|
+
const abbreviatedSkillsResult = await runChat("/sk website\n/ex\n");
|
|
148
|
+
if (abbreviatedSkillsResult.stdout.includes("Unknown command") || !abbreviatedSkillsResult.stdout.includes("website-app")) {
|
|
149
|
+
throw new Error("interactive slash command prefix did not auto-select the first matching command");
|
|
150
|
+
}
|
|
147
151
|
await runChat("remember that this project prefers pytest smoke tests in AGINTI.md\n/exit\n");
|
|
148
152
|
const updatedInstructions = await fs.readFile(path.join(tempRoot, "AGINTI.md"), "utf8");
|
|
149
153
|
if (!updatedInstructions.includes("pytest smoke tests")) {
|
|
@@ -181,7 +185,7 @@ try {
|
|
|
181
185
|
{
|
|
182
186
|
ok: true,
|
|
183
187
|
projectRoot: tempRoot,
|
|
184
|
-
checks: ["markdown-render", "markdown-table-no-duplicate", "patch-diff-render", "prompt-layout", "user-prompt-label", "escape-policy", "live-input-status-layout", "agent-response-gutter", "aginti-md", "instructions-command", "skills-command", "instructions-chat-edit", "interactive-chat", "mock-file-write", "run-status", "resume-latest", "resume-history-full"],
|
|
188
|
+
checks: ["markdown-render", "markdown-table-no-duplicate", "patch-diff-render", "prompt-layout", "user-prompt-label", "escape-policy", "live-input-status-layout", "agent-response-gutter", "aginti-md", "instructions-command", "skills-command", "slash-prefix-autoselect", "instructions-chat-edit", "interactive-chat", "mock-file-write", "run-status", "resume-latest", "resume-history-full"],
|
|
185
189
|
},
|
|
186
190
|
null,
|
|
187
191
|
2
|
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/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/interactive-cli.js
CHANGED
|
@@ -153,6 +153,15 @@ function commandSuggestions(line = "") {
|
|
|
153
153
|
return SLASH_COMMANDS.filter((command) => command.startsWith(trimmed)).slice(0, 8);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
function resolveSlashCommand(command = "") {
|
|
157
|
+
const raw = String(command || "").trim();
|
|
158
|
+
if (!raw) return raw;
|
|
159
|
+
const slashCommand = `/${raw}`;
|
|
160
|
+
if (SLASH_COMMANDS.includes(slashCommand)) return raw;
|
|
161
|
+
const suggestion = SLASH_COMMANDS.find((candidate) => candidate.startsWith(slashCommand));
|
|
162
|
+
return suggestion ? suggestion.slice(1) : raw;
|
|
163
|
+
}
|
|
164
|
+
|
|
156
165
|
function clamp(value, min, max) {
|
|
157
166
|
return Math.min(Math.max(value, min), max);
|
|
158
167
|
}
|
|
@@ -1465,7 +1474,8 @@ async function promptAndSaveProviderKey(provider = "", state = null) {
|
|
|
1465
1474
|
}
|
|
1466
1475
|
|
|
1467
1476
|
async function handleCommand(line, state, packageDir) {
|
|
1468
|
-
const [
|
|
1477
|
+
const [rawCommand, ...rest] = line.slice(1).trim().split(/\s+/);
|
|
1478
|
+
const command = resolveSlashCommand(rawCommand);
|
|
1469
1479
|
const value = rest.join(" ").trim();
|
|
1470
1480
|
|
|
1471
1481
|
if (!command || command === "help" || command === "?") {
|
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
|
+
}
|