@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.
- package/README.md +10 -14
- package/dist/analyzers/active-analyzer.js +62 -0
- package/dist/analyzers/active-analyzer.js.map +7 -0
- package/dist/analyzers/cards/build.js +21 -1
- package/dist/analyzers/cards/build.js.map +2 -2
- package/dist/analyzers/cards/extract.js +138 -0
- package/dist/analyzers/cards/extract.js.map +7 -0
- package/dist/analyzers/cards/routes.js +96 -0
- package/dist/analyzers/cards/routes.js.map +7 -0
- package/dist/analyzers/cbm-analyzer.js +76 -4
- package/dist/analyzers/cbm-analyzer.js.map +2 -2
- package/dist/analyzers/cbm-responses.js +12 -6
- package/dist/analyzers/cbm-responses.js.map +2 -2
- package/dist/analyzers/code-changes.js +159 -0
- package/dist/analyzers/code-changes.js.map +7 -0
- package/dist/analyzers/edge-relationships.js +6 -2
- package/dist/analyzers/edge-relationships.js.map +2 -2
- package/dist/analyzers/extraction-plan.js +541 -0
- package/dist/analyzers/extraction-plan.js.map +7 -0
- package/dist/analyzers/index-job.js +229 -0
- package/dist/analyzers/index-job.js.map +7 -0
- package/dist/cli.js +12 -10
- package/dist/cli.js.map +2 -2
- package/dist/coding-agents/drbot-mcp.js +120 -18
- package/dist/coding-agents/drbot-mcp.js.map +2 -2
- package/dist/commands/add.js +4 -2
- package/dist/commands/add.js.map +2 -2
- package/dist/commands/analyzer.js +35 -35
- package/dist/commands/analyzer.js.map +2 -2
- package/dist/commands/changeset-verify.js +16 -8
- package/dist/commands/changeset-verify.js.map +2 -2
- package/dist/commands/changeset.js +41 -11
- package/dist/commands/changeset.js.map +2 -2
- package/dist/commands/init.js +9 -26
- package/dist/commands/init.js.map +2 -2
- package/dist/commands/mcp.js +35 -31
- package/dist/commands/mcp.js.map +3 -3
- package/dist/commands/relationship.js +2 -12
- package/dist/commands/relationship.js.map +2 -2
- package/dist/commands/reports.js +3 -3
- package/dist/commands/reports.js.map +2 -2
- package/dist/commands/update.js +14 -13
- package/dist/commands/update.js.map +2 -2
- package/dist/commands/upgrade.js +28 -120
- package/dist/commands/upgrade.js.map +2 -2
- package/dist/commands/validate.js +19 -3
- package/dist/commands/validate.js.map +2 -2
- package/dist/commands/version.js +1 -1
- package/dist/commands/visualize.js +30 -12
- package/dist/commands/visualize.js.map +2 -2
- package/dist/core/annotation-store.js +15 -5
- package/dist/core/annotation-store.js.map +2 -2
- package/dist/core/catalog-migration.js +2 -1
- package/dist/core/catalog-migration.js.map +2 -2
- package/dist/core/changeset-base-index.js +7 -0
- package/dist/core/changeset-base-index.js.map +2 -2
- package/dist/core/changeset-brief-compact.js +256 -0
- package/dist/core/changeset-brief-compact.js.map +7 -0
- package/dist/core/changeset-verify.js +356 -26
- package/dist/core/changeset-verify.js.map +3 -3
- package/dist/core/extraction-observability.js +161 -0
- package/dist/core/extraction-observability.js.map +7 -0
- package/dist/core/migration-registry.js +13 -1
- package/dist/core/migration-registry.js.map +2 -2
- package/dist/core/project-init.js +53 -0
- package/dist/core/project-init.js.map +7 -0
- package/dist/core/relationship-staging.js +94 -1
- package/dist/core/relationship-staging.js.map +2 -2
- package/dist/core/staged-changeset-storage.js +83 -3
- package/dist/core/staged-changeset-storage.js.map +2 -2
- package/dist/core/staging-area.js +85 -8
- package/dist/core/staging-area.js.map +2 -2
- package/dist/core/upgrade-plan.js +117 -0
- package/dist/core/upgrade-plan.js.map +7 -0
- package/dist/generated/relationship-index.js +124 -15
- package/dist/generated/relationship-index.js.map +2 -2
- package/dist/integrations/claude_code/.claude-plugin/plugin.json +24 -0
- package/dist/integrations/claude_code/agents/dr-architect.md +19 -5
- package/dist/integrations/claude_code/agents/dr-extractor.md +7 -1
- package/dist/integrations/claude_code/commands/dr-changeset.md +2 -1
- package/dist/integrations/claude_code/commands/dr-map.md +7 -1
- package/dist/integrations/claude_code/commands/dr-plan.md +42 -16
- package/dist/integrations/claude_code/docs/MCP_SERVER.md +28 -31
- package/dist/integrations/claude_code/docs/README.md +20 -1
- package/dist/integrations/claude_code/plugin-agents/dr-advisor.md +255 -0
- package/dist/integrations/claude_code/plugin-agents/dr-architect.md +306 -0
- package/dist/integrations/claude_code/skills/dr_13_testing_layer/SKILL.md +3 -0
- package/dist/integrations/claude_code/skills/dr_change_planning/SKILL.md +90 -31
- package/dist/integrations/claude_code/skills/dr_changeset_reviewer/SKILL.md +5 -0
- package/dist/integrations/claude_code/skills/dr_engineering_guide/SKILL.md +5 -0
- package/dist/integrations/github_copilot/agents/dr-architect.md +2 -0
- package/dist/integrations/github_copilot/skills/dr_13_testing_layer/SKILL.md +3 -0
- package/dist/integrations/github_copilot/skills/dr_change_planning/SKILL.md +88 -30
- package/dist/mcp/changeset-only.js +14 -3
- package/dist/mcp/changeset-only.js.map +2 -2
- package/dist/mcp/project-root.js +85 -0
- package/dist/mcp/project-root.js.map +7 -0
- package/dist/mcp/server-instructions.js +27 -0
- package/dist/mcp/server-instructions.js.map +7 -0
- package/dist/mcp/strict-schema.js +52 -0
- package/dist/mcp/strict-schema.js.map +7 -0
- package/dist/mcp/tool-registry.js +39 -5
- package/dist/mcp/tool-registry.js.map +2 -2
- package/dist/mcp/tools/changeset-analysis-tools.js +25 -5
- package/dist/mcp/tools/changeset-analysis-tools.js.map +3 -3
- package/dist/mcp/tools/changeset-commit.js +224 -0
- package/dist/mcp/tools/changeset-commit.js.map +7 -0
- package/dist/mcp/tools/changeset-editing.js +133 -57
- package/dist/mcp/tools/changeset-editing.js.map +3 -3
- package/dist/mcp/tools/changeset-lifecycle.js +1 -1
- package/dist/mcp/tools/changeset-lifecycle.js.map +2 -2
- package/dist/mcp/tools/changeset-verify.js +12 -6
- package/dist/mcp/tools/changeset-verify.js.map +2 -2
- package/dist/mcp/tools/codebase-extract.js +836 -0
- package/dist/mcp/tools/codebase-extract.js.map +7 -0
- package/dist/mcp/tools/codebase-index.js +125 -0
- package/dist/mcp/tools/codebase-index.js.map +7 -0
- package/dist/mcp/tools/codebase-shared.js +265 -0
- package/dist/mcp/tools/codebase-shared.js.map +7 -0
- package/dist/mcp/tools/codebase-verify.js +48 -0
- package/dist/mcp/tools/codebase-verify.js.map +7 -0
- package/dist/mcp/tools/index.js +15 -0
- package/dist/mcp/tools/index.js.map +2 -2
- package/dist/mcp/tools/model-add.js +189 -73
- package/dist/mcp/tools/model-add.js.map +2 -2
- package/dist/mcp/tools/model-update.js +16 -12
- package/dist/mcp/tools/model-update.js.map +2 -2
- package/dist/mcp/tools/model-validate.js +46 -4
- package/dist/mcp/tools/model-validate.js.map +2 -2
- package/dist/mcp/tools/project-status-collectors.js +381 -0
- package/dist/mcp/tools/project-status-collectors.js.map +7 -0
- package/dist/mcp/tools/project-status.js +237 -0
- package/dist/mcp/tools/project-status.js.map +7 -0
- package/dist/mcp/tools/project-tools.js +505 -0
- package/dist/mcp/tools/project-tools.js.map +7 -0
- package/dist/mcp/tools/relationship-tools.js +89 -20
- package/dist/mcp/tools/relationship-tools.js.map +2 -2
- package/dist/mcp/tools/review-open.js +210 -0
- package/dist/mcp/tools/review-open.js.map +7 -0
- package/dist/mcp/tools/shared.js +10 -2
- package/dist/mcp/tools/shared.js.map +2 -2
- package/dist/reports/model-report-orchestrator.js +31 -2
- package/dist/reports/model-report-orchestrator.js.map +2 -2
- package/dist/schemas/bundled/analyzers/cbm.json +22 -10
- package/dist/schemas/bundled/analyzers/manifest.json +2 -2
- package/dist/schemas/bundled/api.json +1 -1
- package/dist/schemas/bundled/apm.json +1 -1
- package/dist/schemas/bundled/application.json +1 -1
- package/dist/schemas/bundled/base.json +1 -1
- package/dist/schemas/bundled/business.json +1 -1
- package/dist/schemas/bundled/data-model.json +1 -1
- package/dist/schemas/bundled/data-store.json +1 -1
- package/dist/schemas/bundled/manifest.json +3 -3
- package/dist/schemas/bundled/motivation.json +1 -1
- package/dist/schemas/bundled/navigation.json +1 -1
- package/dist/schemas/bundled/product.json +1 -1
- package/dist/schemas/bundled/security.json +1 -1
- package/dist/schemas/bundled/technology.json +1 -1
- package/dist/schemas/bundled/testing.json +145 -1
- package/dist/schemas/bundled/ux.json +1 -1
- package/dist/server/server.js +508 -237
- package/dist/server/server.js.map +3 -3
- package/dist/server/viewer-registry.js +264 -0
- package/dist/server/viewer-registry.js.map +7 -0
- package/dist/telemetry/index.js +1 -1
- package/dist/utils/id-generator.js +5 -2
- package/dist/utils/id-generator.js.map +2 -2
- package/dist/utils/source-reference.js +58 -27
- package/dist/utils/source-reference.js.map +2 -2
- package/dist/utils/spec-version.js +2 -2
- package/dist/utils/spec-version.js.map +1 -1
- package/dist/validators/model-warnings.js +28 -0
- package/dist/validators/model-warnings.js.map +7 -0
- package/dist/viewer/README.md +6 -6
- package/dist/viewer/assets/main-CUOQGGRA.css +1 -0
- package/dist/viewer/assets/main-DvH3gYrI.js +84 -0
- package/dist/viewer/index.html +2 -2
- package/dist/viewer/manifest.json +10 -10
- package/dist/viewer/version.json +1 -1
- package/package.json +1 -1
- package/dist/viewer/assets/main-CxzcuU_o.js +0 -84
- package/dist/viewer/assets/main-j-7ULYpm.css +0 -1
package/dist/server/server.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
42
|
-
//
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 (
|
|
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
|
|
176
|
-
|
|
177
|
-
return
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
188
|
-
timestamp:
|
|
189
|
-
})
|
|
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
|
-
|
|
384
|
-
|
|
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.
|
|
538
|
+
if (!await this.elementExists(body.elementId))
|
|
447
539
|
return c.json({ error: "Element not found" }, 404);
|
|
448
|
-
const annotation = {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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.
|
|
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")
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
return c.json({ error: "Annotation not found" }, 404);
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
})
|
|
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
|
|
1280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
*
|
|
1347
|
-
*
|
|
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
|
|
1350
|
-
const
|
|
1496
|
+
async resolveDrBotAgentLaunch() {
|
|
1497
|
+
const overrides = this._testDrMcpOptions ?? {};
|
|
1351
1498
|
try {
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
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.
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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() &&
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
1891
|
+
this.watcher = chokidar.watch(drRoot, {
|
|
1690
1892
|
persistent: !0,
|
|
1691
|
-
ignoreInitial: !0
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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 ${
|
|
1912
|
+
console.warn(`[Watcher] Could not watch ${drRoot}: ${getErrorMessage(err)}`);
|
|
1702
1913
|
});
|
|
1703
1914
|
} catch (err) {
|
|
1704
|
-
console.warn(`[Watcher] Could not watch ${
|
|
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
|
-
*
|
|
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.
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
this.
|
|
2473
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 {
|