@frebreco/canvas 0.0.1 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FreshlyBrewedCode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1 +1,50 @@
1
- Placeholder for `@frebreco/canvas`. See https://github.com/FreshlyBrewedCode/canvas.
1
+ # canvas
2
+
3
+ **A multiplayer canvas whose frames are coding-agent sessions, files, browser previews and
4
+ terminals.** The agents run on one person's machine; everyone else joins peer to peer from the
5
+ browser.
6
+
7
+ ```bash
8
+ cd your-project
9
+ bunx @frebreco/canvas serve
10
+ ```
11
+
12
+ `canvas serve` prints a host link. Open it, then share the guest link from the board. Requires
13
+ [Bun](https://bun.sh) and at least one ACP agent: `claude` (Claude Code) or `opencode`.
14
+
15
+ Docs: **[canvas.frebreco.de](https://canvas.frebreco.de)** —
16
+ [quick start](https://canvas.frebreco.de/docs/quick-start),
17
+ [security](https://canvas.frebreco.de/docs/security),
18
+ [architecture](https://canvas.frebreco.de/docs/architecture).
19
+
20
+ > **A prototype.** canvas is proving a concept, not hardened. The host link controls agents on
21
+ > your machine: never share it. Read [security](https://canvas.frebreco.de/docs/security) before
22
+ > inviting anyone you would not hand a shell.
23
+
24
+ ## Releases
25
+
26
+ | Channel | Install | Web app |
27
+ | -------- | ------------------------------- | ------------------------------------ |
28
+ | `latest` | `bunx @frebreco/canvas serve` | https://ui.canvas.frebreco.de |
29
+ | `next` | `bunx @frebreco/canvas@next serve` | https://ui.canvas.frebreco.de/next |
30
+
31
+ Every merge to `main` publishes a `next` pre-release; stable releases are cut by hand. Each channel
32
+ opens the web app built from the same commit, so both halves speak the same protocol.
33
+
34
+ ## Development
35
+
36
+ ```bash
37
+ bun install
38
+ bun run dev # the web app, Vite on :4417
39
+ bun src/cli.ts serve --dir <project> # the local server, prints a link to :4417
40
+ bun run check # format, lint, typecheck, test
41
+ ```
42
+
43
+ `AGENTS.md` is the repo guide; `docs/adr/` holds the decisions, `docs/findings/` the evidence,
44
+ `site/` the landing page and docs. Commits and PR titles follow
45
+ [Conventional Commits](https://www.conventionalcommits.org): PRs are squash-merged and their title
46
+ decides the next version.
47
+
48
+ ## License
49
+
50
+ MIT
package/bin/canvas.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The published launcher for `canvas`. The shebang asks for `bun`, and both
4
+ * `bunx` and `npx` honor a bin's shebang, so this normally runs under Bun and
5
+ * hands straight over to the raw-TypeScript CLI.
6
+ *
7
+ * It is plain JavaScript so it can still say something useful when Node runs
8
+ * it anyway (`node bin/canvas.js`, or a package manager that ignores the
9
+ * shebang): Node cannot execute the TypeScript behind it, and a syntax error
10
+ * would not tell anyone that Bun is what is missing.
11
+ *
12
+ * Do not give this file a `#!/usr/bin/env node` shebang: `bunx` honors it too,
13
+ * which would route every `bunx @frebreco/canvas` through Node.
14
+ */
15
+
16
+ if (typeof Bun === "undefined") {
17
+ console.error(
18
+ [
19
+ "",
20
+ " canvas requires Bun.",
21
+ "",
22
+ " This package ships raw TypeScript and runs on the Bun runtime.",
23
+ " Install Bun: https://bun.sh/docs/installation",
24
+ " Then run: bunx @frebreco/canvas serve",
25
+ "",
26
+ ].join("\n"),
27
+ );
28
+ process.exit(1);
29
+ }
30
+
31
+ await import("../src/cli.ts");
package/package.json CHANGED
@@ -1,7 +1,31 @@
1
1
  {
2
2
  "name": "@frebreco/canvas",
3
- "version": "0.0.1",
4
- "description": "Placeholder that reserves this name before the first real release.",
3
+ "version": "0.2.0",
4
+ "description": "A multiplayer canvas for coding agents that run on your machine.",
5
5
  "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/FreshlyBrewedCode/canvas.git" }
6
+ "type": "module",
7
+ "bin": {
8
+ "canvas": "bin/canvas.js"
9
+ },
10
+ "engines": {
11
+ "bun": ">=1.4.1"
12
+ },
13
+ "homepage": "https://canvas.frebreco.de",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/FreshlyBrewedCode/canvas.git"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "dependencies": {
25
+ "@agentclientprotocol/claude-agent-acp": "^0.81.2",
26
+ "@agentclientprotocol/sdk": "^1.5.0",
27
+ "@tanstack/ai": "^0.61.0",
28
+ "@tanstack/ai-acp": "^0.3.17",
29
+ "@tanstack/ai-sandbox": "^0.5.15"
30
+ }
7
31
  }
package/src/cli.ts ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * canvas — a multiplayer canvas for coding agents that run on your machine.
4
+ *
5
+ * canvas serve [--dir .] [--port 4418] [--web-url URL] [--tls-host NAME]
6
+ *
7
+ * Starts the local server and prints the link that opens the board as its
8
+ * host. `--tls-host` serves wss:// on that name (with `.certs/dev.{crt,key}`)
9
+ * so a browser on another device can be the host; without it
10
+ * the server only listens on loopback.
11
+ */
12
+
13
+ import { existsSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { parseArgs } from "node:util";
16
+ import { serve } from "./server/server";
17
+ import { defaultWebUrl } from "./server/web-url";
18
+
19
+ const manifest = (await Bun.file(join(import.meta.dir, "../package.json")).json()) as {
20
+ version?: string;
21
+ };
22
+
23
+ const { positionals, values } = parseArgs({
24
+ allowPositionals: true,
25
+ options: {
26
+ dir: { type: "string", default: "." },
27
+ port: { type: "string", default: "4418" },
28
+ "web-url": {
29
+ type: "string",
30
+ default: process.env.CANVAS_WEB_URL ?? defaultWebUrl(manifest.version),
31
+ },
32
+ "tls-host": { type: "string" },
33
+ cert: { type: "string", default: join(import.meta.dir, "../.certs/dev.crt") },
34
+ key: { type: "string", default: join(import.meta.dir, "../.certs/dev.key") },
35
+ },
36
+ });
37
+
38
+ if (positionals[0] !== "serve") {
39
+ console.log("usage: canvas serve [--dir .] [--port 4418] [--web-url URL] [--tls-host NAME]");
40
+ process.exit(positionals.length === 0 ? 0 : 1);
41
+ }
42
+
43
+ const dir = resolve(values.dir);
44
+ const tlsHost = values["tls-host"];
45
+ if (tlsHost && !(existsSync(values.cert) && existsSync(values.key))) {
46
+ console.error(
47
+ `--tls-host needs a certificate: tailscale cert --cert-file ${values.cert} --key-file ${values.key} ${tlsHost}`,
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ const { server, room } = await serve({
53
+ dir,
54
+ port: Number(values.port),
55
+ hostname: tlsHost ? "0.0.0.0" : "127.0.0.1",
56
+ ...(tlsHost && { tls: { cert: values.cert, key: values.key } }),
57
+ });
58
+
59
+ const serverUrl = tlsHost ? `wss://${tlsHost}:${server.port}` : `ws://127.0.0.1:${server.port}`;
60
+ // Secrets ride in the fragment, which browsers never send to the web host.
61
+ const fragment = new URLSearchParams({
62
+ k: room.key,
63
+ pk: room.hostPublicKey,
64
+ server: serverUrl,
65
+ token: room.token,
66
+ });
67
+ const link = `${values["web-url"].replace(/\/$/, "")}/?room=${room.roomId}#${fragment}`;
68
+
69
+ console.log(`canvas serving ${dir}`);
70
+ console.log(`\n open the board as host:\n ${link}\n`);
71
+ console.log(" keep this link to yourself — it controls agents on this machine.");
72
+ console.log(" share the guest link from the board instead.\n");
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ACP session config options (`session/new` → `configOptions`,
3
+ * `session/set_config_option`, `config_option_update`) mapped to canvas's
4
+ * wire shape (finding 04). Agents decide which options exist — Claude Code
5
+ * adds `effort` and `fast` only for models that support them, opencode lists
6
+ * every model its providers offer — so nothing here knows specific ids.
7
+ */
8
+
9
+ import type { SessionConfigOption } from "@agentclientprotocol/sdk";
10
+ import type {
11
+ AgentConfigChoice,
12
+ AgentConfigOption,
13
+ AgentConfigValue,
14
+ AgentSetting,
15
+ } from "../shared/protocol";
16
+
17
+ export function fromAcp(options: ReadonlyArray<SessionConfigOption>): AgentConfigOption[] {
18
+ return options.map((option) => {
19
+ const base = {
20
+ id: option.id,
21
+ name: option.name,
22
+ ...(option.description && { description: option.description }),
23
+ ...(option.category && { category: option.category }),
24
+ };
25
+ if (option.type === "boolean")
26
+ return { ...base, type: "boolean", value: option.currentValue, choices: [] };
27
+ const choices: AgentConfigChoice[] = [];
28
+ for (const entry of option.options) {
29
+ if ("group" in entry)
30
+ for (const choice of entry.options)
31
+ choices.push({ ...choiceOf(choice), group: entry.name });
32
+ else choices.push(choiceOf(entry));
33
+ }
34
+ return { ...base, type: "select", value: option.currentValue, choices };
35
+ });
36
+ }
37
+
38
+ const choiceOf = (choice: { value: string; name: string; description?: string | null }) => ({
39
+ value: choice.value,
40
+ name: choice.name,
41
+ ...(choice.description && { description: choice.description }),
42
+ });
43
+
44
+ export function settingsOf(options: ReadonlyArray<AgentConfigOption>): AgentSetting[] {
45
+ return options.map((option) => ({
46
+ id: option.id,
47
+ name: option.name,
48
+ ...(option.category && { category: option.category }),
49
+ value: option.value,
50
+ label: labelOf(option, option.value),
51
+ }));
52
+ }
53
+
54
+ export function labelOf(option: AgentConfigOption, value: AgentConfigValue): string {
55
+ if (typeof value === "boolean") return value ? "on" : "off";
56
+ return option.choices.find((choice) => choice.value === value)?.name ?? value;
57
+ }
58
+
59
+ /**
60
+ * Which of the wanted settings still differ from what the agent reports, in
61
+ * the order to apply them: the model first, since it decides which other
62
+ * options exist and what they accept. Values an option does not offer (any
63
+ * more) are skipped rather than sent.
64
+ */
65
+ export function pendingChanges(
66
+ options: ReadonlyArray<AgentConfigOption>,
67
+ wanted: ReadonlyArray<Pick<AgentSetting, "id" | "value">>,
68
+ ): Array<{ id: string; value: AgentConfigValue }> {
69
+ const byId = new Map(options.map((option) => [option.id, option]));
70
+ return wanted
71
+ .filter(({ id, value }) => {
72
+ const option = byId.get(id);
73
+ if (!option || option.value === value) return false;
74
+ return option.type === "boolean"
75
+ ? typeof value === "boolean"
76
+ : option.choices.some((choice) => choice.value === value);
77
+ })
78
+ .map(({ id, value }) => ({ id, value }))
79
+ .sort((a, b) => rank(byId.get(a.id)!) - rank(byId.get(b.id)!));
80
+ }
81
+
82
+ const rank = (option: AgentConfigOption) => (option.category === "model" ? 0 : 1);