gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,947 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { watch, type FSWatcher } from "node:fs";
3
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { open, readFile, realpath, stat } from "node:fs/promises";
5
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
6
+ import { extname, isAbsolute, join, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { DESIGN_COMMENTS_FILE, DESIGN_COMMENT_LIMITS, DESIGN_SCREENSHOT_TYPES, DesignCommentsError, MAX_DESIGN_SCREENSHOT_BYTES, createDesignCommentStore, isDesignCommentId, truncateUtf16, type StoredDesignComment } from "./design-comments.ts";
9
+ import { DESIGN_FEATURE_PATH, DesignHandoffsError, createDesignHandoffStore, sendDesignHandoff, type DesignHandoffDelivery } from "./design-handoffs.ts";
10
+
11
+ // Loopback canvas host for gentle-design (S4-S6). Every route lives under a
12
+ // per-server random token, the Host header must name this server, POSTs need
13
+ // this server's own Origin, and files resolve by realpath inside designRoot.
14
+
15
+ const HOST = "127.0.0.1";
16
+ /** Design directory names the canvas serves: kebab-case, never a dot or path segment. */
17
+ export const DESIGN_SLUG = /^[a-z0-9][a-z0-9-]*$/;
18
+ const SLUG = DESIGN_SLUG;
19
+ const TOKEN_SHAPE = /^[A-Za-z0-9_-]{16,128}$/;
20
+ const ASSET_DIR = fileURLToPath(new URL("../assets/design-canvas/", import.meta.url));
21
+ const ASSETS: Readonly<Record<string, string>> = Object.freeze({
22
+ "canvas.js": "text/javascript; charset=utf-8",
23
+ "canvas.css": "text/css; charset=utf-8",
24
+ "inspector.js": "text/javascript; charset=utf-8",
25
+ });
26
+ const FILE_TYPES: Readonly<Record<string, string>> = Object.freeze({
27
+ ".html": "text/html; charset=utf-8",
28
+ ".css": "text/css; charset=utf-8",
29
+ ".js": "text/javascript; charset=utf-8",
30
+ ".json": "application/json; charset=utf-8",
31
+ ".svg": "image/svg+xml",
32
+ ".png": "image/png",
33
+ ".jpg": "image/jpeg",
34
+ ".jpeg": "image/jpeg",
35
+ ".webp": "image/webp",
36
+ ".gif": "image/gif",
37
+ // No font types: artboards use system font stacks (S1), and a sandboxed
38
+ // artboard's opaque-origin font request would be refused anyway.
39
+ });
40
+ const LIMITS = Object.freeze({
41
+ bodyBytes: 16 * 1024,
42
+ /** POST api/comments may carry one base64 screenshot; its other fields keep `bodyBytes`. */
43
+ commentBodyBytes: 8 * 1024 * 1024,
44
+ ...DESIGN_COMMENT_LIMITS,
45
+ manifestBytes: 256 * 1024,
46
+ fileBytes: 32 * 1024 * 1024,
47
+ artboards: 64,
48
+ artboardSide: 20000,
49
+ sseClients: 16,
50
+ });
51
+ /** design/system.html, written by /gentle:design sync, is shown as the first board of every design. */
52
+ const SYSTEM_FILE = "system.html";
53
+ /** Root files whose changes reach the canvas as `{ slug: null, file }`. */
54
+ const SYSTEM_FILES: ReadonlySet<string> = new Set([SYSTEM_FILE, "system.md"]);
55
+ const SYSTEM_BOARD = Object.freeze({ width: 1440, height: 1200 });
56
+ const DEBOUNCE_MS = 150;
57
+ const HEARTBEAT_MS = 15_000;
58
+ const BASE_HEADERS = Object.freeze({
59
+ "Cache-Control": "no-store",
60
+ "X-Content-Type-Options": "nosniff",
61
+ "Referrer-Policy": "no-referrer",
62
+ });
63
+ // Canvas and API only: artboards run in an opaque origin, so their own
64
+ // subresources (files, inspector.js) would be blocked by a same-origin CORP.
65
+ const SAME_ORIGIN_ONLY = Object.freeze({ "Cross-Origin-Resource-Policy": "same-origin" });
66
+ const CANVAS_CSP = [
67
+ "default-src 'none'",
68
+ "script-src 'self'",
69
+ "style-src 'self'",
70
+ "img-src 'self' data:",
71
+ "connect-src 'self'",
72
+ // Compare (S15) frames the running app next to its artboard: loopback origins only.
73
+ `frame-src 'self' ${["http", "https"].flatMap((scheme) => ["localhost", "127.0.0.1", "[::1]"].map((host) => `${scheme}://${host}:*`)).join(" ")}`,
74
+ "base-uri 'none'",
75
+ "form-action 'none'",
76
+ "frame-ancestors 'none'",
77
+ ].join("; ");
78
+
79
+ /**
80
+ * A comment's screenshot as delivered to the session: `path` is relative to the
81
+ * session cwd (design/<slug>/.screenshots/<id>.<ext>); `data` is base64, absent when unreadable.
82
+ */
83
+ export type DesignCommentScreenshot = { path: string; mimeType: string; data?: string };
84
+
85
+ /** A comment as delivered to the session; `id` names it in design/<slug>/comments.json. */
86
+ export type DesignComment = { id: string; slug: string; file: string; selector: string; excerpt: string; comment: string; screenshot?: DesignCommentScreenshot };
87
+
88
+ /** Session message content: plain text, or text followed by the attached screenshots. */
89
+ export type DesignSessionContent = string | Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
90
+
91
+ /** The most screenshots one batch message attaches; later ones are named for the read tool. */
92
+ export const MAX_DESIGN_BATCH_IMAGES = 6;
93
+
94
+ export type DesignCanvasServerOptions = {
95
+ /** Absolute `<session cwd>/design` directory. */
96
+ designRoot: string;
97
+ /** "Send now": one comment, already saved; a throw leaves it saved and unsent. */
98
+ onComment: (comment: DesignComment) => void | Promise<void>;
99
+ /** "Send all": every open unsent comment of one design, as one batch. */
100
+ onComments?: (batch: DesignComment[]) => void | Promise<void>;
101
+ /**
102
+ * "Implement this": an artboard of the manifest, already validated and recorded as handed off
103
+ * in handoffs.json. It answers whether the handoff was queued behind a busy agent; a throw
104
+ * takes the record back and answers 500.
105
+ */
106
+ onImplement?: (request: DesignImplementRequest) => DesignHandoffDelivery | Promise<DesignHandoffDelivery>;
107
+ initialSlug?: string;
108
+ randomToken?: () => string;
109
+ };
110
+
111
+ export type DesignCanvasServer = {
112
+ url: string;
113
+ port: number;
114
+ token: string;
115
+ close(): Promise<void>;
116
+ /** Canvas pages with an open live-reload stream. */
117
+ connectedClients(): number;
118
+ /** Tells every connected page to show `slug` (null: the system-only view) and reload it; returns how many it reached. */
119
+ show(slug: string | null): number;
120
+ };
121
+
122
+ export type DesignArtboard = { file: string; title: string; tradeoff: string; width: number; height: number };
123
+ /** `feature` is present only when valid; `warnings` only when something in design.json was dropped. */
124
+ export type DesignManifest = { slug: string; title: string; brief: string; artboards: DesignArtboard[]; feature?: string; warnings?: string[] };
125
+ export type DesignImplementRequest = { slug: string; file: string };
126
+
127
+ export { DESIGN_FEATURE_PATH };
128
+
129
+ class HttpError extends Error {
130
+ readonly status: number;
131
+ readonly reason: string;
132
+ readonly body: Record<string, unknown> | undefined;
133
+ constructor(status: number, reason: string, body?: Record<string, unknown>) {
134
+ super(reason);
135
+ this.status = status;
136
+ this.reason = reason;
137
+ this.body = body;
138
+ }
139
+ }
140
+
141
+ /** Store failures as HTTP: a broken comments.json is 422 and is never overwritten. */
142
+ function storeFailure(error: unknown): never {
143
+ if (!(error instanceof DesignCommentsError)) throw error;
144
+ if (error.code === "not-found") throw new HttpError(404, "not-found");
145
+ if (error.code === "invalid-file") throw new HttpError(422, "invalid-comments", { error: "invalid-comments", reason: error.message });
146
+ if (error.code === "limit") throw new HttpError(409, "too-many-comments", { error: "too-many-comments", reason: error.message });
147
+ if (error.code === "invalid-input") throw new HttpError(400, "invalid-comment", { error: "invalid-comment", reason: error.message });
148
+ throw new HttpError(500, "internal");
149
+ }
150
+
151
+ /** handoffs.json failures as HTTP: a broken file is 422 and is never overwritten. */
152
+ function handoffFailure(error: unknown): never {
153
+ if (!(error instanceof DesignHandoffsError)) throw error;
154
+ if (error.code === "not-found") throw new HttpError(404, "not-found");
155
+ if (error.code === "invalid-file") throw new HttpError(422, "invalid-handoffs", { error: "invalid-handoffs", reason: error.message });
156
+ if (error.code === "limit") throw new HttpError(409, "too-many-handoffs", { error: "too-many-handoffs", reason: error.message });
157
+ throw new HttpError(400, "invalid-handoff", { error: "invalid-handoff", reason: error.message });
158
+ }
159
+
160
+ function resolveInstruction(slug: string, ids: readonly string[]): string {
161
+ const list = ids.map((id) => JSON.stringify(id)).join(", ");
162
+ return `mark it resolved with the gentle_design_comments tool (action "resolve", slug "${slug}", ids [${list}]), or set its status to "resolved" in design/${slug}/${DESIGN_COMMENTS_FILE}.`;
163
+ }
164
+
165
+ function excerptLine(excerpt: string): string {
166
+ return `Excerpt: ${excerpt ? JSON.stringify(excerpt) : "(none)"}`;
167
+ }
168
+
169
+ function screenshotLine(screenshot: DesignCommentScreenshot, attached: boolean): string {
170
+ return `Screenshot: ${screenshot.path} (${attached ? "attached; open it with the read tool if you cannot see it" : "open it with the read tool"})`;
171
+ }
172
+
173
+ /** The batch comments whose screenshot is attached: readable ones, in order, up to the cap. */
174
+ function attachedScreenshots(batch: readonly DesignComment[]): Set<DesignComment> {
175
+ return new Set(batch.filter((comment) => comment.screenshot?.data !== undefined).slice(0, MAX_DESIGN_BATCH_IMAGES));
176
+ }
177
+
178
+ function withImages(text: string, comments: Iterable<DesignComment>): DesignSessionContent {
179
+ const images = [...comments].map((comment) => ({ type: "image" as const, data: comment.screenshot!.data!, mimeType: comment.screenshot!.mimeType }));
180
+ return images.length === 0 ? text : [{ type: "text", text }, ...images];
181
+ }
182
+
183
+ /** Session content for one canvas comment: its message, with the screenshot attached when readable. */
184
+ export function designCommentContent(comment: DesignComment): DesignSessionContent {
185
+ return withImages(formatDesignComment(comment), comment.screenshot?.data !== undefined ? [comment] : []);
186
+ }
187
+
188
+ /** Session content for a batch: one message, with up to MAX_DESIGN_BATCH_IMAGES screenshots attached. */
189
+ export function designCommentBatchContent(batch: readonly DesignComment[]): DesignSessionContent {
190
+ return withImages(formatDesignCommentBatch(batch), attachedScreenshots(batch));
191
+ }
192
+
193
+ /** Session message for one canvas comment; paths are relative to the session cwd. */
194
+ export function formatDesignComment(comment: DesignComment): string {
195
+ const artboard = `design/${comment.slug}/${comment.file}`;
196
+ return [
197
+ `Design comment for ${artboard}`,
198
+ "",
199
+ `Design: ${comment.slug}`,
200
+ `Artboard: ${artboard}`,
201
+ `Comment id: ${comment.id}`,
202
+ `Element: ${comment.selector}`,
203
+ excerptLine(comment.excerpt),
204
+ ...(comment.screenshot ? [screenshotLine(comment.screenshot, comment.screenshot.data !== undefined)] : []),
205
+ "",
206
+ "Comment:",
207
+ comment.comment,
208
+ "",
209
+ `Edit only the artboard ${artboard}, following the gentle-design skill.`,
210
+ `When it is applied, ${resolveInstruction(comment.slug, [comment.id])}`,
211
+ ].join("\n");
212
+ }
213
+
214
+ /** One session message for a batch of comments from one design. */
215
+ export function formatDesignCommentBatch(batch: readonly DesignComment[]): string {
216
+ const slug = batch[0]?.slug ?? "";
217
+ const lines = [`Design comments for design/${slug} (${batch.length})`, ""];
218
+ const attached = attachedScreenshots(batch);
219
+ batch.forEach((comment, index) => {
220
+ lines.push(
221
+ `${index + 1}. Comment id: ${comment.id}`,
222
+ ` Artboard: design/${comment.slug}/${comment.file}`,
223
+ ` Element: ${comment.selector}`,
224
+ ` ${excerptLine(comment.excerpt)}`,
225
+ ...(comment.screenshot ? [` ${screenshotLine(comment.screenshot, attached.has(comment))}`] : []),
226
+ " Comment:",
227
+ ...comment.comment.split("\n").map((line) => ` ${line}`),
228
+ "",
229
+ );
230
+ });
231
+ lines.push(
232
+ "Apply each comment following the gentle-design skill, editing only the artboard it names.",
233
+ `When a comment is applied, ${resolveInstruction(slug, batch.map((comment) => comment.id))}`,
234
+ );
235
+ return lines.join("\n");
236
+ }
237
+
238
+ function sameToken(expected: string, candidate: string): boolean {
239
+ const a = Buffer.from(expected);
240
+ const b = Buffer.from(candidate);
241
+ return a.length === b.length && timingSafeEqual(a, b);
242
+ }
243
+
244
+ /** One decoded path segment, or null when it could name anything but a plain entry. */
245
+ function safeSegment(raw: string): string | null {
246
+ let decoded: string;
247
+ try {
248
+ decoded = decodeURIComponent(raw);
249
+ } catch {
250
+ throw new HttpError(400, "malformed-url");
251
+ }
252
+ if (decoded === "" || decoded.startsWith(".") || /[\/\\\0:]/.test(decoded) || /[\u0000-\u001f\u007f]/.test(decoded)) return null;
253
+ return decoded;
254
+ }
255
+
256
+ /** Relative path such as `sub/01-a.html` split into plain segments, or null. */
257
+ function relativeSegments(value: unknown): string[] | null {
258
+ if (typeof value !== "string" || value.length === 0 || value.length > 512) return null;
259
+ const parts = value.split("/");
260
+ for (const part of parts) {
261
+ if (part === "" || part.startsWith(".") || /[\\\0:]/.test(part) || /[\u0000-\u001f\u007f]/.test(part)) return null;
262
+ }
263
+ return parts;
264
+ }
265
+
266
+ function within(root: string, target: string): boolean {
267
+ return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
268
+ }
269
+
270
+ function isMissing(error: unknown): boolean {
271
+ const code = (error as NodeJS.ErrnoException)?.code;
272
+ return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP" || code === "EACCES" || code === "EPERM";
273
+ }
274
+
275
+ function collapse(value: string): string {
276
+ return value.replace(/\s+/g, " ").trim();
277
+ }
278
+
279
+ function normalizeManifest(slug: string, raw: string): DesignManifest {
280
+ let value: unknown;
281
+ try {
282
+ value = JSON.parse(raw);
283
+ } catch {
284
+ throw new HttpError(422, "design.json is not valid JSON");
285
+ }
286
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new HttpError(422, "design.json must be a JSON object");
287
+ const record = value as Record<string, unknown>;
288
+ if (record.title !== undefined && typeof record.title !== "string") throw new HttpError(422, "title must be a string");
289
+ if (record.brief !== undefined && typeof record.brief !== "string") throw new HttpError(422, "brief must be a string");
290
+ if (!Array.isArray(record.artboards)) throw new HttpError(422, "artboards must be an array");
291
+ if (record.artboards.length > LIMITS.artboards) throw new HttpError(422, `artboards must have at most ${LIMITS.artboards} entries`);
292
+ const artboards = record.artboards.map((entry: unknown, index: number): DesignArtboard => {
293
+ const where = `artboards[${index}]`;
294
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new HttpError(422, `${where} must be an object`);
295
+ const board = entry as Record<string, unknown>;
296
+ const segments = relativeSegments(board.file);
297
+ if (!segments || extname(segments[segments.length - 1]).toLowerCase() !== ".html") throw new HttpError(422, `${where}.file must be a relative .html path inside the design`);
298
+ for (const key of ["title", "tradeoff"] as const) {
299
+ if (board[key] !== undefined && typeof board[key] !== "string") throw new HttpError(422, `${where}.${key} must be a string`);
300
+ }
301
+ for (const key of ["width", "height"] as const) {
302
+ const size = board[key];
303
+ if (!Number.isInteger(size) || (size as number) < 1 || (size as number) > LIMITS.artboardSide) throw new HttpError(422, `${where}.${key} must be an integer between 1 and ${LIMITS.artboardSide}`);
304
+ }
305
+ return {
306
+ file: segments.join("/"),
307
+ title: typeof board.title === "string" && board.title.trim() ? board.title.trim() : segments.join("/"),
308
+ tradeoff: typeof board.tradeoff === "string" ? board.tradeoff.trim() : "",
309
+ width: board.width as number,
310
+ height: board.height as number,
311
+ };
312
+ });
313
+ const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : slug;
314
+ const manifest: DesignManifest = { slug, title, brief: typeof record.brief === "string" ? record.brief.trim() : "", artboards };
315
+ if (record.feature !== undefined) {
316
+ // The regex refuses `..` segments too: a name may not start with a dot and has no slash.
317
+ if (typeof record.feature === "string" && DESIGN_FEATURE_PATH.test(record.feature)) manifest.feature = record.feature;
318
+ else manifest.warnings = ["feature must be a relative odd/tasks/<name>.md path; it was ignored"];
319
+ }
320
+ return manifest;
321
+ }
322
+
323
+ function escapeHtml(value: string): string {
324
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!);
325
+ }
326
+
327
+ /** JSON safe to place inside a `<script type="application/json">` element. */
328
+ function scriptJson(value: unknown): string {
329
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
330
+ }
331
+
332
+ function injectInspector(html: string, tag: string): string {
333
+ const index = html.toLowerCase().lastIndexOf("</body>");
334
+ return index === -1 ? html + tag : html.slice(0, index) + tag + html.slice(index);
335
+ }
336
+
337
+ function mediaType(req: IncomingMessage): string {
338
+ return String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
339
+ }
340
+
341
+ async function readBody(req: IncomingMessage, limit: number): Promise<string> {
342
+ const declared = Number(req.headers["content-length"]);
343
+ if (Number.isFinite(declared) && declared > limit) throw new HttpError(413, "body-too-large");
344
+ const chunks: Buffer[] = [];
345
+ let size = 0;
346
+ for await (const chunk of req) {
347
+ size += (chunk as Buffer).length;
348
+ if (size > limit) throw new HttpError(413, "body-too-large");
349
+ chunks.push(chunk as Buffer);
350
+ }
351
+ return Buffer.concat(chunks).toString("utf8");
352
+ }
353
+
354
+ export type DesignListing = { slug: string; title: string; mtime: number; valid: boolean };
355
+
356
+ /**
357
+ * Designs under `designRoot`, most recent design.json first. The one listing the
358
+ * command and the canvas server share: a kebab-case entry counts, symlink or
359
+ * not, when its real path stays inside the real design root and it has a
360
+ * design.json file that also resolves inside it. An invalid manifest is listed
361
+ * with valid: false and its slug as the title.
362
+ */
363
+ export function listDesignEntries(designRoot: string): DesignListing[] {
364
+ let root: string;
365
+ let entries;
366
+ try {
367
+ root = realpathSync(designRoot);
368
+ entries = readdirSync(root, { withFileTypes: true });
369
+ } catch (error) {
370
+ if (isMissing(error)) return [];
371
+ throw error;
372
+ }
373
+ const designs: DesignListing[] = [];
374
+ for (const entry of entries) {
375
+ if (!SLUG.test(entry.name) || !(entry.isDirectory() || entry.isSymbolicLink())) continue;
376
+ let path: string;
377
+ let info;
378
+ try {
379
+ const design = realpathSync(join(root, entry.name));
380
+ if (!within(root, design) || design === root) continue;
381
+ path = realpathSync(join(design, "design.json"));
382
+ if (!within(design, path)) continue;
383
+ info = statSync(path);
384
+ } catch (error) {
385
+ if (isMissing(error)) continue;
386
+ throw error;
387
+ }
388
+ if (!info.isFile()) continue;
389
+ let title = entry.name;
390
+ let valid = false;
391
+ try {
392
+ if (info.size <= LIMITS.manifestBytes) {
393
+ title = normalizeManifest(entry.name, readFileSync(path, "utf8")).title;
394
+ valid = true;
395
+ }
396
+ } catch {
397
+ valid = false;
398
+ }
399
+ designs.push({ slug: entry.name, title, mtime: info.mtimeMs, valid });
400
+ }
401
+ return designs.sort((a, b) => b.mtime - a.mtime || a.slug.localeCompare(b.slug));
402
+ }
403
+
404
+ /** Real path of `<designRoot>/system.html` when it is a file inside the real design root, else undefined. */
405
+ export function resolveSystemBoard(designRoot: string): string | undefined {
406
+ try {
407
+ const root = realpathSync(designRoot);
408
+ const target = realpathSync(join(root, SYSTEM_FILE));
409
+ if (!within(root, target) || target === root) return undefined;
410
+ return statSync(target).isFile() && extname(target).toLowerCase() === ".html" ? target : undefined;
411
+ } catch (error) {
412
+ if (isMissing(error)) return undefined;
413
+ throw error;
414
+ }
415
+ }
416
+
417
+ /** Real path of `<designRoot>/<slug>/<relative>` when it stays inside that design, else undefined. */
418
+ function resolveDesignFileSync(designRoot: string, slug: string, segments: string[]): string | undefined {
419
+ if (!SLUG.test(slug)) return undefined;
420
+ try {
421
+ const root = realpathSync(designRoot);
422
+ const design = realpathSync(join(root, slug));
423
+ if (!within(root, design) || design === root) return undefined;
424
+ const target = realpathSync(join(design, ...segments));
425
+ return within(design, target) && target !== design ? target : undefined;
426
+ } catch (error) {
427
+ if (isMissing(error)) return undefined;
428
+ throw error;
429
+ }
430
+ }
431
+
432
+ /** The normalized manifest of one design, with the server's containment and limits; throws a reason when it cannot be read. */
433
+ export function readDesignManifest(designRoot: string, slug: string): DesignManifest {
434
+ const path = resolveDesignFileSync(designRoot, slug, ["design.json"]);
435
+ if (!path || !statSync(path).isFile()) throw new Error(`No design "${slug}" in design/.`);
436
+ if (statSync(path).size > LIMITS.manifestBytes) throw new Error(`design/${slug}/design.json is too large`);
437
+ try {
438
+ return normalizeManifest(slug, readFileSync(path, "utf8"));
439
+ } catch (error) {
440
+ throw new Error(`design/${slug}/design.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
441
+ }
442
+ }
443
+
444
+ /** Whether `<designRoot>/system.md` is a contained file, so a handoff can name it as the token source. */
445
+ export function hasSystemTokens(designRoot: string): boolean {
446
+ try {
447
+ const root = realpathSync(designRoot);
448
+ const target = realpathSync(join(root, "system.md"));
449
+ return within(root, target) && statSync(target).isFile();
450
+ } catch (error) {
451
+ if (isMissing(error)) return false;
452
+ throw error;
453
+ }
454
+ }
455
+
456
+ export async function startDesignCanvasServer(options: DesignCanvasServerOptions): Promise<DesignCanvasServer> {
457
+ if (typeof options.designRoot !== "string" || !isAbsolute(options.designRoot)) throw new Error("designRoot must be an absolute path");
458
+ if (typeof options.onComment !== "function") throw new Error("onComment must be a function");
459
+ if (options.initialSlug !== undefined && !SLUG.test(options.initialSlug)) throw new Error("initialSlug must be kebab-case");
460
+ const token = (options.randomToken ?? (() => randomBytes(24).toString("base64url")))();
461
+ if (!TOKEN_SHAPE.test(token)) throw new Error("token must be 16-128 base64url characters");
462
+ const designRoot = options.designRoot;
463
+ const store = createDesignCommentStore(designRoot);
464
+ const handoffs = createDesignHandoffStore(designRoot);
465
+ // Deliveries of one design run one at a time (send-now and send-all), so a
466
+ // comment is never delivered twice: a send-all waits for an in-flight send-now.
467
+ const sending = new Map<string, Promise<unknown>>();
468
+
469
+ function exclusive<T>(slug: string, task: () => Promise<T>): Promise<T> {
470
+ const previous = sending.get(slug) ?? Promise.resolve();
471
+ const run = previous.then(task);
472
+ const settled = run.then(() => undefined, () => undefined);
473
+ sending.set(slug, settled);
474
+ void settled.then(() => { if (sending.get(slug) === settled) sending.delete(slug); });
475
+ return run;
476
+ }
477
+ const base = `/${token}/`;
478
+ const template = await readFile(join(ASSET_DIR, "index.html"), "utf8");
479
+
480
+ let port = 0;
481
+ let origin = "";
482
+ /** This server's own origins; a canvas may be opened through either Host name. */
483
+ let ownOrigins = new Set<string>();
484
+ let closing: Promise<void> | undefined;
485
+ let watcher: FSWatcher | undefined;
486
+ const clients = new Set<ServerResponse>();
487
+ const pending = new Map<string, NodeJS.Timeout>();
488
+ let heartbeat: NodeJS.Timeout | undefined;
489
+
490
+ const server = createServer({ maxHeaderSize: 16 * 1024, requireHostHeader: false }, (req, res) => {
491
+ handle(req, res).catch((error: unknown) => {
492
+ if (res.headersSent) return void res.destroy();
493
+ if (error instanceof HttpError) {
494
+ const body = error.body ?? (error.status === 422 ? { error: "invalid-manifest", reason: error.reason } : { error: error.reason });
495
+ return reply(res, error.status, body, error.status === 413 ? { Connection: "close" } : {});
496
+ }
497
+ reply(res, 500, { error: "internal" });
498
+ });
499
+ });
500
+ server.headersTimeout = 10_000;
501
+ server.requestTimeout = 30_000;
502
+ server.keepAliveTimeout = 5_000;
503
+
504
+ function reply(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
505
+ const payload = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body);
506
+ res.writeHead(status, {
507
+ ...BASE_HEADERS,
508
+ ...SAME_ORIGIN_ONLY,
509
+ "Content-Type": "application/json; charset=utf-8",
510
+ ...headers,
511
+ "Content-Length": String(Buffer.byteLength(payload)),
512
+ });
513
+ res.end(payload);
514
+ }
515
+
516
+ /** 200 for content the opaque-origin artboards load, so it carries no same-origin CORP. */
517
+ function raw(res: ServerResponse, body: string | Buffer, headers: Record<string, string>): void {
518
+ res.writeHead(200, { ...BASE_HEADERS, ...headers, "Content-Length": String(Buffer.byteLength(body)) });
519
+ res.end(body);
520
+ }
521
+
522
+ async function rootReal(): Promise<string | undefined> {
523
+ try {
524
+ return await realpath(designRoot);
525
+ } catch (error) {
526
+ if (isMissing(error)) return undefined;
527
+ throw error;
528
+ }
529
+ }
530
+
531
+ /** Real path of `<root>/<slug>/<segments>` when it stays inside that design, else undefined. */
532
+ async function resolveInside(slug: string, segments: string[]): Promise<string | undefined> {
533
+ const root = await rootReal();
534
+ if (!root || !SLUG.test(slug)) return undefined;
535
+ try {
536
+ const design = await realpath(join(root, slug));
537
+ if (!within(root, design) || design === root) return undefined;
538
+ const target = await realpath(join(design, ...segments));
539
+ return within(design, target) && target !== design ? target : undefined;
540
+ } catch (error) {
541
+ if (isMissing(error)) return undefined;
542
+ throw error;
543
+ }
544
+ }
545
+
546
+ const resolveSystem = async (): Promise<string | undefined> => resolveSystemBoard(designRoot);
547
+
548
+ async function readManifest(slug: string): Promise<DesignManifest> {
549
+ const path = await resolveInside(slug, ["design.json"]);
550
+ if (!path) throw new HttpError(404, "not-found");
551
+ const info = await stat(path);
552
+ if (!info.isFile()) throw new HttpError(404, "not-found");
553
+ if (info.size > LIMITS.manifestBytes) throw new HttpError(422, "design.json is too large");
554
+ return normalizeManifest(slug, await readFile(path, "utf8"));
555
+ }
556
+
557
+ function canvasPage(res: ServerResponse, slug: string | null): void {
558
+ const html = template
559
+ .replaceAll("{{BASE}}", escapeHtml(base))
560
+ .replace("{{CONFIG}}", () => scriptJson({ base, slug }));
561
+ reply(res, 200, html, { "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": CANVAS_CSP });
562
+ }
563
+
564
+ async function serveAsset(res: ServerResponse, name: string | null): Promise<void> {
565
+ if (!name || !Object.hasOwn(ASSETS, name)) throw new HttpError(404, "not-found");
566
+ raw(res, await readFile(join(ASSET_DIR, name)), { "Content-Type": ASSETS[name] });
567
+ }
568
+
569
+ /** Artboard responses are CSP-sandboxed so even a top-level visit gets an opaque origin. */
570
+ function artboardCsp(host: string): string {
571
+ const self = `'self' http://${host}`;
572
+ return [
573
+ "sandbox allow-scripts",
574
+ `default-src ${self} 'unsafe-inline' data: blob:`,
575
+ "connect-src 'none'",
576
+ "base-uri 'none'",
577
+ "form-action 'none'",
578
+ `frame-ancestors http://${HOST}:${port} http://localhost:${port}`,
579
+ ].join("; ");
580
+ }
581
+
582
+ async function serveFile(req: IncomingMessage, res: ServerResponse, slug: string, segments: string[]): Promise<void> {
583
+ const type = FILE_TYPES[extname(segments[segments.length - 1]).toLowerCase()];
584
+ if (!type) throw new HttpError(404, "not-found");
585
+ const target = await resolveInside(slug, segments);
586
+ if (!target || FILE_TYPES[extname(target).toLowerCase()] !== type) throw new HttpError(404, "not-found");
587
+ return serveResolved(req, res, target, type);
588
+ }
589
+
590
+ async function serveSystem(req: IncomingMessage, res: ServerResponse): Promise<void> {
591
+ const target = await resolveSystem();
592
+ if (!target || extname(target).toLowerCase() !== ".html") throw new HttpError(404, "not-found");
593
+ return serveResolved(req, res, target, FILE_TYPES[".html"]);
594
+ }
595
+
596
+ /** Serves a real path already contained by the caller, as an artboard resource. */
597
+ async function serveResolved(req: IncomingMessage, res: ServerResponse, target: string, type: string): Promise<void> {
598
+ // Read the already-resolved real path so a swapped link cannot redirect the read.
599
+ const handle = await open(target, "r").catch((error: unknown) => { if (isMissing(error)) throw new HttpError(404, "not-found"); throw error; });
600
+ let content: Buffer;
601
+ try {
602
+ const info = await handle.stat();
603
+ if (!info.isFile()) throw new HttpError(404, "not-found");
604
+ if (info.size > LIMITS.fileBytes) throw new HttpError(413, "file-too-large");
605
+ content = await handle.readFile();
606
+ } finally {
607
+ await handle.close();
608
+ }
609
+ const body = type.startsWith("text/html")
610
+ ? injectInspector(content.toString("utf8"), `<script src="${escapeHtml(base)}assets/inspector.js"></script>`)
611
+ : content;
612
+ raw(res, body, { "Content-Type": type, "Content-Security-Policy": artboardCsp(String(req.headers.host)) });
613
+ }
614
+
615
+ async function readJsonObject(req: IncomingMessage, allowEmpty = false, limit: number = LIMITS.bodyBytes): Promise<Record<string, unknown>> {
616
+ const raw = await readBody(req, limit);
617
+ if (allowEmpty && raw.trim() === "") return {};
618
+ let value: unknown;
619
+ try {
620
+ value = JSON.parse(raw);
621
+ } catch {
622
+ throw new HttpError(400, "invalid-json");
623
+ }
624
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new HttpError(400, "invalid-body");
625
+ return value as Record<string, unknown>;
626
+ }
627
+
628
+ /** The stored comment as a session comment of design `slug`, with its screenshot loaded when it has one. */
629
+ async function toDesignComment(slug: string, stored: StoredDesignComment): Promise<DesignComment> {
630
+ const comment: DesignComment = { id: stored.id, slug, file: stored.file, selector: stored.selector, excerpt: stored.excerpt, comment: stored.comment };
631
+ if (stored.screenshot) {
632
+ const image = await store.readScreenshot(slug, stored.id).catch(() => undefined);
633
+ const ext = extname(stored.screenshot).toLowerCase();
634
+ comment.screenshot = { path: `design/${slug}/${stored.screenshot}`, mimeType: image?.mimeType ?? FILE_TYPES[ext] };
635
+ if (image) comment.screenshot.data = image.data.toString("base64");
636
+ }
637
+ return comment;
638
+ }
639
+
640
+ /** The optional `screenshot: { mimeType, data: base64 }` of a comment body, decoded. */
641
+ function parseScreenshot(value: unknown): { mimeType: string; data: Buffer } | undefined {
642
+ if (value === undefined) return undefined;
643
+ const invalid = (reason: string) => new HttpError(400, "invalid-screenshot", { error: "invalid-screenshot", reason });
644
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid("screenshot must be an object with mimeType and data");
645
+ const { mimeType, data } = value as Record<string, unknown>;
646
+ if (typeof mimeType !== "string" || !Object.hasOwn(DESIGN_SCREENSHOT_TYPES, mimeType)) throw invalid("screenshot must be a PNG, JPEG, WebP or GIF image");
647
+ if (typeof data !== "string" || data.length === 0 || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) throw invalid("screenshot data must be base64");
648
+ const bytes = Buffer.from(data, "base64");
649
+ if (bytes.length > MAX_DESIGN_SCREENSHOT_BYTES) throw invalid(`screenshot must be at most ${MAX_DESIGN_SCREENSHOT_BYTES} bytes`);
650
+ return { mimeType, data: bytes };
651
+ }
652
+
653
+ async function receiveComment(req: IncomingMessage, res: ServerResponse): Promise<void> {
654
+ const body = await readJsonObject(req, false, LIMITS.commentBodyBytes);
655
+ // Only the screenshot gets the larger allowance; the rest of the body keeps the usual limit.
656
+ const shotChars = typeof (body.screenshot as { data?: unknown } | undefined)?.data === "string" ? ((body.screenshot as { data: string }).data.length) : 0;
657
+ if (Buffer.byteLength(JSON.stringify(body)) - shotChars > LIMITS.bodyBytes) throw new HttpError(413, "body-too-large");
658
+ const mode = body.mode ?? "send";
659
+ if (mode !== "send" && mode !== "collect") throw new HttpError(400, "invalid-mode");
660
+ if (typeof body.slug !== "string" || !SLUG.test(body.slug)) throw new HttpError(400, "invalid-slug");
661
+ const segments = relativeSegments(body.file);
662
+ if (!segments || extname(segments[segments.length - 1]).toLowerCase() !== ".html") throw new HttpError(400, "invalid-file");
663
+ if (typeof body.comment !== "string") throw new HttpError(400, "invalid-comment");
664
+ const comment = body.comment.replace(/\r\n?/g, "\n").trim();
665
+ if (comment.length < 1 || comment.length > LIMITS.comment) throw new HttpError(400, "invalid-comment");
666
+ if (typeof body.selector !== "string") throw new HttpError(400, "invalid-selector");
667
+ const selector = collapse(body.selector);
668
+ if (selector.length < 1 || selector.length > LIMITS.selector) throw new HttpError(400, "invalid-selector");
669
+ if (body.excerpt !== undefined && typeof body.excerpt !== "string") throw new HttpError(400, "invalid-excerpt");
670
+ const excerpt = truncateUtf16(collapse(typeof body.excerpt === "string" ? body.excerpt : ""), LIMITS.excerpt).trim();
671
+ const target = await resolveInside(body.slug, segments);
672
+ const info = target ? await stat(target).catch(() => undefined) : undefined;
673
+ if (!target || !info?.isFile() || extname(target).toLowerCase() !== ".html") throw new HttpError(400, "invalid-file");
674
+ const slug = body.slug;
675
+ const screenshot = parseScreenshot(body.screenshot);
676
+ const input = { file: segments.join("/"), selector, excerpt, comment, ...(screenshot ? { screenshot } : {}) };
677
+ const add = () => store.add(slug, input).catch((error: unknown) => {
678
+ if (screenshot && error instanceof DesignCommentsError && error.code === "invalid-input") throw new HttpError(400, "invalid-screenshot", { error: "invalid-screenshot", reason: error.message });
679
+ return storeFailure(error);
680
+ });
681
+ if (mode === "collect") {
682
+ const stored = await add();
683
+ return reply(res, 201, { ok: true, id: stored.id, sent: false });
684
+ }
685
+ // Save, deliver and mark sent under the design's delivery lock, so a send-all cannot see it unsent mid-flight.
686
+ return exclusive(slug, async () => {
687
+ const stored = await add();
688
+ const delivered = await toDesignComment(slug, stored);
689
+ try {
690
+ await options.onComment(delivered);
691
+ } catch {
692
+ throw new HttpError(500, "delivery-failed", { error: "delivery-failed", id: stored.id, saved: true, sent: false });
693
+ }
694
+ await store.markSent(slug, [stored.id]).catch(storeFailure);
695
+ reply(res, 202, { ok: true, id: stored.id, sent: true });
696
+ });
697
+ }
698
+
699
+ async function sendAll(req: IncomingMessage, res: ServerResponse, slug: string): Promise<void> {
700
+ await readJsonObject(req, true);
701
+ return exclusive(slug, async () => {
702
+ const unsent = (await store.listOpen(slug).catch(storeFailure)).filter((comment) => comment.sentAt === null);
703
+ if (unsent.length === 0) return reply(res, 200, { ok: true, sent: 0, ids: [] });
704
+ if (!options.onComments) throw new HttpError(501, "batch-delivery-unavailable");
705
+ const ids = unsent.map((comment) => comment.id);
706
+ const batch = await Promise.all(unsent.map((comment) => toDesignComment(slug, comment)));
707
+ try {
708
+ await options.onComments(batch);
709
+ } catch {
710
+ throw new HttpError(500, "delivery-failed", { error: "delivery-failed", ids, saved: true, sent: false });
711
+ }
712
+ await store.markSent(slug, ids).catch(storeFailure);
713
+ reply(res, 202, { ok: true, sent: ids.length, ids });
714
+ });
715
+ }
716
+
717
+ async function implement(req: IncomingMessage, res: ServerResponse, slug: string): Promise<void> {
718
+ const body = await readJsonObject(req);
719
+ if (!options.onImplement) throw new HttpError(501, "implement-unavailable");
720
+ const segments = relativeSegments(body.file);
721
+ if (!segments) throw new HttpError(400, "invalid-file");
722
+ const file = segments.join("/");
723
+ const manifest = await readManifest(slug);
724
+ if (!manifest.artboards.some((artboard) => artboard.file === file)) throw new HttpError(400, "invalid-file", { error: "invalid-file", reason: `${file} is not an artboard of design/${slug}` });
725
+ const onImplement = options.onImplement;
726
+ let deliveryFailed: HttpError | undefined;
727
+ // Recorded before delivery, so the agent can report progress on it at once (S15);
728
+ // a failed delivery takes the record back. The refusal reason reaches the canvas.
729
+ const { queued } = await sendDesignHandoff(handoffs, { slug, file, feature: manifest.feature }, async () => {
730
+ try {
731
+ return await onImplement({ slug, file });
732
+ } catch (error) {
733
+ const reason = error instanceof Error ? error.message : String(error);
734
+ deliveryFailed = new HttpError(500, "delivery-failed", { error: "delivery-failed", slug, file, reason });
735
+ throw deliveryFailed;
736
+ }
737
+ }).catch((error: unknown) => (deliveryFailed !== undefined && error === deliveryFailed ? Promise.reject(error) : handoffFailure(error)));
738
+ reply(res, 202, { ok: true, slug, file, queued });
739
+ }
740
+
741
+ async function serveScreenshot(res: ServerResponse, slug: string, id: string): Promise<void> {
742
+ const image = await store.readScreenshot(slug, id).catch(storeFailure);
743
+ if (!image) throw new HttpError(404, "not-found");
744
+ reply(res, 200, image.data, { "Content-Type": image.mimeType });
745
+ }
746
+
747
+ async function changeStatus(req: IncomingMessage, res: ServerResponse, slug: string, id: string): Promise<void> {
748
+ const body = await readJsonObject(req);
749
+ if (body.status !== "open" && body.status !== "resolved") throw new HttpError(400, "invalid-status");
750
+ const { updated } = await store.setStatus(slug, [id], body.status).catch(storeFailure);
751
+ if (updated.length === 0) throw new HttpError(404, "not-found");
752
+ reply(res, 200, { ok: true, comment: updated[0] });
753
+ }
754
+
755
+ function broadcast(chunk: string): void {
756
+ for (const client of clients) client.write(chunk);
757
+ }
758
+
759
+ function queueChange(filename: string): void {
760
+ if (closing) return;
761
+ const parts = filename.split(/[\\/]/);
762
+ if (parts.length === 1 && SYSTEM_FILES.has(parts[0])) return debounceChange(parts[0], null, parts[0]);
763
+ const [slug, ...rest] = parts;
764
+ if (!SLUG.test(slug) || rest.length === 0 || rest.some((part) => part === "" || part.startsWith("."))) return;
765
+ if (!FILE_TYPES[extname(rest[rest.length - 1]).toLowerCase()]) return;
766
+ const file = rest.join("/");
767
+ debounceChange(`${slug}/${file}`, slug, file);
768
+ }
769
+
770
+ function debounceChange(key: string, slug: string | null, file: string): void {
771
+ clearTimeout(pending.get(key));
772
+ pending.set(key, setTimeout(() => {
773
+ pending.delete(key);
774
+ broadcast(`event: change\ndata: ${JSON.stringify({ slug, file })}\n\n`);
775
+ }, DEBOUNCE_MS));
776
+ }
777
+
778
+ /** Recursive watch on the real design root; a failed or missing watch is retried by the next stream. */
779
+ async function ensureWatcher(): Promise<void> {
780
+ if (watcher || closing) return;
781
+ const root = await rootReal();
782
+ if (!root || watcher || closing) return;
783
+ try {
784
+ const next = watch(root, { recursive: true, persistent: false }, (_event, filename) => {
785
+ if (typeof filename === "string" && filename) queueChange(filename);
786
+ });
787
+ next.on("error", () => {
788
+ next.close();
789
+ if (watcher === next) watcher = undefined;
790
+ });
791
+ watcher = next;
792
+ } catch {
793
+ watcher = undefined;
794
+ }
795
+ }
796
+
797
+ async function openEvents(req: IncomingMessage, res: ServerResponse): Promise<void> {
798
+ if (clients.size >= LIMITS.sseClients) throw new HttpError(503, "too-many-streams");
799
+ await ensureWatcher();
800
+ res.writeHead(200, { ...BASE_HEADERS, "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
801
+ res.write("retry: 2000\n\n: connected\n\n");
802
+ clients.add(res);
803
+ req.socket.setTimeout(0);
804
+ const drop = () => clients.delete(res);
805
+ req.on("close", drop);
806
+ res.on("error", drop);
807
+ heartbeat ??= setInterval(() => broadcast(": ping\n\n"), HEARTBEAT_MS);
808
+ heartbeat.unref();
809
+ }
810
+
811
+ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
812
+ const host = req.headers.host;
813
+ if (host !== `${HOST}:${port}` && host !== `localhost:${port}`) return reply(res, 421, { error: "misdirected" });
814
+ const raw = String(req.url ?? "");
815
+ if (!raw.startsWith("/")) return reply(res, 404, { error: "not-found" });
816
+ const mark = raw.indexOf("?");
817
+ const parts = (mark === -1 ? raw : raw.slice(0, mark)).split("/");
818
+ // parts[0] is the empty string before the leading slash.
819
+ if (parts.length < 3 || !sameToken(token, parts[1])) return reply(res, 404, { error: "not-found" });
820
+ const rest = parts.slice(2);
821
+ const method = req.method ?? "GET";
822
+ const post = postRoute(rest);
823
+ const get = post ? undefined : route(rest);
824
+ // Unknown paths stay 404 whatever the method; only known routes answer 405.
825
+ if (!post && !get) return reply(res, 404, { error: "not-found" });
826
+ if (method !== (post ? "POST" : "GET")) return reply(res, 405, { error: "method-not-allowed" }, { Allow: post ? "POST" : "GET" });
827
+ if (post) {
828
+ // Every write needs this server's exact Origin and a JSON body, so no other page can forge one.
829
+ if (!ownOrigins.has(String(req.headers.origin))) return reply(res, 403, { error: "forbidden" });
830
+ if (mediaType(req) !== "application/json") return reply(res, 415, { error: "unsupported-media-type" });
831
+ return post(req, res);
832
+ }
833
+ // Browsers send Origin on cross-origin reads; only this server's pages may read it.
834
+ // Sandboxed artboards fetch their own subresources with `Origin: null`.
835
+ const requestOrigin = req.headers.origin;
836
+ const artboardSubresource = rest[0] === "files" || (rest.length === 1 && rest[0] === SYSTEM_FILE) || (rest.length === 2 && rest[0] === "assets" && rest[1] === "inspector.js");
837
+ const trusted = requestOrigin === undefined || ownOrigins.has(requestOrigin) || (requestOrigin === "null" && artboardSubresource);
838
+ if (!trusted) return reply(res, 403, { error: "forbidden" });
839
+ return get!(req, res);
840
+ }
841
+
842
+ type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
843
+
844
+ /** POST route table over raw segments after the token. */
845
+ function postRoute(rest: string[]): Handler | undefined {
846
+ if (rest.length === 2 && rest[0] === "api" && rest[1] === "comments") return receiveComment;
847
+ if (rest.length === 4 && rest[0] === "api" && rest[1] === "designs" && rest[3] === "implement") {
848
+ const slug = safeSegment(rest[2]);
849
+ return slug && SLUG.test(slug) ? (req, res) => implement(req, res, slug) : undefined;
850
+ }
851
+ if (rest.length < 5 || rest[0] !== "api" || rest[1] !== "designs" || rest[3] !== "comments") return undefined;
852
+ const slug = safeSegment(rest[2]);
853
+ if (!slug || !SLUG.test(slug)) return undefined;
854
+ if (rest.length === 5 && rest[4] === "send-all") return (req, res) => sendAll(req, res, slug);
855
+ if (rest.length === 6 && rest[5] === "status") {
856
+ const id = safeSegment(rest[4]);
857
+ return id && isDesignCommentId(id) ? (req, res) => changeStatus(req, res, slug, id) : undefined;
858
+ }
859
+ return undefined;
860
+ }
861
+
862
+ /** GET route table over raw (still encoded) segments after the token. */
863
+ function route(rest: string[]): Handler | undefined {
864
+ const [head, ...tail] = rest;
865
+ if (rest.length === 1 && head === "") return (_req, res) => canvasPage(res, null);
866
+ if (head === "d" && tail.length === 2 && tail[1] === "") {
867
+ const slug = safeSegment(tail[0]);
868
+ return slug && SLUG.test(slug) ? (_req, res) => canvasPage(res, slug) : undefined;
869
+ }
870
+ if (head === "assets" && tail.length === 1) return (_req, res) => serveAsset(res, safeSegment(tail[0]));
871
+ if (head === "events" && tail.length === 0) return openEvents;
872
+ if (head === SYSTEM_FILE && tail.length === 0) return serveSystem;
873
+ if (head === "api" && tail.length === 1 && tail[0] === "system") {
874
+ return async (_req, res) => reply(res, 200, { exists: (await resolveSystem()) !== undefined, file: SYSTEM_FILE, ...SYSTEM_BOARD });
875
+ }
876
+ if (head === "api" && tail[0] === "designs") {
877
+ if (tail.length === 1) return async (_req, res) => reply(res, 200, { designs: listDesignEntries(designRoot) });
878
+ if (tail.length === 2) {
879
+ const slug = safeSegment(tail[1]);
880
+ return slug && SLUG.test(slug) ? async (_req, res) => reply(res, 200, await readManifest(slug)) : undefined;
881
+ }
882
+ if (tail.length === 3 && tail[2] === "comments") {
883
+ const slug = safeSegment(tail[1]);
884
+ return slug && SLUG.test(slug) ? async (_req, res) => reply(res, 200, await store.read(slug).catch(storeFailure)) : undefined;
885
+ }
886
+ if (tail.length === 5 && tail[2] === "comments" && tail[4] === "screenshot") {
887
+ const slug = safeSegment(tail[1]);
888
+ const id = safeSegment(tail[3]);
889
+ return slug && SLUG.test(slug) && id && isDesignCommentId(id) ? (_req, res) => serveScreenshot(res, slug, id) : undefined;
890
+ }
891
+ if (tail.length === 3 && tail[2] === "handoffs") {
892
+ const slug = safeSegment(tail[1]);
893
+ return slug && SLUG.test(slug) ? async (_req, res) => reply(res, 200, await handoffs.read(slug).catch(handoffFailure)) : undefined;
894
+ }
895
+ }
896
+ if (head === "files" && tail.length >= 2) {
897
+ const slug = safeSegment(tail[0]);
898
+ if (!slug || !SLUG.test(slug)) return undefined;
899
+ const segments = tail.slice(1).map(safeSegment);
900
+ if (segments.some((segment) => segment === null)) return undefined;
901
+ return (req, res) => serveFile(req, res, slug, segments as string[]);
902
+ }
903
+ return undefined;
904
+ }
905
+
906
+ await new Promise<void>((resolve, reject) => {
907
+ server.once("error", reject);
908
+ server.listen(0, HOST, () => {
909
+ server.off("error", reject);
910
+ resolve();
911
+ });
912
+ });
913
+ const address = server.address();
914
+ if (!address || typeof address === "string" || address.address !== HOST) {
915
+ server.close();
916
+ throw new Error("design canvas server is not bound to 127.0.0.1");
917
+ }
918
+ port = address.port;
919
+ origin = `http://${HOST}:${port}`;
920
+ ownOrigins = new Set([origin, `http://localhost:${port}`]);
921
+ await ensureWatcher();
922
+
923
+ function close(): Promise<void> {
924
+ closing ??= new Promise<void>((resolve) => {
925
+ clearInterval(heartbeat);
926
+ for (const timer of pending.values()) clearTimeout(timer);
927
+ pending.clear();
928
+ watcher?.close();
929
+ watcher = undefined;
930
+ for (const client of clients) client.destroy();
931
+ clients.clear();
932
+ server.close(() => resolve());
933
+ server.closeAllConnections();
934
+ });
935
+ return closing;
936
+ }
937
+
938
+ function show(slug: string | null): number {
939
+ if (slug !== null && (typeof slug !== "string" || !SLUG.test(slug))) throw new Error("slug must be kebab-case or null");
940
+ if (closing) return 0;
941
+ broadcast(`event: show\ndata: ${JSON.stringify({ slug })}\n\n`);
942
+ return clients.size;
943
+ }
944
+
945
+ const url = `${origin}${base}${options.initialSlug ? `d/${options.initialSlug}/` : ""}`;
946
+ return { url, port, token, close, connectedClients: () => clients.size, show };
947
+ }