@synqa/cli 0.0.0-stage → 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/dist/theme.js ADDED
@@ -0,0 +1,106 @@
1
+ // The Synqa palette and every colour escape the CLI emits. This file is the only place that builds
2
+ // an escape sequence for colour; text from the model or from tools never reaches it as a code.
3
+ // 24-bit colour when the terminal says it has it (COLORTERM=truecolor|24bit), otherwise the nearest
4
+ // xterm-256 index. NO_COLOR, a non-TTY or TERM=dumb turn all of it off.
5
+
6
+
7
+
8
+ export const TERRACOTTA = [0xc8, 0x5a, 0x32]; // #c85a32 accent: the period, spinner, prompt, monogram
9
+ export const BONE = [0xf0, 0xea, 0xd8]; // #f0ead8 warm bone: name and active values
10
+ export const STONE = [0x9e, 0x95, 0x85]; // #9e9585 warm stone: borders, labels, footer
11
+ export const SAGE = [0x4a, 0x7c, 0x59]; // #4a7c59 sage: balance highlights and added diff lines
12
+
13
+
14
+
15
+ // depth is optional so a bare { color: true } keeps meaning 24-bit.
16
+
17
+
18
+ export function colorEnabled(env , isTTY ) {
19
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false;
20
+ if (env.TERM === "dumb") return false;
21
+ return isTTY;
22
+ }
23
+
24
+ export function colorDepth(env ) {
25
+ const c = (env.COLORTERM ?? "").toLowerCase();
26
+ return c === "truecolor" || c === "24bit" ? 24 : 8;
27
+ }
28
+
29
+ const CUBE = [0, 95, 135, 175, 215, 255] ;
30
+
31
+ // Nearest xterm-256 colour: the best of the 6x6x6 cube (16..231) and the grey ramp (232..255).
32
+ export function rgbTo256(rgb ) {
33
+ const [r, g, b] = rgb;
34
+ const nearest = (v ) => {
35
+ let best = 0;
36
+ for (let i = 1; i < CUBE.length; i++) {
37
+ if (Math.abs((CUBE[i] ) - v) < Math.abs((CUBE[best] ) - v)) best = i;
38
+ }
39
+ return best;
40
+ };
41
+ const ri = nearest(r);
42
+ const gi = nearest(g);
43
+ const bi = nearest(b);
44
+ const cubeIdx = 16 + 36 * ri + 6 * gi + bi;
45
+ const dist = (x ) => (x[0] - r) ** 2 + (x[1] - g) ** 2 + (x[2] - b) ** 2;
46
+ const cubeDist = dist([CUBE[ri] , CUBE[gi] , CUBE[bi] ]);
47
+ const grey = Math.min(23, Math.max(0, Math.round(((r + g + b) / 3 - 8) / 10)));
48
+ const level = 8 + 10 * grey;
49
+ const greyDist = dist([level, level, level]);
50
+ return greyDist < cubeDist ? 232 + grey : cubeIdx;
51
+ }
52
+
53
+ const RESET = "\x1b[0m";
54
+
55
+ // The SGR parameters that select `rgb` as the foreground.
56
+ export function fgParams(depth , rgb ) {
57
+ return depth === 8 ? `38;5;${rgbTo256(rgb)}` : `38;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
58
+ }
59
+
60
+ function paint(s , params , text ) {
61
+ return s.color ? `\x1b[${params}m${text}${RESET}` : text;
62
+ }
63
+
64
+ export function terracotta(s , text ) {
65
+ return paint(s, fgParams(s.depth, TERRACOTTA), text);
66
+ }
67
+
68
+ export function boneBold(s , text ) {
69
+ return paint(s, `1;${fgParams(s.depth, BONE)}`, text);
70
+ }
71
+
72
+ export function stone(s , text ) {
73
+ return paint(s, fgParams(s.depth, STONE), text);
74
+ }
75
+
76
+ // Stone and bold together: labels stay readable on a light terminal background.
77
+ export function stoneBold(s , text ) {
78
+ return paint(s, `1;${fgParams(s.depth, STONE)}`, text);
79
+ }
80
+
81
+ export function sage(s , text ) {
82
+ return paint(s, fgParams(s.depth, SAGE), text);
83
+ }
84
+
85
+ // Kept names: the accent is terracotta.
86
+ export const accent = terracotta;
87
+
88
+ export function dim(s , text ) {
89
+ return paint(s, "2", text);
90
+ }
91
+
92
+ export function bold(s , text ) {
93
+ return paint(s, "1", text);
94
+ }
95
+
96
+ export function green(s , text ) {
97
+ return paint(s, "32", text);
98
+ }
99
+
100
+ export function red(s , text ) {
101
+ return paint(s, "31", text);
102
+ }
103
+
104
+ export function cyan(s , text ) {
105
+ return paint(s, "36", text);
106
+ }
package/dist/tiers.js ADDED
@@ -0,0 +1,53 @@
1
+
2
+
3
+ export const TIERS = [
4
+ { key: "blink", id: "synqa-blink-5.5", display: "Blink 5.5" },
5
+ { key: "sonata", id: "synqa-sonata-6.0", display: "Sonata 6.0" },
6
+ { key: "aeon", id: "synqa-aeon-6.5", display: "Aeon 6.5" },
7
+ ];
8
+
9
+ export const DEFAULT_TIER = "blink";
10
+
11
+ // Reasoning effort the user can pick. "default" sends no reasoning_effort at all.
12
+ export const EFFORTS = ["default", "low", "medium"] ;
13
+
14
+
15
+ export function findEffort(name ) {
16
+ const n = name.trim().toLowerCase();
17
+ return EFFORTS.find((e) => e === n);
18
+ }
19
+ export const CONTEXT_TOKENS = 262144;
20
+ export const MAX_OUTPUT_TOKENS = 16384;
21
+ export const REQUEST_TIMEOUT_MS = 600_000;
22
+
23
+ export function findTier(name ) {
24
+ const n = name.trim().toLowerCase();
25
+ return TIERS.find((t) => t.key === n || t.id === n);
26
+ }
27
+
28
+ // Map the x-synqa-routing header to what the status bar shows.
29
+ // local and local-gated -> local, hybrid -> hybrid, fallback-* -> fallback, absent -> "?".
30
+ export function routingLabel(header ) {
31
+ if (!header) return "?";
32
+ const v = header.trim().toLowerCase();
33
+ if (v === "local" || v === "local-gated") return "local";
34
+ if (v === "hybrid") return "hybrid";
35
+ if (v.startsWith("fallback")) return "fallback";
36
+ return "?";
37
+ }
38
+
39
+
40
+
41
+ export function usageFrom(raw ) {
42
+ if (!raw || typeof raw !== "object") return undefined;
43
+ const r = raw ;
44
+ const p = r.prompt_tokens;
45
+ const c = r.completion_tokens;
46
+ if (typeof p !== "number" && typeof c !== "number") return undefined;
47
+ return { prompt: typeof p === "number" ? p : 0, completion: typeof c === "number" ? c : 0 };
48
+ }
49
+
50
+ export function statusLine(tier , routing , usage , total ) {
51
+ const ctx = usage.prompt > 0 ? ` | ctx ${usage.prompt}/${CONTEXT_TOKENS}` : "";
52
+ return `${tier.display} | ${routing} | tokens ${total.prompt} in ${total.completion} out${ctx}`;
53
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,332 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { closeSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import { MAX_DIFF_LINES, renderDiff, unifiedDiff } from "./diff.js";
5
+ import { snapshot } from "./journal.js";
6
+
7
+ import { commandTouchesSecrets, confine, fold, isSensitivePath, writeConfined } from "./paths.js";
8
+ import { visibleEscapes } from "./sanitize.js";
9
+
10
+ const MAX_FILE_BYTES = 1_000_000;
11
+ const MAX_PATTERN = 200;
12
+
13
+ // Reads at most MAX_FILE_BYTES of a regular file; stats first so a huge file is never loaded.
14
+ function readCapped(p ) {
15
+ const st = statSync(p);
16
+ if (!st.isFile()) throw new Error("not a regular file");
17
+ const len = Math.min(st.size, MAX_FILE_BYTES);
18
+ const buf = Buffer.alloc(len);
19
+ const fd = openSync(p, "r");
20
+ try {
21
+ readSync(fd, buf, 0, len, 0);
22
+ } finally {
23
+ closeSync(fd);
24
+ }
25
+ const text = buf.toString("utf8");
26
+ return st.size > MAX_FILE_BYTES ? text + `\n[file truncated at ${MAX_FILE_BYTES} bytes]` : text;
27
+ }
28
+
29
+ // Child processes get the parent environment minus every SYNQA_* variable (the API key).
30
+ export function bashEnv(env ) {
31
+ const out = {};
32
+ for (const [k, v] of Object.entries(env)) {
33
+ if (v !== undefined && !k.toUpperCase().startsWith("SYNQA_")) out[k] = v;
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // `detail` is text this module built (a coloured diff); the caller prints it as is, while the
39
+ // question is model-influenced and must be sanitized by the caller.
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+ const MAX_OUT = 20_000;
52
+ const MAX_RESULTS = 500;
53
+ const SKIP_DIRS = new Set([".git", "node_modules", ".ssh", ".aws", ".gnupg", ".kube"]);
54
+
55
+ export const TOOL_DEFS = [
56
+ def("read_file", "Read a text file inside the working directory.", {
57
+ path: { type: "string", description: "File path relative to the working directory" },
58
+ }, ["path"]),
59
+ def("write_file", "Create or overwrite a file (asks the user first).", {
60
+ path: { type: "string" },
61
+ content: { type: "string" },
62
+ }, ["path", "content"]),
63
+ def("edit_file", "Replace one exact occurrence of old_string with new_string (asks the user first).", {
64
+ path: { type: "string" },
65
+ old_string: { type: "string" },
66
+ new_string: { type: "string" },
67
+ }, ["path", "old_string", "new_string"]),
68
+ def("glob", "List files matching a glob pattern such as src/**/*.ts.", {
69
+ pattern: { type: "string" },
70
+ }, ["pattern"]),
71
+ def("grep", "Search file contents with a regular expression.", {
72
+ pattern: { type: "string" },
73
+ path: { type: "string", description: "Optional directory or file to search" },
74
+ }, ["pattern"]),
75
+ def("bash", "Run a shell command in the working directory (asks the user first).", {
76
+ command: { type: "string" },
77
+ }, ["command"]),
78
+ ];
79
+
80
+ function def(name , description , properties , required ) {
81
+ return { type: "function", function: { name, description, parameters: { type: "object", properties, required } } };
82
+ }
83
+
84
+ function truncate(s ) {
85
+ return s.length > MAX_OUT ? s.slice(0, MAX_OUT) + `\n[truncated ${s.length - MAX_OUT} chars]` : s;
86
+ }
87
+
88
+ export function globToRegExp(pattern ) {
89
+ let re = "";
90
+ for (let i = 0; i < pattern.length; i++) {
91
+ const c = pattern[i] ;
92
+ if (c === "*") {
93
+ if (pattern[i + 1] === "*") {
94
+ i++;
95
+ if (pattern[i + 1] === "/") {
96
+ i++;
97
+ re += "(?:.*/)?";
98
+ } else re += ".*";
99
+ } else re += "[^/]*";
100
+ } else if (c === "?") re += "[^/]";
101
+ else if (c === "{") {
102
+ const end = pattern.indexOf("}", i);
103
+ if (end > i) {
104
+ re += "(?:" + pattern.slice(i + 1, end).split(",").map(escapeRe).join("|") + ")";
105
+ i = end;
106
+ } else re += "\\{";
107
+ } else re += escapeRe(c);
108
+ }
109
+ return new RegExp("^" + re + "$");
110
+ }
111
+
112
+ function escapeRe(s ) {
113
+ return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
114
+ }
115
+
116
+ function walk(root , dir , out ) {
117
+ if (out.length >= MAX_RESULTS * 20) return;
118
+ let entries;
119
+ try {
120
+ entries = readdirSync(dir, { withFileTypes: true });
121
+ } catch {
122
+ return;
123
+ }
124
+ for (const e of entries) {
125
+ if (SKIP_DIRS.has(fold(e.name))) continue;
126
+ const full = join(dir, e.name);
127
+ if (e.isDirectory()) walk(root, full, out);
128
+ else if (e.isFile()) out.push(relative(root, full).split(sep).join("/"));
129
+ }
130
+ }
131
+
132
+ const RC_FILES = new Set([".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile", ".zshenv", ".bash_login"]);
133
+
134
+ // Why an approved write to this target can run code later (hooks, build files, shell startup files,
135
+ // CI, editor tasks, executables). Compared folded, so ".GIT/hooks" counts as ".git/hooks".
136
+ export function riskReasons(root , abs ) {
137
+ const rel = fold(relative(root, abs).split(sep).join("/"));
138
+ const parts = rel.split("/");
139
+ const base = parts[parts.length - 1] ?? "";
140
+ const dirs = parts.slice(0, -1);
141
+ const reasons = [];
142
+ if (rel.startsWith(".git/hooks/") || rel.includes("/.git/hooks/")) reasons.push("a git hook (runs on git actions)");
143
+ else if (dirs.includes(".git")) reasons.push("inside .git");
144
+ if (base === "package.json") reasons.push("package.json (scripts can run on install or test)");
145
+ if (base === "makefile" || base === "gnumakefile") reasons.push("a Makefile (make runs it)");
146
+ if (base === ".envrc") reasons.push(".envrc (direnv runs it when you enter the folder)");
147
+ if (dirs.includes(".vscode") && base.endsWith(".json")) reasons.push("VS Code settings (can define tasks that run)");
148
+ if (rel.startsWith(".github/workflows/") || rel.includes("/.github/workflows/")) reasons.push("a CI workflow");
149
+ if (dirs.includes(".devcontainer") && base.endsWith(".json")) reasons.push("a dev container config (runs commands when the container is built)");
150
+ if (base === "conftest.py") reasons.push("conftest.py (pytest runs it)");
151
+ if (base === "setup.py") reasons.push("setup.py (runs on install)");
152
+ if (base === "pyproject.toml") reasons.push("pyproject.toml (build backends and scripts run from it)");
153
+ if (dirs.includes(".cargo") && base.startsWith("config")) reasons.push("a Cargo config (can set the compiler or a runner)");
154
+ if (base === "build.rs") reasons.push("build.rs (cargo runs it)");
155
+ if (base === "justfile" || base === ".justfile") reasons.push("a justfile (just runs it)");
156
+ if (base === "rakefile" || base === "rakefile.rb") reasons.push("a Rakefile (rake runs it)");
157
+ if (dirs.includes(".husky")) reasons.push("a husky hook (runs on git actions)");
158
+ if (RC_FILES.has(base)) reasons.push("a shell startup file (runs in every new shell)");
159
+ else if (base.startsWith(".")) reasons.push("a dotfile");
160
+ try {
161
+ const st = statSync(abs);
162
+ if (st.isFile() && (st.mode & 0o111) !== 0) reasons.push("an executable file");
163
+ } catch {
164
+ // The file does not exist yet: nothing to check.
165
+ }
166
+ return reasons;
167
+ }
168
+
169
+ export function riskNote(root , abs ) {
170
+ const reasons = riskReasons(root, abs);
171
+ return reasons.length ? `\n WARNING: this is ${reasons.join(", ")}; an approved write can run code later.` : "";
172
+ }
173
+
174
+ const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*}](?:[^()\\]|\\.)*\)[+*{]/;
175
+
176
+ // Heuristic ReDoS guard: refuse a quantified group that itself contains a quantifier, e.g. (a+)+.
177
+ // It does not catch every pathological pattern (see SPEC); lines are also cut to 2000 characters.
178
+ export function hasNestedQuantifier(pattern ) {
179
+ return NESTED_QUANTIFIER.test(pattern);
180
+ }
181
+
182
+ // A file that holds secrets is opened only after an explicit yes. With --yes, or with nobody to ask,
183
+ // the answer is no: a prompt injection must not be able to read keys by running unattended. Every
184
+ // tool that reads a file's bytes (read_file, edit_file) goes through this before opening it.
185
+ async function sensitiveGate(ctx , p , verb ) {
186
+ if (!isSensitivePath(p)) return undefined;
187
+ if (ctx.yes) return "error: refused: this file may hold secrets and --yes never opens those";
188
+ if (!(await ctx.confirm(`${verb} ${visibleEscapes(p)}? It may hold secrets, and its text would be sent to the model.`))) return "denied by user";
189
+ return undefined;
190
+ }
191
+
192
+ async function toolRead(ctx , a ) {
193
+ const p = confine(ctx.cwd, str(a, "path"));
194
+ const refused = await sensitiveGate(ctx, p, "Read");
195
+ if (refused) return refused;
196
+ return truncate(readCapped(p));
197
+ }
198
+
199
+
200
+
201
+ // Shows the change (the question gets the diff as `detail`; with --yes the diff is only shown).
202
+ async function approve(ctx , question , path , before , after ) {
203
+ const rel = relative(ctx.cwd, path) || path;
204
+ // A file that can run code later (hook, package.json, shell startup file, dotfile) is shown in
205
+ // full: a payload on line 60 must not hide behind a cut.
206
+ const risky = riskReasons(ctx.cwd, path).length > 0 || isSensitivePath(path);
207
+ const maxLines = risky ? Infinity : MAX_DIFF_LINES;
208
+ const detail = before === "too-large"
209
+ ? " (the existing file is too large to preview)"
210
+ : renderDiff(unifiedDiff(rel, before === null ? null : before.toString("utf8"), after), { color: ctx.color ?? false }, maxLines);
211
+ // --yes skips the question only for an ordinary file with no AGENTS.md loaded. A write that can
212
+ // run code later, a secrets file, or any write while repository text steers the model always asks
213
+ // (and is denied when nobody can answer): a shell prompt is no boundary if another tool can plant
214
+ // a hook, a Makefile or a workflow that runs without one.
215
+ if (ctx.yes && !risky && !ctx.notesLoaded) {
216
+ ctx.show?.(detail);
217
+ return true;
218
+ }
219
+ return ctx.confirm(question, detail);
220
+ }
221
+
222
+ function journalWrite(ctx , written , before , after ) {
223
+ if (before === "too-large") return " (not undoable: the old file was too large to keep)";
224
+ ctx.journal?.record({ path: written, before, after: Buffer.from(after) });
225
+ return "";
226
+ }
227
+
228
+ async function toolWrite(ctx , a ) {
229
+ const p = confine(ctx.cwd, str(a, "path"));
230
+ const content = str(a, "content");
231
+ const before = snapshot(p);
232
+ const q = `Write ${content.length} chars to ${visibleEscapes(p)}?${riskNote(ctx.cwd, p)}`;
233
+ if (!(await approve(ctx, q, p, before, content))) return "denied by user";
234
+ mkdirSync(dirname(confine(ctx.cwd, p)), { recursive: true });
235
+ const written = writeConfined(ctx.cwd, p, content);
236
+ return `wrote ${relative(ctx.cwd, written)}${journalWrite(ctx, written, before, content)}`;
237
+ }
238
+
239
+ async function toolEdit(ctx , a ) {
240
+ const p = confine(ctx.cwd, str(a, "path"));
241
+ const oldS = str(a, "old_string");
242
+ const newS = str(a, "new_string");
243
+ const refused = await sensitiveGate(ctx, p, "Edit");
244
+ if (refused) return refused;
245
+ const text = readCapped(p);
246
+ if (text.endsWith(`[file truncated at ${MAX_FILE_BYTES} bytes]`)) return "error: file too large to edit";
247
+ if (oldS === "") return "error: old_string is empty";
248
+ const first = text.indexOf(oldS);
249
+ if (first < 0) return "error: old_string not found";
250
+ if (text.indexOf(oldS, first + oldS.length) >= 0) return "error: old_string matches more than once; add context";
251
+ const next = text.slice(0, first) + newS + text.slice(first + oldS.length);
252
+ const before = snapshot(p);
253
+ const q = `Edit ${visibleEscapes(p)}?${riskNote(ctx.cwd, p)}`;
254
+ if (!(await approve(ctx, q, p, before, next))) return "denied by user";
255
+ const written = writeConfined(ctx.cwd, p, next);
256
+ return `edited ${relative(ctx.cwd, p)}${journalWrite(ctx, written, before, next)}`;
257
+ }
258
+
259
+ function toolGlob(ctx , a ) {
260
+ const re = globToRegExp(str(a, "pattern"));
261
+ const all = [];
262
+ walk(ctx.cwd, ctx.cwd, all);
263
+ const hits = all.filter((f) => re.test(f) && !isSensitivePath(join(ctx.cwd, f))).sort().slice(0, MAX_RESULTS);
264
+ return hits.length ? hits.join("\n") : "no matches";
265
+ }
266
+
267
+ function toolGrep(ctx , a ) {
268
+ const pat = str(a, "pattern");
269
+ if (pat.length > MAX_PATTERN) return `error: pattern longer than ${MAX_PATTERN} characters`;
270
+ if (hasNestedQuantifier(pat)) return "error: pattern has a quantified group containing a quantifier (nested quantifier); rewrite it";
271
+ const re = new RegExp(pat);
272
+ const base = confine(ctx.cwd, typeof a.path === "string" && a.path ? a.path : ".");
273
+ const files = [];
274
+ if (statSync(base).isDirectory()) walk(base, base, files);
275
+ else files.push("");
276
+ const out = [];
277
+ for (const f of files) {
278
+ if (out.length >= MAX_RESULTS) break;
279
+ const full = f ? join(base, f) : base;
280
+ if (isSensitivePath(full)) continue;
281
+ let text ;
282
+ try {
283
+ if (statSync(full).size > MAX_FILE_BYTES) continue;
284
+ text = readCapped(full);
285
+ } catch {
286
+ continue;
287
+ }
288
+ if (text.includes("\u0000")) continue;
289
+ text.split("\n").forEach((line, i) => {
290
+ if (out.length < MAX_RESULTS && re.test(line.slice(0, 2000))) out.push(`${relative(ctx.cwd, full)}:${i + 1}:${line.slice(0, 300)}`);
291
+ });
292
+ }
293
+ return out.length ? out.join("\n") : "no matches";
294
+ }
295
+
296
+ async function toolBash(ctx , a ) {
297
+ const command = str(a, "command");
298
+ // Asks unless --yes. Even under --yes it asks when AGENTS.md is loaded, or when the command names
299
+ // a secrets file (or a glob that could): then, with nobody to ask, it is denied. Any other command
300
+ // under --yes runs unattended: use --yes only in repositories you trust.
301
+ const secret = commandTouchesSecrets(command);
302
+ if ((!ctx.yes || ctx.notesLoaded || secret) && !(await ctx.confirm(`Run: ${visibleEscapes(command)}${secret ? "\n WARNING: this command refers to files that may hold secrets." : ""}`))) return "denied by user";
303
+ const r = spawnSync("/bin/sh", ["-c", command], {
304
+ cwd: ctx.cwd, encoding: "utf8", timeout: 120_000, maxBuffer: 4 * 1024 * 1024, env: bashEnv(process.env),
305
+ });
306
+ const body = `${r.stdout ?? ""}${r.stderr ?? ""}`;
307
+ const code = r.status === null ? `signal ${r.signal ?? "timeout"}` : `exit ${r.status}`;
308
+ return truncate(`${body}\n[${code}]`);
309
+ }
310
+
311
+ function str(a , k ) {
312
+ const v = a[k];
313
+ if (typeof v !== "string") throw new Error(`missing string argument: ${k}`);
314
+ return v;
315
+ }
316
+
317
+ // Never throws: failures come back as text so the model can react.
318
+ export async function runTool(ctx , name , args ) {
319
+ try {
320
+ switch (name) {
321
+ case "read_file": return await toolRead(ctx, args);
322
+ case "write_file": return await toolWrite(ctx, args);
323
+ case "edit_file": return await toolEdit(ctx, args);
324
+ case "glob": return toolGlob(ctx, args);
325
+ case "grep": return toolGrep(ctx, args);
326
+ case "bash": return await toolBash(ctx, args);
327
+ default: return `error: unknown tool ${name}`;
328
+ }
329
+ } catch (err) {
330
+ return `error: ${err instanceof Error ? err.message : String(err)}`;
331
+ }
332
+ }