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,48 +1,48 @@
1
- import { parseArgs } from "util";
2
- import { existsSync, mkdirSync, cpSync, unlinkSync } from "fs";
3
- import { join } from "path";
4
- import { homedir } from "os";
5
- import { PACKAGE_ROOT } from "../config.ts";
6
- import { readLine } from "../helpers.ts";
1
+ import { cpSync, existsSync, mkdirSync, unlinkSync } from 'fs'
2
+ import { homedir } from 'os'
3
+ import { join } from 'path'
4
+ import { parseArgs } from 'util'
5
+ import { PACKAGE_ROOT } from '../config.ts'
6
+ import { readLine } from '../helpers.ts'
7
7
 
8
8
  export async function handleInstall(args: string[]) {
9
- const { positionals } = parseArgs({
10
- args,
11
- allowPositionals: true,
12
- });
9
+ const { positionals } = parseArgs({
10
+ args,
11
+ allowPositionals: true,
12
+ })
13
13
 
14
- let mode = positionals[0] as "local" | "global" | undefined;
14
+ let mode = positionals[0] as 'local' | 'global' | undefined
15
15
 
16
- if (!mode) {
17
- process.stdout.write("Install canvas skill for Claude Code.\n\n");
18
- process.stdout.write(" local — install to .claude/ in current project\n");
19
- process.stdout.write(" global — install to ~/.claude/ for all projects\n\n");
20
- process.stdout.write("Choose [local/global]: ");
16
+ if (!mode) {
17
+ process.stdout.write('Install canvas skill for Claude Code.\n\n')
18
+ process.stdout.write(' local — install to .claude/ in current project\n')
19
+ process.stdout.write(' global — install to ~/.claude/ for all projects\n\n')
20
+ process.stdout.write('Choose [local/global]: ')
21
21
 
22
- const input = await readLine();
23
- mode = input.trim().toLowerCase() as "local" | "global";
24
- }
22
+ const input = await readLine()
23
+ mode = input.trim().toLowerCase() as 'local' | 'global'
24
+ }
25
25
 
26
- if (mode !== "local" && mode !== "global") {
27
- console.error("Error: specify 'local' or 'global'");
28
- process.exit(1);
29
- }
26
+ if (mode !== 'local' && mode !== 'global') {
27
+ console.error("Error: specify 'local' or 'global'")
28
+ process.exit(1)
29
+ }
30
30
 
31
- const targetBase = mode === "global"
32
- ? join(homedir(), ".claude")
33
- : join(process.cwd(), ".claude");
31
+ const targetBase = mode === 'global'
32
+ ? join(homedir(), '.claude')
33
+ : join(process.cwd(), '.claude')
34
34
 
35
- const skillTarget = join(targetBase, "skills", "canvas");
36
- mkdirSync(skillTarget, { recursive: true });
35
+ const skillTarget = join(targetBase, 'skills', 'canvas')
36
+ mkdirSync(skillTarget, { recursive: true })
37
37
 
38
- cpSync(join(PACKAGE_ROOT, "skills", "canvas", "SKILL.md"), join(skillTarget, "SKILL.md"));
38
+ cpSync(join(PACKAGE_ROOT, 'skills', 'canvas', 'SKILL.md'), join(skillTarget, 'SKILL.md'))
39
39
 
40
- // Clean up files from older versions
41
- for (const old of ["components.md", "flows.md"]) {
42
- const p = join(skillTarget, old);
43
- if (existsSync(p)) unlinkSync(p);
44
- }
40
+ // Clean up files from older versions
41
+ for (const old of ['components.md', 'flows.md']) {
42
+ const p = join(skillTarget, old)
43
+ if (existsSync(p)) unlinkSync(p)
44
+ }
45
45
 
46
- console.log(` Skill installed to ${skillTarget}`);
47
- console.log(`\nInstalled! The /canvas command is now available in Claude Code.`);
46
+ console.log(` Skill installed to ${skillTarget}`)
47
+ console.log(`\nInstalled! The /canvas command is now available in Claude Code.`)
48
48
  }
@@ -1,66 +1,66 @@
1
- import { existsSync, readFileSync, readdirSync } from "fs";
2
- import { join } from "path";
3
- import { PACKAGE_ROOT } from "../config.ts";
1
+ import { existsSync, readdirSync, readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { PACKAGE_ROOT } from '../config.ts'
4
4
 
5
- const INSTRUCTIONS_DIR = join(PACKAGE_ROOT, "bin", "lib", "instructions");
5
+ const INSTRUCTIONS_DIR = join(PACKAGE_ROOT, 'bin', 'lib', 'instructions')
6
6
 
7
7
  export async function handleInstructions(args: string[]) {
8
- const topic = args[0];
8
+ const topic = args[0]
9
9
 
10
- if (!topic) {
11
- // Base instructions — overview
12
- const overview = readFileSync(join(INSTRUCTIONS_DIR, "overview.md"), "utf-8");
13
- process.stdout.write(overview);
14
- return;
15
- }
10
+ if (!topic) {
11
+ // Base instructions — overview
12
+ const overview = readFileSync(join(INSTRUCTIONS_DIR, 'overview.md'), 'utf-8')
13
+ process.stdout.write(overview)
14
+ return
15
+ }
16
16
 
17
- if (topic === "--list") {
18
- printAvailableTopics(process.stdout);
19
- return;
20
- }
17
+ if (topic === '--list') {
18
+ printAvailableTopics(process.stdout)
19
+ return
20
+ }
21
21
 
22
- // Try exact match: topic.md
23
- const file = join(INSTRUCTIONS_DIR, `${topic}.md`);
24
- if (existsSync(file)) {
25
- const content = readFileSync(file, "utf-8");
26
- process.stdout.write(content);
27
- return;
28
- }
22
+ // Try exact match: topic.md
23
+ const file = join(INSTRUCTIONS_DIR, `${topic}.md`)
24
+ if (existsSync(file)) {
25
+ const content = readFileSync(file, 'utf-8')
26
+ process.stdout.write(content)
27
+ return
28
+ }
29
29
 
30
- // Not found
31
- console.error(`Unknown topic: ${topic}\n`);
32
- printAvailableTopics(process.stderr);
33
- process.exit(1);
30
+ // Not found
31
+ console.error(`Unknown topic: ${topic}\n`)
32
+ printAvailableTopics(process.stderr)
33
+ process.exit(1)
34
34
  }
35
35
 
36
36
  function printAvailableTopics(out: NodeJS.WriteStream) {
37
- const files = readdirSync(INSTRUCTIONS_DIR)
38
- .filter(f => f.endsWith(".md") && f !== "overview.md")
39
- .map(f => f.replace(".md", ""))
40
- .sort();
37
+ const files = readdirSync(INSTRUCTIONS_DIR)
38
+ .filter(f => f.endsWith('.md') && f !== 'overview.md')
39
+ .map(f => f.replace('.md', ''))
40
+ .sort()
41
41
 
42
- const components = files.filter(f => f.startsWith("component-"));
43
- const flows = files.filter(f => f.startsWith("flow-"));
44
- const other = files.filter(f => !f.startsWith("component-") && !f.startsWith("flow-"));
42
+ const components = files.filter(f => f.startsWith('component-'))
43
+ const flows = files.filter(f => f.startsWith('flow-'))
44
+ const other = files.filter(f => !f.startsWith('component-') && !f.startsWith('flow-'))
45
45
 
46
- const lines: string[] = ["## Available Detail Topics", ""];
46
+ const lines: string[] = ['## Available Detail Topics', '']
47
47
 
48
- if (components.length) {
49
- lines.push("### Components");
50
- for (const c of components) lines.push(`- \`${c}\``);
51
- lines.push("");
52
- }
53
- if (flows.length) {
54
- lines.push("### Flows");
55
- for (const f of flows) lines.push(`- \`${f}\``);
56
- lines.push("");
57
- }
58
- if (other.length) {
59
- lines.push("### Other");
60
- for (const o of other) lines.push(`- \`${o}\``);
61
- lines.push("");
62
- }
48
+ if (components.length) {
49
+ lines.push('### Components')
50
+ for (const c of components) lines.push(`- \`${c}\``)
51
+ lines.push('')
52
+ }
53
+ if (flows.length) {
54
+ lines.push('### Flows')
55
+ for (const f of flows) lines.push(`- \`${f}\``)
56
+ lines.push('')
57
+ }
58
+ if (other.length) {
59
+ lines.push('### Other')
60
+ for (const o of other) lines.push(`- \`${o}\``)
61
+ lines.push('')
62
+ }
63
63
 
64
- lines.push("Usage: `bunx agent-canvas instructions <topic>`");
65
- out.write(lines.join("\n") + "\n");
64
+ lines.push('Usage: `bunx agent-canvas instructions <topic>`')
65
+ out.write(lines.join('\n') + '\n')
66
66
  }
@@ -1,111 +1,129 @@
1
- import { parseArgs } from "util";
2
- import { existsSync, statSync } from "fs";
3
- import { resolve, basename } from "path";
4
- import { BASE_URL } from "../config.ts";
5
- import { ensureDaemon, stopDaemon, startDaemon } from "../daemon-lifecycle.ts";
6
- import { getSessionId, openBrowser } from "../helpers.ts";
1
+ import { existsSync, statSync } from 'fs'
2
+ import { basename, resolve } from 'path'
3
+ import { parseArgs } from 'util'
4
+ import { z } from 'zod'
5
+ import { BASE_URL } from '../config.ts'
6
+ import { ensureDaemon, startDaemon, stopDaemon } from '../daemon-lifecycle.ts'
7
+ import { getSessionId, openBrowser } from '../helpers.ts'
8
+
9
+ const pushResponseSchema = z.union([
10
+ z.object({
11
+ ok: z.literal(true),
12
+ browserUrl: z.string(),
13
+ isNew: z.boolean(),
14
+ revision: z.number(),
15
+ canvasFiles: z.array(z.string()),
16
+ }),
17
+ z.object({
18
+ ok: z.literal(false).optional(),
19
+ error: z.string().optional(),
20
+ errors: z.record(z.unknown()).optional(),
21
+ unconsumedFeedback: z.string().optional(),
22
+ }),
23
+ ])
7
24
 
8
25
  export async function handlePush(args: string[]) {
9
- const { values, positionals } = parseArgs({
10
- args,
11
- options: {
12
- session: { type: "string" },
13
- label: { type: "string" },
14
- response: { type: "string" },
15
- },
16
- allowPositionals: true,
17
- });
26
+ const { values, positionals } = parseArgs({
27
+ args,
28
+ options: {
29
+ session: { type: 'string' },
30
+ label: { type: 'string' },
31
+ response: { type: 'string' },
32
+ },
33
+ allowPositionals: true,
34
+ })
18
35
 
19
- const target = positionals[0];
20
- if (!target) {
21
- console.error("Error: No directory specified. Usage: agent-canvas push <directory>");
22
- process.exit(1);
23
- }
36
+ const target = positionals[0]
37
+ if (!target) {
38
+ console.error('Error: No directory specified. Usage: agent-canvas push <directory>')
39
+ process.exit(1)
40
+ }
24
41
 
25
- const resolvedPath = resolve(target);
26
- if (!existsSync(resolvedPath)) {
27
- console.error(`Error: Path not found: ${resolvedPath}`);
28
- process.exit(1);
29
- }
42
+ const resolvedPath = resolve(target)
43
+ if (!existsSync(resolvedPath)) {
44
+ console.error(`Error: Path not found: ${resolvedPath}`)
45
+ process.exit(1)
46
+ }
30
47
 
31
- if (statSync(resolvedPath).isFile()) {
32
- console.error(`Error: Expected a directory, got a file: ${resolvedPath}`);
33
- console.error("Usage: agent-canvas push <directory>");
34
- process.exit(1);
35
- }
48
+ if (statSync(resolvedPath).isFile()) {
49
+ console.error(`Error: Expected a directory, got a file: ${resolvedPath}`)
50
+ console.error('Usage: agent-canvas push <directory>')
51
+ process.exit(1)
52
+ }
36
53
 
37
- const sessionId = getSessionId(values.session);
38
- const projectRoot = process.env.CANVAS_PROJECT_ROOT || process.cwd();
39
- const label = values.label;
40
- const autoLabel = !label ? basename(resolvedPath) : undefined;
54
+ const sessionId = getSessionId(values.session)
55
+ const projectRoot = process.env.CANVAS_PROJECT_ROOT || process.cwd()
56
+ const label = values.label
57
+ const autoLabel = !label ? basename(resolvedPath) : undefined
41
58
 
42
- await ensureDaemon();
59
+ await ensureDaemon()
43
60
 
44
- const response = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
45
- method: "POST",
46
- headers: { "Content-Type": "application/json" },
47
- body: JSON.stringify({
48
- directory: resolvedPath,
49
- projectRoot,
50
- label: label || autoLabel,
51
- ...(values.response ? { response: values.response } : {}),
52
- }),
53
- });
61
+ const response = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({
65
+ directory: resolvedPath,
66
+ projectRoot,
67
+ label: label || autoLabel,
68
+ ...(values.response ? { response: values.response } : {}),
69
+ }),
70
+ })
54
71
 
55
- let result = await response.json() as any;
72
+ let result = pushResponseSchema.parse(await response.json())
56
73
 
57
- // Bun.build() / Bun.Transpiler can fail in long-running daemons due to
58
- // internal state corruption, CWD unlinking, etc. Restart and retry once.
59
- // Skip restart only for clear user errors (syntax errors with line/col,
60
- // runtime validation errors) — those won't be fixed by a restart.
61
- const isUserError = !result.ok && result.errors && Object.values(result.errors).every((e: any) =>
62
- typeof e === "string" && (
63
- /\(line \d+, col \d+\)/.test(e) ||
64
- /Syntax Error|Parse error|Mermaid syntax error/i.test(e) ||
65
- e.startsWith("Runtime error:")
66
- ));
67
- const shouldRestart = !result.ok && result.error === "Canvas compilation failed" && !isUserError;
68
- if (shouldRestart) {
69
- console.error("Compilation failed, restarting daemon and retrying...");
70
- await stopDaemon();
71
- await startDaemon();
72
- const retryResponse = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
73
- method: "POST",
74
- headers: { "Content-Type": "application/json" },
75
- body: JSON.stringify({
76
- directory: resolvedPath,
77
- projectRoot,
78
- label: label || autoLabel,
79
- ...(values.response ? { response: values.response } : {}),
80
- }),
81
- });
82
- result = await retryResponse.json() as any;
83
- }
74
+ // Bun.build() / Bun.Transpiler can fail in long-running daemons due to
75
+ // internal state corruption, CWD unlinking, etc. Restart and retry once.
76
+ // Skip restart only for clear user errors (syntax errors with line/col,
77
+ // runtime validation errors) — those won't be fixed by a restart.
78
+ const isUserError = !result.ok && result.errors && Object.values(result.errors).every((e) =>
79
+ typeof e === 'string' && (
80
+ /\(line \d+, col \d+\)/.test(e)
81
+ || /Syntax Error|Parse error|Mermaid syntax error/i.test(e)
82
+ || e.startsWith('Runtime error:')
83
+ )
84
+ )
85
+ const shouldRestart = !result.ok && result.error === 'Canvas compilation failed' && !isUserError
86
+ if (shouldRestart) {
87
+ console.error('Compilation failed, restarting daemon and retrying...')
88
+ await stopDaemon()
89
+ await startDaemon()
90
+ const retryResponse = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({
94
+ directory: resolvedPath,
95
+ projectRoot,
96
+ label: label || autoLabel,
97
+ ...(values.response ? { response: values.response } : {}),
98
+ }),
99
+ })
100
+ result = pushResponseSchema.parse(await retryResponse.json())
101
+ }
84
102
 
85
- if (!result.ok) {
86
- if (result.unconsumedFeedback) {
87
- console.error(`Error: ${result.error}`);
88
- console.log(result.unconsumedFeedback);
89
- } else {
90
- console.error(`Compilation error:\n${result.error}`);
91
- if (result.errors) {
92
- for (const [file, err] of Object.entries(result.errors)) {
93
- console.error(` ${file}: ${err}`);
94
- }
95
- }
96
- }
97
- process.exit(1);
98
- }
103
+ if (!result.ok) {
104
+ if (result.unconsumedFeedback) {
105
+ console.error(`Error: ${result.error}`)
106
+ console.log(result.unconsumedFeedback)
107
+ } else {
108
+ console.error(`Compilation error:\n${result.error}`)
109
+ if (result.errors) {
110
+ for (const [file, err] of Object.entries(result.errors)) {
111
+ console.error(` ${file}: ${err}`)
112
+ }
113
+ }
114
+ }
115
+ process.exit(1)
116
+ }
99
117
 
100
- if (result.isNew) {
101
- openBrowser(result.browserUrl);
102
- }
118
+ if (result.isNew) {
119
+ openBrowser(result.browserUrl)
120
+ }
103
121
 
104
- console.log(JSON.stringify({
105
- ok: true,
106
- browserUrl: result.browserUrl,
107
- revision: result.revision,
108
- sessionId,
109
- canvasFiles: result.canvasFiles,
110
- }));
122
+ console.log(JSON.stringify({
123
+ ok: true,
124
+ browserUrl: result.browserUrl,
125
+ revision: result.revision,
126
+ sessionId,
127
+ canvasFiles: result.canvasFiles,
128
+ }))
111
129
  }
@@ -0,0 +1,190 @@
1
+ import { waitForEvent } from '@fabrika/daemon-kit'
2
+ import { execFileSync } from 'child_process'
3
+ import { basename } from 'path'
4
+ import { parseArgs } from 'util'
5
+ import { z } from 'zod'
6
+ import { PROJECT_RE, reviewThreadSchema } from '../../../daemon/src/review/model.ts'
7
+ import { BASE_URL, TIMEOUT_MS, WS_URL } from '../config.ts'
8
+ import { ensureDaemon } from '../daemon-lifecycle.ts'
9
+ import { openBrowser } from '../helpers.ts'
10
+
11
+ const USAGE = `agent-canvas review — comments on a running application
12
+
13
+ agent-canvas review open <url> [--follow] [--no-open]
14
+ Serve the app at <url> with the review widget, open it in the
15
+ browser and print the address; --follow then watches as below
16
+ agent-canvas review snippet Print the <script> tag that adds the review widget to the app
17
+ agent-canvas review watch Wait for threads the reviewer sends (blocks)
18
+ agent-canvas review watch --follow Keep waiting; print every batch the reviewer sends
19
+ agent-canvas review fetch Take the threads already sent (returns immediately)
20
+ agent-canvas review reply <thread> <text> [--resolve]
21
+ Answer a thread; --resolve closes it
22
+ agent-canvas review list Show every thread that is not resolved
23
+
24
+ Every command takes --project <name>; it defaults to CANVAS_REVIEW_PROJECT, then to the git repository's directory name.`
25
+
26
+ const claimSchema = z.union([z.object({ found: z.literal(false) }), z.object({ found: z.literal(true), markdown: z.string() })])
27
+ const threadsSchema = z.object({ threads: z.array(reviewThreadSchema) })
28
+ const proxySchema = z.object({ url: z.string() })
29
+ const errorSchema = z.object({ error: z.string() })
30
+
31
+ function defaultProject(): string {
32
+ const fromEnv = process.env.CANVAS_REVIEW_PROJECT
33
+ if (fromEnv !== undefined && fromEnv !== '') return fromEnv
34
+ let root = process.cwd()
35
+ try {
36
+ root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
37
+ } catch {}
38
+ return basename(root).replace(/[^A-Za-z0-9._-]/g, '-')
39
+ }
40
+
41
+ async function call(project: string, method: string, path: string, body?: unknown): Promise<unknown> {
42
+ const response = await fetch(`${BASE_URL}/api/review/${encodeURIComponent(project)}${path}`, {
43
+ method,
44
+ ...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
45
+ })
46
+ const result: unknown = await response.json()
47
+ if (!response.ok) {
48
+ const error = errorSchema.safeParse(result)
49
+ throw new Error(error.success ? error.data.error : `Daemon answered ${response.status}`)
50
+ }
51
+ return result
52
+ }
53
+
54
+ async function claim(project: string): Promise<string | undefined> {
55
+ const result = claimSchema.parse(await call(project, 'POST', '/claim'))
56
+ return result.found ? result.markdown : undefined
57
+ }
58
+
59
+ function waitForRequests(project: string): Promise<string> {
60
+ return waitForEvent<string>({
61
+ url: `${WS_URL}/ws/review-wait/${encodeURIComponent(project)}`,
62
+ // The frame only announces new threads; claiming them is what hands them over, exactly once.
63
+ onFrame: (frame) => (typeof frame === 'object' && frame !== null && 'type' in frame && frame.type === 'queued' ? { type: 'recheck' } : undefined),
64
+ probe: async () => {
65
+ const markdown = await claim(project)
66
+ return markdown === undefined ? undefined : { value: markdown }
67
+ },
68
+ alreadyProbed: true,
69
+ timeoutMs: TIMEOUT_MS,
70
+ reconnect: { attempts: 3, delayMs: 1_000 },
71
+ messages: {
72
+ timeout: 'Timeout waiting for review requests.',
73
+ closed: 'WebSocket closed before the reviewer sent anything.',
74
+ connectionFailed: 'WebSocket connection failed.',
75
+ },
76
+ })
77
+ }
78
+
79
+ const FOLLOW_RECONNECT_MS = 2_000
80
+ const FOLLOW_GIVE_UP_MS = 60_000
81
+
82
+ /**
83
+ * The streaming watcher for hosts that turn each output line into an event (Claude Code's Monitor). One socket stays
84
+ * open, so the reviewer keeps seeing an agent listening between batches. Status goes to stderr; stdout carries only
85
+ * batches and the line that ends the watch, so every stdout event is worth acting on.
86
+ */
87
+ function followRequests(project: string): Promise<never> {
88
+ let claiming = Promise.resolve()
89
+ let unreachableSince: number | null = null
90
+
91
+ const claimAndPrint = () => {
92
+ claiming = claiming.then(async () => {
93
+ const markdown = await claim(project)
94
+ if (markdown !== undefined) process.stdout.write(`${markdown}\n`)
95
+ }).catch((error: unknown) => {
96
+ console.error(`review watch: claim failed: ${error instanceof Error ? error.message : String(error)}`)
97
+ })
98
+ }
99
+
100
+ const connect = () => {
101
+ const socket = new WebSocket(`${WS_URL}/ws/review-wait/${encodeURIComponent(project)}`)
102
+ socket.onopen = () => {
103
+ unreachableSince = null
104
+ console.error(`review watch: listening for ${project}`)
105
+ claimAndPrint()
106
+ }
107
+ socket.onmessage = (event) => {
108
+ // Any frame on the wait socket means "something was queued"; the claim reads what, so the frame needs no parsing.
109
+ if (typeof event.data === 'string') claimAndPrint()
110
+ }
111
+ socket.onclose = () => {
112
+ unreachableSince ??= Date.now()
113
+ if (Date.now() - unreachableSince > FOLLOW_GIVE_UP_MS) {
114
+ console.log(`review watch: the daemon at ${BASE_URL} has been unreachable for a minute; stopped watching ${project}.`)
115
+ process.exit(1)
116
+ }
117
+ setTimeout(connect, FOLLOW_RECONNECT_MS)
118
+ }
119
+ }
120
+
121
+ connect()
122
+ return new Promise<never>(() => {})
123
+ }
124
+
125
+ export async function handleReview(args: string[]) {
126
+ const [subcommand, ...rest] = args
127
+ const { values, positionals } = parseArgs({
128
+ args: rest,
129
+ allowPositionals: true,
130
+ options: {
131
+ project: { type: 'string' },
132
+ resolve: { type: 'boolean', default: false },
133
+ follow: { type: 'boolean', default: false },
134
+ 'no-open': { type: 'boolean', default: false },
135
+ },
136
+ })
137
+ const project = values.project ?? defaultProject()
138
+ if (!PROJECT_RE.test(project)) throw new Error(`Invalid project name "${project}"; use letters, digits, ".", "_" and "-"`)
139
+
140
+ switch (subcommand) {
141
+ case 'open': {
142
+ const [target] = positionals
143
+ if (target === undefined) throw new Error('Usage: agent-canvas review open <url> [--follow] [--no-open]')
144
+ await ensureDaemon()
145
+ const { url } = proxySchema.parse(await call(project, 'POST', '/proxy', { target: new URL(target).href }))
146
+ console.log(`Review ${target} at ${url}`)
147
+ if (!values['no-open']) openBrowser(url)
148
+ if (values.follow) return followRequests(project)
149
+ return
150
+ }
151
+ case 'snippet':
152
+ console.log(`<script src="${BASE_URL}/assets/review.js" data-project="${project}"></script>`)
153
+ return
154
+ case 'watch': {
155
+ await ensureDaemon()
156
+ if (values.follow) return followRequests(project)
157
+ process.stdout.write((await claim(project)) ?? (await waitForRequests(project)))
158
+ return
159
+ }
160
+ case 'fetch': {
161
+ await ensureDaemon()
162
+ const markdown = await claim(project)
163
+ process.stdout.write(markdown ?? 'No review requests are waiting.\n')
164
+ return
165
+ }
166
+ case 'reply': {
167
+ const [threadId, ...words] = positionals
168
+ const body = words.join(' ')
169
+ if (threadId === undefined || body.trim() === '') throw new Error('Usage: agent-canvas review reply <thread> <text> [--resolve]')
170
+ await ensureDaemon()
171
+ await call(project, 'POST', `/threads/${encodeURIComponent(threadId)}/reply`, { body, resolve: values.resolve })
172
+ console.log(values.resolve ? `Replied to ${threadId} and resolved it.` : `Replied to ${threadId}.`)
173
+ return
174
+ }
175
+ case 'list': {
176
+ await ensureDaemon()
177
+ const { threads } = threadsSchema.parse(await call(project, 'GET', '/threads'))
178
+ const pending = threads.filter((thread) => thread.state !== 'resolved')
179
+ if (pending.length === 0) console.log('No unresolved threads.')
180
+ for (const thread of pending) {
181
+ const first = thread.comments[0]?.body.split('\n')[0] ?? ''
182
+ console.log(`${thread.id} ${thread.state.padEnd(8)} ${thread.anchor.url}\n ${first}`)
183
+ }
184
+ return
185
+ }
186
+ default:
187
+ console.log(USAGE)
188
+ process.exit(subcommand === undefined || subcommand === '--help' ? 0 : 1)
189
+ }
190
+ }