@mono-agent/agent-runtime 0.1.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/ARCHITECTURE.md +219 -0
- package/LICENSE +674 -0
- package/README.md +430 -0
- package/package.json +46 -0
- package/src/agent/allowlists.js +49 -0
- package/src/agent/approval.js +211 -0
- package/src/agent/compaction.js +752 -0
- package/src/agent/index.js +40 -0
- package/src/agent/prompt/skill-index.js +66 -0
- package/src/agent/tool-bloat.js +164 -0
- package/src/agent/tools/bash.js +156 -0
- package/src/agent/tools/edit.js +15 -0
- package/src/agent/tools/glob.js +71 -0
- package/src/agent/tools/grep.js +84 -0
- package/src/agent/tools/index.js +17 -0
- package/src/agent/tools/pi-bridge.js +638 -0
- package/src/agent/tools/read.js +39 -0
- package/src/agent/tools/shared/constants.js +21 -0
- package/src/agent/tools/shared/dedup.js +31 -0
- package/src/agent/tools/shared/output-truncation.js +54 -0
- package/src/agent/tools/shared/path-resolver.js +156 -0
- package/src/agent/tools/shared/ripgrep.js +130 -0
- package/src/agent/tools/shared/runtime-context.js +69 -0
- package/src/agent/tools/web-fetch.js +59 -0
- package/src/agent/tools/web-search.js +21 -0
- package/src/agent/tools/write.js +14 -0
- package/src/agent/transcript.js +227 -0
- package/src/ai/backend.js +17 -0
- package/src/ai/cost.js +164 -0
- package/src/ai/failure.js +165 -0
- package/src/ai/file-change-stats.js +234 -0
- package/src/ai/index.js +16 -0
- package/src/ai/live-input-prompt.js +15 -0
- package/src/ai/observer.js +233 -0
- package/src/ai/providers/claude-cli.js +694 -0
- package/src/ai/providers/claude-sdk.js +864 -0
- package/src/ai/providers/claude-subagents.js +67 -0
- package/src/ai/providers/codex-app.js +1045 -0
- package/src/ai/providers/opencode-app.js +356 -0
- package/src/ai/providers/opencode-discovery.js +39 -0
- package/src/ai/providers/pi-events.js +62 -0
- package/src/ai/providers/pi-messages.js +68 -0
- package/src/ai/providers/pi-models.js +111 -0
- package/src/ai/providers/pi-sdk.js +1310 -0
- package/src/ai/registry.js +5 -0
- package/src/ai/runtime/capabilities-used.js +56 -0
- package/src/ai/runtime/capabilities.js +44 -0
- package/src/ai/runtime/context-windows.js +38 -0
- package/src/ai/runtime/fast-mode.js +8 -0
- package/src/ai/runtime/model-refs.js +144 -0
- package/src/ai/runtime/registry.js +57 -0
- package/src/ai/runtime/router.js +214 -0
- package/src/ai/runtime/sessions.js +126 -0
- package/src/ai/streaming/codex-events.js +139 -0
- package/src/ai/streaming/opencode-events.js +54 -0
- package/src/ai/types.js +70 -0
- package/src/index.js +23 -0
- package/src/pi-auth.js +80 -0
- package/src/runtime-brand.js +32 -0
- package/src/runtime.js +104 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Public surface of the agent kernel. The kernel is consumed by the
|
|
2
|
+
// worker, the assistant, the Slack triage path, and the coordinator's
|
|
3
|
+
// run-spawn path. Everything below is intentionally re-exported; anything
|
|
4
|
+
// not listed here is private to the kernel and should not be imported
|
|
5
|
+
// from edge layers.
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
createAgentCompactionManager,
|
|
9
|
+
isLikelyContextTermination,
|
|
10
|
+
} from "./compaction.js";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
buildTranscriptTailSnapshot,
|
|
14
|
+
renderResumeSnapshot,
|
|
15
|
+
} from "./transcript.js";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
ALLOWLIST_MODE_ALL,
|
|
19
|
+
ALLOWLIST_MODE_CUSTOM,
|
|
20
|
+
inferAllowlistMode,
|
|
21
|
+
normalizeAllowlistMode,
|
|
22
|
+
normalizeList,
|
|
23
|
+
parseStoredAllowlist,
|
|
24
|
+
resolveAllowlist,
|
|
25
|
+
resolveAllowlistMap,
|
|
26
|
+
storedAllowlistMode,
|
|
27
|
+
} from "./allowlists.js";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
APPROVAL_DECISIONS,
|
|
31
|
+
RISK_TIERS,
|
|
32
|
+
createApprovalManager,
|
|
33
|
+
wrapToolsWithApprovalGate,
|
|
34
|
+
} from "./approval.js";
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
BINARY_BLOAT_TOOLS,
|
|
38
|
+
DEFAULT_TOOL_BLOAT_CONFIG,
|
|
39
|
+
MAX_TOOL_RESULT_BYTES,
|
|
40
|
+
} from "./tool-bloat.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Skill-rendering helpers used to inject skill bodies and metadata into
|
|
2
|
+
// agent system prompts. Lives in agent/prompt/ because the rendering shape
|
|
3
|
+
// is a kernel concern; data loading (loadSkills, parseSkillFrontmatter)
|
|
4
|
+
// stays in core/skills.js.
|
|
5
|
+
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
const SKILL_PATH_RULE = "Path rule: Files referenced by SKILL.md are bundled with the skill. Resolve `scripts/...`, `references/...`, and `assets/...` relative to that skill's directory; resolve `<skill-directory-name>/...` relative to the configured skills root.";
|
|
9
|
+
|
|
10
|
+
function skillDir(skill) {
|
|
11
|
+
if (!skill?.assetsPath || typeof skill.assetsPath !== "string") return "";
|
|
12
|
+
return resolve(skill.assetsPath);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function inferSkillsRoot(skills = []) {
|
|
16
|
+
const roots = [...new Set(skills.map(skillDir).filter(Boolean).map(dirname))];
|
|
17
|
+
return roots.length === 1 ? roots[0] : "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getSkillAccessDirs(skills = []) {
|
|
21
|
+
const root = inferSkillsRoot(skills);
|
|
22
|
+
if (root) return [root];
|
|
23
|
+
return [...new Set(skills.map(skillDir).filter(Boolean))];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildSkillPathNote({ assetsPath, skillsRoot } = {}) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
if (skillsRoot) lines.push(`Configured skills root: ${resolve(skillsRoot)}`);
|
|
29
|
+
if (assetsPath) lines.push(`Skill directory: ${resolve(assetsPath)}`);
|
|
30
|
+
lines.push(SKILL_PATH_RULE);
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatSkillBodyWithPathNote({ body, assetsPath, skillsRoot, maxChars = 12000 } = {}) {
|
|
35
|
+
const text = [
|
|
36
|
+
buildSkillPathNote({ assetsPath, skillsRoot }),
|
|
37
|
+
String(body || "").trim(),
|
|
38
|
+
].filter(Boolean).join("\n\n");
|
|
39
|
+
return maxChars ? text.slice(0, maxChars) : text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildSkillIndex(skills) {
|
|
43
|
+
const enabled = skills.filter((s) => s.enabled);
|
|
44
|
+
const lines = ["## Available skills", ""];
|
|
45
|
+
const skillsRoot = inferSkillsRoot(enabled);
|
|
46
|
+
if (skillsRoot) {
|
|
47
|
+
lines.push(buildSkillPathNote({ skillsRoot }), "");
|
|
48
|
+
}
|
|
49
|
+
for (const s of enabled) {
|
|
50
|
+
const assetsPath = skillDir(s);
|
|
51
|
+
if (s.priority === "always" && s.body) {
|
|
52
|
+
lines.push(
|
|
53
|
+
`### ${s.name}`,
|
|
54
|
+
"",
|
|
55
|
+
assetsPath ? buildSkillPathNote({ assetsPath }) : SKILL_PATH_RULE,
|
|
56
|
+
"",
|
|
57
|
+
s.body.trim(),
|
|
58
|
+
"",
|
|
59
|
+
);
|
|
60
|
+
} else {
|
|
61
|
+
const pathText = assetsPath ? ` (directory: ${assetsPath})` : "";
|
|
62
|
+
lines.push(`- ${s.name}: ${s.trigger}${pathText}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Tool-result bloat containment.
|
|
2
|
+
//
|
|
3
|
+
// Single tool_result payloads can reach several megabytes and frequently trip
|
|
4
|
+
// the context_bloat warning. This module caps tool_result payloads before they
|
|
5
|
+
// reach the model and substitutes a compact reference text so the agent can
|
|
6
|
+
// still cite the artifact.
|
|
7
|
+
//
|
|
8
|
+
// Persistence is delegated to the host via a `persistArtifact({ filename, buffer,
|
|
9
|
+
// toolName, toolUseId }) -> path | null` callback. Host output sinks
|
|
10
|
+
// in src/core/tool-artifacts.js writes to {runArtifactDir}/tool-output/<file>.
|
|
11
|
+
// Hosts that don't care can pass null and the truncated payload is dropped.
|
|
12
|
+
|
|
13
|
+
export const MAX_TOOL_RESULT_BYTES = 262144;
|
|
14
|
+
|
|
15
|
+
export const BINARY_BLOAT_TOOLS = Object.freeze([
|
|
16
|
+
"mcp__playwright__browser_take_screenshot",
|
|
17
|
+
"mcp__playwright__browser_snapshot",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_TOOL_BLOAT_CONFIG = Object.freeze({
|
|
21
|
+
maxBytes: MAX_TOOL_RESULT_BYTES,
|
|
22
|
+
binaryBloatTools: BINARY_BLOAT_TOOLS,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function blockBytes(block) {
|
|
26
|
+
if (!block || typeof block !== "object") return 0;
|
|
27
|
+
if (block.type === "text") return Buffer.byteLength(String(block.text || ""), "utf8");
|
|
28
|
+
if (block.type === "image") {
|
|
29
|
+
const data = String(block.data || "");
|
|
30
|
+
const clean = data.includes(",") ? data.slice(data.indexOf(",") + 1) : data;
|
|
31
|
+
return Math.floor(clean.length * 0.75);
|
|
32
|
+
}
|
|
33
|
+
try { return Buffer.byteLength(JSON.stringify(block), "utf8"); } catch { return 0; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function totalBytes(blocks) {
|
|
37
|
+
if (!Array.isArray(blocks)) return 0;
|
|
38
|
+
return blocks.reduce((sum, block) => sum + blockBytes(block), 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeBasename(name) {
|
|
42
|
+
return String(name || "tool").replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 80);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function imageExtension(block) {
|
|
46
|
+
const mime = block?.mimeType || block?.mime_type || "";
|
|
47
|
+
const m = /image\/([a-z0-9]+)/i.exec(mime);
|
|
48
|
+
return m ? `.${m[1].toLowerCase()}` : ".bin";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function persistBlock(toolName, block, idx, idTag, persistArtifact) {
|
|
52
|
+
if (typeof persistArtifact !== "function" || !block || typeof block !== "object") return null;
|
|
53
|
+
let filename;
|
|
54
|
+
let buffer;
|
|
55
|
+
if (block.type === "image") {
|
|
56
|
+
filename = `${safeBasename(toolName)}__${idTag}__${idx}${imageExtension(block)}`;
|
|
57
|
+
const data = String(block.data || "");
|
|
58
|
+
const clean = data.includes(",") ? data.slice(data.indexOf(",") + 1) : data;
|
|
59
|
+
buffer = Buffer.from(clean, "base64");
|
|
60
|
+
} else {
|
|
61
|
+
const text = block.type === "text"
|
|
62
|
+
? String(block.text || "")
|
|
63
|
+
: (() => {
|
|
64
|
+
try { return JSON.stringify(block, null, 2); } catch { return String(block); }
|
|
65
|
+
})();
|
|
66
|
+
filename = `${safeBasename(toolName)}__${idTag}__${idx}.txt`;
|
|
67
|
+
buffer = Buffer.from(text, "utf8");
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const path = persistArtifact({ filename, buffer, toolName, toolUseId: idTag });
|
|
71
|
+
return typeof path === "string" && path.length ? path : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function summaryText(toolName, originalBytes, maxBytes, savedPaths) {
|
|
78
|
+
const parts = [
|
|
79
|
+
`[truncated tool_result: ${originalBytes} bytes exceeded ${maxBytes} byte cap`,
|
|
80
|
+
`tool=${toolName}`,
|
|
81
|
+
];
|
|
82
|
+
if (savedPaths.length === 1) parts.push(`saved_to=${savedPaths[0]}`);
|
|
83
|
+
else if (savedPaths.length > 1) parts.push(`saved_to=[${savedPaths.length} files]`);
|
|
84
|
+
else parts.push("persistence unavailable");
|
|
85
|
+
return `${parts.join("; ")}]`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function summarisePayload(toolName, contentBlocks, persistArtifact, options = {}) {
|
|
89
|
+
const {
|
|
90
|
+
maxBytes = MAX_TOOL_RESULT_BYTES,
|
|
91
|
+
toolUseId = null,
|
|
92
|
+
now = Date.now,
|
|
93
|
+
} = options;
|
|
94
|
+
const blocks = Array.isArray(contentBlocks) ? contentBlocks : [];
|
|
95
|
+
const originalBytes = totalBytes(blocks);
|
|
96
|
+
if (originalBytes <= maxBytes) {
|
|
97
|
+
return { rewrittenBlocks: blocks, savedPaths: [], originalBytes, truncated: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stamp = String(now()).slice(-10);
|
|
101
|
+
const idTag = toolUseId ? safeBasename(toolUseId) : `payload-${stamp}`;
|
|
102
|
+
const savedPaths = [];
|
|
103
|
+
blocks.forEach((block, idx) => {
|
|
104
|
+
const path = persistBlock(toolName, block, idx, idTag, persistArtifact);
|
|
105
|
+
if (path) savedPaths.push(path);
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
rewrittenBlocks: [{ type: "text", text: summaryText(toolName, originalBytes, maxBytes, savedPaths) }],
|
|
109
|
+
savedPaths,
|
|
110
|
+
originalBytes,
|
|
111
|
+
truncated: true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function applyToolBloatGuard(toolName, executePromise, options = {}) {
|
|
116
|
+
const {
|
|
117
|
+
persistArtifact = null,
|
|
118
|
+
toolUseId = null,
|
|
119
|
+
maxBytes = MAX_TOOL_RESULT_BYTES,
|
|
120
|
+
onTruncate = null,
|
|
121
|
+
} = options;
|
|
122
|
+
const result = await executePromise;
|
|
123
|
+
if (!result || typeof result !== "object" || !Array.isArray(result.content)) return result;
|
|
124
|
+
const summary = summarisePayload(toolName, result.content, persistArtifact, { maxBytes, toolUseId });
|
|
125
|
+
if (!summary.truncated) return result;
|
|
126
|
+
if (typeof onTruncate === "function") {
|
|
127
|
+
try {
|
|
128
|
+
onTruncate({
|
|
129
|
+
tool: toolName,
|
|
130
|
+
tool_use_id: toolUseId,
|
|
131
|
+
original_bytes: summary.originalBytes,
|
|
132
|
+
max_bytes: maxBytes,
|
|
133
|
+
saved_paths: summary.savedPaths,
|
|
134
|
+
});
|
|
135
|
+
} catch { /* best-effort */ }
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
...result,
|
|
139
|
+
content: summary.rewrittenBlocks,
|
|
140
|
+
details: {
|
|
141
|
+
...(result.details || {}),
|
|
142
|
+
tool_payload_truncated: true,
|
|
143
|
+
tool_payload_original_bytes: summary.originalBytes,
|
|
144
|
+
tool_payload_saved_paths: summary.savedPaths,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function wrapToolsWithBloatGuard(tools, options = {}) {
|
|
150
|
+
const list = Array.isArray(tools) ? tools : [];
|
|
151
|
+
return list.map((tool) => {
|
|
152
|
+
if (!tool || typeof tool.execute !== "function") return tool;
|
|
153
|
+
const originalExecute = tool.execute.bind(tool);
|
|
154
|
+
return {
|
|
155
|
+
...tool,
|
|
156
|
+
async execute(toolCallId, params, signal) {
|
|
157
|
+
return applyToolBloatGuard(tool.name, originalExecute(toolCallId, params, signal), {
|
|
158
|
+
...options,
|
|
159
|
+
toolUseId: toolCallId,
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { prepareSandboxedCommand } from "@mono-agent/sandbox";
|
|
4
|
+
import { DEFAULT_MAX_BASH_OUTPUT_CHARS } from "./shared/constants.js";
|
|
5
|
+
import { capChars } from "./shared/output-truncation.js";
|
|
6
|
+
import {
|
|
7
|
+
isPathAllowed,
|
|
8
|
+
isWorkdirAllowed,
|
|
9
|
+
workspaceRoot,
|
|
10
|
+
} from "./shared/path-resolver.js";
|
|
11
|
+
import { resolveSandboxPolicy } from "./shared/runtime-context.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BASH_TIMEOUT_MS = 120000;
|
|
14
|
+
const BASH_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
const KILL_GRACE_MS = 1000;
|
|
16
|
+
|
|
17
|
+
export function normalizeBashTimeoutMs(value, fallback = DEFAULT_BASH_TIMEOUT_MS) {
|
|
18
|
+
const cap = Number.isFinite(Number(fallback)) && Number(fallback) > 0
|
|
19
|
+
? Math.floor(Number(fallback))
|
|
20
|
+
: DEFAULT_BASH_TIMEOUT_MS;
|
|
21
|
+
const n = Number(value);
|
|
22
|
+
if (!Number.isFinite(n) || n <= 0) return cap;
|
|
23
|
+
const floored = Math.floor(n);
|
|
24
|
+
const ms = floored <= 600 ? floored * 1000 : floored;
|
|
25
|
+
return Math.max(1000, Math.min(ms, cap));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function killProcessGroup(child, signal) {
|
|
29
|
+
if (!child?.pid) return;
|
|
30
|
+
try {
|
|
31
|
+
process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
|
|
32
|
+
} catch {
|
|
33
|
+
try { process.kill(child.pid, signal); } catch { /* already gone */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function appendChunk(chunks, chunk, state) {
|
|
38
|
+
state.bytes += chunk.length;
|
|
39
|
+
if (state.bytes > state.maxBufferBytes) {
|
|
40
|
+
state.bufferExceeded = true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
chunks.push(chunk);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runCommand(commandSpec, { timeoutMs, signal, maxBufferBytes = BASH_MAX_BUFFER_BYTES }) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const child = spawn(commandSpec.command, commandSpec.args || [], {
|
|
50
|
+
cwd: commandSpec.cwd,
|
|
51
|
+
detached: true,
|
|
52
|
+
env: commandSpec.env ? { ...process.env, ...commandSpec.env } : process.env,
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
});
|
|
55
|
+
const stdout = [];
|
|
56
|
+
const stderr = [];
|
|
57
|
+
const state = {
|
|
58
|
+
aborted: false,
|
|
59
|
+
bufferExceeded: false,
|
|
60
|
+
bytes: 0,
|
|
61
|
+
maxBufferBytes,
|
|
62
|
+
spawnError: null,
|
|
63
|
+
timedOut: false,
|
|
64
|
+
};
|
|
65
|
+
let killTimer = null;
|
|
66
|
+
let settled = false;
|
|
67
|
+
|
|
68
|
+
function terminate() {
|
|
69
|
+
killProcessGroup(child, "SIGTERM");
|
|
70
|
+
if (!killTimer) {
|
|
71
|
+
killTimer = setTimeout(() => killProcessGroup(child, "SIGKILL"), KILL_GRACE_MS);
|
|
72
|
+
killTimer.unref?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const timeoutTimer = setTimeout(() => {
|
|
77
|
+
state.timedOut = true;
|
|
78
|
+
terminate();
|
|
79
|
+
}, timeoutMs);
|
|
80
|
+
timeoutTimer.unref?.();
|
|
81
|
+
|
|
82
|
+
const onAbort = () => {
|
|
83
|
+
state.aborted = true;
|
|
84
|
+
terminate();
|
|
85
|
+
};
|
|
86
|
+
if (signal?.aborted) onAbort();
|
|
87
|
+
else signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
88
|
+
|
|
89
|
+
child.stdout?.on("data", (chunk) => {
|
|
90
|
+
if (!appendChunk(stdout, chunk, state)) terminate();
|
|
91
|
+
});
|
|
92
|
+
child.stderr?.on("data", (chunk) => {
|
|
93
|
+
if (!appendChunk(stderr, chunk, state)) terminate();
|
|
94
|
+
});
|
|
95
|
+
child.once("error", (err) => {
|
|
96
|
+
state.spawnError = err;
|
|
97
|
+
});
|
|
98
|
+
child.once("close", (code, closeSignal) => {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
clearTimeout(timeoutTimer);
|
|
102
|
+
if (killTimer) clearTimeout(killTimer);
|
|
103
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
104
|
+
resolve({
|
|
105
|
+
code,
|
|
106
|
+
signal: closeSignal,
|
|
107
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
108
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
109
|
+
...state,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function bashToolImpl({ command, timeout = DEFAULT_BASH_TIMEOUT_MS, max_output_chars, workdir }, { signal, sandboxPolicy, sandboxEngine } = {}) {
|
|
116
|
+
const policy = resolveSandboxPolicy(sandboxPolicy);
|
|
117
|
+
const pathOptions = { sandboxPolicy: policy };
|
|
118
|
+
if (workdir && !isWorkdirAllowed(workdir, pathOptions)) return `Error: Working directory not allowed: ${workdir}`;
|
|
119
|
+
const cwd = workspaceRoot(workdir);
|
|
120
|
+
if (!isPathAllowed(cwd, workdir, pathOptions)) return `Error: Working directory not allowed: ${cwd}`;
|
|
121
|
+
if (!existsSync(cwd)) return `Error: Working directory not found: ${cwd}`;
|
|
122
|
+
const maxChars = Number(max_output_chars) || DEFAULT_MAX_BASH_OUTPUT_CHARS;
|
|
123
|
+
const timeoutMs = normalizeBashTimeoutMs(timeout);
|
|
124
|
+
let prepared;
|
|
125
|
+
try {
|
|
126
|
+
prepared = await prepareSandboxedCommand({
|
|
127
|
+
policy,
|
|
128
|
+
engine: sandboxEngine ?? undefined,
|
|
129
|
+
command: { command: "/bin/bash", args: ["-lc", command], cwd },
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return `Error: ${err?.message || String(err)}`;
|
|
133
|
+
}
|
|
134
|
+
let result;
|
|
135
|
+
try {
|
|
136
|
+
result = await runCommand(prepared, { timeoutMs, signal });
|
|
137
|
+
} finally {
|
|
138
|
+
await prepared.cleanup?.();
|
|
139
|
+
}
|
|
140
|
+
if (result.timedOut) return `Error: Command timed out after ${timeoutMs}ms`;
|
|
141
|
+
if (result.aborted) return "Error: Command aborted";
|
|
142
|
+
if (result.bufferExceeded) return `Error: Command output exceeded ${BASH_MAX_BUFFER_BYTES} bytes`;
|
|
143
|
+
if (result.spawnError) return `Exit code 1:\n${result.spawnError.message}`;
|
|
144
|
+
if (result.code && result.code !== 0) {
|
|
145
|
+
return capChars(`Exit code ${result.code || 1}:\n${result.stdout || ""}${result.stderr || ""}`, {
|
|
146
|
+
label: "Bash",
|
|
147
|
+
maxChars,
|
|
148
|
+
strategy: "head_tail",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (result.signal) return `Exit code 1:\nCommand terminated by ${result.signal}`;
|
|
152
|
+
const output = result.stdout && result.stderr
|
|
153
|
+
? `STDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`
|
|
154
|
+
: (result.stdout || result.stderr || "(no output)");
|
|
155
|
+
return capChars(output, { label: "Bash", maxChars, strategy: "head_tail" });
|
|
156
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isPathAllowed, isWritablePathAllowed, resolveToolPath } from "./shared/path-resolver.js";
|
|
3
|
+
|
|
4
|
+
export async function editToolImpl({ file_path, old_string, new_string, replace_all = false, workdir }, { sandboxPolicy } = {}) {
|
|
5
|
+
const target = resolveToolPath(file_path, workdir);
|
|
6
|
+
const pathOptions = { sandboxPolicy };
|
|
7
|
+
if (!isPathAllowed(target, workdir, pathOptions) || !isWritablePathAllowed(target, workdir, pathOptions)) return `Error: Path not allowed: ${file_path}`;
|
|
8
|
+
if (!existsSync(target)) return `Error: File not found: ${file_path}`;
|
|
9
|
+
const content = readFileSync(target, "utf8");
|
|
10
|
+
const count = content.split(old_string).length - 1;
|
|
11
|
+
if (count === 0) return `Error: old_string not found in ${target}`;
|
|
12
|
+
if (!replace_all && count > 1) return `Error: old_string found ${count} times`;
|
|
13
|
+
writeFileSync(target, replace_all ? content.replaceAll(old_string, new_string) : content.replace(old_string, new_string), "utf8");
|
|
14
|
+
return `Successfully edited ${target}`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MAX_SEARCH_CHARS,
|
|
6
|
+
DEFAULT_MAX_SEARCH_LINES,
|
|
7
|
+
SEARCH_MAX_BUFFER,
|
|
8
|
+
} from "./shared/constants.js";
|
|
9
|
+
import { boundedInt, safeStat } from "./shared/dedup.js";
|
|
10
|
+
import {
|
|
11
|
+
isPathAllowed,
|
|
12
|
+
resolveToolPath,
|
|
13
|
+
workspaceRoot,
|
|
14
|
+
} from "./shared/path-resolver.js";
|
|
15
|
+
import {
|
|
16
|
+
capLines,
|
|
17
|
+
excludedGlobArgs,
|
|
18
|
+
excludedPathSummary,
|
|
19
|
+
formatSearchLines,
|
|
20
|
+
normalizeGlobPattern,
|
|
21
|
+
resolveRgPath,
|
|
22
|
+
ripgrepMissingMessage,
|
|
23
|
+
} from "./shared/ripgrep.js";
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
|
|
27
|
+
export async function globToolImpl({ pattern, path, limit, offset = 0, max_matches, max_output_chars, workdir }, { sandboxPolicy } = {}) {
|
|
28
|
+
const cwd = resolveToolPath(path || workspaceRoot(workdir), workdir);
|
|
29
|
+
if (!isPathAllowed(cwd, workdir, { sandboxPolicy })) return `Error: Path not allowed: ${cwd}`;
|
|
30
|
+
const stat = safeStat(cwd);
|
|
31
|
+
if (!stat?.isDirectory()) return `Error: Glob path is not a directory: ${cwd}`;
|
|
32
|
+
const resultLimit = boundedInt(limit ?? max_matches, DEFAULT_MAX_SEARCH_LINES, { min: 1, max: 1000 });
|
|
33
|
+
const args = [
|
|
34
|
+
"--files",
|
|
35
|
+
"--hidden",
|
|
36
|
+
"--color=never",
|
|
37
|
+
"--glob",
|
|
38
|
+
normalizeGlobPattern(pattern),
|
|
39
|
+
...excludedGlobArgs(),
|
|
40
|
+
];
|
|
41
|
+
const rgPath = resolveRgPath();
|
|
42
|
+
if (!rgPath) return ripgrepMissingMessage();
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await execFileAsync(rgPath, args, { cwd, timeout: 15000, maxBuffer: SEARCH_MAX_BUFFER });
|
|
45
|
+
const lines = stdout.trim().split("\n").filter(Boolean).sort((a, b) => {
|
|
46
|
+
const aStat = safeStat(resolve(cwd, a));
|
|
47
|
+
const bStat = safeStat(resolve(cwd, b));
|
|
48
|
+
return (bStat?.mtimeMs || 0) - (aStat?.mtimeMs || 0) || a.localeCompare(b);
|
|
49
|
+
});
|
|
50
|
+
const result = formatSearchLines(lines, {
|
|
51
|
+
label: "Glob",
|
|
52
|
+
noMatches: "No files found matching pattern.",
|
|
53
|
+
maxLines: resultLimit,
|
|
54
|
+
maxChars: Number(max_output_chars) || DEFAULT_MAX_SEARCH_CHARS,
|
|
55
|
+
offset,
|
|
56
|
+
});
|
|
57
|
+
return result === "No files found matching pattern." ? result : `${result}\n\n${excludedPathSummary()}`;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code === 1) return "No files found matching pattern.";
|
|
60
|
+
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || /maxBuffer/i.test(err.message || "")) {
|
|
61
|
+
return `${capLines(err.stdout || "", {
|
|
62
|
+
label: "Glob",
|
|
63
|
+
noMatches: "Glob result exceeded the output limit before any preview could be captured.",
|
|
64
|
+
maxLines: resultLimit,
|
|
65
|
+
maxChars: Number(max_output_chars) || DEFAULT_MAX_SEARCH_CHARS,
|
|
66
|
+
offset,
|
|
67
|
+
})}\n\n${excludedPathSummary()}`;
|
|
68
|
+
}
|
|
69
|
+
return `Error: ${err.message}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MAX_SEARCH_CHARS,
|
|
6
|
+
DEFAULT_MAX_SEARCH_LINES,
|
|
7
|
+
SEARCH_MAX_BUFFER,
|
|
8
|
+
} from "./shared/constants.js";
|
|
9
|
+
import { boundedInt, safeStat } from "./shared/dedup.js";
|
|
10
|
+
import {
|
|
11
|
+
isPathAllowed,
|
|
12
|
+
resolveToolPath,
|
|
13
|
+
workspaceRoot,
|
|
14
|
+
} from "./shared/path-resolver.js";
|
|
15
|
+
import {
|
|
16
|
+
capLines,
|
|
17
|
+
excludedGlobArgs,
|
|
18
|
+
excludedPathSummary,
|
|
19
|
+
resolveRgPath,
|
|
20
|
+
ripgrepMissingMessage,
|
|
21
|
+
} from "./shared/ripgrep.js";
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
export async function grepToolImpl({
|
|
26
|
+
pattern,
|
|
27
|
+
path,
|
|
28
|
+
glob,
|
|
29
|
+
type,
|
|
30
|
+
output_mode = "files_with_matches",
|
|
31
|
+
context,
|
|
32
|
+
case_insensitive,
|
|
33
|
+
multiline,
|
|
34
|
+
head_limit,
|
|
35
|
+
offset = 0,
|
|
36
|
+
max_matches,
|
|
37
|
+
max_output_chars,
|
|
38
|
+
workdir,
|
|
39
|
+
}, { sandboxPolicy } = {}) {
|
|
40
|
+
const target = resolveToolPath(path || workspaceRoot(workdir), workdir);
|
|
41
|
+
if (!isPathAllowed(target, workdir, { sandboxPolicy })) return `Error: Path not allowed: ${target}`;
|
|
42
|
+
const stat = safeStat(target);
|
|
43
|
+
if (!stat) return `Error: Path not found: ${target}`;
|
|
44
|
+
const cwd = stat.isDirectory() ? target : dirname(target);
|
|
45
|
+
const searchTarget = stat.isDirectory() ? "." : basename(target);
|
|
46
|
+
const mode = ["content", "count", "files_with_matches"].includes(output_mode) ? output_mode : "files_with_matches";
|
|
47
|
+
const args = ["--no-config", "--hidden", "--color=never"];
|
|
48
|
+
if (mode === "files_with_matches") args.push("--files-with-matches");
|
|
49
|
+
else if (mode === "count") args.push("--count-matches");
|
|
50
|
+
else args.push("--line-number");
|
|
51
|
+
if (case_insensitive) args.push("-i");
|
|
52
|
+
if (mode === "content" && context) args.push(`-C${boundedInt(context, 0, { min: 0, max: 20 })}`);
|
|
53
|
+
if (multiline) args.push("-U", "--multiline-dotall");
|
|
54
|
+
if (glob) args.push("--glob", glob);
|
|
55
|
+
if (type) args.push("--type", type);
|
|
56
|
+
args.push(...excludedGlobArgs(), "--", pattern, searchTarget);
|
|
57
|
+
const resultLimit = boundedInt(head_limit ?? max_matches, DEFAULT_MAX_SEARCH_LINES, { min: 1, max: 1000 });
|
|
58
|
+
const rgPath = resolveRgPath();
|
|
59
|
+
if (!rgPath) return ripgrepMissingMessage();
|
|
60
|
+
try {
|
|
61
|
+
const { stdout } = await execFileAsync(rgPath, args, { cwd, timeout: 15000, maxBuffer: SEARCH_MAX_BUFFER });
|
|
62
|
+
const normalized = stdout.trim().split("\n").filter(Boolean).map((line) => line.replace(/^\.\//, ""));
|
|
63
|
+
const formatted = capLines(normalized.join("\n"), {
|
|
64
|
+
label: "Grep",
|
|
65
|
+
noMatches: "No matches found.",
|
|
66
|
+
maxLines: resultLimit,
|
|
67
|
+
maxChars: Number(max_output_chars) || DEFAULT_MAX_SEARCH_CHARS,
|
|
68
|
+
offset,
|
|
69
|
+
});
|
|
70
|
+
return formatted === "No matches found." ? formatted : `${formatted}\n\n${excludedPathSummary()}`;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.code === 1) return "No matches found.";
|
|
73
|
+
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || /maxBuffer/i.test(err.message || "")) {
|
|
74
|
+
return `${capLines(err.stdout || "", {
|
|
75
|
+
label: "Grep",
|
|
76
|
+
noMatches: "Grep result exceeded the output limit before any preview could be captured.",
|
|
77
|
+
maxLines: resultLimit,
|
|
78
|
+
maxChars: Number(max_output_chars) || DEFAULT_MAX_SEARCH_CHARS,
|
|
79
|
+
offset,
|
|
80
|
+
})}\n\n${excludedPathSummary()}`;
|
|
81
|
+
}
|
|
82
|
+
return `Error: ${err.message}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Barrel re-exporting per-tool implementations and the small bit of shared
|
|
2
|
+
// surface that callers outside this directory consume (the path/workdir
|
|
3
|
+
// guards plus the ripgrep resolver used by the configured doctor command. Each tool
|
|
4
|
+
// implementation lives in its own file under `./` and pulls helpers from
|
|
5
|
+
// `./shared/`. `pi-bridge.js` imports the tool impls from this barrel.
|
|
6
|
+
|
|
7
|
+
export { readToolImpl } from "./read.js";
|
|
8
|
+
export { writeToolImpl } from "./write.js";
|
|
9
|
+
export { editToolImpl } from "./edit.js";
|
|
10
|
+
export { globToolImpl } from "./glob.js";
|
|
11
|
+
export { grepToolImpl } from "./grep.js";
|
|
12
|
+
export { bashToolImpl, normalizeBashTimeoutMs } from "./bash.js";
|
|
13
|
+
export { webFetchToolImpl } from "./web-fetch.js";
|
|
14
|
+
export { webSearchToolImpl } from "./web-search.js";
|
|
15
|
+
|
|
16
|
+
export { isPathAllowed, isWorkdirAllowed } from "./shared/path-resolver.js";
|
|
17
|
+
export { resolveRgPath } from "./shared/ripgrep.js";
|