atom-agent 0.3.0 → 1.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/CHANGELOG.md +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Search executors: grep (content regex) and glob (path patterns).
|
|
2
|
+
// Read-only over the repo; node_modules/.git skipped by the walker.
|
|
3
|
+
import { promises as fsp } from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { listFiles } from "./dir-cache.js";
|
|
6
|
+
import { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, resolveSandbox } from "./shared.js";
|
|
7
|
+
// Minimal glob matcher: supports **, **/, *, ?. Used for grep `include`
|
|
8
|
+
// and the glob tool. Patterns without a slash match the basename.
|
|
9
|
+
function globToRegExp(glob) {
|
|
10
|
+
let re = "";
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < glob.length) {
|
|
13
|
+
const c = glob[i];
|
|
14
|
+
if (c === "*") {
|
|
15
|
+
if (glob[i + 1] === "*") {
|
|
16
|
+
// "**/" matches zero or more directories; bare "**" matches all.
|
|
17
|
+
if (glob[i + 2] === "/") {
|
|
18
|
+
re += "(?:.*/)?";
|
|
19
|
+
i += 3;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
re += ".*";
|
|
23
|
+
i += 2;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
re += "[^/]*";
|
|
28
|
+
i += 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else if (c === "?") {
|
|
32
|
+
re += "[^/]";
|
|
33
|
+
i += 1;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
re += c.replace(/[.+^${}()|[\]\\]/, "\\$&");
|
|
37
|
+
i += 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return new RegExp(`^${re}$`);
|
|
41
|
+
}
|
|
42
|
+
function matchesGlob(pattern, relPosix) {
|
|
43
|
+
const norm = pattern.replace(/\\/g, "/");
|
|
44
|
+
if (!norm.includes("/")) {
|
|
45
|
+
const base = relPosix.slice(relPosix.lastIndexOf("/") + 1);
|
|
46
|
+
return globToRegExp(norm).test(base);
|
|
47
|
+
}
|
|
48
|
+
return globToRegExp(norm).test(relPosix);
|
|
49
|
+
}
|
|
50
|
+
export const GREP_OUTPUT_MODES = new Set([
|
|
51
|
+
"content",
|
|
52
|
+
"files_with_matches",
|
|
53
|
+
"count",
|
|
54
|
+
]);
|
|
55
|
+
// File-read fan-out for scans: bounded parallelism (32 in flight) over an
|
|
56
|
+
// ordered list, results in input order. Disk I/O overlaps instead of
|
|
57
|
+
// serializing one open/read/close at a time; the regex pass stays sequential
|
|
58
|
+
// afterwards so outputs and first-failure errors keep exact alpha order.
|
|
59
|
+
// 32 concurrent opens is far below EMFILE on every supported platform.
|
|
60
|
+
const SCAN_CONCURRENCY = 32;
|
|
61
|
+
async function mapLimit(items, limit, fn) {
|
|
62
|
+
const out = new Array(items.length);
|
|
63
|
+
let next = 0;
|
|
64
|
+
const workers = new Array(Math.min(Math.max(limit, 1), Math.max(items.length, 1)))
|
|
65
|
+
.fill(0)
|
|
66
|
+
.map(async () => {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const i = next++;
|
|
69
|
+
if (i >= items.length)
|
|
70
|
+
return;
|
|
71
|
+
out[i] = await fn(items[i], i);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
await Promise.all(workers);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
// Best-effort mtime (ms) for recency sorting; 0 when the file cannot be
|
|
78
|
+
// stat'ed (keeps such entries last instead of failing the search).
|
|
79
|
+
async function mtimeMs(abs) {
|
|
80
|
+
try {
|
|
81
|
+
return (await fsp.stat(abs)).mtimeMs;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Line-regex search under dir (default "."). `include` is a glob like
|
|
88
|
+
// "*.ts". `outputMode` selects the shape (Claude-Code-style):
|
|
89
|
+
// - "content" (default): "file:line: text" lines, capped at 100 matches.
|
|
90
|
+
// - "files_with_matches": matching file paths newest-first with a
|
|
91
|
+
// "Found N file(s)" header, capped at 100 listed files.
|
|
92
|
+
// - "count": per-file "file:count" lines plus a totals line; the totals
|
|
93
|
+
// cover every match even when the listed files are capped.
|
|
94
|
+
export async function grepTool(args, cwd = process.cwd()) {
|
|
95
|
+
try {
|
|
96
|
+
if (typeof args?.pattern !== "string")
|
|
97
|
+
return err("pattern must be a string");
|
|
98
|
+
let re;
|
|
99
|
+
try {
|
|
100
|
+
re = new RegExp(args.pattern);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return err(`invalid regex: ${args.pattern}`);
|
|
104
|
+
}
|
|
105
|
+
const mode = args?.outputMode ?? "content";
|
|
106
|
+
if (!GREP_OUTPUT_MODES.has(mode)) {
|
|
107
|
+
return invalidCall(`field "outputMode" for tool "grep" must be one of "content", "files_with_matches", "count" (got ${JSON.stringify(args?.outputMode)})`);
|
|
108
|
+
}
|
|
109
|
+
const dir = args.dir ?? ".";
|
|
110
|
+
const r = resolveSandbox(dir, cwd);
|
|
111
|
+
if (r.error || !r.abs)
|
|
112
|
+
return r.error ?? err("bad dir");
|
|
113
|
+
let st;
|
|
114
|
+
try {
|
|
115
|
+
st = await fsp.stat(r.abs);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return err(`no such directory: ${dir}`);
|
|
119
|
+
}
|
|
120
|
+
if (!st.isDirectory())
|
|
121
|
+
return err(`not a directory: ${dir}`);
|
|
122
|
+
const files = await listFiles(r.abs, cwd);
|
|
123
|
+
const include = typeof args.include === "string" && args.include.length > 0 ? args.include : null;
|
|
124
|
+
// Single scan for all modes: ONE read per file (the old code read every
|
|
125
|
+
// file twice — counts pass, then content pass). Counts are matching LINES
|
|
126
|
+
// per file (ripgrep --count semantics); content hits accumulate inline in
|
|
127
|
+
// the same alpha order the two-pass scan produced, so outputs are
|
|
128
|
+
// byte-identical with half the I/O.
|
|
129
|
+
const collectHits = mode === "content";
|
|
130
|
+
const sorted = files.sort();
|
|
131
|
+
// Bulk read (bounded parallelism), then regex sequentially: I/O overlaps
|
|
132
|
+
// while outputs and first-failure errors keep exact alpha order.
|
|
133
|
+
const bodies = await mapLimit(sorted, SCAN_CONCURRENCY, async (rel) => {
|
|
134
|
+
if (include && !matchesGlob(include, rel))
|
|
135
|
+
return null;
|
|
136
|
+
try {
|
|
137
|
+
const text = await fsp.readFile(path.resolve(cwd, rel), "utf8");
|
|
138
|
+
if (text.includes("\0"))
|
|
139
|
+
return null; // binary — skip
|
|
140
|
+
return { rel, text };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null; // unreadable — skip
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
const counts = [];
|
|
147
|
+
const hits = [];
|
|
148
|
+
let hitsCapped = false;
|
|
149
|
+
for (const body of bodies) {
|
|
150
|
+
if (body === null)
|
|
151
|
+
continue;
|
|
152
|
+
if (collectHits && hitsCapped)
|
|
153
|
+
break;
|
|
154
|
+
const { rel, text } = body;
|
|
155
|
+
const lines = text.split("\n");
|
|
156
|
+
let n = 0;
|
|
157
|
+
for (let i = 0; i < lines.length; i++) {
|
|
158
|
+
let matched;
|
|
159
|
+
try {
|
|
160
|
+
matched = re.test(lines[i]);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return err(`regex failed on input: ${args.pattern}`);
|
|
164
|
+
}
|
|
165
|
+
// Reset lastIndex in case the pattern is global/sticky.
|
|
166
|
+
re.lastIndex = 0;
|
|
167
|
+
if (!matched)
|
|
168
|
+
continue;
|
|
169
|
+
n += 1;
|
|
170
|
+
if (collectHits && !hitsCapped) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
hits.push(`${rel}:${i + 1}: ${line.length > 200 ? line.slice(0, 200) + "…" : line}`);
|
|
173
|
+
if (hits.length >= GREP_MATCH_CAP)
|
|
174
|
+
hitsCapped = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (n > 0)
|
|
178
|
+
counts.push({ rel, n });
|
|
179
|
+
}
|
|
180
|
+
if (counts.length === 0)
|
|
181
|
+
return "No matches.";
|
|
182
|
+
if (mode === "files_with_matches") {
|
|
183
|
+
const withTime = await Promise.all(counts.map(async (c) => ({ rel: c.rel, t: await mtimeMs(path.resolve(cwd, c.rel)) })));
|
|
184
|
+
withTime.sort((a, b) => b.t - a.t || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
185
|
+
const listed = withTime.slice(0, GREP_MATCH_CAP).map((c) => c.rel);
|
|
186
|
+
let out = `Found ${withTime.length} file(s)\n${listed.join("\n")}`;
|
|
187
|
+
if (withTime.length > GREP_MATCH_CAP)
|
|
188
|
+
out += "\n[truncated: more than 100 matching files]";
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
if (mode === "count") {
|
|
192
|
+
const listed = counts.slice(0, GREP_MATCH_CAP);
|
|
193
|
+
const total = counts.reduce((s, c) => s + c.n, 0);
|
|
194
|
+
let out = listed.map((c) => `${c.rel}:${c.n}`).join("\n") +
|
|
195
|
+
`\nFound ${total} total match(es) across ${counts.length} file(s).`;
|
|
196
|
+
if (counts.length > GREP_MATCH_CAP)
|
|
197
|
+
out += "\n[truncated: more than 100 matching files]";
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
// Content hits accumulated inline above (same order, same cap).
|
|
201
|
+
if (hitsCapped)
|
|
202
|
+
return hits.join("\n") + "\n[truncated: more than 100 matches]";
|
|
203
|
+
return hits.length > 0 ? hits.join("\n") : "No matches.";
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// List paths matching pattern under dir, newest-first by modification
|
|
210
|
+
// time (opencode/Claude parity: recency ≈ relevance), capped at 200.
|
|
211
|
+
export async function globTool(args, cwd = process.cwd()) {
|
|
212
|
+
try {
|
|
213
|
+
if (typeof args?.pattern !== "string" || args.pattern.length === 0) {
|
|
214
|
+
return err("pattern must be a non-empty string");
|
|
215
|
+
}
|
|
216
|
+
const dir = args.dir ?? ".";
|
|
217
|
+
const r = resolveSandbox(dir, cwd);
|
|
218
|
+
if (r.error || !r.abs)
|
|
219
|
+
return r.error ?? err("bad dir");
|
|
220
|
+
let st;
|
|
221
|
+
try {
|
|
222
|
+
st = await fsp.stat(r.abs);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return err(`no such directory: ${dir}`);
|
|
226
|
+
}
|
|
227
|
+
if (!st.isDirectory())
|
|
228
|
+
return err(`not a directory: ${dir}`);
|
|
229
|
+
const files = await listFiles(r.abs, cwd);
|
|
230
|
+
const matched = files.filter((rel) => matchesGlob(args.pattern, rel));
|
|
231
|
+
const withTime = await Promise.all(matched.map(async (rel) => ({ rel, t: await mtimeMs(path.resolve(cwd, rel)) })));
|
|
232
|
+
withTime.sort((a, b) => b.t - a.t || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
233
|
+
const capped = withTime.slice(0, GLOB_MATCH_CAP).map((e) => e.rel);
|
|
234
|
+
let out = capped.length > 0 ? capped.join("\n") : "No matches.";
|
|
235
|
+
if (withTime.length > GLOB_MATCH_CAP)
|
|
236
|
+
out += "\n[truncated: more than 200 matches]";
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Tool kernel: byte/path caps, error formatters, and sandbox-free path
|
|
2
|
+
// resolution shared by every executor. No executor state, no side effects.
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
export const READ_CHAR_CAP = 64 * 1024;
|
|
5
|
+
export const OUTPUT_CAP = 8 * 1024;
|
|
6
|
+
export const GREP_MATCH_CAP = 100;
|
|
7
|
+
export const GLOB_MATCH_CAP = 200;
|
|
8
|
+
export const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
9
|
+
export function err(msg) {
|
|
10
|
+
return `Error: ${msg}`;
|
|
11
|
+
}
|
|
12
|
+
// Model-mistake framing helper: `Error: invalid call: <detail> Fix the
|
|
13
|
+
// arguments and retry.` — the tool never ran. Tool-runtime failures keep
|
|
14
|
+
// plain `Error: <detail>`.
|
|
15
|
+
export function invalidCall(detail) {
|
|
16
|
+
const d = detail.endsWith(".") ? detail : `${detail}.`;
|
|
17
|
+
return `Error: invalid call: ${d} Fix the arguments and retry.`;
|
|
18
|
+
}
|
|
19
|
+
// Resolve a user-supplied path: relative paths resolve against cwd, and
|
|
20
|
+
// absolute paths (plus `..` escapes) are allowed anywhere on the computer
|
|
21
|
+
// (Claude-Code-style permissions model — the mode system, not a path
|
|
22
|
+
// sandbox, is the control plane). Only empty/non-string paths and null
|
|
23
|
+
// bytes are rejected.
|
|
24
|
+
export function resolveSandbox(p, cwd = process.cwd()) {
|
|
25
|
+
if (typeof p !== "string" || p.length === 0) {
|
|
26
|
+
return { error: "Error: path must be a non-empty string" };
|
|
27
|
+
}
|
|
28
|
+
if (p.includes("\0"))
|
|
29
|
+
return { error: "Error: invalid path" };
|
|
30
|
+
return { abs: path.resolve(cwd, p) };
|
|
31
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Shell executors: foreground bash and detached background tasks.
|
|
2
|
+
// No sandbox beyond cwd+timeout — the approval layer owns the privilege
|
|
3
|
+
// decision. Shell output passes through secret scrubbing (see policy.ts).
|
|
4
|
+
import { exec, spawn } from "node:child_process";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import { promises as fsp } from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { scrubSecrets } from "../policy.js";
|
|
11
|
+
import { PROVIDERS } from "../providers.js";
|
|
12
|
+
import { clearDirListingCache } from "./dir-cache.js";
|
|
13
|
+
import { appendOverflow } from "./overflow.js";
|
|
14
|
+
import { err, OUTPUT_CAP } from "./shared.js";
|
|
15
|
+
// ---- Background bash tasks (Claude-Code-style run_in_background) ----
|
|
16
|
+
const BG_TASK_CAP = 20;
|
|
17
|
+
const BG_POLL_MS = 100;
|
|
18
|
+
// Insertion-ordered: the first key is the oldest task (Map preserves
|
|
19
|
+
// insertion order). Finished tasks stay readable until pruned.
|
|
20
|
+
const bgTasks = new Map();
|
|
21
|
+
let bgCounter = 0;
|
|
22
|
+
function bgDir() {
|
|
23
|
+
return path.join(os.tmpdir(), "atom-tasks");
|
|
24
|
+
}
|
|
25
|
+
function newBgId() {
|
|
26
|
+
for (;;) {
|
|
27
|
+
bgCounter += 1;
|
|
28
|
+
const id = `${Date.now().toString(36)}${bgCounter.toString(36)}${randomBytes(3).toString("hex")}`;
|
|
29
|
+
if (!bgTasks.has(id))
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Keep the last ~20 task records in memory; prune older temp files
|
|
34
|
+
// best-effort (a still-running task's files may be recreated by later
|
|
35
|
+
// output — its append guard below stops that once pruned).
|
|
36
|
+
function pruneBgTasks() {
|
|
37
|
+
while (bgTasks.size > BG_TASK_CAP) {
|
|
38
|
+
const oldest = bgTasks.keys().next();
|
|
39
|
+
if (oldest.done)
|
|
40
|
+
return;
|
|
41
|
+
const key = oldest.value;
|
|
42
|
+
const rec = bgTasks.get(key);
|
|
43
|
+
bgTasks.delete(key);
|
|
44
|
+
if (rec) {
|
|
45
|
+
for (const f of [rec.stdoutFile, rec.stderrFile]) {
|
|
46
|
+
try {
|
|
47
|
+
fs.rmSync(f, { force: true });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// best-effort
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Spawn in the background (unref'd, stdin ignored, stdout/stderr piped
|
|
57
|
+
// and appended to temp files) and return IMMEDIATELY. The process runs
|
|
58
|
+
// independent of the loop; poll it with bash_output.
|
|
59
|
+
// Windows note: `detached: true` drops child output on Windows (verified:
|
|
60
|
+
// detached cmd.exe children exit 0 with empty captures, for both fd and
|
|
61
|
+
// pipe stdio), so Windows spawns attached — still unref'd with stdin
|
|
62
|
+
// ignored, so the observable contract (immediate return, independent run,
|
|
63
|
+
// output to temp files) is unchanged. POSIX keeps detached:true so
|
|
64
|
+
// background tasks are shielded from Ctrl+C in a new process group.
|
|
65
|
+
// Chunks are appended synchronously so that once `close` marks the task
|
|
66
|
+
// finished, every byte is already on disk for bash_output — no flush race.
|
|
67
|
+
async function startBackgroundBash(command, cwd) {
|
|
68
|
+
try {
|
|
69
|
+
const dir = bgDir();
|
|
70
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
71
|
+
const id = newBgId();
|
|
72
|
+
const stdoutFile = path.join(dir, `${id}.stdout.log`);
|
|
73
|
+
const stderrFile = path.join(dir, `${id}.stderr.log`);
|
|
74
|
+
await fsp.writeFile(stdoutFile, "", "utf8");
|
|
75
|
+
await fsp.writeFile(stderrFile, "", "utf8");
|
|
76
|
+
const rec = {
|
|
77
|
+
id,
|
|
78
|
+
stdoutFile,
|
|
79
|
+
stderrFile,
|
|
80
|
+
running: true,
|
|
81
|
+
exitCode: null,
|
|
82
|
+
};
|
|
83
|
+
let child;
|
|
84
|
+
try {
|
|
85
|
+
child = spawn(command, {
|
|
86
|
+
cwd,
|
|
87
|
+
shell: true,
|
|
88
|
+
detached: process.platform !== "win32",
|
|
89
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
95
|
+
}
|
|
96
|
+
bgTasks.set(id, rec);
|
|
97
|
+
pruneBgTasks();
|
|
98
|
+
const append = (file, chunk) => {
|
|
99
|
+
// A pruned (evicted) task is unpollable: stop growing its files.
|
|
100
|
+
if (bgTasks.get(id) !== rec)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
fs.appendFileSync(file, chunk);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// best-effort: a failed append must never break the task
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
child.stdout?.on("data", (d) => append(stdoutFile, d));
|
|
110
|
+
child.stderr?.on("data", (d) => append(stderrFile, d));
|
|
111
|
+
child.on("error", () => {
|
|
112
|
+
rec.running = false;
|
|
113
|
+
if (rec.exitCode === null)
|
|
114
|
+
rec.exitCode = 1;
|
|
115
|
+
// `close` may still follow; it overwrites with the real code.
|
|
116
|
+
});
|
|
117
|
+
// `close` (not `exit`): all piped output has been received, and the
|
|
118
|
+
// synchronous appends above mean it is already on disk.
|
|
119
|
+
child.on("close", (code) => {
|
|
120
|
+
rec.running = false;
|
|
121
|
+
rec.exitCode = typeof code === "number" ? code : 1;
|
|
122
|
+
});
|
|
123
|
+
child.unref();
|
|
124
|
+
// A spawned command can touch anything (files, trees, checkouts): the
|
|
125
|
+
// directory-listing cache cannot know what changed, so drop it all. Cheap
|
|
126
|
+
// (next search rescans once) and exactly correct for tool-driven flows;
|
|
127
|
+
// only out-of-process edits stay TTL-bound.
|
|
128
|
+
try {
|
|
129
|
+
clearDirListingCache();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// never break the tool
|
|
133
|
+
}
|
|
134
|
+
return JSON.stringify({ backgroundTaskId: id, status: "running", hint: "use bash_output to poll" });
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function sleepMs(ms) {
|
|
141
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
142
|
+
}
|
|
143
|
+
// Known provider secrets (live env values) for shell-output scrubbing. Shell
|
|
144
|
+
// inherits our environment, so `env`/`printenv` — or an echoed pasted key —
|
|
145
|
+
// would otherwise hand secrets to the model and into saved transcripts.
|
|
146
|
+
// Exported for tests. Stored keys (~/.atom/auth.json) are NOT covered, only
|
|
147
|
+
// env-provided values; short values are skipped by scrubSecrets itself.
|
|
148
|
+
export function providerSecrets() {
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const p of PROVIDERS) {
|
|
151
|
+
for (const name of p.envVars) {
|
|
152
|
+
const v = process.env[name];
|
|
153
|
+
if (typeof v === "string" && v.length > 0)
|
|
154
|
+
out.push(v);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function capBgStream(s, which) {
|
|
160
|
+
// Presentation-layer scrub: background output files on disk keep raw bytes
|
|
161
|
+
// (chunked appends cannot redact safely); everything the model sees via
|
|
162
|
+
// bash_output passes through here.
|
|
163
|
+
const clean = scrubSecrets(s, providerSecrets());
|
|
164
|
+
if (clean.length > OUTPUT_CAP) {
|
|
165
|
+
const full = clean;
|
|
166
|
+
return appendOverflow(full.slice(0, OUTPUT_CAP), `\n[truncated: ${which} exceeded 8KB]`, `background ${which}`, full);
|
|
167
|
+
}
|
|
168
|
+
return clean;
|
|
169
|
+
}
|
|
170
|
+
// Poll a background task. When running and timeoutMs > 0, waits (polling
|
|
171
|
+
// the output files about every 100ms) until exit or the wait expires.
|
|
172
|
+
// Error strings, never throws.
|
|
173
|
+
export async function bashOutputTool(args) {
|
|
174
|
+
try {
|
|
175
|
+
const taskId = typeof args?.taskId === "string" ? args.taskId : "";
|
|
176
|
+
const rec = bgTasks.get(taskId);
|
|
177
|
+
if (!rec)
|
|
178
|
+
return err("unknown background task");
|
|
179
|
+
const t = args?.timeoutMs;
|
|
180
|
+
const timeoutMs = typeof t === "number" && Number.isFinite(t) ? Math.min(Math.max(Math.floor(t), 0), 60000) : 5000;
|
|
181
|
+
const start = Date.now();
|
|
182
|
+
while (rec.running && Date.now() - start < timeoutMs) {
|
|
183
|
+
await sleepMs(Math.min(BG_POLL_MS, Math.max(timeoutMs - (Date.now() - start), 1)));
|
|
184
|
+
}
|
|
185
|
+
let stdout = "";
|
|
186
|
+
let stderr = "";
|
|
187
|
+
try {
|
|
188
|
+
stdout = await fsp.readFile(rec.stdoutFile, "utf8");
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
stdout = "";
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
stderr = await fsp.readFile(rec.stderrFile, "utf8");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
stderr = "";
|
|
198
|
+
}
|
|
199
|
+
return JSON.stringify({
|
|
200
|
+
taskId: rec.id,
|
|
201
|
+
running: rec.running,
|
|
202
|
+
exitCode: rec.running ? null : rec.exitCode,
|
|
203
|
+
stdout: capBgStream(stdout, "stdout"),
|
|
204
|
+
stderr: capBgStream(stderr, "stderr"),
|
|
205
|
+
timedOut: rec.running && timeoutMs > 0,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Run in the system shell with cwd=process.cwd() (or the caller's cwd),
|
|
213
|
+
// stdin closed. stdout/stderr each truncated to ~8KB. Returns JSON:
|
|
214
|
+
// {"exitCode": number, "stdout": string, "stderr": string, ...}.
|
|
215
|
+
// No sandbox beyond cwd+timeout+truncation — the model must treat this
|
|
216
|
+
// as a privileged operation.
|
|
217
|
+
export function bashTool(args, cwd = process.cwd()) {
|
|
218
|
+
if (typeof args?.command !== "string" || args.command.trim().length === 0) {
|
|
219
|
+
return Promise.resolve(err("command must be a non-empty string"));
|
|
220
|
+
}
|
|
221
|
+
if (args.runInBackground === true) {
|
|
222
|
+
return startBackgroundBash(args.command, cwd);
|
|
223
|
+
}
|
|
224
|
+
const timeoutMs = Math.min(Math.max(Math.floor(args.timeoutMs ?? 60000), 1), 120000);
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
exec(args.command, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
227
|
+
// The command ran (whatever its exit): it may have mutated the tree,
|
|
228
|
+
// so the listing cache is dropped (see background path above).
|
|
229
|
+
try {
|
|
230
|
+
clearDirListingCache();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// never break the tool
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const e = error;
|
|
237
|
+
const exitCode = e ? (typeof e.code === "number" ? e.code : 1) : 0;
|
|
238
|
+
let out = typeof stdout === "string" ? stdout : String(stdout ?? "");
|
|
239
|
+
let errText = typeof stderr === "string" ? stderr : String(stderr ?? "");
|
|
240
|
+
// Scrub BEFORE truncation/spill: overflow files must never persist raw
|
|
241
|
+
// secrets to disk either.
|
|
242
|
+
const secrets = providerSecrets();
|
|
243
|
+
if (secrets.length > 0) {
|
|
244
|
+
out = scrubSecrets(out, secrets);
|
|
245
|
+
errText = scrubSecrets(errText, secrets);
|
|
246
|
+
}
|
|
247
|
+
let stdoutTruncated = false;
|
|
248
|
+
let stderrTruncated = false;
|
|
249
|
+
if (out.length > OUTPUT_CAP) {
|
|
250
|
+
const full = out;
|
|
251
|
+
out = appendOverflow(full.slice(0, OUTPUT_CAP), "\n[truncated: stdout exceeded 8KB]", "command stdout", full);
|
|
252
|
+
stdoutTruncated = true;
|
|
253
|
+
}
|
|
254
|
+
if (errText.length > OUTPUT_CAP) {
|
|
255
|
+
const full = errText;
|
|
256
|
+
errText = appendOverflow(full.slice(0, OUTPUT_CAP), "\n[truncated: stderr exceeded 8KB]", "command stderr", full);
|
|
257
|
+
stderrTruncated = true;
|
|
258
|
+
}
|
|
259
|
+
resolve(JSON.stringify({
|
|
260
|
+
exitCode,
|
|
261
|
+
stdout: out,
|
|
262
|
+
stderr: errText,
|
|
263
|
+
timedOut: e?.killed === true,
|
|
264
|
+
stdoutTruncated,
|
|
265
|
+
stderrTruncated,
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
catch (ex) {
|
|
269
|
+
resolve(err(ex instanceof Error ? ex.message : String(ex)));
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|