tau-coding-agent 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -73
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +90 -4
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +138 -183
  13. package/extensions/loop.ts +150 -135
  14. package/extensions/memory.ts +172 -97
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +43 -40
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/submit-review-tool.ts +1 -5
  28. package/extensions/review/submit-triage-tool.ts +1 -0
  29. package/extensions/review/triage.ts +41 -44
  30. package/extensions/sandbox/bash.ts +775 -0
  31. package/extensions/sandbox/command.ts +605 -0
  32. package/extensions/sandbox/config.ts +764 -0
  33. package/extensions/sandbox/index.ts +138 -2850
  34. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  35. package/extensions/sandbox/permissions/dialog.ts +118 -0
  36. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  37. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  38. package/extensions/sandbox/permissions/network.ts +164 -0
  39. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  40. package/extensions/sandbox/runtime.ts +616 -0
  41. package/extensions/stash.ts +28 -15
  42. package/extensions/subagent/README.md +73 -0
  43. package/extensions/subagent/index.ts +822 -0
  44. package/extensions/subagent/interrupt.ts +117 -0
  45. package/extensions/subagent/permissions.ts +101 -0
  46. package/extensions/subagent/rpc.ts +177 -0
  47. package/extensions/tool-display-mode.ts +267 -64
  48. package/extensions/usage/anthropic.ts +5 -6
  49. package/extensions/usage/github-copilot.ts +30 -9
  50. package/extensions/usage/index.ts +250 -357
  51. package/extensions/usage/openai-codex.ts +2 -2
  52. package/extensions/usage/openrouter.ts +10 -2
  53. package/extensions/websearch/README.md +12 -47
  54. package/extensions/websearch/config.ts +5 -2
  55. package/extensions/websearch/index.ts +94 -109
  56. package/extensions/websearch/output.ts +51 -0
  57. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  58. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  59. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  60. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  61. package/extensions/websearch/providers/pi-model.shared.ts +44 -32
  62. package/extensions/websearch/providers/shared.ts +87 -2
  63. package/extensions/websearch/types.ts +0 -3
  64. package/extensions/worktree.ts +132 -172
  65. package/package.json +10 -5
  66. package/skills/browser-tools/SKILL.md +29 -234
  67. package/skills/browser-tools/references/cookies.md +36 -0
  68. package/skills/browser-tools/references/interaction.md +90 -0
  69. package/skills/browser-tools/references/logging.md +34 -0
  70. package/skills/git-clean-history/SKILL.md +6 -6
  71. package/skills/git-commit/SKILL.md +5 -3
  72. package/skills/github-pull-request/SKILL.md +60 -0
  73. package/skills/github-pull-request/references/create.md +40 -0
  74. package/skills/github-pull-request/references/stewardship.md +60 -0
  75. package/skills/oracle/SKILL.md +4 -4
  76. package/skills/oracle/scripts/oracle +64 -51
  77. package/skills/sentry/SKILL.md +15 -185
  78. package/skills/sentry/references/events.md +79 -0
  79. package/skills/sentry/references/issues.md +62 -0
  80. package/skills/sentry/references/logs.md +46 -0
  81. package/skills/update-changelog/SKILL.md +27 -121
  82. package/skills/web-design/SKILL.md +16 -105
  83. package/themes/tau-dark.json +4 -0
  84. package/extensions/openai-fast.ts +0 -229
  85. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  86. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -1,24 +1,22 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
1
2
  import {
2
3
  keyText,
3
4
  type ExtensionCommandContext,
4
5
  type ExtensionContext,
5
6
  type ExtensionAPI,
6
7
  } from "@earendil-works/pi-coding-agent";
7
- import {
8
- getKeybindings,
9
- isKeyRelease,
10
- truncateToWidth,
11
- visibleWidth,
12
- type Component,
13
- } from "@earendil-works/pi-tui";
8
+ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
14
9
 
15
10
  import {
16
11
  buildAdditionalContextSection,
17
12
  buildProjectReviewGuidelinesSection,
18
13
  REVIEW_DEDUP_PROMPT,
14
+ REVIEW_DIFF_SCOPE_PROMPT,
19
15
  REVIEW_FOCUSES,
20
16
  REVIEW_FOCUS_PROMPT,
21
17
  REVIEW_OUTPUT_CONTRACT_PROMPT,
18
+ REVIEW_RUBRIC_PROMPT,
19
+ REVIEW_SNAPSHOT_SCOPE_PROMPT,
22
20
  } from "./prompts.js";
23
21
  import {
24
22
  buildReviewedScopeLine,
@@ -72,13 +70,15 @@ import {
72
70
  } from "./models.js";
73
71
  import {
74
72
  notify,
73
+ publishReviewActivity,
75
74
  REVIEW_CANCELLED_ERROR,
76
- REVIEW_STATUS_KEY,
75
+ REVIEW_PROGRESS_WIDGET_KEY,
76
+ renderReviewProgressHeader,
77
77
  STATUS_SPINNER_FRAMES,
78
78
  STATUS_SPINNER_INTERVAL_MS,
79
- withManagedReviewRun,
79
+ joinAll,
80
80
  withSpinner,
81
- type ReviewExecutionControl,
81
+ type ReviewRuntime,
82
82
  } from "./runtime.js";
83
83
  import {
84
84
  REVIEW_FOCUS_NAMES,
@@ -111,8 +111,6 @@ const REVIEW_STALE_FIX_NEXT_STEP =
111
111
  const REVIEW_STALE_PAYLOAD_WARNING = "Repository changed since this review was generated.";
112
112
  const REVIEW_STALE_PAYLOAD_NEXT_STEP = "Run /review first to refresh it.";
113
113
 
114
- const REVIEW_PROGRESS_WIDGET_KEY = "review-progress";
115
-
116
114
  type ReviewTheme = ExtensionContext["ui"]["theme"];
117
115
 
118
116
  type FocusTask = {
@@ -148,7 +146,6 @@ type ReviewProgressTask = {
148
146
 
149
147
  type ReviewProgressState = {
150
148
  startedAtMs: number;
151
- expanded: boolean;
152
149
  frame: number;
153
150
  tasks: ReviewProgressTask[];
154
151
  };
@@ -166,7 +163,11 @@ type PreparedReviewRun = {
166
163
  tasks: FocusTask[];
167
164
  };
168
165
 
169
- function createReviewProgress(ctx: ExtensionContext, tasks: FocusTask[]): ReviewProgressController {
166
+ function createReviewProgress(
167
+ pi: ExtensionAPI,
168
+ ctx: ExtensionContext,
169
+ tasks: FocusTask[],
170
+ ): ReviewProgressController {
170
171
  if (!ctx.hasUI) {
171
172
  return {
172
173
  update: () => {},
@@ -176,7 +177,6 @@ function createReviewProgress(ctx: ExtensionContext, tasks: FocusTask[]): Review
176
177
 
177
178
  const state: ReviewProgressState = {
178
179
  startedAtMs: Date.now(),
179
- expanded: false,
180
180
  frame: 0,
181
181
  tasks: tasks.map((task) => ({
182
182
  focus: task.focus,
@@ -186,27 +186,35 @@ function createReviewProgress(ctx: ExtensionContext, tasks: FocusTask[]): Review
186
186
  };
187
187
  const progressTasks = new Map(tasks.map((task, index) => [task, state.tasks[index]!]));
188
188
 
189
+ let requestRender: (() => void) | undefined;
190
+ if (ctx.mode === "tui") {
191
+ ctx.ui.setWidget(
192
+ REVIEW_PROGRESS_WIDGET_KEY,
193
+ (tui, theme) => {
194
+ requestRender = () => tui.requestRender();
195
+ return new ReviewProgressComponent(
196
+ state,
197
+ theme,
198
+ () => ctx.ui.getToolsExpanded(),
199
+ () => publishReviewActivity(pi, ctx, `review ${buildReviewProgressStatus(state)}`),
200
+ );
201
+ },
202
+ { placement: "aboveEditor" },
203
+ );
204
+ }
189
205
  const render = () => {
190
- if (state.expanded) {
191
- ctx.ui.setStatus(REVIEW_STATUS_KEY, undefined);
206
+ if (ctx.mode === "tui") {
207
+ publishReviewActivity(pi, ctx, `review ${buildReviewProgressStatus(state)}`);
208
+ requestRender?.();
209
+ } else
192
210
  ctx.ui.setWidget(
193
211
  REVIEW_PROGRESS_WIDGET_KEY,
194
- (_tui, theme) => new ReviewProgressComponent(state, theme),
195
- { placement: "belowEditor" },
212
+ [`Review · ${buildReviewProgressStatus(state)}`],
213
+ {
214
+ placement: "aboveEditor",
215
+ },
196
216
  );
197
- return;
198
- }
199
-
200
- ctx.ui.setWidget(REVIEW_PROGRESS_WIDGET_KEY, undefined, { placement: "belowEditor" });
201
- ctx.ui.setStatus(REVIEW_STATUS_KEY, buildCollapsedReviewProgressStatus(state));
202
217
  };
203
- const unsubscribeToggle = ctx.ui.onTerminalInput((data) => {
204
- if (isKeyRelease(data)) return undefined;
205
- if (!getKeybindings().matches(data, "app.tools.expand")) return undefined;
206
- state.expanded = !state.expanded;
207
- render();
208
- return { consume: true };
209
- });
210
218
  const timer = setInterval(() => {
211
219
  state.frame = (state.frame + 1) % STATUS_SPINNER_FRAMES.length;
212
220
  render();
@@ -222,9 +230,8 @@ function createReviewProgress(ctx: ExtensionContext, tasks: FocusTask[]): Review
222
230
  },
223
231
  stop: () => {
224
232
  clearInterval(timer);
225
- unsubscribeToggle();
226
- ctx.ui.setStatus(REVIEW_STATUS_KEY, undefined);
227
- ctx.ui.setWidget(REVIEW_PROGRESS_WIDGET_KEY, undefined, { placement: "belowEditor" });
233
+ publishReviewActivity(pi, ctx, "review");
234
+ ctx.ui.setWidget(REVIEW_PROGRESS_WIDGET_KEY, undefined);
228
235
  },
229
236
  };
230
237
  }
@@ -233,85 +240,82 @@ class ReviewProgressComponent implements Component {
233
240
  constructor(
234
241
  private state: ReviewProgressState,
235
242
  private theme: ReviewTheme,
243
+ private isExpanded: () => boolean,
244
+ private publishActivity: () => boolean,
236
245
  ) {}
237
246
 
238
247
  invalidate(): void {}
239
248
 
240
249
  render(width: number): string[] {
241
- return buildExpandedReviewProgressLines(this.state, this.theme, width);
250
+ const handled = this.publishActivity();
251
+ if (!this.isExpanded() && handled) return [];
252
+ const hint = [formatDuration(Date.now() - this.state.startedAtMs), keyText("app.tools.expand")]
253
+ .filter(Boolean)
254
+ .join(" · ");
255
+ const header = renderReviewProgressHeader(
256
+ buildReviewProgressStatus(this.state),
257
+ this.theme,
258
+ width,
259
+ hint,
260
+ );
261
+ if (!this.isExpanded()) return [header];
262
+ return [
263
+ header,
264
+ ...buildReviewProgressRows(
265
+ groupReviewProgressTasks(this.state.tasks),
266
+ this.theme,
267
+ width,
268
+ getProgressSpinner(this.state),
269
+ ),
270
+ ];
242
271
  }
243
272
  }
244
273
 
245
- function buildCollapsedReviewProgressStatus(state: ReviewProgressState): string {
274
+ function buildReviewProgressStatus(state: ReviewProgressState): string {
246
275
  const counts = countReviewProgressTasks(state.tasks);
247
- const spinner = getProgressSpinner(state);
248
- const failedText = counts.failed > 0 ? `, ${counts.failed} failed` : "";
249
- return `${spinner} reviewing ${counts.finished}/${counts.total}${failedText} (${keyText("app.tools.expand")} to expand)`;
250
- }
251
-
252
- function buildExpandedReviewProgressLines(
253
- state: ReviewProgressState,
254
- theme: ReviewTheme,
255
- width: number,
256
- ): string[] {
257
- const counts = countReviewProgressTasks(state.tasks);
258
- const spinner = getProgressSpinner(state);
259
- const hint = `${theme.fg("muted", `(${formatDuration(Date.now() - state.startedAtMs)}, `)}${theme.fg(
260
- "dim",
261
- keyText("app.tools.expand"),
262
- )}${theme.fg("muted", " to collapse)")}`;
263
- const header = truncateToWidth(
264
- `${theme.fg("accent", spinner)} reviewing ${counts.finished}/${counts.total} ${hint}`,
265
- width,
266
- theme.fg("muted", "…"),
267
- );
268
- const groupedTasks = groupReviewProgressTasks(state.tasks);
269
- const fullRows = buildReviewProgressRows(groupedTasks, theme, width, "full", spinner);
270
- const rows = fullRows.fits
271
- ? fullRows.lines
272
- : buildReviewProgressRows(groupedTasks, theme, width, "short", spinner).lines;
273
- return [header, ...rows, theme.fg("borderMuted", "─".repeat(Math.max(0, width)))];
276
+ const failedText = counts.failed > 0 ? ` · ${counts.failed} failed` : "";
277
+ return `${counts.finished}/${counts.total} complete${failedText}`;
274
278
  }
275
279
 
276
280
  function buildReviewProgressRows(
277
281
  groupedTasks: Map<string, ReviewProgressTask[]>,
278
282
  theme: ReviewTheme,
279
283
  width: number,
280
- labelStyle: "full" | "short",
281
284
  spinner: string,
282
- ): { fits: boolean; lines: string[] } {
285
+ ): string[] {
283
286
  const focuses = getReviewProgressFocuses(groupedTasks);
284
287
  const rowData = Array.from(groupedTasks, ([model, tasks]) => ({
285
288
  model,
286
- chips: focuses
287
- .map((focus) => {
288
- const task = tasks.find((candidate) => candidate.focus === focus);
289
- return formatReviewProgressChip(
290
- task?.status ?? "running",
291
- focus,
292
- labelStyle,
293
- spinner,
294
- theme,
295
- );
296
- })
297
- .join(" "),
289
+ chips: focuses.map((focus) => {
290
+ const task = tasks.find((candidate) => candidate.focus === focus);
291
+ return formatReviewProgressChip(task?.status ?? "running", focus, spinner, theme);
292
+ }),
298
293
  }));
294
+ const rowWidth = Math.max(0, width - 4);
299
295
  const maxModelWidth = Math.max(0, ...rowData.map((row) => visibleWidth(row.model)));
300
- const maxChipWidth = Math.max(0, ...rowData.map((row) => visibleWidth(row.chips)));
301
- const fullModelColumnWidth = Math.max(maxModelWidth, 1);
302
- const fullRowsFit = rowData.every(
303
- (row) => fullModelColumnWidth + 1 + visibleWidth(row.chips) <= width,
304
- );
305
- const availableModelWidth = width - 1 - maxChipWidth;
306
- const minimumUsefulModelWidth = Math.min(16, fullModelColumnWidth);
307
- const canFit = fullRowsFit || availableModelWidth >= minimumUsefulModelWidth;
308
- const modelColumnWidth = fullRowsFit ? fullModelColumnWidth : Math.max(8, availableModelWidth);
309
- const lines = rowData.map((row) => {
310
- const model = padToWidth(truncateToWidth(row.model, modelColumnWidth, "…"), modelColumnWidth);
311
- return truncateToWidth(`${model} ${row.chips}`, width, "…");
296
+ const maxChipWidth = Math.max(0, ...rowData.map((row) => visibleWidth(row.chips.join(" "))));
297
+ const modelWidth = Math.min(maxModelWidth, Math.max(0, rowWidth - maxChipWidth - 2));
298
+ return rowData.flatMap((row) => {
299
+ if (modelWidth >= Math.min(16, maxModelWidth)) {
300
+ const model = padToWidth(truncateToWidth(row.model, modelWidth), modelWidth);
301
+ return [truncateToWidth(` ${theme.fg("dim", model)} ${row.chips.join(" ")}`, width)];
302
+ }
303
+ // Keep focus names and their statuses together when the matrix cannot fit on one line.
304
+ const lines = [
305
+ truncateToWidth(` ${theme.fg("dim", truncateToWidth(row.model, rowWidth))}`, width),
306
+ ];
307
+ const chipWidth = Math.max(0, rowWidth - 2);
308
+ let line = "";
309
+ for (const chip of row.chips) {
310
+ if (line && visibleWidth(line) + 2 + visibleWidth(chip) > chipWidth) {
311
+ lines.push(truncateToWidth(` ${line}`, width));
312
+ line = "";
313
+ }
314
+ line += (line ? " " : "") + truncateToWidth(chip, chipWidth);
315
+ }
316
+ if (line) lines.push(truncateToWidth(` ${line}`, width));
317
+ return lines;
312
318
  });
313
-
314
- return { fits: canFit, lines };
315
319
  }
316
320
 
317
321
  function getReviewProgressFocuses(groupedTasks: Map<string, ReviewProgressTask[]>): ReviewFocus[] {
@@ -327,18 +331,17 @@ function getReviewProgressFocuses(groupedTasks: Map<string, ReviewProgressTask[]
327
331
  function formatReviewProgressChip(
328
332
  status: ReviewProgressStatus,
329
333
  focus: ReviewFocus,
330
- labelStyle: "full" | "short",
331
334
  spinner: string,
332
335
  theme: ReviewTheme,
333
336
  ): string {
334
- const label = labelStyle === "full" ? focus : getShortReviewFocusLabel(focus);
337
+ const label = theme.fg("text", focus);
335
338
  switch (status) {
336
339
  case "success":
337
340
  return `${theme.fg("success", "✓")} ${label}`;
338
341
  case "failure":
339
342
  return `${theme.fg("error", "✕")} ${label}`;
340
343
  case "running":
341
- return `${spinner} ${label}`;
344
+ return `${theme.fg("accent", spinner)} ${label}`;
342
345
  }
343
346
  }
344
347
 
@@ -373,23 +376,6 @@ function getProgressSpinner(state: ReviewProgressState): string {
373
376
  return STATUS_SPINNER_FRAMES[state.frame % STATUS_SPINNER_FRAMES.length];
374
377
  }
375
378
 
376
- function getShortReviewFocusLabel(focus: ReviewFocus): string {
377
- switch (focus) {
378
- case "general":
379
- return "gen";
380
- case "security":
381
- return "sec";
382
- case "reuse":
383
- return "reuse";
384
- case "quality":
385
- return "qual";
386
- case "testing":
387
- return "test";
388
- case "efficiency":
389
- return "eff";
390
- }
391
- }
392
-
393
379
  function padToWidth(value: string, width: number): string {
394
380
  return `${value}${" ".repeat(Math.max(0, width - visibleWidth(value)))}`;
395
381
  }
@@ -400,7 +386,7 @@ function priorityRank(priority: Priority): number {
400
386
 
401
387
  function buildFocusPrompt(
402
388
  focus: ReviewFocus,
403
- scopeInstructions: string,
389
+ scope: ResolvedScope,
404
390
  projectGuidelines: string | null,
405
391
  additionalContext: string | undefined,
406
392
  ): string {
@@ -408,10 +394,18 @@ function buildFocusPrompt(
408
394
  const projectGuidelinesSection = buildProjectReviewGuidelinesSection(projectGuidelines);
409
395
 
410
396
  const def = REVIEW_FOCUSES[focus];
397
+ const scopePolicy =
398
+ scope.kind === "folder"
399
+ ? REVIEW_SNAPSHOT_SCOPE_PROMPT
400
+ : scope.kind === "custom"
401
+ ? `Use the custom instructions to determine the review scope and whether it is a diff or snapshot review. Apply the corresponding default below unless those instructions override it.\n\n${REVIEW_DIFF_SCOPE_PROMPT}\n\n${REVIEW_SNAPSHOT_SCOPE_PROMPT}`
402
+ : REVIEW_DIFF_SCOPE_PROMPT;
411
403
 
412
404
  return REVIEW_FOCUS_PROMPT.replace("{FOCUS_SUFFIX}", () => def.suffix)
413
405
  .replace("{FOCUS_QUALIFIER}", () => def.qualifier)
414
- .replace("{SCOPE_INSTRUCTIONS}", () => scopeInstructions)
406
+ .replace("{SCOPE_INSTRUCTIONS}", () => buildScopeInstructions(scope))
407
+ .replace("{REVIEW_RUBRIC}", () => REVIEW_RUBRIC_PROMPT)
408
+ .replace("{SCOPE_POLICY}", () => scopePolicy)
415
409
  .replace("{FOCUS_CONTEXT}", () => def.context)
416
410
  .replace("{ADDITIONAL_CONTEXT_SECTION}", () => additionalContextSection)
417
411
  .replace("{PROJECT_GUIDELINES_SECTION}", () => projectGuidelinesSection)
@@ -562,7 +556,7 @@ function createFocusTaskAttempt(
562
556
  async function runFocusTaskOnce(
563
557
  task: FocusTaskAttempt,
564
558
  cwd: string,
565
- control?: ReviewExecutionControl,
559
+ signal: AbortSignal,
566
560
  ): Promise<FocusTaskResult> {
567
561
  const modelLabel = buildFocusTaskAttemptModelLabel(task);
568
562
  const modelArg = getFocusTaskAttemptModelArg(task);
@@ -588,7 +582,7 @@ async function runFocusTaskOnce(
588
582
  prompt: task.prompt,
589
583
  cwd,
590
584
  timeoutMs: REVIEW_TASK_TIMEOUT_MS,
591
- control,
585
+ signal,
592
586
  submitTool: SUBMIT_REVIEW_TOOL,
593
587
  });
594
588
 
@@ -680,21 +674,19 @@ async function runFocusTaskOnce(
680
674
  async function runFocusTaskWithRetry(
681
675
  task: FocusTaskAttempt,
682
676
  cwd: string,
683
- control?: ReviewExecutionControl,
677
+ signal: AbortSignal,
684
678
  ): Promise<FocusTaskResult> {
685
679
  for (let attempt = 0; ; attempt += 1) {
686
- const result = await runFocusTaskOnce(task, cwd, control);
680
+ const result = await runFocusTaskOnce(task, cwd, signal);
687
681
  if (result.ok || attempt >= REVIEW_STARTUP_RETRY_DELAYS_MS.length) return result;
688
682
 
689
683
  if (result.errorKind !== "lock_contention") return result;
690
- if (control?.isCancelled()) {
691
- return createCancelledFocusAttemptResult(task);
692
- }
684
+ signal.throwIfAborted();
693
685
 
694
686
  const baseDelayMs =
695
687
  REVIEW_STARTUP_RETRY_DELAYS_MS[attempt] ??
696
688
  REVIEW_STARTUP_RETRY_DELAYS_MS[REVIEW_STARTUP_RETRY_DELAYS_MS.length - 1];
697
- await new Promise((resolve) => setTimeout(resolve, withJitter(baseDelayMs)));
689
+ await delay(withJitter(baseDelayMs), undefined, { signal });
698
690
  }
699
691
  }
700
692
 
@@ -722,7 +714,7 @@ async function runFocusTaskForProviderCandidate(
722
714
  task: FocusTask,
723
715
  providerCandidate: ResolvedReviewProviderCandidate,
724
716
  cwd: string,
725
- control?: ReviewExecutionControl,
717
+ signal: AbortSignal,
726
718
  ): Promise<FocusTaskResult> {
727
719
  const thinkingLevels = getFocusTaskThinkingLevels(task, providerCandidate);
728
720
  const triedLevels: ReviewThinkingLevel[] = [];
@@ -736,7 +728,7 @@ async function runFocusTaskForProviderCandidate(
736
728
  return createUnsupportedReasoningFocusAttemptResult(attempt, explicitThinkingSupportError);
737
729
  }
738
730
 
739
- const result = await runFocusTaskWithRetry(attempt, cwd, control);
731
+ const result = await runFocusTaskWithRetry(attempt, cwd, signal);
740
732
  if (result.ok) return result;
741
733
 
742
734
  lastResult = result;
@@ -761,9 +753,9 @@ async function runFocusTaskForProviderCandidate(
761
753
  async function runFocusTask(
762
754
  task: FocusTask,
763
755
  cwd: string,
764
- control?: ReviewExecutionControl,
756
+ signal: AbortSignal,
765
757
  ): Promise<FocusTaskResult> {
766
- if (control?.isCancelled()) {
758
+ if (signal.aborted) {
767
759
  return createCancelledFocusResult(task);
768
760
  }
769
761
 
@@ -775,7 +767,7 @@ async function runFocusTask(
775
767
  continue;
776
768
  }
777
769
  if (availability === "supported") {
778
- const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
770
+ const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, signal);
779
771
  if (result.ok) return result;
780
772
  lastResult = result;
781
773
  if (
@@ -795,7 +787,7 @@ async function runFocusTask(
795
787
  continue;
796
788
  }
797
789
 
798
- const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
790
+ const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, signal);
799
791
  if (result.ok) return result;
800
792
  lastResult = result;
801
793
  if (
@@ -814,10 +806,12 @@ async function runFocusTask(
814
806
  resolveProbe = resolve;
815
807
  rejectProbe = reject;
816
808
  });
809
+ // The initiating task reports errors even when no sibling awaits this shared probe.
810
+ void probe.catch(() => {});
817
811
  setProviderCandidateProbe(task.model, providerCandidate, probe);
818
812
 
819
813
  try {
820
- const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
814
+ const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, signal);
821
815
  lastResult = result;
822
816
  const probedAvailability = getProviderCandidateAvailabilityFromResult(result);
823
817
  setProviderCandidateAvailability(task.model, providerCandidate, probedAvailability);
@@ -839,19 +833,21 @@ async function runFocusTask(
839
833
  }
840
834
 
841
835
  async function runReviewDedupTask(options: {
836
+ pi: ExtensionAPI;
842
837
  ctx: ExtensionCommandContext;
843
838
  cwd: string;
844
839
  findings: ReviewReportFinding[];
845
- control?: ReviewExecutionControl;
840
+ signal: AbortSignal;
846
841
  }): Promise<ReviewDedupGroup[] | null> {
847
- const { ctx, cwd, findings, control } = options;
842
+ const { pi, ctx, cwd, findings, signal } = options;
848
843
  if (findings.length <= 1) return [];
849
- if (control?.isCancelled()) return null;
844
+ if (signal.aborted) return null;
850
845
 
851
846
  const model = selectReviewDedupModel(ctx);
852
847
  if (!model) return null;
853
848
 
854
849
  const taskResult = await withSpinner(
850
+ pi,
855
851
  ctx,
856
852
  () => `deduplicating ${findings.length} review findings`,
857
853
  () =>
@@ -872,7 +868,7 @@ async function runReviewDedupTask(options: {
872
868
  prompt: buildReviewDedupPrompt(findings),
873
869
  cwd,
874
870
  timeoutMs: REVIEW_TASK_TIMEOUT_MS,
875
- control,
871
+ signal,
876
872
  }),
877
873
  );
878
874
 
@@ -975,7 +971,7 @@ function parseReviewMessageDetails(value: unknown): ReviewMessageDetails | null
975
971
  }
976
972
 
977
973
  export function getLastMessageReviewDetails(ctx: ExtensionContext): ReviewMessageDetails | null {
978
- const entry = ctx.sessionManager.getBranch().at(-1);
974
+ const entry = ctx.sessionManager.getBranch().findLast((entry) => entry.type !== "usage");
979
975
  if (!entry || entry.type !== "custom_message" || entry.customType !== "review") {
980
976
  return null;
981
977
  }
@@ -992,7 +988,6 @@ function buildReviewTasks(
992
988
  models: ResolvedReviewModel[],
993
989
  focuses: readonly ReviewFocus[],
994
990
  ): FocusTask[] {
995
- const scopeInstructions = buildScopeInstructions(scope);
996
991
  const tasks: FocusTask[] = [];
997
992
 
998
993
  for (const model of models) {
@@ -1000,7 +995,7 @@ function buildReviewTasks(
1000
995
  tasks.push({
1001
996
  model,
1002
997
  focus,
1003
- prompt: buildFocusPrompt(focus, scopeInstructions, guidelines, additionalContext),
998
+ prompt: buildFocusPrompt(focus, scope, guidelines, additionalContext),
1004
999
  });
1005
1000
  }
1006
1001
  }
@@ -1012,12 +1007,14 @@ async function prepareReviewRun(
1012
1007
  pi: ExtensionAPI,
1013
1008
  ctx: ExtensionCommandContext,
1014
1009
  request: ParsedRequest,
1010
+ signal: AbortSignal,
1015
1011
  ): Promise<{ ok: false; error: string } | { ok: true; data: PreparedReviewRun }> {
1016
- if (!(await isGitRepo(pi))) {
1012
+ const workspace = { cwd: ctx.cwd, signal };
1013
+ if (!(await isGitRepo(workspace))) {
1017
1014
  return { ok: false, error: "Not a git repository." };
1018
1015
  }
1019
1016
 
1020
- const resolved = await resolveScope(pi, request.target, (message, type) =>
1017
+ const resolved = await resolveScope(workspace, request.target, (message, type) =>
1021
1018
  notify(ctx, message, type),
1022
1019
  );
1023
1020
  if (!resolved.scope) {
@@ -1027,10 +1024,10 @@ async function prepareReviewRun(
1027
1024
  const scope = resolved.scope;
1028
1025
  const includeUntracked = scope.kind === "working-tree" || scope.kind === "folder";
1029
1026
  const scopeUntrackedFiles = scope.kind === "working-tree" ? scope.untrackedFiles : undefined;
1030
- const [baselineFingerprint, guidelines, models] = await Promise.all([
1031
- computeCurrentFingerprint(pi, ctx.cwd, includeUntracked, scopeUntrackedFiles),
1032
- loadProjectReviewGuidelines(ctx.cwd),
1033
- resolveModels(ctx, request.models, pi.getThinkingLevel()),
1027
+ const [baselineFingerprint, guidelines, models] = await joinAll([
1028
+ computeCurrentFingerprint(workspace, includeUntracked, scopeUntrackedFiles),
1029
+ loadProjectReviewGuidelines(workspace),
1030
+ resolveModels(ctx, request.models, pi.getThinkingLevel(), signal),
1034
1031
  ]);
1035
1032
 
1036
1033
  return {
@@ -1052,18 +1049,19 @@ async function prepareReviewRun(
1052
1049
  }
1053
1050
 
1054
1051
  async function runFocusTasks(
1052
+ pi: ExtensionAPI,
1055
1053
  ctx: ExtensionCommandContext,
1056
1054
  cwd: string,
1057
1055
  tasks: FocusTask[],
1058
- control: ReviewExecutionControl,
1056
+ signal: AbortSignal,
1059
1057
  ): Promise<FocusTaskResult[]> {
1060
- const progress = createReviewProgress(ctx, tasks);
1058
+ const progress = createReviewProgress(pi, ctx, tasks);
1061
1059
  try {
1062
- return await Promise.all(
1060
+ return await joinAll(
1063
1061
  tasks.map(async (task) => {
1064
- const result = control.isCancelled()
1062
+ const result = signal.aborted
1065
1063
  ? createCancelledFocusResult(task)
1066
- : await runFocusTask(task, cwd, control);
1064
+ : await runFocusTask(task, cwd, signal);
1067
1065
  progress.update(task, result);
1068
1066
  return result;
1069
1067
  }),
@@ -1124,10 +1122,11 @@ function applyReviewDedupGroups(
1124
1122
  }
1125
1123
 
1126
1124
  async function buildReviewFindings(
1125
+ pi: ExtensionAPI,
1127
1126
  ctx: ExtensionCommandContext,
1128
1127
  cwd: string,
1129
1128
  successfulFocuses: Array<FocusTaskResult & { output: FocusOutput }>,
1130
- control?: ReviewExecutionControl,
1129
+ signal: AbortSignal,
1131
1130
  ): Promise<ReviewReportFinding[]> {
1132
1131
  const findings = successfulFocuses.flatMap((focus) =>
1133
1132
  focus.output.findings.map((finding) => ({
@@ -1143,10 +1142,11 @@ async function buildReviewFindings(
1143
1142
  }
1144
1143
 
1145
1144
  const dedupGroups = await runReviewDedupTask({
1145
+ pi,
1146
1146
  ctx,
1147
1147
  cwd,
1148
1148
  findings,
1149
- control,
1149
+ signal,
1150
1150
  });
1151
1151
  return dedupGroups ? applyReviewDedupGroups(findings, dedupGroups) : findings;
1152
1152
  }
@@ -1156,14 +1156,13 @@ export async function runReviewPipeline(
1156
1156
  ctx: ExtensionCommandContext,
1157
1157
  request: ParsedRequest,
1158
1158
  source: ReviewRunSource,
1159
+ runtime: ReviewRuntime,
1159
1160
  ): Promise<ReviewRunResult> {
1160
1161
  const startedAtMs = Date.now();
1161
- const prepared = await prepareReviewRun(pi, ctx, request);
1162
- if (!prepared.ok) {
1163
- return { ok: false, error: prepared.error };
1164
- }
1165
-
1166
- return withManagedReviewRun(pi, ctx, source, async (managed) => {
1162
+ return runtime.run(ctx, source, async (signal): Promise<ReviewRunResult> => {
1163
+ const prepared = await prepareReviewRun(pi, ctx, request, signal);
1164
+ signal.throwIfAborted();
1165
+ if (!prepared.ok) return prepared;
1167
1166
  const { scope, includeUntracked, baselineFingerprint, models, tasks } = prepared.data;
1168
1167
  if (source === "review") {
1169
1168
  const modelsText = models
@@ -1172,11 +1171,8 @@ export async function runReviewPipeline(
1172
1171
  notify(ctx, `Review focuses: ${request.focuses.join(", ")} · models: ${modelsText}.`, "info");
1173
1172
  }
1174
1173
 
1175
- const focusResults = await runFocusTasks(ctx, ctx.cwd, tasks, managed.control);
1176
- if (managed.control.isCancelled()) {
1177
- managed.markCancelled();
1178
- return { ok: false, error: REVIEW_CANCELLED_ERROR };
1179
- }
1174
+ const focusResults = await runFocusTasks(pi, ctx, ctx.cwd, tasks, signal);
1175
+ signal.throwIfAborted();
1180
1176
 
1181
1177
  const failedFocuses = focusResults.filter((focus) => !focus.ok);
1182
1178
  const failedCount = failedFocuses.length;
@@ -1219,17 +1215,17 @@ export async function runReviewPipeline(
1219
1215
  };
1220
1216
  }
1221
1217
 
1222
- const findings = await buildReviewFindings(ctx, ctx.cwd, successfulFocuses, managed.control);
1218
+ const findings = await buildReviewFindings(pi, ctx, ctx.cwd, successfulFocuses, signal);
1223
1219
 
1224
- const endingFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, includeUntracked);
1220
+ const endingFingerprint = await computeCurrentFingerprint(
1221
+ { cwd: ctx.cwd, signal },
1222
+ includeUntracked,
1223
+ );
1225
1224
  const reviewStaleness = !fingerprintsEqual(baselineFingerprint, endingFingerprint)
1226
1225
  ? buildReviewStaleness(source)
1227
1226
  : undefined;
1228
1227
 
1229
- if (managed.control.isCancelled()) {
1230
- managed.markCancelled();
1231
- return { ok: false, error: REVIEW_CANCELLED_ERROR };
1232
- }
1228
+ signal.throwIfAborted();
1233
1229
 
1234
1230
  const reviewedScopeLine = buildReviewedScopeLine(scope, Date.now() - startedAtMs);
1235
1231
  let findingsMarkdown = buildReviewFindingsMarkdown(
@@ -1299,7 +1295,6 @@ export async function runReviewPipeline(
1299
1295
  notify(ctx, `Review completed: ${findings.length} finding(s).`, "info");
1300
1296
  }
1301
1297
 
1302
- managed.markSuccessful();
1303
1298
  return { ok: true, details };
1304
1299
  });
1305
1300
  }