@frebreco/canvas 0.3.0-next.1 → 0.3.0-next.3
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/package.json +1 -1
- package/src/server/board-mcp.ts +115 -13
- package/src/server/files.ts +13 -1
- package/src/server/scratch.ts +115 -0
- package/src/server/server.ts +5 -0
- package/src/shared/board-tools.ts +123 -7
package/package.json
CHANGED
package/src/server/board-mcp.ts
CHANGED
|
@@ -12,12 +12,18 @@
|
|
|
12
12
|
* paths are checked against the shared set here first (ADR 0002), so the
|
|
13
13
|
* agent hears why a file can't be shown. It listens on loopback only: agents
|
|
14
14
|
* run on this machine.
|
|
15
|
+
*
|
|
16
|
+
* Scratch files (ADR 0005) never reach the browser as content: a call's
|
|
17
|
+
* `content` becomes a file in `.canvas/scratch/` here, and the call goes on
|
|
18
|
+
* with its path. Reading and writing them needs no board, so those tools are
|
|
19
|
+
* answered here.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import { randomBytes } from "node:crypto";
|
|
18
23
|
import type { McpServer } from "@agentclientprotocol/sdk";
|
|
19
24
|
import { BOARD_SERVER_NAME, BOARD_TOOLS, boardInstructions } from "../shared/board-tools";
|
|
20
25
|
import type { FileContent } from "../shared/protocol";
|
|
26
|
+
import type { Scratch } from "./scratch";
|
|
21
27
|
|
|
22
28
|
export interface BoardCall {
|
|
23
29
|
readonly callId: string;
|
|
@@ -29,6 +35,7 @@ export interface BoardCall {
|
|
|
29
35
|
export interface BoardMcpOptions {
|
|
30
36
|
/** A file as the shared set lets it out. */
|
|
31
37
|
readonly read: (path: string) => FileContent;
|
|
38
|
+
readonly scratch: Pick<Scratch, "create" | "write" | "read" | "list" | "remove">;
|
|
32
39
|
/** Send a call to the host's browser; false if none is connected. */
|
|
33
40
|
readonly relay: (call: BoardCall) => boolean;
|
|
34
41
|
readonly timeoutMs?: number;
|
|
@@ -151,22 +158,84 @@ export class BoardMcp {
|
|
|
151
158
|
private async call(
|
|
152
159
|
sessionId: string,
|
|
153
160
|
tool: string,
|
|
154
|
-
|
|
161
|
+
input: Record<string, unknown>,
|
|
155
162
|
): Promise<ToolResult> {
|
|
156
163
|
if (!BOARD_TOOLS.some((t) => t.name === tool)) return failed(`no tool ${tool}`);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
const args = { ...input };
|
|
165
|
+
const notes: string[] = [];
|
|
166
|
+
// Content becomes a scratch file; the board only ever sees its path.
|
|
167
|
+
const created: string[] = [];
|
|
168
|
+
const undo = () => created.forEach((path) => this.options.scratch.remove(path));
|
|
169
|
+
try {
|
|
170
|
+
switch (tool) {
|
|
171
|
+
case "read_board_file":
|
|
172
|
+
return done(this.readScratch(args.path));
|
|
173
|
+
case "write_board_file":
|
|
174
|
+
return done(this.writeScratch(args));
|
|
167
175
|
}
|
|
176
|
+
if (args.content !== undefined) {
|
|
177
|
+
if (args.path !== undefined) throw new Error("give either path or content, not both");
|
|
178
|
+
args.path = this.createScratch(args.name, args.content, created, notes);
|
|
179
|
+
delete args.content;
|
|
180
|
+
delete args.name;
|
|
181
|
+
} else this.checkPath(args, notes);
|
|
182
|
+
if (Array.isArray(args.files))
|
|
183
|
+
args.files = args.files.map((value: unknown) => {
|
|
184
|
+
if (typeof value !== "object" || value === null)
|
|
185
|
+
throw new Error("each entry of files is an object with a display path");
|
|
186
|
+
const entry = { ...(value as Record<string, unknown>) };
|
|
187
|
+
if (entry.content !== undefined) {
|
|
188
|
+
if (entry.path !== undefined)
|
|
189
|
+
throw new Error("give a list entry either path or content, not both");
|
|
190
|
+
// No name: one from the display path's last segment, e.g. "1 Overview.md" → 1-Overview.md.
|
|
191
|
+
const name =
|
|
192
|
+
entry.name ??
|
|
193
|
+
(String(entry.display ?? "")
|
|
194
|
+
.split("/")
|
|
195
|
+
.at(-1)!
|
|
196
|
+
.replace(/[^\w.-]+/g, "-")
|
|
197
|
+
.replace(/^[^\w]+/, "") ||
|
|
198
|
+
"file");
|
|
199
|
+
entry.path = this.createScratch(name, entry.content, created, notes);
|
|
200
|
+
delete entry.content;
|
|
201
|
+
delete entry.name;
|
|
202
|
+
} else this.checkPath(entry, notes);
|
|
203
|
+
return entry;
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
undo();
|
|
207
|
+
return failed((error as Error).message);
|
|
208
|
+
}
|
|
209
|
+
if (tool === "view_board") {
|
|
210
|
+
const scratch = this.options.scratch.list();
|
|
211
|
+
notes.push(
|
|
212
|
+
scratch.length ? `Scratch files: ${scratch.join(", ")}.` : "No scratch files yet.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const answer = await this.relay(sessionId, tool, args);
|
|
217
|
+
if (answer.isError) {
|
|
218
|
+
undo();
|
|
219
|
+
return answer;
|
|
220
|
+
}
|
|
221
|
+
return notes.length ? done([answer.content[0]!.text, ...notes].join("\n")) : answer;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A named file must be in the shared set (or a scratch file), and have the lines asked for. */
|
|
225
|
+
private checkPath(args: Record<string, unknown>, notes: string[]) {
|
|
226
|
+
if (typeof args.path !== "string" || !args.path.trim()) return;
|
|
227
|
+
const path = args.path.trim().replace(/^(\.\/)+/, "");
|
|
228
|
+
const file = this.options.read(path);
|
|
229
|
+
if (file.kind === "denied") throw new Error(file.reason);
|
|
230
|
+
if (file.kind === "missing")
|
|
231
|
+
notes.push(`(${path} doesn't exist yet; the frame shows it as soon as it is written.)`);
|
|
232
|
+
if (file.kind === "text" && typeof args.start_line === "number") {
|
|
233
|
+
const lines = file.text.split("\n").length - (file.text.endsWith("\n") ? 1 : 0);
|
|
234
|
+
if (args.start_line > lines) throw new Error(`${path} has ${lines} lines`);
|
|
168
235
|
}
|
|
236
|
+
}
|
|
169
237
|
|
|
238
|
+
private relay(sessionId: string, tool: string, args: Record<string, unknown>) {
|
|
170
239
|
const callId = crypto.randomUUID();
|
|
171
240
|
const result = new Promise<ToolResult>((resolve) => {
|
|
172
241
|
const timer = setTimeout(
|
|
@@ -182,8 +251,41 @@ export class BoardMcp {
|
|
|
182
251
|
"the board isn't open right now: it lives in the host's browser, which is not connected",
|
|
183
252
|
);
|
|
184
253
|
}
|
|
185
|
-
|
|
186
|
-
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A new scratch file for `content`; its path. Says so in `notes`. */
|
|
258
|
+
private createScratch(name: unknown, content: unknown, created: string[], notes: string[]) {
|
|
259
|
+
if (typeof content !== "string") throw new Error("content must be text");
|
|
260
|
+
if (typeof name !== "string" || !name.trim())
|
|
261
|
+
throw new Error("content needs a name for its scratch file, e.g. overview.md");
|
|
262
|
+
const path = this.options.scratch.create(name.trim(), content);
|
|
263
|
+
created.push(path);
|
|
264
|
+
const taken = !path.endsWith(`/${name.trim()}`);
|
|
265
|
+
notes.push(`Wrote scratch file ${path}${taken ? ` (${name.trim()} was taken)` : ""}.`);
|
|
266
|
+
return path;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private readScratch(path: unknown): string {
|
|
270
|
+
if (typeof path !== "string") throw new Error("path must be a canvas:scratch/… path");
|
|
271
|
+
const file = this.options.scratch.read(path.trim());
|
|
272
|
+
if (file.kind === "text") return file.text;
|
|
273
|
+
if (file.kind === "denied") throw new Error(file.reason);
|
|
274
|
+
if (file.kind === "missing")
|
|
275
|
+
throw new Error(`${path} doesn't exist; view_board lists the scratch files`);
|
|
276
|
+
throw new Error(`${path} can't be read`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private writeScratch(args: Record<string, unknown>): string {
|
|
280
|
+
if (typeof args.path === "string" && args.path.trim()) {
|
|
281
|
+
if (args.name !== undefined) throw new Error("give either path (overwrite) or name (create)");
|
|
282
|
+
if (typeof args.content !== "string") throw new Error("content must be text");
|
|
283
|
+
this.options.scratch.write(args.path.trim(), args.content);
|
|
284
|
+
return `Wrote ${args.path.trim()}; frames showing it update.`;
|
|
285
|
+
}
|
|
286
|
+
const notes: string[] = [];
|
|
287
|
+
this.createScratch(args.name, args.content, [], notes);
|
|
288
|
+
return notes[0]!;
|
|
187
289
|
}
|
|
188
290
|
}
|
|
189
291
|
|
package/src/server/files.ts
CHANGED
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
* One recursive watcher covers the working dir: editors and agents often
|
|
9
9
|
* replace files rather than write them in place, and a frame may wait for a
|
|
10
10
|
* file (or its directory) that does not exist yet.
|
|
11
|
+
*
|
|
12
|
+
* Scratch files (`canvas:scratch/…`, ADR 0005) are read and listed alongside;
|
|
13
|
+
* the watcher skips `.canvas/`, so `Scratch` reports its writes itself.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import { readFileSync, statSync, watch, type FSWatcher } from "node:fs";
|
|
14
17
|
import { sep } from "node:path";
|
|
15
18
|
import type { FileContent } from "../shared/protocol";
|
|
19
|
+
import { isScratchPath, Scratch } from "./scratch";
|
|
16
20
|
import { SharedSet } from "./shared-set";
|
|
17
21
|
|
|
18
22
|
export const MAX_FILE_BYTES = 1024 * 1024;
|
|
@@ -35,11 +39,13 @@ export class Files {
|
|
|
35
39
|
dir: string,
|
|
36
40
|
private readonly onFile: (path: string, file: FileContent) => void,
|
|
37
41
|
private readonly onTree: (paths: ReadonlyArray<string>) => void,
|
|
42
|
+
private readonly scratch: Scratch = new Scratch(dir),
|
|
38
43
|
) {
|
|
39
44
|
this.shared = new SharedSet(dir);
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
read(path: string): FileContent {
|
|
48
|
+
if (isScratchPath(path)) return this.scratch.read(path);
|
|
43
49
|
let absolute: string;
|
|
44
50
|
try {
|
|
45
51
|
absolute = this.shared.resolve(path);
|
|
@@ -83,6 +89,12 @@ export class Files {
|
|
|
83
89
|
this.ensureWatcher();
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
/** A scratch file was written: re-send it, and the tree if it is new. */
|
|
93
|
+
scratchChanged(path: string): void {
|
|
94
|
+
if (this.subscribers.has(path)) this.send(path);
|
|
95
|
+
if (this.treeWatched) this.sendTree();
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
stop(): void {
|
|
87
99
|
this.watcher?.close();
|
|
88
100
|
if (this.timer) clearTimeout(this.timer);
|
|
@@ -97,7 +109,7 @@ export class Files {
|
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
private sendTree() {
|
|
100
|
-
const paths = this.shared.list();
|
|
112
|
+
const paths = [...this.shared.list(), ...this.scratch.list()];
|
|
101
113
|
const key = paths.join("\0");
|
|
102
114
|
if (key === this.lastTree) return;
|
|
103
115
|
this.lastTree = key;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scratch files (ADR 0005): content an agent writes for the board only —
|
|
3
|
+
* a write-up, an HTML visualisation — kept in `<dir>/.canvas/scratch/`, out
|
|
4
|
+
* of the project. Board paths name them `canvas:scratch/<name>`; they are
|
|
5
|
+
* read like the shared set's files and mirrored the same way.
|
|
6
|
+
*
|
|
7
|
+
* Only agents write them, through their board tools: nothing a browser
|
|
8
|
+
* sends reaches `create` or `write`. One flat namespace; a name is never
|
|
9
|
+
* taken twice, so creating one never overwrites another agent's file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { SCRATCH_PREFIX } from "../shared/board-tools";
|
|
23
|
+
import type { FileContent } from "../shared/protocol";
|
|
24
|
+
|
|
25
|
+
export const MAX_SCRATCH_BYTES = 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
export const isScratchPath = (path: string) => path.startsWith(SCRATCH_PREFIX);
|
|
28
|
+
|
|
29
|
+
/** A name as agents may give it: one plain segment, e.g. `auth-overview.md`. */
|
|
30
|
+
const NAME = /^[\w][\w.-]*$/;
|
|
31
|
+
|
|
32
|
+
export class Scratch {
|
|
33
|
+
private readonly dir: string;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
root: string,
|
|
37
|
+
/** A scratch file was written: `canvas:scratch/<name>`. */
|
|
38
|
+
private readonly onChange: (path: string) => void = () => {},
|
|
39
|
+
) {
|
|
40
|
+
this.dir = join(root, ".canvas", "scratch");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A new file, under `name` or, if that is taken, `name-2`, `name-3`…; its path. */
|
|
44
|
+
create(name: string, text: string): string {
|
|
45
|
+
checkName(name);
|
|
46
|
+
checkSize(text);
|
|
47
|
+
const dot = name.lastIndexOf(".");
|
|
48
|
+
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
|
|
49
|
+
let free = name;
|
|
50
|
+
for (let n = 2; existsSync(join(this.dir, free)); n++) free = `${stem}-${n}${ext}`;
|
|
51
|
+
mkdirSync(this.dir, { recursive: true });
|
|
52
|
+
writeFileSync(join(this.dir, free), text);
|
|
53
|
+
const path = SCRATCH_PREFIX + free;
|
|
54
|
+
this.onChange(path);
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Overwrite an existing scratch file. */
|
|
59
|
+
write(path: string, text: string): void {
|
|
60
|
+
const name = this.name(path);
|
|
61
|
+
if (!existsSync(join(this.dir, name)))
|
|
62
|
+
throw new Error(`${path} doesn't exist; give a name to create it`);
|
|
63
|
+
checkSize(text);
|
|
64
|
+
writeFileSync(join(this.dir, name), text);
|
|
65
|
+
this.onChange(path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Undo a `create` whose board call failed. */
|
|
69
|
+
remove(path: string): void {
|
|
70
|
+
rmSync(join(this.dir, this.name(path)), { force: true });
|
|
71
|
+
this.onChange(path);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
read(path: string): FileContent {
|
|
75
|
+
let name: string;
|
|
76
|
+
try {
|
|
77
|
+
name = this.name(path);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { kind: "denied", reason: (error as Error).message };
|
|
80
|
+
}
|
|
81
|
+
const file = join(this.dir, name);
|
|
82
|
+
const stat = statSync(file, { throwIfNoEntry: false });
|
|
83
|
+
if (!stat?.isFile()) return { kind: "missing" };
|
|
84
|
+
if (stat.size > MAX_SCRATCH_BYTES) return { kind: "too-large", size: stat.size };
|
|
85
|
+
return { kind: "text", text: readFileSync(file, "utf8") };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every scratch file's path, sorted. */
|
|
89
|
+
list(): string[] {
|
|
90
|
+
if (!existsSync(this.dir)) return [];
|
|
91
|
+
return readdirSync(this.dir, { withFileTypes: true })
|
|
92
|
+
.filter((entry) => entry.isFile() && NAME.test(entry.name))
|
|
93
|
+
.map((entry) => SCRATCH_PREFIX + entry.name)
|
|
94
|
+
.sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private name(path: string): string {
|
|
98
|
+
if (!isScratchPath(path)) throw new Error(`not a scratch file: ${path}`);
|
|
99
|
+
const name = path.slice(SCRATCH_PREFIX.length);
|
|
100
|
+
checkName(name);
|
|
101
|
+
return name;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkName(name: string) {
|
|
106
|
+
if (!NAME.test(name) || name.length > 120)
|
|
107
|
+
throw new Error(
|
|
108
|
+
`scratch file names are one plain segment of letters, digits, '.', '_' and '-' (got ${JSON.stringify(name)})`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function checkSize(text: string) {
|
|
113
|
+
if (Buffer.byteLength(text) > MAX_SCRATCH_BYTES)
|
|
114
|
+
throw new Error("scratch files are at most 1 MiB");
|
|
115
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ClientToServer, ServerToClient } from "../shared/protocol";
|
|
|
11
11
|
import { AgentManager, detectAgents } from "./agents";
|
|
12
12
|
import { BoardMcp } from "./board-mcp";
|
|
13
13
|
import { Files } from "./files";
|
|
14
|
+
import { Scratch } from "./scratch";
|
|
14
15
|
import { Store } from "./store";
|
|
15
16
|
import { Terminals } from "./terminals";
|
|
16
17
|
|
|
@@ -31,15 +32,19 @@ export async function serve(options: ServeOptions) {
|
|
|
31
32
|
for (const ws of clients) ws.send(text);
|
|
32
33
|
};
|
|
33
34
|
|
|
35
|
+
// Scratch files are written by agents' board tools; `files` mirrors them.
|
|
36
|
+
const scratch = new Scratch(options.dir, (path) => files.scratchChanged(path));
|
|
34
37
|
const files = new Files(
|
|
35
38
|
options.dir,
|
|
36
39
|
(path, file) => broadcast({ t: "file", path, file }),
|
|
37
40
|
(paths) => broadcast({ t: "tree", paths }),
|
|
41
|
+
scratch,
|
|
38
42
|
);
|
|
39
43
|
process.on("exit", () => files.stop());
|
|
40
44
|
// Board tools: an agent's call goes to one host browser (the board is there).
|
|
41
45
|
const boardMcp = new BoardMcp({
|
|
42
46
|
read: (path) => files.read(path),
|
|
47
|
+
scratch,
|
|
43
48
|
relay: (call) => {
|
|
44
49
|
const [ws] = clients;
|
|
45
50
|
if (!ws) return false;
|
|
@@ -14,6 +14,8 @@ export const BOARD_TOOL_NAMES = [
|
|
|
14
14
|
"open_frame",
|
|
15
15
|
"update_frame",
|
|
16
16
|
"close_frame",
|
|
17
|
+
"read_board_file",
|
|
18
|
+
"write_board_file",
|
|
17
19
|
] as const;
|
|
18
20
|
export type BoardToolName = (typeof BOARD_TOOL_NAMES)[number];
|
|
19
21
|
|
|
@@ -39,7 +41,25 @@ interface FileTarget {
|
|
|
39
41
|
readonly end_line?: number;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
/** A new scratch file to show (ADR 0005); `canvas serve` turns it into a `path`. */
|
|
45
|
+
interface ScratchContent {
|
|
46
|
+
readonly name?: string;
|
|
47
|
+
readonly content?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One entry of a file frame's list (ADR 0005): a file at a display path. */
|
|
51
|
+
export interface FileListEntry extends ScratchContent {
|
|
52
|
+
readonly display: string;
|
|
53
|
+
readonly path?: string;
|
|
54
|
+
readonly start_line?: number;
|
|
55
|
+
readonly end_line?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface FileList {
|
|
59
|
+
readonly files?: ReadonlyArray<FileListEntry>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface OpenFrameArgs extends Placement, FileTarget, ScratchContent, FileList {
|
|
43
63
|
readonly type: FrameKind;
|
|
44
64
|
readonly title?: string;
|
|
45
65
|
readonly url?: string;
|
|
@@ -47,7 +67,7 @@ export interface OpenFrameArgs extends Placement, FileTarget {
|
|
|
47
67
|
readonly draft?: string;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
|
-
export interface UpdateFrameArgs extends Placement, FileTarget {
|
|
70
|
+
export interface UpdateFrameArgs extends Placement, FileTarget, ScratchContent, FileList {
|
|
51
71
|
readonly frame: string;
|
|
52
72
|
readonly title?: string;
|
|
53
73
|
readonly url?: string;
|
|
@@ -58,6 +78,17 @@ export interface CloseFrameArgs {
|
|
|
58
78
|
readonly frame: string;
|
|
59
79
|
}
|
|
60
80
|
|
|
81
|
+
export interface ReadBoardFileArgs {
|
|
82
|
+
readonly path: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface WriteBoardFileArgs extends ScratchContent {
|
|
86
|
+
readonly path?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Where scratch files live, as board paths name them. */
|
|
90
|
+
export const SCRATCH_PREFIX = "canvas:scratch/";
|
|
91
|
+
|
|
61
92
|
const placement = {
|
|
62
93
|
next_to: {
|
|
63
94
|
type: "string",
|
|
@@ -74,12 +105,60 @@ const placement = {
|
|
|
74
105
|
const fileTarget = {
|
|
75
106
|
path: {
|
|
76
107
|
type: "string",
|
|
77
|
-
description:
|
|
108
|
+
description:
|
|
109
|
+
"File path relative to the project root, e.g. src/auth/session.ts, or a scratch file: " +
|
|
110
|
+
"canvas:scratch/<name>.",
|
|
78
111
|
},
|
|
79
112
|
start_line: { type: "integer", minimum: 1, description: "First line to show and highlight." },
|
|
80
113
|
end_line: { type: "integer", minimum: 1, description: "Last highlighted line (inclusive)." },
|
|
81
114
|
};
|
|
82
115
|
|
|
116
|
+
const scratchContent = {
|
|
117
|
+
content: {
|
|
118
|
+
type: "string",
|
|
119
|
+
description:
|
|
120
|
+
"Instead of path: the text of a new scratch file to show — content for this board only, " +
|
|
121
|
+
"like a write-up or an HTML visualisation. canvas keeps it outside the project.",
|
|
122
|
+
},
|
|
123
|
+
name: {
|
|
124
|
+
type: "string",
|
|
125
|
+
description:
|
|
126
|
+
"With content: the scratch file's name, e.g. auth-overview.md; its extension decides how " +
|
|
127
|
+
"it shows. A taken name gets a suffix; the result says which path you got.",
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const fileList = {
|
|
132
|
+
files: {
|
|
133
|
+
type: "array",
|
|
134
|
+
description:
|
|
135
|
+
"file: a list of files for the frame's tree, for people to click through at their pace — " +
|
|
136
|
+
"e.g. the files of one feature, with a write-up first. Each entry is a project file or a " +
|
|
137
|
+
"scratch file at a display path you choose: make folders, rename, order it (a folder sorts " +
|
|
138
|
+
"where its first entry is; no folders for a flat list). The frame shows path, else the " +
|
|
139
|
+
"first entry. update_frame replaces the list; [] removes it.",
|
|
140
|
+
items: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
display: {
|
|
144
|
+
type: "string",
|
|
145
|
+
description:
|
|
146
|
+
'Where it shows in the tree, e.g. "1 Overview.md" or "Auth/session.ts". Unique.',
|
|
147
|
+
},
|
|
148
|
+
path: { type: "string", description: "A project file or canvas:scratch/<name>." },
|
|
149
|
+
...scratchContent,
|
|
150
|
+
start_line: {
|
|
151
|
+
type: "integer",
|
|
152
|
+
minimum: 1,
|
|
153
|
+
description: "Lines to open it at, shown as a badge.",
|
|
154
|
+
},
|
|
155
|
+
end_line: { type: "integer", minimum: 1 },
|
|
156
|
+
},
|
|
157
|
+
required: ["display"],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
83
162
|
export const BOARD_TOOLS: ReadonlyArray<{
|
|
84
163
|
readonly name: BoardToolName;
|
|
85
164
|
readonly description: string;
|
|
@@ -105,7 +184,9 @@ export const BOARD_TOOLS: ReadonlyArray<{
|
|
|
105
184
|
name: "open_frame",
|
|
106
185
|
description:
|
|
107
186
|
"Open a new frame on the board, in your own cluster unless placed next to another frame. " +
|
|
108
|
-
"file: a project file (read-only, live), optionally at a line range which gets highlighted
|
|
187
|
+
"file: a project file (read-only, live), optionally at a line range which gets highlighted, " +
|
|
188
|
+
"or a scratch file: content you pass, kept by canvas outside the project. A file frame can " +
|
|
189
|
+
"also carry a list of files (files): one frame to click through, instead of many. " +
|
|
109
190
|
"browser: a URL, loaded by each viewer's own browser. terminal: an idle shell people can " +
|
|
110
191
|
"type into. agent: another agent session; `draft` pre-fills its prompt, a person sends it.",
|
|
111
192
|
inputSchema: {
|
|
@@ -113,6 +194,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
|
|
|
113
194
|
properties: {
|
|
114
195
|
type: { type: "string", enum: ["file", "browser", "terminal", "agent"] },
|
|
115
196
|
...fileTarget,
|
|
197
|
+
...scratchContent,
|
|
198
|
+
...fileList,
|
|
116
199
|
url: { type: "string", description: "browser: an http(s) URL." },
|
|
117
200
|
agent: { type: "string", description: "agent: which agent runs it (see view_board)." },
|
|
118
201
|
draft: { type: "string", description: "agent: a prompt draft for people to send." },
|
|
@@ -125,7 +208,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
|
|
|
125
208
|
{
|
|
126
209
|
name: "update_frame",
|
|
127
210
|
description:
|
|
128
|
-
"Change a frame: point a file frame at another file
|
|
211
|
+
"Change a frame: point a file frame at another file, line range or new scratch file " +
|
|
212
|
+
"(content), give it a list of files or replace it (files), switch a markdown " +
|
|
129
213
|
"or HTML file between preview and source, change a browser frame's URL, rename a frame, or move " +
|
|
130
214
|
"it next to another frame.",
|
|
131
215
|
inputSchema: {
|
|
@@ -133,6 +217,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
|
|
|
133
217
|
properties: {
|
|
134
218
|
frame: { type: "string", description: "Id of the frame to change." },
|
|
135
219
|
...fileTarget,
|
|
220
|
+
...scratchContent,
|
|
221
|
+
...fileList,
|
|
136
222
|
view: { type: "string", enum: ["preview", "source"] },
|
|
137
223
|
url: { type: "string" },
|
|
138
224
|
title: { type: "string" },
|
|
@@ -151,6 +237,33 @@ export const BOARD_TOOLS: ReadonlyArray<{
|
|
|
151
237
|
required: ["frame"],
|
|
152
238
|
},
|
|
153
239
|
},
|
|
240
|
+
{
|
|
241
|
+
name: "read_board_file",
|
|
242
|
+
description:
|
|
243
|
+
"Read a scratch file (canvas:scratch/<name>), e.g. one another agent wrote. view_board " +
|
|
244
|
+
"lists them.",
|
|
245
|
+
inputSchema: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: { path: { type: "string", description: "canvas:scratch/<name>" } },
|
|
248
|
+
required: ["path"],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: "write_board_file",
|
|
253
|
+
description:
|
|
254
|
+
"Write a scratch file without opening a frame. With name: a new one. With path: overwrite " +
|
|
255
|
+
"an existing one (any agent's); every frame showing it updates. Show a new one with " +
|
|
256
|
+
"open_frame (path).",
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: {
|
|
260
|
+
path: { type: "string", description: "canvas:scratch/<name> to overwrite." },
|
|
261
|
+
name: scratchContent.name,
|
|
262
|
+
content: { type: "string", description: "The file's full text." },
|
|
263
|
+
},
|
|
264
|
+
required: ["content"],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
154
267
|
];
|
|
155
268
|
|
|
156
269
|
/** The agent's standing context, sent as MCP server instructions. */
|
|
@@ -159,7 +272,7 @@ export function boardInstructions(frameId: string): string {
|
|
|
159
272
|
|
|
160
273
|
Frames that sit close together form a cluster: people keep related work together that way, and your own cluster is your workspace. Within a cluster frames sit in rows; frames in a row share a height.
|
|
161
274
|
|
|
162
|
-
The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NAMES.join(", ")}. Use them when showing something on the board helps the people you work with — e.g. asked to show the files relevant to a topic,
|
|
275
|
+
The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NAMES.join(", ")}. Use them when showing something on the board helps the people you work with — e.g. asked to show the files relevant to a topic, show them on the board (one file, or a list of them) at the relevant lines instead of pasting code. Don't use them when a plain answer is enough.
|
|
163
276
|
|
|
164
277
|
- Look first (view_board). Reuse or retarget a frame (update_frame) rather than open a duplicate.
|
|
165
278
|
- New frames go into your cluster by default; that is almost always right. Keep it to a handful per request.
|
|
@@ -168,5 +281,8 @@ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NA
|
|
|
168
281
|
- A terminal frame is an idle shell for people; you cannot type into it. Run commands with your own tools.
|
|
169
282
|
- An agent frame starts another agent. You can leave a draft prompt in it; only a person can send it.
|
|
170
283
|
- A browser frame loads its URL in each viewer's own browser, so localhost means their machine, not this one: only the host sees a localhost URL, guests get a notice.
|
|
171
|
-
-
|
|
284
|
+
- Scratch files hold what exists only to be shown on this board: a write-up, a diagram, an HTML visualisation. Pass the text as \`content\` (with a \`name\`) to open_frame or update_frame, or use write_board_file; canvas keeps them outside the project, as canvas:scratch/<name>. Don't write such files into the project for the board. Anything else — a temp file for your own work, a script, test data — goes wherever it would without canvas.
|
|
285
|
+
- Any agent may read (read_board_file) and overwrite (write_board_file) any scratch file; view_board lists them.
|
|
286
|
+
- To show several files for one topic, prefer one file frame with a list (files) over a frame per file: people click through it at their own pace. You decide the tree: display paths, folders, order, line ranges. A write-up (a scratch file) at the top and a visualisation at the bottom fit in the same list.
|
|
287
|
+
- Markdown and HTML files render. HTML runs its scripts, but relative links and assets (CSS, images, other scripts) don't load, so inline them.`;
|
|
172
288
|
}
|