agent-canvas 0.1.5 → 0.1.8

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 (64) hide show
  1. package/.github/workflows/ci.yml +23 -0
  2. package/.github/workflows/release.yml +36 -0
  3. package/CLAUDE.md +86 -0
  4. package/LICENSE +21 -0
  5. package/README.md +71 -0
  6. package/bin/agent-canvas.ts +26 -353
  7. package/bin/lib/commands/daemon.ts +51 -0
  8. package/bin/lib/commands/fetch.ts +20 -0
  9. package/bin/lib/commands/install.ts +48 -0
  10. package/bin/lib/commands/push.ts +67 -0
  11. package/bin/lib/commands/watch.ts +23 -0
  12. package/bin/lib/config.ts +10 -0
  13. package/bin/lib/daemon-lifecycle.ts +74 -0
  14. package/bin/lib/helpers.ts +78 -0
  15. package/bun.lock +176 -0
  16. package/daemon/build.ts +27 -61
  17. package/daemon/bunfig.toml +2 -0
  18. package/daemon/client/AnnotationProvider.tsx +2 -2
  19. package/daemon/client/AnnotationSidebar.tsx +199 -31
  20. package/daemon/client/App.tsx +39 -3
  21. package/daemon/client/FileBrowser.tsx +8 -7
  22. package/daemon/client/FileViewer.tsx +153 -16
  23. package/daemon/client/PlanRenderer.tsx +6 -3
  24. package/daemon/client/ResponsePreview.tsx +5 -116
  25. package/daemon/client/SessionSwitcher.tsx +21 -10
  26. package/daemon/client/compat/preact-all.ts +4 -0
  27. package/daemon/client/components/Markdown.tsx +44 -0
  28. package/daemon/client/components/ResponseNote.tsx +3 -8
  29. package/daemon/client/components/UserInput.tsx +2 -4
  30. package/daemon/client/components/index.ts +1 -0
  31. package/daemon/client/domDiff.ts +82 -2
  32. package/daemon/client/generateMarkdown.ts +46 -9
  33. package/daemon/client/highlightRange.ts +8 -6
  34. package/daemon/client/runtime.ts +2 -0
  35. package/daemon/client/styles.css +126 -0
  36. package/daemon/client/unifiedDiff.ts +155 -2
  37. package/daemon/client/utils.ts +10 -0
  38. package/daemon/dist/client.css +1 -1
  39. package/daemon/dist/client.js +83 -36
  40. package/daemon/dist/components.js +63 -5
  41. package/daemon/dist/index.html +5 -12
  42. package/daemon/dist/jsx-runtime.js +3 -0
  43. package/daemon/dist/preact-compat.js +1 -0
  44. package/daemon/langMap.ts +8 -0
  45. package/daemon/src/compiler.ts +28 -2
  46. package/daemon/src/handlers/api.ts +150 -0
  47. package/daemon/src/handlers/files.ts +114 -0
  48. package/daemon/src/handlers/static.ts +61 -0
  49. package/daemon/src/handlers/utils.ts +14 -0
  50. package/daemon/src/router.ts +16 -0
  51. package/daemon/src/server.ts +28 -403
  52. package/daemon/src/session.ts +43 -24
  53. package/daemon/src/websocket.ts +83 -0
  54. package/daemon/tsconfig.json +20 -0
  55. package/package.json +4 -14
  56. package/skills/canvas/SKILL.md +47 -22
  57. package/skills/canvas/components.md +31 -0
  58. package/skills/canvas/flows.md +2 -2
  59. package/tsconfig.json +14 -0
  60. package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
  61. package/daemon/dist/jsx-runtime-shim.js +0 -17
  62. package/daemon/dist/react-dom-client-shim.js +0 -4
  63. package/daemon/dist/react-dom-shim.js +0 -4
  64. package/daemon/dist/react-shim.js +0 -4
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ typecheck:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - uses: oven-sh/setup-bun@v2
15
+
16
+ - name: Install dependencies
17
+ run: bun install --frozen-lockfile
18
+
19
+ - name: Typecheck
20
+ run: bun run typecheck
21
+
22
+ - name: Build
23
+ run: bun run build
@@ -0,0 +1,36 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions: {}
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: oven-sh/setup-bun@v2
20
+
21
+ - uses: actions/setup-node@v4
22
+ with:
23
+ node-version: "24.x"
24
+ registry-url: "https://registry.npmjs.org"
25
+
26
+ - name: Install dependencies
27
+ run: bun install --frozen-lockfile
28
+
29
+ - name: Typecheck
30
+ run: bun run typecheck
31
+
32
+ - name: Build
33
+ run: bun run build
34
+
35
+ - name: Publish to npm
36
+ run: npm publish --provenance --access public
package/CLAUDE.md ADDED
@@ -0,0 +1,86 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project
6
+
7
+ Agent Canvas — an interactive browser-based visual canvas for Claude Code. Users author JSX documents that get compiled and rendered in the browser with annotation, feedback, and file browsing capabilities.
8
+
9
+ ## Architecture
10
+
11
+ Three components communicate via HTTP and WebSocket:
12
+
13
+ - **CLI** (`bin/agent-canvas.ts`) — pushes JSX canvases to the daemon, waits for user feedback, manages daemon lifecycle
14
+ - **Daemon** (`daemon/src/server.ts`) — Bun HTTP+WS server on port 19400 (`CANVAS_PORT`). Compiles JSX, manages sessions, serves the browser UI
15
+ - **Browser UI** (`daemon/client/`) — React 18 app loaded via CDN UMD with ESM import maps. Handles annotations, revision history, feedback collection, file browsing
16
+
17
+ **Data flow:** CLI pushes JSX → daemon compiles via `Bun.build()` → browser loads compiled JS module → user annotates/responds → feedback sent back to CLI via WebSocket
18
+
19
+ **Session model:** Each canvas push creates/updates a session identified by `CANVAS_SESSION_ID` (set by hook). Sessions are stored on disk at `~/.claude/agent-canvas/sessions/{id}/` with a revision system (`revisions/{rev}/plan.jsx`, `plan.compiled.js`, `feedback.md`).
20
+
21
+ ## Build
22
+
23
+ ```bash
24
+ bun install
25
+ bun run build # builds client assets to daemon/dist/
26
+ bun daemon/build.ts --watch # watch mode with debounced rebuild
27
+ ```
28
+
29
+ The build produces three bundles (runtime.js, components.js, client.js) plus Tailwind CSS. React is loaded from CDN, not bundled. Import maps route `#canvas/components` and `#canvas/runtime` to the local bundles.
30
+
31
+ ## Typecheck
32
+
33
+ ```bash
34
+ bun run typecheck # runs tsc --noEmit on both root (bin/) and daemon (client/ + src/)
35
+ ```
36
+
37
+ Two separate tsconfig files: `tsconfig.json` (root, covers `bin/`) and `daemon/tsconfig.json` (covers `daemon/src/` and `daemon/client/`). CI runs typecheck on every push and PR.
38
+
39
+ ## Key Technical Details
40
+
41
+ - **Runtime is Bun only** — no Node.js compatibility needed
42
+ - **JSX compilation** uses temp files because `Bun.build()` doesn't support `stdin`. See `daemon/src/compiler.ts`
43
+ - **Component imports are injected** by the compiler — authored JSX can use `Section`, `Task`, `CodeBlock`, etc. without imports
44
+ - **Adding a new component:** create in `daemon/client/components/`, export from `index.ts`, add to `COMPONENT_IMPORTS` in `daemon/src/compiler.ts`, rebuild
45
+ - **Bun's `spawn` throws synchronously** on missing executables — always check with `which` before spawning
46
+
47
+ ## Testing with Demo
48
+
49
+ A demo JSX file lives at `example/plan.jsx` in the project root. To test UI changes:
50
+
51
+ ```bash
52
+ # 1. Start daemon (if not running)
53
+ bun bin/agent-canvas.ts daemon start
54
+
55
+ # 2. Build client assets (use --watch for live reload)
56
+ bun daemon/build.ts --watch
57
+
58
+ # 3. Push the demo plan
59
+ CANVAS_SESSION_ID=planner-demo bun bin/agent-canvas.ts push example/plan.jsx --label "Demo"
60
+
61
+ # 4. Open in browser
62
+ # http://localhost:19400/s/planner-demo
63
+ ```
64
+
65
+ The CLI blocks waiting for feedback after push — press Ctrl+C to exit without submitting. Each push creates a new revision visible in the browser's revision selector.
66
+
67
+ ## Publishing
68
+
69
+ To release a new version:
70
+
71
+ 1. Bump the version in `package.json`
72
+ 2. Commit the change and create a git tag: `git tag v<version>`
73
+ 3. Push both the commit and the tag: `git push && git push origin v<version>`
74
+
75
+ The CI pipeline handles `npm publish` automatically when a new version tag is pushed. Do not run `npm publish` manually.
76
+
77
+ ## Bundled Skill
78
+
79
+ This package distributes a Claude Code skill in `skills/canvas/`. It teaches Claude Code how to use Agent Canvas — writing JSX canvases, pushing, waiting for feedback, iterating, and responding to user feedback. The skill is automatically available to users who install this package.
80
+
81
+ ## Environment Variables
82
+
83
+ - `CANVAS_SESSION_ID` — current session (set by SessionStart hook)
84
+ - `CANVAS_PROJECT_ROOT` — project directory for file serving
85
+ - `CANVAS_PORT` — daemon port (default: 19400)
86
+ - `CANVAS_TIMEOUT` — CLI feedback wait timeout (default: 1 hour)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Contember Limited
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 ADDED
@@ -0,0 +1,71 @@
1
+ # Agent Canvas
2
+
3
+ Interactive browser-based visual canvas for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Create rich, annotatable JSX documents that render in the browser — with revision history, inline annotations, and structured feedback that flows back to Claude.
4
+
5
+ ![License](https://img.shields.io/npm/l/agent-canvas)
6
+ ![npm](https://img.shields.io/npm/v/agent-canvas)
7
+
8
+ ## What it does
9
+
10
+ When Claude needs to present a plan, review, explanation, or decision — instead of dumping markdown in the terminal — it renders a full interactive canvas in your browser. You can:
11
+
12
+ - **Annotate** any element with inline comments
13
+ - **Choose options**, fill inputs, rate with sliders
14
+ - **Browse files** referenced in the canvas
15
+ - **Review revisions** as Claude iterates
16
+ - **Send structured feedback** back to Claude in one click
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # Register the skill and hooks in Claude Code
22
+ bunx agent-canvas install global # for all projects
23
+ bunx agent-canvas install local # for current project only
24
+ ```
25
+
26
+ This installs:
27
+ - The `/canvas` skill so Claude knows how to use it
28
+ - A `SessionStart` hook that sets up session IDs automatically
29
+
30
+ **Requires [Bun](https://bun.sh/) runtime.**
31
+
32
+ ## Usage in Claude Code
33
+
34
+ Once installed, use the `/canvas` command to create a canvas:
35
+
36
+ ```
37
+ > /canvas Plan the authentication refactor
38
+ > /canvas Show me an architecture overview
39
+ > /canvas Review this PR
40
+ ```
41
+
42
+ Claude creates a JSX document, pushes it to a local daemon, and opens it in your browser at `http://localhost:19400/s/<session-id>`.
43
+
44
+ ### Flow patterns
45
+
46
+ Claude uses different canvas flows depending on the task:
47
+
48
+ | Flow | When | Rounds |
49
+ |------|------|--------|
50
+ | **Feature** | Vague requests ("add auth") | Discovery → Requirements → Plan → Implementation → Summary |
51
+ | **Plan** | Clear requirements | Plan → Implementation → Summary |
52
+ | **Explain** | "How does X work?" | Single canvas |
53
+ | **Review** | Code/architecture review | Findings → Fix Plan → Summary |
54
+ | **Decision** | Choosing between options | Single canvas with comparison |
55
+
56
+ ## Development
57
+
58
+ ```bash
59
+ bun install
60
+ bun run build # build client assets
61
+ bun daemon/build.ts --watch # watch mode
62
+
63
+ # Test with demo canvas
64
+ bun bin/agent-canvas.ts daemon start
65
+ CANVAS_SESSION_ID=demo bun bin/agent-canvas.ts push example/plan.jsx --label "Demo"
66
+ # Open http://localhost:19400/s/demo
67
+ ```
68
+
69
+ ## License
70
+
71
+ MIT
@@ -1,377 +1,50 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { spawn } from "child_process";
4
- import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync, cpSync } from "fs";
5
- import { join, resolve, dirname } from "path";
6
- import { homedir, tmpdir } from "os";
7
- import { randomUUID } from "crypto";
8
-
9
- const PACKAGE_ROOT = resolve(join(dirname(import.meta.path), ".."));
10
- const DATA_DIR = join(homedir(), ".claude", "agent-canvas");
11
- const TEMP_DIR = join(tmpdir(), "agent-canvas");
12
- const DAEMON_PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
13
- const BASE_URL = `http://localhost:${DAEMON_PORT}`;
14
- const WS_URL = `ws://localhost:${DAEMON_PORT}`;
15
- const TIMEOUT_MS = parseInt(process.env.CANVAS_TIMEOUT || String(60 * 60 * 1000), 10);
16
- const PID_FILE = join(TEMP_DIR, "daemon.pid");
17
-
18
- function getSessionId(args?: string[]): string {
19
- // Check --session flag first
20
- if (args) {
21
- const idx = args.indexOf("--session");
22
- if (idx !== -1 && args[idx + 1]) {
23
- return args[idx + 1];
24
- }
25
- }
26
- return process.env.CANVAS_SESSION_ID || (() => {
27
- const id = randomUUID();
28
- console.error(`Warning: CANVAS_SESSION_ID not set, using generated ID: ${id}`);
29
- return id;
30
- })();
31
- }
32
-
33
- async function main() {
34
- const args = process.argv.slice(2);
35
-
36
- if (args.length === 0) {
37
- printUsage();
38
- process.exit(1);
39
- }
40
-
41
- const command = args[0];
42
-
43
- switch (command) {
44
- case "push": return handlePush(args.slice(1));
45
- case "wait": return handleWait(args.slice(1));
46
- case "install": return handleInstall(args.slice(1));
47
- case "daemon": return handleDaemon(args.slice(1));
48
- default:
49
- console.error(`Unknown command: ${command}`);
50
- printUsage();
51
- process.exit(1);
52
- }
53
- }
3
+ import { handlePush } from "./lib/commands/push.ts";
4
+ import { handleFetch } from "./lib/commands/fetch.ts";
5
+ import { handleWatch } from "./lib/commands/watch.ts";
6
+ import { handleDaemon } from "./lib/commands/daemon.ts";
7
+ import { handleInstall } from "./lib/commands/install.ts";
54
8
 
55
9
  function printUsage() {
56
10
  console.error(`agent-canvas — Interactive visual canvas for Claude Code
57
11
 
58
12
  Commands:
59
- agent-canvas install [local|global] Install skill & hooks for Claude Code
13
+ agent-canvas install [local|global] Install skill for Claude Code
60
14
  agent-canvas push <file.jsx> Push a canvas, open browser
61
- agent-canvas wait [--session <id>] Wait for user feedback (prints to stdout)
15
+ agent-canvas fetch [--session <id>] Check for feedback (returns immediately)
16
+ agent-canvas watch [--session <id>] Wait for user feedback (blocks until submitted)
62
17
  agent-canvas daemon status Show daemon status
63
18
  agent-canvas daemon stop Stop the daemon
19
+ agent-canvas daemon start Start the daemon
20
+ agent-canvas daemon restart Restart the daemon
64
21
 
65
22
  Environment:
66
- CANVAS_SESSION_ID Session ID (set automatically by hooks)
67
- CANVAS_PROJECT_ROOT Project root (set automatically by hooks)
68
23
  CANVAS_PORT Daemon port (default: 19400)`);
69
24
  }
70
25
 
71
- // ── install ──
72
-
73
- async function handleInstall(args: string[]) {
74
- let mode = args[0] as "local" | "global" | undefined;
75
-
76
- if (!mode) {
77
- // Interactive prompt
78
- process.stdout.write("Install canvas skill and hooks for Claude Code.\n\n");
79
- process.stdout.write(" local — install to .claude/ in current project\n");
80
- process.stdout.write(" global — install to ~/.claude/ for all projects\n\n");
81
- process.stdout.write("Choose [local/global]: ");
82
-
83
- const input = await readLine();
84
- mode = input.trim().toLowerCase() as "local" | "global";
85
- }
86
-
87
- if (mode !== "local" && mode !== "global") {
88
- console.error("Error: specify 'local' or 'global'");
89
- process.exit(1);
90
- }
91
-
92
- const targetBase = mode === "global"
93
- ? join(homedir(), ".claude")
94
- : join(process.cwd(), ".claude");
95
-
96
- // Install skill
97
- const skillTarget = join(targetBase, "skills", "canvas");
98
- mkdirSync(skillTarget, { recursive: true });
99
-
100
- const skillSrc = join(PACKAGE_ROOT, "skills", "canvas");
101
- for (const file of ["SKILL.md", "components.md", "flows.md"]) {
102
- const src = join(skillSrc, file);
103
- if (existsSync(src)) {
104
- cpSync(src, join(skillTarget, file));
105
- }
106
- }
107
-
108
- console.log(` Skill installed to ${skillTarget}`);
109
-
110
- // Install hook script
111
- const hooksDir = join(targetBase, "agent-canvas");
112
- mkdirSync(hooksDir, { recursive: true });
113
- const hookSrc = join(PACKAGE_ROOT, "hooks", "session-start.sh");
114
- const hookDest = join(hooksDir, "session-start.sh");
115
- cpSync(hookSrc, hookDest);
116
- try { require("fs").chmodSync(hookDest, 0o755); } catch {}
117
- console.log(` Hook script installed to ${hookDest}`);
118
-
119
- // Register hook in settings.json
120
- const settingsPath = join(targetBase, "settings.json");
121
- let settings: any = {};
122
- if (existsSync(settingsPath)) {
123
- try { settings = JSON.parse(readFileSync(settingsPath, "utf-8")); } catch {}
124
- }
125
-
126
- if (!settings.hooks) settings.hooks = {};
127
- if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
128
-
129
- const hasHook = settings.hooks.SessionStart.some((h: any) =>
130
- h.hooks?.some((hh: any) => hh.command?.includes("CANVAS_SESSION_ID"))
131
- );
132
-
133
- if (!hasHook) {
134
- settings.hooks.SessionStart.push({
135
- matcher: "*",
136
- hooks: [{
137
- type: "command",
138
- command: `bash "${hookDest}"`,
139
- }],
140
- });
141
-
142
- mkdirSync(dirname(settingsPath), { recursive: true });
143
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
144
- console.log(` Hook registered in ${settingsPath}`);
145
- } else {
146
- console.log(` Hook already registered in ${settingsPath}`);
147
- }
148
-
149
- console.log(`\nInstalled! The /canvas command is now available in Claude Code.`);
150
- }
151
-
152
- // ── push ──
153
-
154
- async function handlePush(args: string[]) {
155
- const fromHook = args.includes("--from-hook");
156
- let jsx: string;
157
- let filePath: string | undefined;
158
-
159
- if (fromHook) {
160
- jsx = await readStdin();
161
- } else {
162
- filePath = args.find((a) => !a.startsWith("--"));
163
- if (!filePath) {
164
- console.error("Error: No file specified. Usage: agent-canvas push <file.jsx>");
165
- process.exit(1);
166
- }
167
- filePath = resolve(filePath);
168
- if (!existsSync(filePath)) {
169
- console.error(`Error: File not found: ${filePath}`);
170
- process.exit(1);
171
- }
172
- jsx = readFileSync(filePath, "utf-8");
173
- }
174
-
175
- const sessionId = getSessionId(args);
176
- const projectRoot = process.env.CANVAS_PROJECT_ROOT || process.cwd();
177
-
178
- // Extract --label flag
179
- const labelIdx = args.indexOf("--label");
180
- const label = labelIdx !== -1 && args[labelIdx + 1] ? args[labelIdx + 1] : undefined;
181
- const autoLabel = !label && filePath ? filePath.split("/").pop()?.replace(/\.jsx$/, "") : undefined;
182
-
183
- await ensureDaemon();
184
-
185
- const response = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
186
- method: "POST",
187
- headers: { "Content-Type": "application/json" },
188
- body: JSON.stringify({ jsx, projectRoot, label: label || autoLabel, sourceFile: filePath ? filePath.split("/").pop() : undefined }),
189
- });
190
-
191
- const result = await response.json() as any;
192
-
193
- if (!result.ok) {
194
- console.error(`Compilation error:\n${result.error}`);
195
- process.exit(1);
196
- }
197
-
198
- if (result.isNew) {
199
- openBrowser(result.browserUrl);
200
- }
201
-
202
- console.log(JSON.stringify({
203
- ok: true,
204
- browserUrl: result.browserUrl,
205
- revision: result.revision,
206
- sessionId,
207
- }));
208
- }
209
-
210
- // ── wait ──
211
-
212
- async function handleWait(args: string[]) {
213
- const sessionId = getSessionId(args);
214
-
215
- await ensureDaemon();
216
-
217
- // Check if feedback already exists
218
- try {
219
- const metaRes = await fetch(`${BASE_URL}/api/session/${sessionId}/meta`);
220
- const meta = await metaRes.json() as any;
221
- if (meta.currentRevision && meta.revisions) {
222
- const currentRev = meta.revisions.find((r: any) => r.revision === meta.currentRevision);
223
- if (currentRev?.hasFeedback) {
224
- const fbRes = await fetch(`${BASE_URL}/api/session/${sessionId}/revision/${meta.currentRevision}/feedback`);
225
- const fbData = await fbRes.json() as any;
226
- if (fbData.feedback) {
227
- process.stdout.write(fbData.feedback);
228
- process.exit(0);
229
- }
230
- }
231
- }
232
- } catch {}
233
-
234
- await waitForFeedback(sessionId);
235
- }
236
-
237
- // ── daemon ──
238
-
239
- async function handleDaemon(args: string[]) {
240
- const subcommand = args[0];
241
-
242
- if (subcommand === "status") {
243
- try {
244
- const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(2000) });
245
- const data = await res.json() as any;
246
- console.log(`Daemon: running on port ${DAEMON_PORT}`);
247
- console.log(`Sessions: ${data.sessions.length > 0 ? data.sessions.join(", ") : "none"}`);
248
- } catch {
249
- console.log("Daemon: not running");
250
- }
251
- } else if (subcommand === "stop") {
252
- if (existsSync(PID_FILE)) {
253
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
254
- try {
255
- process.kill(pid, "SIGTERM");
256
- console.log("Daemon stopped.");
257
- } catch {
258
- console.log("Daemon was not running (stale PID file).");
259
- }
260
- try { unlinkSync(PID_FILE); } catch {}
261
- } else {
262
- console.log("No daemon PID file found.");
263
- }
264
- } else {
265
- console.error("Usage: agent-canvas daemon [status|stop]");
266
- process.exit(1);
267
- }
268
- }
269
-
270
- // ── helpers ──
271
-
272
- async function ensureDaemon(): Promise<void> {
273
- if (await isDaemonRunning()) return;
274
-
275
- console.error("Starting canvas daemon...");
276
- mkdirSync(TEMP_DIR, { recursive: true });
277
-
278
- const daemonScript = join(PACKAGE_ROOT, "daemon", "src", "server.ts");
279
-
280
- const child = spawn("bun", ["run", daemonScript], {
281
- detached: true,
282
- stdio: "ignore",
283
- cwd: join(PACKAGE_ROOT, "daemon"),
284
- });
285
-
286
- child.unref();
287
-
288
- for (let i = 0; i < 30; i++) {
289
- await new Promise((r) => setTimeout(r, 100));
290
- if (await isDaemonRunning()) {
291
- console.error("Daemon started.");
292
- return;
293
- }
294
- }
295
-
296
- console.error("Error: Daemon failed to start within 3 seconds.");
297
- process.exit(1);
298
- }
299
-
300
- async function isDaemonRunning(): Promise<boolean> {
301
- try {
302
- const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(1000) });
303
- const data = await res.json() as any;
304
- return data.ok === true;
305
- } catch {
306
- return false;
307
- }
308
- }
309
-
310
- function openBrowser(url: string) {
311
- const candidates = process.platform === "darwin"
312
- ? ["open"]
313
- : ["xdg-open", "wslview", "sensible-browser"];
26
+ async function main() {
27
+ const args = process.argv.slice(2);
314
28
 
315
- for (const cmd of candidates) {
316
- const which = Bun.spawnSync(["which", cmd]);
317
- if (which.exitCode === 0) {
318
- spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
319
- return;
320
- }
29
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
30
+ printUsage();
31
+ process.exit(args.length === 0 ? 1 : 0);
321
32
  }
322
- console.error(`Open this URL in your browser: ${url}`);
323
- }
324
33
 
325
- async function waitForFeedback(sessionId: string): Promise<void> {
326
- return new Promise((resolveP, rejectP) => {
327
- const ws = new WebSocket(`${WS_URL}/ws/wait/${sessionId}`);
328
- const timeout = setTimeout(() => {
329
- ws.close();
330
- console.error("Error: Timeout waiting for feedback.");
331
- process.exit(1);
332
- }, TIMEOUT_MS);
333
-
334
- ws.onmessage = (event) => {
335
- try {
336
- const data = JSON.parse(typeof event.data === "string" ? event.data : "");
337
- if (data.type === "submit") {
338
- clearTimeout(timeout);
339
- process.stdout.write(data.feedback);
340
- ws.close();
341
- process.exit(0);
342
- }
343
- } catch {}
344
- };
34
+ const command = args[0];
35
+ const rest = args.slice(1);
345
36
 
346
- ws.onerror = () => {
347
- clearTimeout(timeout);
348
- console.error("Error: WebSocket connection failed.");
37
+ switch (command) {
38
+ case "push": return handlePush(rest);
39
+ case "fetch": return handleFetch(rest);
40
+ case "watch": return handleWatch(rest);
41
+ case "install": return handleInstall(rest);
42
+ case "daemon": return handleDaemon(rest);
43
+ default:
44
+ console.error(`Unknown command: ${command}`);
45
+ printUsage();
349
46
  process.exit(1);
350
- };
351
-
352
- ws.onclose = () => {
353
- clearTimeout(timeout);
354
- };
355
- });
356
- }
357
-
358
- async function readStdin(): Promise<string> {
359
- const chunks: Buffer[] = [];
360
- for await (const chunk of Bun.stdin.stream()) {
361
- chunks.push(Buffer.from(chunk));
362
- }
363
- return Buffer.concat(chunks).toString("utf-8");
364
- }
365
-
366
- async function readLine(): Promise<string> {
367
- const buf: number[] = [];
368
- for await (const chunk of Bun.stdin.stream()) {
369
- for (const byte of chunk) {
370
- if (byte === 10) return Buffer.from(buf).toString("utf-8");
371
- buf.push(byte);
372
- }
373
47
  }
374
- return Buffer.from(buf).toString("utf-8");
375
48
  }
376
49
 
377
50
  main().catch((e) => {
@@ -0,0 +1,51 @@
1
+ import { parseArgs } from "util";
2
+ import { DAEMON_PORT, BASE_URL } from "../config.ts";
3
+ import { isDaemonRunning, startDaemon, stopDaemon } from "../daemon-lifecycle.ts";
4
+
5
+ export async function handleDaemon(args: string[]) {
6
+ const { positionals } = parseArgs({
7
+ args,
8
+ allowPositionals: true,
9
+ });
10
+
11
+ const subcommand = positionals[0];
12
+
13
+ switch (subcommand) {
14
+ case "status": {
15
+ try {
16
+ const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(2000) });
17
+ const data = await res.json() as any;
18
+ console.log(`Daemon: running on port ${DAEMON_PORT}`);
19
+ console.log(`Sessions: ${data.sessions.length > 0 ? data.sessions.join(", ") : "none"}`);
20
+ } catch {
21
+ console.log("Daemon: not running");
22
+ }
23
+ break;
24
+ }
25
+ case "stop": {
26
+ stopDaemon();
27
+ break;
28
+ }
29
+ case "start": {
30
+ if (await isDaemonRunning()) {
31
+ console.log("Daemon is already running.");
32
+ return;
33
+ }
34
+ await startDaemon();
35
+ break;
36
+ }
37
+ case "restart": {
38
+ stopDaemon();
39
+ for (let i = 0; i < 10; i++) {
40
+ if (!(await isDaemonRunning())) break;
41
+ await new Promise((r) => setTimeout(r, 100));
42
+ }
43
+ await startDaemon();
44
+ break;
45
+ }
46
+ default: {
47
+ console.error("Usage: agent-canvas daemon [status|stop|start|restart]");
48
+ process.exit(1);
49
+ }
50
+ }
51
+ }