@documentation-robotics/cli 0.3.0 → 0.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 (182) hide show
  1. package/README.md +10 -14
  2. package/dist/analyzers/active-analyzer.js +62 -0
  3. package/dist/analyzers/active-analyzer.js.map +7 -0
  4. package/dist/analyzers/cards/build.js +21 -1
  5. package/dist/analyzers/cards/build.js.map +2 -2
  6. package/dist/analyzers/cards/extract.js +138 -0
  7. package/dist/analyzers/cards/extract.js.map +7 -0
  8. package/dist/analyzers/cards/routes.js +96 -0
  9. package/dist/analyzers/cards/routes.js.map +7 -0
  10. package/dist/analyzers/cbm-analyzer.js +76 -4
  11. package/dist/analyzers/cbm-analyzer.js.map +2 -2
  12. package/dist/analyzers/cbm-responses.js +12 -6
  13. package/dist/analyzers/cbm-responses.js.map +2 -2
  14. package/dist/analyzers/code-changes.js +159 -0
  15. package/dist/analyzers/code-changes.js.map +7 -0
  16. package/dist/analyzers/edge-relationships.js +6 -2
  17. package/dist/analyzers/edge-relationships.js.map +2 -2
  18. package/dist/analyzers/extraction-plan.js +541 -0
  19. package/dist/analyzers/extraction-plan.js.map +7 -0
  20. package/dist/analyzers/index-job.js +229 -0
  21. package/dist/analyzers/index-job.js.map +7 -0
  22. package/dist/cli.js +12 -10
  23. package/dist/cli.js.map +2 -2
  24. package/dist/coding-agents/drbot-mcp.js +120 -18
  25. package/dist/coding-agents/drbot-mcp.js.map +2 -2
  26. package/dist/commands/add.js +4 -2
  27. package/dist/commands/add.js.map +2 -2
  28. package/dist/commands/analyzer.js +35 -35
  29. package/dist/commands/analyzer.js.map +2 -2
  30. package/dist/commands/changeset-verify.js +16 -8
  31. package/dist/commands/changeset-verify.js.map +2 -2
  32. package/dist/commands/changeset.js +41 -11
  33. package/dist/commands/changeset.js.map +2 -2
  34. package/dist/commands/init.js +9 -26
  35. package/dist/commands/init.js.map +2 -2
  36. package/dist/commands/mcp.js +35 -31
  37. package/dist/commands/mcp.js.map +3 -3
  38. package/dist/commands/relationship.js +2 -12
  39. package/dist/commands/relationship.js.map +2 -2
  40. package/dist/commands/reports.js +3 -3
  41. package/dist/commands/reports.js.map +2 -2
  42. package/dist/commands/update.js +14 -13
  43. package/dist/commands/update.js.map +2 -2
  44. package/dist/commands/upgrade.js +28 -120
  45. package/dist/commands/upgrade.js.map +2 -2
  46. package/dist/commands/validate.js +19 -3
  47. package/dist/commands/validate.js.map +2 -2
  48. package/dist/commands/version.js +1 -1
  49. package/dist/commands/visualize.js +30 -12
  50. package/dist/commands/visualize.js.map +2 -2
  51. package/dist/core/annotation-store.js +15 -5
  52. package/dist/core/annotation-store.js.map +2 -2
  53. package/dist/core/catalog-migration.js +2 -1
  54. package/dist/core/catalog-migration.js.map +2 -2
  55. package/dist/core/changeset-base-index.js +7 -0
  56. package/dist/core/changeset-base-index.js.map +2 -2
  57. package/dist/core/changeset-brief-compact.js +256 -0
  58. package/dist/core/changeset-brief-compact.js.map +7 -0
  59. package/dist/core/changeset-verify.js +356 -26
  60. package/dist/core/changeset-verify.js.map +3 -3
  61. package/dist/core/extraction-observability.js +161 -0
  62. package/dist/core/extraction-observability.js.map +7 -0
  63. package/dist/core/migration-registry.js +13 -1
  64. package/dist/core/migration-registry.js.map +2 -2
  65. package/dist/core/project-init.js +53 -0
  66. package/dist/core/project-init.js.map +7 -0
  67. package/dist/core/relationship-staging.js +94 -1
  68. package/dist/core/relationship-staging.js.map +2 -2
  69. package/dist/core/staged-changeset-storage.js +83 -3
  70. package/dist/core/staged-changeset-storage.js.map +2 -2
  71. package/dist/core/staging-area.js +85 -8
  72. package/dist/core/staging-area.js.map +2 -2
  73. package/dist/core/upgrade-plan.js +117 -0
  74. package/dist/core/upgrade-plan.js.map +7 -0
  75. package/dist/generated/relationship-index.js +124 -15
  76. package/dist/generated/relationship-index.js.map +2 -2
  77. package/dist/integrations/claude_code/.claude-plugin/plugin.json +24 -0
  78. package/dist/integrations/claude_code/agents/dr-architect.md +19 -5
  79. package/dist/integrations/claude_code/agents/dr-extractor.md +7 -1
  80. package/dist/integrations/claude_code/commands/dr-changeset.md +2 -1
  81. package/dist/integrations/claude_code/commands/dr-map.md +7 -1
  82. package/dist/integrations/claude_code/commands/dr-plan.md +42 -16
  83. package/dist/integrations/claude_code/docs/MCP_SERVER.md +28 -31
  84. package/dist/integrations/claude_code/docs/README.md +20 -1
  85. package/dist/integrations/claude_code/plugin-agents/dr-advisor.md +255 -0
  86. package/dist/integrations/claude_code/plugin-agents/dr-architect.md +306 -0
  87. package/dist/integrations/claude_code/skills/dr_13_testing_layer/SKILL.md +3 -0
  88. package/dist/integrations/claude_code/skills/dr_change_planning/SKILL.md +90 -31
  89. package/dist/integrations/claude_code/skills/dr_changeset_reviewer/SKILL.md +5 -0
  90. package/dist/integrations/claude_code/skills/dr_engineering_guide/SKILL.md +5 -0
  91. package/dist/integrations/github_copilot/agents/dr-architect.md +2 -0
  92. package/dist/integrations/github_copilot/skills/dr_13_testing_layer/SKILL.md +3 -0
  93. package/dist/integrations/github_copilot/skills/dr_change_planning/SKILL.md +88 -30
  94. package/dist/mcp/changeset-only.js +14 -3
  95. package/dist/mcp/changeset-only.js.map +2 -2
  96. package/dist/mcp/project-root.js +85 -0
  97. package/dist/mcp/project-root.js.map +7 -0
  98. package/dist/mcp/server-instructions.js +27 -0
  99. package/dist/mcp/server-instructions.js.map +7 -0
  100. package/dist/mcp/strict-schema.js +52 -0
  101. package/dist/mcp/strict-schema.js.map +7 -0
  102. package/dist/mcp/tool-registry.js +39 -5
  103. package/dist/mcp/tool-registry.js.map +2 -2
  104. package/dist/mcp/tools/changeset-analysis-tools.js +25 -5
  105. package/dist/mcp/tools/changeset-analysis-tools.js.map +3 -3
  106. package/dist/mcp/tools/changeset-commit.js +224 -0
  107. package/dist/mcp/tools/changeset-commit.js.map +7 -0
  108. package/dist/mcp/tools/changeset-editing.js +133 -57
  109. package/dist/mcp/tools/changeset-editing.js.map +3 -3
  110. package/dist/mcp/tools/changeset-lifecycle.js +1 -1
  111. package/dist/mcp/tools/changeset-lifecycle.js.map +2 -2
  112. package/dist/mcp/tools/changeset-verify.js +12 -6
  113. package/dist/mcp/tools/changeset-verify.js.map +2 -2
  114. package/dist/mcp/tools/codebase-extract.js +836 -0
  115. package/dist/mcp/tools/codebase-extract.js.map +7 -0
  116. package/dist/mcp/tools/codebase-index.js +125 -0
  117. package/dist/mcp/tools/codebase-index.js.map +7 -0
  118. package/dist/mcp/tools/codebase-shared.js +265 -0
  119. package/dist/mcp/tools/codebase-shared.js.map +7 -0
  120. package/dist/mcp/tools/codebase-verify.js +48 -0
  121. package/dist/mcp/tools/codebase-verify.js.map +7 -0
  122. package/dist/mcp/tools/index.js +15 -0
  123. package/dist/mcp/tools/index.js.map +2 -2
  124. package/dist/mcp/tools/model-add.js +189 -73
  125. package/dist/mcp/tools/model-add.js.map +2 -2
  126. package/dist/mcp/tools/model-update.js +16 -12
  127. package/dist/mcp/tools/model-update.js.map +2 -2
  128. package/dist/mcp/tools/model-validate.js +46 -4
  129. package/dist/mcp/tools/model-validate.js.map +2 -2
  130. package/dist/mcp/tools/project-status-collectors.js +381 -0
  131. package/dist/mcp/tools/project-status-collectors.js.map +7 -0
  132. package/dist/mcp/tools/project-status.js +237 -0
  133. package/dist/mcp/tools/project-status.js.map +7 -0
  134. package/dist/mcp/tools/project-tools.js +505 -0
  135. package/dist/mcp/tools/project-tools.js.map +7 -0
  136. package/dist/mcp/tools/relationship-tools.js +89 -20
  137. package/dist/mcp/tools/relationship-tools.js.map +2 -2
  138. package/dist/mcp/tools/review-open.js +210 -0
  139. package/dist/mcp/tools/review-open.js.map +7 -0
  140. package/dist/mcp/tools/shared.js +10 -2
  141. package/dist/mcp/tools/shared.js.map +2 -2
  142. package/dist/reports/model-report-orchestrator.js +31 -2
  143. package/dist/reports/model-report-orchestrator.js.map +2 -2
  144. package/dist/schemas/bundled/analyzers/cbm.json +22 -10
  145. package/dist/schemas/bundled/analyzers/manifest.json +2 -2
  146. package/dist/schemas/bundled/api.json +1 -1
  147. package/dist/schemas/bundled/apm.json +1 -1
  148. package/dist/schemas/bundled/application.json +1 -1
  149. package/dist/schemas/bundled/base.json +1 -1
  150. package/dist/schemas/bundled/business.json +1 -1
  151. package/dist/schemas/bundled/data-model.json +1 -1
  152. package/dist/schemas/bundled/data-store.json +1 -1
  153. package/dist/schemas/bundled/manifest.json +3 -3
  154. package/dist/schemas/bundled/motivation.json +1 -1
  155. package/dist/schemas/bundled/navigation.json +1 -1
  156. package/dist/schemas/bundled/product.json +1 -1
  157. package/dist/schemas/bundled/security.json +1 -1
  158. package/dist/schemas/bundled/technology.json +1 -1
  159. package/dist/schemas/bundled/testing.json +145 -1
  160. package/dist/schemas/bundled/ux.json +1 -1
  161. package/dist/server/server.js +508 -237
  162. package/dist/server/server.js.map +3 -3
  163. package/dist/server/viewer-registry.js +264 -0
  164. package/dist/server/viewer-registry.js.map +7 -0
  165. package/dist/telemetry/index.js +1 -1
  166. package/dist/utils/id-generator.js +5 -2
  167. package/dist/utils/id-generator.js.map +2 -2
  168. package/dist/utils/source-reference.js +58 -27
  169. package/dist/utils/source-reference.js.map +2 -2
  170. package/dist/utils/spec-version.js +2 -2
  171. package/dist/utils/spec-version.js.map +1 -1
  172. package/dist/validators/model-warnings.js +28 -0
  173. package/dist/validators/model-warnings.js.map +7 -0
  174. package/dist/viewer/README.md +6 -6
  175. package/dist/viewer/assets/main-CUOQGGRA.css +1 -0
  176. package/dist/viewer/assets/main-DvH3gYrI.js +84 -0
  177. package/dist/viewer/index.html +2 -2
  178. package/dist/viewer/manifest.json +10 -10
  179. package/dist/viewer/version.json +1 -1
  180. package/package.json +1 -1
  181. package/dist/viewer/assets/main-CxzcuU_o.js +0 -84
  182. package/dist/viewer/assets/main-j-7ULYpm.css +0 -1
@@ -1,11 +1,13 @@
1
1
  import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
2
2
  import { cors } from "hono/cors";
3
3
  import { swaggerUI } from "@hono/swagger-ui";
4
- import { serve } from "@hono/node-server";
4
+ import { createAdaptorServer } from "@hono/node-server";
5
5
  import { createNodeWebSocket } from "@hono/node-ws";
6
6
  import { spawn, spawnSync } from "child_process";
7
7
  import chokidar from "chokidar";
8
- import { randomBytes } from "crypto";
8
+ import { randomBytes, randomUUID } from "crypto";
9
+ import { existsSync, realpathSync } from "fs";
10
+ import { homedir } from "os";
9
11
  import { createRequire } from "module";
10
12
  import { fileURLToPath } from "url";
11
13
  import path from "path";
@@ -14,13 +16,13 @@ import { Model } from "../core/model.js";
14
16
  import { StagedChangesetStorage } from "../core/staged-changeset-storage.js";
15
17
  import { StagingAreaManager } from "../core/staging-area.js";
16
18
  import { CANONICAL_LAYER_NAMES } from "../core/layers.js";
19
+ import { AnnotationStore } from "../core/annotation-store.js";
17
20
  import { telemetryMiddleware } from "./telemetry-middleware.js";
18
21
  import { ClaudeCodeClient } from "../coding-agents/claude-code-client.js";
19
22
  import { CopilotClient } from "../coding-agents/copilot-client.js";
20
23
  import { detectAvailableClients, selectChatClient } from "../coding-agents/chat-utils.js";
21
24
  import { formatForClaudeCode, formatForClaudeCodeWithChangesets, applyCopilotPermissions, CHANGESET_ONLY_ENV } from "../coding-agents/default-permissions.js";
22
- import { DR_ARCHITECT_AGENT, checkInstalledAgentMcpTools, drBotMcpPrompt, drMcpAllowedTools, prepareDrMcpLaunch } from "../coding-agents/drbot-mcp.js";
23
- import { ApiKeyManager } from "../mcp/api-key-manager.js";
25
+ import { DR_ARCHITECT_AGENT, drBotMcpPrompt, drMcpAllowedTools, prepareDrMcpLaunch, prepareDrBotAgent } from "../coding-agents/drbot-mcp.js";
24
26
  import { getErrorMessage } from "../utils/errors.js";
25
27
  import { AnnotationCreateSchema, AnnotationUpdateSchema, AnnotationReplyCreateSchema, LayerNameSchema, IdSchema, AnnotationFilterSchema, ErrorResponseSchema, HealthResponseSchema, AnnotationSchema, AnnotationReplySchema, AnnotationsListSchema, ChangesetsListSchema, ChangesetDetailSchema, ChangesetActionResultSchema, ModelResponseSchema, LayerResponseSchema, SpecResponseSchema, AnnotationRepliesSchema, WSMessageSchema } from "./schemas.js";
26
28
  const JSONRPC_ERRORS = {
@@ -35,21 +37,37 @@ const JSONRPC_ERRORS = {
35
37
  NO_CLIENT_AVAILABLE: -32001
36
38
  // No chat client available
37
39
  };
40
+ function annotationFingerprint(annotation) {
41
+ return {
42
+ elementId: annotation.elementId,
43
+ state: JSON.stringify([annotation.content, annotation.tags, annotation.resolved, annotation.updatedAt ?? null]),
44
+ replyIds: annotation.replies.map((reply) => reply.id)
45
+ };
46
+ }
47
+ function toolResultText(content) {
48
+ return typeof content == "string" ? content : Array.isArray(content) ? content.map((part) => part && typeof part == "object" && typeof part.text == "string" ? part.text : "").filter(Boolean).join(`
49
+ `) : "";
50
+ }
38
51
  class VisualizationServer {
39
52
  app;
40
53
  model;
41
- _server;
42
- // Node HTTP server returned by @hono/node-server
54
+ _servers = [];
55
+ // One per loopback address the viewer listens on
43
56
  upgradeWebSocket;
44
57
  injectWebSocket;
45
58
  clients = /* @__PURE__ */ new Set();
46
59
  watcher;
47
- annotations = /* @__PURE__ */ new Map();
48
- // annotationId -> annotation
49
- annotationsByElement = /* @__PURE__ */ new Map();
50
- // elementId -> Set<annotationId>
51
- replies = /* @__PURE__ */ new Map();
52
- // annotationId -> replies[]
60
+ /**
61
+ * Annotations live on disk (documentation-robotics/annotations/), shared with the MCP
62
+ * annotation_* tools, which run in other processes.
63
+ */
64
+ annotationStore;
65
+ /** What this server last saw of each annotation, to tell which changes to broadcast. */
66
+ annotationSnapshot = /* @__PURE__ */ new Map();
67
+ /** Serializes annotation writes and snapshot syncs, so a sync never sees half of a write. */
68
+ annotationQueue = Promise.resolve();
69
+ /** Pending debounced annotation sync from the file watcher. */
70
+ annotationsBroadcastTimer;
53
71
  changesets = /* @__PURE__ */ new Map();
54
72
  // changesetId -> changeset
55
73
  changesetStorage;
@@ -64,17 +82,35 @@ class VisualizationServer {
64
82
  activeChatProcesses = /* @__PURE__ */ new Map();
65
83
  // conversationId -> spawned chat process
66
84
  chatConversationCounter = 0;
85
+ /**
86
+ * Viewer conversation id -> Claude Code session id. The first send in a conversation starts the
87
+ * session with --session-id; `started` is set once Claude Code has created it, and later sends
88
+ * pass --resume. A new conversation id starts a new session.
89
+ */
90
+ chatSessions = /* @__PURE__ */ new Map();
91
+ /**
92
+ * Conversation id -> the running turn's token. A conversation runs one turn at a time: it is
93
+ * reserved when chat.send arrives and released only after that turn's process has exited.
94
+ */
95
+ busyChatConversations = /* @__PURE__ */ new Map();
96
+ /** Chat processes stopped by chat.cancel: nothing more from them reaches the client. */
97
+ cancelledChatProcesses = /* @__PURE__ */ new WeakSet();
98
+ /** Test hook: Claude Code's config directory (default ~/.claude or CLAUDE_CONFIG_DIR). */
99
+ _testClaudeConfigDir;
100
+ /** Pending debounced `changesets.updated` broadcast from the file watcher. */
101
+ changesetsBroadcastTimer;
102
+ /** Debounce for changeset-directory events: one write is several file events. */
103
+ changesetsDebounceMs = 150;
67
104
  selectedChatClient;
68
105
  // Selected chat client for server
69
106
  chatInitializationError;
70
107
  // Store initialization error for status endpoint
71
108
  _testClaudeCmdOverride;
72
109
  _testCopilotCmdOverride;
73
- /** Test hook: prepare DrBot's MCP launch with these (fake key, scratch dirs) even under _testClaudeCmdOverride. */
110
+ /** Test hook: prepare DrBot's MCP launch with these (scratch dirs) even under _testClaudeCmdOverride. */
74
111
  _testDrMcpOptions;
75
112
  /** Test hook: the claude arguments the last chat launch built, before any _testClaudeCmdOverride. */
76
113
  _testLastClaudeChatArgs;
77
- drMcpKey;
78
114
  drBotWarnings = /* @__PURE__ */ new Set();
79
115
  /**
80
116
  * Valid spec node IDs per layer
@@ -127,7 +163,7 @@ class VisualizationServer {
127
163
  }), ids;
128
164
  })();
129
165
  constructor(model, options) {
130
- this.app = new OpenAPIHono(), this.model = model, this.loadWebSocketAdapter(), this.authEnabled = options?.authEnabled ?? process.env.DR_AUTH_ENABLED !== "false", this.authToken = options?.authToken || process.env.DR_AUTH_TOKEN || this.generateAuthToken(), this.withDanger = options?.withDanger || !1, this.withChangesets = options?.withChangesets || !1, this.viewerPath = options?.viewerPath || new URL("../viewer", import.meta.url).pathname, !this.authEnabled && options?.authToken && console.warn("[VisualizationServer] Warning: authToken provided but authEnabled is false. The token will be generated but not used for authentication. Set authEnabled: true to enable authentication."), this.app.use("/*", cors()), this.app.use("/*", telemetryMiddleware), this.app.use("/api/*", async (c, next) => {
166
+ this.app = new OpenAPIHono(), this.model = model, this.annotationStore = new AnnotationStore(model.rootPath), this.loadWebSocketAdapter(), this.authEnabled = options?.authEnabled ?? process.env.DR_AUTH_ENABLED !== "false", this.authToken = options?.authToken || process.env.DR_AUTH_TOKEN || this.generateAuthToken(), this.withDanger = options?.withDanger || !1, this.withChangesets = options?.withChangesets || !1, this.viewerPath = options?.viewerPath || new URL("../viewer", import.meta.url).pathname, !this.authEnabled && options?.authToken && console.warn("[VisualizationServer] Warning: authToken provided but authEnabled is false. The token will be generated but not used for authentication. Set authEnabled: true to enable authentication."), this.app.use("/*", cors()), this.app.use("/*", telemetryMiddleware), this.app.use("/api/*", async (c, next) => {
131
167
  if (!this.authEnabled)
132
168
  return next();
133
169
  const authHeader = c.req.header("Authorization"), queryToken = c.req.query("token");
@@ -172,21 +208,79 @@ class VisualizationServer {
172
208
  * Performs partial updates with atomic operations
173
209
  */
174
210
  async updateAnnotation(annotationId, body) {
175
- const annotation = this.annotations.get(annotationId);
176
- if (!annotation)
177
- return null;
178
- const updatedAnnotation = {
179
- ...annotation,
180
- content: body.content ?? annotation.content,
181
- tags: body.tags ?? annotation.tags,
182
- resolved: body.resolved ?? annotation.resolved,
183
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
184
- };
185
- return this.annotations.set(annotationId, updatedAnnotation), await this.broadcastMessage({
211
+ const { updated, messages } = await this.withAnnotations(async () => {
212
+ const updated2 = await this.annotationStore.update(annotationId, body);
213
+ return { updated: updated2, messages: updated2 ? this.diffAnnotation(updated2) : [] };
214
+ });
215
+ return updated ? (await this.broadcastAll(messages), updated) : null;
216
+ }
217
+ /** Broadcasts each message, in order. */
218
+ async broadcastAll(messages) {
219
+ for (const message of messages)
220
+ await this.broadcastMessage(message);
221
+ }
222
+ /** Runs an annotation write or sync after the ones queued before it. */
223
+ withAnnotations(fn) {
224
+ const run = this.annotationQueue.then(fn, fn);
225
+ return this.annotationQueue = run.catch(() => {
226
+ }), run;
227
+ }
228
+ /**
229
+ * Records an annotation as seen and returns a broadcast for each difference from what this
230
+ * server last saw of it: the change a route just made, and any change another process made
231
+ * that the watcher has not synced yet (so recording it as seen doesn't swallow that change).
232
+ * Call it inside withAnnotations.
233
+ */
234
+ diffAnnotation(annotation) {
235
+ const before = this.annotationSnapshot.get(annotation.id), after = annotationFingerprint(annotation);
236
+ if (this.annotationSnapshot.set(annotation.id, after), !before)
237
+ return [
238
+ {
239
+ type: "annotation.added",
240
+ annotationId: annotation.id,
241
+ elementId: annotation.elementId,
242
+ timestamp: annotation.createdAt
243
+ }
244
+ ];
245
+ const out = [];
246
+ before.state !== after.state && out.push({
186
247
  type: "annotation.updated",
187
- annotationId: updatedAnnotation.id,
188
- timestamp: updatedAnnotation.updatedAt
189
- }), updatedAnnotation;
248
+ annotationId: annotation.id,
249
+ timestamp: annotation.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
250
+ });
251
+ for (const reply of annotation.replies)
252
+ before.replyIds.includes(reply.id) || out.push({
253
+ type: "annotation.reply.added",
254
+ annotationId: annotation.id,
255
+ replyId: reply.id,
256
+ timestamp: reply.createdAt
257
+ });
258
+ return out;
259
+ }
260
+ /**
261
+ * Compares the annotation files with what this server last saw, and broadcasts each change
262
+ * another process made (the MCP annotation tools, a hand edit) with the same messages the
263
+ * REST routes send: annotation.added, annotation.updated, annotation.reply.added and
264
+ * annotation.deleted.
265
+ */
266
+ async syncAnnotations(options) {
267
+ const messages = await this.withAnnotations(async () => {
268
+ const current = await this.annotationStore.list(), now = (/* @__PURE__ */ new Date()).toISOString(), out = [], seen = /* @__PURE__ */ new Set();
269
+ for (const annotation of current)
270
+ seen.add(annotation.id), out.push(...this.diffAnnotation(annotation));
271
+ for (const id of [...this.annotationSnapshot.keys()])
272
+ seen.has(id) || (this.annotationSnapshot.delete(id), out.push({ type: "annotation.deleted", annotationId: id, timestamp: now }));
273
+ return out;
274
+ });
275
+ options.broadcast && await this.broadcastAll(messages);
276
+ }
277
+ /** Syncs the annotations once their files have been quiet for a moment. */
278
+ scheduleAnnotationsSync() {
279
+ this.annotationsBroadcastTimer && clearTimeout(this.annotationsBroadcastTimer), this.annotationsBroadcastTimer = setTimeout(() => {
280
+ this.annotationsBroadcastTimer = void 0, this.syncAnnotations({ broadcast: !0 }).catch((error) => {
281
+ console.error(`[Watcher] Failed to broadcast annotations: ${getErrorMessage(error)}`);
282
+ });
283
+ }, this.changesetsDebounceMs);
190
284
  }
191
285
  /**
192
286
  * Setup HTTP routes and WebSocket handler
@@ -379,14 +473,12 @@ class VisualizationServer {
379
473
  }
380
474
  }
381
475
  });
382
- this.app.openapi(getAnnotationsRoute, (c) => {
383
- const elementId = c.req.valid("query").elementId;
384
- if (elementId) {
385
- const annotationIds = this.annotationsByElement.get(elementId) || /* @__PURE__ */ new Set(), annotations = Array.from(annotationIds).map((id) => this.annotations.get(id)).filter((a) => a !== void 0);
386
- return c.json({ annotations });
387
- } else {
388
- const annotations = Array.from(this.annotations.values());
476
+ this.app.openapi(getAnnotationsRoute, async (c) => {
477
+ try {
478
+ const query = c.req.valid("query"), annotations = await this.annotationStore.list(query.elementId);
389
479
  return c.json({ annotations });
480
+ } catch (error) {
481
+ return c.json({ error: getErrorMessage(error) }, 500);
390
482
  }
391
483
  });
392
484
  const createAnnotationRoute = createRoute({
@@ -443,24 +535,18 @@ class VisualizationServer {
443
535
  this.app.openapi(createAnnotationRoute, async (c) => {
444
536
  try {
445
537
  const body = c.req.valid("json");
446
- if (!await this.findElement(body.elementId))
538
+ if (!await this.elementExists(body.elementId))
447
539
  return c.json({ error: "Element not found" }, 404);
448
- const annotation = {
449
- id: this.generateAnnotationId(),
450
- elementId: body.elementId,
451
- author: body.author,
452
- content: body.content,
453
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
454
- tags: body.tags,
455
- resolved: !1
456
- // Default to unresolved
457
- };
458
- return this.annotations.set(annotation.id, annotation), this.annotationsByElement.has(annotation.elementId) || this.annotationsByElement.set(annotation.elementId, /* @__PURE__ */ new Set()), this.annotationsByElement.get(annotation.elementId).add(annotation.id), await this.broadcastMessage({
459
- type: "annotation.added",
460
- annotationId: annotation.id,
461
- elementId: annotation.elementId,
462
- timestamp: annotation.createdAt
463
- }), c.json(annotation, 201);
540
+ const { annotation, messages } = await this.withAnnotations(async () => {
541
+ const created = await this.annotationStore.create({
542
+ elementId: body.elementId,
543
+ author: body.author,
544
+ content: body.content,
545
+ tags: body.tags
546
+ });
547
+ return { annotation: created, messages: this.diffAnnotation(created) };
548
+ });
549
+ return await this.broadcastAll(messages), c.json(annotation, 201);
464
550
  } catch (error) {
465
551
  const message = getErrorMessage(error);
466
552
  return c.json({ error: message }, 500);
@@ -505,7 +591,7 @@ class VisualizationServer {
505
591
  });
506
592
  this.app.openapi(getAnnotationRoute, async (c) => {
507
593
  try {
508
- const { annotationId } = c.req.valid("param"), annotation = this.annotations.get(annotationId);
594
+ const { annotationId } = c.req.valid("param"), annotation = await this.annotationStore.get(annotationId);
509
595
  return annotation ? c.json(annotation) : c.json({ error: "Annotation not found" }, 404);
510
596
  } catch (error) {
511
597
  const message = getErrorMessage(error);
@@ -652,16 +738,15 @@ class VisualizationServer {
652
738
  });
653
739
  this.app.openapi(deleteAnnotationRoute, async (c) => {
654
740
  try {
655
- const { annotationId } = c.req.valid("param"), annotation = this.annotations.get(annotationId);
656
- if (!annotation)
657
- return c.json({ error: "Annotation not found" }, 404);
658
- this.annotations.delete(annotationId), this.replies.delete(annotationId);
659
- const elementAnnotations = this.annotationsByElement.get(annotation.elementId);
660
- return elementAnnotations && elementAnnotations.delete(annotationId), await this.broadcastMessage({
741
+ const { annotationId } = c.req.valid("param");
742
+ return await this.withAnnotations(async () => {
743
+ const existed = await this.annotationStore.delete(annotationId);
744
+ return this.annotationSnapshot.delete(annotationId), existed;
745
+ }) ? (await this.broadcastMessage({
661
746
  type: "annotation.deleted",
662
747
  annotationId,
663
748
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
664
- }), c.body(null, 204);
749
+ }), c.body(null, 204)) : c.json({ error: "Annotation not found" }, 404);
665
750
  } catch (error) {
666
751
  const message = getErrorMessage(error);
667
752
  return c.json({ error: message }, 500);
@@ -696,12 +781,13 @@ class VisualizationServer {
696
781
  }
697
782
  }
698
783
  });
699
- this.app.openapi(getAnnotationRepliesRoute, (c) => {
700
- const { annotationId } = c.req.valid("param");
701
- if (!this.annotations.get(annotationId))
702
- return c.json({ error: "Annotation not found" }, 404);
703
- const replies = this.replies.get(annotationId) || [];
704
- return c.json({ replies });
784
+ this.app.openapi(getAnnotationRepliesRoute, async (c) => {
785
+ try {
786
+ const { annotationId } = c.req.valid("param"), annotation = await this.annotationStore.get(annotationId);
787
+ return annotation ? c.json({ replies: annotation.replies }) : c.json({ error: "Annotation not found" }, 404);
788
+ } catch (error) {
789
+ return c.json({ error: getErrorMessage(error) }, 500);
790
+ }
705
791
  });
706
792
  const createAnnotationReplyRoute = createRoute({
707
793
  method: "post",
@@ -749,21 +835,22 @@ class VisualizationServer {
749
835
  });
750
836
  this.app.openapi(createAnnotationReplyRoute, async (c) => {
751
837
  try {
752
- const { annotationId } = c.req.valid("param");
753
- if (!this.annotations.get(annotationId))
754
- return c.json({ error: "Annotation not found" }, 404);
755
- const body = c.req.valid("json"), reply = {
756
- id: this.generateReplyId(),
757
- author: body.author,
758
- content: body.content,
759
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
760
- };
761
- return this.replies.has(annotationId) || this.replies.set(annotationId, []), this.replies.get(annotationId).push(reply), await this.broadcastMessage({
762
- type: "annotation.reply.added",
763
- annotationId,
764
- replyId: reply.id,
765
- timestamp: reply.createdAt
766
- }), c.json(reply, 201);
838
+ const { annotationId } = c.req.valid("param"), body = c.req.valid("json"), { reply, messages } = await this.withAnnotations(async () => {
839
+ const added = await this.annotationStore.addReply(annotationId, {
840
+ author: body.author,
841
+ content: body.content
842
+ });
843
+ if (!added)
844
+ return { reply: null, messages: [] };
845
+ const annotation = await this.annotationStore.get(annotationId), own = {
846
+ type: "annotation.reply.added",
847
+ annotationId,
848
+ replyId: added.id,
849
+ timestamp: added.createdAt
850
+ };
851
+ return { reply: added, messages: annotation ? this.diffAnnotation(annotation) : [own] };
852
+ });
853
+ return reply ? (await this.broadcastAll(messages), c.json(reply, 201)) : c.json({ error: "Annotation not found" }, 404);
767
854
  } catch (error) {
768
855
  const message = getErrorMessage(error);
769
856
  return c.json({ error: message }, 500);
@@ -1126,20 +1213,6 @@ class VisualizationServer {
1126
1213
  }
1127
1214
  return layer in VisualizationServer.LAYER_FALLBACK ? VisualizationServer.LAYER_FALLBACK[layer] : "data-store.database";
1128
1215
  }
1129
- /**
1130
- * Generate unique annotation ID
1131
- * Uses cryptographically secure random bytes for collision-resistant IDs
1132
- */
1133
- generateAnnotationId() {
1134
- return `ann-${Date.now()}-${randomBytes(4).toString("hex")}`;
1135
- }
1136
- /**
1137
- * Generate unique reply ID
1138
- * Uses cryptographically secure random bytes for collision-resistant IDs
1139
- */
1140
- generateReplyId() {
1141
- return `reply-${Date.now()}-${randomBytes(4).toString("hex")}`;
1142
- }
1143
1216
  /**
1144
1217
  * Broadcast message to all connected WebSocket clients
1145
1218
  */
@@ -1195,6 +1268,20 @@ class VisualizationServer {
1195
1268
  }
1196
1269
  return null;
1197
1270
  }
1271
+ /**
1272
+ * Whether an element can be annotated: it is in the model, or a changeset that is still open
1273
+ * (staged, not committed or discarded) adds it. A reviewer comments mostly on what the plan
1274
+ * adds, so those count too.
1275
+ */
1276
+ async elementExists(id) {
1277
+ if (await this.findElement(id))
1278
+ return !0;
1279
+ await this.loadChangesetsFromDisk();
1280
+ for (const changeset of this.changesets.values())
1281
+ if (changeset.status === "staged" && changeset.changes.some((change) => change.type === "add" && change.elementId === id))
1282
+ return !0;
1283
+ return !1;
1284
+ }
1198
1285
  /**
1199
1286
  * Handle WebSocket messages
1200
1287
  */
@@ -1276,14 +1363,32 @@ class VisualizationServer {
1276
1363
  sendError(JSONRPC_ERRORS.NO_CLIENT_AVAILABLE, "No chat client available. Install Claude Code or GitHub Copilot to enable chat.");
1277
1364
  return;
1278
1365
  }
1279
- const conversationId = `conv-${++this.chatConversationCounter}-${Date.now()}`;
1280
- await this.launchChat(ws, conversationId, params.message, id);
1366
+ const clientConversationId = params.conversation_id;
1367
+ if (clientConversationId !== void 0 && (typeof clientConversationId != "string" || clientConversationId.trim() === "" || clientConversationId.length > 200)) {
1368
+ sendError(JSONRPC_ERRORS.INVALID_PARAMS, "conversation_id must be a non-empty string of at most 200 characters");
1369
+ return;
1370
+ }
1371
+ const conversationId = clientConversationId ?? `conv-${++this.chatConversationCounter}-${Date.now()}`;
1372
+ if (this.busyChatConversations.has(conversationId)) {
1373
+ sendError(JSONRPC_ERRORS.INVALID_PARAMS, "A reply is still running in this conversation; wait for it or cancel it first");
1374
+ return;
1375
+ }
1376
+ const turn = this.reserveChatConversation(conversationId);
1377
+ try {
1378
+ await this.launchChat(ws, conversationId, params.message, id, {
1379
+ resumable: clientConversationId !== void 0,
1380
+ turn
1381
+ });
1382
+ } catch (error) {
1383
+ throw this.releaseChatConversation(conversationId, turn), error;
1384
+ }
1281
1385
  break;
1282
1386
  case "chat.cancel":
1283
1387
  let cancelled = !1, cancelledConvId = null;
1284
- for (const [convId, process2] of this.activeChatProcesses)
1388
+ const cancelConvId = params?.conversation_id, cancelCandidates = [...this.activeChatProcesses].filter(([convId, proc]) => !this.cancelledChatProcesses.has(proc) && (typeof cancelConvId != "string" || convId === cancelConvId));
1389
+ for (const [convId, process2] of cancelCandidates)
1285
1390
  try {
1286
- process2.kill(), this.activeChatProcesses.delete(convId), cancelled = !0, cancelledConvId = convId;
1391
+ this.cancelledChatProcesses.add(process2), process2.kill(), cancelled = !0, cancelledConvId = convId;
1287
1392
  break;
1288
1393
  } catch (error) {
1289
1394
  console.warn(`[Chat] Failed to cancel process for conversation ${convId}: ${getErrorMessage(error)}`);
@@ -1304,9 +1409,9 @@ class VisualizationServer {
1304
1409
  /**
1305
1410
  * Launch chat with selected client and stream responses via WebSocket
1306
1411
  */
1307
- async launchChat(ws, conversationId, message, requestId) {
1412
+ async launchChat(ws, conversationId, message, requestId, options = {}) {
1308
1413
  if (!this.selectedChatClient) {
1309
- ws.send(JSON.stringify({
1414
+ options.turn && this.releaseChatConversation(conversationId, options.turn), ws.send(JSON.stringify({
1310
1415
  jsonrpc: "2.0",
1311
1416
  error: {
1312
1417
  code: JSONRPC_ERRORS.NO_CLIENT_AVAILABLE,
@@ -1316,50 +1421,103 @@ class VisualizationServer {
1316
1421
  }));
1317
1422
  return;
1318
1423
  }
1319
- this.selectedChatClient instanceof ClaudeCodeClient ? await this.launchClaudeCodeChat(ws, conversationId, message, requestId) : this.selectedChatClient instanceof CopilotClient ? await this.launchCopilotChat(ws, conversationId, message, requestId) : ws.send(JSON.stringify({
1424
+ this.selectedChatClient instanceof ClaudeCodeClient ? await this.launchClaudeCodeChat(ws, conversationId, message, requestId, options) : this.selectedChatClient instanceof CopilotClient ? await this.launchCopilotChat(ws, conversationId, message, requestId, options) : (options.turn && this.releaseChatConversation(conversationId, options.turn), ws.send(JSON.stringify({
1320
1425
  jsonrpc: "2.0",
1321
1426
  error: {
1322
1427
  code: JSONRPC_ERRORS.NO_CLIENT_AVAILABLE,
1323
1428
  message: "Unknown chat client type"
1324
1429
  },
1325
1430
  id: requestId
1326
- }));
1431
+ })));
1327
1432
  }
1328
1433
  /**
1329
1434
  * Build command arguments for Claude Code launch
1330
1435
  */
1331
- buildClaudeChatArgs(mcpConfigPath) {
1332
- const cmd = ["claude", "--agent", "dr-architect", "--print"], mode = this.drBotMode(), mcpRules = mcpConfigPath ? drMcpAllowedTools(mode) : [];
1436
+ buildClaudeChatArgs(mcpConfigPath, session, agentArgs = []) {
1437
+ const cmd = ["claude", "--agent", DR_ARCHITECT_AGENT, ...agentArgs, "--print"], mode = this.drBotMode(), mcpRules = mcpConfigPath ? drMcpAllowedTools(mode) : [];
1333
1438
  if (mode === "danger")
1334
1439
  cmd.push("--dangerously-skip-permissions");
1335
1440
  else {
1336
1441
  const bashRules = mode === "changesets" ? formatForClaudeCodeWithChangesets() : formatForClaudeCode();
1337
1442
  cmd.push("--allowedTools", [bashRules, ...mcpRules].join(","));
1338
1443
  }
1339
- return mcpConfigPath && cmd.push("--mcp-config", mcpConfigPath, "--append-system-prompt", drBotMcpPrompt(mode)), cmd.push("--verbose", "--output-format", "stream-json"), cmd;
1444
+ return mcpConfigPath && cmd.push("--mcp-config", mcpConfigPath, "--append-system-prompt", drBotMcpPrompt(mode)), session && cmd.push(session.started ? "--resume" : "--session-id", session.sessionId), cmd.push("--verbose", "--output-format", "stream-json"), cmd;
1445
+ }
1446
+ /**
1447
+ * The Claude Code session for a viewer conversation, created on its first send. A send
1448
+ * without a conversation id gets none, so it runs in a fresh session as before.
1449
+ */
1450
+ chatSessionFor(conversationId, resumable) {
1451
+ if (!resumable)
1452
+ return;
1453
+ let session = this.chatSessions.get(conversationId);
1454
+ return session || (session = { sessionId: randomUUID(), started: !1 }, this.chatSessions.set(conversationId, session)), session;
1455
+ }
1456
+ /** Marks a conversation busy for one turn; the token releases it. */
1457
+ reserveChatConversation(conversationId) {
1458
+ const turn = Symbol(conversationId);
1459
+ return this.busyChatConversations.set(conversationId, turn), turn;
1460
+ }
1461
+ /** Ends a turn's hold on its conversation. A stale token (an older turn) releases nothing. */
1462
+ releaseChatConversation(conversationId, turn) {
1463
+ this.busyChatConversations.get(conversationId) === turn && this.busyChatConversations.delete(conversationId);
1464
+ }
1465
+ /** Drops a turn's process from activeChatProcesses, unless a newer turn's has replaced it. */
1466
+ forgetChatProcess(conversationId, proc) {
1467
+ this.activeChatProcesses.get(conversationId) === proc && this.activeChatProcesses.delete(conversationId);
1468
+ }
1469
+ /**
1470
+ * Whether Claude Code already has this session on disk
1471
+ * (`<config dir>/projects/<cwd with non-alphanumerics as "-">/<id>.jsonl`). A first send that
1472
+ * died before the server saw it start can still have created it, and --session-id would then
1473
+ * fail with "already in use".
1474
+ */
1475
+ claudeSessionExists(sessionId) {
1476
+ const configDir = this._testClaudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(homedir(), ".claude"), roots = /* @__PURE__ */ new Set([this.model.rootPath]);
1477
+ try {
1478
+ roots.add(realpathSync(this.model.rootPath));
1479
+ } catch {
1480
+ }
1481
+ for (const root of roots) {
1482
+ const projectDir = root.replace(/[^a-zA-Z0-9]/g, "-");
1483
+ if (existsSync(path.join(configDir, "projects", projectDir, `${sessionId}.jsonl`)))
1484
+ return !0;
1485
+ }
1486
+ return !1;
1340
1487
  }
1341
1488
  /** DrBot's permission mode; danger wins over changeset-only. */
1342
1489
  drBotMode() {
1343
1490
  return this.withDanger ? "danger" : this.withChangesets ? "changesets" : "read-safe";
1344
1491
  }
1345
1492
  /**
1346
- * Writes the MCP config that connects DrBot to `dr mcp` for this model. Returns null when it
1347
- * can't (logged): DrBot then launches without MCP and uses its Bash rules.
1493
+ * Resolves the dr-architect DrBot runs, once: the extra launch arguments (--agents for the
1494
+ * bundled copy) and the check of its tools both come from this lookup.
1348
1495
  */
1349
- async prepareDrBotMcp() {
1350
- const mode = this.drBotMode(), overrides = this._testDrMcpOptions ?? {};
1496
+ async resolveDrBotAgentLaunch() {
1497
+ const overrides = this._testDrMcpOptions ?? {};
1351
1498
  try {
1352
- const check = await checkInstalledAgentMcpTools(this.model.rootPath, mode, overrides.homeDir);
1353
- if (check && (this.warnDrBotOnce(check.noneVisible ? `[Claude Code] The installed ${DR_ARCHITECT_AGENT} agent (${check.path}) predates the dr MCP tools, so the chat can't use them and falls back to Bash dr commands. Run \`dr claude upgrade\` to update it.` : `[Claude Code] The installed ${DR_ARCHITECT_AGENT} agent (${check.path}) doesn't list ${check.missing.join(", ")}, so the chat can't use those dr MCP tools. Run \`dr claude upgrade\` to update it.`), check.noneVisible))
1354
- return null;
1499
+ return await prepareDrBotAgent(this.model.rootPath, this.drBotMode(), {
1500
+ homeDir: overrides.homeDir,
1501
+ env: overrides.env,
1502
+ bundledPath: overrides.bundledPath
1503
+ });
1355
1504
  } catch (error) {
1356
- this.warnDrBotOnce(`[Claude Code] Could not check the installed ${DR_ARCHITECT_AGENT} agent's tools (${getErrorMessage(error)}); connecting the dr MCP server anyway.`);
1505
+ return this.warnDrBotOnce(`[Claude Code] Could not check the installed ${DR_ARCHITECT_AGENT} agent's tools (${getErrorMessage(error)}); connecting the dr MCP server anyway.`), { agent: null, args: [], check: null };
1357
1506
  }
1507
+ }
1508
+ /**
1509
+ * Writes the MCP config that connects DrBot to `dr mcp` for this model. Returns null when it
1510
+ * can't (logged), or when the agent DrBot runs can't see the dr tools: DrBot then launches
1511
+ * without MCP and uses its Bash rules.
1512
+ */
1513
+ async prepareDrBotMcp(check) {
1514
+ const mode = this.drBotMode(), overrides = this._testDrMcpOptions ?? {};
1515
+ if (check && (this.warnDrBotOnce(check.error ? `[Claude Code] Could not pass the bundled ${DR_ARCHITECT_AGENT} agent (${check.path}: ${check.error}), so the chat may run one that can't see the dr MCP tools; it falls back to Bash dr commands.` : check.noneVisible ? `[Claude Code] The installed ${DR_ARCHITECT_AGENT} agent (${check.path}) predates the dr MCP tools, so the chat can't use them and falls back to Bash dr commands. Run \`dr claude upgrade\` to update it.` : `[Claude Code] The installed ${DR_ARCHITECT_AGENT} agent (${check.path}) doesn't list ${check.missing.join(", ")}, so the chat can't use those dr MCP tools. Run \`dr claude upgrade\` to update it.`), check.noneVisible))
1516
+ return null;
1358
1517
  try {
1359
1518
  return await prepareDrMcpLaunch({
1360
1519
  rootPath: this.model.rootPath,
1361
1520
  changesetOnly: mode === "changesets",
1362
- keyManager: { ensureKey: () => this.ensureDrMcpKey() },
1363
1521
  drCommand: overrides.drCommand,
1364
1522
  tempRoot: overrides.tempRoot
1365
1523
  });
@@ -1367,23 +1525,6 @@ class VisualizationServer {
1367
1525
  return console.warn(`[Claude Code] dr MCP server unavailable for chat, falling back to Bash dr commands: ${getErrorMessage(error)}`), null;
1368
1526
  }
1369
1527
  }
1370
- /**
1371
- * The installation's MCP API key. Concurrent chat launches share one in-flight ensureKey call,
1372
- * so a first-time key is created once rather than raced and overwritten (which would leave an
1373
- * earlier launch's MCP server holding a key that no longer matches). Once it settles the next
1374
- * launch reads the key afresh, so a key rotated with `dr mcp --regenerate-key` is picked up.
1375
- */
1376
- ensureDrMcpKey() {
1377
- if (!this.drMcpKey) {
1378
- const pending = (this._testDrMcpOptions?.keyManager ?? new ApiKeyManager()).ensureKey();
1379
- this.drMcpKey = pending;
1380
- const clear = () => {
1381
- this.drMcpKey === pending && (this.drMcpKey = void 0);
1382
- };
1383
- pending.then(clear, clear);
1384
- }
1385
- return this.drMcpKey;
1386
- }
1387
1528
  /** Logs a DrBot setup warning once per server rather than on every chat message. */
1388
1529
  warnDrBotOnce(message) {
1389
1530
  this.drBotWarnings.has(message) || (this.drBotWarnings.add(message), console.warn(message));
@@ -1410,11 +1551,15 @@ class VisualizationServer {
1410
1551
  /**
1411
1552
  * Launch Claude Code CLI with dr-architect agent and stream responses
1412
1553
  */
1413
- async launchClaudeCodeChat(ws, conversationId, message, requestId) {
1554
+ async launchClaudeCodeChat(ws, conversationId, message, requestId, options = {}) {
1414
1555
  let drMcp = null;
1556
+ const session = this.chatSessionFor(conversationId, options.resumable === !0), turnToken = options.turn ?? this.reserveChatConversation(conversationId), release = () => this.releaseChatConversation(conversationId, turnToken);
1415
1557
  try {
1416
- drMcp = this._testClaudeCmdOverride !== void 0 && this._testDrMcpOptions === void 0 ? null : await this.prepareDrBotMcp();
1417
- const cmd = this.buildClaudeChatArgs(drMcp?.configPath);
1558
+ const agent = this._testClaudeCmdOverride !== void 0 && this._testDrMcpOptions === void 0 ? null : await this.resolveDrBotAgentLaunch();
1559
+ drMcp = agent ? await this.prepareDrBotMcp(agent.check) : null, session && !session.started && session.attempted && this.claudeSessionExists(session.sessionId) && (session.started = !0);
1560
+ const usedSessionIdFlag = session !== void 0 && !session.started;
1561
+ session && usedSessionIdFlag && (session.attempted = !0);
1562
+ const cmd = this.buildClaudeChatArgs(drMcp?.configPath, session, agent?.args ?? []);
1418
1563
  this._testLastClaudeChatArgs = [...cmd], this._testClaudeCmdOverride && cmd.splice(0, cmd.length, ...this._testClaudeCmdOverride);
1419
1564
  const proc = spawn(cmd[0], cmd.slice(1), {
1420
1565
  cwd: this.model.rootPath,
@@ -1427,7 +1572,7 @@ class VisualizationServer {
1427
1572
  }
1428
1573
  proc.on("error", (err) => {
1429
1574
  const errorMsg = getErrorMessage(err);
1430
- console.error(`[Claude Code] Failed to spawn subprocess for conversation ${conversationId}: ${errorMsg}`), this.activeChatProcesses.delete(conversationId);
1575
+ console.error(`[Claude Code] Failed to spawn subprocess for conversation ${conversationId}: ${errorMsg}`), this.forgetChatProcess(conversationId, proc), release();
1431
1576
  try {
1432
1577
  ws.send(JSON.stringify({
1433
1578
  jsonrpc: "2.0",
@@ -1441,10 +1586,35 @@ class VisualizationServer {
1441
1586
  console.error(`[Claude Code] Failed to send spawn-error notification: ${getErrorMessage(sendError)}`);
1442
1587
  }
1443
1588
  }), proc.stdin?.on("error", () => {
1444
- }), this.activeChatProcesses.set(conversationId, proc), proc.stdin?.write(message), proc.stdin?.end();
1589
+ });
1590
+ let stderrTail = "";
1591
+ proc.stderr?.on("data", (data) => {
1592
+ stderrTail = (stderrTail + data.toString()).slice(-4096);
1593
+ }), this.activeChatProcesses.set(conversationId, proc);
1594
+ const isCancelled = () => this.cancelledChatProcesses.has(proc);
1595
+ proc.stdin?.write(message), proc.stdin?.end();
1445
1596
  const decoder = new TextDecoder();
1446
1597
  (async () => {
1447
- let accumulatedText = "", buffer = "";
1598
+ const turn = { accumulatedText: "", pendingTools: /* @__PURE__ */ new Map(), session, sawEvent: !1 };
1599
+ let buffer = "";
1600
+ const handleLine = (line, isLast) => {
1601
+ if (isCancelled())
1602
+ return;
1603
+ let event;
1604
+ try {
1605
+ event = JSON.parse(line);
1606
+ } catch (parseError) {
1607
+ process.env.DEBUG && console.debug(`[Chat] Failed to parse JSON chunk: ${getErrorMessage(parseError)}`), this.sendChatNotification(ws, "chat.response.chunk", {
1608
+ conversation_id: conversationId,
1609
+ content: isLast ? line : line + `
1610
+ `,
1611
+ is_final: isLast,
1612
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1613
+ });
1614
+ return;
1615
+ }
1616
+ turn.sawEvent = !0, this.handleClaudeStreamEvent(ws, conversationId, event, turn);
1617
+ };
1448
1618
  try {
1449
1619
  for await (const chunkBuf of proc.stdout) {
1450
1620
  const chunk = decoder.decode(chunkBuf, { stream: !0 });
@@ -1453,79 +1623,34 @@ class VisualizationServer {
1453
1623
  `);
1454
1624
  buffer = lines.pop() || "";
1455
1625
  for (const line of lines)
1456
- if (line.trim())
1457
- try {
1458
- const event = JSON.parse(line);
1459
- if (event.type === "assistant") {
1460
- const content = event.message?.content || [];
1461
- for (const block of content)
1462
- block.type === "text" ? (accumulatedText += block.text, ws.send(JSON.stringify({
1463
- jsonrpc: "2.0",
1464
- method: "chat.response.chunk",
1465
- params: {
1466
- conversation_id: conversationId,
1467
- content: block.text,
1468
- is_final: !1,
1469
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1470
- }
1471
- }))) : block.type === "tool_use" && ws.send(JSON.stringify({
1472
- jsonrpc: "2.0",
1473
- method: "chat.tool.invoke",
1474
- params: {
1475
- conversation_id: conversationId,
1476
- tool_name: block.name,
1477
- tool_input: block.input,
1478
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1479
- }
1480
- }));
1481
- } else event.type === "result" && ws.send(JSON.stringify({
1482
- jsonrpc: "2.0",
1483
- method: "chat.tool.result",
1484
- params: {
1485
- conversation_id: conversationId,
1486
- result: event.result,
1487
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1488
- }
1489
- }));
1490
- } catch (parseError) {
1491
- process.env.DEBUG && console.debug(`[Chat] Failed to parse JSON chunk: ${getErrorMessage(parseError)}`), ws.send(JSON.stringify({
1492
- jsonrpc: "2.0",
1493
- method: "chat.response.chunk",
1494
- params: {
1495
- conversation_id: conversationId,
1496
- content: line + `
1497
- `,
1498
- is_final: !1,
1499
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1500
- }
1501
- }));
1502
- }
1626
+ line.trim() && handleLine(line, !1);
1503
1627
  }
1504
- buffer.trim() && ws.send(JSON.stringify({
1505
- jsonrpc: "2.0",
1506
- method: "chat.response.chunk",
1507
- params: {
1508
- conversation_id: conversationId,
1509
- content: buffer,
1510
- is_final: !0,
1511
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1512
- }
1513
- }));
1628
+ buffer.trim() && handleLine(buffer, !0);
1514
1629
  const exitCode = await this.waitForExit(proc);
1515
- this.activeChatProcesses.delete(conversationId), ws.send(JSON.stringify({
1630
+ if (this.forgetChatProcess(conversationId, proc), session && exitCode === 0 && (session.started = !0), session && usedSessionIdFlag && !options.retried && !isCancelled() && exitCode !== 0 && !turn.sawEvent && /already in use/i.test(stderrTail)) {
1631
+ session.started = !0, await this.launchClaudeCodeChat(ws, conversationId, message, requestId, {
1632
+ ...options,
1633
+ turn: turnToken,
1634
+ retried: !0
1635
+ });
1636
+ return;
1637
+ }
1638
+ isCancelled() || this.failPendingTools(ws, conversationId, turn, "The tool reported no result before the reply ended"), release(), ws.send(JSON.stringify({
1516
1639
  jsonrpc: "2.0",
1517
1640
  result: {
1518
1641
  conversation_id: conversationId,
1519
1642
  status: exitCode === 0 ? "complete" : "error",
1520
1643
  exit_code: exitCode,
1521
- full_response: accumulatedText,
1644
+ full_response: turn.accumulatedText,
1522
1645
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1523
1646
  },
1524
1647
  id: requestId
1525
1648
  }));
1526
1649
  } catch (error) {
1527
- if (!proc.pid)
1650
+ if (!proc.pid) {
1651
+ release();
1528
1652
  return;
1653
+ }
1529
1654
  const errorMsg = getErrorMessage(error);
1530
1655
  ws.send(JSON.stringify({
1531
1656
  jsonrpc: "2.0",
@@ -1534,16 +1659,18 @@ class VisualizationServer {
1534
1659
  message: `Chat failed: ${errorMsg}`
1535
1660
  },
1536
1661
  id: requestId
1537
- })), this.activeChatProcesses.delete(conversationId);
1662
+ })), this.forgetChatProcess(conversationId, proc);
1538
1663
  try {
1539
1664
  await this.killProcessWithRetry(proc, conversationId, "Claude Code");
1540
1665
  } catch (error2) {
1541
1666
  console.error(`[Claude Code] Failed to kill process for conversation ${conversationId}: ${getErrorMessage(error2)}`);
1667
+ } finally {
1668
+ release();
1542
1669
  }
1543
1670
  }
1544
1671
  })().catch((err) => {
1545
1672
  const errorMsg = getErrorMessage(err);
1546
- console.error(`[Claude Code] Stream error: ${errorMsg}`);
1673
+ console.error(`[Claude Code] Stream error: ${errorMsg}`), release();
1547
1674
  try {
1548
1675
  ws.send(JSON.stringify({
1549
1676
  jsonrpc: "2.0",
@@ -1558,7 +1685,7 @@ class VisualizationServer {
1558
1685
  }
1559
1686
  });
1560
1687
  } catch (error) {
1561
- drMcp?.cleanup();
1688
+ drMcp?.cleanup(), release();
1562
1689
  const errorMsg = getErrorMessage(error);
1563
1690
  ws.send(JSON.stringify({
1564
1691
  jsonrpc: "2.0",
@@ -1570,10 +1697,77 @@ class VisualizationServer {
1570
1697
  }));
1571
1698
  }
1572
1699
  }
1700
+ /** Sends a JSON-RPC notification to one chat client. */
1701
+ sendChatNotification(ws, method, params) {
1702
+ ws.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
1703
+ }
1704
+ /**
1705
+ * Turns one Claude Code stream-json event into the chat notifications the viewer reads:
1706
+ * - assistant text -> `chat.response.chunk`
1707
+ * - assistant tool_use -> `chat.tool.invoke` with `status: "executing"`
1708
+ * - user tool_result -> `chat.tool.invoke` again, same `tool_use_id`, `completed` or `failed`
1709
+ * - result (end of the turn) -> `chat.response.complete`
1710
+ */
1711
+ handleClaudeStreamEvent(ws, conversationId, event, turn) {
1712
+ if (turn.session && typeof event?.session_id == "string" && (turn.session.started = !0), event?.type === "assistant") {
1713
+ const content = Array.isArray(event.message?.content) ? event.message.content : [];
1714
+ for (const block of content)
1715
+ block?.type === "text" ? (turn.accumulatedText += block.text, this.sendChatNotification(ws, "chat.response.chunk", {
1716
+ conversation_id: conversationId,
1717
+ content: block.text,
1718
+ is_final: !1,
1719
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1720
+ })) : block?.type === "tool_use" && (typeof block.id == "string" && turn.pendingTools.set(block.id, { name: block.name, input: block.input }), this.sendChatNotification(ws, "chat.tool.invoke", {
1721
+ conversation_id: conversationId,
1722
+ tool_use_id: block.id,
1723
+ tool_name: block.name,
1724
+ tool_input: block.input,
1725
+ status: "executing",
1726
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1727
+ }));
1728
+ } else if (event?.type === "user") {
1729
+ const content = Array.isArray(event.message?.content) ? event.message.content : [];
1730
+ for (const block of content)
1731
+ block?.type !== "tool_result" || typeof block.tool_use_id != "string" || this.sendToolOutcome(ws, conversationId, turn, block.tool_use_id, block.is_error === !0, block.content);
1732
+ } else if (event?.type === "result") {
1733
+ const denials = Array.isArray(event.permission_denials) ? event.permission_denials : [];
1734
+ for (const denial of denials) {
1735
+ const id = denial?.tool_use_id;
1736
+ typeof id == "string" && turn.pendingTools.has(id) && this.sendToolOutcome(ws, conversationId, turn, id, !0, `Permission denied: ${denial.tool_name ?? "tool"}`);
1737
+ }
1738
+ this.sendChatNotification(ws, "chat.response.complete", {
1739
+ conversation_id: conversationId,
1740
+ result: event.result,
1741
+ is_error: event.is_error === !0,
1742
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1743
+ });
1744
+ }
1745
+ }
1746
+ /** Sends a tool's final `chat.tool.invoke` (completed or failed) and stops tracking it. */
1747
+ sendToolOutcome(ws, conversationId, turn, toolUseId, isError, result) {
1748
+ const tool = turn.pendingTools.get(toolUseId);
1749
+ turn.pendingTools.delete(toolUseId), this.sendChatNotification(ws, "chat.tool.invoke", {
1750
+ conversation_id: conversationId,
1751
+ tool_use_id: toolUseId,
1752
+ tool_name: tool?.name,
1753
+ tool_input: tool?.input,
1754
+ status: isError ? "failed" : "completed",
1755
+ is_error: isError,
1756
+ result,
1757
+ ...isError ? { error: toolResultText(result) || "Tool call failed" } : {},
1758
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1759
+ });
1760
+ }
1761
+ /** Reports every tool still waiting for a result as failed. */
1762
+ failPendingTools(ws, conversationId, turn, reason) {
1763
+ for (const toolUseId of [...turn.pendingTools.keys()])
1764
+ this.sendToolOutcome(ws, conversationId, turn, toolUseId, !0, reason);
1765
+ }
1573
1766
  /**
1574
1767
  * Launch GitHub Copilot CLI and stream responses via WebSocket
1575
1768
  */
1576
- async launchCopilotChat(ws, conversationId, message, requestId) {
1769
+ async launchCopilotChat(ws, conversationId, message, requestId, options = {}) {
1770
+ const turnToken = options.turn ?? this.reserveChatConversation(conversationId), release = () => this.releaseChatConversation(conversationId, turnToken);
1577
1771
  try {
1578
1772
  let cmd;
1579
1773
  if (this._testCopilotCmdOverride)
@@ -1590,7 +1784,7 @@ class VisualizationServer {
1590
1784
  });
1591
1785
  proc.on("error", (err) => {
1592
1786
  const errorMsg = getErrorMessage(err);
1593
- console.error(`[Copilot] Failed to spawn subprocess for conversation ${conversationId}: ${errorMsg}`), this.activeChatProcesses.delete(conversationId);
1787
+ console.error(`[Copilot] Failed to spawn subprocess for conversation ${conversationId}: ${errorMsg}`), this.forgetChatProcess(conversationId, proc), release();
1594
1788
  try {
1595
1789
  ws.send(JSON.stringify({
1596
1790
  jsonrpc: "2.0",
@@ -1611,7 +1805,7 @@ class VisualizationServer {
1611
1805
  try {
1612
1806
  for await (const chunkBuf of proc.stdout) {
1613
1807
  const chunk = decoder.decode(chunkBuf, { stream: !0 });
1614
- accumulatedText += chunk, ws.send(JSON.stringify({
1808
+ accumulatedText += chunk, !this.cancelledChatProcesses.has(proc) && ws.send(JSON.stringify({
1615
1809
  jsonrpc: "2.0",
1616
1810
  method: "chat.response.chunk",
1617
1811
  params: {
@@ -1623,7 +1817,7 @@ class VisualizationServer {
1623
1817
  }));
1624
1818
  }
1625
1819
  const exitCode = await this.waitForExit(proc);
1626
- this.activeChatProcesses.delete(conversationId), ws.send(JSON.stringify({
1820
+ this.forgetChatProcess(conversationId, proc), release(), ws.send(JSON.stringify({
1627
1821
  jsonrpc: "2.0",
1628
1822
  result: {
1629
1823
  conversation_id: conversationId,
@@ -1635,8 +1829,10 @@ class VisualizationServer {
1635
1829
  id: requestId
1636
1830
  }));
1637
1831
  } catch (error) {
1638
- if (!proc.pid)
1832
+ if (!proc.pid) {
1833
+ release();
1639
1834
  return;
1835
+ }
1640
1836
  const errorMsg = getErrorMessage(error);
1641
1837
  ws.send(JSON.stringify({
1642
1838
  jsonrpc: "2.0",
@@ -1645,16 +1841,18 @@ class VisualizationServer {
1645
1841
  message: `Chat failed: ${errorMsg}`
1646
1842
  },
1647
1843
  id: requestId
1648
- })), this.activeChatProcesses.delete(conversationId);
1844
+ })), this.forgetChatProcess(conversationId, proc);
1649
1845
  try {
1650
1846
  await this.killProcessWithRetry(proc, conversationId, "Copilot");
1651
1847
  } catch (error2) {
1652
1848
  console.error(`[Copilot] Failed to kill process for conversation ${conversationId}: ${getErrorMessage(error2)}`);
1849
+ } finally {
1850
+ release();
1653
1851
  }
1654
1852
  }
1655
1853
  })().catch((err) => {
1656
1854
  const errorMsg = getErrorMessage(err);
1657
- console.error(`[Copilot] Stream error: ${errorMsg}`);
1855
+ console.error(`[Copilot] Stream error: ${errorMsg}`), release();
1658
1856
  try {
1659
1857
  ws.send(JSON.stringify({
1660
1858
  jsonrpc: "2.0",
@@ -1669,6 +1867,7 @@ class VisualizationServer {
1669
1867
  }
1670
1868
  });
1671
1869
  } catch (error) {
1870
+ release();
1672
1871
  const errorMsg = getErrorMessage(error);
1673
1872
  ws.send(JSON.stringify({
1674
1873
  jsonrpc: "2.0",
@@ -1681,28 +1880,67 @@ class VisualizationServer {
1681
1880
  }
1682
1881
  }
1683
1882
  /**
1684
- * Setup file watcher for model changes
1883
+ * Watch the model and the changesets for changes made outside the server (the CLI, DrBot).
1884
+ * A model change reloads and broadcasts `model.updated`. A changeset change, including the
1885
+ * `.active` marker, broadcasts a debounced `changesets.updated`. An annotation change (the
1886
+ * MCP annotation_* tools write the same files) broadcasts what changed, debounced.
1685
1887
  */
1686
1888
  setupFileWatcher() {
1687
- const modelPath = `${this.model.rootPath}/documentation-robotics/model`;
1889
+ const drRoot = path.join(this.model.rootPath, "documentation-robotics"), modelPath = path.join(drRoot, "model"), changesetsPath = path.join(drRoot, "changesets"), annotationsPath = path.join(drRoot, "annotations"), isWithin = (filePath, dir) => filePath === dir || filePath.startsWith(dir + path.sep);
1688
1890
  try {
1689
- this.watcher = chokidar.watch(modelPath, {
1891
+ this.watcher = chokidar.watch(drRoot, {
1690
1892
  persistent: !0,
1691
- ignoreInitial: !0
1692
- }), this.watcher.on("all", async (_event, path2) => {
1693
- process.env.VERBOSE && console.log(`[Watcher] File changed: ${path2}`);
1694
- try {
1695
- await this.reloadAndBroadcast();
1696
- } catch (error) {
1697
- const message = getErrorMessage(error);
1698
- console.error(`[Watcher] Failed to reload model: ${message}`);
1893
+ ignoreInitial: !0,
1894
+ ignored: (filePath) => !(filePath === drRoot || isWithin(filePath, modelPath) || isWithin(filePath, changesetsPath) || isWithin(filePath, annotationsPath))
1895
+ }), this.watcher.on("all", async (_event, filePath) => {
1896
+ if (process.env.VERBOSE && console.log(`[Watcher] File changed: ${filePath}`), isWithin(filePath, annotationsPath)) {
1897
+ this.scheduleAnnotationsSync();
1898
+ return;
1899
+ }
1900
+ if (isWithin(filePath, changesetsPath)) {
1901
+ this.scheduleChangesetsBroadcast();
1902
+ return;
1699
1903
  }
1904
+ if (isWithin(filePath, modelPath))
1905
+ try {
1906
+ await this.reloadAndBroadcast();
1907
+ } catch (error) {
1908
+ const message = getErrorMessage(error);
1909
+ console.error(`[Watcher] Failed to reload model: ${message}`);
1910
+ }
1700
1911
  }), this.watcher.on("error", (err) => {
1701
- console.warn(`[Watcher] Could not watch ${modelPath}: ${getErrorMessage(err)}`);
1912
+ console.warn(`[Watcher] Could not watch ${drRoot}: ${getErrorMessage(err)}`);
1702
1913
  });
1703
1914
  } catch (err) {
1704
- console.warn(`[Watcher] Could not watch ${modelPath}: ${err}`);
1915
+ console.warn(`[Watcher] Could not watch ${drRoot}: ${err}`);
1916
+ }
1917
+ }
1918
+ /** Broadcasts `changesets.updated` once the changeset files have been quiet for a moment. */
1919
+ scheduleChangesetsBroadcast() {
1920
+ this.changesetsBroadcastTimer && clearTimeout(this.changesetsBroadcastTimer), this.changesetsBroadcastTimer = setTimeout(() => {
1921
+ this.changesetsBroadcastTimer = void 0, this.broadcastChangesetsUpdated().catch((error) => {
1922
+ console.error(`[Watcher] Failed to broadcast changesets: ${getErrorMessage(error)}`);
1923
+ });
1924
+ }, this.changesetsDebounceMs);
1925
+ }
1926
+ /**
1927
+ * Reload the changesets from disk and send every client `changesets.updated` with their ids
1928
+ * and the active one. Clients fetch `GET /api/changesets` for the details.
1929
+ */
1930
+ async broadcastChangesetsUpdated() {
1931
+ await this.loadChangesetsFromDisk();
1932
+ let active = null;
1933
+ try {
1934
+ active = await new StagingAreaManager(this.model.rootPath).getActiveId();
1935
+ } catch (error) {
1936
+ console.warn(`[Changesets] Could not read the active changeset: ${getErrorMessage(error)}`);
1705
1937
  }
1938
+ await this.broadcastMessage({
1939
+ type: "changesets.updated",
1940
+ ids: [...this.changesets.keys()],
1941
+ active,
1942
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1943
+ });
1706
1944
  }
1707
1945
  /**
1708
1946
  * Load all JSON schemas from bundled directory (recursive)
@@ -2403,8 +2641,8 @@ class VisualizationServer {
2403
2641
  * Commit or discard a staged changeset for POST /api/changesets/:id/{commit,discard}.
2404
2642
  *
2405
2643
  * Works on a freshly loaded model rather than the server's in-memory copy, so a CLI
2406
- * change since the last reload can't be overwritten. Afterwards the server reloads
2407
- * (the watcher only sees model files, and a discard touches none) and broadcasts.
2644
+ * change since the last reload can't be overwritten. Afterwards the server reloads and
2645
+ * broadcasts `model.updated` and `changesets.updated` at once, rather than wait for the watcher.
2408
2646
  */
2409
2647
  async runChangesetAction(c, changesetId, action) {
2410
2648
  const model = await Model.load(this.model.rootPath, { lazyLoad: !1 }), manager = new StagingAreaManager(model.rootPath, model), changeset = await manager.load(changesetId);
@@ -2438,6 +2676,11 @@ class VisualizationServer {
2438
2676
  } catch (error) {
2439
2677
  console.error(`[Changesets] Reload after ${action} failed: ${getErrorMessage(error)}`);
2440
2678
  }
2679
+ try {
2680
+ await this.broadcastChangesetsUpdated();
2681
+ } catch (error) {
2682
+ console.error(`[Changesets] Broadcast after ${action} failed: ${getErrorMessage(error)}`);
2683
+ }
2441
2684
  const updated = this.changesets.get(changesetId) ?? await manager.load(changesetId);
2442
2685
  return c.json({
2443
2686
  id: changesetId,
@@ -2465,14 +2708,44 @@ class VisualizationServer {
2465
2708
  * Start the server
2466
2709
  */
2467
2710
  async start(port = 8080) {
2468
- this.changesetStorage = new StagedChangesetStorage(this.model.rootPath), await this.loadChangesetsFromDisk(), this.setupFileWatcher(), this._server = serve({
2469
- port,
2470
- fetch: this.app.fetch
2471
- }), await new Promise((resolve, reject) => {
2472
- this._server.once("listening", resolve), this._server.once("error", (err) => {
2473
- err.code === "EADDRINUSE" ? reject(new Error(`Port ${port} is already in use. Try a different port with --port <port>.`)) : reject(err);
2711
+ this.changesetStorage = new StagedChangesetStorage(this.model.rootPath), await this.loadChangesetsFromDisk(), await this.syncAnnotations({ broadcast: !1 }).catch((error) => {
2712
+ console.warn(`[Annotations] Could not read the annotations: ${getErrorMessage(error)}`);
2713
+ });
2714
+ try {
2715
+ this._servers.push(await this.listenOn(port, "127.0.0.1"));
2716
+ try {
2717
+ this._servers.push(await this.listenOn(port, "::1"));
2718
+ } catch (error) {
2719
+ const code = error.code;
2720
+ if (code !== "EADDRNOTAVAIL" && code !== "EAFNOSUPPORT")
2721
+ throw error;
2722
+ }
2723
+ } catch (error) {
2724
+ throw this.closeServers(), error.code === "EADDRINUSE" ? new Error(`Port ${port} is already in use. Try a different port with --port <port>.`) : error;
2725
+ }
2726
+ for (const server of this._servers)
2727
+ this.injectWebSocket?.(server);
2728
+ this.setupFileWatcher(), console.log(`\u2713 Visualization server running at http://localhost:${port}`);
2729
+ }
2730
+ /**
2731
+ * One HTTP server for the app, listening on host:port. Node reports bind failures (e.g.
2732
+ * EADDRINUSE) through an 'error' event, not a throw; this turns that into a rejection
2733
+ * instead of an unhandled 'error' that would crash the process via server-entry.ts's
2734
+ * uncaughtException handler.
2735
+ */
2736
+ async listenOn(port, host) {
2737
+ const server = createAdaptorServer({ fetch: this.app.fetch });
2738
+ return await new Promise((resolve, reject) => {
2739
+ server.once("listening", resolve), server.once("error", reject), server.listen(port, host);
2740
+ }), server;
2741
+ }
2742
+ /** Closes every HTTP server this instance listens on. */
2743
+ closeServers() {
2744
+ for (const server of this._servers)
2745
+ server.close((error) => {
2746
+ error && process.env.VERBOSE && console.warn(`[SERVER] Error while closing HTTP server: ${getErrorMessage(error)}`);
2474
2747
  });
2475
- }), this.injectWebSocket?.(this._server), console.log(`\u2713 Visualization server running at http://localhost:${port}`);
2748
+ this._servers = [];
2476
2749
  }
2477
2750
  /**
2478
2751
  * Get the current auth token
@@ -2494,11 +2767,11 @@ class VisualizationServer {
2494
2767
  */
2495
2768
  waitForExit(proc) {
2496
2769
  return new Promise((resolve) => {
2497
- if (proc.exitCode !== null) {
2498
- resolve(proc.exitCode);
2770
+ if (this.hasExited(proc)) {
2771
+ resolve(proc.exitCode ?? 1);
2499
2772
  return;
2500
2773
  }
2501
- proc.once("exit", (code) => resolve(code ?? 0));
2774
+ proc.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
2502
2775
  });
2503
2776
  }
2504
2777
  /**
@@ -2549,9 +2822,7 @@ class VisualizationServer {
2549
2822
  const errorMessage = error instanceof Error ? error.message : String(error);
2550
2823
  errorMessage.includes("already closed") || console.warn(`[SERVER] Unexpected error closing WebSocket client: ${errorMessage}`);
2551
2824
  }
2552
- this.clients.clear(), this.watcher && this.watcher.close?.(), this._server && this._server.close((error) => {
2553
- error && process.env.VERBOSE && console.warn(`[SERVER] Error while closing HTTP server: ${getErrorMessage(error)}`);
2554
- });
2825
+ this.clients.clear(), this.watcher && this.watcher.close?.(), this.changesetsBroadcastTimer && (clearTimeout(this.changesetsBroadcastTimer), this.changesetsBroadcastTimer = void 0), this.annotationsBroadcastTimer && (clearTimeout(this.annotationsBroadcastTimer), this.annotationsBroadcastTimer = void 0), this.closeServers();
2555
2826
  }
2556
2827
  }
2557
2828
  export {