@papercraneai/sandbox-agent 0.1.12 → 0.1.14-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ import type { FileIndex } from "./file-index.js";
2
+ interface EditFileArgs {
3
+ fileId: unknown;
4
+ oldString: unknown;
5
+ newString: unknown;
6
+ replaceAll?: unknown;
7
+ }
8
+ interface ToolResult {
9
+ content: {
10
+ type: "text";
11
+ text: string;
12
+ }[];
13
+ isError?: boolean;
14
+ [key: string]: unknown;
15
+ }
16
+ /**
17
+ * Edits a file by handle using a string-replace cascade. The fileId must reference
18
+ * a writable entry. Tries replacers in order (exact, line-trimmed, block-anchor)
19
+ * and returns a unified diff of the change.
20
+ *
21
+ * Not in shared-view's `allowedTools` for v1 — registered for future authoring use.
22
+ */
23
+ export declare function executeEditFile(index: FileIndex, args: EditFileArgs): Promise<ToolResult>;
24
+ export {};
@@ -0,0 +1,218 @@
1
+ import { readFile, writeFile, stat } from "fs/promises";
2
+ import { createTwoFilesPatch } from "diff";
3
+ import { lookup } from "./file-index.js";
4
+ /**
5
+ * Returns the input unchanged. The first and most common case: exact match.
6
+ * Ported from opencode edit.ts (MIT).
7
+ */
8
+ const SimpleReplacer = function* (_content, find) {
9
+ yield find;
10
+ };
11
+ /**
12
+ * Yields candidate substrings whose lines match the search term modulo
13
+ * leading/trailing whitespace per line. Useful when the model's `oldString`
14
+ * has slightly different indentation than the file. Ported from opencode (MIT).
15
+ */
16
+ const LineTrimmedReplacer = function* (content, find) {
17
+ const originalLines = content.split("\n");
18
+ const searchLines = find.split("\n");
19
+ if (searchLines[searchLines.length - 1] === "") {
20
+ searchLines.pop();
21
+ }
22
+ for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
23
+ let matches = true;
24
+ for (let j = 0; j < searchLines.length; j++) {
25
+ if (originalLines[i + j].trim() !== searchLines[j].trim()) {
26
+ matches = false;
27
+ break;
28
+ }
29
+ }
30
+ if (!matches)
31
+ continue;
32
+ let matchStartIndex = 0;
33
+ for (let k = 0; k < i; k++)
34
+ matchStartIndex += originalLines[k].length + 1;
35
+ let matchEndIndex = matchStartIndex;
36
+ for (let k = 0; k < searchLines.length; k++) {
37
+ matchEndIndex += originalLines[i + k].length;
38
+ if (k < searchLines.length - 1)
39
+ matchEndIndex += 1;
40
+ }
41
+ yield content.substring(matchStartIndex, matchEndIndex);
42
+ }
43
+ };
44
+ /**
45
+ * For multi-line blocks (>=3 lines), yields candidates anchored on the first
46
+ * and last lines (trimmed match). Useful when the middle of the block has drift
47
+ * but the boundaries are stable. Ported from opencode (MIT).
48
+ */
49
+ const BlockAnchorReplacer = function* (content, find) {
50
+ const originalLines = content.split("\n");
51
+ const searchLines = find.split("\n");
52
+ if (searchLines.length < 3)
53
+ return;
54
+ if (searchLines[searchLines.length - 1] === "")
55
+ searchLines.pop();
56
+ const firstLineSearch = searchLines[0].trim();
57
+ const lastLineSearch = searchLines[searchLines.length - 1].trim();
58
+ for (let i = 0; i < originalLines.length; i++) {
59
+ if (originalLines[i].trim() !== firstLineSearch)
60
+ continue;
61
+ for (let j = i + 2; j < originalLines.length; j++) {
62
+ if (originalLines[j].trim() !== lastLineSearch)
63
+ continue;
64
+ let matchStartIndex = 0;
65
+ for (let k = 0; k < i; k++)
66
+ matchStartIndex += originalLines[k].length + 1;
67
+ let matchEndIndex = matchStartIndex;
68
+ for (let k = i; k <= j; k++) {
69
+ matchEndIndex += originalLines[k].length;
70
+ if (k < j)
71
+ matchEndIndex += 1;
72
+ }
73
+ yield content.substring(matchStartIndex, matchEndIndex);
74
+ break; // only first match for this anchor pair
75
+ }
76
+ }
77
+ };
78
+ /**
79
+ * Avoids `$` sequence issues in the replacement string. JavaScript's
80
+ * `String.prototype.replaceAll` interprets `$&`, `$1`, etc., in the
81
+ * replacement; this routes through a function replacement which is treated
82
+ * as a literal. Ported from gemini-cli edit.ts (Apache 2.0).
83
+ */
84
+ function safeLiteralReplace(content, search, replacement) {
85
+ // For non-pattern (string) inputs, replaceAll's first-arg-as-string mode
86
+ // does NOT interpret `$&` — only the replacement string does. Use the
87
+ // function form to bypass.
88
+ return content.split(search).join(replacement);
89
+ }
90
+ function replace(content, oldString, newString, replaceAll = false) {
91
+ if (oldString === newString) {
92
+ return { error: "No changes to apply: oldString and newString are identical." };
93
+ }
94
+ const replacers = [
95
+ { name: "SimpleReplacer", fn: SimpleReplacer },
96
+ { name: "LineTrimmedReplacer", fn: LineTrimmedReplacer },
97
+ { name: "BlockAnchorReplacer", fn: BlockAnchorReplacer }
98
+ ];
99
+ let foundButAmbiguous = false;
100
+ for (const { name, fn } of replacers) {
101
+ for (const candidate of fn(content, oldString)) {
102
+ const index = content.indexOf(candidate);
103
+ if (index === -1)
104
+ continue;
105
+ if (replaceAll) {
106
+ return {
107
+ content: safeLiteralReplace(content, candidate, newString),
108
+ replacer: name
109
+ };
110
+ }
111
+ const lastIndex = content.lastIndexOf(candidate);
112
+ if (index !== lastIndex) {
113
+ // Multiple matches; this replacer can't make a unique replacement.
114
+ // Try the next replacer in case it yields a more specific candidate.
115
+ foundButAmbiguous = true;
116
+ continue;
117
+ }
118
+ return {
119
+ content: content.substring(0, index) + newString + content.substring(index + candidate.length),
120
+ replacer: name
121
+ };
122
+ }
123
+ }
124
+ if (foundButAmbiguous) {
125
+ return { error: "Found multiple matches for oldString. Provide more surrounding context to make the match unique, or set replaceAll: true." };
126
+ }
127
+ return { error: "Could not find oldString in the file. It must match exactly, including whitespace and indentation. Try replacing a smaller, more distinctive snippet." };
128
+ }
129
+ // Per-file lock so concurrent EditFile calls on the same file serialize.
130
+ // Ported in spirit from opencode's Semaphore-based locks (MIT).
131
+ const locks = new Map();
132
+ async function withFileLock(absolutePath, fn) {
133
+ const previous = locks.get(absolutePath) ?? Promise.resolve();
134
+ let release = () => { };
135
+ const next = new Promise((resolve) => { release = resolve; });
136
+ locks.set(absolutePath, previous.then(() => next));
137
+ await previous;
138
+ try {
139
+ return await fn();
140
+ }
141
+ finally {
142
+ release();
143
+ if (locks.get(absolutePath) === previous.then(() => next)) {
144
+ locks.delete(absolutePath);
145
+ }
146
+ }
147
+ }
148
+ /**
149
+ * Edits a file by handle using a string-replace cascade. The fileId must reference
150
+ * a writable entry. Tries replacers in order (exact, line-trimmed, block-anchor)
151
+ * and returns a unified diff of the change.
152
+ *
153
+ * Not in shared-view's `allowedTools` for v1 — registered for future authoring use.
154
+ */
155
+ export async function executeEditFile(index, args) {
156
+ const entry = lookup(index, args.fileId);
157
+ if (!entry) {
158
+ return {
159
+ content: [{ type: "text", text: `Error: unknown fileId. Use ListFiles to discover available files.` }],
160
+ isError: true
161
+ };
162
+ }
163
+ if (!entry.writable) {
164
+ return {
165
+ content: [{ type: "text", text: `Error: file ${entry.relativePath} is not writable in this session.` }],
166
+ isError: true
167
+ };
168
+ }
169
+ if (typeof args.oldString !== "string" || typeof args.newString !== "string") {
170
+ return {
171
+ content: [{ type: "text", text: `Error: oldString and newString must be strings` }],
172
+ isError: true
173
+ };
174
+ }
175
+ const replaceAll = args.replaceAll === true;
176
+ return withFileLock(entry.absolutePath, async () => {
177
+ let original;
178
+ try {
179
+ original = await readFile(entry.absolutePath, "utf-8");
180
+ }
181
+ catch (err) {
182
+ return {
183
+ content: [{ type: "text", text: `Error reading file: ${err instanceof Error ? err.message : String(err)}` }],
184
+ isError: true
185
+ };
186
+ }
187
+ const result = replace(original, args.oldString, args.newString, replaceAll);
188
+ if ("error" in result) {
189
+ return {
190
+ content: [{ type: "text", text: `Error: ${result.error}` }],
191
+ isError: true
192
+ };
193
+ }
194
+ try {
195
+ await writeFile(entry.absolutePath, result.content, "utf-8");
196
+ }
197
+ catch (err) {
198
+ return {
199
+ content: [{ type: "text", text: `Error writing file: ${err instanceof Error ? err.message : String(err)}` }],
200
+ isError: true
201
+ };
202
+ }
203
+ try {
204
+ const s = await stat(entry.absolutePath);
205
+ entry.size = s.size;
206
+ }
207
+ catch { /* non-fatal */ }
208
+ const diff = createTwoFilesPatch(entry.relativePath, entry.relativePath, original, result.content, "before", "after");
209
+ const trimmedDiff = diff.replace(/^Index: .*\n=+\n/m, "");
210
+ const matchedSuffix = result.replacer === "SimpleReplacer" ? "" : ` (matched via ${result.replacer})`;
211
+ return {
212
+ content: [{
213
+ type: "text",
214
+ text: `Edited ${entry.relativePath}${matchedSuffix}\n\n${trimmedDiff}`
215
+ }]
216
+ };
217
+ });
218
+ }
@@ -0,0 +1,33 @@
1
+ export interface FileIndexEntry {
2
+ absolutePath: string;
3
+ relativePath: string;
4
+ writable: boolean;
5
+ size: number;
6
+ }
7
+ export type FileIndex = Map<string, FileIndexEntry>;
8
+ interface BuildOptions {
9
+ dashboardRoot: string;
10
+ queryFiles?: string[];
11
+ skip?: Set<string>;
12
+ }
13
+ /**
14
+ * Builds the read/write index for a shared-view chat session.
15
+ *
16
+ * The index is the *only* way handle-based tools can address files. The agent
17
+ * never receives or constructs a path string. There is no `..`, no symlink
18
+ * resolution, no case-folding to get wrong — handles either resolve to an
19
+ * absolute path the index already chose, or they don't.
20
+ *
21
+ * @param options.dashboardRoot Absolute path to the dashboard folder. Walked recursively.
22
+ * @param options.queryFiles Optional list of absolute paths to additional files
23
+ * (e.g., shared query files referenced from the dashboard).
24
+ * Their relative path is resolved against dashboardRoot when
25
+ * they live underneath it; otherwise the absolute path is used.
26
+ */
27
+ export declare function buildFileIndex(options: BuildOptions): Promise<FileIndex>;
28
+ /**
29
+ * Looks up a file id in the index. Returns the entry or undefined.
30
+ * Tools call this rather than ever taking a path argument from the caller.
31
+ */
32
+ export declare function lookup(index: FileIndex, fileId: unknown): FileIndexEntry | undefined;
33
+ export {};
@@ -0,0 +1,102 @@
1
+ import { readdir, stat } from "fs/promises";
2
+ import { join, relative, isAbsolute } from "path";
3
+ import { randomBytes } from "crypto";
4
+ const DEFAULT_SKIP = new Set([
5
+ "node_modules",
6
+ ".next",
7
+ ".turbo",
8
+ ".git",
9
+ ".cache",
10
+ "dist",
11
+ "build",
12
+ ".DS_Store"
13
+ ]);
14
+ function newFileId() {
15
+ return `f_${randomBytes(8).toString("hex")}`;
16
+ }
17
+ async function walk(rootAbs, current, skip, out) {
18
+ const entries = await readdir(current, { withFileTypes: true });
19
+ for (const entry of entries) {
20
+ if (skip.has(entry.name) || entry.name.startsWith("."))
21
+ continue;
22
+ const entryAbs = join(current, entry.name);
23
+ if (entry.isSymbolicLink()) {
24
+ // Symlinks are not followed. Anything we want in scope must be explicitly
25
+ // present as a regular file or directory under the dashboard root.
26
+ continue;
27
+ }
28
+ if (entry.isDirectory()) {
29
+ await walk(rootAbs, entryAbs, skip, out);
30
+ }
31
+ else if (entry.isFile()) {
32
+ const s = await stat(entryAbs);
33
+ out.set(newFileId(), {
34
+ absolutePath: entryAbs,
35
+ relativePath: relative(rootAbs, entryAbs),
36
+ writable: true,
37
+ size: s.size
38
+ });
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * Builds the read/write index for a shared-view chat session.
44
+ *
45
+ * The index is the *only* way handle-based tools can address files. The agent
46
+ * never receives or constructs a path string. There is no `..`, no symlink
47
+ * resolution, no case-folding to get wrong — handles either resolve to an
48
+ * absolute path the index already chose, or they don't.
49
+ *
50
+ * @param options.dashboardRoot Absolute path to the dashboard folder. Walked recursively.
51
+ * @param options.queryFiles Optional list of absolute paths to additional files
52
+ * (e.g., shared query files referenced from the dashboard).
53
+ * Their relative path is resolved against dashboardRoot when
54
+ * they live underneath it; otherwise the absolute path is used.
55
+ */
56
+ export async function buildFileIndex(options) {
57
+ const { dashboardRoot, queryFiles = [], skip = DEFAULT_SKIP } = options;
58
+ if (!isAbsolute(dashboardRoot)) {
59
+ throw new Error(`buildFileIndex: dashboardRoot must be absolute, got: ${dashboardRoot}`);
60
+ }
61
+ const rootStat = await stat(dashboardRoot);
62
+ if (!rootStat.isDirectory()) {
63
+ throw new Error(`buildFileIndex: dashboardRoot is not a directory: ${dashboardRoot}`);
64
+ }
65
+ const index = new Map();
66
+ await walk(dashboardRoot, dashboardRoot, skip, index);
67
+ for (const qf of queryFiles) {
68
+ if (!isAbsolute(qf)) {
69
+ throw new Error(`buildFileIndex: queryFiles entries must be absolute, got: ${qf}`);
70
+ }
71
+ // Skip if this query file is already inside the dashboard root (already walked).
72
+ const indexed = [...index.values()].some((e) => e.absolutePath === qf);
73
+ if (indexed)
74
+ continue;
75
+ let s;
76
+ try {
77
+ s = await stat(qf);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (!s.isFile())
83
+ continue;
84
+ const rel = qf.startsWith(dashboardRoot) ? relative(dashboardRoot, qf) : qf;
85
+ index.set(newFileId(), {
86
+ absolutePath: qf,
87
+ relativePath: rel,
88
+ writable: true,
89
+ size: s.size
90
+ });
91
+ }
92
+ return index;
93
+ }
94
+ /**
95
+ * Looks up a file id in the index. Returns the entry or undefined.
96
+ * Tools call this rather than ever taking a path argument from the caller.
97
+ */
98
+ export function lookup(index, fileId) {
99
+ if (typeof fileId !== "string")
100
+ return undefined;
101
+ return index.get(fileId);
102
+ }
@@ -0,0 +1,25 @@
1
+ import type { FileIndex } from "./file-index.js";
2
+ interface GrepArgs {
3
+ pattern: unknown;
4
+ }
5
+ interface ToolResult {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError?: boolean;
11
+ [key: string]: unknown;
12
+ }
13
+ /**
14
+ * Runs ripgrep restricted to the absolute paths in the file index.
15
+ *
16
+ * The pattern is passed as an argv argument to ripgrep, not interpolated into
17
+ * a shell — so shell metacharacters in the pattern are safe. The file list is
18
+ * piped via stdin so very large indexes don't blow the command-line length.
19
+ *
20
+ * Output is capped at MAX_MATCHES results and lines longer than MAX_LINE_LENGTH
21
+ * are truncated. A 5s wall-clock timeout caps catastrophic-regex CPU per the
22
+ * implementation-time hardening checklist (M4 in the design doc).
23
+ */
24
+ export declare function executeGrepFiles(index: FileIndex, args: GrepArgs): Promise<ToolResult>;
25
+ export {};
@@ -0,0 +1,106 @@
1
+ import { spawn } from "child_process";
2
+ const MAX_MATCHES = 100;
3
+ const MAX_LINE_LENGTH = 2000;
4
+ const RIPGREP_TIMEOUT_MS = 5_000;
5
+ /**
6
+ * Runs ripgrep restricted to the absolute paths in the file index.
7
+ *
8
+ * The pattern is passed as an argv argument to ripgrep, not interpolated into
9
+ * a shell — so shell metacharacters in the pattern are safe. The file list is
10
+ * piped via stdin so very large indexes don't blow the command-line length.
11
+ *
12
+ * Output is capped at MAX_MATCHES results and lines longer than MAX_LINE_LENGTH
13
+ * are truncated. A 5s wall-clock timeout caps catastrophic-regex CPU per the
14
+ * implementation-time hardening checklist (M4 in the design doc).
15
+ */
16
+ export async function executeGrepFiles(index, args) {
17
+ const pattern = args.pattern;
18
+ if (typeof pattern !== "string" || pattern.length === 0) {
19
+ return {
20
+ content: [{ type: "text", text: `Error: pattern must be a non-empty string` }],
21
+ isError: true
22
+ };
23
+ }
24
+ const filePaths = [...index.values()].map((e) => e.absolutePath);
25
+ if (filePaths.length === 0) {
26
+ return {
27
+ content: [{ type: "text", text: `(no files in index to search)` }]
28
+ };
29
+ }
30
+ const child = spawn("rg", [
31
+ "--no-config",
32
+ "--line-number",
33
+ "--no-heading",
34
+ "--color=never",
35
+ "--max-count", "10", // up to 10 matches per file before moving on
36
+ "--max-columns", String(MAX_LINE_LENGTH),
37
+ "--max-columns-preview",
38
+ "--files-from", "-",
39
+ "--regexp", pattern
40
+ ], { stdio: ["pipe", "pipe", "pipe"] });
41
+ // Pipe the file list to ripgrep on stdin
42
+ child.stdin.write(filePaths.join("\n"));
43
+ child.stdin.end();
44
+ const stdoutChunks = [];
45
+ const stderrChunks = [];
46
+ child.stdout.on("data", (c) => stdoutChunks.push(c));
47
+ child.stderr.on("data", (c) => stderrChunks.push(c));
48
+ const timeout = setTimeout(() => {
49
+ child.kill("SIGKILL");
50
+ }, RIPGREP_TIMEOUT_MS);
51
+ let killed = false;
52
+ child.on("close", (_code, signal) => {
53
+ if (signal === "SIGKILL")
54
+ killed = true;
55
+ });
56
+ const exitCode = await new Promise((resolve) => {
57
+ child.on("close", (code) => resolve(code));
58
+ });
59
+ clearTimeout(timeout);
60
+ if (killed) {
61
+ return {
62
+ content: [{ type: "text", text: `Error: search timed out after ${RIPGREP_TIMEOUT_MS / 1000}s. Try a more specific pattern.` }],
63
+ isError: true
64
+ };
65
+ }
66
+ // ripgrep exits 0 when matches found, 1 when no matches, 2+ on errors.
67
+ if (exitCode === 1) {
68
+ return {
69
+ content: [{ type: "text", text: `(no matches for ${JSON.stringify(pattern)})` }]
70
+ };
71
+ }
72
+ if (exitCode !== 0) {
73
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
74
+ return {
75
+ content: [{ type: "text", text: `Error running ripgrep (exit ${exitCode}): ${stderr.slice(0, 500)}` }],
76
+ isError: true
77
+ };
78
+ }
79
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
80
+ const lines = stdout.split("\n").filter((l) => l.length > 0);
81
+ const truncated = lines.length > MAX_MATCHES;
82
+ const shown = truncated ? lines.slice(0, MAX_MATCHES) : lines;
83
+ // ripgrep emits absolute paths since we passed absolute paths in. Rewrite
84
+ // them to relative paths via the index so the agent never sees the absolute
85
+ // form. (No security guarantee — just consistency with the read formatter.)
86
+ const absToRel = new Map();
87
+ for (const entry of index.values())
88
+ absToRel.set(entry.absolutePath, entry.relativePath);
89
+ const rewritten = shown.map((line) => {
90
+ const colon = line.indexOf(":");
91
+ if (colon === -1)
92
+ return line;
93
+ const path = line.slice(0, colon);
94
+ const rel = absToRel.get(path);
95
+ return rel ? `${rel}${line.slice(colon)}` : line;
96
+ });
97
+ const footer = truncated
98
+ ? `\n\n(Showing ${MAX_MATCHES} of ${lines.length} matches. Refine the pattern to narrow results.)`
99
+ : `\n\n(${lines.length} match${lines.length === 1 ? "" : "es"})`;
100
+ return {
101
+ content: [{
102
+ type: "text",
103
+ text: rewritten.join("\n") + footer
104
+ }]
105
+ };
106
+ }
@@ -0,0 +1,23 @@
1
+ import type { FileIndex } from "./file-index.js";
2
+ interface CreateOptions {
3
+ dashboardRoot: string;
4
+ queryFiles?: string[];
5
+ }
6
+ /**
7
+ * Builds the dashboard-fs MCP server for a chat session. Walks the dashboard
8
+ * folder once to construct a handle-based file index; all subsequent reads,
9
+ * writes, edits, and grep calls resolve `fileId` against that index. The agent
10
+ * never sees or constructs a path string — there is no `..`, no symlink to
11
+ * resolve, no case to fold.
12
+ *
13
+ * Registers all six tools (ListFiles, ReadFile, GrepFiles, WriteFile, EditFile,
14
+ * CreateFile). Per the design plan, shared-view chat sessions allowlist only the
15
+ * read-only subset (List, Read, Grep) via `allowedTools`. The write-set is
16
+ * available for future use cases.
17
+ */
18
+ export declare function createDashboardFsServer(options: CreateOptions): Promise<{
19
+ server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
20
+ index: FileIndex;
21
+ listFileIds: () => string[];
22
+ }>;
23
+ export {};
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
3
+ import { buildFileIndex } from "./file-index.js";
4
+ import { executeListFiles, executeReadFile } from "./read.js";
5
+ import { executeGrepFiles } from "./grep.js";
6
+ import { executeWriteFile, executeCreateFile } from "./write.js";
7
+ import { executeEditFile } from "./edit.js";
8
+ /**
9
+ * Builds the dashboard-fs MCP server for a chat session. Walks the dashboard
10
+ * folder once to construct a handle-based file index; all subsequent reads,
11
+ * writes, edits, and grep calls resolve `fileId` against that index. The agent
12
+ * never sees or constructs a path string — there is no `..`, no symlink to
13
+ * resolve, no case to fold.
14
+ *
15
+ * Registers all six tools (ListFiles, ReadFile, GrepFiles, WriteFile, EditFile,
16
+ * CreateFile). Per the design plan, shared-view chat sessions allowlist only the
17
+ * read-only subset (List, Read, Grep) via `allowedTools`. The write-set is
18
+ * available for future use cases.
19
+ */
20
+ export async function createDashboardFsServer(options) {
21
+ const index = await buildFileIndex({
22
+ dashboardRoot: options.dashboardRoot,
23
+ queryFiles: options.queryFiles
24
+ });
25
+ const listFiles = tool("ListFiles", "Lists every file the agent can read in this session, with a stable fileId for each. Call this first to discover what's available. Returns relativePath, size, writable, and the fileId you'll pass to ReadFile, WriteFile, EditFile, and GrepFiles.", {}, async () => executeListFiles(index));
26
+ const readFile = tool("ReadFile", "Reads a file by its fileId (from ListFiles). Returns line-numbered content. Use offset/limit to page through large files.", {
27
+ fileId: z.string().describe("The opaque file handle from ListFiles"),
28
+ offset: z.number().int().min(1).optional().describe("Line number to start reading from (1-indexed). Default: 1."),
29
+ limit: z.number().int().min(1).optional().describe("Maximum lines to read. Default: 2000.")
30
+ }, async (args) => executeReadFile(index, args));
31
+ const grepFiles = tool("GrepFiles", "Searches for a pattern across every file in this session's index using ripgrep. Returns matches as path:line:content. Pattern is a regex. Capped at 100 matches.", {
32
+ pattern: z.string().describe("The regex pattern to search for")
33
+ }, async (args) => executeGrepFiles(index, args));
34
+ const writeFile = tool("WriteFile", "Overwrites a file's content by fileId. Preserves the original line-ending style. Returns a unified diff of the change. The file must already exist in the index and be writable.", {
35
+ fileId: z.string().describe("The opaque file handle from ListFiles"),
36
+ content: z.string().describe("The new full content of the file")
37
+ }, async (args) => executeWriteFile(index, args));
38
+ const editFile = tool("EditFile", "Replaces a substring in a file by fileId. Tries exact match first, then line-trimmed and block-anchor matching to absorb minor whitespace drift. Set replaceAll: true to replace every occurrence.", {
39
+ fileId: z.string().describe("The opaque file handle from ListFiles"),
40
+ oldString: z.string().describe("The text to replace. Must match the file content (exact, line-trimmed, or block-anchored)."),
41
+ newString: z.string().describe("The text to replace it with"),
42
+ replaceAll: z.boolean().optional().describe("If true, replace every match. If false (default), require a unique match.")
43
+ }, async (args) => executeEditFile(index, args));
44
+ const createFile = tool("CreateFile", "Creates a new file under the dashboard root and adds it to the index. Returns the new fileId. The relativePath must not contain `.` or `..` segments and must not refer to an existing file.", {
45
+ relativePath: z.string().describe("Path relative to the dashboard root, e.g. 'components/Card.tsx'"),
46
+ content: z.string().describe("The content of the new file")
47
+ }, async (args) => executeCreateFile(index, options.dashboardRoot, args));
48
+ return {
49
+ server: createSdkMcpServer({
50
+ name: "dashboard-fs",
51
+ tools: [listFiles, readFile, grepFiles, writeFile, editFile, createFile]
52
+ }),
53
+ index,
54
+ // Useful for tests / inspection.
55
+ listFileIds: () => [...index.keys()]
56
+ };
57
+ }
@@ -0,0 +1,28 @@
1
+ import type { FileIndex } from "./file-index.js";
2
+ interface ReadFileArgs {
3
+ fileId: unknown;
4
+ offset?: unknown;
5
+ limit?: unknown;
6
+ }
7
+ interface ToolResult {
8
+ content: {
9
+ type: "text";
10
+ text: string;
11
+ }[];
12
+ isError?: boolean;
13
+ [key: string]: unknown;
14
+ }
15
+ /**
16
+ * Reads a file by handle. The agent never sees or constructs a path; the
17
+ * `fileId` is opaque and resolves to an absolute path only via the
18
+ * server-built index. Output format mirrors what models trained on the
19
+ * SDK Read tool already expect: `<line>: <content>` with truncation
20
+ * markers and a footer explaining offset/total.
21
+ */
22
+ export declare function executeReadFile(index: FileIndex, args: ReadFileArgs): Promise<ToolResult>;
23
+ /**
24
+ * Returns the index as a list of `{ fileId, relativePath, size, writable }`.
25
+ * The agent uses this to discover what's readable before issuing ReadFile.
26
+ */
27
+ export declare function executeListFiles(index: FileIndex): ToolResult;
28
+ export {};