@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.
Files changed (127) hide show
  1. package/THIRD_PARTY_NOTICES.md +29 -0
  2. package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
  3. package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
  4. package/dist/agent-cli-CRPPmS3l.js +2 -0
  5. package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
  6. package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
  7. package/dist/background-server-Cw7CcfaZ.js +156 -0
  8. package/dist/background-server-D2Rrr_e2.js +2 -0
  9. package/dist/build-info.json +1 -1
  10. package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
  11. package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
  12. package/dist/cli.js +3 -2
  13. package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
  14. package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
  15. package/dist/headless-host-Cp37C-v0.js +2 -0
  16. package/dist/input-error-OLgB_h31.js +11 -0
  17. package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
  18. package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
  19. package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
  20. package/dist/profile-BSOkA6A-.js +2 -0
  21. package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
  22. package/dist/remote-attach-BAt2eZx3.js +137 -0
  23. package/dist/remote-extensions-2Cxf3hMo.js +2 -0
  24. package/dist/remote-extensions-BiNx6NQK.js +323 -0
  25. package/dist/remote-language-server-DQgWUNeV.js +252 -0
  26. package/dist/remote-uninstall-DLtDG0o7.js +153 -0
  27. package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
  28. package/dist/review-app-launcher-D7I4DGoH.js +222 -0
  29. package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
  30. package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
  31. package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
  32. package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
  33. package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
  34. package/dist/server/desktop-host.js +1663 -266
  35. package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
  36. package/dist/sharing/index.d.ts +88 -32
  37. package/dist/sharing/index.js +1 -1
  38. package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
  39. package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
  40. package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
  41. package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
  42. package/instructions/authoring.md +16 -12
  43. package/instructions/file-lenses.md +4 -2
  44. package/package.json +9 -14
  45. package/src/agent-connect.ts +507 -0
  46. package/src/agent-selection.ts +4 -0
  47. package/src/ask/agents.ts +59 -13
  48. package/src/ask/pi-mcp.ts +7 -3
  49. package/src/ask/thread-state.ts +9 -4
  50. package/src/ask/thread.ts +88 -50
  51. package/src/ask/threads.ts +53 -7
  52. package/src/cli-install.ts +3 -1
  53. package/src/cli-runner.ts +472 -81
  54. package/src/cli.ts +1 -1
  55. package/src/connect-agents.ts +285 -0
  56. package/src/connect-prompts.ts +60 -6
  57. package/src/desktop-discovery.ts +24 -0
  58. package/src/pinned-download.ts +118 -0
  59. package/src/remote-attach.ts +130 -0
  60. package/src/remote-extensions.ts +359 -0
  61. package/src/remote-language-server.ts +428 -0
  62. package/src/remote-toolchains.ts +107 -0
  63. package/src/remote-uninstall.ts +280 -0
  64. package/src/review-api/README.md +1 -3
  65. package/src/review-api/agent-cli.ts +4 -4
  66. package/src/review-api/agent-client.ts +23 -0
  67. package/src/review-api/ask-history.ts +31 -16
  68. package/src/review-api/authoring-tools.ts +1 -1
  69. package/src/review-api/blocks/index.ts +2 -4
  70. package/src/review-api/document.ts +1 -4
  71. package/src/review-api/http.ts +265 -237
  72. package/src/review-api/local-data.ts +18 -3
  73. package/src/review-api/onboarding.ts +478 -0
  74. package/src/review-api/origin.ts +7 -0
  75. package/src/review-api/recent-folders.ts +173 -0
  76. package/src/review-api/store.ts +56 -10
  77. package/src/review-api/workspaces.ts +56 -11
  78. package/src/review-app-launcher.ts +61 -3
  79. package/src/review-stack.ts +162 -36
  80. package/src/server/background-server.ts +250 -0
  81. package/src/server/desktop-host-shutdown.ts +24 -0
  82. package/src/server/desktop-host.ts +39 -24
  83. package/src/server/desktop-server.ts +68 -118
  84. package/src/server/global-verb-relay.ts +16 -0
  85. package/src/server/headless-host.ts +128 -3
  86. package/src/server/hono-http.ts +6 -1
  87. package/src/server/legacy-tutorial-cleanup.ts +38 -0
  88. package/src/server/review-gateway-hosts.ts +379 -0
  89. package/src/server/review-gateway-list.ts +109 -0
  90. package/src/server/review-gateway-memory.ts +130 -0
  91. package/src/server/review-gateway-pushes.ts +142 -0
  92. package/src/server/review-gateway-streams.ts +415 -0
  93. package/src/server/review-gateway-transport.ts +204 -0
  94. package/src/server/review-gateway.ts +433 -0
  95. package/src/server/review-server-core.ts +36 -6
  96. package/src/server-discovery.ts +11 -0
  97. package/src/sharing/import.ts +1 -0
  98. package/src/ui-telemetry-events.ts +5 -1
  99. package/src/vsix.ts +122 -0
  100. package/dist/agent-cli-BR_Jw5BL.js +0 -2
  101. package/dist/headless-host-BLLJ7Dyu.js +0 -95
  102. package/dist/profile-aMSw1oCM.js +0 -2
  103. package/dist/server-discovery-ZVi8Ay8J.js +0 -66
  104. package/src/fixtures/blocks/tutorial.json +0 -14
  105. package/src/review-api/blocks/tutorial.ts +0 -23
  106. package/src/server/tutorial-service.ts +0 -217
  107. package/tutorial/document.json +0 -216
  108. package/tutorial/lenses.json +0 -10
  109. package/tutorial/runtime-manifest.json +0 -11
  110. package/tutorial/sample-service/package.json +0 -9
  111. package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
  112. package/tutorial/sample-service/src/app.ts +0 -26
  113. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
  114. package/tutorial/sample-service/src/database/schema.ts +0 -9
  115. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
  116. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
  117. package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
  118. package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
  119. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
  120. package/tutorial/sample-service/src/orders/order-service.ts +0 -35
  121. package/tutorial/sample-service/src/orders/order.ts +0 -43
  122. package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
  123. package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
  124. package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
  125. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
  126. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
  127. package/tutorial/sample-service/tsconfig.json +0 -12
package/src/cli-runner.ts CHANGED
@@ -2,19 +2,22 @@ import { existsSync } from "node:fs";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
- import type { Writable } from "node:stream";
5
+ import { Writable } from "node:stream";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
+ import {
9
+ REVIEW_CLI_INSTALL_TARGET_LABELS,
10
+ REVIEW_REMOTE_ATTACH_BEGIN,
11
+ REVIEW_REMOTE_ATTACH_END,
12
+ } from "@dev.fast/review-protocol";
8
13
  import {
9
14
  StoreApiError,
10
- processIsAlive,
11
15
  readStoreAuth,
12
16
  withStoreAuthorization,
13
17
  } from "@dev.fast/trace-core";
14
18
  import {
15
19
  type CliInputStream,
16
20
  DEFAULT_STORE_ORIGIN,
17
- DEV_REVIEW_HOME_ENV,
18
21
  emitJsonEvent,
19
22
  humanStream,
20
23
  jsonRequestedInArgv,
@@ -42,10 +45,16 @@ import {
42
45
  windowsInstallerCommand,
43
46
  } from "./cli-install";
44
47
  import { cliRuntimeInfo, describeCliRuntime } from "./cli-runtime-info";
48
+ import {
49
+ AGENT_CONNECT_TARGETS,
50
+ type AgentConnectTarget,
51
+ connectAgents,
52
+ detectAgents,
53
+ } from "./connect-agents";
45
54
  import { connectPrompts } from "./connect-prompts";
46
55
  import {
47
56
  ReviewInstanceUnavailableError,
48
- readReviewInstances,
57
+ desktopUsesHome,
49
58
  selectReviewInstance,
50
59
  } from "./desktop-discovery";
51
60
  import {
@@ -86,6 +95,8 @@ import {
86
95
  type ReviewTelemetryErrorName,
87
96
  } from "./review-telemetry";
88
97
  import {
98
+ DESKTOP_TAKEOVER_SIGNAL,
99
+ type ReviewServerDiscovery,
89
100
  readReviewServerDiscovery,
90
101
  readReviewServerHealth,
91
102
  reviewServerIsHealthy,
@@ -265,6 +276,16 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
265
276
  ? { ...env, DEV_REVIEW_SERVER_DIR: path.resolve(cwd, stateDir) }
266
277
  : env;
267
278
 
279
+ const refuse = (message: string, json?: boolean) => {
280
+ if (json)
281
+ emitReviewEvent(input.stdout, {
282
+ event: "error",
283
+ error: { name: "Error", message },
284
+ });
285
+ else input.stderr.write(`${message}\n`);
286
+ state.exitCode = 1;
287
+ };
288
+
268
289
  const serverCommand = configureOutput(
269
290
  program
270
291
  .command("server")
@@ -291,6 +312,16 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
291
312
  "--software-maps",
292
313
  "allow the authoring skill to generate optional software maps",
293
314
  )
315
+ .option(
316
+ "--detach",
317
+ "start the server in the background, logging to review-server/server.log",
318
+ )
319
+ .addOption(
320
+ new Option("--started-by <who>")
321
+ .choices(["user", "cli", "desktop"])
322
+ .default("user")
323
+ .hideHelp(),
324
+ )
294
325
  // Batch authoring was removed; name that instead of "unknown option".
295
326
  .addOption(new Option("--authoring-mode <mode>").hideHelp()),
296
327
  "plain",
@@ -299,6 +330,8 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
299
330
  stateDir?: string;
300
331
  port: string;
301
332
  softwareMaps?: boolean;
333
+ detach?: boolean;
334
+ startedBy: "user" | "cli" | "desktop";
302
335
  authoringMode?: string;
303
336
  json?: boolean;
304
337
  }>();
@@ -314,17 +347,49 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
314
347
  "--port must be an integer between 0 and 65535.",
315
348
  );
316
349
  const stateDir = reviewServerStateDir(authoringEnv(options.stateDir));
350
+
351
+ if (options.detach) {
352
+ const { ensureBackgroundServer } =
353
+ await import("./server/background-server.js");
354
+
355
+ const { discovery, started } = await ensureBackgroundServer({
356
+ stateDir,
357
+ env,
358
+ args: [
359
+ "--port",
360
+ `${port}`,
361
+ ...(options.softwareMaps ? ["--software-maps"] : []),
362
+ ],
363
+ });
364
+
365
+ await writeServerStatus(discovery, stateDir, options.json, { started });
366
+
367
+ return;
368
+ }
369
+
317
370
  const controller = new AbortController();
318
371
  const stop = () => controller.abort();
372
+
373
+ const takeover = () => {
374
+ input.stderr.write(
375
+ `Whiteboard Desktop took over ${stateDir}; stopping this server.\n`,
376
+ );
377
+ stop();
378
+ };
379
+
319
380
  process.once("SIGINT", stop);
320
381
  process.once("SIGTERM", stop);
382
+ process.once(DESKTOP_TAKEOVER_SIGNAL, takeover);
383
+
384
+ const { HeadlessServerBusyError, runHeadlessServer } =
385
+ await import("./server/headless-host.js");
321
386
 
322
387
  try {
323
- const { runHeadlessServer } = await import("./server/headless-host.js");
324
388
  await runHeadlessServer({
325
389
  stateDir,
326
390
  port,
327
391
  softwareMapEnabled: options.softwareMaps,
392
+ startedBy: options.startedBy,
328
393
  signal: controller.signal,
329
394
  telemetry,
330
395
  onReady: ({ url, serverPid }) => {
@@ -335,9 +400,13 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
335
400
  );
336
401
  },
337
402
  });
403
+ } catch (error) {
404
+ if (!(error instanceof HeadlessServerBusyError)) throw error;
405
+ refuse(error.message, options.json);
338
406
  } finally {
339
407
  process.off("SIGINT", stop);
340
408
  process.off("SIGTERM", stop);
409
+ process.off(DESKTOP_TAKEOVER_SIGNAL, takeover);
341
410
  }
342
411
  });
343
412
 
@@ -358,15 +427,64 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
358
427
 
359
428
  const stateDir = reviewServerStateDir(authoringEnv(options.stateDir));
360
429
  const discovery = await readReviewServerDiscovery(stateDir);
361
- const health = discovery && (await readReviewServerHealth(discovery));
362
430
 
363
- if (!discovery || !health) throw serverNotReady(stateDir);
364
- const { url, serverPid } = discovery;
365
- const { version, serverId } = health;
431
+ if (!discovery) throw serverNotReady(stateDir);
432
+ await writeServerStatus(discovery, stateDir, options.json);
433
+ });
434
+
435
+ configureJsonOutput(
436
+ serverCommand
437
+ .command("stop")
438
+ .description(
439
+ "Stop a background server started by --detach, the CLI, or Desktop",
440
+ )
441
+ .option(
442
+ "--state-dir <path>",
443
+ "directory selected when starting the server",
444
+ ),
445
+ "plain",
446
+ ).action(async (_options, command: Command) => {
447
+ const options = command.optsWithGlobals<{
448
+ stateDir?: string;
449
+ json?: boolean;
450
+ }>();
451
+
452
+ const stateDir = reviewServerStateDir(authoringEnv(options.stateDir));
453
+
454
+ const { headlessServerOwner, stopBackgroundServer } =
455
+ await import("./server/background-server.js");
456
+
457
+ const owner = await headlessServerOwner(stateDir);
458
+ const discovery = await readReviewServerDiscovery(stateDir);
459
+
460
+ if (owner === undefined) {
461
+ input.stdout.write(
462
+ options.json
463
+ ? `${JSON.stringify({ event: "server.stop", stopped: false, stateDir })}\n`
464
+ : `No Whiteboard server is running in ${stateDir}.\n`,
465
+ );
466
+
467
+ return;
468
+ }
469
+
470
+ const serverPid = owner;
471
+
472
+ if (discovery?.serverPid !== owner)
473
+ throw new Error(
474
+ `Process ${owner} holds the Whiteboard server's lock in ${stateDir} but has published no server; it may be starting. Try again, or end that process.`,
475
+ );
476
+
477
+ if (discovery.startedBy === "user")
478
+ throw new Error(
479
+ `The Whiteboard server in ${stateDir} (process ${serverPid}) runs in the foreground of \`whiteboard server start\`. Stop it there with Ctrl-C.`,
480
+ );
481
+
482
+ await stopBackgroundServer(discovery);
483
+
366
484
  input.stdout.write(
367
485
  options.json
368
- ? `${JSON.stringify({ event: "server.status", ready: true, url, serverPid, stateDir, version, serverId })}\n`
369
- : `Whiteboard server ready at ${url}\nSaved reviews: ${stateDir}\n`,
486
+ ? `${JSON.stringify({ event: "server.stop", stopped: true, serverPid, stateDir })}\n`
487
+ : `Stopped the Whiteboard server (process ${serverPid}).\n`,
370
488
  );
371
489
  });
372
490
 
@@ -394,32 +512,10 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
394
512
  `No saved reviews in ${stateDir}, so there is no server id to reset. Check --state-dir.`,
395
513
  );
396
514
 
397
- // Any live Desktop holds the store, attached window or not. Only a gone
398
- // process counts as stopped: a paused or busy one may not answer /health.
399
- const desktops = await readReviewInstances({
400
- env: { ...env, [DEV_REVIEW_HOME_ENV]: stateDir },
401
- });
402
-
403
- const [problem] = desktops.broken.values();
404
-
405
- if (problem)
406
- throw new Error(
407
- `Cannot tell whether a Whiteboard Desktop is using ${stateDir}: ${problem.message}`,
408
- );
409
-
410
- const inUse = () =>
411
- new Error(
412
- `A Whiteboard server is using ${stateDir}. Stop it first, then run whiteboard server reset-id again.`,
413
- );
515
+ const inUse = `A Whiteboard server is using ${stateDir}. Stop it first, then run whiteboard server reset-id again.`;
414
516
 
415
- if (
416
- desktops.instances.some(({ discovery }) =>
417
- [discovery.appPid, discovery.serverPid].some((pid) =>
418
- processIsAlive(pid),
419
- ),
420
- )
421
- )
422
- throw inUse();
517
+ if (await desktopUsesHome(stateDir, env))
518
+ return refuse(inUse, options.json);
423
519
 
424
520
  const [{ openReviewProfile }, { withHeadlessServerLock }] =
425
521
  await Promise.all([
@@ -445,7 +541,7 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
445
541
  },
446
542
  );
447
543
 
448
- if (!reset.acquired) throw inUse();
544
+ if (!reset.acquired) return refuse(inUse, options.json);
449
545
  const serverId = reset.result;
450
546
 
451
547
  input.stdout.write(
@@ -455,6 +551,239 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
455
551
  );
456
552
  });
457
553
 
554
+ const remote = configureOutput(
555
+ program
556
+ .command("remote")
557
+ .description("Serve reviews to Whiteboard Desktop over SSH"),
558
+ "plain",
559
+ );
560
+
561
+ configureJsonOutput(
562
+ remote
563
+ .command("attach")
564
+ .description(
565
+ "Start a background server if none is healthy and report how to reach it",
566
+ )
567
+ .option(
568
+ "--state-dir <path>",
569
+ "directory for saved reviews and server discovery",
570
+ )
571
+ .option(
572
+ "--groups <groups>",
573
+ "comma-separated optional extension groups the Desktop has enabled, such as go",
574
+ )
575
+ .option(
576
+ "--replace",
577
+ "stop a running server of another version that the CLI or Desktop started, and start this one",
578
+ ),
579
+ "plain",
580
+ ).action(async (_options, command: Command) => {
581
+ const options = command.optsWithGlobals<{
582
+ stateDir?: string;
583
+ json?: boolean;
584
+ groups?: string;
585
+ replace?: boolean;
586
+ }>();
587
+
588
+ const stateDir = reviewServerStateDir(authoringEnv(options.stateDir));
589
+
590
+ const { remoteAttach } = await import("./remote-attach.js");
591
+
592
+ let attach: Awaited<ReturnType<typeof remoteAttach>>;
593
+
594
+ try {
595
+ attach = await remoteAttach({
596
+ stateDir,
597
+ env,
598
+ groups: options.groups?.split(",").map((group) => group.trim()),
599
+ replace: options.replace,
600
+ });
601
+ } catch (error) {
602
+ if (!options.json) throw error;
603
+
604
+ let line = "";
605
+
606
+ emitReviewEvent(
607
+ new Writable({
608
+ write(chunk, _encoding, done) {
609
+ line += chunk;
610
+ done();
611
+ },
612
+ }),
613
+ { event: "error", error: serializeReviewError(error) },
614
+ );
615
+ input.stdout.write(
616
+ `${REVIEW_REMOTE_ATTACH_BEGIN}\n${ensureTrailingNewline(line)}${REVIEW_REMOTE_ATTACH_END}\n`,
617
+ );
618
+ state.exitCode = 1;
619
+
620
+ return;
621
+ }
622
+
623
+ input.stdout.write(
624
+ options.json
625
+ ? `${REVIEW_REMOTE_ATTACH_BEGIN}\n${JSON.stringify(attach)}\n${REVIEW_REMOTE_ATTACH_END}\n`
626
+ : `Whiteboard server ${attach.startedServer ? "started" : "already running"} at ${attach.url}\nLanguage features: ${attach.languageServer ? `VS Code server on port ${attach.languageServer.port}` : `unavailable (${attach.languageServerDetail})`}\n`,
627
+ );
628
+ });
629
+
630
+ configureJsonOutput(
631
+ remote
632
+ .command("extensions")
633
+ .description("Language extensions for the VS Code server on a remote")
634
+ .command("ensure")
635
+ .description(
636
+ "Download the curated extensions for this machine and list them for the VS Code server",
637
+ )
638
+ .option(
639
+ "--groups <groups>",
640
+ "comma-separated optional extension groups the Desktop has enabled, such as go",
641
+ ),
642
+ "plain",
643
+ ).action(async (_options, command: Command) => {
644
+ const { json, groups } = command.optsWithGlobals<{
645
+ json?: boolean;
646
+ groups?: string;
647
+ }>();
648
+
649
+ const { ensureRemoteExtensions } = await import("./remote-extensions.js");
650
+
651
+ const result = await ensureRemoteExtensions({
652
+ env,
653
+ groups: groups?.split(",").map((group) => group.trim()),
654
+ });
655
+
656
+ input.stdout.write(
657
+ json
658
+ ? `${JSON.stringify(result)}\n`
659
+ : [
660
+ ...result.installed.map((id) => `Installed ${id}`),
661
+ ...result.skipped.map(
662
+ ({ id, reason }) => `Skipped ${id}: ${reason}`,
663
+ ),
664
+ ...result.failed.map(({ id, error }) => `Failed ${id}: ${error}`),
665
+ ]
666
+ .map((line) => `${line}\n`)
667
+ .join(""),
668
+ );
669
+ state.exitCode = result.failed.length > 0 ? 1 : 0;
670
+ });
671
+
672
+ configureJsonOutput(
673
+ remote
674
+ .command("uninstall")
675
+ .description(
676
+ "Remove what Whiteboard Desktop installed on this host, stopping a server it started",
677
+ )
678
+ .addOption(
679
+ new Option(
680
+ "--keep-reviews",
681
+ "leave the saved reviews in place",
682
+ ).conflicts("deleteReviews"),
683
+ )
684
+ .addOption(
685
+ new Option("--delete-reviews", "also delete the saved reviews"),
686
+ )
687
+ .option(
688
+ "--state-dir <path>",
689
+ "directory for saved reviews and server discovery",
690
+ ),
691
+ "plain",
692
+ ).action(async (_options, command: Command) => {
693
+ const options = command.optsWithGlobals<{
694
+ stateDir?: string;
695
+ json?: boolean;
696
+ keepReviews?: boolean;
697
+ deleteReviews?: boolean;
698
+ }>();
699
+
700
+ const stateDir = reviewServerStateDir(authoringEnv(options.stateDir));
701
+ const { remoteUninstall } = await import("./remote-uninstall.js");
702
+
703
+ let deleteReviews = options.deleteReviews
704
+ ? true
705
+ : options.keepReviews
706
+ ? false
707
+ : undefined;
708
+
709
+ if (deleteReviews === undefined && !options.json && input.stdin?.isTTY) {
710
+ const prompt = createInterface({
711
+ input: input.stdin,
712
+ output: input.stderr,
713
+ });
714
+
715
+ try {
716
+ deleteReviews = /^y(es)?$/i.test(
717
+ (
718
+ await prompt.question(
719
+ `Also delete the saved reviews in ${stateDir}? [y/N] `,
720
+ )
721
+ ).trim(),
722
+ );
723
+ } finally {
724
+ prompt.close();
725
+ }
726
+ }
727
+
728
+ const result =
729
+ deleteReviews === undefined
730
+ ? {
731
+ event: "remote.uninstall" as const,
732
+ ok: false as const,
733
+ reason:
734
+ "Say what happens to the saved reviews: pass --keep-reviews or --delete-reviews.",
735
+ }
736
+ : await remoteUninstall({
737
+ home: env.HOME ?? "",
738
+ env,
739
+ stateDir,
740
+ deleteReviews,
741
+ });
742
+
743
+ if (!result.ok) {
744
+ if (!options.json) throw new Error(result.reason);
745
+ input.stdout.write(`${JSON.stringify(result)}\n`);
746
+ state.exitCode = 1;
747
+
748
+ return;
749
+ }
750
+
751
+ input.stdout.write(
752
+ options.json
753
+ ? `${JSON.stringify(result)}\n`
754
+ : [
755
+ ...(result.stoppedServer
756
+ ? [
757
+ `Stopped the Whiteboard server (process ${result.stoppedServer.pid}).`,
758
+ ]
759
+ : []),
760
+ ...result.removed.map((removed) => `Removed ${removed}`),
761
+ result.keptReviews
762
+ ? `Saved reviews kept in ${stateDir}`
763
+ : "Saved reviews deleted.",
764
+ "",
765
+ ].join("\n"),
766
+ );
767
+ });
768
+
769
+ async function writeServerStatus(
770
+ discovery: ReviewServerDiscovery,
771
+ stateDir: string,
772
+ json: boolean | undefined,
773
+ extra: { started?: boolean } = {},
774
+ ) {
775
+ const health = await readReviewServerHealth(discovery);
776
+
777
+ if (!health) throw serverNotReady(stateDir);
778
+ const { url, serverPid } = discovery;
779
+ const { version, serverId } = health;
780
+ input.stdout.write(
781
+ json
782
+ ? `${JSON.stringify({ event: "server.status", ready: true, url, serverPid, stateDir, version, serverId, ...extra })}\n`
783
+ : `Whiteboard server ${extra.started === false ? "already running" : "ready"} at ${url}\nSaved reviews: ${stateDir}\n`,
784
+ );
785
+ }
786
+
458
787
  configureJsonOutput(
459
788
  program
460
789
  .command("version")
@@ -662,53 +991,125 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
662
991
  "copilot",
663
992
  "all",
664
993
  ]),
994
+ )
995
+ .addOption(
996
+ new Option(
997
+ "--detect",
998
+ "list the agents on this machine and whether each is connected; changes nothing",
999
+ ).conflicts("yes"),
1000
+ )
1001
+ .option(
1002
+ "--yes",
1003
+ "run the commands the prompt gives, without an agent: claude, codex, opencode or pi",
665
1004
  ),
666
1005
  "plain",
667
1006
  );
668
1007
 
669
- connect.action(async (targets: string[], options: { json?: boolean }) => {
670
- const selected = parseTargets(targets);
1008
+ connect.action(
1009
+ async (
1010
+ targets: string[],
1011
+ options: { json?: boolean; detect?: boolean; yes?: boolean },
1012
+ ) => {
1013
+ const output = {
1014
+ json: options.json,
1015
+ stdout: input.stdout,
1016
+ stderr: input.stderr,
1017
+ };
1018
+
1019
+ if (options.detect) {
1020
+ const agents = await detectAgents({ homeDir: scope.homeDir, env });
1021
+
1022
+ if (options.json) {
1023
+ emitJsonEvent(output, { event: "connect.detect", agents });
1024
+
1025
+ return;
1026
+ }
1027
+
1028
+ humanStream(output).write(
1029
+ agents.length
1030
+ ? `${agents.map((agent) => `${agent.name}: ${agent.connected ? "connected" : "not connected"}${agent.manual ? " (its command is not on PATH)" : ""}`).join("\n")}\n`
1031
+ : "No agents found.\n",
1032
+ );
1033
+
1034
+ return;
1035
+ }
671
1036
 
672
- const { homeDir, devHome } = scope;
1037
+ if (options.yes) {
1038
+ const agents = parseTargets(targets);
673
1039
 
674
- const prompts = connectPrompts({
675
- legacyPaths: await scanLegacySkills(homeDir),
676
- hasShim:
677
- (await isOwnedShim(pathShimPath(homeDir))) ||
678
- (await windowsInstallerCommand(
679
- findReviewPackageRoot(import.meta.url),
1040
+ if (
1041
+ targets.length === 0 ||
1042
+ !agents.every((target): target is AgentConnectTarget =>
1043
+ AGENT_CONNECT_TARGETS.some((known) => known === target),
1044
+ )
1045
+ )
1046
+ throw new Error(
1047
+ "connect --yes takes one or more of claude, codex, opencode or pi.",
1048
+ );
1049
+
1050
+ const results = await connectAgents({
1051
+ agents,
1052
+ homeDir: scope.homeDir,
680
1053
  env,
681
- )) !== undefined,
682
- traceEnabled: await traceMachineEnabled({ homeDir, env }),
683
- fffBinaryPath: path.join(homeDir, ".local", "bin", "fff-mcp"),
684
- fffCorpusRoot: path.join(devHome, "trace-search"),
685
- });
1054
+ });
686
1055
 
687
- const output = {
688
- json: options.json,
689
- stdout: input.stdout,
690
- stderr: input.stderr,
691
- };
1056
+ state.exitCode = results.every((result) => result.connected) ? 0 : 1;
692
1057
 
693
- if (options.json) {
694
- emitJsonEvent(output, {
695
- event: "connect",
696
- prompts: Object.fromEntries(
697
- selected.map((target) => [target, prompts[target]]),
698
- ),
1058
+ if (options.json) {
1059
+ emitJsonEvent(output, { event: "connect.run", agents: results });
1060
+
1061
+ return;
1062
+ }
1063
+
1064
+ humanStream(output).write(
1065
+ results
1066
+ .map(
1067
+ (result) =>
1068
+ `${result.output}${result.name}: ${result.connected ? "connected" : "not connected"}\n`,
1069
+ )
1070
+ .join(""),
1071
+ );
1072
+
1073
+ return;
1074
+ }
1075
+
1076
+ const selected = parseTargets(targets);
1077
+
1078
+ const { homeDir, devHome } = scope;
1079
+
1080
+ const prompts = connectPrompts({
1081
+ legacyPaths: await scanLegacySkills(homeDir),
1082
+ hasShim:
1083
+ (await isOwnedShim(pathShimPath(homeDir))) ||
1084
+ (await windowsInstallerCommand(
1085
+ findReviewPackageRoot(import.meta.url),
1086
+ env,
1087
+ )) !== undefined,
1088
+ traceEnabled: await traceMachineEnabled({ homeDir, env }),
1089
+ fffBinaryPath: path.join(homeDir, ".local", "bin", "fff-mcp"),
1090
+ fffCorpusRoot: path.join(devHome, "trace-search"),
699
1091
  });
700
1092
 
701
- return;
702
- }
1093
+ if (options.json) {
1094
+ emitJsonEvent(output, {
1095
+ event: "connect",
1096
+ prompts: Object.fromEntries(
1097
+ selected.map((target) => [target, prompts[target]]),
1098
+ ),
1099
+ });
703
1100
 
704
- const sections = selected.map((target) =>
705
- selected.length > 1
706
- ? `## ${TARGET_LABELS[target]}\n\n${prompts[target]}`
707
- : prompts[target],
708
- );
1101
+ return;
1102
+ }
709
1103
 
710
- humanStream(output).write(`${sections.join("\n\n")}\n`);
711
- });
1104
+ const sections = selected.map((target) =>
1105
+ selected.length > 1
1106
+ ? `## ${REVIEW_CLI_INSTALL_TARGET_LABELS[target]}\n\n${prompts[target]}`
1107
+ : prompts[target],
1108
+ );
1109
+
1110
+ humanStream(output).write(`${sections.join("\n\n")}\n`);
1111
+ },
1112
+ );
712
1113
 
713
1114
  const migrate = configureOutput(
714
1115
  program.command("migrate", { hidden: true }),
@@ -1137,16 +1538,6 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
1137
1538
  }
1138
1539
  }
1139
1540
 
1140
- const TARGET_LABELS: Record<InstallTarget, string> = {
1141
- claude: "Claude Code",
1142
- codex: "Codex",
1143
- cursor: "Cursor",
1144
- opencode: "OpenCode",
1145
- pi: "Pi",
1146
- omp: "oh-my-pi",
1147
- copilot: "Copilot CLI",
1148
- };
1149
-
1150
1541
  function parseTargets(targets: readonly string[]): InstallTarget[] {
1151
1542
  if (targets.length === 0 || targets.includes("all")) {
1152
1543
  return [...ALL_INSTALL_TARGETS];
package/src/cli.ts CHANGED
@@ -71,7 +71,7 @@ async function maybeDelegateToDesktopCli(
71
71
  if (
72
72
  argv.some(
73
73
  (argument) =>
74
- ["api", "mcp", "server", "instances"].includes(argument) ||
74
+ ["api", "mcp", "server", "remote", "instances"].includes(argument) ||
75
75
  /^--state-dir(?:=|$)/.test(argument),
76
76
  ) ||
77
77
  env.DEV_REVIEW_SERVER_DIR?.trim()