prreviewbuddy 0.25.8 → 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,7 +1,7 @@
1
- import { $ as allJobs, A as agentEnvOf, At as processDiscussion, B as BUILD_VERSION, Bt as STORE_ROOT, C as statedWorkspacePort, Ct as touchWorkspace, D as refreshPrContext, Dt as issuesOutstanding, E as updateReview, Et as isQuestionOutstanding, H as feedbackUrl, I as readEvents, It as clearAgentPreference, J as MANAGED_ROOT, K as fillFileUrlTemplate, Kt as AgentCancelledError, L as record, Lt as readAgentPreference, M as startKissJob, O as isUnchanged, Ot as questionsOutstanding, P as runJob, Pt as git, Q as checkFreshness, R as recordTelemetryUploadConsent, Rt as writeAgentPreference, S as resolveWorkspacePort, T as RefreshUnavailableError, Tt as isIssueOutstanding, U as withUsageRecorded, Vt as AGENT_IDS, W as EXPLAIN_SIMPLY_PROMPT, Wt as agentFor, X as removeWorktree, Y as readMarker, Z as checkCodeFreshness, _ as writeServerRecord, _t as recentReviewGroups, a as liveJobsFor, at as progressSteps, b as MIN_WORKSPACE_PORT, c as discardJob, ct as followedRefName, d as clearServerRecord, et as fail, ft as lineagePosition, gt as previousKissResult, i as lineageIds, it as PHASES, jt as describeAuthorship, k as askCheckout, kt as reviewerDispositions, l as readIndexToken, n as deleteLineage, nt as loadJob, o as wasBlocked, p as readServerRecord, pt as loadWorkspace, q as ReviewBeingDeletedError, qt as AgentUnavailableError, r as deleteReview, rt as saveJob, st as isClaimed, t as relativeTime$1, tt as isTerminal, ut as latestKissRun, v as DEFAULT_WORKSPACE_PORT, w as writePortPreference, wt as doneVerb, x as parseWorkspacePort, xt as saveWorkspace, y as MAX_WORKSPACE_PORT, yt as reviewedCommit, z as telemetryUploadConsent } from "./relative_time-D760FOL9.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
- import { timingSafeEqual } from "node:crypto";
4
+ import { createHash, timingSafeEqual } from "node:crypto";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createServer } from "node:http";
7
7
  //#region ../../packages/review-harness/src/domain/ai_review/review_composition.ts
@@ -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.
@@ -2144,9 +2257,9 @@ function verdictLabel(verdict) {
2144
2257
  */
2145
2258
  var PHASE_CAVEAT = { preparing: "This can take a minute on a large repository." };
2146
2259
  /** The shared phase name as a sentence, with whatever this surface adds to it. */
2147
- function phaseCopy(phase) {
2148
- const caveat = PHASE_CAVEAT[phase];
2149
- return `${PHASES[phase].doing}.${caveat ? ` ${caveat}` : ""}`;
2260
+ function phaseCopy(phase, job) {
2261
+ const caveat = purposeOf(job) === "review" ? PHASE_CAVEAT[phase] : void 0;
2262
+ return `${phasesFor(job)[phase].doing}.${caveat ? ` ${caveat}` : ""}`;
2150
2263
  }
2151
2264
  /**
2152
2265
  * The same phases, as a noun, for the retry sentence.
@@ -2204,7 +2317,7 @@ function conversationNote$1(outcome) {
2204
2317
  * than a gap where a sentence should be.
2205
2318
  */
2206
2319
  function phaseSentence(job) {
2207
- return job && isWorkPhase(job.phase) ? phaseCopy(job.phase) : "";
2320
+ return job && isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "";
2208
2321
  }
2209
2322
  /**
2210
2323
  * A stopped run: what happened, and what would change it.
@@ -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>
@@ -2248,7 +2364,7 @@ function renderJobState(job) {
2248
2364
  <p>${escapeHtml$1(note)}</p>
2249
2365
  </div>`;
2250
2366
  }
2251
- const copy = isWorkPhase(job.phase) ? phaseCopy(job.phase) : "";
2367
+ const copy = isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "";
2252
2368
  const progress = job.progress ? ` <span class="prb-job-progress">${escapeHtml$1(job.progress)}</span>` : "";
2253
2369
  return `<div class="prb-job" data-job-phase="${escapeHtml$1(job.phase)}" role="status" aria-live="polite">
2254
2370
  <p><span class="prb-spinner" aria-hidden="true"></span> ${escapeHtml$1(copy)}${progress}</p>
@@ -2261,7 +2377,7 @@ function jobStatus(job) {
2261
2377
  progress: "",
2262
2378
  failed: false
2263
2379
  };
2264
- const progress = job.phase === "failed" ? job.failure?.message ?? "" : job.progress ?? (isWorkPhase(job.phase) ? phaseCopy(job.phase) : "");
2380
+ const progress = job.phase === "failed" ? job.failure?.message ?? "" : job.progress ?? (isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "");
2265
2381
  return {
2266
2382
  phase: job.phase,
2267
2383
  progress,
@@ -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
  /**
@@ -3121,9 +3253,10 @@ function changeIsRead(job) {
3121
3253
  * second renderer, and the two would disagree the first time either changed.
3122
3254
  */
3123
3255
  function renderSteps(job) {
3256
+ const names = phasesFor(job);
3124
3257
  return `<ol class="prb-progress-steps">${progressSteps(job).map((step) => {
3125
3258
  const note = step.note ? `<p class="prb-progress-note" data-step-note>${escapeHtml$1(step.note)}</p>` : "";
3126
- return `<li class="prb-progress-step" data-phase="${escapeHtml$1(step.phase)}" data-state="${escapeHtml$1(step.state)}" data-label-doing="${escapeHtml$1(PHASES[step.phase].doing)}" data-label-done="${escapeHtml$1(PHASES[step.phase].done)}">
3259
+ return `<li class="prb-progress-step" data-phase="${escapeHtml$1(step.phase)}" data-state="${escapeHtml$1(step.state)}" data-label-doing="${escapeHtml$1(names[step.phase].doing)}" data-label-done="${escapeHtml$1(names[step.phase].done)}">
3127
3260
  <span class="prb-progress-mark" aria-hidden="true"></span>
3128
3261
  <span class="prb-progress-label">${escapeHtml$1(step.label)}</span>
3129
3262
  ${note}
@@ -3165,6 +3298,25 @@ function renderProgressView(job, fileCount) {
3165
3298
  ${renderAvailable(job, fileCount)}
3166
3299
  <p class="prb-progress-elapsed" data-started-at="${job.startedAt}">Running for ${escapeHtml$1(elapsedWords(job.startedAt))}.</p>`;
3167
3300
  }
3301
+ /**
3302
+ * An update running over the review it is updating.
3303
+ *
3304
+ * A strip above the review rather than the progress pane, because nothing here is missing. The
3305
+ * review below is still the current one until the update applies in a single write, so it stays
3306
+ * readable and in place; taking the page over would hide the one thing worth reading while waiting.
3307
+ *
3308
+ * Nothing once the job has ended. The reload that follows an update is what reports it, through the
3309
+ * outcome notice, and a strip still standing after that would be describing work that is over.
3310
+ */
3311
+ function renderUpdateProgress(job) {
3312
+ if (!job || job.phase === "done" || job.phase === "failed") return "";
3313
+ return `<div class="prb-update-progress" data-update-phase="${escapeHtml$1(job.phase)}" role="status" aria-live="polite">
3314
+ <p class="prb-update-progress-title"><span class="prb-spinner" aria-hidden="true"></span>Updating this review</p>
3315
+ <p class="prb-update-progress-lead">The review below stays as it is until the update finishes.</p>
3316
+ ${renderSteps(job)}
3317
+ <p class="prb-progress-elapsed" data-started-at="${job.startedAt}">Running for ${escapeHtml$1(elapsedWords(job.startedAt))}.</p>
3318
+ </div>`;
3319
+ }
3168
3320
  //#endregion
3169
3321
  //#region ../../packages/review-harness/src/workspace/page.ts
3170
3322
  function renderWorkspace(view) {
@@ -3194,13 +3346,14 @@ function renderWorkspace(view) {
3194
3346
  <link rel="stylesheet" href="/assets/sidebar.css">
3195
3347
  <link rel="stylesheet" href="/assets/shell.css">
3196
3348
  </head>
3197
- <body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
3349
+ <body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
3198
3350
  ${renderHeader(view, freshness)}
3199
3351
  ${showsProgress(view) ? "" : renderJobState(view.job ?? null)}
3200
3352
  <div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml$1(JSON.stringify(freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
3201
3353
  dismissedSha: view.dismissedFreshnessSha,
3202
3354
  actions: view.token !== void 0
3203
3355
  })}</div>
3356
+ ${renderUpdateProgress(view.updateJob)}
3204
3357
  ${renderUpdateOutcome(view.lastUpdate)}
3205
3358
  ${renderLineageNotice(view)}
3206
3359
  ${renderReviewBody(view, sections)}
@@ -3331,7 +3484,7 @@ function renderStandardViews(view, sections) {
3331
3484
  detailsInHeader: true
3332
3485
  })}</section>
3333
3486
  <section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null, { diagrams: true })}</section>
3334
- <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 ?? "")}"` : ""}>
3335
3488
  <h3 class="prb-doc-title">Diagrams</h3>
3336
3489
  ${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
3337
3490
  </section>
@@ -4037,6 +4190,9 @@ function renderShutdownDialog() {
4037
4190
  function renderAsk(view, sections) {
4038
4191
  const suggestions = buildSuggestions(view, sections);
4039
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>` : "";
4040
4196
  return `<aside class="prb-ask" aria-label="Ask Buddy AI">
4041
4197
  <div
4042
4198
  class="prb-ask-resize"
@@ -4052,8 +4208,8 @@ function renderAsk(view, sections) {
4052
4208
  </div>
4053
4209
  ${renderPromptBank(view.prompts ?? [])}
4054
4210
  <div class="prb-ask-log" data-ask-log>
4055
- ${transcript.length > 0 ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
4056
- <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">
4057
4213
  <p>Ask about this change. Answers come from the review, the diff and the repository on disk — not from anywhere else.</p>
4058
4214
  ${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
4059
4215
  </div>`}
@@ -4131,6 +4287,21 @@ function statusLabel(status) {
4131
4287
  if (status === "complete") return "Reviewed";
4132
4288
  return status === "in-progress" ? "In progress" : "New";
4133
4289
  }
4290
+ /**
4291
+ * Whether any of that is work still going on, which is what sets the polling cadence.
4292
+ *
4293
+ * Here rather than beside `analysisStates` because this module is the DOM-free, filesystem-free
4294
+ * arithmetic both halves of the index share, and the renderer needs this one answer without
4295
+ * needing an import that reads the jobs directory to get it.
4296
+ *
4297
+ * A stopped analysis is a state, not an activity. Counting it as live would leave the index asking
4298
+ * once a second about a review that finished failing an hour ago, for as long as the tab stayed
4299
+ * open.
4300
+ */
4301
+ function anythingRunning(states) {
4302
+ for (const state of states.values()) if (state.phase !== "failed") return true;
4303
+ return false;
4304
+ }
4134
4305
  //#endregion
4135
4306
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
4136
4307
  function renderReviewsPage(view) {
@@ -4147,7 +4318,7 @@ function renderReviewsPage(view) {
4147
4318
  <link rel="stylesheet" href="/assets/sidebar.css">
4148
4319
  <link rel="stylesheet" href="/assets/shell.css">
4149
4320
  </head>
4150
- <body class="prb-index-body">
4321
+ <body class="prb-index-body"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
4151
4322
  <header class="prb-header">
4152
4323
  <span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
4153
4324
  <span class="prb-change-ref">Reviews</span>
@@ -4164,7 +4335,7 @@ function renderReviewsPage(view) {
4164
4335
  <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
4165
4336
  </div>`}
4166
4337
  <div class="prb-cards" data-cards>
4167
- ${ordered.map((group) => renderCard(group, now)).join("")}
4338
+ ${ordered.map((group) => renderCard(group, now, view.analyses?.get(group.latest.id))).join("")}
4168
4339
  </div>
4169
4340
  ${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
4170
4341
  ${ordered.length > 15 ? renderPager() : ""}
@@ -4265,7 +4436,7 @@ function renderBulkBar() {
4265
4436
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
4266
4437
  * than a rebuild of the list.
4267
4438
  */
4268
- function renderCard(group, now) {
4439
+ function renderCard(group, now, analysis) {
4269
4440
  const review = group.latest;
4270
4441
  const status = statusOf(review);
4271
4442
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
@@ -4291,7 +4462,8 @@ function renderCard(group, now) {
4291
4462
  ${renderRisk(review.overallRisk)}
4292
4463
  <span>${review.changedFiles} file${review.changedFiles === 1 ? "" : "s"}</span>
4293
4464
  <span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">&minus;${review.deletions}</span></span>
4294
- <span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>
4465
+ ${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
4466
+ ${renderAnalysisState(analysis)}
4295
4467
  <span class="prb-card-when"><time datetime="${new Date(touchedAt(review)).toISOString()}" title="${escapeHtml$1(new Date(touchedAt(review)).toLocaleString())}">${escapeHtml$1(whenLabel(review, now))}</time></span>
4296
4468
  </p>
4297
4469
  </a>
@@ -4426,6 +4598,28 @@ function renderHistoryRow(review, number, latest, now) {
4426
4598
  </li>`;
4427
4599
  }
4428
4600
  /**
4601
+ * What is being done to this review right now, when anything is.
4602
+ *
4603
+ * The words come from `PHASES` in `job_progress.ts` and are not written again here. That table is
4604
+ * already what the terminal prints and what the review page's checklist shows, and the last time a
4605
+ * surface kept its own copy the two drifted until a stopped run read "Analysed the change" -- a
4606
+ * tick beside a verb saying the work had happened. One table, three surfaces.
4607
+ *
4608
+ * A failed analysis says so rather than saying nothing. Before this, a review being analysed, one
4609
+ * whose analysis had failed and one never analysed were the same card: all three said "No standard
4610
+ * review yet", which is true of all three and useful about none of them.
4611
+ *
4612
+ * Nothing at all for a job that is neither live nor failed. That is the non-terminal job with no
4613
+ * driver behind it -- killed terminal, crashed process -- which sits in a working phase for up to
4614
+ * half an hour before the sweep calls it stopped. Narrating it as running would be a lie, and
4615
+ * calling it stopped here would be this renderer pre-empting a decision the sweep makes.
4616
+ */
4617
+ function renderAnalysisState(analysis) {
4618
+ if (analysis === void 0) return "";
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>";
4620
+ return `<span class="prb-card-analysis" data-analysis="running"><i></i>${escapeHtml$1(phasesFor({ purpose: analysis.purpose })[analysis.phase].doing)}</span>`;
4621
+ }
4622
+ /**
4429
4623
  * What this review still has open.
4430
4624
  *
4431
4625
  * Unresolved counts, not totals: "1 unanswered question" says where the review stands, where
@@ -4758,6 +4952,132 @@ function renderPrivacySection(settings) {
4758
4952
  </section>`;
4759
4953
  }
4760
4954
  //#endregion
4955
+ //#region ../../packages/review-harness/src/workspace/reviews_revision.ts
4956
+ /**
4957
+ * Whether anything the reviews index draws has changed, as one token.
4958
+ *
4959
+ * The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
4960
+ * never needs to know *which* review changed, so it asks one question whose cost is the same for
4961
+ * five reviews and five hundred. Per-card revisions would be more information than the page has any
4962
+ * use for, and a request per card to carry it.
4963
+ *
4964
+ * Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
4965
+ * tokens for equality, never order them, never parse one. The implementation below is a hash of
4966
+ * facts the page happens to draw today, and the whole point of saying so here is that it can be
4967
+ * replaced by a counter, a watch, or anything else without a page having to be told.
4968
+ *
4969
+ * ## What is in it, and what is deliberately not
4970
+ *
4971
+ * In: every review's id and file revision, and the phase of every analysis running right now.
4972
+ * Between them those cover everything a card shows — the summary, the risk badge, the counts, the
4973
+ * pin, the reviewed mark, the running line — because every one of those reaches disk through
4974
+ * `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
4975
+ *
4976
+ * Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
4977
+ * streams its progress into the job record every few seconds, so a token derived from job file
4978
+ * mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
4979
+ * would then reload every few seconds for the whole length of an analysis — a page thrashing
4980
+ * itself, with the reviewer's scroll position going with it. Phase only, which changes four times
4981
+ * in a run.
4982
+ */
4983
+ /**
4984
+ * What is being done to each review, or was last done and stopped.
4985
+ *
4986
+ * Keyed by review, valued by the phase the card should name. Two states reach it and a third
4987
+ * deliberately does not:
4988
+ *
4989
+ * - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
4990
+ * load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
4991
+ * before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
4992
+ * be the index confidently reporting work that nothing is doing.
4993
+ * - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
4994
+ * this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
4995
+ * `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
4996
+ * and the card's "Analysis stopped" branch was unreachable in production while passing a unit
4997
+ * test that handed it a state the product could not produce. A review whose analysis failed went
4998
+ * back to reading "No standard review yet" the moment its driver let go.
4999
+ * - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
5000
+ * result on the card already, and an abandoned one is the sweep's to judge rather than this
5001
+ * renderer's to pre-empt.
5002
+ *
5003
+ * **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
5004
+ * a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
5005
+ * KISS run, and the review page's poll is built around one finishing while the other goes on. An
5006
+ * earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
5007
+ * first spoke for a standard analysis that was still working, and the card fell silent on work that
5008
+ * was happening in front of the reviewer.
5009
+ *
5010
+ * So the precedence is running, then the most recent ending, and it is stated in that order because
5011
+ * that is the property: **nothing that has ended can hide something that has not.**
5012
+ *
5013
+ * `jobs` is injected only so a test can hand the same set in two different orders and prove the
5014
+ * answer does not move. Production always reads the directory.
5015
+ *
5016
+ * A failure is visible for as long as the job record survives, which is the sweep's retention. Once
5017
+ * that passes the card quietly returns to saying nothing, which is the same silence every review
5018
+ * had before this existed.
5019
+ */
5020
+ function analysisStates(now = Date.now(), jobs = allJobs()) {
5021
+ const byReview = /* @__PURE__ */ new Map();
5022
+ for (const job of jobs) {
5023
+ if (purposeOf(job) === "ask") continue;
5024
+ const held = byReview.get(job.workspaceId);
5025
+ if (held) held.push(job);
5026
+ else byReview.set(job.workspaceId, [job]);
5027
+ }
5028
+ /**
5029
+ * Both comparators are **total**, and that is a requirement rather than tidiness.
5030
+ *
5031
+ * `allJobs` reads a directory, so a tie leaves the winner to whatever order the filesystem
5032
+ * happened to return. This function's output is hashed into the index token, and the page reloads
5033
+ * whenever that token changes -- so an arbitrary winner is not a cosmetic wobble, it is the page
5034
+ * reloading itself for as long as the tie exists, with no cause a reviewer could ever see. The
5035
+ * same hazard the sorting rule was written for, one level further down.
5036
+ *
5037
+ * Ties are real. Two jobs can carry the same timestamp to the millisecond, and a record written
5038
+ * before `finishedAt` existed falls back to `updatedAt`, which two records can share. The id is
5039
+ * the last resort because it is the only field guaranteed to differ.
5040
+ */
5041
+ const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
5042
+ const startedLast = (a, b) => b.startedAt - a.startedAt || byId(a, b);
5043
+ const endedLast = (a, b) => endedAt(b) - endedAt(a) || b.startedAt - a.startedAt || byId(a, b);
5044
+ const states = /* @__PURE__ */ new Map();
5045
+ for (const [reviewId, jobs] of byReview) {
5046
+ const running = jobs.filter((job) => !isTerminal(job) && isClaimed(job.id, now)).sort(startedLast);
5047
+ if (running.length > 0) {
5048
+ states.set(reviewId, {
5049
+ phase: running[0].phase,
5050
+ purpose: purposeOf(running[0])
5051
+ });
5052
+ continue;
5053
+ }
5054
+ const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
5055
+ if (ended[0]?.phase === "failed") states.set(reviewId, {
5056
+ phase: "failed",
5057
+ purpose: "review",
5058
+ ...ended[0].failure?.stopped ? { stopped: true } : {}
5059
+ });
5060
+ }
5061
+ return states;
5062
+ }
5063
+ /**
5064
+ * The token.
5065
+ *
5066
+ * Takes the live analyses rather than reading them, so that one request answers with a token and a
5067
+ * running state that describe the same instant. Computing them separately would let a phase change
5068
+ * land between the two, and the page would then be told "nothing has changed" alongside a state it
5069
+ * had not seen.
5070
+ */
5071
+ function reviewsRevision(live = analysisStates()) {
5072
+ const hash = createHash("sha1");
5073
+ for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
5074
+ for (const id of [...live.keys()].sort()) {
5075
+ const state = live.get(id);
5076
+ hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
5077
+ }
5078
+ return hash.digest("base64url");
5079
+ }
5080
+ //#endregion
4761
5081
  //#region ../../packages/review-harness/src/workspace/ask.ts
4762
5082
  /** Long enough for a real look at the repository; short enough that a hang is not forever. */
4763
5083
  var ASK_TIMEOUT_MS = 18e4;
@@ -4796,6 +5116,7 @@ var AskFailedError = class extends Error {};
4796
5116
  async function askAboutChange(workspace, question, sectionId, signal) {
4797
5117
  const cwd = await askCheckout(workspace);
4798
5118
  const env = agentEnvOf(workspace);
5119
+ const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
4799
5120
  try {
4800
5121
  return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
4801
5122
  capability: "read-code",
@@ -4803,10 +5124,11 @@ async function askAboutChange(workspace, question, sectionId, signal) {
4803
5124
  cwd,
4804
5125
  originRepoPath: workspace.repoPath,
4805
5126
  env,
5127
+ ...requestedModel ? { requestedModel } : {},
4806
5128
  timeoutMs: ASK_TIMEOUT_MS,
4807
5129
  signal,
4808
5130
  onUsage
4809
- })));
5131
+ }), void 0, requestedModel));
4810
5132
  } catch (error) {
4811
5133
  if (error instanceof AgentUnavailableError) throw new AskUnavailableError(`${error.message} It is what this review was made with, so the assistant cannot answer.`);
4812
5134
  throw explained(error, env);
@@ -4881,6 +5203,86 @@ function buildPrompt$1(workspace, question, sectionId, cwd) {
4881
5203
  ].join("\n");
4882
5204
  }
4883
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
4884
5286
  //#region ../../packages/review-harness/src/workspace/diagram.ts
4885
5287
  /** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
4886
5288
  var DIAGRAM_TIMEOUT_MS = 18e4;
@@ -4937,6 +5339,7 @@ flowchart LR
4937
5339
  async function drawDiagram(workspace, sectionId, signal) {
4938
5340
  const cwd = await askCheckout(workspace);
4939
5341
  const env = agentEnvOf(workspace);
5342
+ const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
4940
5343
  let raw;
4941
5344
  try {
4942
5345
  raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
@@ -4945,10 +5348,11 @@ async function drawDiagram(workspace, sectionId, signal) {
4945
5348
  cwd,
4946
5349
  originRepoPath: workspace.repoPath,
4947
5350
  env,
5351
+ ...requestedModel ? { requestedModel } : {},
4948
5352
  timeoutMs: DIAGRAM_TIMEOUT_MS,
4949
5353
  signal,
4950
5354
  onUsage
4951
- }));
5355
+ }), void 0, requestedModel);
4952
5356
  } catch (error) {
4953
5357
  if (error instanceof AgentUnavailableError) throw new DiagramUnavailableError(`${error.message} It is what this review was made with, so nothing can be drawn.`);
4954
5358
  throw error;
@@ -5420,6 +5824,64 @@ var CONTENT_TYPES = {
5420
5824
  ".woff2": "font/woff2",
5421
5825
  ".png": "image/png"
5422
5826
  };
5827
+ /**
5828
+ * The freshness policy, in three classes and one place.
5829
+ *
5830
+ * Every response this server sends falls into exactly one of them, and which one is decided by the
5831
+ * helper that writes it rather than by the route that produced it. That is the whole shape of this:
5832
+ * a route may not have an opinion about caching, because the bug this replaced was four HTML
5833
+ * responses that each said nothing and were each individually reasonable.
5834
+ *
5835
+ * mutable workspace state -> no-store never stored, so never reused
5836
+ * fingerprinted asset -> immutable reused indefinitely; the hash is the identity
5837
+ * other static asset -> no-cache may be stored, must revalidate before reuse
5838
+ *
5839
+ * `no-cache` is a rule about reuse and not a ban on storage, which is the thing about these two
5840
+ * names that is worth saying out loud once: the assets already carried it while the pages, which
5841
+ * are the things that actually change, carried nothing at all. A reviewer marked a review reviewed,
5842
+ * pressed Back, and was shown the list as it had been — and the server had been right the whole
5843
+ * time, because it was never asked.
5844
+ *
5845
+ * The browser half of the same policy is `reloadOnRestore` in `client/page_restore.ts`. Nothing
5846
+ * here reaches the back/forward cache reliably: a restored page is a frozen document being thawed
5847
+ * rather than a stored response being reused, and browsers disagree about whether `no-store` should
5848
+ * keep a document out of that cache. Only the page itself can refuse its own restoration.
5849
+ */
5850
+ var NO_STORE = "no-store";
5851
+ var REVALIDATE = "no-cache";
5852
+ var IMMUTABLE = "public, max-age=31536000, immutable";
5853
+ /**
5854
+ * Whether this asset's name encodes its content, which is the only thing that makes `immutable`
5855
+ * safe to say about it.
5856
+ *
5857
+ * The names come from `vite.client.ts`, which emits `chunk-[hash].js` for everything the bundler
5858
+ * split out and a bare `[name].js` for the three page entries. So the chunks may be kept forever —
5859
+ * a new build produces new names and the old ones are simply never asked for again — while
5860
+ * `workspace.js`, the stylesheets, the font and the icon are all names that survive an upgrade
5861
+ * with different content behind them, and must be revalidated every time they are reused.
5862
+ *
5863
+ * `freshness_policy.test.ts` holds this to the vite option it is derived from. The two halves are
5864
+ * in different packages' build config and source, and a change to the emitted name would otherwise
5865
+ * turn "cache forever" from safe into a stale asset nobody can clear.
5866
+ */
5867
+ function isFingerprinted(name) {
5868
+ return /^chunk-/.test(name);
5869
+ }
5870
+ /**
5871
+ * An HTML document, which is always a view of mutable workspace state.
5872
+ *
5873
+ * Every HTML response goes through here. There is no second way to write one, and
5874
+ * `freshness_policy.test.ts` is what keeps it that way: the cost of this bug was not that somebody
5875
+ * chose wrong, it was that four routes each got to choose.
5876
+ */
5877
+ function sendHtml(res, status, html, cookie) {
5878
+ res.writeHead(status, {
5879
+ "content-type": "text/html; charset=utf-8",
5880
+ "cache-control": NO_STORE,
5881
+ ...cookie ? { "set-cookie": cookie } : {}
5882
+ });
5883
+ res.end(html);
5884
+ }
5423
5885
  function createWorkspaceServer(options = {}) {
5424
5886
  return createServer((req, res) => {
5425
5887
  handle(req, res, options).catch((error) => {
@@ -5443,6 +5905,7 @@ async function handle(req, res, options) {
5443
5905
  if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
5444
5906
  if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
5445
5907
  if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
5908
+ if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
5446
5909
  const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
5447
5910
  if (!match) return send(res, 404, { error: "Not found" });
5448
5911
  const [, id, action] = match;
@@ -5451,7 +5914,7 @@ async function handle(req, res, options) {
5451
5914
  if (!tokenMatches(workspace, url.searchParams.get("t"))) return send(res, 403, { error: "This link is missing its access token." });
5452
5915
  if (url.pathname.startsWith("/r/")) {
5453
5916
  touchWorkspace(workspace);
5454
- return sendHtml(res, workspace);
5917
+ return sendWorkspacePage(res, workspace);
5455
5918
  }
5456
5919
  if (action === "/job") return send(res, 200, jobReport(workspace));
5457
5920
  if (action === "/retry") return handleRetry(res, workspace);
@@ -5462,6 +5925,7 @@ async function handle(req, res, options) {
5462
5925
  if (action === "/file") return void sendFile(res, workspace, url.searchParams);
5463
5926
  if (action === "/ask") return handleAsk(req, res, workspace);
5464
5927
  if (action === "/diagram") return handleDiagram(req, res, workspace);
5928
+ if (action === "/stop") return handleStop(req, res, workspace);
5465
5929
  if (action === "/kiss") return handleKiss(req, res, workspace);
5466
5930
  if (action === "/state") return handleState(req, res, workspace);
5467
5931
  if (action === "/event") return handleEvent(req, res, workspace);
@@ -5525,12 +5989,12 @@ function handleIndex(req, res, params, indexToken) {
5525
5989
  }
5526
5990
  const cookie = indexCookieHeader(indexToken);
5527
5991
  if (onward) return redirect(res, onward, cookie);
5528
- const html = renderReviewsPage({ groups: recentReviewGroups(MAX_LISTED_BRANCHES) });
5529
- res.writeHead(200, {
5530
- "content-type": "text/html; charset=utf-8",
5531
- "set-cookie": cookie
5532
- });
5533
- res.end(html);
5992
+ const states = analysisStates();
5993
+ sendHtml(res, 200, renderReviewsPage({
5994
+ groups: recentReviewGroups(MAX_LISTED_BRANCHES),
5995
+ analyses: states,
5996
+ revision: reviewsRevision(states)
5997
+ }), cookie);
5534
5998
  }
5535
5999
  /**
5536
6000
  * Where `?go=` is allowed to send someone, which is one shape and no other.
@@ -5547,6 +6011,7 @@ function safeDestination(value) {
5547
6011
  function redirect(res, location, cookie) {
5548
6012
  res.writeHead(302, {
5549
6013
  location,
6014
+ "cache-control": NO_STORE,
5550
6015
  ...cookie ? { "set-cookie": cookie } : {}
5551
6016
  });
5552
6017
  res.end();
@@ -5569,8 +6034,7 @@ function redirect(res, location, cookie) {
5569
6034
  function refuseIndex(req, res) {
5570
6035
  const way = wayBack();
5571
6036
  if (!wantsHtml(req)) return send(res, 403, { error: `This link is missing its access token. Run ${wayBackSentence(way)} to get the current one.` });
5572
- res.writeHead(403, { "content-type": "text/html; charset=utf-8" });
5573
- res.end(page("Your reviews", `<h1>This browser has not been shown your reviews yet</h1>
6037
+ sendHtml(res, 403, page("Your reviews", `<h1>This browser has not been shown your reviews yet</h1>
5574
6038
  <p>Nothing is lost. Every review is still here, and the link you came from still works. The
5575
6039
  list has its own credential, separate from the one that opens a single review, so a review
5576
6040
  page is not able to hand it over.</p>
@@ -5614,12 +6078,7 @@ async function handleSettings(req, res, params, indexToken) {
5614
6078
  error
5615
6079
  });
5616
6080
  }
5617
- const html = renderSettingsPage({ settings: readSettings() });
5618
- res.writeHead(200, {
5619
- "content-type": "text/html; charset=utf-8",
5620
- "set-cookie": indexCookieHeader(indexToken)
5621
- });
5622
- res.end(html);
6081
+ sendHtml(res, 200, renderSettingsPage({ settings: readSettings() }), indexCookieHeader(indexToken));
5623
6082
  }
5624
6083
  /**
5625
6084
  * Whether the caller is a browser navigating, rather than something reading the API.
@@ -5693,6 +6152,34 @@ async function handleStatus(req, res, indexToken) {
5693
6152
  });
5694
6153
  }
5695
6154
  /**
6155
+ * Has anything the reviews index draws changed, and is anything still being made.
6156
+ *
6157
+ * The whole of the index's liveness, in one request that costs the same whether the machine holds
6158
+ * five reviews or five hundred. The page reloads when `revision` differs from the one it was
6159
+ * rendered with; it never needs to know which review changed, because its answer to any change is
6160
+ * the same answer.
6161
+ *
6162
+ * Both fields describe one instant, which is why `liveAnalyses` is read once and passed in rather
6163
+ * than read twice. Computed separately, a phase could change between them and the page would be
6164
+ * told nothing had changed alongside a running state it had not seen.
6165
+ *
6166
+ * `live` sets the **cadence**, never whether to poll. A page that renders with nothing running
6167
+ * still has to learn that a review was started from a terminal somewhere, and a page whose work has
6168
+ * finished has to slow down without needing a reload to be told. See `client/reviews.ts`.
6169
+ *
6170
+ * Authorised by the index credential like `/pin`, `/delete` and `/status`, because this is the list
6171
+ * asking about itself. Flat rather than under `/api/`, which is the per-review namespace and whose
6172
+ * route requires a review id this question does not have.
6173
+ */
6174
+ function handleRevision(req, res, indexToken) {
6175
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
6176
+ const states = analysisStates();
6177
+ send(res, 200, {
6178
+ revision: reviewsRevision(states),
6179
+ live: anythingRunning(states)
6180
+ });
6181
+ }
6182
+ /**
5696
6183
  * Pin or unpin a review.
5697
6184
  *
5698
6185
  * Authorised by the index credential rather than the review's own, because pinning is a fact about
@@ -5876,7 +6363,7 @@ function recordWorkspaceOpened(workspace) {
5876
6363
  const event = workspaceOpenedEvent(workspace);
5877
6364
  if (event) record(event);
5878
6365
  }
5879
- async function sendHtml(res, workspace) {
6366
+ async function sendWorkspacePage(res, workspace) {
5880
6367
  const freshness = await freshnessOrNull(workspace);
5881
6368
  const kissFreshness = await kissFreshnessOrNull(workspace);
5882
6369
  const kissRun = latestKissRun(workspace);
@@ -5897,6 +6384,8 @@ async function sendHtml(res, workspace) {
5897
6384
  repoPath: workspace.repoPath,
5898
6385
  askWidth: readPreferences().askWidth,
5899
6386
  transcript: workspace.transcript,
6387
+ pendingAsk: reportRun(workspace.id, "ask"),
6388
+ pendingDiagram: reportRun(workspace.id, "diagram"),
5900
6389
  prompts: readPromptBank(),
5901
6390
  complete: workspace.completedAt !== void 0,
5902
6391
  prContext: workspace.prContext ? {
@@ -5909,8 +6398,10 @@ async function sendHtml(res, workspace) {
5909
6398
  lastUpdate: notice,
5910
6399
  diagrams: workspace.diagrams,
5911
6400
  reviewedSha: reviewedCommit(workspace),
6401
+ revision: workspaceRevision(workspace),
5912
6402
  lineage: lineagePosition(workspace),
5913
6403
  job: workspace.jobId ? loadJob(workspace.jobId) : void 0,
6404
+ updateJob: liveUpdateFor(workspace.id),
5914
6405
  kissRun,
5915
6406
  kissJob: kissRun ? loadJob(kissRun.jobId) : void 0,
5916
6407
  kissFreshness,
@@ -5925,8 +6416,7 @@ async function sendHtml(res, workspace) {
5925
6416
  code: freshness.code.state,
5926
6417
  checkout: freshness.checkout.state
5927
6418
  });
5928
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
5929
- res.end(html);
6419
+ sendHtml(res, 200, html);
5930
6420
  }
5931
6421
  function sendAsset(res, pathname) {
5932
6422
  const name = pathname.slice(8);
@@ -5936,7 +6426,7 @@ function sendAsset(res, pathname) {
5936
6426
  const ext = name.slice(name.lastIndexOf("."));
5937
6427
  res.writeHead(200, {
5938
6428
  "content-type": CONTENT_TYPES[ext] ?? "application/octet-stream",
5939
- "cache-control": "no-cache"
6429
+ "cache-control": isFingerprinted(name) ? IMMUTABLE : REVALIDATE
5940
6430
  });
5941
6431
  res.end(body);
5942
6432
  }
@@ -5998,7 +6488,10 @@ function jobReport(workspace) {
5998
6488
  const kissRun = latestKissRun(workspace);
5999
6489
  return {
6000
6490
  ...jobStatus(workspace.jobId ? loadJob(workspace.jobId) : null),
6001
- kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null)
6491
+ kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null),
6492
+ update: jobStatus(liveUpdateFor(workspace.id)),
6493
+ ask: reportRun(workspace.id, "ask"),
6494
+ diagram: reportRun(workspace.id, "diagram")
6002
6495
  };
6003
6496
  }
6004
6497
  /**
@@ -6011,10 +6504,12 @@ function jobReport(workspace) {
6011
6504
  async function handleFreshness(res, workspace) {
6012
6505
  const kiss = await kissFreshnessOrNull(workspace);
6013
6506
  const report = await freshnessOrNull(workspace);
6507
+ const revision = workspaceRevision(workspace) ?? null;
6014
6508
  if (!report) return send(res, 200, {
6015
6509
  report: null,
6016
6510
  kiss,
6017
- kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null
6511
+ kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
6512
+ revision
6018
6513
  });
6019
6514
  record({
6020
6515
  event: "freshness_checked",
@@ -6027,7 +6522,8 @@ async function handleFreshness(res, workspace) {
6027
6522
  report,
6028
6523
  dismissedSha: workspace.dismissedFreshnessSha ?? null,
6029
6524
  kiss,
6030
- kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null
6525
+ kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
6526
+ revision
6031
6527
  });
6032
6528
  }
6033
6529
  /**
@@ -6095,6 +6591,7 @@ async function handleUpdate(res, workspace) {
6095
6591
  try {
6096
6592
  outcome = await updateReview(workspace);
6097
6593
  } catch (error) {
6594
+ if (error instanceof UpdateAlreadyRunningError) return send(res, 409, { error: error.message });
6098
6595
  outcome = {
6099
6596
  at: Date.now(),
6100
6597
  ok: false,
@@ -6203,6 +6700,15 @@ async function handleAsk(req, res, workspace) {
6203
6700
  const question = typeof body?.question === "string" ? body.question.trim() : "";
6204
6701
  if (!question) return send(res, 400, { error: "No question given." });
6205
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;
6206
6712
  record({
6207
6713
  event: "assistant_question_asked",
6208
6714
  reviewId: workspace.id,
@@ -6210,40 +6716,64 @@ async function handleAsk(req, res, workspace) {
6210
6716
  scoped: sectionId !== null,
6211
6717
  turn: workspace.transcript.length + 1
6212
6718
  });
6213
- const cancel = new AbortController();
6214
- res.on("close", () => {
6215
- if (!res.writableEnded) cancel.abort();
6216
- });
6217
6719
  const startedAt = Date.now();
6218
- 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
+ });
6219
6727
  try {
6220
- answer = await askAboutChange(workspace, question, sectionId, cancel.signal);
6221
- } catch (error) {
6222
- 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
+ }
6223
6740
  record({
6224
6741
  event: "assistant_answered",
6225
6742
  reviewId: workspace.id,
6226
6743
  durationMs: Date.now() - startedAt,
6227
- ok: false,
6228
- ...cancelled ? { cancelled: true } : {}
6744
+ ok: true
6229
6745
  });
6230
- if (cancelled) return;
6231
- 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);
6232
6759
  }
6233
- record({
6234
- event: "assistant_answered",
6235
- reviewId: workspace.id,
6236
- durationMs: Date.now() - startedAt,
6237
- ok: true
6238
- });
6239
- workspace.transcript.push({
6240
- question,
6241
- answer,
6242
- sectionId,
6243
- askedAt: Date.now()
6244
- });
6245
- saveWorkspace(workspace);
6246
- 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) });
6247
6777
  }
6248
6778
  /**
6249
6779
  * Draw the change, or one theme of it, and keep the drawing.
@@ -6257,46 +6787,60 @@ async function handleDiagram(req, res, workspace) {
6257
6787
  const body = await readJson(req);
6258
6788
  const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
6259
6789
  const scope = sectionId ?? "change";
6260
- 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;
6261
6797
  record({
6262
6798
  event: "diagram_requested",
6263
6799
  reviewId: workspace.id,
6264
6800
  scoped: sectionId !== null,
6265
- redraw: existing !== -1
6266
- });
6267
- const cancel = new AbortController();
6268
- res.on("close", () => {
6269
- if (!res.writableEnded) cancel.abort();
6801
+ redraw
6270
6802
  });
6271
6803
  const startedAt = Date.now();
6272
- 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
+ });
6273
6811
  try {
6274
- diagram = await drawDiagram(workspace, sectionId, cancel.signal);
6275
- } catch (error) {
6276
- 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
+ }
6277
6825
  record({
6278
6826
  event: "diagram_drawn",
6279
6827
  reviewId: workspace.id,
6280
6828
  durationMs: Date.now() - startedAt,
6281
- ok: false,
6282
- ...cancelled ? { cancelled: true } : {}
6829
+ ok: true
6283
6830
  });
6284
- if (cancelled) return;
6285
- if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return send(res, 502, { error: error.message });
6286
- 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);
6287
6843
  }
6288
- record({
6289
- event: "diagram_drawn",
6290
- reviewId: workspace.id,
6291
- durationMs: Date.now() - startedAt,
6292
- ok: true
6293
- });
6294
- const diagrams = [...workspace.diagrams ?? []];
6295
- if (existing === -1) diagrams.push(diagram);
6296
- else diagrams[existing] = diagram;
6297
- workspace.diagrams = diagrams;
6298
- saveWorkspace(workspace);
6299
- send(res, 200, { diagram });
6300
6844
  }
6301
6845
  /**
6302
6846
  * Stop serving, at the request of a workspace.
@@ -6385,7 +6929,10 @@ async function readJson(req) {
6385
6929
  }
6386
6930
  }
6387
6931
  function send(res, status, payload) {
6388
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
6932
+ res.writeHead(status, {
6933
+ "content-type": "application/json; charset=utf-8",
6934
+ "cache-control": NO_STORE
6935
+ });
6389
6936
  res.end(JSON.stringify(payload));
6390
6937
  }
6391
6938
  var ASK_SOURCES = [
@@ -6506,16 +7053,12 @@ async function sweep(now = Date.now()) {
6506
7053
  if (markAbandoned(job, now)) report.abandoned += 1;
6507
7054
  continue;
6508
7055
  }
6509
- if (now - finishedAt(job) < 216e5) continue;
7056
+ if (now - endedAt(job) < 216e5) continue;
6510
7057
  if (await retire(job, report)) report.retired += 1;
6511
7058
  }
6512
7059
  await removeOrphans(jobs, report);
6513
7060
  return report;
6514
7061
  }
6515
- /** When this job stopped doing work. `finishedAt` predates nothing, but a record can be old. */
6516
- function finishedAt(job) {
6517
- return job.finishedAt ?? job.updatedAt;
6518
- }
6519
7062
  /**
6520
7063
  * Record that a job stopped, unless it has moved since this pass read it.
6521
7064
  *