interference-agent 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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/assets/screenshot.png +0 -0
  4. package/bun.lock +159 -0
  5. package/package.json +39 -0
  6. package/src/agent/compaction.ts +114 -0
  7. package/src/agent/loop.ts +94 -0
  8. package/src/agent/prompt.ts +89 -0
  9. package/src/agent/subagent.ts +64 -0
  10. package/src/auth.ts +50 -0
  11. package/src/cli-plain.ts +274 -0
  12. package/src/cli.ts +87 -0
  13. package/src/commands/index.ts +184 -0
  14. package/src/config-file.ts +109 -0
  15. package/src/config.ts +212 -0
  16. package/src/context.ts +96 -0
  17. package/src/cost.ts +54 -0
  18. package/src/git.ts +22 -0
  19. package/src/permissions.ts +135 -0
  20. package/src/provider.ts +58 -0
  21. package/src/session/__tests__/session.test.ts +180 -0
  22. package/src/session/snapshot.ts +122 -0
  23. package/src/session/store.ts +120 -0
  24. package/src/skills.ts +177 -0
  25. package/src/tools/__tests__/mutating.test.ts +324 -0
  26. package/src/tools/__tests__/question.test.ts +53 -0
  27. package/src/tools/__tests__/todowrite.test.ts +57 -0
  28. package/src/tools/__tests__/tools.test.ts +217 -0
  29. package/src/tools/_fs.ts +12 -0
  30. package/src/tools/bash.ts +104 -0
  31. package/src/tools/edit.ts +98 -0
  32. package/src/tools/glob.ts +40 -0
  33. package/src/tools/grep.ts +187 -0
  34. package/src/tools/index.ts +21 -0
  35. package/src/tools/ls.ts +70 -0
  36. package/src/tools/question.ts +81 -0
  37. package/src/tools/read.ts +61 -0
  38. package/src/tools/registry.ts +36 -0
  39. package/src/tools/task.ts +71 -0
  40. package/src/tools/todowrite.ts +84 -0
  41. package/src/tools/webfetch.ts +111 -0
  42. package/src/tools/write.ts +51 -0
  43. package/src/tui/App.tsx +738 -0
  44. package/src/tui/ConfirmDialog.tsx +46 -0
  45. package/src/tui/DiffView.tsx +88 -0
  46. package/src/tui/MarkdownText.tsx +63 -0
  47. package/src/tui/Message.tsx +26 -0
  48. package/src/tui/ModelPicker.tsx +44 -0
  49. package/src/tui/Panel.tsx +39 -0
  50. package/src/tui/ProviderPicker.tsx +111 -0
  51. package/src/tui/QuestionDialog.tsx +64 -0
  52. package/src/tui/SessionList.tsx +72 -0
  53. package/src/tui/SlashAutocomplete.tsx +33 -0
  54. package/src/tui/StatusFooter.tsx +71 -0
  55. package/src/tui/ThinkingPicker.tsx +57 -0
  56. package/src/tui/Toast.tsx +64 -0
  57. package/src/tui/TodoList.tsx +49 -0
  58. package/src/tui/ToolStep.tsx +184 -0
  59. package/src/tui/Welcome.tsx +87 -0
  60. package/src/tui/__tests__/tui-render.test.tsx +59 -0
  61. package/src/tui/theme.ts +16 -0
  62. package/src/tui/wordmark.ts +7 -0
  63. package/tsconfig.json +23 -0
@@ -0,0 +1,104 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { decide, requestConfirmation } from "../permissions.ts";
4
+
5
+ const OUTPUT_CAP = 30_000;
6
+ const DEFAULT_TIMEOUT_MS = 120_000;
7
+
8
+ export const bash = tool({
9
+ description:
10
+ "Execute a shell command in the workspace. " +
11
+ "Use this for git operations, running tests, installing packages, building, etc. " +
12
+ "Never use interactive commands (no -i, no editors). " +
13
+ "Explain what the command does before running it.",
14
+ inputSchema: z.object({
15
+ command: z.string().describe("The shell command to execute"),
16
+ timeout: z
17
+ .number()
18
+ .int()
19
+ .min(1_000)
20
+ .max(300_000)
21
+ .optional()
22
+ .describe("Timeout in milliseconds (default: 120000)"),
23
+ }),
24
+ execute: async ({ command, timeout }) => {
25
+ const decision = decide("bash", command);
26
+ if (decision === "deny") {
27
+ return `Command denied by policy: '${command}'`;
28
+ }
29
+ if (decision === "ask") {
30
+ const preview = `[bash] ${command}`;
31
+ const allowed = await requestConfirmation("bash", preview);
32
+ if (!allowed) {
33
+ return `Command refused by user: '${command}'`;
34
+ }
35
+ }
36
+
37
+ const ms = timeout ?? DEFAULT_TIMEOUT_MS;
38
+ const proc = Bun.spawn(["sh", "-c", command], {
39
+ cwd: process.cwd(),
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ timeout: ms,
43
+ killSignal: "SIGTERM",
44
+ });
45
+
46
+ const [stdout, stderr] = await Promise.all([
47
+ readStream(proc.stdout, OUTPUT_CAP),
48
+ readStream(proc.stderr, OUTPUT_CAP),
49
+ ]);
50
+
51
+ const exitCode = await proc.exited;
52
+
53
+ let output = "";
54
+ if (stdout.length > 0) output += stdout;
55
+ if (stderr.length > 0) {
56
+ if (output.length > 0) output += "\n";
57
+ output += "[stderr]\n" + stderr;
58
+ }
59
+
60
+ const truncated =
61
+ stdout.length >= OUTPUT_CAP || stderr.length >= OUTPUT_CAP ? " [output truncated]" : "";
62
+ const exitInfo = exitCode === 0 ? "" : ` (exit code: ${exitCode})`;
63
+
64
+ if (output.length === 0 && exitCode !== 0) {
65
+ return `Command failed (exit code: ${exitCode}) with no output.`;
66
+ }
67
+ if (output.length === 0) {
68
+ return `Command succeeded (no output).`;
69
+ }
70
+
71
+ return `${output}${truncated}${exitInfo}`;
72
+ },
73
+ });
74
+
75
+ async function readStream(
76
+ stream: ReadableStream | null,
77
+ cap: number,
78
+ ): Promise<string> {
79
+ if (!stream) return "";
80
+ const reader = (stream as ReadableStream<Uint8Array>).getReader();
81
+ const decoder = new TextDecoder();
82
+ let text = "";
83
+
84
+ try {
85
+ while (true) {
86
+ const { done, value } = await reader.read();
87
+ if (done) break;
88
+ text += decoder.decode(value, { stream: true });
89
+ if (text.length > cap) {
90
+ text = text.slice(0, cap);
91
+ reader.cancel();
92
+ break;
93
+ }
94
+ }
95
+ } catch {
96
+ // reader cancelled
97
+ }
98
+
99
+ let final: string;
100
+ try { final = decoder.decode(); } catch { final = ""; }
101
+ if (final.length > 0 && text.length < cap) text += final;
102
+
103
+ return text.slice(0, cap);
104
+ }
@@ -0,0 +1,98 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { resolveInWorkspace } from "./_fs.ts";
4
+ import { decide, requestConfirmation } from "../permissions.ts";
5
+ import { snapshotFile } from "../session/snapshot.ts";
6
+ import * as path from "node:path";
7
+
8
+ export const edit = tool({
9
+ description:
10
+ "Replace a string in a file with another string. " +
11
+ "The oldString must match EXACTLY ONCE in the file. " +
12
+ "If it matches zero or multiple times, the edit fails. " +
13
+ "Use `replaceAll: true` to replace all occurrences. " +
14
+ "Prefer this over `write` for targeted changes to existing files.",
15
+ inputSchema: z.object({
16
+ path: z.string().describe("Path to the file, relative to workspace root"),
17
+ oldString: z.string().describe("The exact string to replace"),
18
+ newString: z.string().describe("The string to replace it with"),
19
+ replaceAll: z
20
+ .boolean()
21
+ .optional()
22
+ .describe("If true, replace all occurrences instead of just one (default: false)"),
23
+ }),
24
+ execute: async ({ path: filePath, oldString, newString, replaceAll }) => {
25
+ const abs = resolveInWorkspace(filePath);
26
+ const rel = path.relative(process.cwd(), abs) || abs;
27
+
28
+ const decision = decide("edit", filePath);
29
+ if (decision === "deny") {
30
+ return `Error: edit denied by policy for '${rel}'`;
31
+ }
32
+
33
+ const f = Bun.file(abs);
34
+ if (!(await f.exists())) {
35
+ return `Error: file not found: ${rel}`;
36
+ }
37
+
38
+ const content = await f.text();
39
+ const count = countOccurrences(content, oldString);
40
+
41
+ if (oldString === newString) {
42
+ return `Error: oldString and newString are identical`;
43
+ }
44
+
45
+ if (count === 0) {
46
+ return `Error: oldString not found in ${rel}. Ensure the string matches exactly (including whitespace).`;
47
+ }
48
+
49
+ if (!replaceAll && count > 1) {
50
+ return (
51
+ `Error: oldString matches ${count} times in ${rel}. Use 'replaceAll: true' to replace all, ` +
52
+ `or provide more surrounding context to narrow to a single match.`
53
+ );
54
+ }
55
+
56
+ if (decision === "ask") {
57
+ const preview = generateEditPreview(rel, oldString, newString, replaceAll ?? false, count);
58
+ const allowed = await requestConfirmation("edit", preview);
59
+ if (!allowed) {
60
+ return `Edit refused by user for '${rel}'`;
61
+ }
62
+ }
63
+
64
+ const replaced = replaceAll
65
+ ? content.replaceAll(oldString, newString)
66
+ : content.replace(oldString, newString);
67
+
68
+ await snapshotFile(filePath);
69
+ await Bun.write(abs, replaced);
70
+
71
+ const label = replaceAll ? ` (${count} occurrences)` : "";
72
+ return `Edited ${rel}${label}`;
73
+ },
74
+ });
75
+
76
+ function countOccurrences(haystack: string, needle: string): number {
77
+ if (needle.length === 0) return 0;
78
+ let count = 0;
79
+ let pos = 0;
80
+ while ((pos = haystack.indexOf(needle, pos)) !== -1) {
81
+ count++;
82
+ pos += needle.length;
83
+ }
84
+ return count;
85
+ }
86
+
87
+ function generateEditPreview(
88
+ file: string,
89
+ oldS: string,
90
+ newS: string,
91
+ replaceAll: boolean,
92
+ count: number,
93
+ ): string {
94
+ const label = replaceAll ? `replace all (${count} occurrences)` : "replace once";
95
+ const truncatedOld = oldS.length > 300 ? oldS.slice(0, 300) + "…" : oldS;
96
+ const truncatedNew = newS.length > 300 ? newS.slice(0, 300) + "…" : newS;
97
+ return `[edit] ${label}: ${file}\n- ${truncatedOld}\n+ ${truncatedNew}`;
98
+ }
@@ -0,0 +1,40 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { resolveInWorkspace } from "./_fs.ts";
4
+ import * as path from "node:path";
5
+
6
+ const MAX_RESULTS = 200;
7
+
8
+ export const glob = tool({
9
+ description:
10
+ "Find files matching a glob pattern (e.g. 'src/**/*.ts'). " +
11
+ "Use this to locate files by name or extension.",
12
+ inputSchema: z.object({
13
+ pattern: z.string().describe("Glob pattern relative to workspace root"),
14
+ cwd: z
15
+ .string()
16
+ .optional()
17
+ .describe("Directory to search in, relative to workspace root (default: workspace root)"),
18
+ }),
19
+ execute: async ({ pattern: globPattern, cwd }) => {
20
+ const base = cwd ? resolveInWorkspace(cwd) : process.cwd();
21
+ const baseName = path.relative(process.cwd(), base) || ".";
22
+
23
+ const g = new Bun.Glob(globPattern);
24
+ const results: string[] = [];
25
+
26
+ for await (const match of g.scan({ cwd: base, absolute: false, onlyFiles: true })) {
27
+ const segs = match.split("/");
28
+ if (segs.includes("node_modules") || segs.includes(".git")) continue;
29
+ if (results.length >= MAX_RESULTS) break;
30
+ results.push(match);
31
+ }
32
+
33
+ if (results.length === 0) {
34
+ return `No files matched '${globPattern}' in ${baseName}`;
35
+ }
36
+
37
+ const truncated = results.length >= MAX_RESULTS ? `\n… [truncated to ${MAX_RESULTS} results]` : "";
38
+ return `${results.length} files matching '${globPattern}' in ${baseName}:${truncated}\n${results.join("\n")}`;
39
+ },
40
+ });
@@ -0,0 +1,187 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { resolveInWorkspace } from "./_fs.ts";
4
+ import * as path from "node:path";
5
+ import { readdir, readFile } from "node:fs/promises";
6
+
7
+ const OUTPUT_CAP = 30_000;
8
+ const MAX_MATCHES = 500;
9
+
10
+ export const grep = tool({
11
+ description:
12
+ "Search for a regex pattern in file contents. " +
13
+ "Returns matching lines with file path and line number. " +
14
+ "Use this to find where functions, types, or strings are defined.",
15
+ inputSchema: z.object({
16
+ pattern: z.string().describe("Regex pattern to search for"),
17
+ path: z
18
+ .string()
19
+ .optional()
20
+ .describe("File or directory to search in, relative to workspace root (default: workspace root)"),
21
+ include: z
22
+ .string()
23
+ .optional()
24
+ .describe("Glob pattern to filter files (e.g. '*.ts', 'src/**')"),
25
+ ignoreCase: z
26
+ .boolean()
27
+ .optional()
28
+ .describe("Case-insensitive search (default: false)"),
29
+ }),
30
+ execute: async ({ pattern, path: searchPath, include, ignoreCase }) => {
31
+ const cwd = searchPath ? resolveInWorkspace(searchPath) : process.cwd();
32
+ const cwdName = path.relative(process.cwd(), cwd) || ".";
33
+
34
+ const rgResult = await tryRg(pattern, cwd, include, ignoreCase);
35
+ if (rgResult !== null) return rgResult;
36
+
37
+ try {
38
+ return await jsGrep(pattern, cwd, cwdName, include, ignoreCase);
39
+ } catch (err) {
40
+ const msg = err instanceof Error ? err.message : String(err);
41
+ return `grep error: ${msg}`;
42
+ }
43
+ },
44
+ });
45
+
46
+ async function tryRg(
47
+ pattern: string,
48
+ cwd: string,
49
+ include?: string,
50
+ ignoreCase?: boolean,
51
+ ): Promise<string | null> {
52
+ const args = ["--line-number", "--no-heading", "--color=never", "--no-messages"];
53
+
54
+ if (ignoreCase) args.push("--ignore-case");
55
+
56
+ if (include) args.push("--glob", include);
57
+
58
+ args.push("--", pattern);
59
+ args.push(cwd);
60
+
61
+ const proc = Bun.spawn(["rg", ...args], {
62
+ stdout: "pipe",
63
+ stderr: "pipe",
64
+ });
65
+ const out = await proc.stdout.text();
66
+ const err = await proc.stderr.text();
67
+ await proc.exited;
68
+
69
+ if (proc.exitCode === 0) {
70
+ const cwdName = path.relative(process.cwd(), cwd) || ".";
71
+ return formatMatches(out, pattern, cwdName);
72
+ }
73
+
74
+ if (proc.exitCode === 1) {
75
+ const cwdName = path.relative(process.cwd(), cwd) || ".";
76
+ return `No matches for '${pattern}' in ${cwdName}`;
77
+ }
78
+
79
+ if (err.includes("command not found") || err.includes("No such file")) {
80
+ return null; // rg not available, fallback to JS
81
+ }
82
+
83
+ return `grep error (exit ${proc.exitCode}): ${err || "unknown error"}`;
84
+ }
85
+
86
+ async function jsGrep(
87
+ pattern: string,
88
+ absPath: string,
89
+ name: string,
90
+ include?: string,
91
+ ignoreCase?: boolean,
92
+ ): Promise<string> {
93
+ const flags = ignoreCase ? "i" : "";
94
+ let regex: RegExp;
95
+ try {
96
+ regex = new RegExp(pattern, flags);
97
+ } catch {
98
+ return `Invalid regex pattern: ${pattern}`;
99
+ }
100
+
101
+ const matches: string[] = [];
102
+ const fileEntries = include
103
+ ? await collectMatching(absPath, include)
104
+ : await collectAll(absPath);
105
+
106
+ for (const filePath of fileEntries) {
107
+ if (matches.length >= MAX_MATCHES) break;
108
+
109
+ try {
110
+ const content = await readFile(filePath, "utf-8");
111
+ const lines = content.split("\n");
112
+ const rel = path.relative(process.cwd(), filePath) || filePath;
113
+ for (let i = 0; i < lines.length; i++) {
114
+ const line = lines[i] ?? "";
115
+ if (regex.test(line)) {
116
+ const stripped = line.trimEnd();
117
+ const display =
118
+ stripped.length > 200 ? stripped.slice(0, 200) + "…" : stripped;
119
+ matches.push(`${rel}:${i + 1}: ${display}`);
120
+ if (matches.length >= MAX_MATCHES) break;
121
+ }
122
+ }
123
+ } catch {
124
+ // Skip unreadable files
125
+ }
126
+ }
127
+
128
+ if (matches.length === 0) {
129
+ return `No matches for '${pattern}' in ${name}`;
130
+ }
131
+
132
+ let output = matches.join("\n");
133
+ if (output.length > OUTPUT_CAP) {
134
+ output = output.slice(0, OUTPUT_CAP) + "\n… [truncated]";
135
+ }
136
+
137
+ return `${matches.length} matches for '${pattern}' in ${name}:\n${output}`;
138
+ }
139
+
140
+ async function collectAll(dir: string): Promise<string[]> {
141
+ const results: string[] = [];
142
+ try {
143
+ const entries = await readdir(dir, { withFileTypes: true });
144
+ for (const entry of entries) {
145
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
146
+ const full = path.join(dir, entry.name);
147
+ if (entry.isDirectory()) {
148
+ const nested = await collectAll(full);
149
+ results.push(...nested);
150
+ } else if (entry.isFile()) {
151
+ results.push(full);
152
+ }
153
+ }
154
+ } catch {
155
+ // Permission denied or similar
156
+ }
157
+ return results;
158
+ }
159
+
160
+ async function collectMatching(dir: string, globPattern: string): Promise<string[]> {
161
+ const g = new Bun.Glob(globPattern);
162
+ const results: string[] = [];
163
+ for await (const match of g.scan({ cwd: dir, absolute: false, onlyFiles: true })) {
164
+ results.push(path.join(dir, match));
165
+ }
166
+ return results;
167
+ }
168
+
169
+ function formatMatches(raw: string, pattern: string, cwdName: string): string {
170
+ const lines = raw.split("\n").filter((l) => l.length > 0);
171
+ if (lines.length === 0) {
172
+ return `No matches for '${pattern}' in ${cwdName}`;
173
+ }
174
+
175
+ const truncated = lines.length > MAX_MATCHES;
176
+ const shown = truncated ? lines.slice(0, MAX_MATCHES) : lines;
177
+ let output = shown.join("\n");
178
+
179
+ if (output.length > OUTPUT_CAP) {
180
+ output = output.slice(0, OUTPUT_CAP) + "\n… [truncated]";
181
+ }
182
+
183
+ const note = truncated
184
+ ? `\n… [${lines.length} matches, showing ${MAX_MATCHES}]`
185
+ : "";
186
+ return `${lines.length} matches for '${pattern}' in ${cwdName}:${note}\n${output}`;
187
+ }
@@ -0,0 +1,21 @@
1
+ import { read, ls, glob, grep, write, edit, bash, todowrite, question, readonlyTools, allToolsWithoutTask } from "./registry.ts";
2
+ import { task } from "./task.ts";
3
+ import type { ToolSet } from "ai";
4
+
5
+ export { read, ls, glob, grep, write, edit, bash, todowrite, question, task };
6
+ export { readonlyTools };
7
+
8
+ export const allTools: ToolSet = {
9
+ ...allToolsWithoutTask,
10
+ task,
11
+ };
12
+
13
+ export type AgentMode = "plan" | "build";
14
+
15
+ export function toolsForMode(mode: AgentMode): ToolSet {
16
+ return mode === "plan" ? readonlyTools : allTools;
17
+ }
18
+
19
+ export function isReadonlyTool(name: string): boolean {
20
+ return name in readonlyTools;
21
+ }
@@ -0,0 +1,70 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { resolveInWorkspace } from "./_fs.ts";
4
+ import { readdir, stat } from "node:fs/promises";
5
+ import * as path from "node:path";
6
+
7
+ const MAX_ENTRIES = 200;
8
+ const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", ".venv", "vendor"]);
9
+
10
+ export const ls = tool({
11
+ description:
12
+ "List files and directories in a given directory. " +
13
+ "Use this to explore the project structure.",
14
+ inputSchema: z.object({
15
+ path: z
16
+ .string()
17
+ .optional()
18
+ .describe("Directory path relative to workspace root (default: workspace root)"),
19
+ }),
20
+ execute: async ({ path: dirPath = "." }) => {
21
+ const abs = resolveInWorkspace(dirPath);
22
+ const name = path.relative(process.cwd(), abs) || abs;
23
+
24
+ let entries: string[];
25
+ try {
26
+ entries = await readdir(abs);
27
+ } catch {
28
+ return `Error: directory not found: ${name}`;
29
+ }
30
+
31
+ if (entries.length === 0) {
32
+ return `${name}/ (empty)`;
33
+ }
34
+
35
+ const rows: string[] = [];
36
+ const sorted = entries.sort();
37
+ let skipped = 0;
38
+
39
+ for (const entry of sorted) {
40
+ if (SKIP_DIRS.has(entry)) {
41
+ skipped++;
42
+ continue;
43
+ }
44
+ if (rows.length >= MAX_ENTRIES) break;
45
+
46
+ try {
47
+ const s = await stat(path.join(abs, entry));
48
+ const suffix = s.isDirectory() ? "/" : "";
49
+ const size = s.isFile() ? formatSize(s.size) : "-";
50
+ rows.push(`${size.padStart(6, " ")} ${entry}${suffix}`);
51
+ } catch {
52
+ rows.push(` ? ${entry}`);
53
+ }
54
+ }
55
+
56
+ const more =
57
+ rows.length < entries.length - skipped
58
+ ? `\n… and ${entries.length - skipped - rows.length} more entries`
59
+ : "";
60
+ const skipNote = skipped > 0 ? ` (${skipped} hidden dirs skipped)` : "";
61
+
62
+ return `${name}/${skipNote} (${entries.length - skipped} entries):\n${rows.join("\n")}${more}`;
63
+ },
64
+ });
65
+
66
+ function formatSize(bytes: number): string {
67
+ if (bytes < 1024) return `${bytes}B`;
68
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
69
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
70
+ }
@@ -0,0 +1,81 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+
4
+ export interface QuestionOption {
5
+ label: string;
6
+ description?: string;
7
+ }
8
+
9
+ export interface QuestionSpec {
10
+ question: string;
11
+ header?: string;
12
+ options: QuestionOption[];
13
+ multiple?: boolean;
14
+ }
15
+
16
+ // Risposta: per ogni domanda, le label selezionate (più di una se multiple).
17
+ export type Answers = string[][];
18
+
19
+ // Meccanismo EVENT-DRIVEN (come requestConfirmation in permissions.ts):
20
+ // la UI registra un handler con setAnswerHandler; il tool, dentro execute, chiama
21
+ // requestAnswer e ne attende l'esito. Senza handler (test/headless) si usa il fallback.
22
+ export type AnswerHandler = (questions: QuestionSpec[]) => Promise<Answers>;
23
+
24
+ let answerHandler: AnswerHandler | null = null;
25
+
26
+ export function setAnswerHandler(handler: AnswerHandler | null): void {
27
+ answerHandler = handler;
28
+ }
29
+
30
+ export async function requestAnswer(questions: QuestionSpec[]): Promise<Answers> {
31
+ if (answerHandler) return answerHandler(questions);
32
+ // Fallback senza UI: nessuna risposta (l'agente prosegue con cautela).
33
+ return questions.map(() => []);
34
+ }
35
+
36
+ export const question = tool({
37
+ description:
38
+ "Ask the user one or more questions during execution instead of guessing on ambiguous choices. " +
39
+ "Each question offers a list of options the user picks from (single or multiple choice). " +
40
+ "Use sparingly, only for decisions that genuinely change the outcome and that you cannot resolve " +
41
+ "from the request, the code, or sensible defaults. Execution pauses until the user answers.",
42
+ inputSchema: z.object({
43
+ questions: z
44
+ .array(
45
+ z.object({
46
+ question: z.string().min(1).describe("The full question to ask the user"),
47
+ header: z
48
+ .string()
49
+ .optional()
50
+ .describe("Very short label/chip for the question (e.g. 'Auth method')"),
51
+ options: z
52
+ .array(
53
+ z.object({
54
+ label: z.string().min(1).describe("Short choice text shown to the user"),
55
+ description: z
56
+ .string()
57
+ .optional()
58
+ .describe("Optional explanation of what this option means"),
59
+ }),
60
+ )
61
+ .min(2)
62
+ .describe("The available choices (at least 2, mutually exclusive unless multiple)"),
63
+ multiple: z
64
+ .boolean()
65
+ .optional()
66
+ .describe("Allow selecting more than one option (default false)"),
67
+ }),
68
+ )
69
+ .min(1)
70
+ .describe("The questions to ask (1 or more)"),
71
+ }),
72
+ execute: async ({ questions }) => {
73
+ const answers = await requestAnswer(questions);
74
+ const lines = questions.map((q, i) => {
75
+ const sel = answers[i] ?? [];
76
+ const a = sel.length > 0 ? sel.join(", ") : "(no answer — proceed with best judgment)";
77
+ return `Q: ${q.question}\nA: ${a}`;
78
+ });
79
+ return lines.join("\n\n");
80
+ },
81
+ });
@@ -0,0 +1,61 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { resolveInWorkspace } from "./_fs.ts";
4
+ import * as path from "node:path";
5
+
6
+ const OUTPUT_CAP = 30_000;
7
+
8
+ export const read = tool({
9
+ description:
10
+ "Read a file from the workspace. Use offset and limit for large files. " +
11
+ "Returns content with line numbers. Prefer this over bash for reading files.",
12
+ inputSchema: z.object({
13
+ path: z.string().describe("Path to the file, relative to workspace root"),
14
+ offset: z
15
+ .number()
16
+ .int()
17
+ .min(0)
18
+ .optional()
19
+ .describe("Line number to start reading from (0-indexed)"),
20
+ limit: z
21
+ .number()
22
+ .int()
23
+ .min(1)
24
+ .max(2000)
25
+ .optional()
26
+ .describe("Maximum number of lines to read"),
27
+ }),
28
+ execute: async ({ path: filePath, offset, limit }) => {
29
+ const abs = resolveInWorkspace(filePath);
30
+ const name = path.relative(process.cwd(), abs) || abs;
31
+ const f = Bun.file(abs);
32
+
33
+ if (!(await f.exists())) {
34
+ return `Error: file not found: ${name}`;
35
+ }
36
+
37
+ const raw = await f.text();
38
+ const lines = raw.split("\n");
39
+ const start = offset ?? 0;
40
+ const end = limit ? start + limit : lines.length;
41
+ const sliced = lines.slice(start, end);
42
+
43
+ const numbered = sliced
44
+ .map((line, i) => {
45
+ const n = String(start + i + 1).padStart(4, " ");
46
+ return `${n}: ${line}`;
47
+ })
48
+ .join("\n");
49
+
50
+ const truncated = numbered.length > OUTPUT_CAP;
51
+ const output = truncated
52
+ ? numbered.slice(0, OUTPUT_CAP) + "\n… [truncated]"
53
+ : numbered;
54
+
55
+ const linesLabel =
56
+ end >= lines.length ? `${lines.length} lines` : `${start + 1}-${end} of ${lines.length} lines`;
57
+ const header = `${name} (${linesLabel})${truncated ? " [truncated]" : ""}`;
58
+
59
+ return `${header}\n${output}`;
60
+ },
61
+ });
@@ -0,0 +1,36 @@
1
+ import type { ToolSet } from "ai";
2
+ import { read } from "./read.ts";
3
+ import { ls } from "./ls.ts";
4
+ import { glob } from "./glob.ts";
5
+ import { grep } from "./grep.ts";
6
+ import { write } from "./write.ts";
7
+ import { edit } from "./edit.ts";
8
+ import { bash } from "./bash.ts";
9
+ import { webfetch } from "./webfetch.ts";
10
+ import { todowrite } from "./todowrite.ts";
11
+ import { question } from "./question.ts";
12
+
13
+ export { read, ls, glob, grep, write, edit, bash, webfetch, todowrite, question };
14
+
15
+ export const readonlyTools: ToolSet = {
16
+ read,
17
+ ls,
18
+ glob,
19
+ grep,
20
+ webfetch,
21
+ todowrite,
22
+ question,
23
+ };
24
+
25
+ export const allToolsWithoutTask: ToolSet = {
26
+ read,
27
+ ls,
28
+ glob,
29
+ grep,
30
+ write,
31
+ edit,
32
+ bash,
33
+ webfetch,
34
+ todowrite,
35
+ question,
36
+ };