@dev.fast/whiteboard 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,3952 @@
|
|
|
1
|
+
import { c as jsonString, n as isJsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
|
|
2
|
+
import { a as sharedReviewId, c as verifyShareRepository, d as StreamLimitError, f as readBoundedStream, l as exportShare, m as shareIdSchema, n as ShareClient, p as parseShareLink, r as SharePreflightError, s as readShareRepository, t as ShareAuthError, u as validateShareSources } from "./client-DFoh50W3.js";
|
|
3
|
+
import { d as REVIEW_CLIENT_REMOTE, i as parseReviewVerbRequest, r as parseReviewDesktopVerbResult, u as REVIEW_CLIENT_HEADER } from "./src-DnwdaQ2r.js";
|
|
4
|
+
import { C as TraceStorageDeniedError, G as readStoreAuth, H as DEFAULT_STORE_ORIGIN, U as clearStoreAuth, W as openUrlInBrowser, _ as isTraceStorageConfigured, b as selectTraceStorage, o as listReviewTraceSessions, q as runStoreLogin, s as loadReviewAgentTrace, y as resolveTraceStorage } from "./review-agent-traces-DNJFYzc8.js";
|
|
5
|
+
import { a as inspectSnapshot, c as askAgentIds, d as askQuestionSchema, f as AgentSelectionSchema, i as commandSchema, l as askChoiceKinds, p as selectionMarkdown, r as SCRATCHPAD_ID, s as applyAskChange, u as askPicksSchema } from "./local-data-CxLk75rx.js";
|
|
6
|
+
import { $ as unionIntervals, G as selectionKey, H as comparisonKey, I as fileLineRangeSchema, J as coverageSources, N as lensSelections, Q as subtractIntervals, T as selectionReferences, U as formatAnchor, V as anchorSelection, W as resolveDiffSelection, Y as emptyCoverage, Z as scopedCoverage, h as elements, k as UNCATEGORIZED_LENS_ID, u as anchorPins } from "./stored-document-migration-DHeMpLMb.js";
|
|
7
|
+
import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
|
|
8
|
+
import { A as devReviewHome, h as TraceConfigurationError, i as isS3MockMode } from "./s3-config-CAxhO9u_.js";
|
|
9
|
+
import { t as errorMessage } from "./error-message-OtiDonty.js";
|
|
10
|
+
import { T as traceMachineEnabled } from "./review-home-paths-6zZH-1c9.js";
|
|
11
|
+
import { i as readReviewPackageVersion, n as readBuildCommit } from "./package-paths-B6-zxvIO.js";
|
|
12
|
+
import { a as REVIEW_APP_SESSION_ID_HEADER, c as sanitizeUiTelemetryEvent, i as BUNDLE_FRAME_PATTERN, l as cleanTelemetryText, r as reviewRequestOrigin, s as isReportableCleanedMessage } from "./request-origin-D_4QIdoN.js";
|
|
13
|
+
import { a as scratchpadAvailable, c as readQuerySchemas, i as renderInstructions, o as inspectQuerySchema, r as instructionsQuerySchema, s as queryAnchor, t as authoringTools } from "./authoring-tools-Cr6Kpjsx.js";
|
|
14
|
+
import { t as fuzzyRank } from "./fuzzy-match-BoAmcyak.js";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import crypto$1, { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { constants, mkdirSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { access, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
20
|
+
import path, { posix } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { execFile, spawn } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { pathToFileURL } from "node:url";
|
|
25
|
+
import { Readable, Writable } from "node:stream";
|
|
26
|
+
import { getRequestListener } from "@hono/node-server";
|
|
27
|
+
import { Hono } from "hono";
|
|
28
|
+
import { PROTOCOL_VERSION, RequestError, client, methods, ndJsonStream } from "@agentclientprotocol/sdk";
|
|
29
|
+
import { streamSSE } from "hono/streaming";
|
|
30
|
+
//#region src/server/global-verb-relay.ts
|
|
31
|
+
const DEFAULT_VERB_TIMEOUT_MS = 45e3;
|
|
32
|
+
const DEFAULT_MAX_CLIENTS = 16;
|
|
33
|
+
const NOT_ATTACHED = "No Whiteboard Desktop is attached.";
|
|
34
|
+
/**
|
|
35
|
+
* Sends each verb to every attached client, each with its own frame id, and
|
|
36
|
+
* resolves with the first success, or with the last failure once no client
|
|
37
|
+
* is left to answer.
|
|
38
|
+
*/
|
|
39
|
+
var GlobalReviewDesktopVerbRelay = class {
|
|
40
|
+
clients = /* @__PURE__ */ new Map();
|
|
41
|
+
/** Each verb in flight, under every frame id it was sent with. */
|
|
42
|
+
pending = /* @__PURE__ */ new Map();
|
|
43
|
+
timeoutMs;
|
|
44
|
+
maxClients;
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_VERB_TIMEOUT_MS;
|
|
47
|
+
this.maxClients = options.maxClients ?? DEFAULT_MAX_CLIENTS;
|
|
48
|
+
}
|
|
49
|
+
get attached() {
|
|
50
|
+
return this.clients.size > 0;
|
|
51
|
+
}
|
|
52
|
+
attach(writer) {
|
|
53
|
+
if (this.clients.size >= this.maxClients || this.clients.has(writer) || writer.signal.aborted) return false;
|
|
54
|
+
const detach = () => this.detach(writer);
|
|
55
|
+
this.clients.set(writer, detach);
|
|
56
|
+
writer.signal.addEventListener("abort", detach, { once: true });
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
dispatch(value) {
|
|
60
|
+
const request = parseReviewVerbRequest(value);
|
|
61
|
+
if (this.clients.size === 0) return Promise.resolve({
|
|
62
|
+
ok: false,
|
|
63
|
+
error: NOT_ATTACHED
|
|
64
|
+
});
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const verb = {
|
|
67
|
+
resolve,
|
|
68
|
+
waiting: new Map([...this.clients.keys()].map((client) => [crypto$1.randomUUID(), client])),
|
|
69
|
+
timer: setTimeout(() => this.settle(verb, verb.lastFailure ?? {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: "Whiteboard Desktop verb timed out."
|
|
72
|
+
}), this.timeoutMs)
|
|
73
|
+
};
|
|
74
|
+
verb.timer.unref?.();
|
|
75
|
+
for (const id of verb.waiting.keys()) this.pending.set(id, verb);
|
|
76
|
+
for (const [id, client] of [...verb.waiting]) {
|
|
77
|
+
const frame = `data: ${JSON.stringify({
|
|
78
|
+
event: "desktop-verb",
|
|
79
|
+
id,
|
|
80
|
+
request
|
|
81
|
+
})}\n\n`;
|
|
82
|
+
try {
|
|
83
|
+
Promise.resolve(client.write(frame)).catch(() => {
|
|
84
|
+
this.detach(client);
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
this.detach(client);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
acceptResult(value) {
|
|
93
|
+
const result = parseReviewDesktopVerbResult(value);
|
|
94
|
+
const verb = this.pending.get(result.id);
|
|
95
|
+
if (!verb) return false;
|
|
96
|
+
if (result.response.ok) this.settle(verb, result.response);
|
|
97
|
+
else {
|
|
98
|
+
verb.lastFailure = result.response;
|
|
99
|
+
this.stopWaiting(verb, result.id);
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
close() {
|
|
104
|
+
for (const verb of new Set(this.pending.values())) this.settle(verb, {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: "Whiteboard Desktop relay closed."
|
|
107
|
+
});
|
|
108
|
+
for (const client of [...this.clients.keys()]) {
|
|
109
|
+
this.detach(client);
|
|
110
|
+
try {
|
|
111
|
+
Promise.resolve(client.close()).catch(() => void 0);
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
detach(writer) {
|
|
116
|
+
const listener = this.clients.get(writer);
|
|
117
|
+
if (!listener) return;
|
|
118
|
+
writer.signal.removeEventListener("abort", listener);
|
|
119
|
+
this.clients.delete(writer);
|
|
120
|
+
for (const [id, verb] of [...this.pending]) if (verb.waiting.get(id) === writer) this.stopWaiting(verb, id);
|
|
121
|
+
}
|
|
122
|
+
/** Drops one client's id; with nobody left to answer, the verb fails. */
|
|
123
|
+
stopWaiting(verb, id) {
|
|
124
|
+
verb.waiting.delete(id);
|
|
125
|
+
this.pending.delete(id);
|
|
126
|
+
if (verb.waiting.size === 0) this.settle(verb, verb.lastFailure ?? {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: NOT_ATTACHED
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
settle(verb, response) {
|
|
132
|
+
for (const id of verb.waiting.keys()) this.pending.delete(id);
|
|
133
|
+
verb.waiting.clear();
|
|
134
|
+
clearTimeout(verb.timer);
|
|
135
|
+
verb.resolve(response);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/server/http-json.ts
|
|
140
|
+
const DEFAULT_MAX_REQUEST_BYTES = 1048576;
|
|
141
|
+
var HttpJsonError = class extends Error {
|
|
142
|
+
statusCode;
|
|
143
|
+
constructor(message, statusCode) {
|
|
144
|
+
super(message);
|
|
145
|
+
this.statusCode = statusCode;
|
|
146
|
+
this.name = "HttpJsonError";
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
/** An error whose status and machine code the JSON routes hand straight back. */
|
|
150
|
+
var ReviewServerError = class extends Error {
|
|
151
|
+
statusCode;
|
|
152
|
+
code;
|
|
153
|
+
name = "ReviewServerError";
|
|
154
|
+
constructor(message, statusCode, code) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.statusCode = statusCode;
|
|
157
|
+
this.code = code;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/server/hono-http.ts
|
|
162
|
+
function createNodeRequestListener(app) {
|
|
163
|
+
return getRequestListener(app.fetch);
|
|
164
|
+
}
|
|
165
|
+
function jsonResponse(body, status, options = {}) {
|
|
166
|
+
const headers = new Headers({ "content-type": options.contentType ?? "application/json; charset=utf-8" });
|
|
167
|
+
if (options.cacheControl) headers.set("cache-control", options.cacheControl);
|
|
168
|
+
const serialized = JSON.stringify(body);
|
|
169
|
+
return new Response(options.newline === false ? serialized : `${serialized}\n`, {
|
|
170
|
+
status,
|
|
171
|
+
headers
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function applyCorsHeaders(request, response) {
|
|
175
|
+
const origin = request.headers.get("origin");
|
|
176
|
+
if (origin) {
|
|
177
|
+
response.headers.set("access-control-allow-origin", origin);
|
|
178
|
+
const vary = response.headers.get("vary");
|
|
179
|
+
const varyFields = vary?.split(",").map((field) => field.trim().toLowerCase());
|
|
180
|
+
if (!varyFields?.includes("*") && !varyFields?.includes("origin")) response.headers.set("vary", vary ? `${vary}, Origin` : "Origin");
|
|
181
|
+
}
|
|
182
|
+
response.headers.set("access-control-allow-headers", `content-type, x-review-token, ${REVIEW_APP_SESSION_ID_HEADER}`);
|
|
183
|
+
response.headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
184
|
+
response.headers.set("access-control-allow-private-network", "true");
|
|
185
|
+
return response;
|
|
186
|
+
}
|
|
187
|
+
function corsPreflightResponse(request) {
|
|
188
|
+
return applyCorsHeaders(request, new Response(null, { status: 204 }));
|
|
189
|
+
}
|
|
190
|
+
function isAuthorizedRequest(request, expectedToken) {
|
|
191
|
+
const supplied = request.headers.get("x-review-token") ?? new URL(request.url).searchParams.get("token");
|
|
192
|
+
if (!supplied) return false;
|
|
193
|
+
const expected = Buffer.from(expectedToken);
|
|
194
|
+
const actual = Buffer.from(supplied);
|
|
195
|
+
return expected.length === actual.length && crypto$1.timingSafeEqual(expected, actual);
|
|
196
|
+
}
|
|
197
|
+
async function readBoundedRequestJson(request, maxBytes = DEFAULT_MAX_REQUEST_BYTES, emptyValue, options = {}) {
|
|
198
|
+
assertJsonContentType(request, options);
|
|
199
|
+
const contentLength = Number(request.headers.get("content-length"));
|
|
200
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) throw requestTooLarge(maxBytes);
|
|
201
|
+
let body;
|
|
202
|
+
try {
|
|
203
|
+
body = request.body ? (await readBoundedStream(request.body, maxBytes)).toString("utf8") : "";
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof StreamLimitError) throw requestTooLarge(maxBytes);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
if (!body && emptyValue !== void 0) return emptyValue;
|
|
209
|
+
try {
|
|
210
|
+
return parseJsonText(body);
|
|
211
|
+
} catch {
|
|
212
|
+
throw new HttpJsonError("Invalid JSON body.", 400);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function assertJsonContentType(request, options) {
|
|
216
|
+
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
217
|
+
if (mediaType !== "application/json" && !mediaType?.endsWith("+json") && !(options.allowTextPlain && mediaType === "text/plain")) throw new HttpJsonError("Content-Type must be application/json.", 415);
|
|
218
|
+
}
|
|
219
|
+
function requestTooLarge(maxBytes) {
|
|
220
|
+
return new HttpJsonError(`Request body exceeds ${maxBytes === 1048576 ? "1 MiB" : `${maxBytes} bytes`}.`, 413);
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/error-telemetry.ts
|
|
224
|
+
/**
|
|
225
|
+
* Errors whose message quotes the document being checked. A review tool runs
|
|
226
|
+
* authored prose through schemas, so these carry review content by design —
|
|
227
|
+
* unlike an editor, which mostly reports on files. Keep the class, the digest
|
|
228
|
+
* and the frames; drop the message. VS Code drops file-operation errors and
|
|
229
|
+
* errors carrying a system code for the same reason.
|
|
230
|
+
*/
|
|
231
|
+
const SCHEMA_ERROR_NAMES = /* @__PURE__ */ new Set(["ZodError", "ValidationError"]);
|
|
232
|
+
const MESSAGE_HASH_LENGTH = 16;
|
|
233
|
+
const MAX_FRAMES = 10;
|
|
234
|
+
const MAX_STACK_LENGTH = 16384;
|
|
235
|
+
const MAX_ERROR_NAME_LENGTH = 40;
|
|
236
|
+
const ERROR_NAME_PATTERN = /^[A-Za-z0-9_$-]+$/;
|
|
237
|
+
/**
|
|
238
|
+
* Path segments that exist only inside the shipped bundle. A frame keeps the
|
|
239
|
+
* text that follows the LAST occurrence of one of these and discards everything
|
|
240
|
+
* before it, which is what removes the absolute prefix. A frame with no such
|
|
241
|
+
* segment is not ours, so it is dropped.
|
|
242
|
+
*/
|
|
243
|
+
const BUNDLE_ANCHORS = [
|
|
244
|
+
"/out/",
|
|
245
|
+
"/assets/",
|
|
246
|
+
"/review-runtime/"
|
|
247
|
+
];
|
|
248
|
+
/** `at fn (url:line:col)`, `at url:line:col`, and the bare `url:line:col`. */
|
|
249
|
+
const STACK_FRAME_PATTERN = /(?:\(|\bat\s+|^\s*)([^()\s]+?):(\d+):(\d+)\)?\s*$/;
|
|
250
|
+
/**
|
|
251
|
+
* `cause` is the raw error envelope a client attached beside its event
|
|
252
|
+
* properties: `{ name, message, stack }` as JSON, or anything else a hostile
|
|
253
|
+
* client sent, which derives nothing.
|
|
254
|
+
*/
|
|
255
|
+
function deriveErrorTelemetryProperties(cause) {
|
|
256
|
+
const derived = {};
|
|
257
|
+
try {
|
|
258
|
+
if (!isJsonObject(cause)) return derived;
|
|
259
|
+
const name = jsonString(cause.name);
|
|
260
|
+
if (name !== void 0 && name.length > 0 && name.length <= MAX_ERROR_NAME_LENGTH && ERROR_NAME_PATTERN.test(name)) derived.error_name = name;
|
|
261
|
+
const message = jsonString(cause.message);
|
|
262
|
+
if (message !== void 0 && message.length > 0) {
|
|
263
|
+
derived.message_hash = hashErrorMessage(message);
|
|
264
|
+
if (!SCHEMA_ERROR_NAMES.has(derived.error_name ?? "")) {
|
|
265
|
+
const cleaned = cleanTelemetryText(message, NO_DELETED_DIRECTORIES);
|
|
266
|
+
if (isReportableCleanedMessage(cleaned)) derived.message = cleaned;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const frames = packBundleFrames(cause.stack);
|
|
270
|
+
if (frames) derived.frames = frames;
|
|
271
|
+
} catch {}
|
|
272
|
+
return derived;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Properties only this module may produce, because each is a claim about work
|
|
276
|
+
* the server did that the allowlist cannot check for itself: whether a message
|
|
277
|
+
* was cleaned, and whether a digest or a frame list really came from this
|
|
278
|
+
* error. A client that sent one directly would reach the allowlist with no
|
|
279
|
+
* cleaning behind it.
|
|
280
|
+
*
|
|
281
|
+
* `error_name` is deliberately absent. The allowlist caps it at 40 identifier
|
|
282
|
+
* characters, which makes it safe whoever sends it, and `bug_report_send_failed`
|
|
283
|
+
* sends it with no error envelope.
|
|
284
|
+
*/
|
|
285
|
+
const SERVER_DERIVED_PROPERTIES = [
|
|
286
|
+
"message",
|
|
287
|
+
"message_hash",
|
|
288
|
+
"frames"
|
|
289
|
+
];
|
|
290
|
+
/**
|
|
291
|
+
* Merge a client's event properties with the ones derived from its raw error.
|
|
292
|
+
* This is the only supported way to build an error-bearing telemetry payload:
|
|
293
|
+
* it removes any server-derived property the client tried to assert before
|
|
294
|
+
* adding the real ones.
|
|
295
|
+
*/
|
|
296
|
+
function mergeErrorTelemetryProperties(clientProperties, cause) {
|
|
297
|
+
const merged = { ...clientProperties };
|
|
298
|
+
for (const key of SERVER_DERIVED_PROPERTIES) delete merged[key];
|
|
299
|
+
if (cause) Object.assign(merged, deriveErrorTelemetryProperties(cause));
|
|
300
|
+
return merged;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Review deliberately gives the cleaner NO directories to delete, which is the
|
|
304
|
+
* one place its configuration differs from VS Code's.
|
|
305
|
+
*
|
|
306
|
+
* VS Code passes five — its app root, extensions path, user data path, home
|
|
307
|
+
* directory and temporary directory — and deletes each prefix outright so the
|
|
308
|
+
* remainder reads as a relative path. That is right for a stack trace, where
|
|
309
|
+
* the remainder is VS Code's own file. It is wrong here: a path under the home
|
|
310
|
+
* directory then keeps everything after it, so
|
|
311
|
+
* `/Users/you/work/acme-repo/plan.md` would be sent as
|
|
312
|
+
* `/work/acme-repo/plan.md` — the repository name intact. A review tool cannot
|
|
313
|
+
* disclose that.
|
|
314
|
+
*
|
|
315
|
+
* With no directories to delete, the cleaner's overlap check never fires and
|
|
316
|
+
* the whole path is replaced by a marker instead, which is what we want. Frames
|
|
317
|
+
* are unaffected: they are anchored on the bundle directory separately.
|
|
318
|
+
*/
|
|
319
|
+
const NO_DELETED_DIRECTORIES = [];
|
|
320
|
+
/**
|
|
321
|
+
* A stable fingerprint of the message. Two reports with the same digest had the
|
|
322
|
+
* same cause; the digest itself reveals nothing, because it is one-way and the
|
|
323
|
+
* message is never sent alongside it.
|
|
324
|
+
*/
|
|
325
|
+
function hashErrorMessage(message) {
|
|
326
|
+
return createHash("sha256").update(message, "utf8").digest("hex").slice(0, MESSAGE_HASH_LENGTH);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Rewrite a stack into bundle-relative `file:line:col` frames joined by "|".
|
|
330
|
+
* Returns undefined when no frame resolves inside the bundle.
|
|
331
|
+
*/
|
|
332
|
+
function packBundleFrames(stack) {
|
|
333
|
+
const text = Array.isArray(stack) ? stack.join("\n") : jsonString(stack);
|
|
334
|
+
if (!text) return void 0;
|
|
335
|
+
const frames = [];
|
|
336
|
+
for (const line of text.slice(0, MAX_STACK_LENGTH).split("\n")) {
|
|
337
|
+
const match = STACK_FRAME_PATTERN.exec(line);
|
|
338
|
+
if (!match) continue;
|
|
339
|
+
const file = bundleRelativePath(match[1]);
|
|
340
|
+
if (!file) continue;
|
|
341
|
+
const frame = `${file}:${match[2]}:${match[3]}`;
|
|
342
|
+
if (!BUNDLE_FRAME_PATTERN.test(frame)) continue;
|
|
343
|
+
frames.push(frame);
|
|
344
|
+
if (frames.length >= MAX_FRAMES) break;
|
|
345
|
+
}
|
|
346
|
+
return frames.length > 0 ? frames.join("|") : void 0;
|
|
347
|
+
}
|
|
348
|
+
function bundleRelativePath(location) {
|
|
349
|
+
const clean = location.split(/[?#]/, 1)[0];
|
|
350
|
+
let cut = -1;
|
|
351
|
+
let anchorLength = 0;
|
|
352
|
+
for (const anchor of BUNDLE_ANCHORS) {
|
|
353
|
+
const index = clean.lastIndexOf(anchor);
|
|
354
|
+
if (index > cut) {
|
|
355
|
+
cut = index;
|
|
356
|
+
anchorLength = anchor.length;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (cut < 0) return void 0;
|
|
360
|
+
const relative = clean.slice(cut + anchorLength);
|
|
361
|
+
const prefix = clean.slice(cut + 1, cut + anchorLength);
|
|
362
|
+
const path = prefix === "out/" ? relative : `${prefix}${relative}`;
|
|
363
|
+
return path.length > 0 ? path : void 0;
|
|
364
|
+
}
|
|
365
|
+
const BURST_EVERY = 100;
|
|
366
|
+
const DEFAULT_MAX_KEYS = 1e3;
|
|
367
|
+
var ClientErrorBudget = class {
|
|
368
|
+
maxKeys;
|
|
369
|
+
counts = /* @__PURE__ */ new Map();
|
|
370
|
+
constructor(maxKeys = DEFAULT_MAX_KEYS) {
|
|
371
|
+
this.maxKeys = maxKeys;
|
|
372
|
+
}
|
|
373
|
+
admit(sessionId, messageHash) {
|
|
374
|
+
const key = `${sessionId}\n${messageHash}`;
|
|
375
|
+
const count = (this.counts.get(key) ?? 0) + 1;
|
|
376
|
+
this.counts.delete(key);
|
|
377
|
+
this.counts.set(key, count);
|
|
378
|
+
while (this.counts.size > this.maxKeys) {
|
|
379
|
+
const oldest = this.counts.keys().next().value;
|
|
380
|
+
if (oldest === void 0) break;
|
|
381
|
+
this.counts.delete(oldest);
|
|
382
|
+
}
|
|
383
|
+
const suppressed = Math.max(0, count - 5);
|
|
384
|
+
if (suppressed === 0) return {
|
|
385
|
+
verdict: "send",
|
|
386
|
+
suppressed
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
verdict: suppressed === 1 || suppressed % BURST_EVERY === 0 ? "burst" : "drop",
|
|
390
|
+
suppressed
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/server/ui-telemetry.ts
|
|
396
|
+
const contextSchema = z.object({
|
|
397
|
+
reviewUuid: z.string().min(1).max(128).optional(),
|
|
398
|
+
presentationSessionId: z.string().min(1).max(128).optional()
|
|
399
|
+
});
|
|
400
|
+
const clientErrorBudget = new ClientErrorBudget();
|
|
401
|
+
/**
|
|
402
|
+
* Applies the per-session, per-digest error budget to a sanitized event, and
|
|
403
|
+
* returns what to send in its place: the event itself, one
|
|
404
|
+
* review_error_burst that says how many were withheld, or nothing. Every path
|
|
405
|
+
* that reports review_client_error goes through here. Other events, and errors
|
|
406
|
+
* without a session or a digest, pass unchanged.
|
|
407
|
+
*/
|
|
408
|
+
function admitUiTelemetryEvent(event) {
|
|
409
|
+
if (event.event !== "review_client_error") return event;
|
|
410
|
+
const sessionId = jsonString(event.properties.app_session_id);
|
|
411
|
+
const messageHash = jsonString(event.properties.message_hash);
|
|
412
|
+
if (sessionId === void 0 || messageHash === void 0) return event;
|
|
413
|
+
const admission = clientErrorBudget.admit(sessionId, messageHash);
|
|
414
|
+
if (admission.verdict === "send") return event;
|
|
415
|
+
if (admission.verdict === "drop") return void 0;
|
|
416
|
+
return sanitizeUiTelemetryEvent({
|
|
417
|
+
name: "error_burst",
|
|
418
|
+
properties: {
|
|
419
|
+
message_hash: messageHash,
|
|
420
|
+
suppressed: admission.suppressed,
|
|
421
|
+
app_session_id: sessionId
|
|
422
|
+
}
|
|
423
|
+
}) ?? void 0;
|
|
424
|
+
}
|
|
425
|
+
const MAX_CLIENT_ERROR_SESSIONS = 100;
|
|
426
|
+
const MAX_CLIENT_ERRORS_PER_SESSION = 20;
|
|
427
|
+
const clientErrorsBySession = /* @__PURE__ */ new Map();
|
|
428
|
+
function recordClientError(event) {
|
|
429
|
+
if (event?.event !== "review_client_error") return;
|
|
430
|
+
const sessionId = jsonString(event.properties.app_session_id);
|
|
431
|
+
const errorName = jsonString(event.properties.error_name);
|
|
432
|
+
if (sessionId === void 0 || errorName === void 0) return;
|
|
433
|
+
const names = clientErrorsBySession.get(sessionId) ?? [];
|
|
434
|
+
names.push(errorName);
|
|
435
|
+
if (names.length > MAX_CLIENT_ERRORS_PER_SESSION) names.shift();
|
|
436
|
+
clientErrorsBySession.delete(sessionId);
|
|
437
|
+
clientErrorsBySession.set(sessionId, names);
|
|
438
|
+
while (clientErrorsBySession.size > MAX_CLIENT_ERROR_SESSIONS) {
|
|
439
|
+
const oldest = clientErrorsBySession.keys().next().value;
|
|
440
|
+
if (oldest === void 0) break;
|
|
441
|
+
clientErrorsBySession.delete(oldest);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function clientErrorsForSession(sessionId) {
|
|
445
|
+
const names = clientErrorsBySession.get(sessionId) ?? [];
|
|
446
|
+
if (names.length > 0) {
|
|
447
|
+
clientErrorsBySession.delete(sessionId);
|
|
448
|
+
clientErrorsBySession.set(sessionId, names);
|
|
449
|
+
}
|
|
450
|
+
return [...names];
|
|
451
|
+
}
|
|
452
|
+
/** How far back a client may date its own event. */
|
|
453
|
+
const MAX_CLIENT_EVENT_AGE_MS = 3e5;
|
|
454
|
+
/**
|
|
455
|
+
* The client's occurrence time, clamped to the recent past: parallel requests
|
|
456
|
+
* can arrive out of order, but a client can never date an event ahead of now
|
|
457
|
+
* or far behind it.
|
|
458
|
+
*/
|
|
459
|
+
function clientOccurredAt(raw, now) {
|
|
460
|
+
const parsed = z.number().safeParse(raw);
|
|
461
|
+
if (!parsed.success) return now;
|
|
462
|
+
return Math.min(now, Math.max(now - MAX_CLIENT_EVENT_AGE_MS, parsed.data));
|
|
463
|
+
}
|
|
464
|
+
async function captureSanitizedUiTelemetry(telemetry, request, name, properties, onSanitized, rawError, rawContext, rawOccurredAt) {
|
|
465
|
+
const occurredAt = clientOccurredAt(rawOccurredAt, Date.now());
|
|
466
|
+
const appSessionId = request.headers.get("x-review-app-session-id") ?? void 0;
|
|
467
|
+
const mergedProperties = mergeErrorTelemetryProperties(isJsonObject(properties) ? properties : {}, rawError);
|
|
468
|
+
if (appSessionId) mergedProperties.app_session_id = appSessionId;
|
|
469
|
+
const sanitized = sanitizeUiTelemetryEvent({
|
|
470
|
+
name,
|
|
471
|
+
properties: mergedProperties
|
|
472
|
+
});
|
|
473
|
+
if (!sanitized) return;
|
|
474
|
+
const admitted = admitUiTelemetryEvent(sanitized);
|
|
475
|
+
if (!admitted) return;
|
|
476
|
+
const parsedContext = contextSchema.safeParse(rawContext);
|
|
477
|
+
const context = parsedContext.success ? parsedContext.data : void 0;
|
|
478
|
+
onSanitized?.(admitted, context);
|
|
479
|
+
try {
|
|
480
|
+
await telemetry.captureUiEvent?.(admitted.event, admitted.properties, context, occurredAt);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
console.error(error);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
//#endregion
|
|
486
|
+
//#region src/ask/pi-mcp.ts
|
|
487
|
+
/**
|
|
488
|
+
* pi-acp keeps the MCP servers a session is given without passing them to
|
|
489
|
+
* Pi. Pi 0.99.0 and later take them from an extension, so Ask starts Pi
|
|
490
|
+
* through a wrapper that loads one, registering the servers for that session
|
|
491
|
+
* only. Older Pi, and Windows, which would need a different wrapper, answer
|
|
492
|
+
* without them.
|
|
493
|
+
*/
|
|
494
|
+
/** The first Pi with `pi.registerMcpServer()`. */
|
|
495
|
+
const PI_MCP_VERSION = [
|
|
496
|
+
0,
|
|
497
|
+
99,
|
|
498
|
+
0
|
|
499
|
+
];
|
|
500
|
+
/** How long a version read is trusted: a session's first prompt and its
|
|
501
|
+
* launch ask moments apart, and an update during a review is seen soon. */
|
|
502
|
+
const VERSION_TTL_MS = 3e4;
|
|
503
|
+
const SERVERS_ENV = "WHITEBOARD_ASK_MCP";
|
|
504
|
+
const PI_ENV = "WHITEBOARD_ASK_PI";
|
|
505
|
+
const EXTENSION_ENV = "WHITEBOARD_ASK_PI_EXTENSION";
|
|
506
|
+
const EXTENSION = `// Managed by Whiteboard: gives a Pi session that Ask starts the MCP servers
|
|
507
|
+
// Whiteboard hands it. Do not edit.
|
|
508
|
+
export default function (pi: {
|
|
509
|
+
registerMcpServer(name: string, config: object): void;
|
|
510
|
+
}) {
|
|
511
|
+
const servers = JSON.parse(process.env.${SERVERS_ENV} ?? "{}");
|
|
512
|
+
|
|
513
|
+
for (const [name, config] of Object.entries(servers))
|
|
514
|
+
pi.registerMcpServer(name, config as object);
|
|
515
|
+
}
|
|
516
|
+
`;
|
|
517
|
+
const WRAPPER = `#!/bin/sh
|
|
518
|
+
# Managed by Whiteboard: starts Pi for Ask with Whiteboard's extension.
|
|
519
|
+
exec "$${PI_ENV}" -e "$${EXTENSION_ENV}" "$@"
|
|
520
|
+
`;
|
|
521
|
+
const versions = /* @__PURE__ */ new Map();
|
|
522
|
+
function piVersion(executable) {
|
|
523
|
+
const cached = versions.get(executable);
|
|
524
|
+
if (cached && Date.now() - cached.read < VERSION_TTL_MS) return cached.version;
|
|
525
|
+
const version = promisify(execFile)(executable, ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+)\.(\d+)\.(\d+)/u.exec(stdout)?.slice(1).map(Number)).catch(() => void 0);
|
|
526
|
+
versions.set(executable, {
|
|
527
|
+
read: Date.now(),
|
|
528
|
+
version
|
|
529
|
+
});
|
|
530
|
+
return version;
|
|
531
|
+
}
|
|
532
|
+
/** Whether this Pi can be given MCP servers. */
|
|
533
|
+
async function piTakesMcp(executable) {
|
|
534
|
+
if (process.platform === "win32") return false;
|
|
535
|
+
const version = await piVersion(executable);
|
|
536
|
+
if (!version) return false;
|
|
537
|
+
for (const [index, least] of PI_MCP_VERSION.entries()) {
|
|
538
|
+
const part = version[index] ?? 0;
|
|
539
|
+
if (part !== least) return part > least;
|
|
540
|
+
}
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
/** Writes a file only when it changed, replacing it whole, since another
|
|
544
|
+
* Ask session may be running it. */
|
|
545
|
+
async function keep(file, content, mode) {
|
|
546
|
+
if (await readFile(file, "utf8").catch(() => void 0) === content) return;
|
|
547
|
+
const staged = `${file}.${process.pid}.tmp`;
|
|
548
|
+
await writeFile(staged, content, { mode });
|
|
549
|
+
await chmod(staged, mode);
|
|
550
|
+
await rename(staged, file);
|
|
551
|
+
}
|
|
552
|
+
function piServers(servers) {
|
|
553
|
+
const piServers = {};
|
|
554
|
+
const record = (pairs) => Object.fromEntries(pairs.map(({ name, value }) => [name, value]));
|
|
555
|
+
for (const server of servers) if ("command" in server) piServers[server.name] = {
|
|
556
|
+
command: server.command,
|
|
557
|
+
args: server.args,
|
|
558
|
+
env: record(server.env),
|
|
559
|
+
exposure: "direct"
|
|
560
|
+
};
|
|
561
|
+
else if (server.type === "http") piServers[server.name] = {
|
|
562
|
+
url: server.url,
|
|
563
|
+
headers: record(server.headers),
|
|
564
|
+
exposure: "direct"
|
|
565
|
+
};
|
|
566
|
+
return piServers;
|
|
567
|
+
}
|
|
568
|
+
/** The command pi-acp runs in place of Pi: Pi with the extension that
|
|
569
|
+
* registers `servers`, which go to it in `env`. */
|
|
570
|
+
async function piWithMcp(executable, servers, env) {
|
|
571
|
+
const directory = path.join(devReviewHome(), "ask", "pi");
|
|
572
|
+
const extension = path.join(directory, "whiteboard-mcp.ts");
|
|
573
|
+
const wrapper = path.join(directory, "pi");
|
|
574
|
+
await mkdir(directory, { recursive: true });
|
|
575
|
+
await keep(extension, EXTENSION, 420);
|
|
576
|
+
await keep(wrapper, WRAPPER, 493);
|
|
577
|
+
env[PI_ENV] = executable;
|
|
578
|
+
env[EXTENSION_ENV] = extension;
|
|
579
|
+
env[SERVERS_ENV] = JSON.stringify(piServers(servers));
|
|
580
|
+
return wrapper;
|
|
581
|
+
}
|
|
582
|
+
//#endregion
|
|
583
|
+
//#region src/ask/agents.ts
|
|
584
|
+
const askAgents = {
|
|
585
|
+
claude: {
|
|
586
|
+
name: "Claude Code",
|
|
587
|
+
commands: ["claude"],
|
|
588
|
+
launch: {
|
|
589
|
+
adapter: "@agentclientprotocol/claude-agent-acp/dist/index.js",
|
|
590
|
+
executableEnv: "CLAUDE_CODE_EXECUTABLE"
|
|
591
|
+
},
|
|
592
|
+
signIn: "claude auth login",
|
|
593
|
+
readOnlyMode: "default",
|
|
594
|
+
sessionMeta: { claudeCode: { options: {
|
|
595
|
+
disallowedTools: [
|
|
596
|
+
"Edit",
|
|
597
|
+
"MultiEdit",
|
|
598
|
+
"Write",
|
|
599
|
+
"NotebookEdit",
|
|
600
|
+
"EnterPlanMode",
|
|
601
|
+
"ExitPlanMode"
|
|
602
|
+
],
|
|
603
|
+
allowDangerouslySkipPermissions: false
|
|
604
|
+
} } },
|
|
605
|
+
bypass: {
|
|
606
|
+
mode: "bypassPermissions",
|
|
607
|
+
sessionMeta: { claudeCode: { options: { disallowedTools: ["EnterPlanMode", "ExitPlanMode"] } } }
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
codex: {
|
|
611
|
+
name: "Codex",
|
|
612
|
+
commands: ["codex"],
|
|
613
|
+
launch: {
|
|
614
|
+
adapter: "@agentclientprotocol/codex-acp/dist/index.js",
|
|
615
|
+
executableEnv: "CODEX_PATH"
|
|
616
|
+
},
|
|
617
|
+
signIn: "codex login",
|
|
618
|
+
readOnlyMode: "read-only",
|
|
619
|
+
bypass: { mode: "agent-full-access" }
|
|
620
|
+
},
|
|
621
|
+
cursor: {
|
|
622
|
+
name: "Cursor",
|
|
623
|
+
commands: ["cursor-agent"],
|
|
624
|
+
installDirs: [".local/bin"],
|
|
625
|
+
launch: { args: ["acp"] },
|
|
626
|
+
signIn: "cursor-agent login",
|
|
627
|
+
readOnlyMode: "ask",
|
|
628
|
+
bypass: { mode: "agent" }
|
|
629
|
+
},
|
|
630
|
+
opencode: {
|
|
631
|
+
name: "OpenCode",
|
|
632
|
+
commands: ["opencode"],
|
|
633
|
+
installDirs: [".opencode/bin"],
|
|
634
|
+
launch: { args: ["acp"] },
|
|
635
|
+
signIn: "opencode auth login",
|
|
636
|
+
readOnlyMode: "build",
|
|
637
|
+
env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: {
|
|
638
|
+
edit: "deny",
|
|
639
|
+
bash: "ask",
|
|
640
|
+
webfetch: "ask"
|
|
641
|
+
} }) },
|
|
642
|
+
bypass: {
|
|
643
|
+
mode: "build",
|
|
644
|
+
env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: {
|
|
645
|
+
edit: "allow",
|
|
646
|
+
bash: "allow",
|
|
647
|
+
webfetch: "allow"
|
|
648
|
+
} }) }
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
pi: {
|
|
652
|
+
name: "Pi",
|
|
653
|
+
commands: ["pi"],
|
|
654
|
+
launch: {
|
|
655
|
+
adapter: "pi-acp/dist/index.js",
|
|
656
|
+
executableEnv: "PI_ACP_PI_COMMAND"
|
|
657
|
+
},
|
|
658
|
+
signIn: "pi",
|
|
659
|
+
mcp: {
|
|
660
|
+
supported: piTakesMcp,
|
|
661
|
+
launch: piWithMcp
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
/** Desktop inherits the login shell's PATH, so this sees what a terminal sees.
|
|
666
|
+
* Package-manager bin directories are skipped: the adapters' own dependencies
|
|
667
|
+
* put `codex` there, and that is not the user's install. */
|
|
668
|
+
async function findExecutable(command, env = process.env) {
|
|
669
|
+
for (const directory of (env.PATH ?? "").split(path.delimiter)) {
|
|
670
|
+
if (!directory || directory.split(path.sep).includes("node_modules")) continue;
|
|
671
|
+
const candidate = path.join(directory, command);
|
|
672
|
+
try {
|
|
673
|
+
if (!(await stat(candidate)).isFile()) continue;
|
|
674
|
+
await access(candidate, constants.X_OK);
|
|
675
|
+
return candidate;
|
|
676
|
+
} catch {}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/** The user's install of an agent's CLI: on PATH, else where its installer
|
|
680
|
+
* puts it. */
|
|
681
|
+
async function findAgent(spec, env = process.env) {
|
|
682
|
+
const installed = (spec.installDirs ?? []).map((directory) => path.join(homedir(), directory));
|
|
683
|
+
for (const command of spec.commands) {
|
|
684
|
+
const found = await findExecutable(command, env) ?? await findExecutable(command, { PATH: installed.join(path.delimiter) });
|
|
685
|
+
if (found) return found;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/** Whether the agent's model gets the MCP servers its sessions are given. */
|
|
689
|
+
async function askAgentTakesMcp(agent) {
|
|
690
|
+
const spec = askAgents[agent];
|
|
691
|
+
if (!spec.mcp) return true;
|
|
692
|
+
const executable = await findAgent(spec);
|
|
693
|
+
return executable !== void 0 && spec.mcp.supported(executable);
|
|
694
|
+
}
|
|
695
|
+
async function detectAskAgents(env = process.env) {
|
|
696
|
+
return Promise.all(askAgentIds.map(async (id) => ({
|
|
697
|
+
id,
|
|
698
|
+
name: askAgents[id].name,
|
|
699
|
+
available: await findAgent(askAgents[id], env) !== void 0,
|
|
700
|
+
readOnly: askAgents[id].readOnlyMode !== void 0,
|
|
701
|
+
bypass: askAgents[id].bypass !== void 0
|
|
702
|
+
})));
|
|
703
|
+
}
|
|
704
|
+
/** Runs an adapter with this server's runtime, Desktop's being Electron, or
|
|
705
|
+
* the user's CLI itself. */
|
|
706
|
+
const launchAskAgent = async (agent, cwd, { bypass = false, mcpServers = [] } = {}) => {
|
|
707
|
+
const spec = askAgents[agent];
|
|
708
|
+
const executable = await findAgent(spec);
|
|
709
|
+
if (!executable) throw new Error(`${spec.name} is not installed.`);
|
|
710
|
+
const env = {
|
|
711
|
+
...process.env,
|
|
712
|
+
...bypass && spec.bypass?.env || spec.env
|
|
713
|
+
};
|
|
714
|
+
delete env.CLAUDECODE;
|
|
715
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
716
|
+
const { launch } = spec;
|
|
717
|
+
let command = executable;
|
|
718
|
+
let args;
|
|
719
|
+
if ("adapter" in launch) {
|
|
720
|
+
env[launch.executableEnv] = spec.mcp && mcpServers.length && await spec.mcp.supported(executable) ? await spec.mcp.launch(executable, mcpServers, env) : executable;
|
|
721
|
+
if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = "1";
|
|
722
|
+
command = process.execPath;
|
|
723
|
+
args = [createRequire(import.meta.url).resolve(launch.adapter)];
|
|
724
|
+
} else args = launch.args;
|
|
725
|
+
const child = spawn(command, args, {
|
|
726
|
+
cwd,
|
|
727
|
+
env,
|
|
728
|
+
stdio: [
|
|
729
|
+
"pipe",
|
|
730
|
+
"pipe",
|
|
731
|
+
"pipe"
|
|
732
|
+
]
|
|
733
|
+
});
|
|
734
|
+
let stderr = "";
|
|
735
|
+
child.stderr.setEncoding("utf8");
|
|
736
|
+
child.stderr.on("data", (chunk) => {
|
|
737
|
+
stderr = (stderr + chunk).slice(-8e3);
|
|
738
|
+
});
|
|
739
|
+
await new Promise((resolve, reject) => {
|
|
740
|
+
child.once("spawn", resolve);
|
|
741
|
+
child.once("error", reject);
|
|
742
|
+
});
|
|
743
|
+
const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
|
744
|
+
return {
|
|
745
|
+
connect: (client) => {
|
|
746
|
+
const connection = client.connect(stream);
|
|
747
|
+
child.once("exit", () => connection.close());
|
|
748
|
+
return connection;
|
|
749
|
+
},
|
|
750
|
+
diagnostics: () => stderr,
|
|
751
|
+
stop: () => {
|
|
752
|
+
if (child.exitCode === null) child.kill();
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
};
|
|
756
|
+
//#endregion
|
|
757
|
+
//#region src/ask/protocol.ts
|
|
758
|
+
/** Claude's adapter names the MCP server behind an `mcp__*` permission here. */
|
|
759
|
+
const claudeMcpMetaSchema = z.object({ claudeCode: z.object({ mcpServer: z.object({ name: z.string() }) }) });
|
|
760
|
+
/** Codex's adapter puts the server in an MCP tool call's input. */
|
|
761
|
+
const mcpInputSchema = z.union([z.object({ server: z.string() }), z.object({ serverName: z.string() })]);
|
|
762
|
+
/** The MCP server a tool call runs on, if the adapter says. */
|
|
763
|
+
function mcpServerOf(toolCall) {
|
|
764
|
+
const claude = claudeMcpMetaSchema.safeParse(toolCall._meta).data;
|
|
765
|
+
if (claude) return claude.claudeCode.mcpServer.name;
|
|
766
|
+
const input = mcpInputSchema.safeParse(toolCall.rawInput).data;
|
|
767
|
+
return input && ("server" in input ? input.server : input.serverName);
|
|
768
|
+
}
|
|
769
|
+
/** Wraps the selection context in the first prompt, so a replayed
|
|
770
|
+
* conversation can show the question without it. */
|
|
771
|
+
const CONTEXT_OPEN = "<whiteboard-context>";
|
|
772
|
+
const CONTEXT_CLOSE = "</whiteboard-context>";
|
|
773
|
+
/** A conversation closed mid-turn, as it should look when reopened: the
|
|
774
|
+
* agent stopped, so nothing is still running or waiting on the reviewer. */
|
|
775
|
+
function settled(entries) {
|
|
776
|
+
return entries.map((entry) => {
|
|
777
|
+
if (entry.kind === "permission" && entry.outcome === void 0) return {
|
|
778
|
+
...entry,
|
|
779
|
+
outcome: "cancelled"
|
|
780
|
+
};
|
|
781
|
+
if (entry.kind === "tool" && (entry.status === "pending" || entry.status === "in_progress")) return {
|
|
782
|
+
...entry,
|
|
783
|
+
status: "failed"
|
|
784
|
+
};
|
|
785
|
+
return entry;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
/** Enough of a tool's output to see what came back. */
|
|
789
|
+
const OUTPUT_LIMIT = 4e3;
|
|
790
|
+
const selectOptionSchema = z.object({
|
|
791
|
+
value: z.string(),
|
|
792
|
+
name: z.string(),
|
|
793
|
+
description: z.string().nullish()
|
|
794
|
+
});
|
|
795
|
+
/** A select's choices, flat or in groups. */
|
|
796
|
+
const selectOptionsSchema = z.array(z.union([selectOptionSchema, z.object({
|
|
797
|
+
group: z.string(),
|
|
798
|
+
options: z.array(selectOptionSchema)
|
|
799
|
+
})]));
|
|
800
|
+
/** The ACP config option category each choice comes from. */
|
|
801
|
+
const choiceCategories = /* @__PURE__ */ new Map([["model", "model"], ["effort", "thought_level"]]);
|
|
802
|
+
const effortNames = /* @__PURE__ */ new Map([["xhigh", "Extra high"]]);
|
|
803
|
+
/** What the agent offers of each choice, from its session config options,
|
|
804
|
+
* with the config option that sets it. */
|
|
805
|
+
function choicesOf(options) {
|
|
806
|
+
const found = /* @__PURE__ */ new Map();
|
|
807
|
+
for (const kind of askChoiceKinds) {
|
|
808
|
+
const option = options?.find((candidate) => candidate.type === "select" && (candidate.category === choiceCategories.get(kind) || candidate.id === kind));
|
|
809
|
+
if (option?.type !== "select") continue;
|
|
810
|
+
const choices = selectOptionsSchema.safeParse(option.options).data;
|
|
811
|
+
if (!choices) continue;
|
|
812
|
+
found.set(kind, {
|
|
813
|
+
configId: option.id,
|
|
814
|
+
select: {
|
|
815
|
+
current: option.currentValue,
|
|
816
|
+
options: choices.flatMap((choice) => "group" in choice ? choice.options : [choice]).map(({ value, name: offered, description }) => {
|
|
817
|
+
const name = kind === "effort" && effortNames.get(value) || offered;
|
|
818
|
+
return description ? {
|
|
819
|
+
value,
|
|
820
|
+
name,
|
|
821
|
+
description
|
|
822
|
+
} : {
|
|
823
|
+
value,
|
|
824
|
+
name
|
|
825
|
+
};
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
return found;
|
|
831
|
+
}
|
|
832
|
+
/** A slash command as the panel offers it. */
|
|
833
|
+
function commandOf({ name, description, input }) {
|
|
834
|
+
return input?.hint ? {
|
|
835
|
+
name,
|
|
836
|
+
description,
|
|
837
|
+
hint: input.hint
|
|
838
|
+
} : {
|
|
839
|
+
name,
|
|
840
|
+
description
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/** What a question carries, as its entry shows it. */
|
|
844
|
+
function attachmentsOf({ mentions = [], images = [] }) {
|
|
845
|
+
return [...mentions.map((mention) => ({
|
|
846
|
+
kind: "file",
|
|
847
|
+
path: mention
|
|
848
|
+
})), ...images.map(({ name }) => ({
|
|
849
|
+
kind: "image",
|
|
850
|
+
name
|
|
851
|
+
}))];
|
|
852
|
+
}
|
|
853
|
+
const toolInputSchema = z.object({
|
|
854
|
+
command: z.union([z.string(), z.array(z.string())]).optional(),
|
|
855
|
+
description: z.string().optional(),
|
|
856
|
+
file_path: z.string().optional(),
|
|
857
|
+
path: z.string().optional(),
|
|
858
|
+
query: z.string().optional(),
|
|
859
|
+
pattern: z.string().optional(),
|
|
860
|
+
patterns: z.array(z.string()).optional()
|
|
861
|
+
});
|
|
862
|
+
/** What a tool call ran or opened, and why, from what the adapter sent. */
|
|
863
|
+
function toolDetails(update) {
|
|
864
|
+
const input = toolInputSchema.safeParse(update.rawInput).data;
|
|
865
|
+
const command = input?.command;
|
|
866
|
+
const target = (Array.isArray(command) ? command.join(" ") : command) ?? input?.file_path ?? input?.path ?? input?.query ?? input?.pattern ?? input?.patterns?.join(" | ") ?? update.locations?.[0]?.path;
|
|
867
|
+
const details = {};
|
|
868
|
+
if (target) details.input = target;
|
|
869
|
+
if (input?.description) details.summary = input.description;
|
|
870
|
+
return details;
|
|
871
|
+
}
|
|
872
|
+
/** A finished tool's text output. Claude fences a command's output. */
|
|
873
|
+
function toolOutput(update) {
|
|
874
|
+
const text = (update.content ?? []).flatMap((item) => item.type === "content" && item.content.type === "text" ? [item.content.text] : []).join("\n").replace(/^\s*```[^\n]*\n([\s\S]*?)\n?```\s*$/u, "$1").trimEnd();
|
|
875
|
+
if (!text) return void 0;
|
|
876
|
+
return text.length > OUTPUT_LIMIT ? `${text.slice(0, OUTPUT_LIMIT)}\n…` : text;
|
|
877
|
+
}
|
|
878
|
+
/** A first question, after the selection it is about. */
|
|
879
|
+
function withContext(context, question) {
|
|
880
|
+
return [{
|
|
881
|
+
type: "text",
|
|
882
|
+
text: `${CONTEXT_OPEN}\n${context}\n${CONTEXT_CLOSE}`
|
|
883
|
+
}, {
|
|
884
|
+
type: "text",
|
|
885
|
+
text: question
|
|
886
|
+
}];
|
|
887
|
+
}
|
|
888
|
+
/** A replayed first message, as the reviewer typed it. */
|
|
889
|
+
function withoutContext(text) {
|
|
890
|
+
const start = text.indexOf(CONTEXT_OPEN);
|
|
891
|
+
if (start === -1) return text.trim();
|
|
892
|
+
const end = text.indexOf(CONTEXT_CLOSE, start);
|
|
893
|
+
return (text.slice(0, start) + (end === -1 ? "" : text.slice(end + 21))).trim();
|
|
894
|
+
}
|
|
895
|
+
/** ACP's "authentication required": the agent's login lapsed or never was.
|
|
896
|
+
* Some adapters send the same code for other failures, so its message has
|
|
897
|
+
* to say so too. */
|
|
898
|
+
function signedOut(error) {
|
|
899
|
+
return error instanceof RequestError && error.code === RequestError.authRequired().code && /auth/i.test(error.message);
|
|
900
|
+
}
|
|
901
|
+
//#endregion
|
|
902
|
+
//#region src/ask/thread.ts
|
|
903
|
+
/** Refused without asking: changes to files, and leaving the read-only
|
|
904
|
+
* mode (Claude asks to exit plan mode with its plan). */
|
|
905
|
+
const REFUSED_KINDS = /* @__PURE__ */ new Set([
|
|
906
|
+
"edit",
|
|
907
|
+
"delete",
|
|
908
|
+
"move",
|
|
909
|
+
"switch_mode"
|
|
910
|
+
]);
|
|
911
|
+
const piGreetingSchema = z.object({ piAcp: z.object({ startupInfo: z.string() }) });
|
|
912
|
+
const cursorTodosSchema = z.object({ todos: z.unknown() }).loose();
|
|
913
|
+
/** MCP servers the reviewer's own agent may bring whose tools only read,
|
|
914
|
+
* so their calls run without asking, like the agent reading files. */
|
|
915
|
+
const READ_ONLY_MCP_SERVERS = /* @__PURE__ */ new Set(["fff"]);
|
|
916
|
+
const askThreadLimits = {
|
|
917
|
+
startMs: 6e4,
|
|
918
|
+
stopGraceMs: 15e3
|
|
919
|
+
};
|
|
920
|
+
/** One Ask conversation: an agent process and one ACP session in the review's checkout. */
|
|
921
|
+
var AskThread = class {
|
|
922
|
+
launch;
|
|
923
|
+
start;
|
|
924
|
+
limits;
|
|
925
|
+
id;
|
|
926
|
+
state;
|
|
927
|
+
/** The number of changes so far; a snapshot carries the one it includes. */
|
|
928
|
+
seq = 0;
|
|
929
|
+
listeners = /* @__PURE__ */ new Set();
|
|
930
|
+
closers = /* @__PURE__ */ new Set();
|
|
931
|
+
decisions = /* @__PURE__ */ new Map();
|
|
932
|
+
process;
|
|
933
|
+
connection;
|
|
934
|
+
/** Counts connections, so a start that was given up on cannot take over. */
|
|
935
|
+
generation = 0;
|
|
936
|
+
/** What the last agent process said on stderr, once it is gone. */
|
|
937
|
+
stderr = "";
|
|
938
|
+
sessionId;
|
|
939
|
+
closed = false;
|
|
940
|
+
/** The MCP server behind each tool call an adapter reported one for. */
|
|
941
|
+
mcpCalls = /* @__PURE__ */ new Map();
|
|
942
|
+
mcpServers;
|
|
943
|
+
/** Loading an earlier conversation: the agent replays it as updates. */
|
|
944
|
+
replaying = false;
|
|
945
|
+
/** Replayed user messages as sent, context included. */
|
|
946
|
+
replayedUser = /* @__PURE__ */ new Map();
|
|
947
|
+
/** The panel already shows the conversation, so a load's replay is not
|
|
948
|
+
* needed: a saved copy, or the thread itself when it tries again. */
|
|
949
|
+
shown;
|
|
950
|
+
/** The config option that sets each choice the agent offers. */
|
|
951
|
+
configIds = /* @__PURE__ */ new Map();
|
|
952
|
+
/** The agent's other settings as last seen, to say when it changes one
|
|
953
|
+
* itself, as Codex's /plan does. */
|
|
954
|
+
settings = /* @__PURE__ */ new Map();
|
|
955
|
+
/** The model and effort in use, which a new agent process starts with. */
|
|
956
|
+
picks;
|
|
957
|
+
/** The question being answered, until its turn ends; after a failure,
|
|
958
|
+
* the one trying again asks. */
|
|
959
|
+
asking;
|
|
960
|
+
/** The session has not had the selection yet: a new conversation, or a
|
|
961
|
+
* new session for one the agent could not reopen. */
|
|
962
|
+
needsContext;
|
|
963
|
+
/** The reviewer changed what the agent may do since it was last told. */
|
|
964
|
+
permitted = false;
|
|
965
|
+
/** The agent could not reopen the session, for a reason signing in would
|
|
966
|
+
* not fix, so trying again starts a new one. */
|
|
967
|
+
unloadable = false;
|
|
968
|
+
/** Stop ended the agent itself: a start, or a turn that would not stop. */
|
|
969
|
+
halted = false;
|
|
970
|
+
stopTimer;
|
|
971
|
+
/** What Pi greets a new session with, its version and skills, which is no
|
|
972
|
+
* part of the answer. */
|
|
973
|
+
greeting;
|
|
974
|
+
constructor(launch, start, mcpServers = () => [], limits = askThreadLimits) {
|
|
975
|
+
this.launch = launch;
|
|
976
|
+
this.start = start;
|
|
977
|
+
this.limits = limits;
|
|
978
|
+
this.id = start.id ?? randomUUID();
|
|
979
|
+
this.mcpServers = mcpServers();
|
|
980
|
+
this.picks = { ...start.picks };
|
|
981
|
+
this.state = {
|
|
982
|
+
id: this.id,
|
|
983
|
+
agent: start.agent,
|
|
984
|
+
agentName: askAgents[start.agent].name,
|
|
985
|
+
status: "starting",
|
|
986
|
+
readOnly: false,
|
|
987
|
+
bypass: start.bypass ?? false,
|
|
988
|
+
head: start.head,
|
|
989
|
+
cwd: start.cwd,
|
|
990
|
+
selection: start.selection,
|
|
991
|
+
entries: "resume" in start && start.resume.entries || []
|
|
992
|
+
};
|
|
993
|
+
if ("resume" in start) this.sessionId = start.resume.sessionId;
|
|
994
|
+
this.shown = "resume" in start && Boolean(start.resume.entries);
|
|
995
|
+
this.needsContext = !("resume" in start);
|
|
996
|
+
}
|
|
997
|
+
get reviewId() {
|
|
998
|
+
return this.start.reviewId;
|
|
999
|
+
}
|
|
1000
|
+
read() {
|
|
1001
|
+
return this.state;
|
|
1002
|
+
}
|
|
1003
|
+
snapshot() {
|
|
1004
|
+
return {
|
|
1005
|
+
seq: this.seq,
|
|
1006
|
+
snapshot: this.state
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
/** Every change after the current `seq`, in order. */
|
|
1010
|
+
subscribe(listener) {
|
|
1011
|
+
this.listeners.add(listener);
|
|
1012
|
+
return () => this.listeners.delete(listener);
|
|
1013
|
+
}
|
|
1014
|
+
/** Starts the agent and asks the first question, or loads an earlier
|
|
1015
|
+
* conversation; failures land in the state. */
|
|
1016
|
+
async open() {
|
|
1017
|
+
const start = this.start;
|
|
1018
|
+
if ("resume" in start) {
|
|
1019
|
+
await this.attempt(async () => {
|
|
1020
|
+
await this.connect();
|
|
1021
|
+
this.emit({
|
|
1022
|
+
type: "set",
|
|
1023
|
+
status: "idle"
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this.asking = {
|
|
1029
|
+
id: this.addUser(start.question),
|
|
1030
|
+
question: start.question
|
|
1031
|
+
};
|
|
1032
|
+
await this.attempt(async () => {
|
|
1033
|
+
await this.connect();
|
|
1034
|
+
await this.prompt(start.question);
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
/** Why a question cannot be asked now, if it cannot. */
|
|
1038
|
+
askRefusal() {
|
|
1039
|
+
switch (this.state.status) {
|
|
1040
|
+
case "idle": return;
|
|
1041
|
+
case "failed": return `${this.state.agentName} stopped. Try again first.`;
|
|
1042
|
+
default: return "The agent is still answering.";
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/** Asks a follow-up, starting the agent again if it has stopped since. */
|
|
1046
|
+
async ask(question) {
|
|
1047
|
+
const refusal = this.askRefusal();
|
|
1048
|
+
if (refusal) throw new Error(refusal);
|
|
1049
|
+
this.asking = {
|
|
1050
|
+
id: this.addUser(question),
|
|
1051
|
+
question
|
|
1052
|
+
};
|
|
1053
|
+
await this.attempt(async () => {
|
|
1054
|
+
await this.reconnect();
|
|
1055
|
+
await this.prompt(question);
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
/** Starts the agent again after it failed, as when its login lapsed, and
|
|
1059
|
+
* asks again the question it did not answer, if one failed. */
|
|
1060
|
+
async retry() {
|
|
1061
|
+
if (this.state.status !== "failed") throw new Error("Only a conversation that failed can try again.");
|
|
1062
|
+
this.disconnect();
|
|
1063
|
+
const asked = this.state.entries.findIndex((entry) => entry.kind === "user" && entry.id === this.asking?.id);
|
|
1064
|
+
const question = asked === -1 ? void 0 : this.asking?.question;
|
|
1065
|
+
const partial = asked === -1 ? [] : this.state.entries.slice(asked + 1);
|
|
1066
|
+
if (partial.length) this.emit({
|
|
1067
|
+
type: "remove",
|
|
1068
|
+
ids: partial.map((entry) => entry.id)
|
|
1069
|
+
});
|
|
1070
|
+
if (this.unloadable) {
|
|
1071
|
+
this.unloadable = false;
|
|
1072
|
+
this.sessionId = void 0;
|
|
1073
|
+
this.needsContext = true;
|
|
1074
|
+
}
|
|
1075
|
+
this.emit({
|
|
1076
|
+
type: "set",
|
|
1077
|
+
status: "starting",
|
|
1078
|
+
error: null,
|
|
1079
|
+
signIn: null
|
|
1080
|
+
});
|
|
1081
|
+
await this.attempt(async () => {
|
|
1082
|
+
await this.connect();
|
|
1083
|
+
if (question) await this.prompt(question);
|
|
1084
|
+
else this.emit({
|
|
1085
|
+
type: "set",
|
|
1086
|
+
status: "idle"
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
decide(permissionId, optionId) {
|
|
1091
|
+
const resolve = this.decisions.get(permissionId);
|
|
1092
|
+
if (!resolve) return false;
|
|
1093
|
+
resolve({ outcome: {
|
|
1094
|
+
outcome: "selected",
|
|
1095
|
+
optionId
|
|
1096
|
+
} });
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
/** Stops the turn, or the start, in progress. A turn the agent does not
|
|
1100
|
+
* end soon after is ended by stopping the agent. */
|
|
1101
|
+
async cancel() {
|
|
1102
|
+
for (const resolve of this.decisions.values()) resolve({ outcome: { outcome: "cancelled" } });
|
|
1103
|
+
const { status } = this.state;
|
|
1104
|
+
if (status === "starting") {
|
|
1105
|
+
this.halt();
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const { connection, sessionId } = this;
|
|
1109
|
+
if (status !== "running" && status !== "waiting" || !connection) return;
|
|
1110
|
+
clearTimeout(this.stopTimer);
|
|
1111
|
+
this.stopTimer = setTimeout(() => {
|
|
1112
|
+
if (this.connection === connection) this.halt();
|
|
1113
|
+
}, this.limits.stopGraceMs);
|
|
1114
|
+
if (sessionId) await connection.agent.notify(methods.agent.session.cancel, { sessionId }).catch(() => {});
|
|
1115
|
+
}
|
|
1116
|
+
close() {
|
|
1117
|
+
if (this.closed) return;
|
|
1118
|
+
this.closed = true;
|
|
1119
|
+
if (this.sessionId && this.state.status !== "starting" && this.state.status !== "failed") this.start.onSave?.(settled(this.state.entries));
|
|
1120
|
+
for (const resolve of this.decisions.values()) resolve({ outcome: { outcome: "cancelled" } });
|
|
1121
|
+
if (this.connection && this.sessionId) this.connection.agent.notify(methods.agent.session.cancel, { sessionId: this.sessionId }).catch(() => {});
|
|
1122
|
+
this.disconnect();
|
|
1123
|
+
this.listeners.clear();
|
|
1124
|
+
for (const closed of this.closers) closed();
|
|
1125
|
+
this.closers.clear();
|
|
1126
|
+
}
|
|
1127
|
+
/** Runs when the thread closes, at once if it already has. */
|
|
1128
|
+
onClose(closed) {
|
|
1129
|
+
if (this.closed) {
|
|
1130
|
+
closed();
|
|
1131
|
+
return () => {};
|
|
1132
|
+
}
|
|
1133
|
+
this.closers.add(closed);
|
|
1134
|
+
return () => this.closers.delete(closed);
|
|
1135
|
+
}
|
|
1136
|
+
/** Runs a step that talks to the agent; failures land in the state. */
|
|
1137
|
+
async attempt(step) {
|
|
1138
|
+
this.halted = false;
|
|
1139
|
+
try {
|
|
1140
|
+
await step();
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (this.closed) return;
|
|
1143
|
+
if (this.halted) this.stopped();
|
|
1144
|
+
else this.fail(error);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
/** Stop ended the agent before a turn could: nothing is running now, and
|
|
1148
|
+
* the next question starts the agent again. */
|
|
1149
|
+
stopped() {
|
|
1150
|
+
this.halted = false;
|
|
1151
|
+
if (this.asking) this.push({
|
|
1152
|
+
kind: "notice",
|
|
1153
|
+
id: randomUUID(),
|
|
1154
|
+
severity: "info",
|
|
1155
|
+
title: "Stopped here."
|
|
1156
|
+
});
|
|
1157
|
+
this.asking = void 0;
|
|
1158
|
+
this.emit({
|
|
1159
|
+
type: "set",
|
|
1160
|
+
status: "idle",
|
|
1161
|
+
error: null
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
/** Ends the agent process, failing whatever was waiting on it. */
|
|
1165
|
+
halt() {
|
|
1166
|
+
this.halted = true;
|
|
1167
|
+
this.disconnect();
|
|
1168
|
+
}
|
|
1169
|
+
disconnect() {
|
|
1170
|
+
this.generation += 1;
|
|
1171
|
+
clearTimeout(this.stopTimer);
|
|
1172
|
+
if (this.process) this.stderr = this.process.diagnostics();
|
|
1173
|
+
this.connection?.close();
|
|
1174
|
+
this.process?.stop();
|
|
1175
|
+
this.connection = void 0;
|
|
1176
|
+
this.process = void 0;
|
|
1177
|
+
}
|
|
1178
|
+
/** Starts the agent again if it stopped since the last turn. */
|
|
1179
|
+
async reconnect() {
|
|
1180
|
+
if (this.connection && !this.connection.signal.aborted) return;
|
|
1181
|
+
this.disconnect();
|
|
1182
|
+
this.emit({
|
|
1183
|
+
type: "set",
|
|
1184
|
+
status: "starting"
|
|
1185
|
+
});
|
|
1186
|
+
await this.connect();
|
|
1187
|
+
}
|
|
1188
|
+
/** Starts the agent and opens the session, or gives up after a while. */
|
|
1189
|
+
async connect() {
|
|
1190
|
+
let timer;
|
|
1191
|
+
const expired = new Promise((_resolve, reject) => {
|
|
1192
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${this.state.agentName} did not start within ${Math.round(this.limits.startMs / 1e3)} seconds.`)), this.limits.startMs);
|
|
1193
|
+
});
|
|
1194
|
+
const starting = this.startAgent();
|
|
1195
|
+
starting.catch(() => {});
|
|
1196
|
+
try {
|
|
1197
|
+
await Promise.race([starting, expired]);
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
this.disconnect();
|
|
1200
|
+
throw error;
|
|
1201
|
+
} finally {
|
|
1202
|
+
clearTimeout(timer);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
async startAgent() {
|
|
1206
|
+
const generation = ++this.generation;
|
|
1207
|
+
const launched = await this.launch(this.start.agent, this.start.cwd, {
|
|
1208
|
+
bypass: this.state.bypass,
|
|
1209
|
+
mcpServers: this.mcpServers
|
|
1210
|
+
});
|
|
1211
|
+
if (this.closed || this.halted || generation !== this.generation) {
|
|
1212
|
+
launched.stop();
|
|
1213
|
+
throw new Error(`${this.state.agentName} was stopped while it started.`);
|
|
1214
|
+
}
|
|
1215
|
+
this.process = launched;
|
|
1216
|
+
const connection = launched.connect(client({ name: "whiteboard" }).onRequest(methods.client.session.requestPermission, ({ params, signal }) => this.requestPermission(params, signal)).onNotification(methods.client.session.update, ({ params }) => this.update(params)).onRequest("cursor/update_todos", cursorTodosSchema, ({ params }) => ({ outcome: {
|
|
1217
|
+
outcome: "accepted",
|
|
1218
|
+
todos: params.todos
|
|
1219
|
+
} })));
|
|
1220
|
+
this.connection = connection;
|
|
1221
|
+
const agent = connection.agent;
|
|
1222
|
+
const initialized = await agent.request(methods.agent.initialize, {
|
|
1223
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1224
|
+
clientCapabilities: {
|
|
1225
|
+
session: { notices: {} },
|
|
1226
|
+
_meta: { parameterizedModelPicker: true }
|
|
1227
|
+
},
|
|
1228
|
+
clientInfo: {
|
|
1229
|
+
name: "whiteboard",
|
|
1230
|
+
title: "Whiteboard",
|
|
1231
|
+
version: "1"
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
this.emit({
|
|
1235
|
+
type: "set",
|
|
1236
|
+
accepts: { image: initialized.agentCapabilities?.promptCapabilities?.image === true }
|
|
1237
|
+
});
|
|
1238
|
+
const session = await this.session(connection, generation, initialized.agentCapabilities?.loadSession === true);
|
|
1239
|
+
const { bypass } = this.state;
|
|
1240
|
+
const spec = askAgents[this.start.agent];
|
|
1241
|
+
const mode = bypass ? spec.bypass?.mode : spec.readOnlyMode;
|
|
1242
|
+
const configurable = mode !== void 0 && session.response.configOptions?.some((option) => option.id === "mode" && option.type === "select" && selectOptionsSchema.safeParse(option.options).data?.flatMap((choice) => "group" in choice ? choice.options : [choice]).some((choice) => choice.value === mode));
|
|
1243
|
+
const selectable = mode !== void 0 && session.response.modes?.availableModes.some((available) => available.id === mode);
|
|
1244
|
+
let config = session.response.configOptions;
|
|
1245
|
+
if (configurable) config = (await agent.request(methods.agent.session.setConfigOption, {
|
|
1246
|
+
sessionId: session.sessionId,
|
|
1247
|
+
configId: "mode",
|
|
1248
|
+
value: mode
|
|
1249
|
+
})).configOptions ?? config;
|
|
1250
|
+
else if (selectable) await agent.request(methods.agent.session.setMode, {
|
|
1251
|
+
sessionId: session.sessionId,
|
|
1252
|
+
modeId: mode
|
|
1253
|
+
});
|
|
1254
|
+
this.emit({
|
|
1255
|
+
type: "set",
|
|
1256
|
+
readOnly: !bypass && Boolean(configurable || selectable)
|
|
1257
|
+
});
|
|
1258
|
+
this.useConfig(config);
|
|
1259
|
+
for (const kind of askChoiceKinds) {
|
|
1260
|
+
const picked = this.picks[kind];
|
|
1261
|
+
const select = this.state.choices?.[kind];
|
|
1262
|
+
if (picked && picked !== select?.current && select?.options.some((option) => option.value === picked)) await this.change(kind, picked);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
/** Answers the next question with another model or effort, starting the
|
|
1266
|
+
* agent again if it has stopped since. */
|
|
1267
|
+
async choose(kind, value) {
|
|
1268
|
+
if (this.state.status !== "idle") throw new Error("Settings can change between answers.");
|
|
1269
|
+
if (!this.connection || this.connection.signal.aborted) {
|
|
1270
|
+
await this.attempt(async () => {
|
|
1271
|
+
await this.reconnect();
|
|
1272
|
+
this.emit({
|
|
1273
|
+
type: "set",
|
|
1274
|
+
status: "idle"
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
if (this.state.status !== "idle") return;
|
|
1278
|
+
}
|
|
1279
|
+
await this.change(kind, value);
|
|
1280
|
+
}
|
|
1281
|
+
async change(kind, value) {
|
|
1282
|
+
const configId = this.configIds.get(kind);
|
|
1283
|
+
if (!this.connection || !this.sessionId || !configId) throw new Error(`${this.state.agentName} offers no such choice.`);
|
|
1284
|
+
const response = await this.connection.agent.request(methods.agent.session.setConfigOption, {
|
|
1285
|
+
sessionId: this.sessionId,
|
|
1286
|
+
configId,
|
|
1287
|
+
value
|
|
1288
|
+
});
|
|
1289
|
+
this.picks[kind] = value;
|
|
1290
|
+
this.useConfig(response.configOptions);
|
|
1291
|
+
}
|
|
1292
|
+
useConfig(options) {
|
|
1293
|
+
const found = choicesOf(options);
|
|
1294
|
+
this.noteSettings(options, found);
|
|
1295
|
+
if (!found.size) return;
|
|
1296
|
+
const choices = {};
|
|
1297
|
+
for (const [kind, { configId, select }] of found) {
|
|
1298
|
+
this.configIds.set(kind, configId);
|
|
1299
|
+
choices[kind] = select;
|
|
1300
|
+
}
|
|
1301
|
+
this.emit({
|
|
1302
|
+
type: "set",
|
|
1303
|
+
choices
|
|
1304
|
+
});
|
|
1305
|
+
this.announce();
|
|
1306
|
+
}
|
|
1307
|
+
/** Says when a setting the panel does not show changes, which can be
|
|
1308
|
+
* all a command does. */
|
|
1309
|
+
noteSettings(options, choices) {
|
|
1310
|
+
const shown = new Set([...choices.values()].map(({ configId }) => configId));
|
|
1311
|
+
for (const option of options ?? []) {
|
|
1312
|
+
if (option.type !== "select" || shown.has(option.id)) continue;
|
|
1313
|
+
const before = this.settings.get(option.id);
|
|
1314
|
+
this.settings.set(option.id, option.currentValue);
|
|
1315
|
+
if (before === void 0 || before === option.currentValue) continue;
|
|
1316
|
+
const value = selectOptionsSchema.safeParse(option.options).data?.flatMap((choice) => "group" in choice ? choice.options : [choice]).find((choice) => choice.value === option.currentValue)?.name ?? option.currentValue;
|
|
1317
|
+
this.push({
|
|
1318
|
+
kind: "notice",
|
|
1319
|
+
id: randomUUID(),
|
|
1320
|
+
severity: "info",
|
|
1321
|
+
title: `${this.state.agentName} set ${option.name.toLowerCase()} to ${value}.`
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
/** Tells the host what the agent offers now, once its settings are
|
|
1326
|
+
* known: a reopening agent lists its commands first, and what it says
|
|
1327
|
+
* then must not stand as an offer of no model. */
|
|
1328
|
+
announce() {
|
|
1329
|
+
const { choices, commands, accepts } = this.state;
|
|
1330
|
+
if (!choices) return;
|
|
1331
|
+
const offer = { choices };
|
|
1332
|
+
if (commands) offer.commands = commands;
|
|
1333
|
+
if (accepts) offer.accepts = accepts;
|
|
1334
|
+
this.start.onOffer?.(offer);
|
|
1335
|
+
}
|
|
1336
|
+
/** Edits and runs commands without asking from the next answer, or stops:
|
|
1337
|
+
* the agent starts again in that mode and reopens the conversation. */
|
|
1338
|
+
async permit(bypass) {
|
|
1339
|
+
if (this.state.status !== "idle") throw new Error("Settings can change between answers.");
|
|
1340
|
+
if (bypass === this.state.bypass) return;
|
|
1341
|
+
if (bypass && !askAgents[this.start.agent].bypass) throw new Error(`${this.state.agentName} cannot bypass permissions.`);
|
|
1342
|
+
this.emit({
|
|
1343
|
+
type: "set",
|
|
1344
|
+
bypass
|
|
1345
|
+
});
|
|
1346
|
+
this.permitted = true;
|
|
1347
|
+
this.start.onBypass?.(bypass);
|
|
1348
|
+
this.disconnect();
|
|
1349
|
+
this.settings.clear();
|
|
1350
|
+
this.emit({
|
|
1351
|
+
type: "set",
|
|
1352
|
+
status: "starting"
|
|
1353
|
+
});
|
|
1354
|
+
await this.attempt(async () => {
|
|
1355
|
+
await this.connect();
|
|
1356
|
+
this.emit({
|
|
1357
|
+
type: "set",
|
|
1358
|
+
status: "idle"
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
/** What the session starts with: read-only, or bypassing permissions. */
|
|
1363
|
+
sessionMeta() {
|
|
1364
|
+
const spec = askAgents[this.start.agent];
|
|
1365
|
+
return this.state.bypass && spec.bypass?.sessionMeta || spec.sessionMeta;
|
|
1366
|
+
}
|
|
1367
|
+
/** The agent says which mode it is in now; only its read-only one keeps
|
|
1368
|
+
* the checkout as it is. */
|
|
1369
|
+
useMode(mode) {
|
|
1370
|
+
const readOnly = !this.state.bypass && mode === askAgents[this.start.agent].readOnlyMode;
|
|
1371
|
+
if (readOnly !== this.state.readOnly) this.emit({
|
|
1372
|
+
type: "set",
|
|
1373
|
+
readOnly
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
/** A new session, or the earlier one loaded with its history replayed. */
|
|
1377
|
+
async session(connection, generation, canLoad) {
|
|
1378
|
+
const agent = connection.agent;
|
|
1379
|
+
const start = this.start;
|
|
1380
|
+
const name = this.state.agentName;
|
|
1381
|
+
const earlier = this.sessionId;
|
|
1382
|
+
if (!earlier) {
|
|
1383
|
+
const response = await agent.request(methods.agent.session.new, {
|
|
1384
|
+
cwd: start.cwd,
|
|
1385
|
+
mcpServers: this.mcpServers,
|
|
1386
|
+
_meta: this.sessionMeta()
|
|
1387
|
+
});
|
|
1388
|
+
this.sessionId = response.sessionId;
|
|
1389
|
+
start.onSession?.(response.sessionId);
|
|
1390
|
+
this.greeting = piGreetingSchema.safeParse(response._meta).data?.piAcp.startupInfo;
|
|
1391
|
+
return {
|
|
1392
|
+
sessionId: response.sessionId,
|
|
1393
|
+
response
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
const unloadable = (reason, cause) => {
|
|
1397
|
+
this.unloadable = true;
|
|
1398
|
+
return new Error(`${name} could not reopen this conversation. ${reason} Try again to continue it in a new session, which starts without what was said before.`, { cause });
|
|
1399
|
+
};
|
|
1400
|
+
if (!canLoad) throw unloadable(`${name} cannot reopen past conversations.`);
|
|
1401
|
+
this.shown ||= this.state.entries.length > 0;
|
|
1402
|
+
this.replaying = true;
|
|
1403
|
+
try {
|
|
1404
|
+
return {
|
|
1405
|
+
sessionId: earlier,
|
|
1406
|
+
response: await agent.request(methods.agent.session.load, {
|
|
1407
|
+
sessionId: earlier,
|
|
1408
|
+
cwd: start.cwd,
|
|
1409
|
+
mcpServers: this.mcpServers,
|
|
1410
|
+
_meta: this.sessionMeta()
|
|
1411
|
+
})
|
|
1412
|
+
};
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
if (signedOut(error) || this.closed || this.halted || generation !== this.generation) throw error;
|
|
1415
|
+
throw unloadable(errorMessage(error), error);
|
|
1416
|
+
} finally {
|
|
1417
|
+
this.replaying = false;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
/** Asks a question in the session, with the selection first if the
|
|
1421
|
+
* session has not had it. */
|
|
1422
|
+
async prompt(question) {
|
|
1423
|
+
const { connection, sessionId } = this;
|
|
1424
|
+
if (!connection || !sessionId) throw new Error(`${this.state.agentName} is not running.`);
|
|
1425
|
+
const command = question.text.startsWith("/");
|
|
1426
|
+
const withSelection = this.needsContext && !command;
|
|
1427
|
+
const withPermission = (withSelection || this.permitted) && !command;
|
|
1428
|
+
const context = [...withSelection ? [this.start.context] : [], ...withPermission ? [this.state.bypass ? "The reviewer lets you change files in the checkout and run commands without asking." : "Do not change files in the checkout."] : []].join("\n");
|
|
1429
|
+
this.emit({
|
|
1430
|
+
type: "set",
|
|
1431
|
+
status: "running",
|
|
1432
|
+
error: null
|
|
1433
|
+
});
|
|
1434
|
+
const prompt = [...context ? withContext(context, question.text) : [{
|
|
1435
|
+
type: "text",
|
|
1436
|
+
text: question.text
|
|
1437
|
+
}], ...this.attachments(question)];
|
|
1438
|
+
let stopReason;
|
|
1439
|
+
try {
|
|
1440
|
+
({stopReason} = await connection.agent.request(methods.agent.session.prompt, {
|
|
1441
|
+
sessionId,
|
|
1442
|
+
prompt
|
|
1443
|
+
}));
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
if (!this.halted) throw error;
|
|
1446
|
+
stopReason = "cancelled";
|
|
1447
|
+
this.halted = false;
|
|
1448
|
+
} finally {
|
|
1449
|
+
clearTimeout(this.stopTimer);
|
|
1450
|
+
}
|
|
1451
|
+
if (withSelection) this.needsContext = false;
|
|
1452
|
+
if (withPermission) this.permitted = false;
|
|
1453
|
+
this.asking = void 0;
|
|
1454
|
+
const asked = this.state.entries.findLastIndex((entry) => entry.kind === "user");
|
|
1455
|
+
if (stopReason === "cancelled") this.push({
|
|
1456
|
+
kind: "notice",
|
|
1457
|
+
id: randomUUID(),
|
|
1458
|
+
severity: "info",
|
|
1459
|
+
title: "Stopped here."
|
|
1460
|
+
});
|
|
1461
|
+
else if (stopReason === "end_turn" && asked === this.state.entries.length - 1) this.push({
|
|
1462
|
+
kind: "notice",
|
|
1463
|
+
id: randomUUID(),
|
|
1464
|
+
severity: "info",
|
|
1465
|
+
title: `${this.state.agentName} finished without replying.`
|
|
1466
|
+
});
|
|
1467
|
+
this.emit({
|
|
1468
|
+
type: "set",
|
|
1469
|
+
status: "idle",
|
|
1470
|
+
error: stopReason === "refusal" ? `${this.state.agentName} declined to answer.` : stopReason === "max_tokens" || stopReason === "max_turn_requests" ? `${this.state.agentName} stopped before finishing.` : null
|
|
1471
|
+
});
|
|
1472
|
+
this.start.onTurn?.();
|
|
1473
|
+
this.start.onSave?.(this.state.entries);
|
|
1474
|
+
}
|
|
1475
|
+
async requestPermission(request, signal) {
|
|
1476
|
+
const id = request.toolCall.toolCallId;
|
|
1477
|
+
const toolKind = request.toolCall.kind ?? "other";
|
|
1478
|
+
const server = mcpServerOf(request.toolCall) ?? this.mcpCalls.get(id);
|
|
1479
|
+
const allowOnce = request.options.find((option) => option.kind === "allow_once");
|
|
1480
|
+
if (allowOnce && (this.state.bypass || server && (READ_ONLY_MCP_SERVERS.has(server) || this.mcpServers.some((provided) => provided.name === server)))) return { outcome: {
|
|
1481
|
+
outcome: "selected",
|
|
1482
|
+
optionId: allowOnce.optionId
|
|
1483
|
+
} };
|
|
1484
|
+
const input = toolDetails(request.toolCall).input ?? this.state.entries.find((entry) => entry.kind === "tool" && entry.id === id)?.input;
|
|
1485
|
+
const entry = {
|
|
1486
|
+
kind: "permission",
|
|
1487
|
+
id,
|
|
1488
|
+
title: request.toolCall.title ?? "Run a tool",
|
|
1489
|
+
toolKind,
|
|
1490
|
+
options: request.options
|
|
1491
|
+
};
|
|
1492
|
+
if (input) entry.input = input;
|
|
1493
|
+
if (REFUSED_KINDS.has(toolKind)) {
|
|
1494
|
+
const reject = request.options.find((option) => option.kind === "reject_once");
|
|
1495
|
+
this.push({
|
|
1496
|
+
...entry,
|
|
1497
|
+
outcome: reject?.optionId ?? "cancelled",
|
|
1498
|
+
automatic: true
|
|
1499
|
+
});
|
|
1500
|
+
return reject ? { outcome: {
|
|
1501
|
+
outcome: "selected",
|
|
1502
|
+
optionId: reject.optionId
|
|
1503
|
+
} } : { outcome: { outcome: "cancelled" } };
|
|
1504
|
+
}
|
|
1505
|
+
this.push(entry);
|
|
1506
|
+
this.emit({
|
|
1507
|
+
type: "set",
|
|
1508
|
+
status: "waiting"
|
|
1509
|
+
});
|
|
1510
|
+
const response = await new Promise((resolve) => {
|
|
1511
|
+
this.decisions.set(id, resolve);
|
|
1512
|
+
const withdrawn = () => resolve({ outcome: { outcome: "cancelled" } });
|
|
1513
|
+
if (signal.aborted) withdrawn();
|
|
1514
|
+
else signal.addEventListener("abort", withdrawn, { once: true });
|
|
1515
|
+
});
|
|
1516
|
+
this.decisions.delete(id);
|
|
1517
|
+
this.replace(id, "permission", (current) => ({
|
|
1518
|
+
...current,
|
|
1519
|
+
outcome: response.outcome.outcome === "selected" ? response.outcome.optionId : "cancelled"
|
|
1520
|
+
}));
|
|
1521
|
+
if (this.state.status === "waiting" && !this.decisions.size) this.emit({
|
|
1522
|
+
type: "set",
|
|
1523
|
+
status: "running"
|
|
1524
|
+
});
|
|
1525
|
+
return response;
|
|
1526
|
+
}
|
|
1527
|
+
update({ sessionId, update }) {
|
|
1528
|
+
if (sessionId !== this.sessionId) return;
|
|
1529
|
+
this.apply(update);
|
|
1530
|
+
}
|
|
1531
|
+
apply(update) {
|
|
1532
|
+
if (this.useSessionState(update)) return;
|
|
1533
|
+
if (this.replaying && this.shown) return;
|
|
1534
|
+
switch (update.sessionUpdate) {
|
|
1535
|
+
case "user_message_chunk":
|
|
1536
|
+
if (this.replaying && update.content.type === "text") this.replayUser(update.content.text, update.messageId ?? void 0);
|
|
1537
|
+
return;
|
|
1538
|
+
case "agent_message_chunk": {
|
|
1539
|
+
if (update.content.type !== "text") return;
|
|
1540
|
+
const { text } = update.content;
|
|
1541
|
+
if (text === this.greeting) {
|
|
1542
|
+
this.greeting = void 0;
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const last = this.state.entries.at(-1);
|
|
1546
|
+
const id = update.messageId ?? void 0;
|
|
1547
|
+
if (last?.kind === "agent" && (!id || last.id === id)) {
|
|
1548
|
+
this.emit({
|
|
1549
|
+
type: "append",
|
|
1550
|
+
id: last.id,
|
|
1551
|
+
text
|
|
1552
|
+
});
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
this.push({
|
|
1556
|
+
kind: "agent",
|
|
1557
|
+
id: id ?? randomUUID(),
|
|
1558
|
+
text
|
|
1559
|
+
});
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
case "config_option_update": {
|
|
1563
|
+
this.useConfig(update.configOptions);
|
|
1564
|
+
const mode = update.configOptions.find((option) => option.id === "mode" && option.type === "select");
|
|
1565
|
+
if (mode?.type === "select") this.useMode(mode.currentValue);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
case "current_mode_update":
|
|
1569
|
+
this.useMode(update.currentModeId);
|
|
1570
|
+
return;
|
|
1571
|
+
case "notice": {
|
|
1572
|
+
if (this.state.entries.some((entry) => entry.kind === "notice" && entry.title === update.title && entry.description === (update.description ?? void 0))) return;
|
|
1573
|
+
const notice = {
|
|
1574
|
+
kind: "notice",
|
|
1575
|
+
id: randomUUID(),
|
|
1576
|
+
severity: update.severity,
|
|
1577
|
+
title: update.title
|
|
1578
|
+
};
|
|
1579
|
+
if (update.description) notice.description = update.description;
|
|
1580
|
+
this.push(notice);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
case "tool_call":
|
|
1584
|
+
this.noteMcpServer(update);
|
|
1585
|
+
this.push({
|
|
1586
|
+
kind: "tool",
|
|
1587
|
+
id: update.toolCallId,
|
|
1588
|
+
title: update.title,
|
|
1589
|
+
toolKind: update.kind ?? "other",
|
|
1590
|
+
status: update.status ?? "pending",
|
|
1591
|
+
...toolDetails(update)
|
|
1592
|
+
});
|
|
1593
|
+
this.showPlan(update);
|
|
1594
|
+
return;
|
|
1595
|
+
case "tool_call_update":
|
|
1596
|
+
this.noteMcpServer(update);
|
|
1597
|
+
if (!this.state.entries.some((entry) => entry.kind === "tool" && entry.id === update.toolCallId)) {
|
|
1598
|
+
this.push({
|
|
1599
|
+
kind: "tool",
|
|
1600
|
+
id: update.toolCallId,
|
|
1601
|
+
title: update.title ?? "Tool call",
|
|
1602
|
+
toolKind: update.kind ?? "other",
|
|
1603
|
+
status: update.status ?? "pending",
|
|
1604
|
+
...toolDetails(update)
|
|
1605
|
+
});
|
|
1606
|
+
this.showPlan(update);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
this.replace(update.toolCallId, "tool", (current) => {
|
|
1610
|
+
const status = update.status ?? current.status;
|
|
1611
|
+
const output = status === "completed" || status === "failed" ? toolOutput(update) : void 0;
|
|
1612
|
+
const next = {
|
|
1613
|
+
...current,
|
|
1614
|
+
title: update.title ?? current.title,
|
|
1615
|
+
toolKind: update.kind ?? current.toolKind,
|
|
1616
|
+
status,
|
|
1617
|
+
...toolDetails(update)
|
|
1618
|
+
};
|
|
1619
|
+
if (output) next.output = output;
|
|
1620
|
+
return next;
|
|
1621
|
+
});
|
|
1622
|
+
this.showPlan(update);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
/** What the session says about itself rather than the turn: its
|
|
1627
|
+
* commands, how full it is, and its name. A reload's replay keeps them. */
|
|
1628
|
+
useSessionState(update) {
|
|
1629
|
+
switch (update.sessionUpdate) {
|
|
1630
|
+
case "available_commands_update":
|
|
1631
|
+
this.emit({
|
|
1632
|
+
type: "set",
|
|
1633
|
+
commands: update.availableCommands.map(commandOf)
|
|
1634
|
+
});
|
|
1635
|
+
this.announce();
|
|
1636
|
+
return true;
|
|
1637
|
+
case "usage_update": {
|
|
1638
|
+
const { used, size, cost } = update;
|
|
1639
|
+
this.emit({
|
|
1640
|
+
type: "set",
|
|
1641
|
+
usage: cost ? {
|
|
1642
|
+
used,
|
|
1643
|
+
size,
|
|
1644
|
+
cost: {
|
|
1645
|
+
amount: cost.amount,
|
|
1646
|
+
currency: cost.currency
|
|
1647
|
+
}
|
|
1648
|
+
} : {
|
|
1649
|
+
used,
|
|
1650
|
+
size
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
return true;
|
|
1654
|
+
}
|
|
1655
|
+
case "session_info_update": {
|
|
1656
|
+
const title = update.title?.trim();
|
|
1657
|
+
if (title && title !== this.state.title) {
|
|
1658
|
+
this.emit({
|
|
1659
|
+
type: "set",
|
|
1660
|
+
title
|
|
1661
|
+
});
|
|
1662
|
+
this.start.onTitle?.(title);
|
|
1663
|
+
}
|
|
1664
|
+
return true;
|
|
1665
|
+
}
|
|
1666
|
+
default: return false;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
noteMcpServer(update) {
|
|
1670
|
+
const server = mcpServerOf(update);
|
|
1671
|
+
if (server) this.mcpCalls.set(update.toolCallId, server);
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* In its read-only mode Claude answers by writing a plan and asking to
|
|
1675
|
+
* leave the mode with it. That request is refused, so the plan is shown
|
|
1676
|
+
* as the answer; the refusal's own text, once it comes, is not.
|
|
1677
|
+
*/
|
|
1678
|
+
showPlan(update) {
|
|
1679
|
+
const call = this.state.entries.find((entry) => entry.kind === "tool" && entry.id === update.toolCallId);
|
|
1680
|
+
if (call?.kind !== "tool" || call.toolKind !== "switch_mode" || call.status === "completed" || call.status === "failed") return;
|
|
1681
|
+
const plan = (update.content ?? []).flatMap((item) => item.type === "content" && item.content.type === "text" ? [item.content.text] : []).join("\n\n").trim();
|
|
1682
|
+
if (!plan) return;
|
|
1683
|
+
const id = `plan:${update.toolCallId}`;
|
|
1684
|
+
if (this.state.entries.some((entry) => entry.id === id)) this.replace(id, "agent", (current) => ({
|
|
1685
|
+
...current,
|
|
1686
|
+
text: plan
|
|
1687
|
+
}));
|
|
1688
|
+
else this.push({
|
|
1689
|
+
kind: "agent",
|
|
1690
|
+
id,
|
|
1691
|
+
text: plan
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
/** Chunks of one replayed message join, like an answer's. */
|
|
1695
|
+
replayUser(text, messageId) {
|
|
1696
|
+
const last = this.state.entries.at(-1);
|
|
1697
|
+
if (last?.kind === "user" && this.replayedUser.has(last.id) && (!messageId || last.id === messageId)) {
|
|
1698
|
+
const sent = this.replayedUser.get(last.id) + text;
|
|
1699
|
+
this.replayedUser.set(last.id, sent);
|
|
1700
|
+
this.emit({
|
|
1701
|
+
type: "entry",
|
|
1702
|
+
entry: {
|
|
1703
|
+
...last,
|
|
1704
|
+
text: withoutContext(sent)
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const id = messageId ?? randomUUID();
|
|
1710
|
+
this.replayedUser.set(id, text);
|
|
1711
|
+
this.push({
|
|
1712
|
+
kind: "user",
|
|
1713
|
+
id,
|
|
1714
|
+
text: withoutContext(text)
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
addUser(question) {
|
|
1718
|
+
const id = randomUUID();
|
|
1719
|
+
const attachments = attachmentsOf(question);
|
|
1720
|
+
const entry = {
|
|
1721
|
+
kind: "user",
|
|
1722
|
+
id,
|
|
1723
|
+
text: question.text,
|
|
1724
|
+
at: Date.now()
|
|
1725
|
+
};
|
|
1726
|
+
if (attachments.length) entry.attachments = attachments;
|
|
1727
|
+
this.push(entry);
|
|
1728
|
+
return id;
|
|
1729
|
+
}
|
|
1730
|
+
/** A question's files, as links into the checkout, and its images, for
|
|
1731
|
+
* an agent that reads them. */
|
|
1732
|
+
attachments(question) {
|
|
1733
|
+
const root = path.resolve(this.start.cwd);
|
|
1734
|
+
const blocks = [];
|
|
1735
|
+
for (const mention of question.mentions ?? []) {
|
|
1736
|
+
const file = path.resolve(root, mention);
|
|
1737
|
+
if (!file.startsWith(root + path.sep)) continue;
|
|
1738
|
+
blocks.push({
|
|
1739
|
+
type: "resource_link",
|
|
1740
|
+
uri: pathToFileURL(file).href,
|
|
1741
|
+
name: path.basename(file),
|
|
1742
|
+
title: mention
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
const images = question.images ?? [];
|
|
1746
|
+
if (!images.length) return blocks;
|
|
1747
|
+
if (!this.state.accepts?.image) {
|
|
1748
|
+
this.push({
|
|
1749
|
+
kind: "notice",
|
|
1750
|
+
id: randomUUID(),
|
|
1751
|
+
severity: "warning",
|
|
1752
|
+
title: `${this.state.agentName} does not read images, so they were left out.`
|
|
1753
|
+
});
|
|
1754
|
+
return blocks;
|
|
1755
|
+
}
|
|
1756
|
+
for (const { mimeType, data } of images) blocks.push({
|
|
1757
|
+
type: "image",
|
|
1758
|
+
mimeType,
|
|
1759
|
+
data
|
|
1760
|
+
});
|
|
1761
|
+
return blocks;
|
|
1762
|
+
}
|
|
1763
|
+
fail(cause) {
|
|
1764
|
+
if (this.closed) return;
|
|
1765
|
+
if (signedOut(cause)) {
|
|
1766
|
+
const { name, signIn } = askAgents[this.start.agent];
|
|
1767
|
+
this.emit({
|
|
1768
|
+
type: "set",
|
|
1769
|
+
status: "failed",
|
|
1770
|
+
error: `${name} is signed out.`,
|
|
1771
|
+
signIn
|
|
1772
|
+
});
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
const diagnostics = (this.process?.diagnostics() ?? this.stderr).trim();
|
|
1776
|
+
this.emit({
|
|
1777
|
+
type: "set",
|
|
1778
|
+
status: "failed",
|
|
1779
|
+
error: [errorMessage(cause), diagnostics?.split("\n").at(-1)].filter(Boolean).join("\n")
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
push(entry) {
|
|
1783
|
+
this.emit({
|
|
1784
|
+
type: "add",
|
|
1785
|
+
entry
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
replace(id, kind, change) {
|
|
1789
|
+
const current = this.state.entries.find((entry) => entry.id === id && entry.kind === kind);
|
|
1790
|
+
if (current) this.emit({
|
|
1791
|
+
type: "entry",
|
|
1792
|
+
entry: change(current)
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
/** The one way the state changes, so watchers can follow it change by change. */
|
|
1796
|
+
emit(change) {
|
|
1797
|
+
if (this.closed) return;
|
|
1798
|
+
this.state = applyAskChange(this.state, change);
|
|
1799
|
+
this.seq += 1;
|
|
1800
|
+
const update = {
|
|
1801
|
+
seq: this.seq,
|
|
1802
|
+
change
|
|
1803
|
+
};
|
|
1804
|
+
for (const listener of this.listeners) listener(update);
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
//#endregion
|
|
1808
|
+
//#region src/ask/threads.ts
|
|
1809
|
+
/** How long an agent may take to say what it offers. */
|
|
1810
|
+
const OFFER_TIMEOUT_MS = 3e4;
|
|
1811
|
+
/** How long after its session opens an agent may take to list its
|
|
1812
|
+
* commands, which it sends on its own rather than in the response. */
|
|
1813
|
+
const COMMANDS_WAIT_MS = 2e3;
|
|
1814
|
+
/** What an agent offers, from a session it starts and leaves without
|
|
1815
|
+
* asking anything: with the model given, where it offers that model, since
|
|
1816
|
+
* the efforts on offer depend on it. */
|
|
1817
|
+
async function offeredBy(launch, agent, cwd, model) {
|
|
1818
|
+
const process = await launch(agent, cwd);
|
|
1819
|
+
const timer = setTimeout(() => process.stop(), OFFER_TIMEOUT_MS);
|
|
1820
|
+
let listed = () => {};
|
|
1821
|
+
const commands = new Promise((resolve) => {
|
|
1822
|
+
listed = resolve;
|
|
1823
|
+
});
|
|
1824
|
+
try {
|
|
1825
|
+
const connection = process.connect(client({ name: "whiteboard" }).onNotification(methods.client.session.update, ({ params: { update } }) => {
|
|
1826
|
+
if (update.sessionUpdate === "available_commands_update") listed(update.availableCommands.map(commandOf));
|
|
1827
|
+
}));
|
|
1828
|
+
const initialized = await connection.agent.request(methods.agent.initialize, {
|
|
1829
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1830
|
+
clientCapabilities: { _meta: { parameterizedModelPicker: true } },
|
|
1831
|
+
clientInfo: {
|
|
1832
|
+
name: "whiteboard",
|
|
1833
|
+
title: "Whiteboard",
|
|
1834
|
+
version: "1"
|
|
1835
|
+
}
|
|
1836
|
+
});
|
|
1837
|
+
const session = await connection.agent.request(methods.agent.session.new, {
|
|
1838
|
+
cwd,
|
|
1839
|
+
mcpServers: [],
|
|
1840
|
+
_meta: askAgents[agent].sessionMeta
|
|
1841
|
+
});
|
|
1842
|
+
const offer = {
|
|
1843
|
+
choices: {},
|
|
1844
|
+
accepts: { image: initialized.agentCapabilities?.promptCapabilities?.image === true }
|
|
1845
|
+
};
|
|
1846
|
+
let found = choicesOf(session.configOptions);
|
|
1847
|
+
const models = found.get("model");
|
|
1848
|
+
if (model && models && model !== models.select.current && models.select.options.some((option) => option.value === model)) found = choicesOf((await connection.agent.request(methods.agent.session.setConfigOption, {
|
|
1849
|
+
sessionId: session.sessionId,
|
|
1850
|
+
configId: models.configId,
|
|
1851
|
+
value: model
|
|
1852
|
+
})).configOptions);
|
|
1853
|
+
for (const [kind, { select }] of found) offer.choices[kind] = select;
|
|
1854
|
+
let wait;
|
|
1855
|
+
const offered = await Promise.race([commands, new Promise((resolve) => {
|
|
1856
|
+
wait = setTimeout(() => resolve(void 0), COMMANDS_WAIT_MS);
|
|
1857
|
+
})]).finally(() => clearTimeout(wait));
|
|
1858
|
+
if (offered) offer.commands = offered;
|
|
1859
|
+
return offer;
|
|
1860
|
+
} catch (error) {
|
|
1861
|
+
const diagnostics = process.diagnostics().trim();
|
|
1862
|
+
throw new Error(`${askAgents[agent].name} did not say what it offers. ${errorMessage(error)}${diagnostics ? `\n${diagnostics}` : ""}`, { cause: error });
|
|
1863
|
+
} finally {
|
|
1864
|
+
clearTimeout(timer);
|
|
1865
|
+
process.stop();
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
/** The live Ask threads of one server; they end with it. */
|
|
1869
|
+
var AskThreads = class {
|
|
1870
|
+
launch;
|
|
1871
|
+
tools;
|
|
1872
|
+
limits;
|
|
1873
|
+
threads = /* @__PURE__ */ new Map();
|
|
1874
|
+
/** One question to each agent at a time about what it offers with a
|
|
1875
|
+
* model. */
|
|
1876
|
+
offers = /* @__PURE__ */ new Map();
|
|
1877
|
+
mcpServers;
|
|
1878
|
+
constructor(launch, tools = {}, limits = askThreadLimits) {
|
|
1879
|
+
this.launch = launch;
|
|
1880
|
+
this.tools = tools;
|
|
1881
|
+
this.limits = limits;
|
|
1882
|
+
this.mcpServers = tools.mcpServers ?? (() => []);
|
|
1883
|
+
}
|
|
1884
|
+
/** How the agent's sessions reach Whiteboard's tools: the MCP tools they
|
|
1885
|
+
* are given where its model gets them, else the CLI from its shell. */
|
|
1886
|
+
async reach(agent) {
|
|
1887
|
+
if (this.mcpServers().length && await (this.tools.takesMcp ?? askAgentTakesMcp)(agent)) return { kind: "mcp" };
|
|
1888
|
+
const command = this.tools.cli?.();
|
|
1889
|
+
return command ? {
|
|
1890
|
+
kind: "cli",
|
|
1891
|
+
command
|
|
1892
|
+
} : void 0;
|
|
1893
|
+
}
|
|
1894
|
+
open(start) {
|
|
1895
|
+
const thread = new AskThread(this.launch, start, this.mcpServers, this.limits);
|
|
1896
|
+
this.threads.set(thread.id, thread);
|
|
1897
|
+
thread.open();
|
|
1898
|
+
return thread;
|
|
1899
|
+
}
|
|
1900
|
+
get(id) {
|
|
1901
|
+
return this.threads.get(id);
|
|
1902
|
+
}
|
|
1903
|
+
/** What the agent offers to choose before anything is asked of it, with
|
|
1904
|
+
* its own model or the one given. */
|
|
1905
|
+
offered(agent, cwd, model) {
|
|
1906
|
+
const key = JSON.stringify([agent, model]);
|
|
1907
|
+
let offer = this.offers.get(key);
|
|
1908
|
+
if (!offer) {
|
|
1909
|
+
offer = offeredBy(this.launch, agent, cwd, model).finally(() => this.offers.delete(key));
|
|
1910
|
+
this.offers.set(key, offer);
|
|
1911
|
+
}
|
|
1912
|
+
return offer;
|
|
1913
|
+
}
|
|
1914
|
+
close(id) {
|
|
1915
|
+
this.threads.get(id)?.close();
|
|
1916
|
+
this.threads.delete(id);
|
|
1917
|
+
}
|
|
1918
|
+
closeAll() {
|
|
1919
|
+
for (const id of [...this.threads.keys()]) this.close(id);
|
|
1920
|
+
}
|
|
1921
|
+
};
|
|
1922
|
+
//#endregion
|
|
1923
|
+
//#region src/ask/checkout-files.ts
|
|
1924
|
+
const execFileAsync$1 = promisify(execFile);
|
|
1925
|
+
/** The checkout's files relative to its root: tracked ones, and new ones git
|
|
1926
|
+
* does not ignore. None where git cannot list them. */
|
|
1927
|
+
async function checkoutFiles(root) {
|
|
1928
|
+
try {
|
|
1929
|
+
const { stdout } = await execFileAsync$1("git", [
|
|
1930
|
+
"-C",
|
|
1931
|
+
root,
|
|
1932
|
+
"ls-files",
|
|
1933
|
+
"-z",
|
|
1934
|
+
"--cached",
|
|
1935
|
+
"--others",
|
|
1936
|
+
"--exclude-standard"
|
|
1937
|
+
], { maxBuffer: 67108864 });
|
|
1938
|
+
return [...new Set(stdout.split("\0").filter(Boolean))];
|
|
1939
|
+
} catch {
|
|
1940
|
+
return [];
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
/** How long a listing serves mentions before git is asked again. */
|
|
1944
|
+
const LISTING_MS = 1e4;
|
|
1945
|
+
const listings = /* @__PURE__ */ new Map();
|
|
1946
|
+
/** The checkout's files for a mention picker, which asks on each keystroke:
|
|
1947
|
+
* listed again only once the last listing is a few seconds old. */
|
|
1948
|
+
function mentionableFiles(root) {
|
|
1949
|
+
const now = Date.now();
|
|
1950
|
+
const listed = listings.get(root);
|
|
1951
|
+
if (listed && now - listed.at < LISTING_MS) return listed.files;
|
|
1952
|
+
const files = checkoutFiles(root);
|
|
1953
|
+
listings.set(root, {
|
|
1954
|
+
at: now,
|
|
1955
|
+
files
|
|
1956
|
+
});
|
|
1957
|
+
return files;
|
|
1958
|
+
}
|
|
1959
|
+
//#endregion
|
|
1960
|
+
//#region src/ask/file-refs.ts
|
|
1961
|
+
/** `:42`, `:42:7`, `:42-50`, `#L42`, `#L42C7`, `#L42-L50`. */
|
|
1962
|
+
const lineSuffix = /(?::(\d+)(?::\d+)?(?:-\d+)?|#L(\d+)(?:C\d+)?(?:-L?\d+)?)$/;
|
|
1963
|
+
/** A scheme, as in `https://`; a Windows drive letter is not one. */
|
|
1964
|
+
const urlScheme = /^[a-z][a-z\d+.-]+:/i;
|
|
1965
|
+
/** What a piece of inline code or a link target names, if it could be a
|
|
1966
|
+
* file: a path with a directory or an extension, and no spaces. Whether the
|
|
1967
|
+
* file exists is the checkout's to say. */
|
|
1968
|
+
function parseFileRef(text) {
|
|
1969
|
+
let value = text.trim();
|
|
1970
|
+
if (value.startsWith("file://")) try {
|
|
1971
|
+
value = decodeURIComponent(value.slice(7));
|
|
1972
|
+
} catch {
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
if (!value || value.length > 400 || /[\s`'"<>|*?{}$]/.test(value) || urlScheme.test(value) || value.startsWith("-")) return void 0;
|
|
1976
|
+
const match = lineSuffix.exec(value);
|
|
1977
|
+
const path = (match ? value.slice(0, match.index) : value).replace(/^\.\//, "");
|
|
1978
|
+
const name = path.split("/").at(-1) ?? "";
|
|
1979
|
+
if (!name || !(path.includes("/") || /\.[a-z][a-z\d]*$/i.test(name))) return void 0;
|
|
1980
|
+
const line = Number(match?.[1] ?? match?.[2]);
|
|
1981
|
+
return line > 0 ? {
|
|
1982
|
+
path,
|
|
1983
|
+
line
|
|
1984
|
+
} : { path };
|
|
1985
|
+
}
|
|
1986
|
+
/** Resolves the paths an answer names to files in the checkout, relative to
|
|
1987
|
+
* its root. Agents write paths from the checkout root, as absolute paths, or
|
|
1988
|
+
* relative to the package they are in; a path that could be several files
|
|
1989
|
+
* resolves to the one the agent's tools touched, else to none. */
|
|
1990
|
+
function resolveFileRefs(root, files, paths, touched = []) {
|
|
1991
|
+
const known = new Set(files);
|
|
1992
|
+
const inRoot = (path) => path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
|
1993
|
+
const named = (path) => known.has(path) ? [path] : files.filter((file) => file.endsWith(`/${path}`));
|
|
1994
|
+
const touchedFiles = new Set(touched.flatMap((path) => {
|
|
1995
|
+
const relative = inRoot(path);
|
|
1996
|
+
return relative.startsWith("/") ? [] : named(relative);
|
|
1997
|
+
}));
|
|
1998
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1999
|
+
for (const asked of paths) {
|
|
2000
|
+
const path = inRoot(asked);
|
|
2001
|
+
if (path.startsWith("/")) continue;
|
|
2002
|
+
for (const candidate of [path, path.replace(/^[ab]\//, "")]) {
|
|
2003
|
+
const matches = named(candidate);
|
|
2004
|
+
const touchedMatches = matches.filter((file) => touchedFiles.has(file));
|
|
2005
|
+
const match = matches.length === 1 ? matches[0] : touchedMatches.length === 1 ? touchedMatches[0] : void 0;
|
|
2006
|
+
if (match) {
|
|
2007
|
+
resolved.set(asked, match);
|
|
2008
|
+
break;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
return resolved;
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* NDJSON of a thread: a snapshot, then each change in `seq` order. Answer
|
|
2016
|
+
* tokens arrive as appends, so a reply costs about what the agent sent.
|
|
2017
|
+
*/
|
|
2018
|
+
function watchAskThread(thread, backlog = 256) {
|
|
2019
|
+
const encoder = new TextEncoder();
|
|
2020
|
+
let pending = [thread.snapshot()];
|
|
2021
|
+
let stop = () => {};
|
|
2022
|
+
/** The thread closed; the stream ends once `pending` is sent. */
|
|
2023
|
+
let ended = false;
|
|
2024
|
+
/** The stream ended, so nothing more may be enqueued. */
|
|
2025
|
+
let finished = false;
|
|
2026
|
+
const flush = (controller) => {
|
|
2027
|
+
while (pending.length && (controller.desiredSize ?? 0) > 0) controller.enqueue(encoder.encode(`${JSON.stringify(pending.shift())}\n`));
|
|
2028
|
+
if (ended && !pending.length && !finished) {
|
|
2029
|
+
finished = true;
|
|
2030
|
+
controller.close();
|
|
2031
|
+
}
|
|
2032
|
+
};
|
|
2033
|
+
const body = new ReadableStream({
|
|
2034
|
+
start(controller) {
|
|
2035
|
+
const unsubscribe = thread.subscribe((update) => {
|
|
2036
|
+
pending.push(update);
|
|
2037
|
+
if (pending.length > backlog) pending = [thread.snapshot()];
|
|
2038
|
+
flush(controller);
|
|
2039
|
+
});
|
|
2040
|
+
const unclose = thread.onClose(() => {
|
|
2041
|
+
ended = true;
|
|
2042
|
+
unsubscribe();
|
|
2043
|
+
flush(controller);
|
|
2044
|
+
});
|
|
2045
|
+
stop = () => {
|
|
2046
|
+
unsubscribe();
|
|
2047
|
+
unclose();
|
|
2048
|
+
};
|
|
2049
|
+
flush(controller);
|
|
2050
|
+
},
|
|
2051
|
+
pull: flush,
|
|
2052
|
+
cancel() {
|
|
2053
|
+
stop();
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
return new Response(body, { headers: {
|
|
2057
|
+
"content-type": "application/x-ndjson",
|
|
2058
|
+
"cache-control": "no-store"
|
|
2059
|
+
} });
|
|
2060
|
+
}
|
|
2061
|
+
//#endregion
|
|
2062
|
+
//#region src/review-stack.ts
|
|
2063
|
+
const execFileAsync = promisify(execFile);
|
|
2064
|
+
const GitHubStacksSchema = z.array(z.object({ pull_requests: z.array(z.object({
|
|
2065
|
+
number: z.number().int().positive(),
|
|
2066
|
+
head: z.object({ ref: z.string().min(1) })
|
|
2067
|
+
})) }));
|
|
2068
|
+
async function resolveReviewStackLayers(subject, reviews, runGitHubApi = defaultRunGitHubApi) {
|
|
2069
|
+
const binding = subject.origin?.pullRequestUrl?.match(/^https:\/\/([a-z0-9.-]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9]\d*)$/);
|
|
2070
|
+
if (!binding) return [];
|
|
2071
|
+
const [, host, owner, repository, number] = binding;
|
|
2072
|
+
const pullRequestNumber = Number(number);
|
|
2073
|
+
const repoKey = `https://${host}/${owner}/${repository}`;
|
|
2074
|
+
let stacks;
|
|
2075
|
+
try {
|
|
2076
|
+
stacks = GitHubStacksSchema.parse(JSON.parse(await runGitHubApi(host, `repos/${owner}/${repository}/stacks?pull_request=${number}`)));
|
|
2077
|
+
} catch {
|
|
2078
|
+
return [];
|
|
2079
|
+
}
|
|
2080
|
+
const stack = stacks.find((candidate) => candidate.pull_requests.some((pr) => pr.number === pullRequestNumber));
|
|
2081
|
+
if (!stack) return [];
|
|
2082
|
+
const currentIndex = stack.pull_requests.findIndex((pr) => pr.number === pullRequestNumber);
|
|
2083
|
+
return stack.pull_requests.map((pr, index) => {
|
|
2084
|
+
const review = reviews.find((candidate) => {
|
|
2085
|
+
return (candidate.origin?.pullRequestUrl?.replace(/\/pull\/\d+.*$/, "") ?? candidate.pins?.repositoryId) === repoKey && candidate.origin?.pullRequestNumber === pr.number;
|
|
2086
|
+
});
|
|
2087
|
+
return {
|
|
2088
|
+
branch: pr.head.ref,
|
|
2089
|
+
pullRequestNumber: pr.number,
|
|
2090
|
+
pullRequestUrl: `${repoKey}/pull/${pr.number}`,
|
|
2091
|
+
reviewUuid: review?.reviewId ?? null,
|
|
2092
|
+
reviewTitle: review?.title ?? null,
|
|
2093
|
+
relation: index < currentIndex ? "earlier" : index === currentIndex ? "current" : "later"
|
|
2094
|
+
};
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
async function defaultRunGitHubApi(host, endpoint) {
|
|
2098
|
+
const { stdout } = await execFileAsync("gh", [
|
|
2099
|
+
"api",
|
|
2100
|
+
"--hostname",
|
|
2101
|
+
host,
|
|
2102
|
+
endpoint
|
|
2103
|
+
], {
|
|
2104
|
+
env: {
|
|
2105
|
+
...process.env,
|
|
2106
|
+
GH_PROMPT_DISABLED: "1"
|
|
2107
|
+
},
|
|
2108
|
+
encoding: "utf8",
|
|
2109
|
+
timeout: 1e4,
|
|
2110
|
+
maxBuffer: 2097152
|
|
2111
|
+
});
|
|
2112
|
+
return stdout;
|
|
2113
|
+
}
|
|
2114
|
+
//#endregion
|
|
2115
|
+
//#region src/sharing/auth.ts
|
|
2116
|
+
/** CI credentials stay in the server environment and are never persisted to a profile. */
|
|
2117
|
+
async function readSharingAuth(env = process.env) {
|
|
2118
|
+
if (env.DEV_REVIEW_SHARE_TOKEN === void 0) {
|
|
2119
|
+
if (env.DEV_REVIEW_SHARE_ORIGIN !== void 0) throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN requires DEV_REVIEW_SHARE_TOKEN.");
|
|
2120
|
+
return readStoreAuth(env);
|
|
2121
|
+
}
|
|
2122
|
+
const token = env.DEV_REVIEW_SHARE_TOKEN.trim();
|
|
2123
|
+
if (!token) throw new ReviewInputError("DEV_REVIEW_SHARE_TOKEN is empty.");
|
|
2124
|
+
let url;
|
|
2125
|
+
try {
|
|
2126
|
+
url = new URL(env.DEV_REVIEW_SHARE_ORIGIN ?? DEFAULT_STORE_ORIGIN);
|
|
2127
|
+
} catch {
|
|
2128
|
+
throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.");
|
|
2129
|
+
}
|
|
2130
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.");
|
|
2131
|
+
return {
|
|
2132
|
+
origin: url.origin,
|
|
2133
|
+
token
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
//#endregion
|
|
2137
|
+
//#region src/sharing/host.ts
|
|
2138
|
+
const publishSchema = z.strictObject({
|
|
2139
|
+
reviewId: z.string().min(1),
|
|
2140
|
+
version: z.number().int().nonnegative().optional(),
|
|
2141
|
+
requestId: z.uuid().optional()
|
|
2142
|
+
});
|
|
2143
|
+
/** Mounted behind local host authentication. Account credentials never enter the renderer. */
|
|
2144
|
+
function mountSharingHost(app, store, data, shared, options = {}) {
|
|
2145
|
+
const startLogin = options.login ?? runStoreLogin;
|
|
2146
|
+
const openUrl = options.openUrl ?? openUrlInBrowser;
|
|
2147
|
+
let login = { pending: false };
|
|
2148
|
+
app.get("/sharing/account", async (context) => {
|
|
2149
|
+
const account = await readStoreAuth();
|
|
2150
|
+
return context.json({
|
|
2151
|
+
account: account ? {
|
|
2152
|
+
login: account.login,
|
|
2153
|
+
origin: account.origin
|
|
2154
|
+
} : null,
|
|
2155
|
+
...login
|
|
2156
|
+
});
|
|
2157
|
+
});
|
|
2158
|
+
app.post("/sharing/login", async (context) => {
|
|
2159
|
+
if (!login.pending) {
|
|
2160
|
+
login = { pending: true };
|
|
2161
|
+
options.onLogin?.("started");
|
|
2162
|
+
const discard = new Writable({ write(_chunk, _encoding, done) {
|
|
2163
|
+
done();
|
|
2164
|
+
} });
|
|
2165
|
+
startLogin({
|
|
2166
|
+
traces: false,
|
|
2167
|
+
stdout: discard,
|
|
2168
|
+
stderr: discard,
|
|
2169
|
+
openUrl: async (url) => {
|
|
2170
|
+
login.url = url;
|
|
2171
|
+
await openUrl(url);
|
|
2172
|
+
}
|
|
2173
|
+
}).then((code) => {
|
|
2174
|
+
login = { pending: false };
|
|
2175
|
+
if (code) login.error = "Sign-in did not finish. Try again.";
|
|
2176
|
+
options.onLogin?.(code ? "failed" : "succeeded", code ? "did_not_finish" : void 0);
|
|
2177
|
+
}).catch(() => {
|
|
2178
|
+
login = {
|
|
2179
|
+
pending: false,
|
|
2180
|
+
error: "Sign-in failed. Try again."
|
|
2181
|
+
};
|
|
2182
|
+
options.onLogin?.("failed", "error");
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
return context.json(login);
|
|
2186
|
+
});
|
|
2187
|
+
mountSharingPublisher(app, store, data, options);
|
|
2188
|
+
const imports = /* @__PURE__ */ new Map();
|
|
2189
|
+
app.get("/sharing/import/:id", (context) => {
|
|
2190
|
+
const id = context.req.param("id");
|
|
2191
|
+
const status = shared.status(id);
|
|
2192
|
+
const result = {
|
|
2193
|
+
reviewId: id,
|
|
2194
|
+
...status
|
|
2195
|
+
};
|
|
2196
|
+
if (status.stage === "ready") return context.json({
|
|
2197
|
+
...result,
|
|
2198
|
+
title: shared.get(id).snapshot.title
|
|
2199
|
+
});
|
|
2200
|
+
return context.json(result);
|
|
2201
|
+
});
|
|
2202
|
+
app.post("/sharing/import", async (context) => {
|
|
2203
|
+
const { url } = z.strictObject({ url: z.string().max(2048) }).parse(await readBoundedRequestJson(context.req.raw));
|
|
2204
|
+
const parsed = parseShareLink(url);
|
|
2205
|
+
const account = await readStoreAuth();
|
|
2206
|
+
if (parsed.origin !== DEFAULT_STORE_ORIGIN && parsed.origin !== account?.origin) throw new ReviewInputError("This share uses an untrusted service. Sign in to that service before opening its links.", 400);
|
|
2207
|
+
const id = sharedReviewId(parsed.origin, parsed.shareId);
|
|
2208
|
+
if (!imports.has(id)) {
|
|
2209
|
+
shared.setStatus(id, "downloading");
|
|
2210
|
+
const job = (async () => {
|
|
2211
|
+
if (shared.has(id)) {
|
|
2212
|
+
await shared.prepare(id);
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
const bundle = await new ShareClient(parsed.origin).download(parsed.shareId, parsed.capability);
|
|
2216
|
+
await shared.import(parsed.origin, parsed.shareId, bundle);
|
|
2217
|
+
})().catch((error) => {
|
|
2218
|
+
shared.setStatus(id, "error", error instanceof ReviewInputError ? error.message : "This share is unavailable, revoked, or needs a newer Whiteboard version.");
|
|
2219
|
+
}).finally(() => imports.delete(id));
|
|
2220
|
+
imports.set(id, job);
|
|
2221
|
+
shared.trackImport(job);
|
|
2222
|
+
}
|
|
2223
|
+
return context.json({
|
|
2224
|
+
reviewId: id,
|
|
2225
|
+
...shared.status(id)
|
|
2226
|
+
}, 202);
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
/** Publishing needs only an authored store, never Desktop or recipient workspaces. */
|
|
2230
|
+
function mountSharingPublisher(app, store, data, options = {}) {
|
|
2231
|
+
const verifyRepository = options.verifyRepository ?? verifyShareRepository;
|
|
2232
|
+
app.post("/sharing/publish", async (context) => {
|
|
2233
|
+
const input = publishSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
2234
|
+
if (input.reviewId.startsWith("shared-")) throw new ReviewInputError("Only the authoring review can be shared.", 409);
|
|
2235
|
+
const snapshot = store.read(input.reviewId, input.version);
|
|
2236
|
+
if (snapshot.target?.kind === "worktree") throw new ReviewInputError("Pin this review to commits before sharing it.");
|
|
2237
|
+
validateShareSources(snapshot);
|
|
2238
|
+
const account = await readSharingAuth();
|
|
2239
|
+
if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login before sharing.", 409);
|
|
2240
|
+
const root = snapshot.pins ? store.repositoryPath(snapshot.pins.repositoryId) : void 0;
|
|
2241
|
+
const repository = root ? await (options.readRepository ?? readShareRepository)(root) : void 0;
|
|
2242
|
+
const bundle = await exportShare({
|
|
2243
|
+
store,
|
|
2244
|
+
data,
|
|
2245
|
+
reviewId: input.reviewId,
|
|
2246
|
+
version: snapshot.version,
|
|
2247
|
+
repository
|
|
2248
|
+
});
|
|
2249
|
+
const verification = root && snapshot.pins && repository ? verifyRepository(root, snapshot.pins, repository).then(() => ({ ok: true }), (error) => ({
|
|
2250
|
+
ok: false,
|
|
2251
|
+
error
|
|
2252
|
+
})) : Promise.resolve({ ok: true });
|
|
2253
|
+
try {
|
|
2254
|
+
const result = await new ShareClient(account.origin, account.token, options.fetch).create(bundle, input.requestId ?? randomUUID(), async () => {
|
|
2255
|
+
const result = await verification;
|
|
2256
|
+
if (!result.ok) throw result.error;
|
|
2257
|
+
});
|
|
2258
|
+
const url = new URL(result.url);
|
|
2259
|
+
if (process.env.DEV_FAST_REVIEW_APP_URL_PROTOCOL === "dev-fast-review-preview") url.searchParams.set("app", "preview");
|
|
2260
|
+
options.onPublished?.({
|
|
2261
|
+
reviewId: input.reviewId,
|
|
2262
|
+
version: snapshot.version
|
|
2263
|
+
});
|
|
2264
|
+
return context.json({
|
|
2265
|
+
...result,
|
|
2266
|
+
url: url.href,
|
|
2267
|
+
version: snapshot.version
|
|
2268
|
+
});
|
|
2269
|
+
} catch (error) {
|
|
2270
|
+
if (error instanceof SharePreflightError) return context.json({ error: error.message }, 422);
|
|
2271
|
+
if (error instanceof ShareAuthError) {
|
|
2272
|
+
if (process.env.DEV_REVIEW_SHARE_TOKEN === void 0) await clearStoreAuth();
|
|
2273
|
+
throw new ReviewInputError("Your sign-in has expired. Sign in again to share.", 401);
|
|
2274
|
+
}
|
|
2275
|
+
throw new ReviewInputError("Sharing failed. Check your connection and login, then retry.", 409);
|
|
2276
|
+
}
|
|
2277
|
+
});
|
|
2278
|
+
app.post("/sharing/revoke", async (context) => {
|
|
2279
|
+
const { shareId } = z.strictObject({ shareId: shareIdSchema }).parse(await readBoundedRequestJson(context.req.raw));
|
|
2280
|
+
const account = await readSharingAuth();
|
|
2281
|
+
if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login first.", 409);
|
|
2282
|
+
await new ShareClient(account.origin, account.token).revoke(shareId);
|
|
2283
|
+
options.onRevoked?.({ shareId });
|
|
2284
|
+
return context.json({
|
|
2285
|
+
shareId,
|
|
2286
|
+
revoked: true
|
|
2287
|
+
});
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
//#endregion
|
|
2291
|
+
//#region src/sharing/routes.ts
|
|
2292
|
+
/** Snapshot-backed reads used by the same HTTP routes as local reviews. */
|
|
2293
|
+
var SharedReviewData = class {
|
|
2294
|
+
store;
|
|
2295
|
+
constructor(store) {
|
|
2296
|
+
this.store = store;
|
|
2297
|
+
}
|
|
2298
|
+
map(id, resourceId) {
|
|
2299
|
+
const map = this.store.get(id).presentation.maps[resourceId];
|
|
2300
|
+
if (!map) throw new ReviewInputError("Map is not included in this share.", 404);
|
|
2301
|
+
return map;
|
|
2302
|
+
}
|
|
2303
|
+
async resource(id, resourceId) {
|
|
2304
|
+
const resource = this.store.get(id).manifest.resources.find((item) => item.id === resourceId);
|
|
2305
|
+
if (!resource) throw new ReviewInputError("Resource is not included in this share.", 404);
|
|
2306
|
+
return {
|
|
2307
|
+
data: await this.store.readObject(id, resource.object),
|
|
2308
|
+
mimeType: resource.mimeType
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
//#endregion
|
|
2313
|
+
//#region src/review-api/anchor-quotes.ts
|
|
2314
|
+
const QUOTE_LIMIT = 20;
|
|
2315
|
+
const clip = (line) => line.trim() === "" ? "(blank line)" : line.length > 120 ? `${line.slice(0, 119)}…` : line;
|
|
2316
|
+
/**
|
|
2317
|
+
* The first and last line of each anchor an edit added or changed, so its
|
|
2318
|
+
* author can see a range is the one meant without reading the file again.
|
|
2319
|
+
* A range that can't be read was already rejected, so it is skipped here.
|
|
2320
|
+
* `unquoted` counts the anchors past the limit, so a short list isn't
|
|
2321
|
+
* mistaken for a complete one.
|
|
2322
|
+
*/
|
|
2323
|
+
async function anchorQuotes(before, after, read) {
|
|
2324
|
+
const key = (id, source) => `${id}\0${selectionKey(source)}`;
|
|
2325
|
+
const known = new Set(before ? selectionReferences(before.document, { tolerant: true }).map(({ id, source }) => key(id, source)) : []);
|
|
2326
|
+
const added = selectionReferences(after.document, { tolerant: true }).filter(({ id, source }) => !known.has(key(id, source)));
|
|
2327
|
+
return {
|
|
2328
|
+
quotes: (await Promise.all(added.slice(0, QUOTE_LIMIT).map(async ({ source }) => {
|
|
2329
|
+
try {
|
|
2330
|
+
const pins = anchorPins(source, after.pins);
|
|
2331
|
+
const line = async (endpoint) => (await read(pins, endpoint.side, source.file)).split("\n")[endpoint.line - 1] ?? "";
|
|
2332
|
+
const first = clip(await line(source.start));
|
|
2333
|
+
return source.start.side === source.end.side && source.start.line === source.end.line ? {
|
|
2334
|
+
anchor: formatAnchor(source),
|
|
2335
|
+
first
|
|
2336
|
+
} : {
|
|
2337
|
+
anchor: formatAnchor(source),
|
|
2338
|
+
first,
|
|
2339
|
+
last: clip(await line(source.end))
|
|
2340
|
+
};
|
|
2341
|
+
} catch {
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
}))).filter((quote) => quote !== void 0),
|
|
2345
|
+
unquoted: Math.max(0, added.length - QUOTE_LIMIT)
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
//#endregion
|
|
2349
|
+
//#region src/review-api/document-text.ts
|
|
2350
|
+
/** A reading view of saved content, not another document format to maintain. */
|
|
2351
|
+
function documentText(snapshot, targetId, full = false) {
|
|
2352
|
+
const target = targetId === void 0 ? void 0 : elements(snapshot.document).find((item) => item.id === targetId);
|
|
2353
|
+
if (targetId !== void 0 && !target) throw new ReviewInputError("Target not found in this version.", 404);
|
|
2354
|
+
const detailed = full || target !== void 0;
|
|
2355
|
+
const lines = [
|
|
2356
|
+
`# ${snapshot.title}`,
|
|
2357
|
+
`Session ${snapshot.reviewId} · version ${snapshot.version}`,
|
|
2358
|
+
""
|
|
2359
|
+
];
|
|
2360
|
+
const write = (depth, text) => {
|
|
2361
|
+
for (const line of text.split("\n")) lines.push(" ".repeat(depth) + line);
|
|
2362
|
+
};
|
|
2363
|
+
const code = (depth, value) => {
|
|
2364
|
+
write(depth, `Code (${value.language}):`);
|
|
2365
|
+
write(depth + 1, value.text);
|
|
2366
|
+
};
|
|
2367
|
+
const render = (element, depth) => {
|
|
2368
|
+
const title = "title" in element ? element.title : void 0;
|
|
2369
|
+
write(depth, `[${element.id}] ${element.type}${title ? `: ${title}` : ""}`);
|
|
2370
|
+
const detail = (text) => write(depth + 1, text);
|
|
2371
|
+
switch (element.type) {
|
|
2372
|
+
case "section":
|
|
2373
|
+
case "callout":
|
|
2374
|
+
if (element.type === "callout") detail(`Tone: ${element.tone}`);
|
|
2375
|
+
if (element.type === "section" && element.defaultCollapsed) detail("Initially collapsed");
|
|
2376
|
+
element.children.forEach((child) => render(child, depth + 1));
|
|
2377
|
+
break;
|
|
2378
|
+
case "markdown":
|
|
2379
|
+
detail(detailed ? element.markdown : element.markdown.split("\n")[0] + (element.markdown.includes("\n") ? " …" : ""));
|
|
2380
|
+
break;
|
|
2381
|
+
case "code":
|
|
2382
|
+
if (element.caption) detail(element.caption);
|
|
2383
|
+
if (detailed) code(depth + 1, element);
|
|
2384
|
+
else detail(`${element.language}, ${element.text.split("\n").length} lines`);
|
|
2385
|
+
break;
|
|
2386
|
+
case "divider": break;
|
|
2387
|
+
case "code_peek":
|
|
2388
|
+
detail(element.source);
|
|
2389
|
+
if (element.caption) detail(element.caption);
|
|
2390
|
+
break;
|
|
2391
|
+
case "sequence":
|
|
2392
|
+
for (const [key, label] of Object.entries(element.actors)) detail(`Actor ${key}: ${label}`);
|
|
2393
|
+
element.steps.forEach((step) => render(step, depth + 1));
|
|
2394
|
+
break;
|
|
2395
|
+
case "step":
|
|
2396
|
+
detail(`${element.from} → ${element.to}: ${element.label} (${element.style})`);
|
|
2397
|
+
if (element.source) detail(element.source);
|
|
2398
|
+
if (detailed && element.explanation) detail(element.explanation);
|
|
2399
|
+
if (detailed && element.code) code(depth + 1, element.code);
|
|
2400
|
+
break;
|
|
2401
|
+
case "call_stack_diff":
|
|
2402
|
+
for (const side of ["base", "head"]) {
|
|
2403
|
+
detail(`${side}:`);
|
|
2404
|
+
element[side].forEach((frame) => {
|
|
2405
|
+
write(depth + 2, `${frame.label ?? frame.key ?? "Frame"}: ${frame.source}`);
|
|
2406
|
+
if (detailed && frame.via) write(depth + 3, `${frame.via.kind}: ${frame.via.reason}`);
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
break;
|
|
2410
|
+
case "database_lens":
|
|
2411
|
+
for (const [key, label] of Object.entries(element.actors)) detail(`Actor ${key}: ${label}`);
|
|
2412
|
+
for (const [key, store] of Object.entries(element.stores)) {
|
|
2413
|
+
detail(`Store ${key}: ${store.label} (${store.storage})`);
|
|
2414
|
+
for (const [key, collection] of Object.entries(store.collections)) {
|
|
2415
|
+
write(depth + 2, `Collection ${key}: ${collection.label}`);
|
|
2416
|
+
if (detailed) for (const [key, field] of Object.entries(collection.fields)) write(depth + 3, `${key}: ${field.label}, ${field.dataType}${field.primaryKey ? ", primary key" : ""}${field.nullable ? ", nullable" : ""}${field.references ? ` → ${field.references.store}.${field.references.collection}.${field.references.field}` : ""}`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
for (const useCase of element.useCases) {
|
|
2420
|
+
detail(`Use case: ${useCase.label}`);
|
|
2421
|
+
if (detailed && useCase.summary) write(depth + 2, useCase.summary);
|
|
2422
|
+
for (const operation of useCase.operations) write(depth + 2, `${operation.actor}: ${operation.kind} ${operation.store}.${operation.collection}${operation.field ? `.${operation.field}` : ""} — ${operation.label} (${operation.source})`);
|
|
2423
|
+
}
|
|
2424
|
+
break;
|
|
2425
|
+
case "image":
|
|
2426
|
+
detail(`${element.alt} (asset: ${element.assetId})`);
|
|
2427
|
+
if (element.caption) detail(element.caption);
|
|
2428
|
+
break;
|
|
2429
|
+
case "trace_quote":
|
|
2430
|
+
detail(`${element.traceId}, event ${element.eventId}`);
|
|
2431
|
+
detail(element.text);
|
|
2432
|
+
break;
|
|
2433
|
+
case "flow_diagram":
|
|
2434
|
+
if (element.description) detail(element.description);
|
|
2435
|
+
for (const node of element.nodes) {
|
|
2436
|
+
detail(`[${node.id}] ${node.key}: ${node.label}`);
|
|
2437
|
+
if (detailed && node.description) write(depth + 2, node.description);
|
|
2438
|
+
for (const attachment of node.attachments) write(depth + 2, `${attachment.label}: ${attachment.sources.join(", ")}`);
|
|
2439
|
+
}
|
|
2440
|
+
for (const edge of element.edges) detail(`[${edge.id}] ${edge.from} → ${edge.to}${edge.label ? `: ${edge.label}` : ""}`);
|
|
2441
|
+
break;
|
|
2442
|
+
case "software_map": detail(`Map: ${element.mapVersionId}${element.focusElementId ? `, focus: ${element.focusElementId}` : ""}`);
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
(target ? [target] : snapshot.document).forEach((element) => render(element, 0));
|
|
2446
|
+
if (!snapshot.document.length) lines.push("(Empty review)");
|
|
2447
|
+
if (!target && snapshot.lenses?.length) {
|
|
2448
|
+
lines.push("", "Lenses (edit with session_lens_edit):");
|
|
2449
|
+
for (const lens of snapshot.lenses) {
|
|
2450
|
+
write(0, `[${lens.id}] ${lens.title}`);
|
|
2451
|
+
for (const item of lens.targets) if (item.kind === "files") write(1, `Files: ${item.patterns.join(", ")}`);
|
|
2452
|
+
else for (const source of item.sources) write(1, `Range: ${source}`);
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
if (!detailed && snapshot.document.length) lines.push("", "Use targetId for one component's complete content, or full:true for the whole review.");
|
|
2456
|
+
return lines.join("\n");
|
|
2457
|
+
}
|
|
2458
|
+
//#endregion
|
|
2459
|
+
//#region src/review-api/file-lenses.ts
|
|
2460
|
+
/** Subtract authored coverage, independently of viewed state. */
|
|
2461
|
+
function uncategorizedSources(files, sources) {
|
|
2462
|
+
return files.flatMap((file) => {
|
|
2463
|
+
const covered = scopedCoverage(file, sources);
|
|
2464
|
+
return coverageSources(file, {
|
|
2465
|
+
base: subtractIntervals(file.changed.base, covered.base),
|
|
2466
|
+
head: subtractIntervals(file.changed.head, covered.head)
|
|
2467
|
+
});
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
/** Resolve all targets into one union; a whole-file range subsumes narrower ranges. */
|
|
2471
|
+
function resolveFileLens(lens, files, fileSources, resolve) {
|
|
2472
|
+
const targets = lens.targets;
|
|
2473
|
+
const selected = targets.flatMap((target) => target.kind === "ranges" ? target.sources.flatMap((source) => resolve(anchorSelection(source))) : files.filter((file) => matchesFileLens(target.patterns, file)).flatMap((file) => fileSources.get(file.path) ?? []));
|
|
2474
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2475
|
+
for (const source of selected) {
|
|
2476
|
+
const key = JSON.stringify([source.side, source.file]);
|
|
2477
|
+
const group = groups.get(key) ?? [];
|
|
2478
|
+
group.push(source);
|
|
2479
|
+
groups.set(key, group);
|
|
2480
|
+
}
|
|
2481
|
+
const sources = [...groups.values()].flatMap((group) => unionIntervals(group.map((source) => [source.fromLine - 1, source.toLine])).map(([start, end]) => ({
|
|
2482
|
+
...group[0],
|
|
2483
|
+
fromLine: start + 1,
|
|
2484
|
+
toLine: end
|
|
2485
|
+
})));
|
|
2486
|
+
return {
|
|
2487
|
+
sources,
|
|
2488
|
+
fileCount: new Set(sources.map((source) => files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file)).size,
|
|
2489
|
+
wholeFiles: targets.every((target) => target.kind === "files")
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
/** Evaluate authored patterns against the changed-file list, never the filesystem. */
|
|
2493
|
+
function matchesFileLens(patterns, file) {
|
|
2494
|
+
return patterns.some((pattern) => {
|
|
2495
|
+
const normalized = pattern.startsWith("./") ? pattern.slice(2) : pattern;
|
|
2496
|
+
return [file.path, file.previousPath].some((path) => path !== void 0 && (path === normalized || posix.matchesGlob(path, normalized)));
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
//#endregion
|
|
2500
|
+
//#region src/review-api/review-progress.ts
|
|
2501
|
+
const coverageModeSchema = z.enum(["structural", "textual"]).default("structural");
|
|
2502
|
+
const progressUpdateSchema = z.strictObject({
|
|
2503
|
+
mode: coverageModeSchema,
|
|
2504
|
+
version: z.number().int().nonnegative(),
|
|
2505
|
+
files: z.array(z.strictObject({
|
|
2506
|
+
path: z.string(),
|
|
2507
|
+
fingerprint: z.string().min(1),
|
|
2508
|
+
sources: z.array(fileLineRangeSchema)
|
|
2509
|
+
})).max(1e4),
|
|
2510
|
+
viewed: z.boolean()
|
|
2511
|
+
});
|
|
2512
|
+
/** Coverage survives new pins only when both complete file contents are unchanged. */
|
|
2513
|
+
async function reviewProgress(store, data, snapshot, signal = new AbortController().signal, mode = "structural", partial) {
|
|
2514
|
+
const marks = snapshot.shared ? /* @__PURE__ */ new Map() : store.viewedCoverage(snapshot.reviewId);
|
|
2515
|
+
const pins = snapshot.pins ? (await data.resolveSource(snapshot)).pins : void 0;
|
|
2516
|
+
const comparison = pins ? partial ?? await data.coverage(snapshot.reviewId, pins, mode) : {
|
|
2517
|
+
files: [],
|
|
2518
|
+
fileSources: /* @__PURE__ */ new Map(),
|
|
2519
|
+
alignments: /* @__PURE__ */ new Map()
|
|
2520
|
+
};
|
|
2521
|
+
signal.throwIfAborted();
|
|
2522
|
+
const documentKey = JSON.stringify(pins);
|
|
2523
|
+
const comparisons = /* @__PURE__ */ new Map();
|
|
2524
|
+
/** The comparison a reference's own pins name, shared across references. */
|
|
2525
|
+
const ownComparison = (own) => {
|
|
2526
|
+
const key = JSON.stringify(own);
|
|
2527
|
+
let loading = comparisons.get(key);
|
|
2528
|
+
if (loading) return loading;
|
|
2529
|
+
if (partial) {
|
|
2530
|
+
if (data.coverageSnapshot(snapshot.reviewId, own, mode).pending) return void 0;
|
|
2531
|
+
}
|
|
2532
|
+
loading = data.coverage(snapshot.reviewId, own, mode);
|
|
2533
|
+
comparisons.set(key, loading);
|
|
2534
|
+
return loading;
|
|
2535
|
+
};
|
|
2536
|
+
const withViewed = (file) => ({
|
|
2537
|
+
...file,
|
|
2538
|
+
viewed: marks.get(file.path)?.fingerprint === file.fingerprint ? marks.get(file.path).coverage : emptyCoverage()
|
|
2539
|
+
});
|
|
2540
|
+
const files = comparison.files.map(withViewed);
|
|
2541
|
+
const referenceFiles = {};
|
|
2542
|
+
const fileSources = new Map(comparison.fileSources);
|
|
2543
|
+
const alignments = new Map(comparison.alignments);
|
|
2544
|
+
const resolvedSelections = {};
|
|
2545
|
+
const unavailableSelections = {};
|
|
2546
|
+
const pendingSources = /* @__PURE__ */ new Set();
|
|
2547
|
+
const resolve = async (source) => {
|
|
2548
|
+
const key = selectionKey(source);
|
|
2549
|
+
if (resolvedSelections[key]) return resolvedSelections[key];
|
|
2550
|
+
const sourcePins = anchorPins(source, pins);
|
|
2551
|
+
const own = JSON.stringify(sourcePins) !== documentKey;
|
|
2552
|
+
let ownFiles = files;
|
|
2553
|
+
let ownAlignments = alignments;
|
|
2554
|
+
if (own) {
|
|
2555
|
+
const loading = ownComparison(sourcePins);
|
|
2556
|
+
if (!loading) {
|
|
2557
|
+
pendingSources.add(key);
|
|
2558
|
+
return [];
|
|
2559
|
+
}
|
|
2560
|
+
const loaded = await loading;
|
|
2561
|
+
ownFiles = loaded.files;
|
|
2562
|
+
ownAlignments = loaded.alignments;
|
|
2563
|
+
}
|
|
2564
|
+
const file = ownFiles.find((file) => source.file === file.path || source.file === file.previousPath);
|
|
2565
|
+
if (own) {
|
|
2566
|
+
const group = referenceFiles[comparisonKey(sourcePins)] ??= {};
|
|
2567
|
+
if (file) group[file.path] ??= withViewed(file);
|
|
2568
|
+
}
|
|
2569
|
+
let rows = file && ownAlignments.get(file.path);
|
|
2570
|
+
if (!file && partial && !own) {
|
|
2571
|
+
pendingSources.add(key);
|
|
2572
|
+
return [];
|
|
2573
|
+
}
|
|
2574
|
+
if (!file) {
|
|
2575
|
+
const [base, head] = await Promise.all([data.file(sourcePins, "base", source.file), data.file(sourcePins, "head", source.file)]);
|
|
2576
|
+
if (base.text !== head.text) throw new Error("Changed context file has no alignment.");
|
|
2577
|
+
rows = Array.from({ length: base.text.split("\n").length }, (_, line) => [line, line]);
|
|
2578
|
+
}
|
|
2579
|
+
if (!rows) throw new Error("File has no text alignment.");
|
|
2580
|
+
const result = resolveDiffSelection(source, rows, file ?? { path: source.file });
|
|
2581
|
+
resolvedSelections[key] = result;
|
|
2582
|
+
return result;
|
|
2583
|
+
};
|
|
2584
|
+
const resolveAvailable = async (source) => {
|
|
2585
|
+
try {
|
|
2586
|
+
return await resolve(source);
|
|
2587
|
+
} catch {
|
|
2588
|
+
unavailableSelections[selectionKey(source)] = "Source links need updating at these pins";
|
|
2589
|
+
return [];
|
|
2590
|
+
}
|
|
2591
|
+
};
|
|
2592
|
+
const lenses = await Promise.all((snapshot.lenses ?? []).map(async (authored) => {
|
|
2593
|
+
const lens = {
|
|
2594
|
+
id: authored.id,
|
|
2595
|
+
title: authored.title
|
|
2596
|
+
};
|
|
2597
|
+
try {
|
|
2598
|
+
await Promise.all(lensSelections([authored]).map(({ source }) => resolveAvailable(source)));
|
|
2599
|
+
const resolved = resolveFileLens(authored, files, fileSources, (source) => resolvedSelections[selectionKey(source)] ?? []);
|
|
2600
|
+
return {
|
|
2601
|
+
...lens,
|
|
2602
|
+
...resolved,
|
|
2603
|
+
pending: !!partial,
|
|
2604
|
+
unavailable: partial || resolved.fileCount ? void 0 : "No files match these targets"
|
|
2605
|
+
};
|
|
2606
|
+
} catch {
|
|
2607
|
+
return {
|
|
2608
|
+
...lens,
|
|
2609
|
+
sources: [],
|
|
2610
|
+
unavailable: "Source links need updating at these pins"
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
}));
|
|
2614
|
+
await Promise.all(selectionReferences(snapshot.document, { tolerant: true }).map(async ({ source }) => {
|
|
2615
|
+
try {
|
|
2616
|
+
await resolveAvailable(source);
|
|
2617
|
+
} catch {}
|
|
2618
|
+
}));
|
|
2619
|
+
const uncategorized = partial ? [] : uncategorizedSources(files, lenses.filter((lens) => !lens.unavailable).flatMap((lens) => lens.sources));
|
|
2620
|
+
lenses.push({
|
|
2621
|
+
id: UNCATEGORIZED_LENS_ID,
|
|
2622
|
+
title: "Uncategorized changes",
|
|
2623
|
+
sources: uncategorized,
|
|
2624
|
+
wholeFiles: false,
|
|
2625
|
+
fileCount: new Set(uncategorized.map((source) => files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file)).size,
|
|
2626
|
+
pending: !!partial,
|
|
2627
|
+
unavailable: partial || uncategorized.length ? void 0 : "All changed lines are covered by lenses"
|
|
2628
|
+
});
|
|
2629
|
+
const progress = {
|
|
2630
|
+
complete: !partial,
|
|
2631
|
+
files,
|
|
2632
|
+
referenceFiles,
|
|
2633
|
+
lenses,
|
|
2634
|
+
resolvedSelections,
|
|
2635
|
+
unavailableSelections
|
|
2636
|
+
};
|
|
2637
|
+
const untrackedFiles = pins ? await data.untrackedFiles(pins) : void 0;
|
|
2638
|
+
if (untrackedFiles) progress.untrackedFiles = untrackedFiles;
|
|
2639
|
+
return progress;
|
|
2640
|
+
}
|
|
2641
|
+
/** The most files an uncategorized report names; the rest are counted. */
|
|
2642
|
+
const REPORT_FILES = 50;
|
|
2643
|
+
/** What a lens author still has to place: the changed lines no lens selects,
|
|
2644
|
+
* grouped by file. */
|
|
2645
|
+
function uncategorizedReport(progress) {
|
|
2646
|
+
const sources = progress.lenses.find((lens) => lens.id === "automatic-uncategorized")?.sources ?? [];
|
|
2647
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2648
|
+
for (const source of sources) {
|
|
2649
|
+
const path = progress.files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file;
|
|
2650
|
+
const entry = byPath.get(path) ?? {
|
|
2651
|
+
path,
|
|
2652
|
+
lines: 0,
|
|
2653
|
+
ranges: []
|
|
2654
|
+
};
|
|
2655
|
+
entry.lines += source.toLine - source.fromLine + 1;
|
|
2656
|
+
entry.ranges.push({
|
|
2657
|
+
side: source.side,
|
|
2658
|
+
fromLine: source.fromLine,
|
|
2659
|
+
toLine: source.toLine
|
|
2660
|
+
});
|
|
2661
|
+
byPath.set(path, entry);
|
|
2662
|
+
}
|
|
2663
|
+
const files = [...byPath.values()];
|
|
2664
|
+
const report = {
|
|
2665
|
+
lines: files.reduce((total, file) => total + file.lines, 0),
|
|
2666
|
+
files: files.slice(0, REPORT_FILES)
|
|
2667
|
+
};
|
|
2668
|
+
if (files.length > REPORT_FILES) report.moreFiles = files.length - REPORT_FILES;
|
|
2669
|
+
return report;
|
|
2670
|
+
}
|
|
2671
|
+
/** Each lens as authored, with what it resolves to, for a lens author. */
|
|
2672
|
+
function lensReport(lenses, progress) {
|
|
2673
|
+
return {
|
|
2674
|
+
lenses: lenses.map((lens) => {
|
|
2675
|
+
const resolved = progress.lenses.find((item) => item.id === lens.id);
|
|
2676
|
+
return {
|
|
2677
|
+
...lens,
|
|
2678
|
+
fileCount: resolved?.fileCount ?? 0,
|
|
2679
|
+
...resolved?.unavailable && { unavailable: resolved.unavailable }
|
|
2680
|
+
};
|
|
2681
|
+
}),
|
|
2682
|
+
uncategorized: uncategorizedReport(progress)
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
//#endregion
|
|
2686
|
+
//#region src/review-api/traces.ts
|
|
2687
|
+
async function listPinnedTraces(cwd, pins, override) {
|
|
2688
|
+
const selection = selectTraceStorage();
|
|
2689
|
+
const sources = [];
|
|
2690
|
+
if (selection.s3?.credentials || isS3MockMode()) sources.push("s3");
|
|
2691
|
+
if (selection.hosted) sources.push("hosted");
|
|
2692
|
+
const result = {
|
|
2693
|
+
ok: true,
|
|
2694
|
+
configured: isTraceStorageConfigured(),
|
|
2695
|
+
storage: override ?? selection.mode,
|
|
2696
|
+
sources
|
|
2697
|
+
};
|
|
2698
|
+
try {
|
|
2699
|
+
if (selection.error) return {
|
|
2700
|
+
...result,
|
|
2701
|
+
storageError: selection.error,
|
|
2702
|
+
sessions: []
|
|
2703
|
+
};
|
|
2704
|
+
const storage = await resolveTraceStorage({
|
|
2705
|
+
cwd,
|
|
2706
|
+
override
|
|
2707
|
+
});
|
|
2708
|
+
if (!storage && (override ?? selection.mode) === "hosted") return {
|
|
2709
|
+
...result,
|
|
2710
|
+
storageError: "The hosted trace store has no login on this machine. Run `whiteboard login` and open the review again.",
|
|
2711
|
+
sessions: []
|
|
2712
|
+
};
|
|
2713
|
+
return {
|
|
2714
|
+
...result,
|
|
2715
|
+
sessions: await listReviewTraceSessions({
|
|
2716
|
+
rootPath: cwd,
|
|
2717
|
+
baseCommit: pins.base,
|
|
2718
|
+
headCommit: pins.head,
|
|
2719
|
+
storage
|
|
2720
|
+
})
|
|
2721
|
+
};
|
|
2722
|
+
} catch (error) {
|
|
2723
|
+
if (error instanceof TraceStorageDeniedError || error instanceof TraceConfigurationError) return {
|
|
2724
|
+
...result,
|
|
2725
|
+
storageError: error.message,
|
|
2726
|
+
sessions: []
|
|
2727
|
+
};
|
|
2728
|
+
throw error;
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
async function readStoredTrace(cwd, sessionId, trace, override) {
|
|
2732
|
+
try {
|
|
2733
|
+
const storage = await resolveTraceStorage({
|
|
2734
|
+
cwd,
|
|
2735
|
+
override
|
|
2736
|
+
});
|
|
2737
|
+
const loaded = await loadReviewAgentTrace({
|
|
2738
|
+
cwd,
|
|
2739
|
+
sessionId,
|
|
2740
|
+
trace,
|
|
2741
|
+
storage
|
|
2742
|
+
});
|
|
2743
|
+
if (!loaded) return {
|
|
2744
|
+
ok: false,
|
|
2745
|
+
status: 404,
|
|
2746
|
+
error: `Trace not found for session ${sessionId}.`
|
|
2747
|
+
};
|
|
2748
|
+
return {
|
|
2749
|
+
ok: true,
|
|
2750
|
+
parserVersion: loaded.parserVersion,
|
|
2751
|
+
session: loaded.descriptor,
|
|
2752
|
+
trace: loaded.traceName,
|
|
2753
|
+
cacheStatus: loaded.cacheStatus,
|
|
2754
|
+
subagents: loaded.subagents,
|
|
2755
|
+
title: loaded.trace.title,
|
|
2756
|
+
startedAt: loaded.trace.startedAt,
|
|
2757
|
+
endedAt: loaded.trace.endedAt,
|
|
2758
|
+
activeMs: loaded.trace.activeMs,
|
|
2759
|
+
userTurns: loaded.trace.userTurns,
|
|
2760
|
+
toolCalls: loaded.trace.toolCalls,
|
|
2761
|
+
events: loaded.trace.events
|
|
2762
|
+
};
|
|
2763
|
+
} catch (error) {
|
|
2764
|
+
if (error instanceof TraceStorageDeniedError || error instanceof TraceConfigurationError) return {
|
|
2765
|
+
ok: false,
|
|
2766
|
+
status: error instanceof TraceStorageDeniedError ? 403 : 400,
|
|
2767
|
+
error: error.message
|
|
2768
|
+
};
|
|
2769
|
+
throw error;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
//#endregion
|
|
2773
|
+
//#region src/review-api/http.ts
|
|
2774
|
+
const askStartSchema = z.strictObject({
|
|
2775
|
+
agent: z.enum(askAgentIds),
|
|
2776
|
+
question: askQuestionSchema,
|
|
2777
|
+
selection: AgentSelectionSchema,
|
|
2778
|
+
picks: askPicksSchema.optional(),
|
|
2779
|
+
bypass: z.boolean().optional()
|
|
2780
|
+
});
|
|
2781
|
+
const askPermitSchema = z.strictObject({ bypass: z.boolean() });
|
|
2782
|
+
const askOpenSchema = z.strictObject({ picks: askPicksSchema.optional() });
|
|
2783
|
+
const askChoiceSchema = z.strictObject({
|
|
2784
|
+
kind: z.enum(askChoiceKinds),
|
|
2785
|
+
value: z.string().min(1).max(200)
|
|
2786
|
+
});
|
|
2787
|
+
const askFilesSchema = z.strictObject({ paths: z.array(z.string().min(1).max(400)).max(100) });
|
|
2788
|
+
const askFollowUpSchema = z.strictObject({ question: askQuestionSchema });
|
|
2789
|
+
/** A question with its images: four of up to 5 MB each, base64. */
|
|
2790
|
+
const ASK_REQUEST_MAX_BYTES = 31457280;
|
|
2791
|
+
const askMentionsSchema = z.object({
|
|
2792
|
+
query: z.string().max(400).default(""),
|
|
2793
|
+
/** A conversation's checkout, which can be an earlier version's. */
|
|
2794
|
+
thread: z.string().optional()
|
|
2795
|
+
});
|
|
2796
|
+
const askOfferQuerySchema = z.object({
|
|
2797
|
+
/** The model picked for a question not yet asked. */
|
|
2798
|
+
model: askPicksSchema.shape.model });
|
|
2799
|
+
/** How many files a mention picker shows. */
|
|
2800
|
+
const MENTION_LIMIT = 20;
|
|
2801
|
+
const askDecisionSchema = z.strictObject({
|
|
2802
|
+
permissionId: z.string().min(1),
|
|
2803
|
+
optionId: z.string().min(1)
|
|
2804
|
+
});
|
|
2805
|
+
const SCRATCHPAD_DISABLED = "The scratchpad is off. Turn it on in Whiteboard Desktop Settings.";
|
|
2806
|
+
/** A gateway forwarding from another machine; it gets no local paths. */
|
|
2807
|
+
const remoteCaller = (context) => context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
|
|
2808
|
+
/** Both hosts mount this behind their token authentication. */
|
|
2809
|
+
function createReviewApi(store, data, open, shared, capabilities = () => ({
|
|
2810
|
+
desktopAvailable: Boolean(open),
|
|
2811
|
+
softwareMapEnabled: false
|
|
2812
|
+
}), scratchpadEnabled = () => false, traceEnabled = async () => false, status = () => ({}), hooks = {}, ask) {
|
|
2813
|
+
const app = new Hono();
|
|
2814
|
+
app.onError((error, context) => {
|
|
2815
|
+
if (error instanceof HttpJsonError) return context.json({ error: error.message }, error.statusCode);
|
|
2816
|
+
if (error instanceof ReviewInputError) return context.json({ error: error.message }, error.status);
|
|
2817
|
+
if (error instanceof z.ZodError) return context.json({
|
|
2818
|
+
error: z.prettifyError(error),
|
|
2819
|
+
issues: error.issues
|
|
2820
|
+
}, 400);
|
|
2821
|
+
console.error(`[Review API] ${context.req.method} ${context.req.path} failed:`, error);
|
|
2822
|
+
return context.json({ error: `Whiteboard operation failed (${failureKind(error)}). The server logged the cause; Whiteboard Desktop writes it to main.log in its logs folder.` }, 500);
|
|
2823
|
+
});
|
|
2824
|
+
if (data) app.use("*", async (context, next) => {
|
|
2825
|
+
if (context.req.method === "GET" && !context.req.query("version")) await store.refreshWorktrees();
|
|
2826
|
+
await next();
|
|
2827
|
+
});
|
|
2828
|
+
const sharedData = shared ? new SharedReviewData(shared) : void 0;
|
|
2829
|
+
const isShared = (id) => id.startsWith("shared-");
|
|
2830
|
+
const sharedCommandSchema = z.object({ operation: z.object({ reviewId: z.string().optional() }) });
|
|
2831
|
+
const ensureScratchpad = async (id) => {
|
|
2832
|
+
if (open && scratchpadEnabled() && (!id || id === SCRATCHPAD_ID)) await store.ensureScratchpad();
|
|
2833
|
+
};
|
|
2834
|
+
const refuseDisabledScratchpad = (id) => {
|
|
2835
|
+
if (id === SCRATCHPAD_ID && !scratchpadEnabled()) throw new ReviewInputError(SCRATCHPAD_DISABLED, 409);
|
|
2836
|
+
};
|
|
2837
|
+
const sharedGuard = async (context, next) => {
|
|
2838
|
+
refuseDisabledScratchpad(context.req.param("id"));
|
|
2839
|
+
await ensureScratchpad(context.req.param("id"));
|
|
2840
|
+
const id = context.req.param("id");
|
|
2841
|
+
if (!id || !isShared(id)) return next();
|
|
2842
|
+
const query = readQuerySchemas.get.parse({ version: context.req.query("version") });
|
|
2843
|
+
readReview(id, query.version);
|
|
2844
|
+
if (context.req.method !== "GET" && !/\/(open|source|copy-context|environment)$/.test(context.req.path) && !/\/workspaces\/[^/]+\/retry$/.test(context.req.path)) throw new ReviewInputError("Shared reviews are read-only.", 409);
|
|
2845
|
+
return next();
|
|
2846
|
+
};
|
|
2847
|
+
app.use("/:id", sharedGuard);
|
|
2848
|
+
app.use("/:id/*", sharedGuard);
|
|
2849
|
+
if (shared && data) {
|
|
2850
|
+
shared.connect(store, data);
|
|
2851
|
+
mountSharingHost(app, store, data, shared, hooks.sharing);
|
|
2852
|
+
}
|
|
2853
|
+
const readReview = (id, version) => {
|
|
2854
|
+
if (!id.startsWith("shared-")) return store.read(id, version);
|
|
2855
|
+
const snapshot = shared?.get(id).snapshot;
|
|
2856
|
+
if (!snapshot || version !== void 0 && version !== snapshot.version) throw new ReviewInputError("Shared review version is unavailable.", 404);
|
|
2857
|
+
return snapshot;
|
|
2858
|
+
};
|
|
2859
|
+
const catalog = (mode = "structural") => {
|
|
2860
|
+
const local = store.list(mode);
|
|
2861
|
+
return [...scratchpadEnabled() ? local : local.filter((summary) => summary.kind !== "scratchpad"), ...shared?.list(mode) ?? []];
|
|
2862
|
+
};
|
|
2863
|
+
app.get("/", async (context) => {
|
|
2864
|
+
await ensureScratchpad();
|
|
2865
|
+
return context.json(catalog(coverageModeSchema.parse(context.req.query("mode"))));
|
|
2866
|
+
});
|
|
2867
|
+
const instructionContext = async () => ({
|
|
2868
|
+
desktopAvailable: Boolean(open),
|
|
2869
|
+
scratchpadEnabled: scratchpadEnabled(),
|
|
2870
|
+
traceEnabled: await traceEnabled()
|
|
2871
|
+
});
|
|
2872
|
+
app.get("/authoring", async (context) => {
|
|
2873
|
+
const instructions = await instructionContext();
|
|
2874
|
+
return context.json(authoringTools(scratchpadAvailable(instructions), instructions.traceEnabled));
|
|
2875
|
+
});
|
|
2876
|
+
app.get("/instructions", async (context) => {
|
|
2877
|
+
const { topic } = instructionsQuerySchema.parse(context.req.query());
|
|
2878
|
+
return context.json(await renderInstructions(topic, await instructionContext()));
|
|
2879
|
+
});
|
|
2880
|
+
app.get("/:id/progress", async (context) => {
|
|
2881
|
+
if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
|
|
2882
|
+
const query = readQuerySchemas.get.pick({ version: true }).extend({
|
|
2883
|
+
mode: coverageModeSchema,
|
|
2884
|
+
wait: z.enum(["false", "true"]).default("true")
|
|
2885
|
+
}).parse(context.req.query());
|
|
2886
|
+
const snapshot = readReview(context.req.param("id"), query.version);
|
|
2887
|
+
const documentPins = query.wait === "false" && snapshot.pins ? (await data.resolveSource(snapshot)).pins : void 0;
|
|
2888
|
+
if (documentPins) {
|
|
2889
|
+
const state = data.coverageSnapshot(snapshot.reviewId, documentPins, query.mode);
|
|
2890
|
+
if (state.pending) return context.json(await reviewProgress(store, data, snapshot, context.req.raw.signal, query.mode, state.comparison), 202);
|
|
2891
|
+
}
|
|
2892
|
+
return context.json(await reviewProgress(store, data, snapshot, context.req.raw.signal, query.mode));
|
|
2893
|
+
});
|
|
2894
|
+
app.post("/:id/progress", async (context) => {
|
|
2895
|
+
if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
|
|
2896
|
+
const input = progressUpdateSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
2897
|
+
const id = context.req.param("id");
|
|
2898
|
+
const snapshot = store.read(id);
|
|
2899
|
+
const progress = await reviewProgress(store, data, snapshot, context.req.raw.signal, input.mode);
|
|
2900
|
+
const files = input.files.map((update) => {
|
|
2901
|
+
const file = progress.files.find((file) => file.path === update.path);
|
|
2902
|
+
if (!file || file.fingerprint !== update.fingerprint) throw new ReviewInputError("This file changed. Reload before marking it viewed.", 409);
|
|
2903
|
+
return {
|
|
2904
|
+
path: file.path,
|
|
2905
|
+
fingerprint: file.fingerprint,
|
|
2906
|
+
scope: scopedCoverage(file, update.sources)
|
|
2907
|
+
};
|
|
2908
|
+
});
|
|
2909
|
+
if (store.read(id).version !== snapshot.version) throw new ReviewInputError("Review changed during this update. Try again.", 409);
|
|
2910
|
+
store.updateViewedCoverage(id, files, input.viewed);
|
|
2911
|
+
return context.json(await reviewProgress(store, data, store.read(id, input.version), context.req.raw.signal, input.mode));
|
|
2912
|
+
});
|
|
2913
|
+
app.get("/:id/lenses", async (context) => {
|
|
2914
|
+
if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
|
|
2915
|
+
const snapshot = readReview(context.req.param("id"));
|
|
2916
|
+
return context.json({
|
|
2917
|
+
version: snapshot.version,
|
|
2918
|
+
...lensReport(snapshot.lenses ?? [], await reviewProgress(store, data, snapshot, context.req.raw.signal))
|
|
2919
|
+
});
|
|
2920
|
+
});
|
|
2921
|
+
app.get("/status", async (context) => context.json({
|
|
2922
|
+
...status(),
|
|
2923
|
+
desktopAvailable: (await capabilities()).desktopAvailable
|
|
2924
|
+
}));
|
|
2925
|
+
app.get("/capabilities", async (context) => context.json({
|
|
2926
|
+
...await capabilities(),
|
|
2927
|
+
scratchpadEnabled: scratchpadEnabled()
|
|
2928
|
+
}));
|
|
2929
|
+
app.get("/:id/activity", (context) => {
|
|
2930
|
+
const id = context.req.param("id");
|
|
2931
|
+
readReview(id);
|
|
2932
|
+
return context.json(isShared(id) ? {
|
|
2933
|
+
workingCount: 0,
|
|
2934
|
+
expiresAt: null
|
|
2935
|
+
} : store.activity.read(id));
|
|
2936
|
+
});
|
|
2937
|
+
for (const action of [
|
|
2938
|
+
"begin",
|
|
2939
|
+
"update",
|
|
2940
|
+
"end"
|
|
2941
|
+
]) app.post(`/:id/activity/${action}`, async (context) => {
|
|
2942
|
+
const input = await readBoundedRequestJson(context.req.raw);
|
|
2943
|
+
const id = context.req.param("id");
|
|
2944
|
+
store.assertExists(id);
|
|
2945
|
+
return context.json(store.activity.update(id, isJsonObject(input) ? {
|
|
2946
|
+
...input,
|
|
2947
|
+
action
|
|
2948
|
+
} : input));
|
|
2949
|
+
});
|
|
2950
|
+
app.get("/watch", async (context) => {
|
|
2951
|
+
const query = context.req.query("subscriptions");
|
|
2952
|
+
if (query !== void 0) {
|
|
2953
|
+
let input;
|
|
2954
|
+
try {
|
|
2955
|
+
input = JSON.parse(query);
|
|
2956
|
+
} catch {
|
|
2957
|
+
throw new ReviewInputError("Invalid subscriptions.");
|
|
2958
|
+
}
|
|
2959
|
+
const subscriptions = z.array(z.strictObject({
|
|
2960
|
+
reviewId: z.string().min(1).nullable(),
|
|
2961
|
+
mode: coverageModeSchema
|
|
2962
|
+
})).parse(input);
|
|
2963
|
+
const dirty = new Set(subscriptions.keys());
|
|
2964
|
+
const mark = (id) => {
|
|
2965
|
+
let marked = false;
|
|
2966
|
+
subscriptions.forEach((item, index) => {
|
|
2967
|
+
if (item.reviewId === id) {
|
|
2968
|
+
dirty.add(index);
|
|
2969
|
+
marked = true;
|
|
2970
|
+
}
|
|
2971
|
+
});
|
|
2972
|
+
return marked;
|
|
2973
|
+
};
|
|
2974
|
+
return watch$1(() => subscriptions.map(({ reviewId, mode }, index) => {
|
|
2975
|
+
if (!dirty.delete(index)) return null;
|
|
2976
|
+
try {
|
|
2977
|
+
return { value: reviewId === null ? catalog(mode) : {
|
|
2978
|
+
...readReview(reviewId),
|
|
2979
|
+
activity: store.activity.read(reviewId),
|
|
2980
|
+
coverageRevision: data?.coverageRevision ?? 0
|
|
2981
|
+
} };
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
return { error: error instanceof ReviewInputError ? error.message : "Could not read review." };
|
|
2984
|
+
}
|
|
2985
|
+
}), (notify) => {
|
|
2986
|
+
const stops = [
|
|
2987
|
+
store.watchWorktrees(),
|
|
2988
|
+
data?.subscribeCoverage(() => {
|
|
2989
|
+
subscriptions.forEach((item, index) => {
|
|
2990
|
+
if (item.reviewId !== null) dirty.add(index);
|
|
2991
|
+
});
|
|
2992
|
+
if (dirty.size > 0) notify();
|
|
2993
|
+
}) ?? (() => {}),
|
|
2994
|
+
store.subscribe((result) => {
|
|
2995
|
+
if (mark(result.reviewId)) notify();
|
|
2996
|
+
}),
|
|
2997
|
+
store.activity.subscribe((id) => {
|
|
2998
|
+
if (mark(id)) notify();
|
|
2999
|
+
}),
|
|
3000
|
+
store.activity.subscribeWorking(() => {
|
|
3001
|
+
if (mark(null)) notify();
|
|
3002
|
+
}),
|
|
3003
|
+
shared?.subscribe(() => {
|
|
3004
|
+
if (mark(null)) notify();
|
|
3005
|
+
}) ?? (() => {}),
|
|
3006
|
+
store.subscribeCatalog(() => {
|
|
3007
|
+
if (mark(null)) notify();
|
|
3008
|
+
})
|
|
3009
|
+
];
|
|
3010
|
+
return () => stops.forEach((stop) => stop());
|
|
3011
|
+
}, () => {});
|
|
3012
|
+
}
|
|
3013
|
+
await ensureScratchpad();
|
|
3014
|
+
return watch$1(() => catalog(coverageModeSchema.parse(context.req.query("mode"))), (notify) => {
|
|
3015
|
+
const local = store.subscribeCatalog(notify);
|
|
3016
|
+
const activity = store.activity.subscribeWorking(notify);
|
|
3017
|
+
const imported = shared?.subscribe(notify);
|
|
3018
|
+
return () => {
|
|
3019
|
+
local();
|
|
3020
|
+
activity();
|
|
3021
|
+
imported?.();
|
|
3022
|
+
};
|
|
3023
|
+
});
|
|
3024
|
+
});
|
|
3025
|
+
/** Show a review in Desktop and start preparing its pinned checkouts. */
|
|
3026
|
+
const openReview = async (review) => {
|
|
3027
|
+
if (!open) throw new ReviewInputError("The desktop is not connected.", 409);
|
|
3028
|
+
const settings = await open({
|
|
3029
|
+
reviewId: review.reviewId,
|
|
3030
|
+
title: review.title
|
|
3031
|
+
});
|
|
3032
|
+
let environmentIssues;
|
|
3033
|
+
try {
|
|
3034
|
+
if (review.target?.kind === "commits" && review.pins) data?.workspaces.open(review.reviewId, review.pins).catch(() => {});
|
|
3035
|
+
environmentIssues = data?.currentEnvironmentIssues(review);
|
|
3036
|
+
} catch (error) {
|
|
3037
|
+
environmentIssues = [{ message: `Could not check language checkouts: ${errorMessage(error)}. Recheck with review_environment.` }];
|
|
3038
|
+
}
|
|
3039
|
+
return {
|
|
3040
|
+
...settings,
|
|
3041
|
+
environmentIssues: environmentIssues?.length ? environmentIssues : void 0
|
|
3042
|
+
};
|
|
3043
|
+
};
|
|
3044
|
+
/**
|
|
3045
|
+
* A created or returned review is shown where Desktop can, unless the author asked
|
|
3046
|
+
* not to. The review is already saved, so a failed open is reported, not thrown.
|
|
3047
|
+
*/
|
|
3048
|
+
const openCreated = async (reviewId) => {
|
|
3049
|
+
try {
|
|
3050
|
+
if (!open || !(await capabilities()).desktopAvailable) return { opened: false };
|
|
3051
|
+
return {
|
|
3052
|
+
opened: true,
|
|
3053
|
+
...await openReview(store.read(reviewId))
|
|
3054
|
+
};
|
|
3055
|
+
} catch (error) {
|
|
3056
|
+
return {
|
|
3057
|
+
opened: false,
|
|
3058
|
+
openError: `${errorMessage(error)} Retry with review_open.`
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
};
|
|
3062
|
+
app.post("/:id/open", async (context) => {
|
|
3063
|
+
const id = context.req.param("id");
|
|
3064
|
+
if (isShared(id)) await shared?.assertReady(id);
|
|
3065
|
+
return context.json({
|
|
3066
|
+
ok: true,
|
|
3067
|
+
...await openReview(readReview(id))
|
|
3068
|
+
});
|
|
3069
|
+
});
|
|
3070
|
+
app.get("/:id/watch", (context) => {
|
|
3071
|
+
const id = context.req.param("id");
|
|
3072
|
+
let document;
|
|
3073
|
+
return watch$1(() => ({
|
|
3074
|
+
...document ??= readReview(id),
|
|
3075
|
+
activity: isShared(id) ? {
|
|
3076
|
+
workingCount: 0,
|
|
3077
|
+
expiresAt: null
|
|
3078
|
+
} : store.activity.read(id)
|
|
3079
|
+
}), (notify) => {
|
|
3080
|
+
const stopRefresh = store.watchWorktrees();
|
|
3081
|
+
const stopDocument = store.subscribe((result) => {
|
|
3082
|
+
if (result.reviewId === id) {
|
|
3083
|
+
document = void 0;
|
|
3084
|
+
notify();
|
|
3085
|
+
}
|
|
3086
|
+
});
|
|
3087
|
+
const stopActivity = store.activity.subscribe((changed) => {
|
|
3088
|
+
if (changed === id) notify();
|
|
3089
|
+
});
|
|
3090
|
+
return () => {
|
|
3091
|
+
stopRefresh();
|
|
3092
|
+
stopDocument();
|
|
3093
|
+
stopActivity();
|
|
3094
|
+
};
|
|
3095
|
+
});
|
|
3096
|
+
});
|
|
3097
|
+
if (data) {
|
|
3098
|
+
const traceQuery = readQuerySchemas.maps.extend({
|
|
3099
|
+
storage: z.enum(["s3", "hosted"]).optional(),
|
|
3100
|
+
trace: z.string().min(1).optional()
|
|
3101
|
+
});
|
|
3102
|
+
const tracePins = (id, version) => {
|
|
3103
|
+
const { pins } = readReview(id, version);
|
|
3104
|
+
if (!pins) throw new ReviewInputError("This document has no source pins of its own.", 409);
|
|
3105
|
+
return pins;
|
|
3106
|
+
};
|
|
3107
|
+
app.get("/:id/agent-traces", async (context) => {
|
|
3108
|
+
const query = traceQuery.parse(context.req.query());
|
|
3109
|
+
const pins = tracePins(context.req.param("id"), query.version);
|
|
3110
|
+
return context.json(await listPinnedTraces(store.repositoryPath(pins.repositoryId), pins, query.storage));
|
|
3111
|
+
});
|
|
3112
|
+
app.get("/:id/agent-traces/:sessionId", async (context) => {
|
|
3113
|
+
const query = traceQuery.parse(context.req.query());
|
|
3114
|
+
const pins = tracePins(context.req.param("id"), query.version);
|
|
3115
|
+
const result = await readStoredTrace(store.repositoryPath(pins.repositoryId), context.req.param("sessionId"), query.trace, query.storage);
|
|
3116
|
+
if (!result.ok) return context.json({
|
|
3117
|
+
ok: false,
|
|
3118
|
+
error: result.error
|
|
3119
|
+
}, result.status);
|
|
3120
|
+
return context.json(result);
|
|
3121
|
+
});
|
|
3122
|
+
app.post("/:id/navigator", async (context) => {
|
|
3123
|
+
if (remoteCaller(context)) throw new ReviewInputError("Source windows are not available for a review on another machine.", 409);
|
|
3124
|
+
const input = readQuerySchemas.file.extend({
|
|
3125
|
+
side: z.enum(["base", "head"]).default("head"),
|
|
3126
|
+
file: z.string().min(1).optional(),
|
|
3127
|
+
empty: z.literal("true").optional()
|
|
3128
|
+
}).parse(context.req.query());
|
|
3129
|
+
return context.json(await data.navigatorWorkspace(readReview(context.req.param("id"), input.version), {
|
|
3130
|
+
...input,
|
|
3131
|
+
empty: input.empty === "true",
|
|
3132
|
+
anchor: queryAnchor(input)
|
|
3133
|
+
}));
|
|
3134
|
+
});
|
|
3135
|
+
app.get("/:id/tree", async (context) => {
|
|
3136
|
+
const input = readQuerySchemas.tree.parse(context.req.query());
|
|
3137
|
+
const { pins } = await data.resolveSource(readReview(context.req.param("id"), input.version), input.commit, queryAnchor(input));
|
|
3138
|
+
return context.json(await data.tree(pins, input.side, input.path));
|
|
3139
|
+
});
|
|
3140
|
+
app.get("/:id/maps/:resourceId", async (context) => {
|
|
3141
|
+
const query = readQuerySchemas.maps.parse(context.req.query());
|
|
3142
|
+
const id = context.req.param("id");
|
|
3143
|
+
if (id && isShared(id)) return context.json(sharedData.map(id, z.string().parse(context.req.param("resourceId"))));
|
|
3144
|
+
return context.json(await data.map(await data.sourcePins(readReview(id, query.version)), z.string().parse(context.req.param("resourceId"))));
|
|
3145
|
+
});
|
|
3146
|
+
app.post("/repositories", async (context) => {
|
|
3147
|
+
const input = z.strictObject({ path: z.string().min(1) }).parse(await readBoundedRequestJson(context.req.raw));
|
|
3148
|
+
return context.json(await data.register(input.path));
|
|
3149
|
+
});
|
|
3150
|
+
app.post("/resources", async (context) => context.json(await data.upload(await readBoundedRequestJson(context.req.raw, 8388608))));
|
|
3151
|
+
app.get("/:id/resources/:resourceId", async (context) => {
|
|
3152
|
+
const id = context.req.param("id");
|
|
3153
|
+
const snapshot = readReview(id);
|
|
3154
|
+
const resource = id && isShared(id) ? {
|
|
3155
|
+
...await sharedData.resource(id, z.string().parse(context.req.param("resourceId"))),
|
|
3156
|
+
repositoryId: snapshot.pins?.repositoryId ?? ""
|
|
3157
|
+
} : store.resource(z.string().parse(context.req.param("resourceId")));
|
|
3158
|
+
if (snapshot.pins && resource.repositoryId !== snapshot.pins.repositoryId) throw new ReviewInputError("Resource is outside this repository.", 404);
|
|
3159
|
+
return new Response(Buffer.from(resource.data), { headers: {
|
|
3160
|
+
"content-type": resource.mimeType,
|
|
3161
|
+
"x-content-type-options": "nosniff"
|
|
3162
|
+
} });
|
|
3163
|
+
});
|
|
3164
|
+
app.get("/:id/language-context", async (context) => {
|
|
3165
|
+
const input = readQuerySchemas.maps.extend({
|
|
3166
|
+
side: z.enum(["base", "head"]).default("head"),
|
|
3167
|
+
commit: z.string().optional(),
|
|
3168
|
+
repositoryId: z.string().optional(),
|
|
3169
|
+
head: z.string().optional(),
|
|
3170
|
+
base: z.string().optional()
|
|
3171
|
+
}).parse(context.req.query());
|
|
3172
|
+
const snapshot = readReview(context.req.param("id"), input.version);
|
|
3173
|
+
const environment = await data.languageEnvironment(snapshot, input.side, input.commit, false, queryAnchor(input));
|
|
3174
|
+
return context.json(remoteCaller(context) ? {
|
|
3175
|
+
identity: createHash("sha256").update(environment.identity).digest("hex"),
|
|
3176
|
+
...environment.issue && { issue: "The checkout for language features is not available on the remote machine." }
|
|
3177
|
+
} : environment);
|
|
3178
|
+
});
|
|
3179
|
+
app.post("/:id/environment", async (context) => {
|
|
3180
|
+
const input = z.strictObject({ retry: z.boolean().optional() }).parse(await readBoundedRequestJson(context.req.raw));
|
|
3181
|
+
return context.json({ issues: await data.environmentIssues(readReview(context.req.param("id")), input.retry) });
|
|
3182
|
+
});
|
|
3183
|
+
app.post("/workspace-cleanup", async (context) => {
|
|
3184
|
+
const input = z.strictObject({ workspaceId: z.string().min(1).optional() }).parse(await readBoundedRequestJson(context.req.raw));
|
|
3185
|
+
if (input.workspaceId) await data.workspaces.retryCleanup(input.workspaceId);
|
|
3186
|
+
return context.json({ failures: data.workspaces.failures() });
|
|
3187
|
+
});
|
|
3188
|
+
app.get("/:id/workspaces", (context) => {
|
|
3189
|
+
readReview(context.req.param("id"));
|
|
3190
|
+
return context.json(data.workspaces.list(context.req.param("id")));
|
|
3191
|
+
});
|
|
3192
|
+
app.post("/:id/workspaces/:workspaceId/retry", async (context) => {
|
|
3193
|
+
return context.json(await data.workspaces.retry(context.req.param("id"), context.req.param("workspaceId")));
|
|
3194
|
+
});
|
|
3195
|
+
app.get("/:id/file", async (context) => {
|
|
3196
|
+
const input = readQuerySchemas.file.extend({ binary: z.literal("describe").optional() }).parse(context.req.query());
|
|
3197
|
+
const id = context.req.param("id");
|
|
3198
|
+
const anchor = queryAnchor(input);
|
|
3199
|
+
const { snapshot, pins } = await data.resolveSource(readReview(id, input.version), input.commit, anchor);
|
|
3200
|
+
const file = await data.file(pins, input.side, input.file, input.binary === "describe");
|
|
3201
|
+
if (file.text.includes("\0")) return context.json({
|
|
3202
|
+
binary: true,
|
|
3203
|
+
file: file.file,
|
|
3204
|
+
side: file.side,
|
|
3205
|
+
commit: file.commit
|
|
3206
|
+
});
|
|
3207
|
+
const local = !remoteCaller(context) && !input.commit && !anchor && input.side === "head" && snapshot.target?.kind === "worktree" ? await data.liveFile(pins.repositoryId, input.file, file.text) : void 0;
|
|
3208
|
+
return context.json({
|
|
3209
|
+
...file,
|
|
3210
|
+
...local
|
|
3211
|
+
});
|
|
3212
|
+
});
|
|
3213
|
+
app.get("/:id/structural-diff", async (context) => {
|
|
3214
|
+
const input = readQuerySchemas.structuralDiff.parse(context.req.query());
|
|
3215
|
+
const id = context.req.param("id");
|
|
3216
|
+
const { pins } = await data.resolveSource(readReview(id, input.version), input.commit, queryAnchor(input));
|
|
3217
|
+
const abort = new AbortController();
|
|
3218
|
+
const encoder = new TextEncoder();
|
|
3219
|
+
const stream = new ReadableStream({
|
|
3220
|
+
async start(controller) {
|
|
3221
|
+
const send = (event) => {
|
|
3222
|
+
if (!abort.signal.aborted) controller.enqueue(encoder.encode(JSON.stringify(event) + "\n"));
|
|
3223
|
+
};
|
|
3224
|
+
try {
|
|
3225
|
+
for await (const event of data.structuralChanges({
|
|
3226
|
+
reviewId: id,
|
|
3227
|
+
pins,
|
|
3228
|
+
signal: AbortSignal.any([context.req.raw.signal, abort.signal]),
|
|
3229
|
+
file: input.file
|
|
3230
|
+
})) send(event);
|
|
3231
|
+
} catch (error) {
|
|
3232
|
+
send({
|
|
3233
|
+
type: "error",
|
|
3234
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3235
|
+
});
|
|
3236
|
+
} finally {
|
|
3237
|
+
if (!abort.signal.aborted) controller.close();
|
|
3238
|
+
}
|
|
3239
|
+
},
|
|
3240
|
+
cancel() {
|
|
3241
|
+
abort.abort();
|
|
3242
|
+
}
|
|
3243
|
+
});
|
|
3244
|
+
return new Response(stream, { headers: {
|
|
3245
|
+
"content-type": "application/x-ndjson",
|
|
3246
|
+
"cache-control": "no-store"
|
|
3247
|
+
} });
|
|
3248
|
+
});
|
|
3249
|
+
app.get("/:id/diff", async (context) => {
|
|
3250
|
+
const input = readQuerySchemas.diff.parse(context.req.query());
|
|
3251
|
+
const { pins } = await data.resolveSource(readReview(context.req.param("id"), input.version), input.commit, queryAnchor(input));
|
|
3252
|
+
return context.json(await data.changes(pins));
|
|
3253
|
+
});
|
|
3254
|
+
app.get("/:id/commits", async (context) => {
|
|
3255
|
+
const input = readQuerySchemas.commits.parse(context.req.query());
|
|
3256
|
+
return context.json(await data.commits((await data.resolveSource(readReview(context.req.param("id"), input.version))).pins));
|
|
3257
|
+
});
|
|
3258
|
+
}
|
|
3259
|
+
app.post("/:id/copy-context", async (context) => {
|
|
3260
|
+
const query = readQuerySchemas.get.pick({ version: true }).extend({ mode: coverageModeSchema }).parse(context.req.query());
|
|
3261
|
+
const selection = AgentSelectionSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
3262
|
+
return context.json({ text: await selectionContext(context.req.param("id"), selection, query.version) });
|
|
3263
|
+
});
|
|
3264
|
+
/** The Markdown an agent gets for a selection, copied or asked about. */
|
|
3265
|
+
async function selectionContext(reviewId, selection, version) {
|
|
3266
|
+
if (selection.apiSource && selection.apiSource.reviewId !== reviewId) throw new ReviewInputError("Selection belongs to another review.");
|
|
3267
|
+
const snapshot = readReview(reviewId, selection.apiSource?.version ?? version);
|
|
3268
|
+
const target = selection.target;
|
|
3269
|
+
let excerpt = "";
|
|
3270
|
+
if (target.kind === "code" && !selection.selectedDiff) {
|
|
3271
|
+
if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
|
|
3272
|
+
const source = await data.quote((await data.resolveSource(snapshot, selection.apiSource?.commit, selection.apiSource?.pins)).pins, {
|
|
3273
|
+
side: target.side,
|
|
3274
|
+
file: target.path,
|
|
3275
|
+
fromLine: target.startLine,
|
|
3276
|
+
toLine: target.endLine
|
|
3277
|
+
});
|
|
3278
|
+
excerpt = `## ${target.side}: ${target.path}:${target.startLine}-${target.endLine} (${source.commit})\n` + source.text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
3279
|
+
}
|
|
3280
|
+
const diff = selection.selectedDiff;
|
|
3281
|
+
const text = selectionMarkdown(selection, excerpt, diff ? {
|
|
3282
|
+
base: `a/${diff.oldPath}`,
|
|
3283
|
+
head: `b/${diff.newPath}`
|
|
3284
|
+
} : void 0);
|
|
3285
|
+
return [
|
|
3286
|
+
`Selected ${target.kind === "text" ? "text" : "code"} from Whiteboard: ${snapshot.title}`,
|
|
3287
|
+
`Session ID: ${snapshot.reviewId}`,
|
|
3288
|
+
`Version: ${snapshot.version}`,
|
|
3289
|
+
...selection.apiSource?.commit ? [`Selected commit: ${selection.apiSource.commit}`] : [],
|
|
3290
|
+
...selection.apiSource?.pins ? [
|
|
3291
|
+
`Selected repository ID: ${selection.apiSource.pins.repositoryId}`,
|
|
3292
|
+
...selection.apiSource.pins.base ? [`Selected base: ${selection.apiSource.pins.base}`] : [],
|
|
3293
|
+
`Selected head: ${selection.apiSource.pins.head}`
|
|
3294
|
+
] : [],
|
|
3295
|
+
...snapshot.pins ? [
|
|
3296
|
+
`Repository ID: ${snapshot.pins.repositoryId}`,
|
|
3297
|
+
`Session base: ${snapshot.pins.base}`,
|
|
3298
|
+
`Session head: ${snapshot.pins.head}`
|
|
3299
|
+
] : [],
|
|
3300
|
+
`Read this version with session_get({"sessionId":"${snapshot.reviewId}","version":${snapshot.version},"full":true}).`,
|
|
3301
|
+
"",
|
|
3302
|
+
text,
|
|
3303
|
+
"",
|
|
3304
|
+
""
|
|
3305
|
+
].join("\n");
|
|
3306
|
+
}
|
|
3307
|
+
if (ask && data) {
|
|
3308
|
+
const readThread = (reviewId, threadId) => {
|
|
3309
|
+
const thread = ask.threads.get(threadId);
|
|
3310
|
+
if (!thread || thread.reviewId !== reviewId) throw new ReviewInputError("This conversation has ended.", 404);
|
|
3311
|
+
return thread;
|
|
3312
|
+
};
|
|
3313
|
+
/** What an agent reads before a session's first question: the review,
|
|
3314
|
+
* the checkout, and the selection. */
|
|
3315
|
+
const askContext = async (agent, snapshot, checkout, selection, version) => {
|
|
3316
|
+
const reach = await ask.threads.reach(agent);
|
|
3317
|
+
const { reviewId } = snapshot;
|
|
3318
|
+
return [
|
|
3319
|
+
`A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about a selection.`,
|
|
3320
|
+
checkout.live ? "Your working directory is the repository the review describes." : `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
|
|
3321
|
+
"Answer the question.",
|
|
3322
|
+
"Name files by their path from the checkout root, with a line where it helps, as in `src/app.ts:42`; the reviewer can open them from your answer.",
|
|
3323
|
+
...reach?.kind === "mcp" ? [`The whiteboard MCP tools read and change this review: its sessionId is "${reviewId}". Read it with session_get. If the reviewer asks you to change the review, edit it with session_edit; do not write files to do it.`] : reach?.kind === "cli" ? [`Whiteboard's CLI reads and changes this review from your shell: its sessionId is "${reviewId}". Read it with \`${reach.command} api session_get '{"sessionId":"${reviewId}"}'\`. If the reviewer asks you to change the review, edit it with \`${reach.command} api session_edit '<json>'\`; do not write files to do it. \`${reach.command} api tools\` lists each tool's input.`] : [],
|
|
3324
|
+
"",
|
|
3325
|
+
await selectionContext(reviewId, selection, version)
|
|
3326
|
+
].join("\n");
|
|
3327
|
+
};
|
|
3328
|
+
app.get("/:id/ask/agents", async (context) => context.json({ agents: await ask.agents() }));
|
|
3329
|
+
app.get("/:id/ask/agents/:agent/offer", async (context) => {
|
|
3330
|
+
const agent = z.enum(askAgentIds).parse(context.req.param("agent"));
|
|
3331
|
+
const { model } = askOfferQuerySchema.parse(context.req.query());
|
|
3332
|
+
const stored = store.askHistory.offer(agent);
|
|
3333
|
+
const last = stored && Object.keys(stored.choices).length ? stored : void 0;
|
|
3334
|
+
const models = last?.choices.model;
|
|
3335
|
+
const another = model !== void 0 && model !== models?.current && (!models || models.options.some((option) => option.value === model));
|
|
3336
|
+
const known = another ? store.askHistory.offer(agent, model) : last;
|
|
3337
|
+
if (known) return context.json({ offer: known });
|
|
3338
|
+
const checkout = await data.agentCheckout(readReview(context.req.param("id")));
|
|
3339
|
+
const offer = await ask.threads.offered(agent, checkout.rootPath, another ? model : void 0);
|
|
3340
|
+
if (last) store.askHistory.saveModelOffer(agent, offer);
|
|
3341
|
+
else store.askHistory.saveOffer(agent, offer);
|
|
3342
|
+
return context.json({ offer });
|
|
3343
|
+
});
|
|
3344
|
+
app.get("/:id/ask/mentions", async (context) => {
|
|
3345
|
+
const reviewId = context.req.param("id");
|
|
3346
|
+
const { query, thread } = askMentionsSchema.parse(context.req.query());
|
|
3347
|
+
const cwd = thread ? readThread(reviewId, thread).read().cwd : (await data.agentCheckout(readReview(reviewId))).rootPath;
|
|
3348
|
+
const files = fuzzyRank(query, await mentionableFiles(cwd), (file) => [file, file.slice(file.lastIndexOf("/") + 1)]);
|
|
3349
|
+
return context.json({ paths: files.slice(0, MENTION_LIMIT) });
|
|
3350
|
+
});
|
|
3351
|
+
app.post("/:id/ask", async (context) => {
|
|
3352
|
+
const reviewId = context.req.param("id");
|
|
3353
|
+
const { version } = readQuerySchemas.get.pick({ version: true }).parse(context.req.query());
|
|
3354
|
+
const input = askStartSchema.parse(await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES));
|
|
3355
|
+
const snapshot = readReview(reviewId, version);
|
|
3356
|
+
const checkout = await data.agentCheckout(snapshot);
|
|
3357
|
+
const target = input.selection.target;
|
|
3358
|
+
const id = crypto.randomUUID();
|
|
3359
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3360
|
+
const thread = ask.threads.open({
|
|
3361
|
+
id,
|
|
3362
|
+
reviewId,
|
|
3363
|
+
agent: input.agent,
|
|
3364
|
+
onSession: (sessionId) => store.askHistory.get(id) ? store.askHistory.updateSession(id, sessionId) : store.askHistory.save({
|
|
3365
|
+
id,
|
|
3366
|
+
reviewId,
|
|
3367
|
+
agent: input.agent,
|
|
3368
|
+
sessionId,
|
|
3369
|
+
version: snapshot.version,
|
|
3370
|
+
head: checkout.head,
|
|
3371
|
+
cwd: checkout.rootPath,
|
|
3372
|
+
selection: input.selection,
|
|
3373
|
+
title: input.question.text.slice(0, 200),
|
|
3374
|
+
createdAt,
|
|
3375
|
+
updatedAt: createdAt,
|
|
3376
|
+
bypass: input.bypass
|
|
3377
|
+
}),
|
|
3378
|
+
onTurn: () => store.askHistory.touch(id),
|
|
3379
|
+
onSave: (entries) => store.askHistory.saveEntries(id, entries),
|
|
3380
|
+
picks: input.picks,
|
|
3381
|
+
bypass: input.bypass,
|
|
3382
|
+
onBypass: (bypass) => store.askHistory.setBypass(id, bypass),
|
|
3383
|
+
onOffer: (offer) => store.askHistory.saveOffer(input.agent, offer),
|
|
3384
|
+
onTitle: (title) => store.askHistory.rename(id, title),
|
|
3385
|
+
cwd: checkout.rootPath,
|
|
3386
|
+
head: checkout.head,
|
|
3387
|
+
selection: {
|
|
3388
|
+
title: input.selection.title,
|
|
3389
|
+
quote: target.kind === "text" ? target.quote : void 0
|
|
3390
|
+
},
|
|
3391
|
+
context: await askContext(input.agent, snapshot, checkout, input.selection, version),
|
|
3392
|
+
question: input.question
|
|
3393
|
+
});
|
|
3394
|
+
return context.json({ threadId: thread.id });
|
|
3395
|
+
});
|
|
3396
|
+
app.get("/:id/ask/threads", (context) => {
|
|
3397
|
+
const reviewId = context.req.param("id");
|
|
3398
|
+
readReview(reviewId);
|
|
3399
|
+
return context.json({ threads: store.askHistory.list(reviewId) });
|
|
3400
|
+
});
|
|
3401
|
+
app.post("/:id/ask/:threadId/open", async (context) => {
|
|
3402
|
+
const reviewId = context.req.param("id");
|
|
3403
|
+
const threadId = context.req.param("threadId");
|
|
3404
|
+
if (ask.threads.get(threadId)?.reviewId === reviewId) return context.json({ threadId });
|
|
3405
|
+
const record = store.askHistory.get(threadId);
|
|
3406
|
+
const { picks } = askOpenSchema.parse(await readBoundedRequestJson(context.req.raw, void 0, {}));
|
|
3407
|
+
if (record?.reviewId !== reviewId) throw new ReviewInputError("This conversation was not found.", 404);
|
|
3408
|
+
const snapshot = readReview(reviewId, record.version);
|
|
3409
|
+
const checkout = await data.agentCheckout(snapshot);
|
|
3410
|
+
const target = record.selection.target;
|
|
3411
|
+
ask.threads.open({
|
|
3412
|
+
id: record.id,
|
|
3413
|
+
reviewId,
|
|
3414
|
+
agent: record.agent,
|
|
3415
|
+
cwd: checkout.rootPath,
|
|
3416
|
+
head: checkout.head,
|
|
3417
|
+
selection: {
|
|
3418
|
+
title: record.selection.title,
|
|
3419
|
+
quote: target.kind === "text" ? target.quote : void 0
|
|
3420
|
+
},
|
|
3421
|
+
resume: {
|
|
3422
|
+
sessionId: record.sessionId,
|
|
3423
|
+
entries: record.entries
|
|
3424
|
+
},
|
|
3425
|
+
context: await askContext(record.agent, snapshot, checkout, record.selection, record.version),
|
|
3426
|
+
onSession: (sessionId) => store.askHistory.updateSession(record.id, sessionId),
|
|
3427
|
+
onTurn: () => store.askHistory.touch(record.id),
|
|
3428
|
+
onSave: (entries) => store.askHistory.saveEntries(record.id, entries),
|
|
3429
|
+
picks,
|
|
3430
|
+
bypass: record.bypass,
|
|
3431
|
+
onBypass: (bypass) => store.askHistory.setBypass(record.id, bypass),
|
|
3432
|
+
onOffer: (offer) => store.askHistory.saveOffer(record.agent, offer),
|
|
3433
|
+
onTitle: (title) => store.askHistory.rename(record.id, title)
|
|
3434
|
+
});
|
|
3435
|
+
return context.json({ threadId: record.id });
|
|
3436
|
+
});
|
|
3437
|
+
app.get("/:id/ask/:threadId/watch", (context) => {
|
|
3438
|
+
return watchAskThread(readThread(context.req.param("id"), context.req.param("threadId")));
|
|
3439
|
+
});
|
|
3440
|
+
app.post("/:id/ask/:threadId/prompt", async (context) => {
|
|
3441
|
+
const thread = readThread(context.req.param("id"), context.req.param("threadId"));
|
|
3442
|
+
const { question } = askFollowUpSchema.parse(await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES));
|
|
3443
|
+
const refusal = thread.askRefusal();
|
|
3444
|
+
if (refusal) throw new ReviewInputError(refusal, 409);
|
|
3445
|
+
thread.ask(question);
|
|
3446
|
+
return context.json({ ok: true });
|
|
3447
|
+
});
|
|
3448
|
+
app.post("/:id/ask/:threadId/permission", async (context) => {
|
|
3449
|
+
const thread = readThread(context.req.param("id"), context.req.param("threadId"));
|
|
3450
|
+
const decision = askDecisionSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
3451
|
+
if (!thread.decide(decision.permissionId, decision.optionId)) throw new ReviewInputError("This request was already answered.", 409);
|
|
3452
|
+
return context.json({ ok: true });
|
|
3453
|
+
});
|
|
3454
|
+
app.post("/:id/ask/:threadId/files", async (context) => {
|
|
3455
|
+
const thread = readThread(context.req.param("id"), context.req.param("threadId"));
|
|
3456
|
+
const { paths } = askFilesSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
3457
|
+
const { cwd, entries } = thread.read();
|
|
3458
|
+
const touched = entries.flatMap((entry) => entry.kind === "tool" ? `${entry.title} ${entry.input ?? ""}`.split(/\s+/).flatMap((token) => parseFileRef(token)?.path ?? []) : []);
|
|
3459
|
+
const files = resolveFileRefs(cwd, await checkoutFiles(cwd), paths, touched);
|
|
3460
|
+
return context.json({ files: [...files].map(([path, file]) => ({
|
|
3461
|
+
path,
|
|
3462
|
+
file
|
|
3463
|
+
})) });
|
|
3464
|
+
});
|
|
3465
|
+
app.post("/:id/ask/:threadId/choice", async (context) => {
|
|
3466
|
+
const thread = readThread(context.req.param("id"), context.req.param("threadId"));
|
|
3467
|
+
const { kind, value } = askChoiceSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
3468
|
+
if (thread.read().status !== "idle") throw new ReviewInputError("Settings can change once the agent finishes answering.", 409);
|
|
3469
|
+
try {
|
|
3470
|
+
await thread.choose(kind, value);
|
|
3471
|
+
} catch (error) {
|
|
3472
|
+
throw new ReviewInputError(errorMessage(error), 409);
|
|
3473
|
+
}
|
|
3474
|
+
return context.json({ ok: true });
|
|
3475
|
+
});
|
|
3476
|
+
app.post("/:id/ask/:threadId/permissions", async (context) => {
|
|
3477
|
+
const thread = readThread(context.req.param("id"), context.req.param("threadId"));
|
|
3478
|
+
const { bypass } = askPermitSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
3479
|
+
if (thread.read().status !== "idle") throw new ReviewInputError("Settings can change once the agent finishes answering.", 409);
|
|
3480
|
+
try {
|
|
3481
|
+
await thread.permit(bypass);
|
|
3482
|
+
} catch (error) {
|
|
3483
|
+
throw new ReviewInputError(errorMessage(error), 409);
|
|
3484
|
+
}
|
|
3485
|
+
return context.json({ ok: true });
|
|
3486
|
+
});
|
|
3487
|
+
app.post("/:id/ask/:threadId/retry", (context) => {
|
|
3488
|
+
const thread = readThread(context.req.param("id"), context.req.param("threadId"));
|
|
3489
|
+
if (thread.read().status !== "failed") throw new ReviewInputError("Only a conversation that failed can try again.", 409);
|
|
3490
|
+
thread.retry();
|
|
3491
|
+
return context.json({ ok: true });
|
|
3492
|
+
});
|
|
3493
|
+
app.post("/:id/ask/:threadId/cancel", async (context) => {
|
|
3494
|
+
await readThread(context.req.param("id"), context.req.param("threadId")).cancel();
|
|
3495
|
+
return context.json({ ok: true });
|
|
3496
|
+
});
|
|
3497
|
+
app.post("/:id/ask/:threadId/close", (context) => {
|
|
3498
|
+
readThread(context.req.param("id"), context.req.param("threadId"));
|
|
3499
|
+
ask.threads.close(context.req.param("threadId"));
|
|
3500
|
+
return context.json({ ok: true });
|
|
3501
|
+
});
|
|
3502
|
+
app.delete("/:id/ask/:threadId", (context) => {
|
|
3503
|
+
const reviewId = context.req.param("id");
|
|
3504
|
+
const threadId = context.req.param("threadId");
|
|
3505
|
+
if (store.askHistory.get(threadId)?.reviewId !== reviewId) throw new ReviewInputError("This conversation was not found.", 404);
|
|
3506
|
+
if (ask.threads.get(threadId)?.reviewId === reviewId) ask.threads.close(threadId);
|
|
3507
|
+
store.askHistory.delete(threadId);
|
|
3508
|
+
return context.json({ ok: true });
|
|
3509
|
+
});
|
|
3510
|
+
}
|
|
3511
|
+
app.get("/:id/stack", async (context) => {
|
|
3512
|
+
const query = readQuerySchemas.get.parse(context.req.query());
|
|
3513
|
+
const id = context.req.param("id");
|
|
3514
|
+
const snapshot = readReview(id, query.version);
|
|
3515
|
+
if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
|
|
3516
|
+
const layers = await resolveReviewStackLayers(snapshot, store.list());
|
|
3517
|
+
return context.json({ layers });
|
|
3518
|
+
});
|
|
3519
|
+
app.get("/:id/history", (context) => {
|
|
3520
|
+
const id = context.req.param("id");
|
|
3521
|
+
if (!isShared(id)) return context.json(store.history(id));
|
|
3522
|
+
const snapshot = readReview(id);
|
|
3523
|
+
return context.json([{
|
|
3524
|
+
version: snapshot.version,
|
|
3525
|
+
title: snapshot.title,
|
|
3526
|
+
createdAt: snapshot.createdAt
|
|
3527
|
+
}]);
|
|
3528
|
+
});
|
|
3529
|
+
app.get("/:id/inspect", (context) => {
|
|
3530
|
+
const query = inspectQuerySchema.parse(context.req.query());
|
|
3531
|
+
const id = context.req.param("id");
|
|
3532
|
+
const snapshot = readReview(id, query.version);
|
|
3533
|
+
return context.json(query.format === "text" ? documentText(snapshot, query.targetId, Boolean(query.full)) : query.targetId !== void 0 ? inspectSnapshot(snapshot, query.targetId) : query.full ? snapshot : inspectSnapshot(snapshot));
|
|
3534
|
+
});
|
|
3535
|
+
app.get("/:id", async (context) => {
|
|
3536
|
+
const query = readQuerySchemas.get.parse(context.req.query());
|
|
3537
|
+
const snapshot = { ...readReview(context.req.param("id"), query.version) };
|
|
3538
|
+
if (data && query.full) try {
|
|
3539
|
+
const pins = await data.sourcePins(snapshot);
|
|
3540
|
+
if (pins) snapshot.pins = pins;
|
|
3541
|
+
} catch (error) {
|
|
3542
|
+
if (!(error instanceof ReviewInputError) || error.status !== 404) throw error;
|
|
3543
|
+
snapshot.sourceUnavailable = true;
|
|
3544
|
+
}
|
|
3545
|
+
return context.json(query.full ? snapshot : inspectSnapshot(snapshot, query.targetId));
|
|
3546
|
+
});
|
|
3547
|
+
/** After a lens write: the changed lines still uncategorized at that
|
|
3548
|
+
* version, so the author can fill the gaps. A comparison that cannot be
|
|
3549
|
+
* read leaves a warning instead of failing the saved write. */
|
|
3550
|
+
const lensGaps = async (reviewId, version, request) => {
|
|
3551
|
+
if (!data) return {};
|
|
3552
|
+
try {
|
|
3553
|
+
return { uncategorized: uncategorizedReport(await reviewProgress(store, data, store.read(reviewId, version), request.signal)) };
|
|
3554
|
+
} catch (error) {
|
|
3555
|
+
return { warnings: [`Uncategorized changes are unavailable: ${errorMessage(error)}`] };
|
|
3556
|
+
}
|
|
3557
|
+
};
|
|
3558
|
+
app.post("/commands", async (context) => {
|
|
3559
|
+
const { command: body, open: requestedOpen } = takeCreateOpen(await readBoundedRequestJson(context.req.raw));
|
|
3560
|
+
const request = await locateRepositories(body, (path) => {
|
|
3561
|
+
if (!data) throw new ReviewInputError("Repositories are unavailable.");
|
|
3562
|
+
return data.register(path);
|
|
3563
|
+
});
|
|
3564
|
+
const input = commandSchema.parse(request);
|
|
3565
|
+
const command = sharedCommandSchema.safeParse(input);
|
|
3566
|
+
if (command.success) {
|
|
3567
|
+
refuseDisabledScratchpad(command.data.operation.reviewId);
|
|
3568
|
+
await ensureScratchpad(command.data.operation.reviewId);
|
|
3569
|
+
}
|
|
3570
|
+
if (input.operation.type === "create" && input.operation.kind === "scratchpad") refuseDisabledScratchpad(SCRATCHPAD_ID);
|
|
3571
|
+
if (command.success && command.data.operation.reviewId?.startsWith("shared-")) {
|
|
3572
|
+
const parsed = commandSchema.parse(input);
|
|
3573
|
+
if (shared && parsed.operation.type === "delete") {
|
|
3574
|
+
await shared.removeLocal(parsed.operation.reviewId);
|
|
3575
|
+
return context.json({
|
|
3576
|
+
reviewId: parsed.operation.reviewId,
|
|
3577
|
+
version: 0,
|
|
3578
|
+
deleted: true
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
if (shared && parsed.operation.type === "attention") {
|
|
3582
|
+
await shared.setAttention(parsed.operation.reviewId, parsed.operation.action);
|
|
3583
|
+
return context.json({
|
|
3584
|
+
reviewId: parsed.operation.reviewId,
|
|
3585
|
+
version: shared.get(parsed.operation.reviewId).snapshot.version,
|
|
3586
|
+
attention: true
|
|
3587
|
+
});
|
|
3588
|
+
}
|
|
3589
|
+
throw new ReviewInputError("Shared reviews are read-only.", 409);
|
|
3590
|
+
}
|
|
3591
|
+
const result = await store.execute(input);
|
|
3592
|
+
if (input.operation.type === "edit" && data && !result.deleted) {
|
|
3593
|
+
const { quotes, unquoted } = await anchorQuotes(result.version > 0 ? store.read(result.reviewId, result.version - 1) : void 0, store.read(result.reviewId, result.version), async (pins, side, file) => (await data.file(pins, side, file)).text);
|
|
3594
|
+
return context.json({
|
|
3595
|
+
...result,
|
|
3596
|
+
...quotes.length && { quotes },
|
|
3597
|
+
...unquoted && { unquotedAnchors: unquoted }
|
|
3598
|
+
});
|
|
3599
|
+
}
|
|
3600
|
+
if (input.operation.type === "lens_edit") {
|
|
3601
|
+
const gaps = await lensGaps(result.reviewId, result.version, context.req.raw);
|
|
3602
|
+
return context.json({
|
|
3603
|
+
...result,
|
|
3604
|
+
...gaps,
|
|
3605
|
+
...gaps.warnings && { warnings: [...result.warnings ?? [], ...gaps.warnings] }
|
|
3606
|
+
});
|
|
3607
|
+
}
|
|
3608
|
+
if (input.operation.type !== "create") return context.json(result);
|
|
3609
|
+
if (result.created !== false) hooks.onReviewCreated?.({
|
|
3610
|
+
reviewId: result.reviewId,
|
|
3611
|
+
kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
|
|
3612
|
+
blocks: store.read(result.reviewId).document.length,
|
|
3613
|
+
...reviewRequestOrigin(context.req.raw.headers)
|
|
3614
|
+
});
|
|
3615
|
+
return context.json({
|
|
3616
|
+
...result,
|
|
3617
|
+
review: store.summary(result.reviewId),
|
|
3618
|
+
...requestedOpen === false ? { opened: false } : await openCreated(result.reviewId)
|
|
3619
|
+
});
|
|
3620
|
+
});
|
|
3621
|
+
return app;
|
|
3622
|
+
}
|
|
3623
|
+
const systemErrorSchema = z.object({ code: z.string().regex(/^[A-Z0-9_]+$/) });
|
|
3624
|
+
/** A system error code such as EACCES, else the error's class; never its message. */
|
|
3625
|
+
function failureKind(error) {
|
|
3626
|
+
const system = systemErrorSchema.safeParse(error);
|
|
3627
|
+
return system.success ? system.data.code : error.name;
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* `open` steers presentation, not the saved review, so it stays out of the
|
|
3631
|
+
* command. Anything else, including `open` off create, is left for
|
|
3632
|
+
* commandSchema to reject.
|
|
3633
|
+
*/
|
|
3634
|
+
function takeCreateOpen(body) {
|
|
3635
|
+
const create = z.looseObject({ operation: z.looseObject({
|
|
3636
|
+
type: z.literal("create"),
|
|
3637
|
+
open: z.boolean().optional()
|
|
3638
|
+
}) }).safeParse(body);
|
|
3639
|
+
if (!create.success) return { command: body };
|
|
3640
|
+
const { open, ...operation } = create.data.operation;
|
|
3641
|
+
return {
|
|
3642
|
+
command: {
|
|
3643
|
+
...create.data,
|
|
3644
|
+
operation
|
|
3645
|
+
},
|
|
3646
|
+
open
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* Agents name a checkout by its path (pathTargetSchema, and repositoryPath on
|
|
3651
|
+
* a create from a PR); the store keeps the id it registers as. A command that
|
|
3652
|
+
* already names ids is left as it is.
|
|
3653
|
+
*/
|
|
3654
|
+
async function locateRepositories(body, register) {
|
|
3655
|
+
const located = z.looseObject({ operation: z.looseObject({
|
|
3656
|
+
type: z.enum(["create", "set_target"]),
|
|
3657
|
+
target: z.looseObject({ repositoryPath: z.string() }).optional(),
|
|
3658
|
+
repositoryPath: z.string().optional()
|
|
3659
|
+
}) }).safeParse(body);
|
|
3660
|
+
if (!located.success) return body;
|
|
3661
|
+
const { target, repositoryPath, ...operation } = located.data.operation;
|
|
3662
|
+
const byId = async ({ repositoryPath: path, ...rest }) => ({
|
|
3663
|
+
...rest,
|
|
3664
|
+
repositoryId: (await register(path)).id
|
|
3665
|
+
});
|
|
3666
|
+
return {
|
|
3667
|
+
...located.data,
|
|
3668
|
+
operation: {
|
|
3669
|
+
...operation,
|
|
3670
|
+
...target && { target: await byId(target) },
|
|
3671
|
+
...repositoryPath && await byId({ repositoryPath })
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3675
|
+
/** Send committed state, coalescing updates when the reader falls behind. */
|
|
3676
|
+
function watch$1(read, subscribe, probe = read) {
|
|
3677
|
+
probe();
|
|
3678
|
+
let stop = () => {};
|
|
3679
|
+
let dirty = true;
|
|
3680
|
+
const encoder = new TextEncoder();
|
|
3681
|
+
const send = (controller) => {
|
|
3682
|
+
if (!dirty || controller.desiredSize === null || controller.desiredSize <= 0) return;
|
|
3683
|
+
try {
|
|
3684
|
+
const line = JSON.stringify(read()) + "\n";
|
|
3685
|
+
dirty = false;
|
|
3686
|
+
controller.enqueue(encoder.encode(line));
|
|
3687
|
+
} catch (error) {
|
|
3688
|
+
stop();
|
|
3689
|
+
controller.error(error);
|
|
3690
|
+
}
|
|
3691
|
+
};
|
|
3692
|
+
const body = new ReadableStream({
|
|
3693
|
+
start(controller) {
|
|
3694
|
+
stop = subscribe(() => {
|
|
3695
|
+
dirty = true;
|
|
3696
|
+
send(controller);
|
|
3697
|
+
});
|
|
3698
|
+
send(controller);
|
|
3699
|
+
},
|
|
3700
|
+
pull: send,
|
|
3701
|
+
cancel() {
|
|
3702
|
+
stop();
|
|
3703
|
+
}
|
|
3704
|
+
});
|
|
3705
|
+
return new Response(body, { headers: {
|
|
3706
|
+
"content-type": "application/x-ndjson",
|
|
3707
|
+
"cache-control": "no-store"
|
|
3708
|
+
} });
|
|
3709
|
+
}
|
|
3710
|
+
//#endregion
|
|
3711
|
+
//#region src/server/review-server-core.ts
|
|
3712
|
+
const version = readReviewPackageVersion(import.meta.url);
|
|
3713
|
+
const commit = readBuildCommit(import.meta.url);
|
|
3714
|
+
/**
|
|
3715
|
+
* What every review server shares: CORS, an open /health, token auth, and
|
|
3716
|
+
* the /control relay a Desktop attaches to, with errors answered as JSON.
|
|
3717
|
+
* Callers add their routes after.
|
|
3718
|
+
*/
|
|
3719
|
+
function createReviewServerApp(input) {
|
|
3720
|
+
const app = new Hono();
|
|
3721
|
+
app.use("*", async (context, next) => {
|
|
3722
|
+
await next();
|
|
3723
|
+
applyCorsHeaders(context.req.raw, context.res);
|
|
3724
|
+
});
|
|
3725
|
+
app.options("*", (context) => corsPreflightResponse(context.req.raw));
|
|
3726
|
+
app.get("/health", (context) => {
|
|
3727
|
+
const health = {
|
|
3728
|
+
ok: true,
|
|
3729
|
+
instanceId: input.instanceId,
|
|
3730
|
+
desktopAttached: input.relay.attached,
|
|
3731
|
+
version
|
|
3732
|
+
};
|
|
3733
|
+
return serverJson(200, isAuthorizedRequest(context.req.raw, input.token) ? {
|
|
3734
|
+
...health,
|
|
3735
|
+
serverId: input.serverId,
|
|
3736
|
+
serverPid: process.pid,
|
|
3737
|
+
commit
|
|
3738
|
+
} : health);
|
|
3739
|
+
});
|
|
3740
|
+
app.use("*", async (context, next) => {
|
|
3741
|
+
if (!isAuthorizedRequest(context.req.raw, input.token)) return serverJson(401, {
|
|
3742
|
+
ok: false,
|
|
3743
|
+
error: "Unauthorized"
|
|
3744
|
+
});
|
|
3745
|
+
await next();
|
|
3746
|
+
});
|
|
3747
|
+
app.get("/control", (context) => openControlEvents(context, input.relay));
|
|
3748
|
+
app.post("/control/result", async (context) => {
|
|
3749
|
+
const accepted = input.relay.acceptResult(await readBoundedRequestJson(context.req.raw));
|
|
3750
|
+
return serverJson(accepted ? 200 : 404, { ok: accepted });
|
|
3751
|
+
});
|
|
3752
|
+
app.notFound(() => serverJson(404, {
|
|
3753
|
+
ok: false,
|
|
3754
|
+
error: "Not found."
|
|
3755
|
+
}));
|
|
3756
|
+
app.onError((error) => {
|
|
3757
|
+
const serverError = error instanceof ReviewServerError ? error : void 0;
|
|
3758
|
+
const message = toError$1(error).message;
|
|
3759
|
+
return serverJson(serverError?.statusCode ?? httpJsonStatus(error), serverError?.code ? {
|
|
3760
|
+
ok: false,
|
|
3761
|
+
code: serverError.code,
|
|
3762
|
+
error: message
|
|
3763
|
+
} : {
|
|
3764
|
+
ok: false,
|
|
3765
|
+
error: message
|
|
3766
|
+
});
|
|
3767
|
+
});
|
|
3768
|
+
return app;
|
|
3769
|
+
}
|
|
3770
|
+
function createWhiteboardCore(input) {
|
|
3771
|
+
const { store, data, shared } = input.profile;
|
|
3772
|
+
const app = createReviewServerApp({
|
|
3773
|
+
token: input.token,
|
|
3774
|
+
instanceId: input.instanceId,
|
|
3775
|
+
serverId: store.serverId(),
|
|
3776
|
+
relay: input.relay
|
|
3777
|
+
});
|
|
3778
|
+
const callbacks = relayReviewCallbacks(input.relay, input.softwareMapEnabled);
|
|
3779
|
+
const askThreads = input.ask && new AskThreads(launchAskAgent, input.ask.tools);
|
|
3780
|
+
const api = createReviewApi(store, data, callbacks.open, shared, callbacks.capabilities, input.scratchpad, () => traceMachineEnabled(), input.status, input.hooks, askThreads && {
|
|
3781
|
+
threads: askThreads,
|
|
3782
|
+
agents: () => detectAskAgents()
|
|
3783
|
+
});
|
|
3784
|
+
if (!shared) mountSharingPublisher(api, store, data);
|
|
3785
|
+
return {
|
|
3786
|
+
app,
|
|
3787
|
+
api,
|
|
3788
|
+
close: () => askThreads?.closeAll()
|
|
3789
|
+
};
|
|
3790
|
+
}
|
|
3791
|
+
/** The Desktop callbacks `createReviewApi` takes, answered over the relay. */
|
|
3792
|
+
function relayReviewCallbacks(relay, softwareMapEnabled = false) {
|
|
3793
|
+
return {
|
|
3794
|
+
async open(review) {
|
|
3795
|
+
const result = await relay.dispatch({
|
|
3796
|
+
name: "openApiReview",
|
|
3797
|
+
args: review
|
|
3798
|
+
});
|
|
3799
|
+
if (!result.ok) throw new ReviewInputError(result.error, 409);
|
|
3800
|
+
return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
|
|
3801
|
+
},
|
|
3802
|
+
async capabilities() {
|
|
3803
|
+
if (!relay.attached) return {
|
|
3804
|
+
desktopAvailable: false,
|
|
3805
|
+
softwareMapEnabled
|
|
3806
|
+
};
|
|
3807
|
+
const result = await relay.dispatch({
|
|
3808
|
+
name: "authoringCapabilities",
|
|
3809
|
+
args: {}
|
|
3810
|
+
});
|
|
3811
|
+
if (!result.ok) throw new ReviewInputError(result.error, 409);
|
|
3812
|
+
return {
|
|
3813
|
+
desktopAvailable: true,
|
|
3814
|
+
...z.object({ softwareMapEnabled: z.boolean() }).parse(result.result)
|
|
3815
|
+
};
|
|
3816
|
+
}
|
|
3817
|
+
};
|
|
3818
|
+
}
|
|
3819
|
+
function openControlEvents(context, relay) {
|
|
3820
|
+
let attached = false;
|
|
3821
|
+
const response = streamSSE(context, async (output) => {
|
|
3822
|
+
let finish;
|
|
3823
|
+
const disconnected = new Promise((resolve) => {
|
|
3824
|
+
finish = resolve;
|
|
3825
|
+
});
|
|
3826
|
+
const abort = new AbortController();
|
|
3827
|
+
let pending = output.write(": attached\n\n").then(() => void 0);
|
|
3828
|
+
const writer = {
|
|
3829
|
+
signal: abort.signal,
|
|
3830
|
+
write(frame) {
|
|
3831
|
+
pending = pending.then(async () => {
|
|
3832
|
+
await output.write(frame);
|
|
3833
|
+
});
|
|
3834
|
+
},
|
|
3835
|
+
close() {
|
|
3836
|
+
finish();
|
|
3837
|
+
output.close();
|
|
3838
|
+
}
|
|
3839
|
+
};
|
|
3840
|
+
output.onAbort(() => {
|
|
3841
|
+
abort.abort();
|
|
3842
|
+
finish();
|
|
3843
|
+
});
|
|
3844
|
+
attached = relay.attach(writer);
|
|
3845
|
+
if (!attached) {
|
|
3846
|
+
finish();
|
|
3847
|
+
return;
|
|
3848
|
+
}
|
|
3849
|
+
try {
|
|
3850
|
+
await disconnected;
|
|
3851
|
+
await pending;
|
|
3852
|
+
} finally {
|
|
3853
|
+
abort.abort();
|
|
3854
|
+
}
|
|
3855
|
+
});
|
|
3856
|
+
if (!attached) {
|
|
3857
|
+
response.body?.cancel();
|
|
3858
|
+
return serverJson(409, {
|
|
3859
|
+
ok: false,
|
|
3860
|
+
error: "This server has no room for another Whiteboard Desktop."
|
|
3861
|
+
});
|
|
3862
|
+
}
|
|
3863
|
+
response.headers.set("connection", "close");
|
|
3864
|
+
response.headers.set("cache-control", "no-cache, no-transform");
|
|
3865
|
+
response.headers.set("content-type", "text/event-stream; charset=utf-8");
|
|
3866
|
+
return response;
|
|
3867
|
+
}
|
|
3868
|
+
function serverJson(status, body) {
|
|
3869
|
+
return jsonResponse(body, status, { cacheControl: "no-store" });
|
|
3870
|
+
}
|
|
3871
|
+
function httpJsonStatus(cause) {
|
|
3872
|
+
return cause instanceof HttpJsonError ? cause.statusCode : 400;
|
|
3873
|
+
}
|
|
3874
|
+
function toError$1(cause) {
|
|
3875
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
3876
|
+
}
|
|
3877
|
+
//#endregion
|
|
3878
|
+
//#region src/server/process-error-telemetry.ts
|
|
3879
|
+
function serverCrashReportPath(env = process.env) {
|
|
3880
|
+
return path.join(devReviewHome(env), "telemetry", "server-crash.json");
|
|
3881
|
+
}
|
|
3882
|
+
function installProcessErrorTelemetry(telemetry, options = {}) {
|
|
3883
|
+
const target = options.process ?? process;
|
|
3884
|
+
const crashReportPath = options.crashReportPath ?? serverCrashReportPath();
|
|
3885
|
+
const onUncaught = (cause) => {
|
|
3886
|
+
const properties = serverErrorProperties(toError(cause), options.appSessionId);
|
|
3887
|
+
if (target.listenerCount("uncaughtException") === 0) writeCrashReport(crashReportPath, properties);
|
|
3888
|
+
else sendServerError(telemetry, properties);
|
|
3889
|
+
};
|
|
3890
|
+
const onRejection = (cause) => {
|
|
3891
|
+
sendServerError(telemetry, serverErrorProperties(toError(cause), options.appSessionId));
|
|
3892
|
+
};
|
|
3893
|
+
const watchRejections = target.listenerCount("unhandledRejection") > 0;
|
|
3894
|
+
target.on("uncaughtExceptionMonitor", onUncaught);
|
|
3895
|
+
if (watchRejections) target.on("unhandledRejection", onRejection);
|
|
3896
|
+
return () => {
|
|
3897
|
+
target.off("uncaughtExceptionMonitor", onUncaught);
|
|
3898
|
+
if (watchRejections) target.off("unhandledRejection", onRejection);
|
|
3899
|
+
};
|
|
3900
|
+
}
|
|
3901
|
+
/** Sends, once, the fatal error an earlier server process left behind. */
|
|
3902
|
+
async function drainServerCrashReport(telemetry, crashReportPath = serverCrashReportPath()) {
|
|
3903
|
+
let properties;
|
|
3904
|
+
try {
|
|
3905
|
+
const text = await readFile(crashReportPath, "utf8");
|
|
3906
|
+
await rm(crashReportPath, { force: true });
|
|
3907
|
+
properties = parseJsonText(text);
|
|
3908
|
+
} catch {
|
|
3909
|
+
return;
|
|
3910
|
+
}
|
|
3911
|
+
await sendServerError(telemetry, properties);
|
|
3912
|
+
}
|
|
3913
|
+
function toError(cause) {
|
|
3914
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
3915
|
+
}
|
|
3916
|
+
function serverErrorProperties(error, appSessionId) {
|
|
3917
|
+
const properties = {
|
|
3918
|
+
error_source: "server_unexpected",
|
|
3919
|
+
error_process: "server",
|
|
3920
|
+
error_name: error.name
|
|
3921
|
+
};
|
|
3922
|
+
if (appSessionId) properties.app_session_id = appSessionId;
|
|
3923
|
+
return mergeErrorTelemetryProperties(properties, {
|
|
3924
|
+
name: error.name,
|
|
3925
|
+
message: error.message,
|
|
3926
|
+
stack: error.stack ?? ""
|
|
3927
|
+
});
|
|
3928
|
+
}
|
|
3929
|
+
function writeCrashReport(crashReportPath, properties) {
|
|
3930
|
+
const sanitized = sanitizeUiTelemetryEvent({
|
|
3931
|
+
name: "client_error",
|
|
3932
|
+
properties
|
|
3933
|
+
});
|
|
3934
|
+
if (!sanitized) return;
|
|
3935
|
+
try {
|
|
3936
|
+
mkdirSync(path.dirname(crashReportPath), { recursive: true });
|
|
3937
|
+
writeFileSync(crashReportPath, JSON.stringify(sanitized.properties), { mode: 384 });
|
|
3938
|
+
} catch {}
|
|
3939
|
+
}
|
|
3940
|
+
async function sendServerError(telemetry, properties) {
|
|
3941
|
+
const sanitized = sanitizeUiTelemetryEvent({
|
|
3942
|
+
name: "client_error",
|
|
3943
|
+
properties
|
|
3944
|
+
});
|
|
3945
|
+
const admitted = sanitized ? admitUiTelemetryEvent(sanitized) : void 0;
|
|
3946
|
+
if (!admitted) return;
|
|
3947
|
+
try {
|
|
3948
|
+
await telemetry.captureUiEvent?.(admitted.event, admitted.properties);
|
|
3949
|
+
} catch {}
|
|
3950
|
+
}
|
|
3951
|
+
//#endregion
|
|
3952
|
+
export { serverJson as a, recordClientError as c, HttpJsonError as d, ReviewServerError as f, createWhiteboardCore as i, createNodeRequestListener as l, installProcessErrorTelemetry as n, captureSanitizedUiTelemetry as o, GlobalReviewDesktopVerbRelay as p, serverCrashReportPath as r, clientErrorsForSession as s, drainServerCrashReport as t, readBoundedRequestJson as u };
|