prreviewbuddy 0.25.13 → 0.27.2

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,4 +1,4 @@
1
- import { $ as checkCodeFreshness, $t as AgentCancelledError, A as liveUpdateFor, At as workspaceStamps, B as recordTelemetryUploadConsent, C as writePortPreference, D as isUnchanged, E as refreshPrContext, Et as saveWorkspace, Ft as questionsOutstanding, G as withUsageRecorded, Gt as writeAgentPreference, H as BUILD_VERSION, I as runJob, It as reviewerDispositions, J as fillFileUrlTemplate, Jt as AGENT_IDS, K as EXPLAIN_SIMPLY_PROMPT, Lt as processDiscussion, M as agentEnvOf, Mt as isIssueOutstanding, Nt as isQuestionOutstanding, O as askCheckout, Ot as touchWorkspace, P as startKissJob, Pt as issuesOutstanding, Q as removeWorktree, R as readEvents, Rt as describeAuthorship, S as statedWorkspacePort, St as recentReviewGroups, T as updateReview, Ut as clearAgentPreference, V as telemetryUploadConsent, Vt as git, W as feedbackUrl, Wt as readAgentPreference, X as MANAGED_ROOT, Y as ReviewBeingDeletedError, Z as readMarker, Zt as agentFor, _ as DEFAULT_WORKSPACE_PORT, _t as lineagePosition, a as liveJobsFor, at as loadJob, b as parseWorkspacePort, c as readIndexToken, ct as phasesFor, en as AgentUnavailableError, et as checkFreshness, f as readServerRecord, ft as isClaimed, g as writeServerRecord, ht as latestKissRun, i as lineageIds, it as isTerminal, jt as doneVerb, k as UpdateAlreadyRunningError, kt as workspaceRevision, lt as progressSteps, n as deleteLineage, nt as endedAt, o as wasBlocked, ot as saveJob, pt as followedRefName, qt as STORE_ROOT, r as deleteReview, rt as fail, s as discardJob, t as relativeTime$1, tt as allJobs, u as clearServerRecord, ut as purposeOf, v as MAX_WORKSPACE_PORT, vt as loadWorkspace, w as RefreshUnavailableError, wt as reviewedCommit, x as resolveWorkspacePort, xt as previousKissResult, y as MIN_WORKSPACE_PORT, z as record } from "./relative_time-vn8Ex9E8.js";
1
+ import { $ as fillFileUrlTemplate, $t as agentFor, A as removeWorktree, At as workspaceRevision, C as recordTelemetryUploadConsent, Ct as recentReviewGroups, D as ReviewBeingDeletedError, Dt as saveWorkspace, E as EXPLAIN_SIMPLY_PROMPT, F as clearServerRecord, Ft as issuesOutstanding, G as DEFAULT_WORKSPACE_PORT, Gt as clearAgentPreference, H as BUILD_VERSION, Ht as git, It as questionsOutstanding, J as parseWorkspacePort, K as MAX_WORKSPACE_PORT, Kt as readAgentPreference, L as readServerRecord, Lt as reviewerDispositions, M as relativeTime$1, Mt as doneVerb, N as readIndexToken, Nt as isIssueOutstanding, O as MANAGED_ROOT, Pt as isQuestionOutstanding, Rt as processDiscussion, S as record, St as previousKissResult, T as withUsageRecorded, Tt as reviewedCommit, V as writeServerRecord, W as feedbackUrl, Wt as resolveModel, X as statedWorkspacePort, Xt as AGENT_IDS, Y as resolveWorkspacePort, Yt as STORE_ROOT, Z as writePortPreference, _ as startKissJob, a as wasBlocked, at as isTerminal, c as updateReview, d as askCheckout, dt as purposeOf, et as checkCodeFreshness, f as UpdateAlreadyRunningError, gt as latestKissRun, h as agentEnvOf, i as liveJobsFor, it as fail, jt as workspaceStamps, k as readMarker, kt as touchWorkspace, l as refreshPrContext, lt as phasesFor, mt as followedRefName, n as deleteReview, nn as AgentUnavailableError, nt as allJobs, o as discardJob, ot as loadJob, p as liveUpdateFor, pt as isClaimed, q as MIN_WORKSPACE_PORT, qt as writeAgentPreference, r as lineageIds, rt as endedAt, s as RefreshUnavailableError, st as saveJob, t as deleteLineage, tn as AgentCancelledError, tt as checkFreshness, u as isUnchanged, ut as progressSteps, vt as lineagePosition, w as telemetryUploadConsent, x as readEvents, y as runJob, yt as loadWorkspace, zt as describeAuthorship } from "./delete_review-B3xciJ03.js";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -571,6 +571,119 @@ function humanizeModelId(id) {
571
571
  return display;
572
572
  }
573
573
  //#endregion
574
+ //#region ../../packages/review-harness/src/domain/utils/agent_label.ts
575
+ /**
576
+ * What a person is shown when a review names the tool that produced it.
577
+ *
578
+ * Here in the domain rather than read off `AgentIdentity.displayName`, because this runs in the
579
+ * extension bundle and the adapter registry reaches `node:child_process`. Deriving the label from
580
+ * the stored `agentId` keeps the client free of node builtins and adds no stored field.
581
+ *
582
+ * A closed set of this product's own adapter ids, which is not the kind of table that goes stale
583
+ * invisibly: adding an adapter is our own act, and anything unrecognised still renders through
584
+ * `humanizeModelId` rather than disappearing. That is why this is not folded into
585
+ * `humanizeModelId` itself, whose value is knowing nothing about which names exist.
586
+ */
587
+ var AGENT_DISPLAY_NAMES = {
588
+ claude: "Claude Code",
589
+ codex: "Codex",
590
+ gemini: "Gemini CLI"
591
+ };
592
+ /**
593
+ * Absent reads as Claude, matching `agentIdOf` and `agentName`: a session written before agents
594
+ * were selectable ran under Claude, and showing it as unknown would invent a gap in the oldest
595
+ * reviews on disk.
596
+ */
597
+ var DEFAULT_AGENT_DISPLAY_NAME = AGENT_DISPLAY_NAMES.claude;
598
+ function agentDisplayName(agentId) {
599
+ if (!agentId) return DEFAULT_AGENT_DISPLAY_NAME;
600
+ return AGENT_DISPLAY_NAMES[agentId] ?? humanizeModelId(agentId);
601
+ }
602
+ //#endregion
603
+ //#region ../../packages/review-harness/src/domain/ai_review/model_provenance.ts
604
+ /**
605
+ * Values `model` carried when it meant "which agent", before the field was frozen.
606
+ *
607
+ * A closed set of three of our own historical strings, not a catalogue of vendor models, so it
608
+ * never grows and never goes stale. It lives here rather than in `humanizeModelId` on purpose:
609
+ * the corruption belongs to how sessions were once written, and that label function's whole value
610
+ * is knowing nothing about which models exist.
611
+ */
612
+ var LEGACY_AGENT_IDS = /* @__PURE__ */ new Set([
613
+ "codex",
614
+ "claude-code",
615
+ "gemini-cli"
616
+ ]);
617
+ var present = (value) => typeof value === "string" && value !== "" ? value : null;
618
+ function modelProvenanceOf(session) {
619
+ return {
620
+ requestedModel: present(session.requestedModel) ?? legacyModel(session.model),
621
+ observedModel: present(session.observedModel),
622
+ agentVersion: present(session.agentVersion)
623
+ };
624
+ }
625
+ /**
626
+ * A legacy `model`, read as a request or discarded.
627
+ *
628
+ * The extension put the model it was about to call into this field, so for its sessions the value
629
+ * really is what was requested. The CLI put an agent id there, which is not a model at all and is
630
+ * dropped rather than displayed: showing "Reviewed with Codex, model Codex" would be the record
631
+ * inventing provenance out of its own filing.
632
+ */
633
+ function legacyModel(model) {
634
+ const value = present(model);
635
+ if (value === null || LEGACY_AGENT_IDS.has(value)) return null;
636
+ return value;
637
+ }
638
+ /**
639
+ * Whether a coding agent made this, as opposed to the extension calling a provider API.
640
+ *
641
+ * The awkward case is a session with no `agentId` at all, because two different producers leave it
642
+ * empty. A CLI review from before agents were selectable means Claude, which is why
643
+ * `agentDisplayName` defaults that way. An extension review means no agent ran at all, and naming
644
+ * one would credit Claude Code for somebody's OpenAI review.
645
+ *
646
+ * The legacy sentinel tells them apart, which is the last job that field has: only the CLI ever
647
+ * wrote one, so a record with no agent id and no sentinel came from the extension.
648
+ */
649
+ function producedByAgent(session) {
650
+ if (session.agentId) return true;
651
+ const legacy = present(session.model);
652
+ return legacy !== null && LEGACY_AGENT_IDS.has(legacy);
653
+ }
654
+ /**
655
+ * The full provenance, for the disclosure rather than the page.
656
+ *
657
+ * Opens with "Reviewed with X" rather than "Agent: X", keeping the sentence that was already here:
658
+ * a review is not owned by an agent, it holds analyses, and this names the one being shown.
659
+ *
660
+ * The line that earns this list its place is the last one: a pinned model that nobody confirmed.
661
+ * `--model X` states what we asked for, and on an agent that reports nothing back there is no way
662
+ * to know it was honoured, so the pin is shown alongside the fact that it is unverified. Without
663
+ * that line a reader would take a requested model for a measured one, which is the overstatement
664
+ * this whole split exists to prevent.
665
+ *
666
+ * Said only when something was pinned. On an unpinned run there is no claim outstanding, and a
667
+ * standing "not reported" would be noise on every review anybody ever runs.
668
+ *
669
+ * Both sentences that name an agent are withheld from a review no agent produced, through the same
670
+ * `producedByAgent` distinction `attributionLabel` makes. An extension review has no agent id, and
671
+ * naming one anyway credited Claude Code for somebody's OpenAI review and then reported that Claude
672
+ * Code had not confirmed a model nobody had asked it for. What remains for such a review is the
673
+ * model it requested and the model that answered, which is the whole of what is known about it.
674
+ */
675
+ function provenanceDetailLines(session) {
676
+ const agent = producedByAgent(session) ? agentDisplayName(session.agentId) : null;
677
+ const { requestedModel, observedModel, agentVersion } = modelProvenanceOf(session);
678
+ return [
679
+ ...agent ? [`Reviewed with ${agent}`] : [],
680
+ ...agentVersion ? [`Agent version ${agentVersion}`] : [],
681
+ ...requestedModel ? [`Requested model ${humanizeModelId(requestedModel)}`] : [],
682
+ ...observedModel ? [`Reported model ${humanizeModelId(observedModel)}`] : [],
683
+ ...agent && requestedModel && !observedModel ? [`Reported model not reported by ${agent}`] : []
684
+ ];
685
+ }
686
+ //#endregion
574
687
  //#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
575
688
  /**
576
689
  * What every pill in the sidebar actually means, in one place.
@@ -881,7 +994,8 @@ var CANCEL_BUTTON = `<button type="button" class="pr-helper-cancel-button">Cance
881
994
  function renderGeneratingOverview(session, iconUrl) {
882
995
  const activeIndex = session.progress.findIndex((step) => !step.done);
883
996
  const brandMark = iconUrl ? `<img src="${escapeHtml$1(iconUrl)}" class="pr-helper-brand-mark" alt="" />` : "";
884
- const poweredByName = session.provider === "openai_compatible" ? humanizeModelId(session.model) : PROVIDER_DISPLAY_NAME[session.provider];
997
+ const pickedModel = modelProvenanceOf(session).requestedModel;
998
+ const poweredByName = session.provider === "openai_compatible" && pickedModel ? humanizeModelId(pickedModel) : PROVIDER_DISPLAY_NAME[session.provider];
885
999
  const isThinking = activeIndex === 2;
886
1000
  return `
887
1001
  <div class="pr-helper-generating pr-helper-generating-focused">
@@ -991,8 +1105,7 @@ function coverageDetails(session) {
991
1105
  * agent: it holds analyses, and this names the one it is currently showing.
992
1106
  */
993
1107
  function agentDetails(session) {
994
- const name = humanizeModelId(session.model);
995
- return name ? [`Reviewed with ${name}`] : [];
1108
+ return provenanceDetailLines(session);
996
1109
  }
997
1110
  /**
998
1111
  * Everything the Analysis details disclosure says, as facts rather than as markup.
@@ -2220,6 +2333,9 @@ function phaseSentence(job) {
2220
2333
  function renderFailureBody(job, options = {}) {
2221
2334
  if (!job.failure) return "";
2222
2335
  const noun = isWorkPhase(job.failure.phase) ? PHASE_NOUN[job.failure.phase] : job.failure.phase;
2336
+ if (job.failure.stopped) return `<p>${options.lead === false ? "" : "<strong>Review stopped.</strong> "}Nothing went wrong, and what it had finished is kept.</p>
2337
+ <button type="button" class="prb-job-retry" data-job-retry>Carry on</button>
2338
+ <p class="prb-job-note">It picks up from the ${escapeHtml$1(noun)}, not from the start.</p>`;
2223
2339
  const next = job.failure.kind === "agent_authentication_failed" ? `<p class="prb-job-note">Signing that agent in is what changes this. Once it is signed in, update the review.</p>` : `<button type="button" class="prb-job-retry" data-job-retry>Retry</button>
2224
2340
  <p class="prb-job-note">Retry picks up from the ${escapeHtml$1(noun)}, not from the start.</p>`;
2225
2341
  return `<p>${options.lead === false ? "" : "<strong>Review paused.</strong> "}${escapeHtml$1(job.failure.message)}</p>
@@ -2584,7 +2700,7 @@ function provenanceFacts(run) {
2584
2700
  run.completedAt ? `Completed ${relativeTime$1(run.completedAt)}` : "",
2585
2701
  `Commit ${run.reviewedSha.slice(0, 7)}`,
2586
2702
  audienceSentence(run.audience),
2587
- run.agentId ? `Analysed by ${run.agentId}${run.model ? ` (${run.model})` : ""}` : "",
2703
+ run.agentId ? `Analysed by ${agentDisplayName(run.agentId)}${modelSuffix(run)}` : "",
2588
2704
  `Rubric version ${run.promptVersion}`
2589
2705
  ].filter((fact) => fact !== "");
2590
2706
  }
@@ -2725,6 +2841,22 @@ function renderLocationList(locations) {
2725
2841
  </button></li>`;
2726
2842
  }).join("")}</ul>`;
2727
2843
  }
2844
+ /**
2845
+ * The model this run ran under, as far as it is known.
2846
+ *
2847
+ * Observed over requested, which is the precedence `attributionLabel` applies to a standard
2848
+ * review's line and the reason this reads `modelProvenanceOf` rather than a field: what the agent
2849
+ * said answered outranks what it was asked for, and only one of the two is ever a claim about what
2850
+ * actually happened. Neither known leaves the agent to stand alone rather than inventing a pin.
2851
+ *
2852
+ * `modelProvenanceOf` is also what keeps the frozen `model` out of here, which on a KISS run only
2853
+ * ever held an agent id: "Analysed by Codex (Codex)" was the shape that produced.
2854
+ */
2855
+ function modelSuffix(run) {
2856
+ const { requestedModel, observedModel } = modelProvenanceOf(run);
2857
+ const model = observedModel ?? requestedModel;
2858
+ return model ? ` (${humanizeModelId(model)})` : "";
2859
+ }
2728
2860
  //#endregion
2729
2861
  //#region ../../packages/review-harness/src/workspace/update_view.ts
2730
2862
  /**
@@ -3352,7 +3484,7 @@ function renderStandardViews(view, sections) {
3352
3484
  detailsInHeader: true
3353
3485
  })}</section>
3354
3486
  <section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null, { diagrams: true })}</section>
3355
- <section class="prb-view" data-view="diagram" hidden data-diagram-pane>
3487
+ <section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
3356
3488
  <h3 class="prb-doc-title">Diagrams</h3>
3357
3489
  ${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
3358
3490
  </section>
@@ -4058,6 +4190,9 @@ function renderShutdownDialog() {
4058
4190
  function renderAsk(view, sections) {
4059
4191
  const suggestions = buildSuggestions(view, sections);
4060
4192
  const transcript = view.transcript ?? [];
4193
+ const pending = view.pendingAsk ?? null;
4194
+ const pendingTurn = pending ? `<div class="prb-msg prb-msg-user">${escapeHtml$1(pending.question ?? "")}</div>
4195
+ <div class="prb-thinking" data-ask-pending><i></i><i></i><i></i><span>Reading the change…</span></div>` : "";
4061
4196
  return `<aside class="prb-ask" aria-label="Ask Buddy AI">
4062
4197
  <div
4063
4198
  class="prb-ask-resize"
@@ -4073,8 +4208,8 @@ function renderAsk(view, sections) {
4073
4208
  </div>
4074
4209
  ${renderPromptBank(view.prompts ?? [])}
4075
4210
  <div class="prb-ask-log" data-ask-log>
4076
- ${transcript.length > 0 ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
4077
- <div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>`).join("") : `<div class="prb-ask-empty">
4211
+ ${transcript.length > 0 || pending ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
4212
+ <div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>`).join("") + pendingTurn : `<div class="prb-ask-empty">
4078
4213
  <p>Ask about this change. Answers come from the review, the diff and the repository on disk — not from anywhere else.</p>
4079
4214
  ${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
4080
4215
  </div>`}
@@ -4481,7 +4616,7 @@ function renderHistoryRow(review, number, latest, now) {
4481
4616
  */
4482
4617
  function renderAnalysisState(analysis) {
4483
4618
  if (analysis === void 0) return "";
4484
- if (analysis.phase === "failed") return "<span class=\"prb-card-analysis\" data-analysis=\"stopped\">Analysis stopped</span>";
4619
+ if (analysis.phase === "failed") return analysis.stopped ? "<span class=\"prb-card-analysis\" data-analysis=\"stopped\">Analysis stopped</span>" : "<span class=\"prb-card-analysis\" data-analysis=\"failed\">Analysis failed</span>";
4485
4620
  return `<span class="prb-card-analysis" data-analysis="running"><i></i>${escapeHtml$1(phasesFor({ purpose: analysis.purpose })[analysis.phase].doing)}</span>`;
4486
4621
  }
4487
4622
  /**
@@ -4916,9 +5051,11 @@ function analysisStates(now = Date.now(), jobs = allJobs()) {
4916
5051
  });
4917
5052
  continue;
4918
5053
  }
4919
- if (jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast)[0]?.phase === "failed") states.set(reviewId, {
5054
+ const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
5055
+ if (ended[0]?.phase === "failed") states.set(reviewId, {
4920
5056
  phase: "failed",
4921
- purpose: "review"
5057
+ purpose: "review",
5058
+ ...ended[0].failure?.stopped ? { stopped: true } : {}
4922
5059
  });
4923
5060
  }
4924
5061
  return states;
@@ -4936,7 +5073,7 @@ function reviewsRevision(live = analysisStates()) {
4936
5073
  for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
4937
5074
  for (const id of [...live.keys()].sort()) {
4938
5075
  const state = live.get(id);
4939
- hash.update(`${id}@${state.purpose}:${state.phase}\n`);
5076
+ hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
4940
5077
  }
4941
5078
  return hash.digest("base64url");
4942
5079
  }
@@ -4979,6 +5116,7 @@ var AskFailedError = class extends Error {};
4979
5116
  async function askAboutChange(workspace, question, sectionId, signal) {
4980
5117
  const cwd = await askCheckout(workspace);
4981
5118
  const env = agentEnvOf(workspace);
5119
+ const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
4982
5120
  try {
4983
5121
  return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
4984
5122
  capability: "read-code",
@@ -4986,10 +5124,11 @@ async function askAboutChange(workspace, question, sectionId, signal) {
4986
5124
  cwd,
4987
5125
  originRepoPath: workspace.repoPath,
4988
5126
  env,
5127
+ ...requestedModel ? { requestedModel } : {},
4989
5128
  timeoutMs: ASK_TIMEOUT_MS,
4990
5129
  signal,
4991
5130
  onUsage
4992
- })));
5131
+ }), void 0, requestedModel));
4993
5132
  } catch (error) {
4994
5133
  if (error instanceof AgentUnavailableError) throw new AskUnavailableError(`${error.message} It is what this review was made with, so the assistant cannot answer.`);
4995
5134
  throw explained(error, env);
@@ -5064,6 +5203,86 @@ function buildPrompt$1(workspace, question, sectionId, cwd) {
5064
5203
  ].join("\n");
5065
5204
  }
5066
5205
  //#endregion
5206
+ //#region ../../packages/review-harness/src/workspace/assistant_runs.ts
5207
+ var RUN_KINDS = ["ask", "diagram"];
5208
+ var runs = /* @__PURE__ */ new Map();
5209
+ function keyOf(workspaceId, kind) {
5210
+ return `${workspaceId}:${kind}`;
5211
+ }
5212
+ /**
5213
+ * Start a run, or learn that one is already going.
5214
+ *
5215
+ * One call rather than a check followed by a start, so there is no gap between them for a second
5216
+ * request to fall through. The existing run comes back rather than a bare refusal because the
5217
+ * caller's next move is to show it, not to report an error.
5218
+ *
5219
+ * A stopped run gives up its place at once, though its handler may still be unwinding. Stop is
5220
+ * over for the reviewer when it answers, and the page reopens Ask then. A stopped run can keep
5221
+ * nothing, and `endRun` leaves a newer run alone, so the two cannot collide. An answered run still
5222
+ * holds its place until it is saved, or the next question would be asked without it.
5223
+ */
5224
+ function beginRun(workspaceId, kind, detail, now = Date.now()) {
5225
+ const key = keyOf(workspaceId, kind);
5226
+ const existing = runs.get(key);
5227
+ if (existing && existing.state !== "stopped") return { alreadyRunning: existing };
5228
+ const controller = new AbortController();
5229
+ const run = {
5230
+ workspaceId,
5231
+ kind,
5232
+ question: detail.question ?? null,
5233
+ sectionId: detail.sectionId,
5234
+ startedAt: now,
5235
+ signal: controller.signal,
5236
+ state: "running",
5237
+ controller
5238
+ };
5239
+ runs.set(key, run);
5240
+ return { run };
5241
+ }
5242
+ /** Null for a stopped run as well: it is over for the page, whatever its handler is still doing. */
5243
+ function reportRun(workspaceId, kind) {
5244
+ const run = runs.get(keyOf(workspaceId, kind));
5245
+ if (!run || run.state === "stopped") return null;
5246
+ return {
5247
+ question: run.question,
5248
+ sectionId: run.sectionId,
5249
+ startedAt: run.startedAt
5250
+ };
5251
+ }
5252
+ /**
5253
+ * The reviewer pressed Stop.
5254
+ *
5255
+ * Idempotent: a second press, a press after the answer landed, and a press with nothing running are
5256
+ * all harmless and all answer false. True only when this call is the one that stopped the run.
5257
+ */
5258
+ function stopRun(workspaceId, kind) {
5259
+ const run = runs.get(keyOf(workspaceId, kind));
5260
+ if (!run || run.state !== "running") return false;
5261
+ run.state = "stopped";
5262
+ run.controller.abort();
5263
+ return true;
5264
+ }
5265
+ /**
5266
+ * The agent answered. Call this in the same continuation that received the answer, before anything
5267
+ * else is awaited, so a Stop handled in between cannot slip ahead of an answer that had already won.
5268
+ *
5269
+ * False when Stop got there first, and then the answer must not be kept however complete it is.
5270
+ */
5271
+ function completeRun(run) {
5272
+ const held = run;
5273
+ if (held.state !== "running") return false;
5274
+ held.state = "completed";
5275
+ return true;
5276
+ }
5277
+ /**
5278
+ * The run is over, whichever way. Removes this run and only this one: by the time a slow handler
5279
+ * gets here a newer run of the same kind may have begun, and it is not this handler's to end.
5280
+ */
5281
+ function endRun(run) {
5282
+ const key = keyOf(run.workspaceId, run.kind);
5283
+ if (runs.get(key) === run) runs.delete(key);
5284
+ }
5285
+ //#endregion
5067
5286
  //#region ../../packages/review-harness/src/workspace/diagram.ts
5068
5287
  /** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
5069
5288
  var DIAGRAM_TIMEOUT_MS = 18e4;
@@ -5120,6 +5339,7 @@ flowchart LR
5120
5339
  async function drawDiagram(workspace, sectionId, signal) {
5121
5340
  const cwd = await askCheckout(workspace);
5122
5341
  const env = agentEnvOf(workspace);
5342
+ const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
5123
5343
  let raw;
5124
5344
  try {
5125
5345
  raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
@@ -5128,10 +5348,11 @@ async function drawDiagram(workspace, sectionId, signal) {
5128
5348
  cwd,
5129
5349
  originRepoPath: workspace.repoPath,
5130
5350
  env,
5351
+ ...requestedModel ? { requestedModel } : {},
5131
5352
  timeoutMs: DIAGRAM_TIMEOUT_MS,
5132
5353
  signal,
5133
5354
  onUsage
5134
- }));
5355
+ }), void 0, requestedModel);
5135
5356
  } catch (error) {
5136
5357
  if (error instanceof AgentUnavailableError) throw new DiagramUnavailableError(`${error.message} It is what this review was made with, so nothing can be drawn.`);
5137
5358
  throw error;
@@ -5704,6 +5925,7 @@ async function handle(req, res, options) {
5704
5925
  if (action === "/file") return void sendFile(res, workspace, url.searchParams);
5705
5926
  if (action === "/ask") return handleAsk(req, res, workspace);
5706
5927
  if (action === "/diagram") return handleDiagram(req, res, workspace);
5928
+ if (action === "/stop") return handleStop(req, res, workspace);
5707
5929
  if (action === "/kiss") return handleKiss(req, res, workspace);
5708
5930
  if (action === "/state") return handleState(req, res, workspace);
5709
5931
  if (action === "/event") return handleEvent(req, res, workspace);
@@ -6162,6 +6384,8 @@ async function sendWorkspacePage(res, workspace) {
6162
6384
  repoPath: workspace.repoPath,
6163
6385
  askWidth: readPreferences().askWidth,
6164
6386
  transcript: workspace.transcript,
6387
+ pendingAsk: reportRun(workspace.id, "ask"),
6388
+ pendingDiagram: reportRun(workspace.id, "diagram"),
6165
6389
  prompts: readPromptBank(),
6166
6390
  complete: workspace.completedAt !== void 0,
6167
6391
  prContext: workspace.prContext ? {
@@ -6265,7 +6489,9 @@ function jobReport(workspace) {
6265
6489
  return {
6266
6490
  ...jobStatus(workspace.jobId ? loadJob(workspace.jobId) : null),
6267
6491
  kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null),
6268
- update: jobStatus(liveUpdateFor(workspace.id))
6492
+ update: jobStatus(liveUpdateFor(workspace.id)),
6493
+ ask: reportRun(workspace.id, "ask"),
6494
+ diagram: reportRun(workspace.id, "diagram")
6269
6495
  };
6270
6496
  }
6271
6497
  /**
@@ -6474,6 +6700,15 @@ async function handleAsk(req, res, workspace) {
6474
6700
  const question = typeof body?.question === "string" ? body.question.trim() : "";
6475
6701
  if (!question) return send(res, 400, { error: "No question given." });
6476
6702
  const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
6703
+ const started = beginRun(workspace.id, "ask", {
6704
+ question,
6705
+ sectionId
6706
+ });
6707
+ if ("alreadyRunning" in started) return send(res, 409, {
6708
+ error: "Buddy AI is still answering the last question on this review.",
6709
+ running: true
6710
+ });
6711
+ const run = started.run;
6477
6712
  record({
6478
6713
  event: "assistant_question_asked",
6479
6714
  reviewId: workspace.id,
@@ -6481,40 +6716,64 @@ async function handleAsk(req, res, workspace) {
6481
6716
  scoped: sectionId !== null,
6482
6717
  turn: workspace.transcript.length + 1
6483
6718
  });
6484
- const cancel = new AbortController();
6485
- res.on("close", () => {
6486
- if (!res.writableEnded) cancel.abort();
6487
- });
6488
6719
  const startedAt = Date.now();
6489
- let answer;
6720
+ const failed = () => record({
6721
+ event: "assistant_answered",
6722
+ reviewId: workspace.id,
6723
+ durationMs: Date.now() - startedAt,
6724
+ ok: false,
6725
+ ...run.state === "stopped" ? { cancelled: true } : {}
6726
+ });
6490
6727
  try {
6491
- answer = await askAboutChange(workspace, question, sectionId, cancel.signal);
6492
- } catch (error) {
6493
- const cancelled = error instanceof AgentCancelledError;
6728
+ let answer;
6729
+ try {
6730
+ answer = await askAboutChange(workspace, question, sectionId, run.signal);
6731
+ } catch (error) {
6732
+ failed();
6733
+ if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
6734
+ throw error;
6735
+ }
6736
+ if (!completeRun(run)) {
6737
+ failed();
6738
+ return sendIfOpen(res, 200, { stopped: true });
6739
+ }
6494
6740
  record({
6495
6741
  event: "assistant_answered",
6496
6742
  reviewId: workspace.id,
6497
6743
  durationMs: Date.now() - startedAt,
6498
- ok: false,
6499
- ...cancelled ? { cancelled: true } : {}
6744
+ ok: true
6500
6745
  });
6501
- if (cancelled) return;
6502
- throw error;
6746
+ const fresh = loadWorkspace(workspace.id);
6747
+ if (fresh) {
6748
+ fresh.transcript.push({
6749
+ question,
6750
+ answer,
6751
+ sectionId,
6752
+ askedAt: Date.now()
6753
+ });
6754
+ saveWorkspace(fresh);
6755
+ }
6756
+ sendIfOpen(res, 200, { answer });
6757
+ } finally {
6758
+ endRun(run);
6503
6759
  }
6504
- record({
6505
- event: "assistant_answered",
6506
- reviewId: workspace.id,
6507
- durationMs: Date.now() - startedAt,
6508
- ok: true
6509
- });
6510
- workspace.transcript.push({
6511
- question,
6512
- answer,
6513
- sectionId,
6514
- askedAt: Date.now()
6515
- });
6516
- saveWorkspace(workspace);
6517
- send(res, 200, { answer });
6760
+ }
6761
+ /** Only while somebody is listening. A page that left took its socket with it. */
6762
+ function sendIfOpen(res, status, body) {
6763
+ if (res.writableEnded || res.destroyed) return;
6764
+ send(res, status, body);
6765
+ }
6766
+ /**
6767
+ * The reviewer pressed Stop, on this page or on another one showing the same review.
6768
+ *
6769
+ * The only way a question or a drawing ends early. Idempotent, so a double press and a press that
6770
+ * lands as the answer does are both harmless: the run decides once which of the two won.
6771
+ */
6772
+ async function handleStop(req, res, workspace) {
6773
+ const body = await readJson(req);
6774
+ const kind = RUN_KINDS.find((candidate) => candidate === body?.kind);
6775
+ if (!kind) return send(res, 400, { error: "Say what to stop: the question or the drawing." });
6776
+ send(res, 200, { stopped: stopRun(workspace.id, kind) });
6518
6777
  }
6519
6778
  /**
6520
6779
  * Draw the change, or one theme of it, and keep the drawing.
@@ -6528,46 +6787,60 @@ async function handleDiagram(req, res, workspace) {
6528
6787
  const body = await readJson(req);
6529
6788
  const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
6530
6789
  const scope = sectionId ?? "change";
6531
- const existing = (workspace.diagrams ?? []).findIndex((diagram) => diagram.scope === scope);
6790
+ const redraw = (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
6791
+ const started = beginRun(workspace.id, "diagram", { sectionId });
6792
+ if ("alreadyRunning" in started) return send(res, 409, {
6793
+ error: "A diagram of this review is already being drawn.",
6794
+ running: true
6795
+ });
6796
+ const run = started.run;
6532
6797
  record({
6533
6798
  event: "diagram_requested",
6534
6799
  reviewId: workspace.id,
6535
6800
  scoped: sectionId !== null,
6536
- redraw: existing !== -1
6537
- });
6538
- const cancel = new AbortController();
6539
- res.on("close", () => {
6540
- if (!res.writableEnded) cancel.abort();
6801
+ redraw
6541
6802
  });
6542
6803
  const startedAt = Date.now();
6543
- let diagram;
6804
+ const failed = () => record({
6805
+ event: "diagram_drawn",
6806
+ reviewId: workspace.id,
6807
+ durationMs: Date.now() - startedAt,
6808
+ ok: false,
6809
+ ...run.state === "stopped" ? { cancelled: true } : {}
6810
+ });
6544
6811
  try {
6545
- diagram = await drawDiagram(workspace, sectionId, cancel.signal);
6546
- } catch (error) {
6547
- const cancelled = error instanceof AgentCancelledError;
6812
+ let diagram;
6813
+ try {
6814
+ diagram = await drawDiagram(workspace, sectionId, run.signal);
6815
+ } catch (error) {
6816
+ failed();
6817
+ if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
6818
+ if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return sendIfOpen(res, 502, { error: error.message });
6819
+ throw error;
6820
+ }
6821
+ if (!completeRun(run)) {
6822
+ failed();
6823
+ return sendIfOpen(res, 200, { stopped: true });
6824
+ }
6548
6825
  record({
6549
6826
  event: "diagram_drawn",
6550
6827
  reviewId: workspace.id,
6551
6828
  durationMs: Date.now() - startedAt,
6552
- ok: false,
6553
- ...cancelled ? { cancelled: true } : {}
6829
+ ok: true
6554
6830
  });
6555
- if (cancelled) return;
6556
- if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return send(res, 502, { error: error.message });
6557
- throw error;
6831
+ const fresh = loadWorkspace(workspace.id);
6832
+ if (fresh) {
6833
+ const diagrams = [...fresh.diagrams ?? []];
6834
+ const existing = diagrams.findIndex((one) => one.scope === scope);
6835
+ if (existing === -1) diagrams.push(diagram);
6836
+ else diagrams[existing] = diagram;
6837
+ fresh.diagrams = diagrams;
6838
+ saveWorkspace(fresh);
6839
+ }
6840
+ sendIfOpen(res, 200, { diagram });
6841
+ } finally {
6842
+ endRun(run);
6558
6843
  }
6559
- record({
6560
- event: "diagram_drawn",
6561
- reviewId: workspace.id,
6562
- durationMs: Date.now() - startedAt,
6563
- ok: true
6564
- });
6565
- const diagrams = [...workspace.diagrams ?? []];
6566
- if (existing === -1) diagrams.push(diagram);
6567
- else diagrams[existing] = diagram;
6568
- workspace.diagrams = diagrams;
6569
- saveWorkspace(workspace);
6570
- send(res, 200, { diagram });
6571
6844
  }
6572
6845
  /**
6573
6846
  * Stop serving, at the request of a workspace.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prreviewbuddy",
3
- "version": "0.25.13",
3
+ "version": "0.27.2",
4
4
  "description": "Review a branch or pull request in an isolated checkout, from your terminal, with the coding agent you already have installed.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://prreviewbuddy.com",