agent-canvas 0.2.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -106
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +5 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/files-api.test.ts +0 -78
  96. package/daemon/src/remote-feedback.test.ts +0 -165
  97. package/daemon/src/secrets-api.test.ts +0 -123
  98. package/daemon/src/share-e2e.test.ts +0 -277
  99. package/daemon/src/share.test.ts +0 -289
@@ -1,30 +1,30 @@
1
- import { parseArgs } from "util";
2
- import { ensureDaemon } from "../daemon-lifecycle.ts";
3
- import { getSessionId, consumeFeedback, waitForFeedback } from "../helpers.ts";
1
+ import { parseArgs } from 'util'
2
+ import { ensureDaemon } from '../daemon-lifecycle.ts'
3
+ import { consumeFeedback, getSessionId, waitForFeedback } from '../helpers.ts'
4
4
 
5
5
  export async function handleWatch(args: string[]) {
6
- const { values } = parseArgs({
7
- args,
8
- options: {
9
- session: { type: "string" },
10
- },
11
- });
6
+ const { values } = parseArgs({
7
+ args,
8
+ options: {
9
+ session: { type: 'string' },
10
+ },
11
+ })
12
12
 
13
- const sessionId = getSessionId(values.session);
14
- await ensureDaemon();
13
+ const sessionId = getSessionId(values.session)
14
+ await ensureDaemon()
15
15
 
16
- const feedback = await consumeFeedback(sessionId);
17
- if (feedback) {
18
- process.stdout.write(feedback);
19
- process.exit(0);
20
- }
16
+ const feedback = await consumeFeedback(sessionId)
17
+ if (feedback) {
18
+ process.stdout.write(feedback)
19
+ process.exit(0)
20
+ }
21
21
 
22
- try {
23
- const submittedFeedback = await waitForFeedback(sessionId);
24
- process.stdout.write(submittedFeedback);
25
- } catch (error) {
26
- const message = error instanceof Error ? error.message : String(error);
27
- console.error(`Error: ${message}`);
28
- process.exit(1);
29
- }
22
+ try {
23
+ const submittedFeedback = await waitForFeedback(sessionId)
24
+ process.stdout.write(submittedFeedback)
25
+ } catch (error) {
26
+ const message = error instanceof Error ? error.message : String(error)
27
+ console.error(`Error: ${message}`)
28
+ process.exit(1)
29
+ }
30
30
  }
package/bin/lib/config.ts CHANGED
@@ -1,13 +1,13 @@
1
- import { resolve, join, dirname } from "path";
2
- import { readFileSync } from "fs";
3
- import { tmpdir } from "os";
1
+ import { readFileSync } from 'fs'
2
+ import { tmpdir } from 'os'
3
+ import { dirname, join, resolve } from 'path'
4
4
 
5
- export const PACKAGE_ROOT = resolve(join(dirname(import.meta.path), "../.."));
6
- export const VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8")).version as string;
7
- export const TEMP_DIR = join(tmpdir(), "agent-canvas");
8
- export const DAEMON_PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
9
- export const BASE_URL = `http://localhost:${DAEMON_PORT}`;
10
- export const WS_URL = `ws://localhost:${DAEMON_PORT}`;
11
- export const TIMEOUT_MS = parseInt(process.env.CANVAS_TIMEOUT || String(60 * 60 * 1000), 10);
12
- export const PID_FILE = join(TEMP_DIR, "daemon.pid");
13
- export const CLI_AUTH_FILE = join(TEMP_DIR, "daemon-auth-token");
5
+ export const PACKAGE_ROOT = resolve(join(dirname(import.meta.path), '../..'))
6
+ export const VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8')).version as string
7
+ export const TEMP_DIR = join(tmpdir(), 'agent-canvas')
8
+ export const DAEMON_PORT = parseInt(process.env.CANVAS_PORT || '19400', 10)
9
+ export const BASE_URL = `http://localhost:${DAEMON_PORT}`
10
+ export const WS_URL = `ws://localhost:${DAEMON_PORT}`
11
+ export const TIMEOUT_MS = parseInt(process.env.CANVAS_TIMEOUT || String(60 * 60 * 1000), 10)
12
+ export const PID_FILE = join(TEMP_DIR, 'daemon.pid')
13
+ export const CLI_AUTH_FILE = join(TEMP_DIR, 'daemon-auth-token')
@@ -1,19 +1,15 @@
1
- import { readFileSync } from "fs";
2
- import { join } from "path";
3
- import {
4
- createDaemonLifecycle,
5
- DaemonStartError,
6
- type DaemonEvent,
7
- } from "@fabrika/daemon-kit";
8
- import { PACKAGE_ROOT, TEMP_DIR, BASE_URL, PID_FILE, CLI_AUTH_FILE, VERSION } from "./config.ts";
1
+ import { createDaemonLifecycle, type DaemonEvent, DaemonStartError } from '@fabrika/daemon-kit'
2
+ import { readFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { BASE_URL, CLI_AUTH_FILE, PACKAGE_ROOT, PID_FILE, TEMP_DIR, VERSION } from './config.ts'
9
5
 
10
6
  interface CanvasHealth {
11
- pid: number;
12
- version: string;
7
+ pid: number
8
+ version: string
13
9
  }
14
10
 
15
11
  function isRecord(value: unknown): value is Record<string, unknown> {
16
- return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
17
13
  }
18
14
 
19
15
  /**
@@ -21,67 +17,67 @@ function isRecord(value: unknown): value is Record<string, unknown> {
21
17
  * behind. Drop this fallback once no such daemon can still be running.
22
18
  */
23
19
  function legacyPidFromFile(): number | undefined {
24
- try {
25
- const pid = Number(readFileSync(PID_FILE, "utf-8").trim());
26
- return Number.isInteger(pid) && pid > 0 ? pid : undefined;
27
- } catch {
28
- return undefined;
29
- }
20
+ try {
21
+ const pid = Number(readFileSync(PID_FILE, 'utf-8').trim())
22
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined
23
+ } catch {
24
+ return undefined
25
+ }
30
26
  }
31
27
 
32
28
  function parseHealth(body: unknown): CanvasHealth | undefined {
33
- if (!isRecord(body) || body.ok !== true || typeof body.version !== "string") return undefined;
34
- const pid = typeof body.pid === "number" ? body.pid : legacyPidFromFile();
35
- if (pid === undefined) return undefined;
36
- return { pid, version: body.version };
29
+ if (!isRecord(body) || body.ok !== true || typeof body.version !== 'string') return undefined
30
+ const pid = typeof body.pid === 'number' ? body.pid : legacyPidFromFile()
31
+ if (pid === undefined) return undefined
32
+ return { pid, version: body.version }
37
33
  }
38
34
 
39
35
  function report(event: DaemonEvent) {
40
- if (event.type === "version-mismatch") {
41
- console.error(`Daemon version mismatch (daemon: ${event.running}, CLI: ${VERSION}), restarting...`);
42
- }
43
- if (event.type === "starting") console.error("Starting canvas daemon...");
44
- if (event.type === "started") console.error("Daemon started.");
36
+ if (event.type === 'version-mismatch') {
37
+ console.error(`Daemon version mismatch (daemon: ${event.running}, CLI: ${VERSION}), restarting...`)
38
+ }
39
+ if (event.type === 'starting') console.error('Starting canvas daemon...')
40
+ if (event.type === 'started') console.error('Daemon started.')
45
41
  }
46
42
 
47
43
  const lifecycle = createDaemonLifecycle<CanvasHealth>({
48
- baseUrl: BASE_URL,
49
- parseHealth,
50
- pidFile: PID_FILE,
51
- // The running Bun, not a PATH lookup — `Bun.spawn` throws on a missing executable.
52
- command: [process.execPath, "run", join(PACKAGE_ROOT, "daemon", "src", "server.ts")],
53
- cwd: join(PACKAGE_ROOT, "daemon"),
54
- logFile: join(TEMP_DIR, "daemon-startup.log"),
55
- versionCheck: { expected: VERSION, of: (health) => health.version },
56
- cleanupFiles: [CLI_AUTH_FILE],
57
- onEvent: report,
58
- });
44
+ baseUrl: BASE_URL,
45
+ parseHealth,
46
+ pidFile: PID_FILE,
47
+ // The running Bun, not a PATH lookup — `Bun.spawn` throws on a missing executable.
48
+ command: [process.execPath, 'run', join(PACKAGE_ROOT, 'daemon', 'src', 'server.ts')],
49
+ cwd: join(PACKAGE_ROOT, 'daemon'),
50
+ logFile: join(TEMP_DIR, 'daemon-startup.log'),
51
+ versionCheck: { expected: VERSION, of: (health) => health.version },
52
+ cleanupFiles: [CLI_AUTH_FILE],
53
+ onEvent: report,
54
+ })
59
55
 
60
56
  export async function isDaemonRunning(): Promise<boolean> {
61
- return lifecycle.isRunning();
57
+ return lifecycle.isRunning()
62
58
  }
63
59
 
64
60
  function reportStartFailure(error: unknown): never {
65
- if (error instanceof DaemonStartError && error.logTail) {
66
- console.error("Daemon stderr:\n" + error.logTail);
67
- }
68
- console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
69
- process.exit(1);
61
+ if (error instanceof DaemonStartError && error.logTail) {
62
+ console.error('Daemon stderr:\n' + error.logTail)
63
+ }
64
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
65
+ process.exit(1)
70
66
  }
71
67
 
72
68
  export async function startDaemon(): Promise<void> {
73
- await lifecycle.start().catch(reportStartFailure);
69
+ await lifecycle.start().catch(reportStartFailure)
74
70
  }
75
71
 
76
72
  export async function ensureDaemon(): Promise<void> {
77
- await lifecycle.ensure().catch(reportStartFailure);
73
+ await lifecycle.ensure().catch(reportStartFailure)
78
74
  }
79
75
 
80
76
  export async function stopDaemon(): Promise<boolean> {
81
- const result = await lifecycle.stop();
82
- if (result.reason === "stopped") console.log("Daemon stopped.");
83
- else if (result.reason === "stale-pid") console.log("Daemon was not running (stale PID file).");
84
- else if (result.reason === "not-running") console.log("No daemon PID file found.");
85
- else console.log(result.message);
86
- return result.stopped;
77
+ const result = await lifecycle.stop()
78
+ if (result.reason === 'stopped') console.log('Daemon stopped.')
79
+ else if (result.reason === 'stale-pid') console.log('Daemon was not running (stale PID file).')
80
+ else if (result.reason === 'not-running') console.log('No daemon PID file found.')
81
+ else console.log(result.message)
82
+ return result.stopped
87
83
  }
@@ -1,111 +1,117 @@
1
- import { spawn } from "child_process";
2
- import { randomUUID } from "crypto";
3
- import { waitForEvent, type WaiterSocket, type WaitVerdict } from "@fabrika/daemon-kit";
4
- import { BASE_URL, WS_URL, TIMEOUT_MS } from "./config.ts";
1
+ import { type WaiterSocket, waitForEvent, type WaitVerdict } from '@fabrika/daemon-kit'
2
+ import { spawn } from 'child_process'
3
+ import { randomUUID } from 'crypto'
4
+ import { z } from 'zod'
5
+ import { BASE_URL, TIMEOUT_MS, WS_URL } from './config.ts'
5
6
 
6
7
  export function getSessionId(session?: string): string {
7
- if (session) return session;
8
- return process.env.CANVAS_SESSION_ID || (() => {
9
- const id = randomUUID();
10
- console.error(`Warning: CANVAS_SESSION_ID not set, using generated ID: ${id}`);
11
- return id;
12
- })();
8
+ if (session) return session
9
+ return process.env.CANVAS_SESSION_ID || (() => {
10
+ const id = randomUUID()
11
+ console.error(`Warning: CANVAS_SESSION_ID not set, using generated ID: ${id}`)
12
+ return id
13
+ })()
13
14
  }
14
15
 
16
+ const consumeResponseSchema = z.discriminatedUnion('found', [
17
+ z.object({ found: z.literal(true), feedback: z.string() }),
18
+ z.object({ found: z.literal(false) }),
19
+ ])
20
+
15
21
  export async function consumeFeedback(sessionId: string): Promise<string | null> {
16
- const res = await fetch(`${BASE_URL}/api/session/${sessionId}/feedback/consume`, { method: "POST" });
17
- const data = await res.json() as any;
18
- if (data.found) return data.feedback;
19
- return null;
22
+ const res = await fetch(`${BASE_URL}/api/session/${sessionId}/feedback/consume`, { method: 'POST' })
23
+ const data = consumeResponseSchema.parse(await res.json())
24
+ if (data.found) return data.feedback
25
+ return null
20
26
  }
21
27
 
22
28
  export function openBrowser(url: string) {
23
- const candidates = process.platform === "darwin"
24
- ? ["open"]
25
- : ["xdg-open", "wslview", "sensible-browser"];
29
+ const candidates = process.platform === 'darwin'
30
+ ? ['open']
31
+ : ['xdg-open', 'wslview', 'sensible-browser']
26
32
 
27
- for (const cmd of candidates) {
28
- const which = Bun.spawnSync(["which", cmd]);
29
- if (which.exitCode === 0) {
30
- spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
31
- return;
32
- }
33
- }
34
- console.error(`Open this URL in your browser: ${url}`);
33
+ for (const cmd of candidates) {
34
+ const which = Bun.spawnSync(['which', cmd])
35
+ if (which.exitCode === 0) {
36
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref()
37
+ return
38
+ }
39
+ }
40
+ console.error(`Open this URL in your browser: ${url}`)
35
41
  }
36
42
 
37
43
  interface FeedbackWaitOptions {
38
- createSocket?: (url: string) => WaiterSocket;
39
- timeoutMs?: number;
40
- reconnectAttempts?: number;
41
- reconnectDelayMs?: number;
44
+ createSocket?: (url: string) => WaiterSocket
45
+ timeoutMs?: number
46
+ reconnectAttempts?: number
47
+ reconnectDelayMs?: number
42
48
  }
43
49
 
44
50
  interface CanvasRenderError {
45
- revision: number;
46
- filename: string;
47
- message: string;
48
- stack?: string;
49
- componentStack?: string;
51
+ revision: number
52
+ filename: string
53
+ message: string
54
+ stack?: string
55
+ componentStack?: string
50
56
  }
51
57
 
52
58
  function isRecord(value: unknown): value is Record<string, unknown> {
53
- return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
54
60
  }
55
61
 
56
62
  function isCanvasRenderError(value: unknown): value is CanvasRenderError {
57
- return typeof value === "object" && value !== null
58
- && "revision" in value && Number.isInteger(value.revision)
59
- && "filename" in value && typeof value.filename === "string"
60
- && "message" in value && typeof value.message === "string"
61
- && (!("stack" in value) || value.stack === undefined || typeof value.stack === "string")
62
- && (!("componentStack" in value) || value.componentStack === undefined || typeof value.componentStack === "string");
63
+ return typeof value === 'object' && value !== null
64
+ && 'revision' in value && Number.isInteger(value.revision)
65
+ && 'filename' in value && typeof value.filename === 'string'
66
+ && 'message' in value && typeof value.message === 'string'
67
+ && (!('stack' in value) || value.stack === undefined || typeof value.stack === 'string')
68
+ && (!('componentStack' in value) || value.componentStack === undefined || typeof value.componentStack === 'string')
63
69
  }
64
70
 
65
71
  function formatCanvasRenderError(error: CanvasRenderError): string {
66
- const details = [`Canvas render failed in ${error.filename} (revision ${error.revision}):`, error.message];
67
- if (error.componentStack?.trim()) details.push(`Component stack:${error.componentStack}`);
68
- if (error.stack?.trim()) details.push(`Browser stack:\n${error.stack}`);
69
- return details.join("\n");
72
+ const details = [`Canvas render failed in ${error.filename} (revision ${error.revision}):`, error.message]
73
+ if (error.componentStack?.trim()) details.push(`Component stack:${error.componentStack}`)
74
+ if (error.stack?.trim()) details.push(`Browser stack:\n${error.stack}`)
75
+ return details.join('\n')
70
76
  }
71
77
 
72
78
  /** A canvas that failed to render is the answer — retrying the socket cannot fix it. */
73
79
  function readFeedbackFrame(frame: unknown): WaitVerdict<string> | undefined {
74
- if (!isRecord(frame)) return undefined;
75
- if (frame.type === "submit" && typeof frame.feedback === "string") {
76
- return { type: "settle", value: frame.feedback };
77
- }
78
- if (frame.type === "render-error" && isCanvasRenderError(frame.error)) {
79
- return { type: "fail", error: new Error(formatCanvasRenderError(frame.error)) };
80
- }
81
- return undefined;
80
+ if (!isRecord(frame)) return undefined
81
+ if (frame.type === 'submit' && typeof frame.feedback === 'string') {
82
+ return { type: 'settle', value: frame.feedback }
83
+ }
84
+ if (frame.type === 'render-error' && isCanvasRenderError(frame.error)) {
85
+ return { type: 'fail', error: new Error(formatCanvasRenderError(frame.error)) }
86
+ }
87
+ return undefined
82
88
  }
83
89
 
84
90
  export function waitForFeedback(sessionId: string, options: FeedbackWaitOptions = {}): Promise<string> {
85
- return waitForEvent<string>({
86
- url: `${WS_URL}/ws/wait/${sessionId}`,
87
- onFrame: readFeedbackFrame,
88
- ...(options.createSocket ? { createSocket: options.createSocket } : {}),
89
- timeoutMs: options.timeoutMs ?? TIMEOUT_MS,
90
- reconnect: {
91
- attempts: options.reconnectAttempts ?? 3,
92
- delayMs: options.reconnectDelayMs ?? 1_000,
93
- },
94
- messages: {
95
- timeout: "Timeout waiting for feedback.",
96
- closed: "WebSocket closed before feedback was submitted.",
97
- connectionFailed: "WebSocket connection failed.",
98
- },
99
- });
91
+ return waitForEvent<string>({
92
+ url: `${WS_URL}/ws/wait/${sessionId}`,
93
+ onFrame: readFeedbackFrame,
94
+ ...(options.createSocket ? { createSocket: options.createSocket } : {}),
95
+ timeoutMs: options.timeoutMs ?? TIMEOUT_MS,
96
+ reconnect: {
97
+ attempts: options.reconnectAttempts ?? 3,
98
+ delayMs: options.reconnectDelayMs ?? 1_000,
99
+ },
100
+ messages: {
101
+ timeout: 'Timeout waiting for feedback.',
102
+ closed: 'WebSocket closed before feedback was submitted.',
103
+ connectionFailed: 'WebSocket connection failed.',
104
+ },
105
+ })
100
106
  }
101
107
 
102
108
  export async function readLine(): Promise<string> {
103
- const buf: number[] = [];
104
- for await (const chunk of Bun.stdin.stream()) {
105
- for (const byte of chunk) {
106
- if (byte === 10) return Buffer.from(buf).toString("utf-8");
107
- buf.push(byte);
108
- }
109
- }
110
- return Buffer.from(buf).toString("utf-8");
109
+ const buf: number[] = []
110
+ for await (const chunk of Bun.stdin.stream()) {
111
+ for (const byte of chunk) {
112
+ if (byte === 10) return Buffer.from(buf).toString('utf-8')
113
+ buf.push(byte)
114
+ }
115
+ }
116
+ return Buffer.from(buf).toString('utf-8')
111
117
  }
@@ -0,0 +1,88 @@
1
+ # Review mode — comments on a running application
2
+
3
+ Review mode lets the user click through a running web application (a prototype, a mock, a dev server) and leave
4
+ comments on its elements, the way they comment on a canvas. A comment stays a note until the user sends it to you.
5
+ You receive sent threads as markdown, change the code, and answer each thread; the answer appears next to the element.
6
+
7
+ Review mode has no session ID. Threads belong to a **project**. The commands default to `CANVAS_REVIEW_PROJECT`, then
8
+ to the git repository's directory name; pass `--project <name>` to name another.
9
+
10
+ ## Setup
11
+
12
+ Open the application through the review proxy. The application stays as it is:
13
+
14
+ ```bash
15
+ bunx agent-canvas review open <url> --project <name>
16
+ ```
17
+
18
+ `<url>` is where the application's dev server runs, e.g. `http://localhost:5173/`; read it from the running server or
19
+ the project's dev script, and ask the user when you cannot tell. The daemon serves the application on a port of its own
20
+ with the widget added to every HTML page, opens it in the user's browser and prints the address. Hot reload passes
21
+ through. The port stays the same across daemon restarts. Only applications on `localhost` can be opened.
22
+
23
+ When the user wants the widget on the application's own address instead, add the tag to its HTML page in development
24
+ only:
25
+
26
+ ```bash
27
+ bunx agent-canvas review snippet --project <name>
28
+ # <script src="http://localhost:19400/assets/review.js" data-project="<name>"></script>
29
+ ```
30
+
31
+ Tell the user to use **Comment** in the bottom-right toolbar: click an element, or select text first, write the
32
+ comment, then **Send to Claude** or **Save note**.
33
+
34
+ ## Loop
35
+
36
+ Start the watcher right away. The toolbar shows the user "Claude is listening" while it runs. Do NOT poll, sleep, or
37
+ check proactively.
38
+
39
+ **Claude Code**: open the application and watch in one command, with the Monitor tool and its longest timeout:
40
+
41
+ ```bash
42
+ bunx agent-canvas review open <url> --follow --project <name>
43
+ ```
44
+
45
+ Its first stdout line is the address the user reviews at. Then it keeps one connection open and prints every batch the
46
+ user sends as one event. Its only other stdout line is the one that ends the watch when the daemon stays unreachable.
47
+ When the monitor expires, re-arm it with `bunx agent-canvas review watch --follow --project <name>`, which does not
48
+ open the browser again. With the snippet instead of the proxy, use that command from the start.
49
+
50
+ **Other hosts**: run the one-shot watcher in the background. It prints one batch and exits; start it again after
51
+ answering:
52
+
53
+ ```bash
54
+ bunx agent-canvas review watch --project <name>
55
+ ```
56
+
57
+ The output lists each thread with:
58
+
59
+ - `Page` — the URL the comment was made on, hash route included. Through the proxy it carries the proxy's port; the
60
+ path and the hash are the application's.
61
+ - `Where` — the page heading and the section or dialog around the element.
62
+ - `Element` — ARIA role and accessible name, the way you would target the element in a browser.
63
+ - `Selector` — a CSS path to the element.
64
+ - `Components` — React components that own the element, outermost first.
65
+ - `Source` — `file:line:column` where the element and its nearest components are written, innermost first. Start
66
+ there. It needs React 19 in development and source maps from the dev server; without them, search for the innermost
67
+ component instead.
68
+ - `Selected text` — when the user commented on a text selection.
69
+ - `Screenshot` — when the user attached one: a PNG of the element in its surroundings, the element outlined in red.
70
+ Read it; it shows what the user sees, which matters for visual comments.
71
+ - `Markup` — the element's HTML, trimmed: classes, attributes and structure, not whole subtrees.
72
+ - The conversation so far.
73
+
74
+ For each thread:
75
+
76
+ 1. Make the change. The application's dev server reloads it; the user sees it in place.
77
+ 2. Answer the thread:
78
+
79
+ ```bash
80
+ bunx agent-canvas review reply <thread-id> "<what you changed>" --resolve --project <name>
81
+ ```
82
+
83
+ Leave out `--resolve` when you ask a question back; the thread returns to the user.
84
+
85
+ With the one-shot watcher, start `review watch` again for the next batch.
86
+
87
+ If the user says they already sent comments and no watcher was running, take them with `review fetch`. `review list`
88
+ shows every thread that is not resolved, notes included.
@@ -9,9 +9,11 @@ Visual checklist. Read-only display (feedback via annotations).
9
9
  ## Usage
10
10
 
11
11
  ```jsx
12
- <Checklist items={[
13
- { label: "Run migration on staging", checked: true },
14
- { label: "Verify data integrity", checked: false },
15
- { label: "Update API docs", checked: false },
16
- ]} />
12
+ <Checklist
13
+ items={[
14
+ { label: 'Run migration on staging', checked: true },
15
+ { label: 'Verify data integrity', checked: false },
16
+ { label: 'Update API docs', checked: false },
17
+ ]}
18
+ />
17
19
  ```
@@ -6,23 +6,29 @@ When you use the full module format (`export default function Canvas()`), you ca
6
6
 
7
7
  ```jsx
8
8
  function Metric({ label, value, trend }) {
9
- const color = trend === 'up' ? 'var(--color-accent-green)' : trend === 'down' ? 'var(--color-accent-red)' : 'var(--color-text-secondary)';
10
- return (
11
- <div style={{ display: 'flex', gap: 12, alignItems: 'baseline' }}>
12
- <span style={{ color: 'var(--color-text-tertiary)', fontSize: '0.75rem' }}>{label}</span>
13
- <span style={{ color, fontSize: '1.25rem', fontWeight: 600 }}>{value}</span>
14
- </div>
15
- );
9
+ const color = trend === 'up'
10
+ ? 'var(--color-accent-green)'
11
+ : trend === 'down'
12
+ ? 'var(--color-accent-red)'
13
+ : 'var(--color-text-secondary)'
14
+ return (
15
+ <div style={{ display: 'flex', gap: 12, alignItems: 'baseline' }}>
16
+ <span style={{ color: 'var(--color-text-tertiary)', fontSize: '0.75rem' }}>
17
+ {label}
18
+ </span>
19
+ <span style={{ color, fontSize: '1.25rem', fontWeight: 600 }}>{value}</span>
20
+ </div>
21
+ )
16
22
  }
17
23
 
18
24
  export default function Canvas() {
19
- return (
20
- <Section title="Performance Summary">
21
- <Metric label="P95 Latency" value="42ms" trend="down" />
22
- <Metric label="Error Rate" value="0.3%" trend="up" />
23
- <Metric label="Throughput" value="1.2k rps" trend="up" />
24
- </Section>
25
- );
25
+ return (
26
+ <Section title="Performance Summary">
27
+ <Metric label="P95 Latency" value="42ms" trend="down" />
28
+ <Metric label="Error Rate" value="0.3%" trend="up" />
29
+ <Metric label="Throughput" value="1.2k rps" trend="up" />
30
+ </Section>
31
+ )
26
32
  }
27
33
  ```
28
34
 
@@ -11,8 +11,9 @@ Shows a diff view between two code snippets.
11
11
  ## Usage
12
12
 
13
13
  ```jsx
14
- <Diff language="typescript"
15
- before={`function auth(req) {\n return true;\n}`}
16
- after={`function auth(req) {\n const token = req.headers.authorization;\n if (!token) throw new Error('Unauthorized');\n return verify(token);\n}`}
14
+ <Diff
15
+ language="typescript"
16
+ before={`function auth(req) {\n return true;\n}`}
17
+ after={`function auth(req) {\n const token = req.headers.authorization;\n if (!token) throw new Error('Unauthorized');\n return verify(token);\n}`}
17
18
  />
18
19
  ```
@@ -21,23 +21,33 @@ Previously called "Task" — Item is the generalized version.
21
21
  ```jsx
22
22
  {/* As a plan task */}
23
23
  <Item id="migrate-db" label="Run database migration" badge="todo">
24
- Execute the migration script against staging first.
25
- <CodeBlock language="bash">npm run migrate:staging</CodeBlock>
24
+ Execute the migration script against staging first.
25
+ <CodeBlock language="bash">npm run migrate:staging</CodeBlock>
26
26
  </Item>
27
27
 
28
28
  {/* As a review finding */}
29
- <Item id="finding-1" label="Missing null check in auth handler" badge="critical" badgeVariant="danger">
30
- The handler doesn't check for missing tokens.
31
- <FilePreview path="src/auth.ts" lines={[42, 48]} />
29
+ <Item
30
+ id="finding-1"
31
+ label="Missing null check in auth handler"
32
+ badge="critical"
33
+ badgeVariant="danger"
34
+ >
35
+ The handler doesn't check for missing tokens.
36
+ <FilePreview path="src/auth.ts" lines={[42, 48]} />
32
37
  </Item>
33
38
 
34
39
  {/* As a requirement */}
35
- <Item id="req-auth" label="JWT authentication" badge="must-have" badgeVariant="danger">
36
- All API endpoints must require a valid JWT token.
40
+ <Item
41
+ id="req-auth"
42
+ label="JWT authentication"
43
+ badge="must-have"
44
+ badgeVariant="danger"
45
+ >
46
+ All API endpoints must require a valid JWT token.
37
47
  </Item>
38
48
 
39
49
  {/* As an architecture component */}
40
50
  <Item label="API Gateway" badge="core" badgeVariant="info">
41
- Routes requests to downstream services. Handles rate limiting.
51
+ Routes requests to downstream services. Handles rate limiting.
42
52
  </Item>
43
53
  ```
@@ -5,7 +5,8 @@ Renders markdown content with full GFM support (headings, lists, tables, code bl
5
5
  ## Inline source — markdown as children
6
6
 
7
7
  ```jsx
8
- <Markdown>{`
8
+ <Markdown>
9
+ {`
9
10
  # Summary
10
11
 
11
12
  This is **bold** and *italic*. Here's a list:
@@ -17,7 +18,8 @@ This is **bold** and *italic*. Here's a list:
17
18
  \`\`\`typescript
18
19
  const x = 42;
19
20
  \`\`\`
20
- `}</Markdown>
21
+ `}
22
+ </Markdown>
21
23
  ```
22
24
 
23
25
  ## File reference — loads file content at compile time