prreviewbuddy 0.87.2 → 0.90.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/dist/server.js CHANGED
@@ -1,9 +1,9 @@
1
- import { $ as MAX_WORKSPACE_PORT, $i as resolveBase, $n as fillFileUrlTemplate, $r as createWithMembers, $t as markAllRead, A as relativeTime$1, Ai as currentGradeIn, An as CODE_COPY_CLASS, Ar as mutateWorkspace, At as checkArchitecture, B as readIndexToken, Bi as statusLookup, Bn as isWorktreeHeld, Br as workspaceStamps, Bt as REPOSITORY_SCOPE, C as RUN_KINDS, Ci as isExplanationAction, Cn as chatConversationSection, Cr as kissDecisionRefusal, Ct as waitingHistory, D as heldRuns, Di as analysisRefOf, Dn as GITHUB_MARKDOWN, Dr as lineagePosition, Dt as describeRuntimes, E as endRun, Ei as NO_STATUS, En as hasAuthorContent, Er as lineageKey, Et as RUNTIME_WORDS, F as runBatch, Fi as isGradeFor, Fn as isBusy, Fr as reviewedCommit, Ft as storeArchitecture, Gi as objectWithAnyOf, Gr as lifecycleChip, Gt as branchOf, H as clearServerRecordOwnedBy, Hi as followUpFindingId, Hn as removeWorktree, Hr as activityAttributes, Ht as isArchitectureId, I as activateQueued, Ii as isOffered, In as withReviewLock, It as storedScopes, J as stopChild, Ji as kissOutcome, Jn as checkReview, Jr as offersLifecycle, Jt as createArchitectureNotification, K as serverEntryExists, Ki as describeAuthorship, Kn as findExistingReview, Kr as lifecycleItemLabel, Kt as subjectOf, L as compactQueue, Li as promptGrade, Ln as MANAGED_ROOT, Lr as summarise, Lt as trackedFiles, M as extractPullRequestLinks, Mi as findingIn, Mn as renderInlineText, Mr as presentedKissRunOf, Mt as partFiles, N as parsePullRequestLink, Ni as findingStatus, Nr as previousKissResult, Nt as readArchitecture, O as reportRun, Oi as assessmentLookup, On as renderMarkdown, Or as loadWorkspace, Ot as formatDuration, P as unsupportedReason, Pi as gradeScale, Pn as ReviewBeingDeletedError, Pr as recentWorkspaces, Pt as repositoryKey, Q as DEFAULT_WORKSPACE_PORT, Qi as isCommitReview, Qn as forgeResolver, Qr as createCollection, Qt as listNotifications, Ri as reassessIn, Rn as createWorktree, Rr as touchWorkspace, Rt as ownersOf, S as track, Sa as AgentUnavailableError, Si as explanationOf, Sn as EXPLAIN_SIMPLY_PROMPT, Sr as indexReviewGroups, St as feedbackUrl, T as completeRun, Ti as ANALYSIS_ONLY, Tn as authorPreview, Tr as latestKissRun, Tt as recordRuntimeBestEffort, Ui as asString, Un as requestJobStop, Ur as activityLabel, Ut as writeJsonAtomically, Vi as wasReassessed, Vn as readMarker, Vr as LIFECYCLES, Vt as ancestorsOf, W as readServerRecord, Wi as extractJsonText, Wr as isLifecycle, Wt as announceJob, Xi as originRemote, Xn as reviewedShaOf, Xr as sectionOf, Xt as createPrUpdate, Yi as resolveTarget, Yn as followedBranch, Yr as parseLifecycle, Yt as createPrState, Z as writeServerRecord, Zi as DetachedHeadError, Zn as fetchHead, Zr as collectionsStamp, Zt as eventName, _ as runningJobs, _a as detectAgents, _i as reviewerDispositions, _n as storedConversation, _t as recordTelemetryUploadConsent, a as wasBlocked, aa as resolveAgent, ai as updateCollections, an as readStateOf, ar as isTerminal, at as failureOf$1, b as startKissJob, ba as runnableAgents, bi as recommendationsOf, bn as isUnchanged, br as groupByLineage, bt as BUILD_VERSION, c as RefreshUnavailableError, ca as clearAgentPreference, ci as queueRemove, cn as toneOf, cr as saveJob, ct as settleReview, d as mergeUpdateInto, di as doneVerb, dn as withUsageRecorded, dr as isTerminalJobPhase, ea as AmbiguousRefError, ei as deleteCollection, en as markRead, er as RUN_RECORD_RETENTION_MS, et as MIN_WORKSPACE_PORT, fa as STORE_ROOT, fi as isIssueOutstanding, fn as existingPaths, fr as phasesFor, g as liveUpdateFor, ga as agentIdentityOf, gi as resolvedByConversation, gn as saveRefresh, gr as isClaimed, h as UpdateAlreadyRunningError, ha as agentFor$1, hi as questionsOutstanding, hn as prepareConversationWithin, i as liveJobsFor, ia as refExists, ii as setMembership, in as readNotification, ir as fail$1, it as writePortPreference, j as batchIdentity, ji as findingAssessment, jn as escapeHtml$1, jt as loadArchitecture, k as stopRun, ki as closesFinding, kn as CODE_BLOCK_CLASS, kt as ARCHITECTURE_DIR, l as cancelUpdate, la as readAgentPreference, li as queueStamp, ln as unreadAround, lt as reanalyseReview, m as askCheckout, ma as agentById, mi as issuesOutstanding, mn as adoptLegacyBaseline, mr as purposeOf, mt as record, n as deleteReview, na as shorthandOf, ni as readCollections, nn as moveIds, nr as attemptIdOf, nt as resolveWorkspacePort, o as discardJob, oa as resolveModel, oi as queueAdd, on as removeNotification, or as loadJob, ot as observeReview, p as updateReview, pa as AGENT_IDS, pi as isQuestionOutstanding, pn as ASK_REREAD_CEILING_MS, pr as progressSteps, pt as readEvents, q as spawnServer, qi as WITHHELD_REASON, qn as checkCodeFreshness, qr as lifecyclePill, qt as categoryOf, r as lineageIds, ra as git, ri as renameCollection, rn as pruneNotifications, rr as endedAt, rt as statedWorkspacePort, s as FETCH_TIMEOUT_MS, sa as agentIdOf, si as queueAddMany, sn as retireNotification, sr as pruneJobResidue, st as prepareReview, t as deleteLineage, ta as shortNameOf, ti as nameProblemText, tn as markReadForReview, tr as allJobs, tt as parseWorkspacePort, u as cancellableHere, ua as writeAgentPreference, ui as readQueue, un as unreadCount$1, ur as isLiveJob, ut as runJob, v as kissAgentId, va as isAgentId, vi as verdictClosure, vn as unforeseenFailure, vr as decideKissRecommendation, vt as telemetryUploadConsent, w as beginRun, wi as withExplanation, wn as UNESTABLISHED_MOTIVATION, wr as kissDecisionsOf, wt as mapRuntimes, x as isDriving, xa as AgentCancelledError, xi as explanationActionOf, xn as agentEnvOf, xr as indexKeptIds, ya as parkedReason, yi as processDiscussion, yn as refreshPrContext, yr as followedRefName, z as settleQueue, zi as sourcePhrase, zn as holdWorktree, zr as workspaceRevision, zt as QUOTE_LIMITS } from "./delete_review-D7TXn5ck.js";
2
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
- import { execFile } from "node:child_process";
4
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
5
- import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
1
+ import { $a as versionFrom, $i as explanationOf, $n as authorPreview, $r as kissDecisionsOf, $t as recordRuntimeBestEffort, A as settleQueue, Aa as isCommitReview, Ai as createWithMembers, An as moveIds, Ar as attemptIdOf, At as UpdateAlreadyRunningError, B as humanizeModelId, Ba as clearAgentPreference, Bi as queueStamp, Bn as existingPaths, Br as phasesFor, C as parsePullRequestLink, Ca as describeAuthorship, Ci as lifecyclePillAction, Cn as createPrState, Cr as followedBranch, Ct as RefreshUnavailableError, D as activateQueued, Da as originRemote, Di as sectionOf, Dn as markAllRead, Dr as fillFileUrlTemplate, E as relativeTime$1, Ea as resolveTarget, Ei as parseLifecycle, En as listNotifications, Er as forgeResolver, Et as mergeUpdateInto, Fa as git, Fi as setMembership, Fn as retireNotification, Fr as pruneJobResidue, Ft as prepareReview, G as reviewTag, Ga as AGENT_IDS, Gi as issuesOutstanding, Gn as storedConversation, Gr as isClaimed, Gt as recordTelemetryUploadConsent, H as findingRef, Ha as writeAgentPreference, Hi as doneVerb, Hn as adoptLegacyBaseline, Hr as purposeOf, Ht as record, Ia as refExists, Ii as updateCollections, In as toneOf, Ir as saveJob, It as settleReview, Ja as agentIdentityOf, Ji as reviewerDispositions, Jn as isUnchanged, Jr as followedRefName, Jt as BUILD_VERSION, K as reviewTagsIn, Ka as agentById, Ki as questionsOutstanding, Kn as unforeseenFailure, Kt as telemetryUploadConsent, L as modelProvenanceOf, La as resolveAgent, Li as queueAdd, Ln as unreadAround, Lt as reanalyseReview, Ma as AmbiguousRefError, Mi as nameProblemText, Mn as readNotification, Mr as fail$1, Mt as runningJobs, N as cliEntry, Na as shortNameOf, Ni as readCollections, Nn as readStateOf, Nr as isTerminal, Nt as failureOf$1, O as compactQueue, Oa as DetachedHeadError, Oi as collectionsStamp, On as markRead, Or as RUN_RECORD_RETENTION_MS, Ot as updateReview, Pa as shorthandOf, Pi as renameCollection, Pn as removeNotification, Pr as loadJob, Pt as observeReview, Q as markAskRunning, Qa as runnableAgents, Qi as explanationActionOf, Qn as UNESTABLISHED_MOTIVATION, Qr as kissDecisionRefusal, Qt as mapRuntimes, R as provenanceDetailLines, Ra as resolveModel, Ri as queueAddMany, Rn as unreadCount$1, Rr as isLiveJob, Rt as runJob, S as extractPullRequestLinks, Sa as objectWithAnyOf, Si as lifecycleName$1, Sn as createArchitectureNotification, Sr as checkReview, St as FETCH_TIMEOUT_MS, T as runBatch, Ta as kissOutcome, Ti as offersLifecycle, Tn as eventName, Tr as fetchHead, Tt as cancellableHere, U as findingRefs, Ui as isIssueOutstanding, Un as prepareConversationWithin, V as findingAtRef, Va as readAgentPreference, Vi as readQueue, Vn as ASK_REREAD_CEILING_MS, Vr as progressSteps, Vt as readEvents, W as refOf, Wa as STORE_ROOT, Wi as isQuestionOutstanding, Wn as saveRefresh, X as isRequestId, Xa as isAgentId, Xi as processDiscussion, Xn as EXPLAIN_SIMPLY_PROMPT, Xr as indexKeptIds, Xt as feedbackUrl, Ya as detectAgents, Yi as verdictClosure, Yn as agentEnvOf, Yr as groupByLineage, Za as parkedReason, Zi as recommendationsOf, Zn as chatConversationSection, Zr as indexReviewGroups, Zt as waitingHistory, _ as endRun, _a as statusLookup, _i as activityAttributes, _n as writeJsonAtomically, _r as requestJobStop, _t as parseWorkspacePort, a as lineageIds, aa as assessmentLookup, ai as mutateWorkspace, an as loadArchitecture, ar as escapeHtml$1, b as stopRun, ba as asString, bi as lifecycleChip, bn as subjectOf, br as findExistingReview, bt as writePortPreference, c as discardJob, ca as findingAssessment, ci as previousKissResult, cn as repositoryKey, cr as ReviewBeingDeletedError, ct as serverEntryExists, d as startKissJob, da as gradeScale, dn as trackedFiles, dr as MANAGED_ROOT, ea as isExplanationAction, ei as latestKissRun, en as RUNTIME_WORDS, er as hasAuthorContent, f as isDriving, fa as isGradeFor, fi as summarise, fn as ownersOf, fr as createWorktree, g as completeRun, ga as sourcePhrase, gi as LIFECYCLES, gn as isArchitectureId, gr as removeWorktree, gt as MIN_WORKSPACE_PORT, h as beginRun, ha as reassessIn, hi as workspaceStamps, hn as ancestorsOf, hr as readMarker, ht as MAX_WORKSPACE_PORT, i as deleteReview, ia as analysisRefOf, in as checkArchitecture, ir as CODE_COPY_CLASS, ja as resolveBase, ji as deleteCollection, jn as pruneNotifications, jr as endedAt, jt as liveUpdateFor, ki as createCollection, kn as markReadForReview, kr as allJobs, kt as askCheckout, l as kissAgentId, la as findingIn, li as recentWorkspaces, ln as storeArchitecture, lr as isBusy, lt as spawnServer, m as RUN_KINDS, ma as promptGrade, mi as workspaceRevision, mn as REPOSITORY_SCOPE, mr as isWorktreeHeld, mt as DEFAULT_WORKSPACE_PORT, n as MCP_TOOL_NAMES, na as ANALYSIS_ONLY, ni as lineagePosition, nn as formatDuration, no as AgentUnavailableError, nr as renderMarkdown, o as liveJobsFor, oa as closesFinding, on as partFiles, or as renderInlineText, ot as readServerRecord, p as track, pa as isOffered, pi as touchWorkspace, pn as QUOTE_LIMITS, pr as holdWorktree, pt as writeServerRecord, q as endAskRequest, qa as agentFor$1, qi as resolvedByConversation, qn as refreshPrContext, qr as decideKissRecommendation, r as deleteLineage, ra as NO_STATUS, ri as loadWorkspace, rn as ARCHITECTURE_DIR, rr as CODE_BLOCK_CLASS, rt as clearServerRecordOwnedBy, s as wasBlocked, sa as currentGradeIn, si as presentedKissRunOf, sn as readArchitecture, ta as withExplanation, ti as lineageKey, tn as describeRuntimes, to as AgentCancelledError, tr as GITHUB_MARKDOWN, tt as readIndexToken, ua as findingStatus, ui as reviewedCommit, un as storedScopes, ur as withReviewLock, ut as stopChild, v as heldRuns, va as wasReassessed, vi as activityLabel, vn as announceJob, vt as resolveWorkspacePort, w as unsupportedReason, wa as WITHHELD_REASON, wi as lifecyclePillLabel, wn as createPrUpdate, wr as reviewedShaOf, wt as cancelUpdate, x as batchIdentity, xa as extractJsonText, xi as lifecycleItemLabel, xn as categoryOf, xr as checkCodeFreshness, y as reportRun, ya as followUpFindingId, yi as isLifecycle, yn as branchOf, yt as statedWorkspacePort, z as agentDisplayName, za as agentIdOf, zi as queueRemove, zn as withUsageRecorded, zr as isTerminalJobPhase } from "./mcp_tools-CSj5vcSE.js";
2
+ import { basename, delimiter, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { execFile, spawn } from "node:child_process";
4
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
6
5
  import { homedir } from "node:os";
6
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createServer } from "node:http";
9
9
  //#region ../../packages/review-harness/src/domain/ai_review/review_composition.ts
@@ -228,6 +228,254 @@ function readingThemeLine(sections, sectionId) {
228
228
  return section ? `The reviewer is currently reading the "${section.title}" theme. Answer with that in view, but do not ignore the rest of the change if it bears on the question.` : null;
229
229
  }
230
230
  //#endregion
231
+ //#region ../../packages/review-harness/src/integrations/executable.ts
232
+ /**
233
+ * Which command an agent's config should start PR Review Buddy with.
234
+ *
235
+ * A stable command on PATH, never the package's own `main.js` and never node itself. The file a
236
+ * command resolves to moves with every install method's upgrades; the command does not. The one
237
+ * chosen is the first on PATH that resolves to *this* install, not merely the first: a machine can
238
+ * have several copies, and an older one earlier on PATH is exactly what breaks a registration that
239
+ * names the bare word (a 0.36 copy has no `mcp serve` at all).
240
+ */
241
+ var CLI_COMMAND = "prreviewbuddy";
242
+ /** What the MCP server is started with, after the command. */
243
+ var SERVE_ARGS = ["mcp", "serve"];
244
+ function realpathOrNull(path) {
245
+ try {
246
+ return realpathSync(path);
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+ /** Every executable `name` on `pathValue`, in PATH order, each directory once. */
252
+ function everyOnPath(name, pathValue) {
253
+ const seen = /* @__PURE__ */ new Set();
254
+ const found = [];
255
+ for (const dir of (pathValue ?? "").split(delimiter)) {
256
+ if (!dir || seen.has(dir)) continue;
257
+ seen.add(dir);
258
+ const candidate = join(dir, name);
259
+ try {
260
+ accessSync(candidate, constants.X_OK);
261
+ found.push(candidate);
262
+ } catch {}
263
+ }
264
+ return found;
265
+ }
266
+ function chooseExecutable(entry, pathValue) {
267
+ const ours = realpathOrNull(entry) ?? entry;
268
+ const shadowedBy = [];
269
+ for (const candidate of everyOnPath(CLI_COMMAND, pathValue)) {
270
+ if (realpathOrNull(candidate) === ours) return {
271
+ command: candidate,
272
+ entry: ours,
273
+ shadowedBy
274
+ };
275
+ shadowedBy.push(candidate);
276
+ }
277
+ return {
278
+ command: null,
279
+ entry: ours,
280
+ shadowedBy
281
+ };
282
+ }
283
+ /**
284
+ * Whether a registered command starts this install. An absolute command by its real path; a bare
285
+ * word the way the agent will find it, as the first on PATH.
286
+ */
287
+ function startsThisInstall(command, entry, pathValue) {
288
+ if (!command) return false;
289
+ const ours = realpathOrNull(entry) ?? entry;
290
+ const path = isAbsolute(command) ? command : command.includes("/") ? null : everyOnPath(command, pathValue)[0];
291
+ return path ? realpathOrNull(path) === ours : false;
292
+ }
293
+ //#endregion
294
+ //#region ../../packages/review-harness/src/workspace/integrations_view.ts
295
+ /**
296
+ * Settings → AI integrations: one row per coding agent that can start PR Review Buddy's MCP server,
297
+ * with what its config holds now and what can be done about it.
298
+ *
299
+ * Rendered by the server like the rest of Settings, with its real state in it. After an action the
300
+ * client fetches this page again and swaps in the row it changed (`client/integrations.ts`), so a
301
+ * row is only ever drawn here.
302
+ *
303
+ * Nothing in this file names an agent or its command. The rows, the config files, the commands a
304
+ * person may run by hand and the variable that selects a profile all arrive in the report, from
305
+ * each adapter's own `mcpRegistration`.
306
+ */
307
+ var INTEGRATIONS_ANCHOR = "ai-integrations";
308
+ var STATE_LABEL = {
309
+ "not-installed": "Not installed",
310
+ "not-connected": "Not connected",
311
+ connected: "Connected",
312
+ "connected-elsewhere": "Uses another copy",
313
+ disabled: "Turned off",
314
+ unknown: "Could not check"
315
+ };
316
+ /** A path as a person would type it, with their home as `~`. */
317
+ function homeRelative(path, home) {
318
+ return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
319
+ }
320
+ function renderIntegrationsSection(report, home) {
321
+ return `<div class="prb-page-panel" id="${INTEGRATIONS_ANCHOR}"><section class="prb-settings-section prb-integrations" data-integrations>
322
+ <h2>AI integrations</h2>
323
+ <p class="prb-settings-help">Use PR Review Buddy from your coding agent. Once connected, ${agentNames(report)}
324
+ can start reviews, read their issues and questions, request updates and ask Buddy AI about a
325
+ change, without opening the browser. These buttons act at once; Save changes is not needed.</p>
326
+ ${report ? renderBody$1(report, home) : `<p class="prb-settings-help" role="alert">The coding agents on this machine could not be checked just now. Reload this page to try again.</p>`}
327
+ <details class="prb-integrations-scope">
328
+ <summary>What can my coding agent do?</summary>
329
+ <p class="prb-settings-help">Start a review of a branch, a pull request or a commit; read a
330
+ review's issues and questions; request an update after new commits; and ask Buddy AI about the
331
+ change. It cannot delete reviews, dismiss or resolve findings, approve or comment on a pull
332
+ request, or change these settings. A review PR Review Buddy itself runs can read reviews but
333
+ never start one, so an agent cannot set off a chain of reviews.</p>
334
+ <p class="prb-settings-help"><a href="/help#coding-agents">How AI integrations work</a></p>
335
+ </details>
336
+ </section></div>
337
+ ${renderIntegrationDialog()}`;
338
+ }
339
+ /** "Claude Code or Codex", from the rows themselves. */
340
+ function agentNames(report) {
341
+ const names = (report?.integrations ?? []).map((status) => escapeHtml$1(status.displayName));
342
+ if (names.length === 0) return "your coding agent";
343
+ return names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
344
+ }
345
+ function renderBody$1(report, home) {
346
+ const { executable } = report;
347
+ const notes = [];
348
+ if (!executable.command) notes.push(`<p class="prb-settings-help" role="alert">No <code>${CLI_COMMAND}</code> command on your PATH starts this copy of PR Review Buddy
349
+ (<code>${escapeHtml$1(homeRelative(executable.entry, home))}</code>), so it cannot be connected from here. Install it with
350
+ <code>npm install -g ${CLI_COMMAND}</code>, then reload this page. The command to connect by hand names this copy's own
351
+ file, which moves when it is upgraded.</p>`);
352
+ else if (executable.shadowedBy.length > 0) notes.push(`<p class="prb-settings-help">A plain <code>${CLI_COMMAND}</code> in your shell starts a different copy
353
+ (<code>${escapeHtml$1(homeRelative(executable.shadowedBy[0], home))}</code>). AI integrations use
354
+ <code>${escapeHtml$1(homeRelative(executable.command, home))}</code>, which is this one.</p>`);
355
+ const rows = report.integrations.map((status) => renderIntegrationRow(status, report, home)).join("\n");
356
+ return `${notes.join("\n")}
357
+ <ul class="prb-integrations-list">
358
+ ${rows}
359
+ </ul>
360
+ <p class="prb-settings-help">A change reaches new agent sessions only. Sessions already open keep the tools they started with.</p>`;
361
+ }
362
+ function renderIntegrationRow(status, report, home) {
363
+ const name = escapeHtml$1(status.displayName);
364
+ const stable = report.executable.command !== null;
365
+ const config = escapeHtml$1(homeRelative(status.configPath, home));
366
+ const button = (action, label, primary = false) => `<button type="button" class="${primary ? "prb-integration-primary" : "prb-integration-button"}" data-integration-action="${action}">${label}</button>`;
367
+ const copy = `<button type="button" class="prb-integration-button" data-integration-copy>Copy command</button>`;
368
+ const actions = [];
369
+ let detail = "";
370
+ switch (status.state) {
371
+ case "not-installed":
372
+ detail = `${name} is not installed on this machine. Install it, then reload this page to connect it.`;
373
+ actions.push(copy);
374
+ break;
375
+ case "not-connected":
376
+ if (stable) actions.push(button("connect", `Connect to ${name}`, true));
377
+ actions.push(copy);
378
+ break;
379
+ case "connected":
380
+ actions.push(button("verify", "Verify connection", true), button("disconnect", "Disconnect"));
381
+ break;
382
+ case "connected-elsewhere":
383
+ detail = `${name} starts <code>${escapeHtml$1(homeRelative(status.registered?.command ?? "", home))}${status.registered?.args.length ? ` ${escapeHtml$1(status.registered.args.join(" "))}` : ""}</code>, which is not this copy of PR Review Buddy.`;
384
+ if (stable) actions.push(button("replace", "Replace…", true));
385
+ actions.push(button("verify", "Verify connection"), button("disconnect", "Disconnect"));
386
+ break;
387
+ case "disabled":
388
+ detail = `PR Review Buddy is registered but turned off in <code>${config}</code>, so ${name} does not start it.
389
+ Turn it on in that file, or disconnect it and connect again.`;
390
+ actions.push(button("verify", "Verify connection"), button("disconnect", "Disconnect"));
391
+ break;
392
+ case "unknown":
393
+ detail = escapeHtml$1(status.reason ?? "Its config could not be read.");
394
+ actions.push(copy);
395
+ }
396
+ const registered = status.registered ? `${status.registered.command} ${status.registered.args.join(" ")}`.trim() : "";
397
+ return ` <li class="prb-integration" data-integration="${escapeHtml$1(status.agent)}" data-state="${status.state}"
398
+ data-name="${name}" data-config="${config}" data-add-command="${escapeHtml$1(status.addCommand)}"
399
+ data-remove-command="${escapeHtml$1(status.removeCommand)}" data-registered="${escapeHtml$1(registered)}">
400
+ <div class="prb-integration-head">
401
+ <span class="prb-integration-name">${name}</span>
402
+ <span class="prb-integration-state" data-state="${status.state}">${STATE_LABEL[status.state]}</span>
403
+ </div>
404
+ <p class="prb-settings-help">${status.profileVariable ? `Default ${name} config, ` : `${name} config, `}<code>${config}</code></p>
405
+ ${detail ? `<p class="prb-settings-help">${detail}</p>` : ""}
406
+ <div class="prb-integration-actions">${actions.join("")}</div>
407
+ <p class="prb-integration-result" data-integration-result role="status" aria-live="polite"></p>
408
+ <details class="prb-integration-manual">
409
+ <summary>Connect by hand</summary>
410
+ <pre><code>${escapeHtml$1(status.addCommand)}</code></pre>
411
+ <p class="prb-settings-help">To disconnect: <code>${escapeHtml$1(status.removeCommand)}</code></p>
412
+ </details>
413
+ ${status.profileVariable ? renderProfile(status) : ""}
414
+ </li>`;
415
+ }
416
+ /**
417
+ * For an agent with profiles. The row above manages the default one only, by decision: guessing at
418
+ * which directories are profiles would be inventing a convention some people do not follow.
419
+ */
420
+ function renderProfile(status) {
421
+ const variable = escapeHtml$1(status.profileVariable ?? "");
422
+ const name = escapeHtml$1(status.displayName);
423
+ return `<details class="prb-integration-manual" data-integration-profile data-profile-variable="${variable}"
424
+ data-profile-add-command="${escapeHtml$1(status.profileAddCommand ?? "")}">
425
+ <summary>Using a different ${name} profile?</summary>
426
+ <p class="prb-settings-help">The status above is for the default config only. A profile kept in its
427
+ own directory (selected with <code>${variable}</code>) has its own list of servers. Enter that directory
428
+ to get the command that connects it.</p>
429
+ <label class="prb-field prb-settings-field">
430
+ <span>Profile directory</span>
431
+ <input type="text" data-profile-dir autocomplete="off" spellcheck="false">
432
+ </label>
433
+ <pre hidden data-profile-output><code data-profile-command></code></pre>
434
+ <button type="button" class="prb-integration-button" data-profile-copy hidden>Copy command</button>
435
+ </details>`;
436
+ }
437
+ /** One confirmation for every action that changes an agent's config, filled in by the client. */
438
+ function renderIntegrationDialog() {
439
+ return `<dialog class="prb-dialog prb-integration-dialog" data-integration-dialog aria-labelledby="prb-integration-dialog-title">
440
+ <h2 id="prb-integration-dialog-title" data-integration-dialog-title></h2>
441
+ <p data-integration-dialog-body></p>
442
+ <pre><code data-integration-dialog-command></code></pre>
443
+ <p class="prb-dialog-reassure" data-integration-dialog-note></p>
444
+ <div class="prb-dialog-actions">
445
+ <button type="button" data-integration-cancel>Cancel</button>
446
+ <button type="button" class="prb-dialog-primary" data-integration-confirm></button>
447
+ </div>
448
+ </dialog>`;
449
+ }
450
+ /** The key "Not now" stores in `dismissedNotices`. One notice, ever: it names no fact that can change. */
451
+ var INTEGRATIONS_NOTICE_KEY = "integrations:offer";
452
+ /**
453
+ * Whether a review page should mention AI integrations, and which agents to name if so.
454
+ *
455
+ * Only once the guided tour has been answered, so it never competes with the tour; it reaches
456
+ * somebody who answered the tour long ago just the same. Only on a finished review, where reading
457
+ * it from an agent is something they could do next. Only when every agent's state is known, at
458
+ * least one is installed and none is registered at all: an integration nobody here can use is not
459
+ * worth interrupting a review for, and one already set up needs no advert. Returns null for no.
460
+ */
461
+ function integrationsNoticeFor(input) {
462
+ if (!input.live || !input.hasResult || !input.tourAnswered) return null;
463
+ if (input.dismissed?.includes("integrations:offer")) return null;
464
+ const rows = input.report?.integrations ?? [];
465
+ if (rows.length === 0 || rows.some((row) => row.state === "unknown")) return null;
466
+ if (rows.some((row) => row.state === "connected" || row.state === "connected-elsewhere" || row.state === "disabled")) return null;
467
+ const installed = rows.filter((row) => row.state === "not-connected");
468
+ return installed.length > 0 ? installed.map((row) => row.displayName) : null;
469
+ }
470
+ function renderIntegrationsNotice(names) {
471
+ return `<div class="prb-job prb-integrations-notice" role="status" data-notice>
472
+ <p><strong>Use PR Review Buddy from ${names.map(escapeHtml$1).join(" or ")}.</strong> Your coding agent can read this review and
473
+ investigate its findings without opening the browser.</p>
474
+ <a class="prb-integrations-notice-link" href="/settings#${INTEGRATIONS_ANCHOR}" data-notice-dismiss="${INTEGRATIONS_NOTICE_KEY}">Set up integration</a>
475
+ <button type="button" class="prb-fresh-dismiss" data-notice-dismiss="${INTEGRATIONS_NOTICE_KEY}">Not now</button>
476
+ </div>`;
477
+ }
478
+ //#endregion
231
479
  //#region ../../packages/review-harness/src/workspace/reviews_index.ts
232
480
  /**
233
481
  * The sections of the list, top to bottom.
@@ -395,16 +643,6 @@ function ordersExplicitly(filter) {
395
643
  return filter === "queue";
396
644
  }
397
645
  /**
398
- * The lifecycle chip on a card, or absent.
399
- *
400
- * In a lifecycle section the heading already names the lifecycle, so the chip only says what moved
401
- * (`lifecycleChip`). In Pinned nothing names it, so the chip does, in the words the review page's
402
- * pill uses: the list and the page give one answer about the same review.
403
- */
404
- function cardLifecycleChip(pinned, lifecycle, activity) {
405
- return pinned ? lifecyclePill(lifecycle, activity)?.text : lifecycleChip(lifecycle, activity);
406
- }
407
- /**
408
646
  * "45 reviews · 13 branches", and "13 reviews" when those are the same thirteen things.
409
647
  *
410
648
  * The two numbers are different questions and only both worth asking once they differ. A page of
@@ -497,6 +735,13 @@ function statusLabel(status) {
497
735
  return status === "in-progress" ? "Not reviewed" : "New";
498
736
  }
499
737
  /**
738
+ * The status pill's accessible name: where the reviewer is, then what pressing it does. The visible
739
+ * words are the state, so a screen reader hears both rather than only a state it cannot act on.
740
+ */
741
+ function statusPillLabel(status) {
742
+ return `${statusLabel(status)}. Mark as ${status === "complete" ? "not reviewed" : "reviewed"}`;
743
+ }
744
+ /**
500
745
  * The bulk bar's way to tick everything the filters show, across pages.
501
746
  *
502
747
  * Counted ("Select all 7") only while the headline is not already saying "of 7", so the number is
@@ -758,6 +1003,47 @@ function renderFeedbackDraftBody() {
758
1003
  </div>`;
759
1004
  }
760
1005
  //#endregion
1006
+ //#region ../../packages/review-harness/src/workspace/state_pills.ts
1007
+ /**
1008
+ * The two pills that say where a review stands, and change it: status (what the reviewer has done)
1009
+ * and lifecycle (what happens next). Drawn on every Reviews card and in the review page's header,
1010
+ * from this one module, so the two places cannot disagree in words, shape or behaviour.
1011
+ *
1012
+ * Pure, with no node builtins: the server renders the markup and both pages' scripts repaint it
1013
+ * from an answer, through `paintStatusPill` and `paintLifecyclePill`.
1014
+ *
1015
+ * The pills are shortcuts for the `…` menu's items, never a second set of rules. The status pill is
1016
+ * the menu's Mark reviewed / Mark not reviewed. The lifecycle pill is Mark done on Active and Move
1017
+ * to active on Done. On Waiting it opens the lifecycle items the menu would offer (`offersLifecycle`),
1018
+ * because Waiting has more than one way out and a toggle would have to guess which.
1019
+ *
1020
+ * Every lifecycle draws a pill, Active included. The pill names the stored state only; why a parked
1021
+ * card is back in Active is the change chip's job (`lifecycleChip`), a separate fact.
1022
+ */
1023
+ /** The status pill as a button. `attributes` carries whatever the page needs to find it by. */
1024
+ function statusPillHtml(status, attributes = "") {
1025
+ return `<button type="button" class="prb-card-status prb-state-pill" data-status="${status}"${attributes} aria-label="${escapeHtml$1(statusPillLabel(status))}">${statusLabel(status)}</button>`;
1026
+ }
1027
+ /**
1028
+ * The lifecycle pill. Interactive, it is a button inside a `[data-menu]`, which the shared menus
1029
+ * open only while the pill is a trigger (Waiting); otherwise the page acts on the press directly.
1030
+ * The panel ships with all three items so a repaint never builds markup. Not interactive (an older
1031
+ * review in a lineage, where lifecycle is not offered), it is a span that only says.
1032
+ */
1033
+ function lifecycleControlHtml(lifecycle, activity, options) {
1034
+ const state = lifecycle?.state ?? "active";
1035
+ const name = escapeHtml$1(lifecycleName$1(lifecycle));
1036
+ const attributes = options.attributes ?? "";
1037
+ if (!options.interactive) return `<span class="prb-state-pill prb-lifecycle-pill" data-lifecycle-pill="${state}"${attributes}>${name}</span>`;
1038
+ const action = lifecyclePillAction(lifecycle);
1039
+ const trigger = action === "menu" ? " data-menu-trigger aria-haspopup=\"true\" aria-expanded=\"false\"" : "";
1040
+ const items = LIFECYCLES.map(({ id }) => `<button type="button" class="prb-menu-item" data-lifecycle-set="${id}"${offersLifecycle(id, lifecycle, activity) ? "" : " hidden"}>${escapeHtml$1(lifecycleItemLabel(id, lifecycle, activity))}</button>`).join("");
1041
+ return `<div class="prb-menu prb-pill-menu" data-menu data-lifecycle-control>
1042
+ <button type="button" class="prb-state-pill prb-lifecycle-pill" data-lifecycle-pill="${state}" data-lifecycle-press="${action}"${attributes}${trigger} aria-label="${escapeHtml$1(lifecyclePillLabel(lifecycle))}">${name}</button>
1043
+ <div class="prb-menu-panel" data-menu-panel hidden>${items}</div>
1044
+ </div>`;
1045
+ }
1046
+ //#endregion
761
1047
  //#region ../../packages/review-harness/src/workspace/way_back.ts
762
1048
  var CLI = {
763
1049
  command: "prreviewbuddy open",
@@ -1352,63 +1638,6 @@ function renderFindingExplanation(explanation, options = {}) {
1352
1638
  </details>`;
1353
1639
  }
1354
1640
  //#endregion
1355
- //#region ../../packages/review-harness/src/domain/ai_review/finding_ref.ts
1356
- var PREFIX = {
1357
- issue: "I",
1358
- question: "Q"
1359
- };
1360
- function findingRef(kind, index) {
1361
- return `${PREFIX[kind]}${index + 1}`;
1362
- }
1363
- /** Every finding's reference by its id, for a renderer that meets the findings out of order. */
1364
- function findingRefs(result) {
1365
- const refs = /* @__PURE__ */ new Map();
1366
- result.issues.forEach((issue, index) => refs.set(`issue:${issue.id}`, findingRef("issue", index)));
1367
- result.questions.forEach((question, index) => refs.set(`question:${question.id}`, findingRef("question", index)));
1368
- return refs;
1369
- }
1370
- /** The reference of one finding, or undefined when the analysis does not hold it. */
1371
- function refOf(refs, kind, id) {
1372
- return refs.get(`${kind}:${id}`);
1373
- }
1374
- /**
1375
- * The finding a reference names, or null. Only the exact form (`I3`, `q2` is accepted as `Q2`):
1376
- * a reference out of range names nothing rather than the nearest finding.
1377
- */
1378
- function findingAtRef(result, ref) {
1379
- const match = /^([IQ])([1-9]\d*)$/i.exec(ref.trim());
1380
- if (!match) return null;
1381
- const index = Number(match[2]) - 1;
1382
- if (match[1].toUpperCase() === "I") {
1383
- const finding = result.issues[index];
1384
- return finding === void 0 ? null : {
1385
- kind: "issue",
1386
- index,
1387
- finding
1388
- };
1389
- }
1390
- const finding = result.questions[index];
1391
- return finding === void 0 ? null : {
1392
- kind: "question",
1393
- index,
1394
- finding
1395
- };
1396
- }
1397
- /**
1398
- * The review a set of references belongs to: `PRB-` and the first eight characters of the analysis's
1399
- * session id. An update keeps the session, and so its references; a fresh analysis or a reanalysis
1400
- * starts a new session and numbers its findings again, so its tag differs. A feedback draft carries
1401
- * it, and Reconcile reads it back, so a reply to an older draft is never read against findings that
1402
- * a later analysis happens to number the same way.
1403
- */
1404
- function reviewTag(sessionId) {
1405
- return `PRB-${sessionId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase()}`;
1406
- }
1407
- /** Every review tag in a text, as `reviewTag` writes them. */
1408
- function reviewTagsIn(text) {
1409
- return [...new Set([...text.matchAll(/\bPRB-([a-z0-9]{8})\b/gi)].map((match) => `PRB-${match[1].toLowerCase()}`))];
1410
- }
1411
- //#endregion
1412
1641
  //#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
1413
1642
  /**
1414
1643
  * What every pill in the sidebar actually means, in one place.
@@ -1879,158 +2108,6 @@ var AUTHOR_CONTEXT_PREVIEW_ID = "pr-helper-author-context-preview";
1879
2108
  var SHOW_FULL_DESCRIPTION = "View full PR description";
1880
2109
  var AUTHOR_CONTEXT_SECTION_ID = "pr-helper-author-context";
1881
2110
  //#endregion
1882
- //#region ../../packages/review-harness/src/domain/utils/model_label.ts
1883
- var KNOWN_BRANDS = {
1884
- gpt: "GPT",
1885
- claude: "Claude"
1886
- };
1887
- var isNumericToken = (token) => /^\d+(\.\d+)?$/.test(token);
1888
- /**
1889
- * Tokens that are initialisms rather than words, so title case is wrong for them.
1890
- *
1891
- * Needed once agent ids reached this function: `gemini-cli` came out as "Gemini Cli", which is a
1892
- * misspelling of the product's name on a line that exists to say which product made the review.
1893
- * Kept to genuine initialisms, because this transform's whole value is that it knows nothing about
1894
- * which models exist and so never goes stale.
1895
- */
1896
- var INITIALISMS = /* @__PURE__ */ new Set([
1897
- "cli",
1898
- "api",
1899
- "sdk"
1900
- ]);
1901
- function titleCase$1(token) {
1902
- if (INITIALISMS.has(token.toLowerCase())) return token.toUpperCase();
1903
- return token.charAt(0).toUpperCase() + token.slice(1);
1904
- }
1905
- function humanizeModelId(id) {
1906
- const tokens = id.split("-").filter(Boolean);
1907
- if (tokens.length === 0) return id;
1908
- const [brandToken, ...rest] = tokens;
1909
- let display = KNOWN_BRANDS[brandToken.toLowerCase()] ?? titleCase$1(brandToken);
1910
- let prevWasNumeric = false;
1911
- rest.forEach((token, index) => {
1912
- const numeric = isNumericToken(token);
1913
- if (numeric && prevWasNumeric) display += `.${token}`;
1914
- else if (numeric && index === 0) display += `-${token}`;
1915
- else display += ` ${numeric ? token : titleCase$1(token)}`;
1916
- prevWasNumeric = numeric;
1917
- });
1918
- return display;
1919
- }
1920
- //#endregion
1921
- //#region ../../packages/review-harness/src/domain/utils/agent_label.ts
1922
- /**
1923
- * What a person is shown when a review names the tool that produced it.
1924
- *
1925
- * Here in the domain rather than read off `AgentIdentity.displayName`, because this runs in the
1926
- * extension bundle and the adapter registry reaches `node:child_process`. Deriving the label from
1927
- * the stored `agentId` keeps the client free of node builtins and adds no stored field.
1928
- *
1929
- * A closed set of this product's own adapter ids, which is not the kind of table that goes stale
1930
- * invisibly: adding an adapter is our own act, and anything unrecognised still renders through
1931
- * `humanizeModelId` rather than disappearing. That is why this is not folded into
1932
- * `humanizeModelId` itself, whose value is knowing nothing about which names exist.
1933
- */
1934
- var AGENT_DISPLAY_NAMES = {
1935
- claude: "Claude Code",
1936
- codex: "Codex",
1937
- gemini: "Gemini CLI"
1938
- };
1939
- /**
1940
- * Absent reads as Claude, matching `agentIdOf` and `agentName`: a session written before agents
1941
- * were selectable ran under Claude, and showing it as unknown would invent a gap in the oldest
1942
- * reviews on disk.
1943
- */
1944
- var DEFAULT_AGENT_DISPLAY_NAME = AGENT_DISPLAY_NAMES.claude;
1945
- function agentDisplayName(agentId) {
1946
- if (!agentId) return DEFAULT_AGENT_DISPLAY_NAME;
1947
- return AGENT_DISPLAY_NAMES[agentId] ?? humanizeModelId(agentId);
1948
- }
1949
- //#endregion
1950
- //#region ../../packages/review-harness/src/domain/ai_review/model_provenance.ts
1951
- /**
1952
- * Values `model` carried when it meant "which agent", before the field was frozen.
1953
- *
1954
- * A closed set of three of our own historical strings, not a catalogue of vendor models, so it
1955
- * never grows and never goes stale. It lives here rather than in `humanizeModelId` on purpose:
1956
- * the corruption belongs to how sessions were once written, and that label function's whole value
1957
- * is knowing nothing about which models exist.
1958
- */
1959
- var LEGACY_AGENT_IDS = /* @__PURE__ */ new Set([
1960
- "codex",
1961
- "claude-code",
1962
- "gemini-cli"
1963
- ]);
1964
- var present = (value) => typeof value === "string" && value !== "" ? value : null;
1965
- function modelProvenanceOf(session) {
1966
- return {
1967
- requestedModel: present(session.requestedModel) ?? legacyModel(session.model),
1968
- observedModel: present(session.observedModel),
1969
- agentVersion: present(session.agentVersion)
1970
- };
1971
- }
1972
- /**
1973
- * A legacy `model`, read as a request or discarded.
1974
- *
1975
- * The extension put the model it was about to call into this field, so for its sessions the value
1976
- * really is what was requested. The CLI put an agent id there, which is not a model at all and is
1977
- * dropped rather than displayed: showing "Reviewed with Codex, model Codex" would be the record
1978
- * inventing provenance out of its own filing.
1979
- */
1980
- function legacyModel(model) {
1981
- const value = present(model);
1982
- if (value === null || LEGACY_AGENT_IDS.has(value)) return null;
1983
- return value;
1984
- }
1985
- /**
1986
- * Whether a coding agent made this, as opposed to the extension calling a provider API.
1987
- *
1988
- * The awkward case is a session with no `agentId` at all, because two different producers leave it
1989
- * empty. A CLI review from before agents were selectable means Claude, which is why
1990
- * `agentDisplayName` defaults that way. An extension review means no agent ran at all, and naming
1991
- * one would credit Claude Code for somebody's OpenAI review.
1992
- *
1993
- * The legacy sentinel tells them apart, which is the last job that field has: only the CLI ever
1994
- * wrote one, so a record with no agent id and no sentinel came from the extension.
1995
- */
1996
- function producedByAgent(session) {
1997
- if (session.agentId) return true;
1998
- const legacy = present(session.model);
1999
- return legacy !== null && LEGACY_AGENT_IDS.has(legacy);
2000
- }
2001
- /**
2002
- * The full provenance, for the disclosure rather than the page.
2003
- *
2004
- * Opens with "Reviewed with X" rather than "Agent: X", keeping the sentence that was already here:
2005
- * a review is not owned by an agent, it holds analyses, and this names the one being shown.
2006
- *
2007
- * The line that earns this list its place is the last one: a pinned model that nobody confirmed.
2008
- * `--model X` states what we asked for, and on an agent that reports nothing back there is no way
2009
- * to know it was honoured, so the pin is shown alongside the fact that it is unverified. Without
2010
- * that line a reader would take a requested model for a measured one, which is the overstatement
2011
- * this whole split exists to prevent.
2012
- *
2013
- * Said only when something was pinned. On an unpinned run there is no claim outstanding, and a
2014
- * standing "not reported" would be noise on every review anybody ever runs.
2015
- *
2016
- * Both sentences that name an agent are withheld from a review no agent produced, through the same
2017
- * `producedByAgent` distinction `attributionLabel` makes. An extension review has no agent id, and
2018
- * naming one anyway credited Claude Code for somebody's OpenAI review and then reported that Claude
2019
- * Code had not confirmed a model nobody had asked it for. What remains for such a review is the
2020
- * model it requested and the model that answered, which is the whole of what is known about it.
2021
- */
2022
- function provenanceDetailLines(session) {
2023
- const agent = producedByAgent(session) ? agentDisplayName(session.agentId) : null;
2024
- const { requestedModel, observedModel, agentVersion } = modelProvenanceOf(session);
2025
- return [
2026
- ...agent ? [`Reviewed with ${agent}`] : [],
2027
- ...agentVersion ? [`Agent version ${agentVersion}`] : [],
2028
- ...requestedModel ? [`Requested model ${humanizeModelId(requestedModel)}`] : [],
2029
- ...observedModel ? [`Reported model ${humanizeModelId(observedModel)}`] : [],
2030
- ...agent && requestedModel && !observedModel ? [`Reported model not reported by ${agent}`] : []
2031
- ];
2032
- }
2033
- //#endregion
2034
2111
  //#region ../../packages/review-harness/src/domain/ai_review/investigation_record.ts
2035
2112
  /**
2036
2113
  * The records for one finding, oldest first. Records accumulate: every "Investigate Again" appends.
@@ -6465,7 +6542,7 @@ ${renderReviewBar(view, freshness)}
6465
6542
  ${showsProgress(view) || updating(view) ? "" : renderJobState(view.job ?? null, {
6466
6543
  dismissed: view.dismissedNotices,
6467
6544
  canDismiss: view.token !== void 0
6468
- })}
6545
+ })}${view.integrationsNotice?.length ? `\n${renderIntegrationsNotice(view.integrationsNotice)}` : ""}
6469
6546
  <div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml$1(JSON.stringify(freshness))}"` : ""}${view.availability ? ` data-availability="${escapeHtml$1(JSON.stringify(view.availability))}"` : ""}${view.remote ? ` data-remote="${escapeHtml$1(JSON.stringify(view.remote))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
6470
6547
  dismissedSha: view.dismissedFreshnessSha,
6471
6548
  actions: view.token !== void 0 && !updating(view),
@@ -6942,20 +7019,30 @@ function renderReviewBar(view, freshness) {
6942
7019
  </div>`;
6943
7020
  }
6944
7021
  /**
6945
- * Where this review stands, as pills that say and never do.
7022
+ * Where this review stands, as two pills that say it and change it.
6946
7023
  *
6947
7024
  * Two, because they answer two questions. Reviewed or Not reviewed is what the reviewer has done;
6948
- * Waiting or Done is what happens next with the branch. A review can be Reviewed and Waiting, so a
6949
- * single pill would have to drop one of them. Active draws nothing, as an open pull request does
6950
- * not: it is where every review starts, and a pill that never varies teaches nothing.
7025
+ * Active, Waiting or Done is what happens next with the branch. A review can be Reviewed and
7026
+ * Waiting, so a single pill would have to drop one of them. Each is a shortcut for its `…` items
7027
+ * (see `state_pills.ts`); the lifecycle pill acts only where those items are offered, on the newest
7028
+ * review of the branch, and on an older one only says.
7029
+ *
7030
+ * Why a parked review has moved is its own chip beside them, the card's chip, never the pill's text.
6951
7031
  *
6952
7032
  * Never "New" here, because being on this page is what ends it.
6953
7033
  */
6954
7034
  function renderStatePills(view) {
6955
7035
  const status = view.complete ? "complete" : "in-progress";
6956
- const pill = lifecyclePill(view.lifecycle, view.activity);
6957
- return `<span class="prb-card-status" data-status="${status}" data-page-status>${statusLabel(status)}</span>
6958
- <span class="prb-card-status prb-page-lifecycle" data-page-lifecycle${pill?.changed ? " data-changed" : ""}${pill ? "" : " hidden"}>${escapeHtml$1(pill?.text ?? "")}</span>`;
7036
+ const latest = !view.lineage || view.lineage.position >= view.lineage.total;
7037
+ const chip = lifecycleChip(view.lifecycle, view.activity);
7038
+ return `<span class="prb-state-pills">
7039
+ ${statusPillHtml(status, " data-page-status")}
7040
+ ${lifecycleControlHtml(view.lifecycle, view.activity, {
7041
+ interactive: latest,
7042
+ attributes: " data-page-lifecycle"
7043
+ })}
7044
+ <span class="prb-card-lifecycle" data-page-lifecycle-chip${chip === void 0 ? " hidden" : ""}>${escapeHtml$1(chip ?? "")}</span>
7045
+ </span>`;
6959
7046
  }
6960
7047
  /**
6961
7048
  * Everything that can be done to this review, from the page you are reading it on.
@@ -10438,7 +10525,7 @@ function renderOperation(operation, review, positions, prStates, now, collection
10438
10525
  const branch = review ? followedRefName(review.branch, review.headResolvedRef) ?? review.branch : row?.branch;
10439
10526
  const prState = prNumber !== void 0 && repoPath ? prStates?.get(requestKey(repoPath, prNumber)) : void 0;
10440
10527
  const queued = own ? positions.get(own.id) : void 0;
10441
- const status = own ? statusOf(own) : "new";
10528
+ const status = own ? statusOf$1(own) : "new";
10442
10529
  const search = [
10443
10530
  review ? searchText(review) : "",
10444
10531
  row?.label ?? "",
@@ -10535,7 +10622,10 @@ function renderBulkBar() {
10535
10622
  * this branch as far as the product knows; the earlier ones are history and are folded away until
10536
10623
  * asked for.
10537
10624
  *
10538
- * The checkbox sits outside the link, like the pin and the menu and for the same reason: it is a
10625
+ * The two pills, the menu and the pin are one row of controls over the card's top-right corner,
10626
+ * outside its link and after it, so a reader meets the title first and then what can be done to it:
10627
+ * where the reviewer is, what happens next, then the menu and the star. Outside because a button inside a link is not a thing HTML has, and a press on any of
10628
+ * them must never open the review. The checkbox sits outside the link for the same reason: it is a
10539
10629
  * control on the row rather than a way into the review. It is named by its branch, because fifteen
10540
10630
  * boxes announcing themselves as "Select" are fifteen identical announcements. It ships on every
10541
10631
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
@@ -10551,18 +10641,15 @@ function renderCard(group, now, activity, rank, analysis, queued, moved, prState
10551
10641
  const placement = instance.placement === "home" ? "home" : `collection:${instance.placement.collection}`;
10552
10642
  const line = collectionsLine(instance.memberOf, instance.refs, instance.placement === "home" ? void 0 : instance.placement.collection);
10553
10643
  const hidden = inProgressOnly || instance.hidden;
10554
- const status = statusOf(review);
10555
- const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
10644
+ const status = statusOf$1(review);
10645
+ const chip = lifecycleChip(review.lifecycle, activity);
10556
10646
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
10557
10647
  const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
10558
10648
  return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""}${activityAttributes(activity)} data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}" data-placement="${escapeHtml$1(placement)}" data-collections="${escapeHtml$1(instance.memberOf.join(" "))}"${inProgressOnly ? " data-in-progress-only" : ""}${hidden ? " hidden" : ""}>
10559
10649
  <input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
10560
- <button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review">${review.pinned ? "&#9733;" : "&#9734;"}</button>
10561
- ${renderCardMenu(group, followed, queued !== void 0, status, activity)}
10562
10650
  <a class="prb-card-link" href="${escapeHtml$1(href)}">
10563
10651
  <div class="prb-card-head">
10564
10652
  <h2>${escapeHtml$1(review.title)}</h2>
10565
- ${renderStatus(status)}
10566
10653
  ${renderPrState(prState)}
10567
10654
  ${`<span class="prb-card-lifecycle" data-lifecycle-chip${chip === void 0 ? " hidden" : ""}>${escapeHtml$1(chip ?? "")}</span>`}
10568
10655
  </div>
@@ -10579,12 +10666,18 @@ function renderCard(group, now, activity, rank, analysis, queued, moved, prState
10579
10666
  ${moved ? `<span class="prb-card-moved">${moved === "branch" ? "Branch updated" : "PR updated"}</span>` : ""}
10580
10667
  <span>${review.changedFiles} file${review.changedFiles === 1 ? "" : "s"}</span>
10581
10668
  <span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">&minus;${review.deletions}</span></span>
10582
- ${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
10669
+ ${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${renderLeft(whatIsLeft(review))}</span>`}
10583
10670
  ${renderAnalysisState(analysis)}
10584
10671
  <span class="prb-card-collections" data-collections-line${line === void 0 ? " hidden" : ""}>${escapeHtml$1(line ?? "")}</span>
10585
10672
  <span class="prb-card-when"><time datetime="${new Date(activity.at).toISOString()}" title="${escapeHtml$1(new Date(activity.at).toLocaleString())}">${escapeHtml$1(`${activityLabel(activity.kind)} ${relativeTime(activity.at, now)}`)}</time></span>
10586
10673
  </p>
10587
10674
  </a>
10675
+ <div class="prb-card-controls">
10676
+ ${statusPillHtml(status)}
10677
+ ${lifecycleControlHtml(review.lifecycle, activity, { interactive: true })}
10678
+ ${renderCardMenu(group, followed, queued !== void 0, status, activity)}
10679
+ <button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review">${review.pinned ? "&#9733;" : "&#9734;"}</button>
10680
+ </div>
10588
10681
  ${renderHistory(group, now)}
10589
10682
  </article>`;
10590
10683
  }
@@ -10735,7 +10828,7 @@ function renderHistoryRow(review, number, latest, now) {
10735
10828
  <span class="prb-card-history-name">Review ${number} ${latest ? "<span class=\"prb-card-history-latest\">Latest</span>" : "<span class=\"prb-card-history-previous\">Previous</span>"}</span>
10736
10829
  ${review.commitSha ? `<code class="prb-card-history-sha" title="${escapeHtml$1(review.commitSha)}">${escapeHtml$1(review.commitSha.slice(0, 7))}</code>` : ""}
10737
10830
  <span class="prb-card-history-when">${escapeHtml$1(relativeTime(review.createdAt, now))}</span>
10738
- ${latest ? renderStatus(statusOf(review)) : ""}
10831
+ ${latest ? renderStatus(statusOf$1(review)) : ""}
10739
10832
  </a>
10740
10833
  </li>`;
10741
10834
  }
@@ -10770,14 +10863,30 @@ function renderAnalysisState(analysis) {
10770
10863
  * product does not track file-by-file review, so the card does not pretend it does.
10771
10864
  */
10772
10865
  function whatIsLeft(review) {
10773
- if (!review.hasStandardReview) return "No standard review yet";
10866
+ if (!review.hasStandardReview) return [{ text: "No standard review yet" }];
10774
10867
  const parts = [];
10775
- if (review.openIssues > 0) parts.push(`${review.openIssues} issue${review.openIssues === 1 ? "" : "s"}`);
10776
- else if (review.issues > 0) parts.push("all issues resolved");
10777
- if (review.unansweredQuestions > 0) parts.push(`${review.unansweredQuestions} unanswered question${review.unansweredQuestions === 1 ? "" : "s"}`);
10778
- else if (review.questions > 0) parts.push("all questions answered");
10779
- if (review.messages > 0) parts.push(`${review.messages} Buddy AI message${review.messages === 1 ? "" : "s"}`);
10780
- return parts.length > 0 ? parts.join(" · ") : "Nothing flagged";
10868
+ if (review.openIssues > 0) parts.push({
10869
+ text: `${review.openIssues} issue${review.openIssues === 1 ? "" : "s"}`,
10870
+ tone: "attention"
10871
+ });
10872
+ else if (review.issues > 0) parts.push({
10873
+ text: "Issues resolved",
10874
+ tone: "success"
10875
+ });
10876
+ if (review.unansweredQuestions > 0) parts.push({
10877
+ text: `${review.unansweredQuestions} unanswered question${review.unansweredQuestions === 1 ? "" : "s"}`,
10878
+ tone: "attention"
10879
+ });
10880
+ else if (review.questions > 0) parts.push({
10881
+ text: "Questions answered",
10882
+ tone: "success"
10883
+ });
10884
+ if (review.messages > 0) parts.push({ text: `${review.messages} Buddy AI message${review.messages === 1 ? "" : "s"}` });
10885
+ return parts.length > 0 ? parts : [{ text: "Nothing flagged" }];
10886
+ }
10887
+ /** The parts as the meta line draws them: a badge where there is a tone, plain words otherwise. */
10888
+ function renderLeft(parts) {
10889
+ return parts.map((part) => part.tone === void 0 ? `<span>${escapeHtml$1(part.text)}</span>` : `<span class="prb-card-badge" data-tone="${part.tone}">${part.tone === "success" ? "<span aria-hidden=\"true\">✓</span> " : ""}${escapeHtml$1(part.text)}</span>`).join("");
10781
10890
  }
10782
10891
  /**
10783
10892
  * Three states, and only one of them is inferred from anything.
@@ -10786,7 +10895,7 @@ function whatIsLeft(review) {
10786
10895
  * like completion — questions ticked, issues read — means the checklist is empty, which is not
10787
10896
  * the same as being done; a reviewer can still be reading code with nothing left to tick.
10788
10897
  */
10789
- function statusOf(review) {
10898
+ function statusOf$1(review) {
10790
10899
  if (review.completedAt) return "complete";
10791
10900
  return review.lastViewedAt ? "in-progress" : "new";
10792
10901
  }
@@ -11366,6 +11475,8 @@ ${renderWorkspaceRail({
11366
11475
  Run <code>prreviewbuddy config path</code> to be told where that file is. Appearance is kept with
11367
11476
  the workspace's other display choices in <code>~/.prreviewbuddy/ui.json</code>.</p>
11368
11477
 
11478
+ ${view.integrations === void 0 ? "" : renderIntegrationsSection(view.integrations, view.home)}
11479
+
11369
11480
  ${renderServerSection()}
11370
11481
  </main>
11371
11482
  </div>
@@ -11479,6 +11590,486 @@ function renderPrivacySection(settings) {
11479
11590
  </section>`;
11480
11591
  }
11481
11592
  //#endregion
11593
+ //#region ../../packages/review-harness/src/integrations/handshake.ts
11594
+ /**
11595
+ * Asking a registered PR Review Buddy command to answer, before it is registered and when a person
11596
+ * asks whether a registration works.
11597
+ *
11598
+ * The one place outside the four spawners that starts a process, and it only ever starts PR Review
11599
+ * Buddy itself: the command an agent's config names, or the one about to be written there. Never a
11600
+ * shell, and never anything the browser named: every command reaching here was read from an
11601
+ * agent's config or chosen by `chooseExecutable`.
11602
+ */
11603
+ var VERSION_TIMEOUT_MS = 1e4;
11604
+ var HANDSHAKE_TIMEOUT_MS = 1e4;
11605
+ /** The version `command --version` prints, or null when it does not answer with one. */
11606
+ function commandVersion(command, env) {
11607
+ return new Promise((resolvePromise) => {
11608
+ execFile(command, ["--version"], {
11609
+ env,
11610
+ encoding: "utf8",
11611
+ timeout: VERSION_TIMEOUT_MS
11612
+ }, (error, stdout) => {
11613
+ resolvePromise(error ? null : versionFrom(stdout) ?? null);
11614
+ });
11615
+ });
11616
+ }
11617
+ /** The protocol revision asked for. A server that speaks another one says so in its answer. */
11618
+ var PROTOCOL_VERSION = "2025-06-18";
11619
+ /**
11620
+ * Start the server the way an agent would, say `initialize` and list its tools, then close its
11621
+ * stdin, which is how every MCP client ends a stdio session. Proves the command answers as an MCP
11622
+ * server; it cannot prove an agent has loaded it, which only a new agent session does.
11623
+ */
11624
+ function mcpHandshake(command, args, env, timeoutMs = HANDSHAKE_TIMEOUT_MS) {
11625
+ return new Promise((resolvePromise) => {
11626
+ let settled = false;
11627
+ let buffered = "";
11628
+ let stderr = "";
11629
+ let server = null;
11630
+ const child = spawn(command, [...args], {
11631
+ env,
11632
+ stdio: [
11633
+ "pipe",
11634
+ "pipe",
11635
+ "pipe"
11636
+ ]
11637
+ });
11638
+ const finish = (result) => {
11639
+ if (settled) return;
11640
+ settled = true;
11641
+ clearTimeout(timer);
11642
+ child.stdin.end();
11643
+ setTimeout(() => {
11644
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
11645
+ }, 2e3).unref();
11646
+ resolvePromise(result);
11647
+ };
11648
+ const timer = setTimeout(() => finish({
11649
+ ok: false,
11650
+ reason: `It started but did not answer as an MCP server within ${Math.round(timeoutMs / 1e3)} seconds.`
11651
+ }), timeoutMs);
11652
+ child.on("error", (error) => {
11653
+ const missing = error.code === "ENOENT";
11654
+ finish({
11655
+ ok: false,
11656
+ reason: missing ? `\`${command}\` does not exist.` : `\`${command}\` could not be started: ${error.message}`
11657
+ });
11658
+ });
11659
+ child.stderr.on("data", (chunk) => {
11660
+ stderr += String(chunk);
11661
+ });
11662
+ child.on("close", (code) => {
11663
+ const said = stderr.trim().split("\n").filter(Boolean).pop();
11664
+ finish({
11665
+ ok: false,
11666
+ reason: `It exited with code ${code ?? "none"} before answering${said ? `: ${said}` : "."}`
11667
+ });
11668
+ });
11669
+ child.stdout.on("data", (chunk) => {
11670
+ buffered += String(chunk);
11671
+ let newline;
11672
+ while ((newline = buffered.indexOf("\n")) >= 0) {
11673
+ const line = buffered.slice(0, newline).trim();
11674
+ buffered = buffered.slice(newline + 1);
11675
+ if (!line) continue;
11676
+ let message;
11677
+ try {
11678
+ message = JSON.parse(line);
11679
+ } catch {
11680
+ return finish({
11681
+ ok: false,
11682
+ reason: "It wrote something other than MCP messages to its output."
11683
+ });
11684
+ }
11685
+ if (message.error) return finish({
11686
+ ok: false,
11687
+ reason: `It refused: ${String(message.error.message ?? "no reason given")}`
11688
+ });
11689
+ if (message.id === 1) {
11690
+ const info = message.result?.serverInfo ?? {};
11691
+ server = {
11692
+ name: String(info.name ?? ""),
11693
+ version: String(info.version ?? "")
11694
+ };
11695
+ child.stdin.write(`${JSON.stringify({
11696
+ jsonrpc: "2.0",
11697
+ method: "notifications/initialized"
11698
+ })}\n`);
11699
+ child.stdin.write(`${JSON.stringify({
11700
+ jsonrpc: "2.0",
11701
+ id: 2,
11702
+ method: "tools/list"
11703
+ })}\n`);
11704
+ } else if (message.id === 2 && server) {
11705
+ const tools = Array.isArray(message.result?.tools) ? message.result.tools.map((tool) => String(tool.name ?? "")) : [];
11706
+ return finish({
11707
+ ok: true,
11708
+ name: server.name,
11709
+ version: server.version,
11710
+ tools
11711
+ });
11712
+ }
11713
+ }
11714
+ });
11715
+ child.stdin.on("error", () => {});
11716
+ child.stdin.write(`${JSON.stringify({
11717
+ jsonrpc: "2.0",
11718
+ id: 1,
11719
+ method: "initialize",
11720
+ params: {
11721
+ protocolVersion: PROTOCOL_VERSION,
11722
+ capabilities: {},
11723
+ clientInfo: {
11724
+ name: "prreviewbuddy-settings",
11725
+ version: "1"
11726
+ }
11727
+ }
11728
+ })}\n`);
11729
+ });
11730
+ }
11731
+ //#endregion
11732
+ //#region ../../packages/review-harness/src/integrations/shell_words.ts
11733
+ /**
11734
+ * argv as one line a person can paste into a POSIX shell. Pure, so the Settings page can build a
11735
+ * profile's command in the browser with the same quoting the server uses.
11736
+ */
11737
+ var SAFE = /^[A-Za-z0-9_\/.:=@%+,-]+$/;
11738
+ function shellQuote(word) {
11739
+ if (word !== "" && SAFE.test(word)) return word;
11740
+ return `'${word.replace(/'/g, `'\\''`)}'`;
11741
+ }
11742
+ function shellJoin(argv) {
11743
+ return argv.map(shellQuote).join(" ");
11744
+ }
11745
+ //#endregion
11746
+ //#region ../../packages/review-harness/src/integrations/service.ts
11747
+ var INTEGRATION_ACTIONS = [
11748
+ "connect",
11749
+ "disconnect",
11750
+ "verify"
11751
+ ];
11752
+ /** One object for the process, so that reads made with the defaults can share one in flight. */
11753
+ var defaults = null;
11754
+ function defaultIntegrationDeps() {
11755
+ defaults ??= {
11756
+ agents: AGENT_IDS.map(agentById).filter((agent) => agent.mcpRegistration),
11757
+ env: process.env,
11758
+ entry: cliEntry(),
11759
+ version: BUILD_VERSION,
11760
+ commandVersion,
11761
+ handshake: mcpHandshake
11762
+ };
11763
+ return defaults;
11764
+ }
11765
+ /** The agents that can be integrated, by id. Anything else is not a row and not an action. */
11766
+ function integrationAgent(id, deps) {
11767
+ const agent = deps.agents.find((candidate) => candidate.id === id);
11768
+ return agent?.mcpRegistration ? agent : null;
11769
+ }
11770
+ var SERVE_ENTRY = (command) => ({
11771
+ command,
11772
+ args: [...SERVE_ARGS],
11773
+ env: {}
11774
+ });
11775
+ function sameArgs(args) {
11776
+ return args.length === SERVE_ARGS.length && args.every((arg, index) => arg === SERVE_ARGS[index]);
11777
+ }
11778
+ function errorText(error) {
11779
+ return error instanceof Error ? error.message : String(error);
11780
+ }
11781
+ async function statusOf(agent, executable, deps) {
11782
+ const registration = agent.mcpRegistration;
11783
+ const pin = registration.pinConfig(deps.env);
11784
+ const add = registration.addCommand(SERVE_ENTRY(executable.command ?? executable.entry));
11785
+ const base = {
11786
+ agent: agent.id,
11787
+ displayName: agent.displayName,
11788
+ state: "unknown",
11789
+ configPath: registration.configPath(deps.env),
11790
+ addCommand: shellJoin([...pin, ...add]),
11791
+ removeCommand: shellJoin([...pin, ...registration.removeCommand()]),
11792
+ ...registration.profileVariable ? {
11793
+ profileVariable: registration.profileVariable,
11794
+ profileAddCommand: shellJoin(add)
11795
+ } : {}
11796
+ };
11797
+ const availability = await agent.available(agent.captureEnv(deps.env));
11798
+ if (!availability.ok && !availability.found) return {
11799
+ ...base,
11800
+ state: "not-installed",
11801
+ reason: availability.reason
11802
+ };
11803
+ let entry;
11804
+ try {
11805
+ entry = await registration.read(deps.env);
11806
+ } catch (error) {
11807
+ return {
11808
+ ...base,
11809
+ state: "unknown",
11810
+ reason: `Its config could not be read: ${errorText(error)}`
11811
+ };
11812
+ }
11813
+ if (!entry) return {
11814
+ ...base,
11815
+ state: "not-connected"
11816
+ };
11817
+ const registered = {
11818
+ command: entry.command,
11819
+ args: entry.args,
11820
+ env: entry.env
11821
+ };
11822
+ if (entry.disabled) return {
11823
+ ...base,
11824
+ state: "disabled",
11825
+ registered
11826
+ };
11827
+ const ours = startsThisInstall(entry.command, deps.entry, deps.env.PATH) && sameArgs(entry.args);
11828
+ return {
11829
+ ...base,
11830
+ state: ours ? "connected" : "connected-elsewhere",
11831
+ registered
11832
+ };
11833
+ }
11834
+ /**
11835
+ * Every agent's status, read now. Callers that only want to know share a read already running, so
11836
+ * a page loaded twice asks each agent once. `fresh` never shares: after a change, a read that began
11837
+ * before it would answer with the config as it was.
11838
+ *
11839
+ * The cached report is the one that began last, not the one that finished last, so a slow read
11840
+ * that started first can never overwrite a newer answer.
11841
+ */
11842
+ function integrationsReport(deps = defaultIntegrationDeps(), options = {}) {
11843
+ const running = inFlight$1.get(deps);
11844
+ if (running && !options.fresh) return running;
11845
+ const started = ++reportsStarted;
11846
+ const report = readReport(deps).then((result) => {
11847
+ if (started > lastStarted) {
11848
+ lastStarted = started;
11849
+ last = result;
11850
+ }
11851
+ return result;
11852
+ });
11853
+ inFlight$1.set(deps, report);
11854
+ const settle = () => {
11855
+ if (inFlight$1.get(deps) === report) inFlight$1.delete(deps);
11856
+ };
11857
+ report.then(settle, settle);
11858
+ return report;
11859
+ }
11860
+ async function readReport(deps) {
11861
+ const executable = chooseExecutable(deps.entry, deps.env.PATH);
11862
+ const integrations = await Promise.all(deps.agents.flatMap((agent) => {
11863
+ const integrable = integrationAgent(agent.id, deps);
11864
+ return integrable ? [statusOf(integrable, executable, deps)] : [];
11865
+ }));
11866
+ return {
11867
+ version: deps.version,
11868
+ executable,
11869
+ integrations
11870
+ };
11871
+ }
11872
+ var last = null;
11873
+ var reportsStarted = 0;
11874
+ var lastStarted = 0;
11875
+ var inFlight$1 = /* @__PURE__ */ new WeakMap();
11876
+ /** The newest report any caller computed, or null before the first. For a page that must not wait. */
11877
+ function lastIntegrationsReport() {
11878
+ return last;
11879
+ }
11880
+ /** One action per agent at a time: two presses of Connect must not run two `add`s. */
11881
+ var busy = /* @__PURE__ */ new Set();
11882
+ async function runIntegrationAction(agentId, action, options = {}, deps = defaultIntegrationDeps()) {
11883
+ const agent = integrationAgent(agentId, deps);
11884
+ if (!agent) throw new Error(`${agentId} cannot be connected to PR Review Buddy.`);
11885
+ const executable = chooseExecutable(deps.entry, deps.env.PATH);
11886
+ if (busy.has(agent.id)) return {
11887
+ outcome: "refused",
11888
+ message: `${agent.displayName} is already being changed. Wait for that to finish.`,
11889
+ status: await statusOf(agent, executable, deps)
11890
+ };
11891
+ busy.add(agent.id);
11892
+ try {
11893
+ const status = await statusOf(agent, executable, deps);
11894
+ const result = action === "connect" ? await connect(agent, status, executable, options.replace === true, deps) : action === "disconnect" ? await disconnect(agent, status, executable, deps) : await verify(agent, status, deps);
11895
+ await integrationsReport(deps, { fresh: true }).catch(() => void 0);
11896
+ return result;
11897
+ } finally {
11898
+ busy.delete(agent.id);
11899
+ }
11900
+ }
11901
+ async function connect(agent, status, executable, replace, deps) {
11902
+ const name = agent.displayName;
11903
+ const registration = agent.mcpRegistration;
11904
+ const refuse = (message) => ({
11905
+ outcome: "refused",
11906
+ message,
11907
+ status
11908
+ });
11909
+ if (status.state === "not-installed") return refuse(`${name} is not installed on this machine, so there is nothing to connect.`);
11910
+ if (status.state === "unknown") return refuse(`Nothing was changed. ${status.reason ?? ""}`.trim());
11911
+ if (status.state === "connected") return {
11912
+ outcome: "already-connected",
11913
+ message: `${name} is already connected to this PR Review Buddy.`,
11914
+ status
11915
+ };
11916
+ if (status.state === "disabled") return refuse(`Nothing was changed. PR Review Buddy is registered in ${status.configPath} but turned off there, so ${name} does not start it. Turn it on in that file, or disconnect it and connect again.`);
11917
+ if (!executable.command) return refuse(`No \`prreviewbuddy\` command on your PATH starts this copy of PR Review Buddy, so there is nothing stable to register. Install it with npm, then try again.`);
11918
+ const answered = await deps.commandVersion(executable.command, deps.env);
11919
+ if (answered !== deps.version) return refuse(`\`${executable.command}\` answered ${answered ? `version ${answered}` : "without a version"}, not ${deps.version}, so it was not registered.`);
11920
+ const wanted = SERVE_ENTRY(executable.command);
11921
+ if (status.state === "not-connected") {
11922
+ try {
11923
+ await registration.add(wanted, deps.env);
11924
+ } catch (error) {
11925
+ return {
11926
+ outcome: "failed",
11927
+ message: `${name} did not add it: ${errorText(error)}`,
11928
+ status: await statusOf(agent, executable, deps)
11929
+ };
11930
+ }
11931
+ const after = await statusOf(agent, executable, deps);
11932
+ return after.state === "connected" ? {
11933
+ outcome: "connected",
11934
+ message: `${name} is connected. Start a new ${name} session to use it; sessions already open keep the tools they started with.`,
11935
+ status: after
11936
+ } : {
11937
+ outcome: "failed",
11938
+ message: `${name} accepted the change, but its config does not show it. Nothing else was changed.`,
11939
+ status: after
11940
+ };
11941
+ }
11942
+ if (!replace) return refuse(`${name} already starts a different PR Review Buddy (\`${status.registered?.command ?? ""}\`). Replace it to use this one.`);
11943
+ let previous;
11944
+ try {
11945
+ previous = await registration.read(deps.env);
11946
+ } catch (error) {
11947
+ return refuse(`Nothing was changed: ${name}'s config could not be read again (${errorText(error)}).`);
11948
+ }
11949
+ if (!previous) return connect(agent, {
11950
+ ...status,
11951
+ state: "not-connected"
11952
+ }, executable, false, deps);
11953
+ if (previous.unrecreatable.length > 0) return refuse(`Nothing was changed. The current registration has settings ${name}'s own command cannot put back (${previous.unrecreatable.join(", ")}), so it is not removed on your behalf. Change it by hand instead.`);
11954
+ const previousCommand = shellJoin(registration.addCommand(previous));
11955
+ try {
11956
+ await registration.remove(deps.env);
11957
+ } catch (error) {
11958
+ return {
11959
+ outcome: "failed",
11960
+ message: `Nothing was changed: ${name} did not remove the current registration (${errorText(error)}).`,
11961
+ status: await statusOf(agent, executable, deps)
11962
+ };
11963
+ }
11964
+ let addError = null;
11965
+ try {
11966
+ await registration.add(wanted, deps.env);
11967
+ } catch (error) {
11968
+ addError = error;
11969
+ }
11970
+ if (!addError) {
11971
+ const after = await statusOf(agent, executable, deps);
11972
+ if (after.state === "connected") return {
11973
+ outcome: "replaced",
11974
+ message: `${name} now starts this PR Review Buddy. Start a new ${name} session to use it.`,
11975
+ status: after
11976
+ };
11977
+ addError = /* @__PURE__ */ new Error("its config does not show the new registration");
11978
+ }
11979
+ try {
11980
+ if (await registration.read(deps.env)) await registration.remove(deps.env);
11981
+ await registration.add(previous, deps.env);
11982
+ const restored = await registration.read(deps.env);
11983
+ if (restored && restored.command === previous.command && restored.args.join("\0") === previous.args.join("\0")) return {
11984
+ outcome: "restored",
11985
+ message: `${name} did not take the new registration (${errorText(addError)}). Your previous registration has been put back.`,
11986
+ status: await statusOf(agent, executable, deps)
11987
+ };
11988
+ } catch {}
11989
+ return {
11990
+ outcome: "restore-failed",
11991
+ message: `${name} did not take the new registration (${errorText(addError)}), and the previous one could not be put back. Run the command below to restore it.`,
11992
+ status: await statusOf(agent, executable, deps),
11993
+ previousCommand
11994
+ };
11995
+ }
11996
+ async function disconnect(agent, status, executable, deps) {
11997
+ const name = agent.displayName;
11998
+ if (status.state === "not-installed" || status.state === "not-connected") return {
11999
+ outcome: "not-registered",
12000
+ message: `${name} has no PR Review Buddy registration to remove.`,
12001
+ status
12002
+ };
12003
+ if (status.state === "unknown") return {
12004
+ outcome: "refused",
12005
+ message: `Nothing was changed. ${status.reason ?? ""}`.trim(),
12006
+ status
12007
+ };
12008
+ try {
12009
+ await agent.mcpRegistration.remove(deps.env);
12010
+ } catch (error) {
12011
+ return {
12012
+ outcome: "failed",
12013
+ message: `${name} did not remove it: ${errorText(error)}`,
12014
+ status: await statusOf(agent, executable, deps)
12015
+ };
12016
+ }
12017
+ const after = await statusOf(agent, executable, deps);
12018
+ return after.state === "not-connected" ? {
12019
+ outcome: "disconnected",
12020
+ message: `${name} no longer starts PR Review Buddy. Sessions already open keep it until they end.`,
12021
+ status: after
12022
+ } : {
12023
+ outcome: "failed",
12024
+ message: `${name} reported success, but its config still has a registration.`,
12025
+ status: after
12026
+ };
12027
+ }
12028
+ async function verify(agent, status, deps) {
12029
+ const name = agent.displayName;
12030
+ if (!status.registered) return {
12031
+ outcome: "not-registered",
12032
+ message: `${name} has no PR Review Buddy registration to check.`,
12033
+ status
12034
+ };
12035
+ const { command, args, env } = status.registered;
12036
+ const answer = await deps.handshake(command, args, {
12037
+ ...deps.env,
12038
+ ...env
12039
+ });
12040
+ if (!answer.ok) return {
12041
+ outcome: "not-working",
12042
+ message: `${name}'s registration does not work: ${answer.reason}`,
12043
+ status
12044
+ };
12045
+ if (answer.name !== "prreviewbuddy") return {
12046
+ outcome: "not-working",
12047
+ message: `${name}'s registration starts an MCP server called “${answer.name}”, not PR Review Buddy.`,
12048
+ status
12049
+ };
12050
+ const missing = MCP_TOOL_NAMES.filter((tool) => !answer.tools.includes(tool));
12051
+ if (missing.length > 0) return {
12052
+ outcome: "not-working",
12053
+ message: `${name}'s registration starts PR Review Buddy ${answer.version}, which does not offer ${missing.join(", ")}. Connect this copy (${deps.version}) instead.`,
12054
+ status
12055
+ };
12056
+ if (status.state === "disabled") return {
12057
+ outcome: "not-working",
12058
+ message: `The registration answers as PR Review Buddy ${answer.version}, but it is turned off in ${status.configPath}, so ${name} does not start it.`,
12059
+ status
12060
+ };
12061
+ const elsewhere = status.state === "connected-elsewhere" ? ` It is a different copy from this one (${deps.version}).` : "";
12062
+ return {
12063
+ outcome: "working",
12064
+ message: `Working. ${name}'s registration starts PR Review Buddy ${answer.version} with ${answer.tools.length} tools.${elsewhere} A ${name} session started from now on can use it.`,
12065
+ status,
12066
+ server: {
12067
+ version: answer.version,
12068
+ tools: answer.tools
12069
+ }
12070
+ };
12071
+ }
12072
+ //#endregion
11482
12073
  //#region ../../packages/review-harness/src/workspace/notification_facts.ts
11483
12074
  /**
11484
12075
  * What a recorded event says beyond its name, in two lengths: the Activity row's full line, and a
@@ -11815,6 +12406,15 @@ var AGENT_PRODUCTS = ((names) => names.length <= 1 ? names[0] ?? "" : `${names.s
11815
12406
  return [];
11816
12407
  }
11817
12408
  }));
12409
+ /** The agents AI integrations can connect, by their own names, the same way. */
12410
+ var INTEGRATED_PRODUCTS = ((names) => names.length <= 1 ? names[0] ?? "your coding agent" : `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`)([...AGENT_IDS].sort().flatMap((id) => {
12411
+ try {
12412
+ const agent = agentById(id);
12413
+ return agent?.mcpRegistration ? [agent.displayName] : [];
12414
+ } catch {
12415
+ return [];
12416
+ }
12417
+ }));
11818
12418
  /**
11819
12419
  * The concepts behind the CLI's commands live here rather than in `prreviewbuddy --help`, which
11820
12420
  * says what to type and points here for what it means. The diagrams are text on purpose.
@@ -12018,6 +12618,48 @@ feature: E</pre>
12018
12618
  else's branch and an author reviews their own pull request. Run one with
12019
12619
  <code>prreviewbuddy review --lens kiss --audience author</code> or
12020
12620
  <code>--audience reviewer</code>.</p>`
12621
+ },
12622
+ {
12623
+ id: "coding-agents",
12624
+ title: "Coding agents",
12625
+ body: `<p>Your coding agent can use PR Review Buddy directly. Connected, ${INTEGRATED_PRODUCTS} can start
12626
+ a review, read its issues and questions, request an update after new commits and ask Buddy AI
12627
+ about the change, all without opening the browser. It talks to PR Review Buddy through MCP, the
12628
+ Model Context Protocol: a standard way for a coding agent to use tools that another program
12629
+ provides, here a small server PR Review Buddy runs on your machine for the agent, and stops when
12630
+ the agent session ends.</p>
12631
+ <h3>Connecting</h3>
12632
+ <p>Open <a href="/settings#ai-integrations">Settings → AI integrations</a> and press Connect beside
12633
+ your agent. You are shown the exact command first, and it is your agent's own command for adding a
12634
+ server, so nothing edits its files behind its back. Then start a new session of the agent:
12635
+ sessions already open keep the tools they started with. <b>Verify connection</b> starts the
12636
+ registered server and lists its tools, which proves it answers; Disconnect removes it again.</p>
12637
+ <p>Prefer to do it yourself? <b>Copy command</b> gives you the same command to run in a terminal.
12638
+ It names the full path of the <code>prreviewbuddy</code> that runs this workspace, because an agent
12639
+ started from a desktop app may not see the same <code>PATH</code> as your shell, and could
12640
+ otherwise start an older copy.</p>
12641
+ <p>Connecting manages each agent's default configuration. If you keep several profiles of an
12642
+ agent in their own directories, Connected describes the default one only; the agent's row has a
12643
+ <b>Using a different profile?</b> section with the command that connects another.</p>
12644
+ <h3>What an agent can and cannot do</h3>
12645
+ <p>Seven tools: <code>review_pr</code> starts or returns a review, <code>get_review</code>,
12646
+ <code>list_findings</code> and <code>get_finding</code> read one, <code>get_review_status</code>
12647
+ follows its progress, <code>update_review</code> brings it up to date with new commits, and
12648
+ <code>ask_buddy</code> asks Buddy AI about it. An agent cannot delete a review, dismiss or resolve a
12649
+ finding, approve or comment on a pull request, or change your settings. A review PR Review Buddy
12650
+ runs can read reviews but never start one, so a review can never start another.</p>
12651
+ <h3>When it does not work</h3>
12652
+ <ul>
12653
+ <li><b>Uses another copy</b>: the agent is registered to a different <code>prreviewbuddy</code>,
12654
+ often an older install. Replace points it at this one, and puts the old registration back if the
12655
+ agent does not accept the new one.</li>
12656
+ <li><b>Not installed</b>: the agent's command is not on this machine's <code>PATH</code>.</li>
12657
+ <li><b>Turned off</b>: the agent's config has the registration but switched off, so the agent
12658
+ does not start it. Turn it on there, or Disconnect and Connect again.</li>
12659
+ <li><b>Verify fails</b>: the reason is the server's own. Reinstalling PR Review Buddy with
12660
+ <code>npm install -g prreviewbuddy</code> and reconnecting fixes a command that has moved.</li>
12661
+ </ul>
12662
+ <p><code>prreviewbuddy help mcp</code> covers the same tools from the terminal.</p>`
12021
12663
  },
12022
12664
  {
12023
12665
  id: "workspace-server",
@@ -16193,7 +16835,7 @@ async function handle(req, res, options) {
16193
16835
  if (url.pathname.startsWith("/assets/")) return sendAsset(res, url.pathname);
16194
16836
  if (url.pathname === "/") return handleIndex(req, res, url.searchParams, options.indexToken);
16195
16837
  if (url.pathname === "/open") return handleOpen(req, res, url.searchParams, options.indexToken);
16196
- if (url.pathname === "/settings") return void handleSettings(req, res, url.searchParams, options.indexToken);
16838
+ if (url.pathname === "/settings") return void handleSettings(req, res, url.searchParams, options.indexToken, options.integrations);
16197
16839
  if (url.pathname === "/notifications") return void handleNotifications(req, res, url.searchParams, options.indexToken);
16198
16840
  if (url.pathname === "/runs") return handleRuns(req, res, url.searchParams, options.indexToken);
16199
16841
  if (url.pathname === "/runs/revision") return handleRunsRevision(req, res, options.indexToken);
@@ -16217,6 +16859,7 @@ async function handle(req, res, options) {
16217
16859
  if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
16218
16860
  if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
16219
16861
  if (url.pathname === "/restart") return handleIndexRestart(req, res, options);
16862
+ if (url.pathname === "/integrations") return handleIntegrations(req, res, options);
16220
16863
  if (url.pathname.startsWith("/add-review/")) return handleAddReview(req, res, url.pathname, options);
16221
16864
  const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
16222
16865
  if (!match) return send(res, 404, { error: "Not found" });
@@ -16420,7 +17063,7 @@ function refuseIndex(req, res) {
16420
17063
  * `GET` renders, `POST` saves, on one path. The save answers with what was stored rather than with
16421
17064
  * a status, so the page can render the file rather than its own hopes about it.
16422
17065
  */
16423
- async function handleSettings(req, res, params, indexToken) {
17066
+ async function handleSettings(req, res, params, indexToken, integrations) {
16424
17067
  if (!indexAuthorised(req, params, indexToken)) {
16425
17068
  if (req.method === "POST") return send(res, 403, { error: "This link is missing its access token." });
16426
17069
  return refuseIndex(req, res);
@@ -16450,10 +17093,29 @@ async function handleSettings(req, res, params, indexToken) {
16450
17093
  unreadNotifications: unreadCount$1(),
16451
17094
  runningRuns: runningNow(),
16452
17095
  driving: isDriving(),
16453
- sidebar: readPreferences().sidebar
17096
+ sidebar: readPreferences().sidebar,
17097
+ integrations: await settingsIntegrations(integrations),
17098
+ home: homedir()
16454
17099
  }), indexCookieHeader(indexToken));
16455
17100
  }
16456
17101
  /**
17102
+ * Read fresh for Settings, which is where a person goes to find out, and in parallel the agents
17103
+ * answer in about a third of a second. Capped, so an agent whose CLI hangs costs the page the
17104
+ * newest report anyone had rather than the page itself.
17105
+ */
17106
+ var SETTINGS_INTEGRATIONS_WAIT_MS = 3e3;
17107
+ async function settingsIntegrations(deps) {
17108
+ let timer;
17109
+ const fallback = new Promise((resolvePromise) => {
17110
+ timer = setTimeout(() => resolvePromise(lastIntegrationsReport()), SETTINGS_INTEGRATIONS_WAIT_MS);
17111
+ });
17112
+ try {
17113
+ return await Promise.race([integrationsReport(deps ?? defaultIntegrationDeps()).catch(() => lastIntegrationsReport()), fallback]);
17114
+ } finally {
17115
+ clearTimeout(timer);
17116
+ }
17117
+ }
17118
+ /**
16457
17119
  * Help. Static, but still behind the index's credential like every other workspace place: its
16458
17120
  * rail and strip carry the unread count, which is this machine's state.
16459
17121
  */
@@ -17468,6 +18130,13 @@ async function sendWorkspacePage(res, workspace) {
17468
18130
  promptsCollapsed: readPreferences().promptsCollapsed === true,
17469
18131
  reviewLayout: readPreferences().reviewLayout ?? {},
17470
18132
  guidedTour: readPreferences().guidedTour,
18133
+ integrationsNotice: integrationsNoticeFor({
18134
+ live: true,
18135
+ hasResult: Boolean(workspace.session.result),
18136
+ tourAnswered: readPreferences().guidedTour !== void 0,
18137
+ dismissed: readPreferences().dismissedNotices,
18138
+ report: lastIntegrationsReport()
18139
+ }),
17471
18140
  tourBusy: busyReviewIds().has(workspace.id),
17472
18141
  changeMapRegister: readPreferences().changeMapRegister ?? "simple",
17473
18142
  architecture: await architecturePane(workspace),
@@ -17884,6 +18553,8 @@ async function handleAsk(req, res, workspace) {
17884
18553
  const body = await readJson(req);
17885
18554
  const question = typeof body?.question === "string" ? body.question.trim() : "";
17886
18555
  if (!question) return send(res, 400, { error: "No question given." });
18556
+ if (body?.requestId !== void 0 && !isRequestId(body.requestId)) return send(res, 400, { error: "requestId must be a UUID." });
18557
+ const requestId = body?.requestId;
17887
18558
  const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
17888
18559
  const explains = explanationRequested(workspace.session, body?.explains);
17889
18560
  const continuing = explanationContinued(workspace, body?.continuesExplanation);
@@ -17914,7 +18585,8 @@ async function handleAsk(req, res, workspace) {
17914
18585
  });
17915
18586
  const started = beginRun(workspace.id, "ask", {
17916
18587
  question,
17917
- sectionId
18588
+ sectionId,
18589
+ ...requestId ? { requestId } : {}
17918
18590
  });
17919
18591
  if ("alreadyRunning" in started) return send(res, 409, {
17920
18592
  error: "Buddy AI is still answering the last question on this review.",
@@ -17929,6 +18601,13 @@ async function handleAsk(req, res, workspace) {
17929
18601
  turn: workspace.transcript.length + 1
17930
18602
  });
17931
18603
  const startedAt = Date.now();
18604
+ let ended = {
18605
+ state: "failed",
18606
+ message: "Buddy AI could not answer this question."
18607
+ };
18608
+ if (requestId) try {
18609
+ markAskRunning(requestId, workspace.id, question, process.pid);
18610
+ } catch {}
17932
18611
  const failed = () => record({
17933
18612
  event: "assistant_answered",
17934
18613
  reviewId: workspace.id,
@@ -17959,11 +18638,25 @@ async function handleAsk(req, res, workspace) {
17959
18638
  });
17960
18639
  } catch (error) {
17961
18640
  failed();
17962
- if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
18641
+ if (run.state === "stopped") {
18642
+ ended = {
18643
+ state: "cancelled",
18644
+ message: "The question was stopped before it was answered."
18645
+ };
18646
+ return sendIfOpen(res, 200, { stopped: true });
18647
+ }
18648
+ ended = {
18649
+ state: "failed",
18650
+ message: error instanceof Error ? error.message : String(error)
18651
+ };
17963
18652
  throw error;
17964
18653
  }
17965
18654
  if (!completeRun(run)) {
17966
18655
  failed();
18656
+ ended = {
18657
+ state: "cancelled",
18658
+ message: "The question was stopped before it was answered."
18659
+ };
17967
18660
  return sendIfOpen(res, 200, { stopped: true });
17968
18661
  }
17969
18662
  record({
@@ -18009,7 +18702,8 @@ async function handleAsk(req, res, workspace) {
18009
18702
  sectionId,
18010
18703
  askedAt,
18011
18704
  ...kept ? { explains: kept } : {},
18012
- ...reconciles ? { reconciles } : {}
18705
+ ...reconciles ? { reconciles } : {},
18706
+ ...requestId ? { requestId } : {}
18013
18707
  });
18014
18708
  if (kept) {
18015
18709
  explanation = {
@@ -18036,6 +18730,13 @@ async function handleAsk(req, res, workspace) {
18036
18730
  askedAt: keptAt,
18037
18731
  ...reconcileOutcome ? { reconcileTurn: true } : {}
18038
18732
  } : {};
18733
+ ended = saved ? {
18734
+ state: "completed",
18735
+ askedAt: keptAt
18736
+ } : {
18737
+ state: "failed",
18738
+ message: "The review was deleted before the answer could be kept."
18739
+ };
18039
18740
  sendIfOpen(res, 200, {
18040
18741
  answer: shown,
18041
18742
  ...explanation ? { explanation } : {},
@@ -18045,6 +18746,9 @@ async function handleAsk(req, res, workspace) {
18045
18746
  });
18046
18747
  } finally {
18047
18748
  endRun(run);
18749
+ if (requestId) try {
18750
+ endAskRequest(requestId, workspace.id, ended);
18751
+ } catch {}
18048
18752
  }
18049
18753
  }
18050
18754
  /**
@@ -18428,6 +19132,40 @@ function applyState(workspace, body) {
18428
19132
  }
18429
19133
  /** 1 MB is far more than any question or id list, and far less than a memory problem. */
18430
19134
  var MAX_BODY_BYTES = 1048576;
19135
+ /**
19136
+ * AI integrations: the status of each coding agent's PR Review Buddy registration, and the three
19137
+ * actions on it. The one route that changes another application's config, so it is the narrowest:
19138
+ *
19139
+ * - the index cookie only, never a token in the URL, like every other index mutation;
19140
+ * - JSON only, so a plain HTML form on another site cannot post here at all;
19141
+ * - this server's own origin, when the browser says where the request came from;
19142
+ * - an agent id from the registry and an action from a fixed list, and nothing else from the body.
19143
+ * Which command is written, and which config, is the service's and the adapter's to decide.
19144
+ */
19145
+ async function handleIntegrations(req, res, options) {
19146
+ if (!options.indexToken || !secretMatches(options.indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
19147
+ if (!sameOriginRequest(req)) return send(res, 403, { error: "Only this workspace can change AI integrations." });
19148
+ const deps = options.integrations ?? defaultIntegrationDeps();
19149
+ if (req.method === "GET") return send(res, 200, await integrationsReport(deps));
19150
+ if (req.method !== "POST") return send(res, 405, { error: "Method not allowed." });
19151
+ if (!/^application\/json\b/i.test(req.headers["content-type"] ?? "")) return send(res, 415, { error: "Send JSON." });
19152
+ const body = await readJson(req);
19153
+ const action = body?.action;
19154
+ if (!INTEGRATION_ACTIONS.includes(action)) return send(res, 400, { error: "Unknown action." });
19155
+ if (!integrationAgent(body?.agent, deps)) return send(res, 400, { error: "That agent cannot be connected to PR Review Buddy." });
19156
+ send(res, 200, await runIntegrationAction(body?.agent, action, { replace: body?.replace === true }, deps));
19157
+ }
19158
+ /**
19159
+ * Whether the browser says this request came from this server's own pages. A request that says
19160
+ * nothing (a script, `curl`) is judged by the cookie alone; one that names another origin is refused.
19161
+ */
19162
+ function sameOriginRequest(req) {
19163
+ const site = req.headers["sec-fetch-site"];
19164
+ if (typeof site === "string" && site !== "same-origin" && site !== "none") return false;
19165
+ const origin = req.headers.origin;
19166
+ if (typeof origin !== "string") return true;
19167
+ return origin === `http://${req.headers.host ?? ""}`;
19168
+ }
18431
19169
  async function readJson(req) {
18432
19170
  const chunks = [];
18433
19171
  let size = 0;
@@ -18725,6 +19463,7 @@ server.on("error", () => process.exit(1));
18725
19463
  var stopSweeping = startSweeping();
18726
19464
  var stopWatching = startWatching();
18727
19465
  var stopReconciling = startReconciling();
19466
+ integrationsReport().catch(() => void 0);
18728
19467
  for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
18729
19468
  stopSweeping();
18730
19469
  stopWatching();