@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
|
@@ -2,6 +2,7 @@ you are writing an interactive rfc-style whiteboard, for consumption by a staff
|
|
|
2
2
|
|
|
3
3
|
**flow**
|
|
4
4
|
follow these first five steps in order, with no tool calls beyond what they need: loading the tools they use (`session_create`, `session_activity_begin`, `session_edit`, `session_get`, and later `session_activity_update` and `session_activity_end`) and, for a PR, reading its description (`gh pr view`).
|
|
5
|
+
|
|
5
6
|
- create the whiteboard, pinned to the commits/pr the user describes, naming the checkout by its `repositoryPath`. if they name none, use a `worktree` target with no `base`: it reviews the checkout, uncommitted work included, against the default branch. for uncommitted work only, pass `base: "HEAD"`. untracked files are left out: run `git add -N <file>` on new files you want reviewed
|
|
6
7
|
- trigger a subagent with this exact instruction: "Call `session_get_instructions({topic:\"file-lenses\"})` and follow it for whiteboard <sessionId>, whose change is `<base>..<head>` in `<repositoryPath>`."
|
|
7
8
|
- call `session_activity_begin` and pass its `activityId` on every `session_edit`
|
|
@@ -9,27 +10,30 @@ follow these first five steps in order, with no tool calls beyond what they need
|
|
|
9
10
|
- immediately after reading the diff (and the PR description) - put down a first pass at the what/why section.
|
|
10
11
|
|
|
11
12
|
- whiteboard structure - each of these should be written as a top-level `section`, in this order:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
- what/why: succinct description of what the change is, + why the change was made (if this context is available to you.)
|
|
14
|
+
- requirements: as given by the user, in their own words, if this context is available to you. otherwise, omit. write these as short bullet points
|
|
15
|
+
- design: how the solution works at the level of components, data and control flow, not functions.
|
|
16
|
+
- pick one diagram that best shows the shape of the change:
|
|
17
|
+
- `sequence` if participants interact over time (who calls whom, async handoffs)
|
|
18
|
+
- `flow_diagram` if the interesting part is branches, retries or state transitions
|
|
19
|
+
- `database_lens` if the interesting part is a schema change to the data stores, and/or who reads/writes it
|
|
20
|
+
- if the change is mostly a new/changed contract, show the key types / interfaces as `code_peek`(s)
|
|
21
|
+
plus the main decisions and tradeoffs, and alternatives considered if you have evidence for them (trace, PR discussion). skip for small changes whose design is self-evident.
|
|
22
|
+
- implementation: how the code delivers the design, at the level of functions and files. walk the changed code in the order a reader should follow it, starting with the entry point.
|
|
23
|
+
- `call_stack_diff` for the old vs. new path through user flows. always root the flows in the user/agent entry point (eg a button click, CLI command, etc.), including unchanged nodes along the way.
|
|
24
|
+
- `code_peek` for the few spots that carry the mechanism or an invariant; link everything else inline
|
|
24
25
|
|
|
25
26
|
- before finishing, read the whole whiteboard back with `session_get({sessionId, full: true})` (without `full`, prose is cut short) and fix any contradictions/unverified claims.
|
|
26
27
|
|
|
27
28
|
**updating existing whiteboard**
|
|
29
|
+
|
|
28
30
|
- move the whiteboard to the new commits with `session_set_target`
|
|
29
31
|
- read the existing whiteboard (if you haven't already,) read the diff since last whiteboard, make any necessary updates to the whiteboard.
|
|
30
32
|
|
|
31
33
|
**guidelines**
|
|
34
|
+
|
|
32
35
|
- IMPORTANT: Write incrementally. The user sees you write on the canvas in real-time. Show them visual progress every few seconds.
|
|
36
|
+
- IMPORTANT: When writing, you have a max budget of 3 sentences at a time in a `markdown` component before you must use a diagram or a visual of some sort (another `content.type`, such as `code_peek`, `sequence`, `flow_diagram`, `call_stack_diff` or `database_lens`). If a visual/diagram covers the same content as prose, use a visual aid. When you do write, write in the ASD-STE100 spec (the full spec is a bit too stringent, so aim for ~80% of that style)
|
|
33
37
|
- use `session_activity_update` to provide regular status updates on your area of focus.
|
|
34
38
|
- keep whiteboards short and sweet when possible (esp. for small changes.) feel free to omit sections.
|
|
35
39
|
- when something (a phrase in the prose, diagram node, etc.) describes actual code in the codebase, always default to attaching/hyperlink code.
|
|
@@ -3,11 +3,13 @@ you are grouping a code whiteboard's changed files into file lenses for the Diff
|
|
|
3
3
|
**input:** a whiteboard id, and its change as `<base>..<head>` in a repository path.
|
|
4
4
|
|
|
5
5
|
**flow**
|
|
6
|
+
|
|
6
7
|
- call `session_activity_begin` before writing, pass its `activityId` on every `session_lens_edit`, and call `session_activity_end` when done.
|
|
7
8
|
- call `session_lens_get`: before any lens exists, its `uncategorized` lists every changed file
|
|
8
9
|
- categorize all the changes into buckets - leaving nothing in uncategorized changes by the end
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
- first, categorize away non-implementation code: tests, docs, generated files and lockfiles, config and build, fixtures and snapshots, pure renames and moves, formatting-only changes, imports — all might be reasonable.
|
|
11
|
+
- then, when left with only implementation code, split it by the part of the design each file serves (e.g. the data model, an API, a UI surface), in the order a reader should take them. keep each lens small enough to read in one sitting, and don't split a file across lenses unless it holds two unrelated changes.
|
|
11
12
|
|
|
12
13
|
**guidelines**
|
|
14
|
+
|
|
13
15
|
- to see a file's changes, run `git diff <base> <head> -- <path>` in the repository. otherwise, rely on the file listing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev.fast/whiteboard",
|
|
3
|
-
"version": "0.2.5-preview.
|
|
3
|
+
"version": "0.2.5-preview.20261015.115",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
"onboarding.md",
|
|
19
19
|
"dist",
|
|
20
20
|
"instructions",
|
|
21
|
-
"tutorial",
|
|
22
|
-
"!tutorial/.bundle",
|
|
23
21
|
"src",
|
|
24
22
|
"!src/**/*.test.ts",
|
|
25
23
|
"!src/**/*test-utils*",
|
|
@@ -59,24 +57,27 @@
|
|
|
59
57
|
"semver": "7.8.5",
|
|
60
58
|
"sharp": "0.35.5",
|
|
61
59
|
"smol-toml": "1.9.0",
|
|
60
|
+
"undici": "7.29.1",
|
|
61
|
+
"yauzl": "3.4.0",
|
|
62
62
|
"zod": "4.6.5",
|
|
63
|
-
"@dev.fast/diffr": "0.1.
|
|
63
|
+
"@dev.fast/diffr": "0.1.20"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@dev.fast/local-vcs": "^0.1.0",
|
|
67
67
|
"@types/node": "24.12.2",
|
|
68
68
|
"@types/semver": "7.7.1",
|
|
69
|
+
"@types/yauzl": "3.4.0",
|
|
69
70
|
"micromark-extension-math": "3.1.0",
|
|
70
71
|
"pino-pretty": "13.1.3",
|
|
71
72
|
"tsdown": "0.22.14",
|
|
72
73
|
"tsx": "4.21.0",
|
|
73
74
|
"typescript": "6.0.3",
|
|
74
75
|
"vitest": "4.1.11",
|
|
75
|
-
"@dev.fast/review-protocol": "0.0.0",
|
|
76
76
|
"@dev.fast/json": "0.0.0",
|
|
77
|
+
"@dev.fast/review-protocol": "0.0.0",
|
|
77
78
|
"@dev.fast/review-share-protocol": "0.1.2",
|
|
78
|
-
"@dev.fast/trace-
|
|
79
|
-
"@dev.fast/trace-
|
|
79
|
+
"@dev.fast/trace-core": "0.0.0",
|
|
80
|
+
"@dev.fast/trace-protocol": "0.5.0"
|
|
80
81
|
},
|
|
81
82
|
"engines": {
|
|
82
83
|
"node": ">=24 <25"
|
|
@@ -86,17 +87,11 @@
|
|
|
86
87
|
"repo": "devdotfast/review",
|
|
87
88
|
"docs": "packages/review/instructions/authoring.md"
|
|
88
89
|
},
|
|
89
|
-
"gitHead": "
|
|
90
|
+
"gitHead": "fd78e6a852a7f20798854059c5196c661f0df44f",
|
|
90
91
|
"scripts": {
|
|
91
92
|
"prebuild": "pnpm run build:workspace-deps",
|
|
92
93
|
"build": "node --max-old-space-size=8192 node_modules/tsdown/dist/run.mjs",
|
|
93
|
-
"build:tutorial-assets": "tsx scripts/build-tutorial-assets.ts",
|
|
94
|
-
"ensure:tutorial-assets": "tsx scripts/ensure-tutorial-assets.ts",
|
|
95
|
-
"check:tutorial": "tsx scripts/check-tutorial.ts",
|
|
96
94
|
"build:workspace-deps": "pnpm --filter @dev.fast/diffr --filter @dev.fast/json --filter @dev.fast/local-vcs --filter @dev.fast/review-protocol --filter @dev.fast/review-share-protocol --filter @dev.fast/trace-protocol --filter @dev.fast/trace-core build",
|
|
97
|
-
"pretypecheck": "pnpm run ensure:tutorial-assets",
|
|
98
|
-
"pretest": "pnpm run ensure:tutorial-assets",
|
|
99
|
-
"pretest:node": "pnpm run ensure:tutorial-assets",
|
|
100
95
|
"start": "tsx src/cli.ts",
|
|
101
96
|
"review": "tsx src/cli.ts",
|
|
102
97
|
"test": "vitest run --config vitest.config.ts",
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { type JsonObject, type JsonValue, isJsonObject } from "@dev.fast/json";
|
|
7
|
+
import type {
|
|
8
|
+
ReviewConnectAgent,
|
|
9
|
+
ReviewConnectResult,
|
|
10
|
+
} from "@dev.fast/review-protocol";
|
|
11
|
+
|
|
12
|
+
import { findExecutable } from "./ask/agents.js";
|
|
13
|
+
import { type McpLaunch, reviewMcpLaunch } from "./connect-prompts.js";
|
|
14
|
+
import { cursorInstallDeeplink } from "./cursor-deeplink.js";
|
|
15
|
+
import { ALL_INSTALL_TARGETS, type InstallTarget } from "./install.js";
|
|
16
|
+
|
|
17
|
+
const MARKETPLACE = "devdotfast/whiteboard";
|
|
18
|
+
|
|
19
|
+
const PLUGIN = "whiteboard@devfast";
|
|
20
|
+
|
|
21
|
+
const STEP_TIMEOUT_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
interface Harness {
|
|
24
|
+
name: string;
|
|
25
|
+
/** The CLI's names on PATH, most specific first. */
|
|
26
|
+
commands: string[];
|
|
27
|
+
/** Where its installer puts the CLI when that is not on PATH, under home. */
|
|
28
|
+
installDirs?: string[];
|
|
29
|
+
/** A directory under home whose presence means the harness is installed,
|
|
30
|
+
* for one that is an app rather than a CLI. */
|
|
31
|
+
homeDir?: string;
|
|
32
|
+
/** macOS app bundles that mean the harness is installed. */
|
|
33
|
+
apps?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const HARNESSES: Record<InstallTarget, Harness> = {
|
|
37
|
+
claude: {
|
|
38
|
+
name: "Claude Code",
|
|
39
|
+
commands: ["claude"],
|
|
40
|
+
installDirs: [".claude/local", ".local/bin"],
|
|
41
|
+
},
|
|
42
|
+
codex: { name: "Codex", commands: ["codex"] },
|
|
43
|
+
cursor: {
|
|
44
|
+
name: "Cursor",
|
|
45
|
+
commands: ["cursor"],
|
|
46
|
+
homeDir: ".cursor",
|
|
47
|
+
apps: ["/Applications/Cursor.app"],
|
|
48
|
+
},
|
|
49
|
+
opencode: {
|
|
50
|
+
name: "OpenCode",
|
|
51
|
+
commands: ["opencode"],
|
|
52
|
+
installDirs: [".opencode/bin"],
|
|
53
|
+
},
|
|
54
|
+
pi: { name: "Pi", commands: ["pi"] },
|
|
55
|
+
omp: { name: "oh-my-pi", commands: ["omp"], homeDir: ".omp" },
|
|
56
|
+
copilot: { name: "Copilot CLI", commands: ["copilot"] },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export interface ConnectEnvironment {
|
|
60
|
+
homeDir?: string;
|
|
61
|
+
env?: NodeJS.ProcessEnv;
|
|
62
|
+
platform?: NodeJS.Platform;
|
|
63
|
+
/** Whether ~/.local/bin/whiteboard exists for the MCP launch to run. */
|
|
64
|
+
hasShim: boolean;
|
|
65
|
+
run?: ConnectRun;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Runs one subprocess; never rejects, so a step's failure is its output. */
|
|
69
|
+
export type ConnectRun = (
|
|
70
|
+
file: string,
|
|
71
|
+
args: string[],
|
|
72
|
+
) => Promise<{ code: number; output: string }>;
|
|
73
|
+
|
|
74
|
+
const runProcess: ConnectRun = (file, args) =>
|
|
75
|
+
new Promise((resolve) => {
|
|
76
|
+
execFile(
|
|
77
|
+
file,
|
|
78
|
+
args,
|
|
79
|
+
{
|
|
80
|
+
timeout: STEP_TIMEOUT_MS,
|
|
81
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
82
|
+
// A plugin install must never wait on a prompt nobody can see.
|
|
83
|
+
env: { ...process.env, CI: "1", GIT_TERMINAL_PROMPT: "0" },
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
},
|
|
86
|
+
(error, stdout, stderr) => {
|
|
87
|
+
const output = `${stdout}${stderr}`.trim();
|
|
88
|
+
|
|
89
|
+
// Only success matters to a step, not which failure it was.
|
|
90
|
+
resolve(
|
|
91
|
+
error
|
|
92
|
+
? { code: 1, output: output || error.message }
|
|
93
|
+
: { code: 0, output },
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
async function exists(target: string): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
await stat(target);
|
|
102
|
+
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function findHarness(
|
|
110
|
+
harness: Harness,
|
|
111
|
+
homeDir: string,
|
|
112
|
+
env: NodeJS.ProcessEnv,
|
|
113
|
+
): Promise<string | undefined> {
|
|
114
|
+
const installed = (harness.installDirs ?? []).map((directory) =>
|
|
115
|
+
path.join(homeDir, directory),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
for (const command of harness.commands) {
|
|
119
|
+
const found =
|
|
120
|
+
(await findExecutable(command, env)) ??
|
|
121
|
+
(await findExecutable(command, { PATH: installed.join(path.delimiter) }));
|
|
122
|
+
|
|
123
|
+
if (found) return found;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function harnessFound(
|
|
130
|
+
harness: Harness,
|
|
131
|
+
homeDir: string,
|
|
132
|
+
env: NodeJS.ProcessEnv,
|
|
133
|
+
platform: NodeJS.Platform,
|
|
134
|
+
): Promise<boolean> {
|
|
135
|
+
if (await findHarness(harness, homeDir, env)) return true;
|
|
136
|
+
|
|
137
|
+
if (harness.homeDir && (await exists(path.join(homeDir, harness.homeDir))))
|
|
138
|
+
return true;
|
|
139
|
+
|
|
140
|
+
if (platform !== "darwin") return false;
|
|
141
|
+
|
|
142
|
+
for (const app of harness.apps ?? []) if (await exists(app)) return true;
|
|
143
|
+
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readJson(file: string): Promise<JsonValue | undefined> {
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Follows `keys` down nested JSON objects. */
|
|
156
|
+
function lookup(value: JsonValue | undefined, ...keys: string[]) {
|
|
157
|
+
let current = value;
|
|
158
|
+
|
|
159
|
+
for (const key of keys) {
|
|
160
|
+
if (!isJsonObject(current)) return undefined;
|
|
161
|
+
current = current[key];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return current;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether a `[plugins."…"]` table names the plugin, and does not turn it
|
|
168
|
+
* off. */
|
|
169
|
+
export function codexHasPlugin(toml: string): boolean {
|
|
170
|
+
const lines = toml.split("\n");
|
|
171
|
+
|
|
172
|
+
const start = lines.findIndex(
|
|
173
|
+
(line) => line.trim() === `[plugins."${PLUGIN}"]`,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (start === -1) return false;
|
|
177
|
+
|
|
178
|
+
for (const line of lines.slice(start + 1)) {
|
|
179
|
+
const trimmed = line.trim();
|
|
180
|
+
|
|
181
|
+
if (trimmed.startsWith("[")) break;
|
|
182
|
+
|
|
183
|
+
if (/^enabled\s*=\s*false\b/.test(trimmed)) return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Whether a harness already has Whiteboard, as its own config records it:
|
|
190
|
+
* the plugin it installed, or the MCP server added to it. Copilot keeps no
|
|
191
|
+
* record Whiteboard reads, so it always installs again, which is harmless. */
|
|
192
|
+
async function harnessConnected(
|
|
193
|
+
target: InstallTarget,
|
|
194
|
+
homeDir: string,
|
|
195
|
+
env: NodeJS.ProcessEnv,
|
|
196
|
+
platform: NodeJS.Platform,
|
|
197
|
+
): Promise<boolean> {
|
|
198
|
+
const claudeDir = env.CLAUDE_CONFIG_DIR ?? path.join(homeDir, ".claude");
|
|
199
|
+
|
|
200
|
+
const hasServer = async (file: string, ...keys: string[]) =>
|
|
201
|
+
lookup(await readJson(file), ...keys, "whiteboard") !== undefined;
|
|
202
|
+
|
|
203
|
+
switch (target) {
|
|
204
|
+
case "claude": {
|
|
205
|
+
if (platform === "win32")
|
|
206
|
+
return hasServer(path.join(homeDir, ".claude.json"), "mcpServers");
|
|
207
|
+
|
|
208
|
+
const [installed, settings] = await Promise.all([
|
|
209
|
+
readJson(path.join(claudeDir, "plugins", "installed_plugins.json")),
|
|
210
|
+
readJson(path.join(claudeDir, "settings.json")),
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
const installs = lookup(installed, "plugins", PLUGIN);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
Array.isArray(installs) &&
|
|
217
|
+
installs.length > 0 &&
|
|
218
|
+
lookup(settings, "enabledPlugins", PLUGIN) !== false
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case "codex":
|
|
223
|
+
try {
|
|
224
|
+
return codexHasPlugin(
|
|
225
|
+
await readFile(
|
|
226
|
+
path.join(
|
|
227
|
+
env.CODEX_HOME ?? path.join(homeDir, ".codex"),
|
|
228
|
+
"config.toml",
|
|
229
|
+
),
|
|
230
|
+
"utf8",
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case "opencode": {
|
|
238
|
+
const config = await readJson(
|
|
239
|
+
path.join(
|
|
240
|
+
env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config"),
|
|
241
|
+
"opencode",
|
|
242
|
+
"opencode.json",
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Its `mcp add` has written both shapes.
|
|
247
|
+
return (
|
|
248
|
+
lookup(config, "mcp", "whiteboard") !== undefined ||
|
|
249
|
+
lookup(config, "mcp", "servers", "whiteboard") !== undefined
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
case "pi":
|
|
254
|
+
return hasServer(
|
|
255
|
+
path.join(homeDir, ".pi", "agent", "mcp.json"),
|
|
256
|
+
"mcpServers",
|
|
257
|
+
);
|
|
258
|
+
case "omp":
|
|
259
|
+
return hasServer(
|
|
260
|
+
path.join(homeDir, ".omp", "agent", "mcp.json"),
|
|
261
|
+
"mcpServers",
|
|
262
|
+
);
|
|
263
|
+
case "cursor":
|
|
264
|
+
return cursorHasServer(homeDir);
|
|
265
|
+
case "copilot":
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Every harness Whiteboard connects to, whether this machine has it, and
|
|
271
|
+
* whether it already has Whiteboard. */
|
|
272
|
+
export async function detectConnectAgents(
|
|
273
|
+
input: Omit<ConnectEnvironment, "hasShim" | "run"> = {},
|
|
274
|
+
): Promise<ReviewConnectAgent[]> {
|
|
275
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
276
|
+
const env = input.env ?? process.env;
|
|
277
|
+
const platform = input.platform ?? process.platform;
|
|
278
|
+
|
|
279
|
+
return Promise.all(
|
|
280
|
+
ALL_INSTALL_TARGETS.map(async (target) => {
|
|
281
|
+
const found = await harnessFound(
|
|
282
|
+
HARNESSES[target],
|
|
283
|
+
homeDir,
|
|
284
|
+
env,
|
|
285
|
+
platform,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
target,
|
|
290
|
+
name: HARNESSES[target].name,
|
|
291
|
+
found,
|
|
292
|
+
connected:
|
|
293
|
+
found && (await harnessConnected(target, homeDir, env, platform)),
|
|
294
|
+
};
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface Step {
|
|
300
|
+
args: string[];
|
|
301
|
+
/** Output that means the step had already been done. */
|
|
302
|
+
done?: RegExp;
|
|
303
|
+
/** Whether a failure leaves the next step to decide the outcome. */
|
|
304
|
+
optional?: boolean;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const ALREADY = /already/i;
|
|
308
|
+
|
|
309
|
+
/** The commands that install Whiteboard into a CLI harness, as
|
|
310
|
+
* connect-prompts.ts tells an agent to run them. */
|
|
311
|
+
function cliSteps(
|
|
312
|
+
target: Exclude<InstallTarget, "cursor" | "omp">,
|
|
313
|
+
launch: McpLaunch,
|
|
314
|
+
platform: NodeJS.Platform,
|
|
315
|
+
): Step[] {
|
|
316
|
+
// The plugin install says whether it worked: the marketplace can already be
|
|
317
|
+
// known by another source, such as the repository's earlier name.
|
|
318
|
+
const marketplace = {
|
|
319
|
+
args: ["plugin", "marketplace", "add", MARKETPLACE],
|
|
320
|
+
optional: true,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// The plugins' sh launch cannot start on Windows, so there Claude and
|
|
324
|
+
// Copilot (which loads the Claude plugin) register the server directly.
|
|
325
|
+
const windowsServer = (scope: string[]) => ({
|
|
326
|
+
args: [
|
|
327
|
+
"mcp",
|
|
328
|
+
"add",
|
|
329
|
+
...scope,
|
|
330
|
+
"whiteboard",
|
|
331
|
+
"--",
|
|
332
|
+
launch.command,
|
|
333
|
+
...launch.args,
|
|
334
|
+
],
|
|
335
|
+
done: ALREADY,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
switch (target) {
|
|
339
|
+
case "claude":
|
|
340
|
+
return platform === "win32"
|
|
341
|
+
? [windowsServer(["-s", "user"])]
|
|
342
|
+
: [
|
|
343
|
+
marketplace,
|
|
344
|
+
{
|
|
345
|
+
args: ["plugin", "install", PLUGIN, "--scope", "user"],
|
|
346
|
+
done: ALREADY,
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
case "codex":
|
|
350
|
+
return [marketplace, { args: ["plugin", "add", PLUGIN], done: ALREADY }];
|
|
351
|
+
case "copilot":
|
|
352
|
+
return platform === "win32"
|
|
353
|
+
? [windowsServer([])]
|
|
354
|
+
: [marketplace, { args: ["plugin", "install", PLUGIN], done: ALREADY }];
|
|
355
|
+
case "opencode":
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
args: [
|
|
359
|
+
"mcp",
|
|
360
|
+
"add",
|
|
361
|
+
"--global",
|
|
362
|
+
"whiteboard",
|
|
363
|
+
"--",
|
|
364
|
+
launch.command,
|
|
365
|
+
...launch.args,
|
|
366
|
+
],
|
|
367
|
+
done: ALREADY,
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
case "pi":
|
|
371
|
+
return [
|
|
372
|
+
{
|
|
373
|
+
args: [
|
|
374
|
+
"mcp",
|
|
375
|
+
"add",
|
|
376
|
+
"whiteboard",
|
|
377
|
+
"--",
|
|
378
|
+
launch.command,
|
|
379
|
+
...launch.args,
|
|
380
|
+
],
|
|
381
|
+
done: ALREADY,
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** oh-my-pi reads its MCP servers from one JSON file; merge ours in. */
|
|
388
|
+
async function connectOmp(
|
|
389
|
+
homeDir: string,
|
|
390
|
+
launch: McpLaunch,
|
|
391
|
+
): Promise<ReviewConnectResult> {
|
|
392
|
+
const file = path.join(homeDir, ".omp", "agent", "mcp.json");
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const config: JsonValue = (await exists(file))
|
|
396
|
+
? JSON.parse(await readFile(file, "utf8"))
|
|
397
|
+
: {};
|
|
398
|
+
|
|
399
|
+
if (!isJsonObject(config)) throw new Error(`${file} is not a JSON object.`);
|
|
400
|
+
|
|
401
|
+
const servers: JsonObject = isJsonObject(config.mcpServers)
|
|
402
|
+
? { ...config.mcpServers }
|
|
403
|
+
: {};
|
|
404
|
+
|
|
405
|
+
servers.whiteboard = { command: launch.command, args: [...launch.args] };
|
|
406
|
+
const next: JsonObject = { ...config, mcpServers: servers };
|
|
407
|
+
|
|
408
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
409
|
+
await writeFile(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
410
|
+
|
|
411
|
+
return { target: "omp", state: "connected", output: `Updated ${file}` };
|
|
412
|
+
} catch (error) {
|
|
413
|
+
return {
|
|
414
|
+
target: "omp",
|
|
415
|
+
state: "failed",
|
|
416
|
+
output: error instanceof Error ? error.message : String(error),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Whether Cursor's global MCP config already names Whiteboard's server. */
|
|
422
|
+
async function cursorHasServer(homeDir: string): Promise<boolean> {
|
|
423
|
+
try {
|
|
424
|
+
const config: JsonValue = JSON.parse(
|
|
425
|
+
await readFile(path.join(homeDir, ".cursor", "mcp.json"), "utf8"),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
isJsonObject(config) &&
|
|
430
|
+
isJsonObject(config.mcpServers) &&
|
|
431
|
+
config.mcpServers.whiteboard !== undefined
|
|
432
|
+
);
|
|
433
|
+
} catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Cursor only installs an MCP server the user confirms, so open its link,
|
|
439
|
+
* unless it already has one: confirming again would ask for nothing new. */
|
|
440
|
+
async function connectCursor(
|
|
441
|
+
homeDir: string,
|
|
442
|
+
launch: McpLaunch,
|
|
443
|
+
platform: NodeJS.Platform,
|
|
444
|
+
run: ConnectRun,
|
|
445
|
+
): Promise<ReviewConnectResult> {
|
|
446
|
+
if (await cursorHasServer(homeDir))
|
|
447
|
+
return {
|
|
448
|
+
target: "cursor",
|
|
449
|
+
state: "connected",
|
|
450
|
+
output: "Cursor already has the whiteboard MCP server.",
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const url = cursorInstallDeeplink(launch);
|
|
454
|
+
|
|
455
|
+
const [file, args] =
|
|
456
|
+
platform === "darwin"
|
|
457
|
+
? // In the background, so Cursor's prompt waits behind Whiteboard.
|
|
458
|
+
["open", ["-g", url]]
|
|
459
|
+
: platform === "win32"
|
|
460
|
+
? ["cmd", ["/d", "/c", "start", '""', url]]
|
|
461
|
+
: ["xdg-open", [url]];
|
|
462
|
+
|
|
463
|
+
const result = await run(file, args);
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
target: "cursor",
|
|
467
|
+
state: result.code === 0 ? "approve" : "failed",
|
|
468
|
+
output: result.output,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Installs Whiteboard's plugin, or its MCP server, into one harness. */
|
|
473
|
+
export async function connectAgent(
|
|
474
|
+
target: InstallTarget,
|
|
475
|
+
input: ConnectEnvironment,
|
|
476
|
+
): Promise<ReviewConnectResult> {
|
|
477
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
478
|
+
const env = input.env ?? process.env;
|
|
479
|
+
const platform = input.platform ?? process.platform;
|
|
480
|
+
const run = input.run ?? runProcess;
|
|
481
|
+
const launch = reviewMcpLaunch(input.hasShim, platform);
|
|
482
|
+
|
|
483
|
+
if (target === "omp") return connectOmp(homeDir, launch);
|
|
484
|
+
|
|
485
|
+
if (target === "cursor") return connectCursor(homeDir, launch, platform, run);
|
|
486
|
+
|
|
487
|
+
const executable = await findHarness(HARNESSES[target], homeDir, env);
|
|
488
|
+
|
|
489
|
+
if (!executable)
|
|
490
|
+
return {
|
|
491
|
+
target,
|
|
492
|
+
state: "failed",
|
|
493
|
+
output: `${HARNESSES[target].name} is not installed.`,
|
|
494
|
+
};
|
|
495
|
+
const outputs: string[] = [];
|
|
496
|
+
|
|
497
|
+
for (const step of cliSteps(target, launch, platform)) {
|
|
498
|
+
const result = await run(executable, step.args);
|
|
499
|
+
|
|
500
|
+
outputs.push(result.output);
|
|
501
|
+
|
|
502
|
+
if (result.code !== 0 && !step.optional && !step.done?.test(result.output))
|
|
503
|
+
return { target, state: "failed", output: outputs.join("\n").trim() };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { target, state: "connected", output: outputs.join("\n").trim() };
|
|
507
|
+
}
|
package/src/agent-selection.ts
CHANGED
|
@@ -35,6 +35,8 @@ export const AgentSelectionSchema = z.strictObject({
|
|
|
35
35
|
startLine: z.number().int().positive(),
|
|
36
36
|
endLine: z.number().int().positive(),
|
|
37
37
|
}),
|
|
38
|
+
/** Nothing in particular: a question about the whole review. */
|
|
39
|
+
z.strictObject({ kind: z.literal("review") }),
|
|
38
40
|
]),
|
|
39
41
|
title: z.string().max(500),
|
|
40
42
|
detail: z.string().max(24000).optional(),
|
|
@@ -55,6 +57,8 @@ export function selectionMarkdown(
|
|
|
55
57
|
if (selection.selectedDiff)
|
|
56
58
|
return selectedDiffMarkdown(selection.selectedDiff, diffPaths);
|
|
57
59
|
|
|
60
|
+
if (target.kind === "review") return "";
|
|
61
|
+
|
|
58
62
|
if (target.kind === "text")
|
|
59
63
|
return target.quote
|
|
60
64
|
.split(/\r?\n/)
|