@dev.fast/whiteboard 0.2.5-preview.20261013.113 → 0.2.5-preview.20261015.115
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/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
- package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
- package/dist/agent-cli-CRPPmS3l.js +2 -0
- package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
- package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
- package/dist/background-server-Cw7CcfaZ.js +156 -0
- package/dist/background-server-D2Rrr_e2.js +2 -0
- package/dist/build-info.json +1 -1
- package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
- package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
- package/dist/cli.js +3 -2
- package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
- package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
- package/dist/headless-host-Cp37C-v0.js +2 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
- package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
- package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
- package/dist/profile-BSOkA6A-.js +2 -0
- package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
- package/dist/remote-attach-BAt2eZx3.js +137 -0
- package/dist/remote-extensions-2Cxf3hMo.js +2 -0
- package/dist/remote-extensions-BiNx6NQK.js +323 -0
- package/dist/remote-language-server-DQgWUNeV.js +252 -0
- package/dist/remote-uninstall-DLtDG0o7.js +153 -0
- package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
- package/dist/review-app-launcher-D7I4DGoH.js +222 -0
- package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
- package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
- package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
- package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
- package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
- package/dist/server/desktop-host.js +1663 -266
- package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
- package/dist/sharing/index.d.ts +88 -32
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
- package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
- package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
- package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
- package/instructions/authoring.md +16 -12
- package/instructions/file-lenses.md +4 -2
- package/package.json +9 -14
- package/src/agent-connect.ts +507 -0
- package/src/agent-selection.ts +4 -0
- package/src/ask/agents.ts +59 -13
- package/src/ask/pi-mcp.ts +7 -3
- package/src/ask/thread-state.ts +9 -4
- package/src/ask/thread.ts +88 -50
- package/src/ask/threads.ts +53 -7
- package/src/cli-install.ts +3 -1
- package/src/cli-runner.ts +472 -81
- package/src/cli.ts +1 -1
- package/src/connect-agents.ts +285 -0
- package/src/connect-prompts.ts +60 -6
- package/src/desktop-discovery.ts +24 -0
- package/src/pinned-download.ts +118 -0
- package/src/remote-attach.ts +130 -0
- package/src/remote-extensions.ts +359 -0
- package/src/remote-language-server.ts +428 -0
- package/src/remote-toolchains.ts +107 -0
- package/src/remote-uninstall.ts +280 -0
- package/src/review-api/README.md +1 -3
- package/src/review-api/agent-cli.ts +4 -4
- package/src/review-api/agent-client.ts +23 -0
- package/src/review-api/ask-history.ts +31 -16
- package/src/review-api/authoring-tools.ts +1 -1
- package/src/review-api/blocks/index.ts +2 -4
- package/src/review-api/document.ts +1 -4
- package/src/review-api/http.ts +265 -237
- package/src/review-api/local-data.ts +18 -3
- package/src/review-api/onboarding.ts +478 -0
- package/src/review-api/origin.ts +7 -0
- package/src/review-api/recent-folders.ts +173 -0
- package/src/review-api/store.ts +56 -10
- package/src/review-api/workspaces.ts +56 -11
- package/src/review-app-launcher.ts +61 -3
- package/src/review-stack.ts +162 -36
- package/src/server/background-server.ts +250 -0
- package/src/server/desktop-host-shutdown.ts +24 -0
- package/src/server/desktop-host.ts +39 -24
- package/src/server/desktop-server.ts +68 -118
- package/src/server/global-verb-relay.ts +16 -0
- package/src/server/headless-host.ts +128 -3
- package/src/server/hono-http.ts +6 -1
- package/src/server/legacy-tutorial-cleanup.ts +38 -0
- package/src/server/review-gateway-hosts.ts +379 -0
- package/src/server/review-gateway-list.ts +109 -0
- package/src/server/review-gateway-memory.ts +130 -0
- package/src/server/review-gateway-pushes.ts +142 -0
- package/src/server/review-gateway-streams.ts +415 -0
- package/src/server/review-gateway-transport.ts +204 -0
- package/src/server/review-gateway.ts +433 -0
- package/src/server/review-server-core.ts +36 -6
- package/src/server-discovery.ts +11 -0
- package/src/sharing/import.ts +1 -0
- package/src/ui-telemetry-events.ts +5 -1
- package/src/vsix.ts +122 -0
- package/dist/agent-cli-BR_Jw5BL.js +0 -2
- package/dist/headless-host-BLLJ7Dyu.js +0 -95
- package/dist/profile-aMSw1oCM.js +0 -2
- package/dist/server-discovery-ZVi8Ay8J.js +0 -66
- package/src/fixtures/blocks/tutorial.json +0 -14
- package/src/review-api/blocks/tutorial.ts +0 -23
- package/src/server/tutorial-service.ts +0 -217
- package/tutorial/document.json +0 -216
- package/tutorial/lenses.json +0 -10
- package/tutorial/runtime-manifest.json +0 -11
- package/tutorial/sample-service/package.json +0 -9
- package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
- package/tutorial/sample-service/src/app.ts +0 -26
- package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
- package/tutorial/sample-service/src/database/schema.ts +0 -9
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
- package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
- package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
- package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
- package/tutorial/sample-service/src/orders/order-service.ts +0 -35
- package/tutorial/sample-service/src/orders/order.ts +0 -43
- package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
- package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
- package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
- package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
- package/tutorial/sample-service/tsconfig.json +0 -12
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
REVIEW_CLI_INSTALL_TARGET_LABELS,
|
|
8
|
+
REVIEW_REMOTE_AGENT_IDS,
|
|
9
|
+
type ReviewRemoteAgentId,
|
|
10
|
+
} from "@dev.fast/review-protocol";
|
|
11
|
+
import {
|
|
12
|
+
type AgentTraceHookAgent,
|
|
13
|
+
agentTraceHomeDirectory,
|
|
14
|
+
} from "@dev.fast/trace-core";
|
|
15
|
+
import { parse as parseToml } from "smol-toml";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
import { CONNECT_COMMANDS, type ConnectCommand } from "./connect-prompts";
|
|
19
|
+
import { isDirectory } from "./fs-utils";
|
|
20
|
+
|
|
21
|
+
export const AGENT_CONNECT_TARGETS: readonly (ReviewRemoteAgentId &
|
|
22
|
+
AgentTraceHookAgent)[] = REVIEW_REMOTE_AGENT_IDS;
|
|
23
|
+
|
|
24
|
+
export type AgentConnectTarget = ReviewRemoteAgentId;
|
|
25
|
+
|
|
26
|
+
export interface DetectedAgent {
|
|
27
|
+
id: AgentConnectTarget;
|
|
28
|
+
name: string;
|
|
29
|
+
connected: boolean;
|
|
30
|
+
manual?: true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ConnectedAgent {
|
|
34
|
+
id: AgentConnectTarget;
|
|
35
|
+
name: string;
|
|
36
|
+
connected: boolean;
|
|
37
|
+
output: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const OUTPUT_LIMIT = 4000;
|
|
41
|
+
|
|
42
|
+
export const AGENT_CONNECT_TIMEOUT_MS = 120_000;
|
|
43
|
+
|
|
44
|
+
interface Scope {
|
|
45
|
+
homeDir: string;
|
|
46
|
+
env: NodeJS.ProcessEnv;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function detectAgents(scope: Scope): Promise<DetectedAgent[]> {
|
|
50
|
+
const found: DetectedAgent[] = [];
|
|
51
|
+
|
|
52
|
+
for (const id of AGENT_CONNECT_TARGETS) {
|
|
53
|
+
if (
|
|
54
|
+
!(await isDirectory(
|
|
55
|
+
agentTraceHomeDirectory(id, scope.homeDir, scope.env),
|
|
56
|
+
))
|
|
57
|
+
)
|
|
58
|
+
continue;
|
|
59
|
+
|
|
60
|
+
const manual = !(await onPath(CONNECT_COMMANDS[id][0].argv[0], scope.env));
|
|
61
|
+
|
|
62
|
+
found.push({
|
|
63
|
+
id,
|
|
64
|
+
name: REVIEW_CLI_INSTALL_TARGET_LABELS[id],
|
|
65
|
+
connected: await connected(id, scope),
|
|
66
|
+
...(manual && { manual: true as const }),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function connectAgents(
|
|
74
|
+
input: Scope & {
|
|
75
|
+
agents: readonly AgentConnectTarget[];
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
},
|
|
78
|
+
): Promise<ConnectedAgent[]> {
|
|
79
|
+
const results: ConnectedAgent[] = [];
|
|
80
|
+
|
|
81
|
+
for (const id of input.agents) {
|
|
82
|
+
const deadline = Date.now() + (input.timeoutMs ?? AGENT_CONNECT_TIMEOUT_MS);
|
|
83
|
+
let output = "";
|
|
84
|
+
|
|
85
|
+
const commands: readonly ConnectCommand[] = CONNECT_COMMANDS[id];
|
|
86
|
+
|
|
87
|
+
for (const { argv, ifAny } of commands) {
|
|
88
|
+
const left = deadline - Date.now();
|
|
89
|
+
|
|
90
|
+
if (left <= 0) break;
|
|
91
|
+
const step = await run(argv, input.env, left);
|
|
92
|
+
|
|
93
|
+
output += `$ ${argv.join(" ")}\n${step.output}`;
|
|
94
|
+
|
|
95
|
+
if (!step.ok && !ifAny) {
|
|
96
|
+
output += `Stopped: ${argv.join(" ")} failed.\n`;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
results.push({
|
|
102
|
+
id,
|
|
103
|
+
name: REVIEW_CLI_INSTALL_TARGET_LABELS[id],
|
|
104
|
+
connected: await connected(id, input),
|
|
105
|
+
output: output.slice(-OUTPUT_LIMIT),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const WHITEBOARD_PLUGIN = "whiteboard@devfast";
|
|
113
|
+
|
|
114
|
+
const ClaudePluginsSchema = z.object({
|
|
115
|
+
plugins: z.record(z.string(), z.unknown()),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const CodexConfigSchema = z.object({
|
|
119
|
+
plugins: z.record(z.string(), z.unknown()).optional(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const CodexPluginSchema = z.object({ enabled: z.boolean().optional() });
|
|
123
|
+
|
|
124
|
+
const PiSettingsSchema = z.object({
|
|
125
|
+
packages: z.array(
|
|
126
|
+
z.union([
|
|
127
|
+
z.string(),
|
|
128
|
+
z.object({ source: z.string() }).transform((entry) => entry.source),
|
|
129
|
+
]),
|
|
130
|
+
),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const PI_PACKAGE = /^npm:@dev\.fast\/pi-whiteboard(@.*)?$/;
|
|
134
|
+
|
|
135
|
+
const OPENCODE_PLUGIN =
|
|
136
|
+
/"plugin"\s*:\s*\[[^\]]*"@dev\.fast\/opencode-whiteboard(@[^"]*)?"/;
|
|
137
|
+
|
|
138
|
+
const OpenCodeConfigSchema = z.object({
|
|
139
|
+
mcp: z.object({
|
|
140
|
+
whiteboard: z.unknown().optional(),
|
|
141
|
+
servers: z.object({ whiteboard: z.unknown().optional() }).optional(),
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/** OpenCode's config is JSONC: drop comments and trailing commas, keep strings. */
|
|
146
|
+
const JSONC_EXTRAS =
|
|
147
|
+
/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\/|,(?=\s*[}\]])/g;
|
|
148
|
+
|
|
149
|
+
function openCodeConnected(text: string): boolean {
|
|
150
|
+
if (OPENCODE_PLUGIN.test(text)) return true;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const { mcp } = OpenCodeConfigSchema.parse(
|
|
154
|
+
JSON.parse(text.replace(JSONC_EXTRAS, "$1")),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
mcp.whiteboard !== undefined || mcp.servers?.whiteboard !== undefined
|
|
159
|
+
);
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function connected(
|
|
166
|
+
id: AgentConnectTarget,
|
|
167
|
+
scope: Scope,
|
|
168
|
+
): Promise<boolean> {
|
|
169
|
+
const home = agentTraceHomeDirectory(id, scope.homeDir, scope.env);
|
|
170
|
+
|
|
171
|
+
const read = (...parts: string[]) =>
|
|
172
|
+
readFile(path.join(home, ...parts), "utf8");
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
switch (id) {
|
|
176
|
+
case "claude": {
|
|
177
|
+
const installs = ClaudePluginsSchema.parse(
|
|
178
|
+
JSON.parse(await read("plugins", "installed_plugins.json")),
|
|
179
|
+
).plugins[WHITEBOARD_PLUGIN];
|
|
180
|
+
|
|
181
|
+
return Array.isArray(installs)
|
|
182
|
+
? installs.length > 0
|
|
183
|
+
: Boolean(installs);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "codex": {
|
|
187
|
+
const entry = CodexConfigSchema.parse(
|
|
188
|
+
parseToml(await read("config.toml")),
|
|
189
|
+
).plugins?.[WHITEBOARD_PLUGIN];
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
entry !== undefined &&
|
|
193
|
+
CodexPluginSchema.parse(entry).enabled !== false
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case "opencode": {
|
|
198
|
+
for (const name of ["opencode.json", "opencode.jsonc", "config.json"]) {
|
|
199
|
+
if (openCodeConnected(await read(name).catch(() => ""))) return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case "pi":
|
|
206
|
+
return PiSettingsSchema.parse(
|
|
207
|
+
JSON.parse(await read("agent", "settings.json")),
|
|
208
|
+
).packages.some((entry) => PI_PACKAGE.test(entry));
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function onPath(
|
|
216
|
+
command: string,
|
|
217
|
+
env: NodeJS.ProcessEnv,
|
|
218
|
+
): Promise<string | undefined> {
|
|
219
|
+
for (const directory of (env.PATH ?? "").split(path.delimiter)) {
|
|
220
|
+
if (!path.isAbsolute(directory)) continue;
|
|
221
|
+
const candidate = path.join(directory, command);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await access(candidate, constants.X_OK);
|
|
225
|
+
|
|
226
|
+
return candidate;
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function run(
|
|
234
|
+
argv: readonly string[],
|
|
235
|
+
env: NodeJS.ProcessEnv,
|
|
236
|
+
timeoutMs: number,
|
|
237
|
+
): Promise<{ output: string; ok: boolean }> {
|
|
238
|
+
const [command = "", ...args] = argv;
|
|
239
|
+
const file = await onPath(command, env);
|
|
240
|
+
|
|
241
|
+
if (!file)
|
|
242
|
+
return { output: `${command} was not found on PATH.\n`, ok: false };
|
|
243
|
+
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
let output = "";
|
|
246
|
+
|
|
247
|
+
const keep = (chunk: Buffer) => {
|
|
248
|
+
output = (output + chunk.toString("utf8")).slice(-OUTPUT_LIMIT);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const child = spawn(file, args, {
|
|
252
|
+
env,
|
|
253
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
254
|
+
detached: true,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const timer = setTimeout(() => {
|
|
258
|
+
output += `\n${command} did not finish within ${Math.round(timeoutMs / 1000)} seconds.\n`;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
262
|
+
} catch {
|
|
263
|
+
child.kill("SIGKILL");
|
|
264
|
+
}
|
|
265
|
+
}, timeoutMs);
|
|
266
|
+
|
|
267
|
+
child.stdout.on("data", keep);
|
|
268
|
+
child.stderr.on("data", keep);
|
|
269
|
+
child.once("error", (error) => {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
resolve({ output: `${output}${error.message}\n`, ok: false });
|
|
272
|
+
});
|
|
273
|
+
child.once("close", (code) => {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
resolve(
|
|
276
|
+
code === 0
|
|
277
|
+
? { output, ok: true }
|
|
278
|
+
: {
|
|
279
|
+
output: `${output}${output.endsWith("\n") || !output ? "" : "\n"}(exit ${code ?? "signal"})\n`,
|
|
280
|
+
ok: false,
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
package/src/connect-prompts.ts
CHANGED
|
@@ -89,6 +89,64 @@ export const CLAUDE_WINDOWS_MCP_ADD = `claude mcp add -s user whiteboard -- ${WI
|
|
|
89
89
|
/** Copilot CLI loads the Claude plugin, so on Windows it too registers the server directly. */
|
|
90
90
|
export const COPILOT_WINDOWS_MCP_ADD = `copilot mcp add whiteboard -- ${WINDOWS_MCP_LAUNCH.command} ${WINDOWS_MCP_LAUNCH.args.join(" ")}`;
|
|
91
91
|
|
|
92
|
+
export interface ConnectCommand {
|
|
93
|
+
argv: readonly string[];
|
|
94
|
+
ifAny?: true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const CONNECT_COMMANDS = {
|
|
98
|
+
claude: [
|
|
99
|
+
{
|
|
100
|
+
argv: ["claude", "plugin", "marketplace", "add", "devdotfast/whiteboard"],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
argv: [
|
|
104
|
+
"claude",
|
|
105
|
+
"plugin",
|
|
106
|
+
"install",
|
|
107
|
+
"whiteboard@devfast",
|
|
108
|
+
"--scope",
|
|
109
|
+
"user",
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
argv: ["claude", "mcp", "remove", "-s", "user", "whiteboard"],
|
|
114
|
+
ifAny: true,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
codex: [
|
|
118
|
+
{
|
|
119
|
+
argv: ["codex", "plugin", "marketplace", "add", "devdotfast/whiteboard"],
|
|
120
|
+
},
|
|
121
|
+
{ argv: ["codex", "plugin", "add", "whiteboard@devfast"] },
|
|
122
|
+
{ argv: ["codex", "mcp", "remove", "whiteboard"], ifAny: true },
|
|
123
|
+
],
|
|
124
|
+
opencode: [
|
|
125
|
+
{
|
|
126
|
+
argv: [
|
|
127
|
+
"opencode",
|
|
128
|
+
"mcp",
|
|
129
|
+
"add",
|
|
130
|
+
"--global",
|
|
131
|
+
"whiteboard",
|
|
132
|
+
"--",
|
|
133
|
+
REVIEW_MCP_LAUNCH.command,
|
|
134
|
+
...REVIEW_MCP_LAUNCH.args,
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
pi: [{ argv: ["pi", "install", PI_WHITEBOARD_PACKAGE] }],
|
|
139
|
+
} as const satisfies Record<string, readonly ConnectCommand[]>;
|
|
140
|
+
|
|
141
|
+
function runBlock(commands: readonly ConnectCommand[]): string {
|
|
142
|
+
const lines = commands.map(
|
|
143
|
+
({ argv, ifAny }) =>
|
|
144
|
+
`${argv.join(" ")}${ifAny ? " # old manual registration, if any" : ""}`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return `Run:\n\n\`\`\`sh\n${lines.join("\n")}\n\`\`\``;
|
|
148
|
+
}
|
|
149
|
+
|
|
92
150
|
function pluginSteps(
|
|
93
151
|
target: Exclude<InstallTarget, "cursor">,
|
|
94
152
|
platform: NodeJS.Platform,
|
|
@@ -100,13 +158,9 @@ function pluginSteps(
|
|
|
100
158
|
`Run:\n\n\`\`\`sh\nclaude plugin uninstall whiteboard@devfast # its launch cannot start on Windows, if installed\nclaude mcp remove -s user whiteboard # old registration, if any\n${CLAUDE_WINDOWS_MCP_ADD}\n\`\`\``,
|
|
101
159
|
];
|
|
102
160
|
|
|
103
|
-
return [
|
|
104
|
-
"Run:\n\n```sh\nclaude plugin marketplace add devdotfast/whiteboard\nclaude plugin install whiteboard@devfast --scope user\nclaude mcp remove -s user whiteboard # old manual registration, if any\n```",
|
|
105
|
-
];
|
|
161
|
+
return [runBlock(CONNECT_COMMANDS.claude)];
|
|
106
162
|
case "codex":
|
|
107
|
-
return [
|
|
108
|
-
"Run:\n\n```sh\ncodex plugin marketplace add devdotfast/whiteboard\ncodex plugin add whiteboard@devfast\ncodex mcp remove whiteboard # old manual registration, if any\n```",
|
|
109
|
-
];
|
|
163
|
+
return [runBlock(CONNECT_COMMANDS.codex)];
|
|
110
164
|
case "opencode": {
|
|
111
165
|
const launch = reviewMcpLaunch(true, platform);
|
|
112
166
|
|
package/src/desktop-discovery.ts
CHANGED
|
@@ -11,8 +11,10 @@ import {
|
|
|
11
11
|
parseJsonText,
|
|
12
12
|
parseReviewDesktopDiscovery,
|
|
13
13
|
} from "@dev.fast/review-protocol";
|
|
14
|
+
import { processIsAlive } from "@dev.fast/trace-core";
|
|
14
15
|
|
|
15
16
|
import {
|
|
17
|
+
DEV_REVIEW_HOME_ENV,
|
|
16
18
|
reviewDefaultInstancePath,
|
|
17
19
|
reviewDevInstanceKey,
|
|
18
20
|
reviewInstancesDir,
|
|
@@ -183,6 +185,28 @@ export interface ReviewInstanceDependencies {
|
|
|
183
185
|
warn?: (message: string) => void;
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
/** Any live Desktop holds the store, attached window or not. Only a gone
|
|
189
|
+
* process counts as stopped: a paused or busy one may not answer /health. */
|
|
190
|
+
export async function desktopUsesHome(
|
|
191
|
+
home: string,
|
|
192
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
193
|
+
) {
|
|
194
|
+
const desktops = await readReviewInstances({
|
|
195
|
+
env: { ...env, [DEV_REVIEW_HOME_ENV]: home },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const [problem] = desktops.broken.values();
|
|
199
|
+
|
|
200
|
+
if (problem)
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Cannot tell whether a Whiteboard Desktop is using ${home}: ${problem.message}`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return desktops.instances.some(({ discovery }) =>
|
|
206
|
+
[discovery.appPid, discovery.serverPid].some((pid) => processIsAlive(pid)),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
186
210
|
/** Records by key, plus the files that could not be read, by key. */
|
|
187
211
|
export async function readReviewInstances(
|
|
188
212
|
dependencies: ReviewInstanceDependencies,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Plain Node and zod only: apps/review-desktop/scripts imports this file directly.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import type { ReadableStream as WebReadableStream } from "node:stream/web";
|
|
9
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
13
|
+
|
|
14
|
+
interface DownloadOptions {
|
|
15
|
+
size?: number;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Streams `url` into `file` through a temporary file and returns its sha256. */
|
|
21
|
+
export async function downloadFile(
|
|
22
|
+
url: string,
|
|
23
|
+
file: string,
|
|
24
|
+
{ size, timeoutMs = DOWNLOAD_TIMEOUT_MS, signal }: DownloadOptions = {},
|
|
25
|
+
) {
|
|
26
|
+
const { host } = new URL(url);
|
|
27
|
+
const partial = `${file}.${process.pid}.part`;
|
|
28
|
+
let response: Response;
|
|
29
|
+
|
|
30
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
response = await fetch(url, {
|
|
34
|
+
signal: AbortSignal.any([
|
|
35
|
+
AbortSignal.timeout(timeoutMs),
|
|
36
|
+
...(signal ? [signal] : []),
|
|
37
|
+
]),
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Network error reaching ${host}: ${error instanceof Error ? networkCause(error) : String(error)}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!response.ok || !response.body)
|
|
46
|
+
throw new Error(`${host} answered ${response.status} for ${url}`);
|
|
47
|
+
|
|
48
|
+
const hash = createHash("sha256");
|
|
49
|
+
let received = 0;
|
|
50
|
+
|
|
51
|
+
const tooLarge = new Error(`${url} is larger than its pinned ${size} bytes`);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await pipeline(
|
|
55
|
+
// SAFETY: Node's fetch body is its own web stream; the DOM type only
|
|
56
|
+
// names the same object.
|
|
57
|
+
Readable.fromWeb(response.body as WebReadableStream),
|
|
58
|
+
async function* (chunks: AsyncIterable<Buffer>) {
|
|
59
|
+
for await (const chunk of chunks) {
|
|
60
|
+
received += chunk.length;
|
|
61
|
+
|
|
62
|
+
if (size !== undefined && received > size) throw tooLarge;
|
|
63
|
+
|
|
64
|
+
hash.update(chunk);
|
|
65
|
+
yield chunk;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
createWriteStream(partial),
|
|
69
|
+
);
|
|
70
|
+
await rename(partial, file);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
await rm(partial, { force: true });
|
|
73
|
+
|
|
74
|
+
if (error === tooLarge) throw error;
|
|
75
|
+
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Network error downloading from ${host}: ${error instanceof Error ? networkCause(error) : String(error)}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return hash.digest("hex");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Leaves a matching `file` in place; otherwise downloads it and checks the pin. */
|
|
85
|
+
export async function downloadPinned(
|
|
86
|
+
pin: { url: string; sha256: string; size?: number },
|
|
87
|
+
file: string,
|
|
88
|
+
options: Omit<DownloadOptions, "size"> = {},
|
|
89
|
+
) {
|
|
90
|
+
if ((await sha256File(file).catch(() => undefined)) === pin.sha256) return;
|
|
91
|
+
|
|
92
|
+
const actual = await downloadFile(pin.url, file, {
|
|
93
|
+
...options,
|
|
94
|
+
size: pin.size,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (actual !== pin.sha256) {
|
|
98
|
+
await rm(file, { force: true });
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Checksum mismatch for ${pin.url}: expected ${pin.sha256}, got ${actual}. The download was deleted.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function sha256File(file: string) {
|
|
106
|
+
const hash = createHash("sha256");
|
|
107
|
+
|
|
108
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
109
|
+
|
|
110
|
+
return hash.digest("hex");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const networkCauseSchema = z.object({ code: z.string() });
|
|
114
|
+
|
|
115
|
+
const networkCause = (error: Error) =>
|
|
116
|
+
error.name === "TimeoutError"
|
|
117
|
+
? "timed out"
|
|
118
|
+
: (networkCauseSchema.safeParse(error.cause).data?.code ?? error.message);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { gt as greaterVersion, valid as validVersion } from "semver";
|
|
2
|
+
|
|
3
|
+
import { readReviewPackageVersion } from "./package-paths";
|
|
4
|
+
import {
|
|
5
|
+
type EnsureRemoteLanguageServerInput,
|
|
6
|
+
ensureRemoteLanguageServer,
|
|
7
|
+
} from "./remote-language-server";
|
|
8
|
+
import { missingToolchains } from "./remote-toolchains";
|
|
9
|
+
import { readReviewServerHealth, serverNotReady } from "./server-discovery";
|
|
10
|
+
import {
|
|
11
|
+
type EnsureBackgroundServerInput,
|
|
12
|
+
ensureBackgroundServer,
|
|
13
|
+
recordedBackgroundServer,
|
|
14
|
+
stopBackgroundServer,
|
|
15
|
+
} from "./server/background-server";
|
|
16
|
+
|
|
17
|
+
export async function remoteAttach(input: {
|
|
18
|
+
stateDir: string;
|
|
19
|
+
env: NodeJS.ProcessEnv;
|
|
20
|
+
packageRoot?: string;
|
|
21
|
+
cli?: EnsureBackgroundServerInput["cli"];
|
|
22
|
+
groups?: string[];
|
|
23
|
+
ensureExtensions?: EnsureRemoteLanguageServerInput["ensure"];
|
|
24
|
+
installTimeoutMs?: number;
|
|
25
|
+
replace?: boolean;
|
|
26
|
+
version?: string;
|
|
27
|
+
}) {
|
|
28
|
+
const extensions = new AbortController();
|
|
29
|
+
|
|
30
|
+
const language = ensureRemoteLanguageServer({
|
|
31
|
+
env: input.env,
|
|
32
|
+
packageRoot: input.packageRoot,
|
|
33
|
+
groups: input.groups,
|
|
34
|
+
signal: extensions.signal,
|
|
35
|
+
ensure: input.ensureExtensions,
|
|
36
|
+
installTimeoutMs: input.installTimeoutMs,
|
|
37
|
+
cli: input.cli,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const toolchains = missingToolchains(input.groups ?? [], input.env);
|
|
41
|
+
|
|
42
|
+
let server: Awaited<ReturnType<typeof ensureBackgroundServer>>;
|
|
43
|
+
let previousVersion: string | undefined;
|
|
44
|
+
|
|
45
|
+
let incompatibleRunning:
|
|
46
|
+
| { version: string; pid: number; startedBy: "user" | "cli" | "desktop" }
|
|
47
|
+
| undefined;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const version = input.version ?? readReviewPackageVersion(import.meta.url);
|
|
51
|
+
|
|
52
|
+
const other = input.replace
|
|
53
|
+
? await otherVersionRunning(input.stateDir, version)
|
|
54
|
+
: undefined;
|
|
55
|
+
|
|
56
|
+
if (other && (other.startedBy === "user" || newer(other.version, version)))
|
|
57
|
+
incompatibleRunning = other;
|
|
58
|
+
else if (other) {
|
|
59
|
+
await stopBackgroundServer({ serverPid: other.pid });
|
|
60
|
+
previousVersion = other.version;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
server = await ensureBackgroundServer({
|
|
64
|
+
stateDir: input.stateDir,
|
|
65
|
+
env: input.env,
|
|
66
|
+
startedBy: "desktop",
|
|
67
|
+
cli: input.cli,
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
extensions.abort();
|
|
71
|
+
await Promise.all([language, toolchains]);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { discovery, started } = server;
|
|
76
|
+
|
|
77
|
+
const {
|
|
78
|
+
languageServer,
|
|
79
|
+
languageServerDetail,
|
|
80
|
+
languageServerPending,
|
|
81
|
+
languageGroups,
|
|
82
|
+
} = await language;
|
|
83
|
+
|
|
84
|
+
const missing = await toolchains;
|
|
85
|
+
|
|
86
|
+
const health = await readReviewServerHealth(discovery);
|
|
87
|
+
|
|
88
|
+
if (!health) throw serverNotReady(input.stateDir);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
event: "remote.attach" as const,
|
|
92
|
+
version: health.version ?? null,
|
|
93
|
+
commit: health.commit ?? null,
|
|
94
|
+
serverId: health.serverId ?? null,
|
|
95
|
+
url: discovery.url,
|
|
96
|
+
token: discovery.token,
|
|
97
|
+
startedServer: started,
|
|
98
|
+
languageServer,
|
|
99
|
+
...(languageServerDetail !== undefined && { languageServerDetail }),
|
|
100
|
+
...(languageServerPending && { languageServerPending }),
|
|
101
|
+
languageGroups: languageGroups.map(({ group, installed, detail }) => {
|
|
102
|
+
const details = [detail, missing.get(group)].filter(Boolean).join("; ");
|
|
103
|
+
|
|
104
|
+
return { group, installed, ...(details && { detail: details }) };
|
|
105
|
+
}),
|
|
106
|
+
...(previousVersion !== undefined && { replaced: true, previousVersion }),
|
|
107
|
+
...(incompatibleRunning && { incompatibleRunning }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const newer = (running: string, own: string) =>
|
|
112
|
+
validVersion(running) !== null &&
|
|
113
|
+
validVersion(own) !== null &&
|
|
114
|
+
greaterVersion(running, own);
|
|
115
|
+
|
|
116
|
+
async function otherVersionRunning(stateDir: string, version: string) {
|
|
117
|
+
const recorded = await recordedBackgroundServer(stateDir);
|
|
118
|
+
|
|
119
|
+
if (!recorded) return undefined;
|
|
120
|
+
const { discovery, health } = recorded;
|
|
121
|
+
const running = health.version ?? "unknown";
|
|
122
|
+
|
|
123
|
+
return running === version
|
|
124
|
+
? undefined
|
|
125
|
+
: {
|
|
126
|
+
version: running,
|
|
127
|
+
pid: discovery.serverPid,
|
|
128
|
+
startedBy: discovery.startedBy,
|
|
129
|
+
};
|
|
130
|
+
}
|