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,10 +1,10 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { spawn } from "node:child_process";
3
1
  import { createHash } from "node:crypto";
4
2
  import { promises as fs } from "node:fs";
5
3
  import path from "node:path";
6
4
 
7
5
  import { parsePrReference } from "./request.js";
6
+ import { runReviewCommand, type ReviewCommandContext } from "./runner.js";
7
+ import { joinAll } from "./runtime.js";
8
8
  import type { ReviewFingerprint, ReviewTarget } from "./schema.js";
9
9
 
10
10
  export type ResolvedScope =
@@ -46,44 +46,40 @@ function hashString(value: string): string {
46
46
  return createHash("sha256").update(value).digest("hex");
47
47
  }
48
48
 
49
- async function runGit(
50
- pi: ExtensionAPI,
51
- args: string[],
52
- ): Promise<{ stdout: string; stderr: string; code: number }> {
53
- const { stdout, stderr, code } = await pi.exec("git", args);
54
- return { stdout, stderr, code };
49
+ function runGit(context: ReviewCommandContext, args: string[]) {
50
+ return runReviewCommand(context, "git", args);
55
51
  }
56
52
 
57
- export async function isGitRepo(pi: ExtensionAPI): Promise<boolean> {
58
- const result = await runGit(pi, ["rev-parse", "--git-dir"]);
53
+ export async function isGitRepo(context: ReviewCommandContext): Promise<boolean> {
54
+ const result = await runGit(context, ["rev-parse", "--git-dir"]);
59
55
  return result.code === 0;
60
56
  }
61
57
 
62
- async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
63
- const { stdout, code } = await runGit(pi, ["branch", "--show-current"]);
58
+ async function getCurrentBranch(context: ReviewCommandContext): Promise<string | null> {
59
+ const { stdout, code } = await runGit(context, ["branch", "--show-current"]);
64
60
  if (code !== 0) return null;
65
61
  const branch = stdout.trim();
66
62
  return branch.length > 0 ? branch : null;
67
63
  }
68
64
 
69
- async function resolveHeadSha(pi: ExtensionAPI): Promise<string | null> {
70
- const { code, stdout } = await runGit(pi, ["rev-parse", "--verify", "HEAD"]);
65
+ async function resolveHeadSha(context: ReviewCommandContext): Promise<string | null> {
66
+ const { code, stdout } = await runGit(context, ["rev-parse", "--verify", "HEAD"]);
71
67
  if (code !== 0) return null;
72
68
  const sha = stdout.trim();
73
69
  return sha.length > 0 ? sha : null;
74
70
  }
75
71
 
76
- async function hasHeadCommit(pi: ExtensionAPI): Promise<boolean> {
77
- return (await resolveHeadSha(pi)) !== null;
72
+ async function hasHeadCommit(context: ReviewCommandContext): Promise<boolean> {
73
+ return (await resolveHeadSha(context)) !== null;
78
74
  }
79
75
 
80
- async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
81
- const remoteHead = await runGit(pi, ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
76
+ async function getDefaultBranch(context: ReviewCommandContext): Promise<string> {
77
+ const remoteHead = await runGit(context, ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
82
78
  if (remoteHead.code === 0 && remoteHead.stdout.trim()) {
83
79
  return remoteHead.stdout.trim().replace(/^origin\//, "");
84
80
  }
85
81
 
86
- const branches = await runGit(pi, ["branch", "--format=%(refname:short)"]);
82
+ const branches = await runGit(context, ["branch", "--format=%(refname:short)"]);
87
83
  if (branches.code === 0) {
88
84
  const names = parseGitFileList(branches.stdout);
89
85
  if (names.includes("main")) return "main";
@@ -94,8 +90,8 @@ async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
94
90
  return "main";
95
91
  }
96
92
 
97
- async function getMergeBase(pi: ExtensionAPI, branch: string): Promise<string | null> {
98
- const { stdout, code } = await runGit(pi, ["merge-base", "HEAD", branch]);
93
+ async function getMergeBase(context: ReviewCommandContext, branch: string): Promise<string | null> {
94
+ const { stdout, code } = await runGit(context, ["merge-base", "HEAD", branch]);
99
95
  if (code !== 0) return null;
100
96
  const sha = stdout.trim();
101
97
  return sha.length > 0 ? sha : null;
@@ -108,12 +104,15 @@ function parseGitFileList(stdout: string): string[] {
108
104
  .filter(Boolean);
109
105
  }
110
106
 
111
- async function getTrackedChangedFiles(pi: ExtensionAPI, hasHead?: boolean): Promise<string[]> {
112
- const headAvailable = hasHead ?? (await hasHeadCommit(pi));
107
+ async function getTrackedChangedFiles(
108
+ context: ReviewCommandContext,
109
+ hasHead?: boolean,
110
+ ): Promise<string[]> {
111
+ const headAvailable = hasHead ?? (await hasHeadCommit(context));
113
112
  if (!headAvailable) {
114
- const [staged, unstaged] = await Promise.all([
115
- runGit(pi, ["diff", "--cached", "--name-only"]),
116
- runGit(pi, ["diff", "--name-only"]),
113
+ const [staged, unstaged] = await joinAll([
114
+ runGit(context, ["diff", "--cached", "--name-only"]),
115
+ runGit(context, ["diff", "--name-only"]),
117
116
  ]);
118
117
 
119
118
  const files = new Set<string>([
@@ -123,35 +122,38 @@ async function getTrackedChangedFiles(pi: ExtensionAPI, hasHead?: boolean): Prom
123
122
  return Array.from(files).sort((a, b) => a.localeCompare(b));
124
123
  }
125
124
 
126
- const { stdout, code } = await runGit(pi, ["diff", "HEAD", "--name-only"]);
125
+ const { stdout, code } = await runGit(context, ["diff", "HEAD", "--name-only"]);
127
126
  if (code !== 0) return [];
128
127
  return parseGitFileList(stdout);
129
128
  }
130
129
 
131
- async function getUntrackedFiles(pi: ExtensionAPI): Promise<string[]> {
132
- const { stdout, code } = await runGit(pi, ["ls-files", "--others", "--exclude-standard"]);
130
+ async function getUntrackedFiles(context: ReviewCommandContext): Promise<string[]> {
131
+ const { stdout, code } = await runGit(context, ["ls-files", "--others", "--exclude-standard"]);
133
132
  if (code !== 0) return [];
134
133
  return parseGitFileList(stdout);
135
134
  }
136
135
 
137
- async function getDiffFilesInRange(pi: ExtensionAPI, range: string): Promise<string[]> {
138
- const { stdout, code } = await runGit(pi, ["diff", "--name-only", range]);
136
+ async function getDiffFilesInRange(
137
+ context: ReviewCommandContext,
138
+ range: string,
139
+ ): Promise<string[]> {
140
+ const { stdout, code } = await runGit(context, ["diff", "--name-only", range]);
139
141
  if (code !== 0) return [];
140
142
  return parseGitFileList(stdout);
141
143
  }
142
144
 
143
- async function hasPendingTrackedChanges(pi: ExtensionAPI): Promise<boolean> {
144
- const { stdout, code } = await runGit(pi, ["status", "--porcelain"]);
145
+ async function hasPendingTrackedChanges(context: ReviewCommandContext): Promise<boolean> {
146
+ const { stdout, code } = await runGit(context, ["status", "--porcelain"]);
145
147
  if (code !== 0) return false;
146
148
  const lines = stdout.split("\n").filter((line) => line.length > 0);
147
149
  return lines.some((line) => !line.startsWith("??"));
148
150
  }
149
151
 
150
152
  async function getPrInfo(
151
- pi: ExtensionAPI,
153
+ context: ReviewCommandContext,
152
154
  prNumber: number,
153
155
  ): Promise<{ baseBranch: string; headBranch: string } | null> {
154
- const { stdout, code } = await pi.exec("gh", [
156
+ const { stdout, code } = await runReviewCommand(context, "gh", [
155
157
  "pr",
156
158
  "view",
157
159
  String(prNumber),
@@ -174,16 +176,16 @@ async function getPrInfo(
174
176
  }
175
177
 
176
178
  async function checkoutPr(
177
- pi: ExtensionAPI,
179
+ context: ReviewCommandContext,
178
180
  prNumber: number,
179
181
  ): Promise<{ ok: boolean; error?: string }> {
180
- const fetch = await runGit(pi, ["fetch", "origin", `refs/pull/${prNumber}/head`]);
182
+ const fetch = await runGit(context, ["fetch", "origin", `refs/pull/${prNumber}/head`]);
181
183
  if (fetch.code !== 0) {
182
184
  const error = (fetch.stderr || fetch.stdout || "Failed to fetch PR").trim();
183
185
  return { ok: false, error };
184
186
  }
185
187
 
186
- const checkout = await runGit(pi, ["switch", "--detach", "FETCH_HEAD"]);
188
+ const checkout = await runGit(context, ["switch", "--detach", "FETCH_HEAD"]);
187
189
  if (checkout.code !== 0) {
188
190
  const error = (checkout.stderr || checkout.stdout || "Failed to checkout PR").trim();
189
191
  return { ok: false, error };
@@ -192,27 +194,29 @@ async function checkoutPr(
192
194
  return { ok: true };
193
195
  }
194
196
 
195
- export async function getPrCheckoutBlockedError(pi: ExtensionAPI): Promise<string | null> {
196
- return (await hasPendingTrackedChanges(pi))
197
+ export async function getPrCheckoutBlockedError(
198
+ context: ReviewCommandContext,
199
+ ): Promise<string | null> {
200
+ return (await hasPendingTrackedChanges(context))
197
201
  ? "Cannot checkout PR with pending tracked changes. Commit or stash first."
198
202
  : null;
199
203
  }
200
204
 
201
205
  export async function preparePrCheckoutScope(
202
- pi: ExtensionAPI,
206
+ context: ReviewCommandContext,
203
207
  reportStatus: GitStatusReporter | undefined,
204
208
  details: { prNumber: number; baseBranch: string; headBranch: string },
205
209
  ): Promise<
206
210
  | { ok: false; error: string }
207
211
  | { ok: true; scope: Extract<ResolvedScope, { kind: "branch-diff" }> }
208
212
  > {
209
- const blockedError = await getPrCheckoutBlockedError(pi);
213
+ const blockedError = await getPrCheckoutBlockedError(context);
210
214
  if (blockedError) {
211
215
  return { ok: false, error: blockedError };
212
216
  }
213
217
 
214
218
  reportStatus?.(`Checking out PR #${details.prNumber}...`, "info");
215
- const checkout = await checkoutPr(pi, details.prNumber);
219
+ const checkout = await checkoutPr(context, details.prNumber);
216
220
  if (!checkout.ok) {
217
221
  return {
218
222
  ok: false,
@@ -221,7 +225,7 @@ export async function preparePrCheckoutScope(
221
225
  }
222
226
  reportStatus?.(`Checked out PR #${details.prNumber} (${details.headBranch}).`, "info");
223
227
 
224
- const resolvedScope = await resolveBranchDiffScope(pi, {
228
+ const resolvedScope = await resolveBranchDiffScope(context, {
225
229
  baseBranch: details.baseBranch,
226
230
  description: (diffFileCount) =>
227
231
  `PR #${details.prNumber} diff vs ${details.baseBranch} (${diffFileCount} files)`,
@@ -238,17 +242,21 @@ export async function preparePrCheckoutScope(
238
242
  return { ok: true, scope: resolvedScope.scope };
239
243
  }
240
244
 
241
- export async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
245
+ export async function loadProjectReviewGuidelines({
246
+ cwd,
247
+ signal,
248
+ }: ReviewCommandContext): Promise<string | null> {
242
249
  let currentDir = path.resolve(cwd);
243
250
 
244
251
  while (true) {
252
+ signal?.throwIfAborted();
245
253
  const piDir = path.join(currentDir, ".pi");
246
254
  const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md");
247
255
 
248
256
  const piStats = await fs.stat(piDir).catch(() => null);
249
257
  if (piStats?.isDirectory()) {
250
258
  try {
251
- const content = await fs.readFile(guidelinesPath, "utf8");
259
+ const content = await fs.readFile(guidelinesPath, { encoding: "utf8", signal });
252
260
  const trimmed = content.trim();
253
261
  return trimmed.length > 0 ? trimmed : null;
254
262
  } catch {
@@ -262,13 +270,13 @@ export async function loadProjectReviewGuidelines(cwd: string): Promise<string |
262
270
  }
263
271
  }
264
272
 
265
- async function hashGitDiff(pi: ExtensionAPI): Promise<string> {
266
- const head = await runGit(pi, ["diff", "--no-ext-diff", "HEAD"]);
273
+ async function hashGitDiff(context: ReviewCommandContext): Promise<string> {
274
+ const head = await runGit(context, ["diff", "--no-ext-diff", "HEAD"]);
267
275
  if (head.code === 0) return hashString(head.stdout);
268
276
 
269
- const [staged, unstaged] = await Promise.all([
270
- runGit(pi, ["diff", "--no-ext-diff", "--cached"]),
271
- runGit(pi, ["diff", "--no-ext-diff"]),
277
+ const [staged, unstaged] = await joinAll([
278
+ runGit(context, ["diff", "--no-ext-diff", "--cached"]),
279
+ runGit(context, ["diff", "--no-ext-diff"]),
272
280
  ]);
273
281
  const stagedHash = staged.code === 0 ? hashString(staged.stdout) : null;
274
282
  const unstagedHash = unstaged.code === 0 ? hashString(unstaged.stdout) : null;
@@ -277,53 +285,36 @@ async function hashGitDiff(pi: ExtensionAPI): Promise<string> {
277
285
  }
278
286
 
279
287
  async function computeUntrackedContentHash(
280
- pi: ExtensionAPI,
281
- cwd: string,
288
+ context: ReviewCommandContext,
282
289
  precomputedUntrackedFiles?: string[],
283
290
  ): Promise<string> {
284
- const untrackedFiles = [...(precomputedUntrackedFiles ?? (await getUntrackedFiles(pi)))].sort(
285
- (a, b) => a.localeCompare(b),
286
- );
291
+ const untrackedFiles = [
292
+ ...(precomputedUntrackedFiles ?? (await getUntrackedFiles(context))),
293
+ ].sort((a, b) => a.localeCompare(b));
287
294
  if (untrackedFiles.length === 0) return hashString("");
288
295
 
289
- const hashes = await hashObjectBatch(cwd, untrackedFiles);
296
+ const { stdout, code } = await runReviewCommand(
297
+ context,
298
+ "git",
299
+ ["hash-object", "--stdin-paths"],
300
+ untrackedFiles.join("\n"),
301
+ );
302
+ const hashes = code === 0 ? stdout.trim().split("\n") : [];
290
303
  const entries = untrackedFiles.map((file, i) => `${file}\0${hashes[i] ?? "missing"}`);
291
304
  return hashString(entries.join("\n"));
292
305
  }
293
306
 
294
- function hashObjectBatch(cwd: string, files: string[]): Promise<string[]> {
295
- return new Promise((resolve) => {
296
- const proc = spawn("git", ["hash-object", "--stdin-paths"], {
297
- cwd,
298
- stdio: ["pipe", "pipe", "ignore"],
299
- });
300
- let stdout = "";
301
- proc.stdout.on("data", (chunk: Buffer) => {
302
- stdout += chunk.toString();
303
- });
304
- proc.on("error", () => resolve([]));
305
- proc.on("close", (code) => {
306
- resolve(code === 0 ? stdout.trim().split("\n") : []);
307
- });
308
- proc.stdin.on("error", () => {
309
- /* ignore broken pipe */
310
- });
311
- proc.stdin.end(files.join("\n"));
312
- });
313
- }
314
-
315
307
  export async function computeCurrentFingerprint(
316
- pi: ExtensionAPI,
317
- cwd: string,
308
+ context: ReviewCommandContext,
318
309
  includeUntracked: boolean,
319
310
  precomputedUntrackedFiles?: string[],
320
311
  ): Promise<ReviewFingerprint> {
321
- const [headSha, branch, trackedDiffHash, untrackedHash] = await Promise.all([
322
- resolveHeadSha(pi).then((sha) => sha ?? ""),
323
- getCurrentBranch(pi).then((branch) => branch ?? ""),
324
- hashGitDiff(pi),
312
+ const [headSha, branch, trackedDiffHash, untrackedHash] = await joinAll([
313
+ resolveHeadSha(context).then((sha) => sha ?? ""),
314
+ getCurrentBranch(context).then((branch) => branch ?? ""),
315
+ hashGitDiff(context),
325
316
  includeUntracked
326
- ? computeUntrackedContentHash(pi, cwd, precomputedUntrackedFiles)
317
+ ? computeUntrackedContentHash(context, precomputedUntrackedFiles)
327
318
  : Promise.resolve(REVIEW_UNTRACKED_HASH_DISABLED),
328
319
  ]);
329
320
 
@@ -349,16 +340,16 @@ type BranchDiffScopeOptions = {
349
340
  };
350
341
 
351
342
  async function resolveBranchDiffScope(
352
- pi: ExtensionAPI,
343
+ context: ReviewCommandContext,
353
344
  options: BranchDiffScopeOptions,
354
345
  ): Promise<{ scope?: ResolvedScope; error?: string }> {
355
- const mergeBase = await getMergeBase(pi, options.baseBranch);
346
+ const mergeBase = await getMergeBase(context, options.baseBranch);
356
347
  if (!mergeBase) {
357
348
  return { error: options.mergeBaseError };
358
349
  }
359
350
 
360
351
  const range = `${mergeBase}..HEAD`;
361
- const diffFiles = await getDiffFilesInRange(pi, range);
352
+ const diffFiles = await getDiffFilesInRange(context, range);
362
353
  if (diffFiles.length === 0) {
363
354
  return { error: options.emptyDiffError };
364
355
  }
@@ -375,20 +366,19 @@ async function resolveBranchDiffScope(
375
366
  }
376
367
 
377
368
  export async function resolveScope(
378
- pi: ExtensionAPI,
369
+ context: ReviewCommandContext,
379
370
  target: ReviewTarget,
380
371
  reportStatus?: GitStatusReporter,
381
372
  ): Promise<{ scope?: ResolvedScope; error?: string }> {
382
373
  switch (target.type) {
383
374
  case "auto":
384
375
  case "uncommitted": {
385
- const untrackedPromise = getUntrackedFiles(pi);
386
- const headSha = await resolveHeadSha(pi);
387
- const hasHead = headSha !== null;
388
- const [trackedFiles, untrackedFiles] = await Promise.all([
389
- getTrackedChangedFiles(pi, hasHead),
390
- untrackedPromise,
376
+ const [headSha, untrackedFiles] = await joinAll([
377
+ resolveHeadSha(context),
378
+ getUntrackedFiles(context),
391
379
  ]);
380
+ const hasHead = headSha !== null;
381
+ const trackedFiles = await getTrackedChangedFiles(context, hasHead);
392
382
  if (trackedFiles.length > 0 || untrackedFiles.length > 0) {
393
383
  return {
394
384
  scope: {
@@ -405,8 +395,8 @@ export async function resolveScope(
405
395
  return { error: "No uncommitted changes to review." };
406
396
  }
407
397
 
408
- const baseBranch = await getDefaultBranch(pi);
409
- return resolveBranchDiffScope(pi, {
398
+ const baseBranch = await getDefaultBranch(context);
399
+ return resolveBranchDiffScope(context, {
410
400
  baseBranch,
411
401
  description: (diffFileCount) => `branch diff vs ${baseBranch} (${diffFileCount} files)`,
412
402
  mergeBaseError: `Could not determine merge-base against ${baseBranch}`,
@@ -414,7 +404,7 @@ export async function resolveScope(
414
404
  });
415
405
  }
416
406
  case "branch": {
417
- return resolveBranchDiffScope(pi, {
407
+ return resolveBranchDiffScope(context, {
418
408
  baseBranch: target.branch,
419
409
  description: (diffFileCount) => `branch diff vs ${target.branch} (${diffFileCount} files)`,
420
410
  mergeBaseError: `Could not determine merge-base against ${target.branch}`,
@@ -431,7 +421,7 @@ export async function resolveScope(
431
421
  };
432
422
  }
433
423
  case "pr": {
434
- const blockedError = await getPrCheckoutBlockedError(pi);
424
+ const blockedError = await getPrCheckoutBlockedError(context);
435
425
  if (blockedError) {
436
426
  return { error: blockedError };
437
427
  }
@@ -442,14 +432,14 @@ export async function resolveScope(
442
432
  }
443
433
 
444
434
  reportStatus?.(`Fetching PR #${prNumber} information...`, "info");
445
- const prInfo = await getPrInfo(pi, prNumber);
435
+ const prInfo = await getPrInfo(context, prNumber);
446
436
  if (!prInfo) {
447
437
  return {
448
438
  error: `Could not load PR #${prNumber}. Ensure gh is authenticated and PR exists.`,
449
439
  };
450
440
  }
451
441
 
452
- const preparedPrScope = await preparePrCheckoutScope(pi, reportStatus, {
442
+ const preparedPrScope = await preparePrCheckoutScope(context, reportStatus, {
453
443
  prNumber,
454
444
  baseBranch: prInfo.baseBranch,
455
445
  headBranch: prInfo.headBranch,
@@ -495,7 +485,7 @@ export function buildScopeInstructions(scope: ResolvedScope): string {
495
485
  : "- There are no tracked-file diffs.";
496
486
  const untracked =
497
487
  scope.untrackedFiles.length > 0
498
- ? `- Also review untracked files as snapshots by reading them directly.\n- Untracked files (${scope.untrackedFiles.length}):\n${scope.untrackedFiles.map((f) => ` - ${f}`).join("\n")}`
488
+ ? `- Also review untracked files as additions by reading their current contents directly.\n- Untracked files (${scope.untrackedFiles.length}):\n${scope.untrackedFiles.map((f) => ` - ${f}`).join("\n")}`
499
489
  : "- There are no untracked files.";
500
490
  return `Scope: working tree review.\n${tracked}\n${untracked}`;
501
491
  }
@@ -503,7 +493,7 @@ export function buildScopeInstructions(scope: ResolvedScope): string {
503
493
  return `Scope: branch diff review against base branch ${scope.baseBranch}.\n- Merge base: ${scope.mergeBase}\n- First capture the full diff with: \`git diff ${scope.mergeBase}..HEAD\` (treat this diff as mandatory review context).\n- Files in diff (${scope.diffFiles.length}):\n${scope.diffFiles.map((f) => ` - ${f}`).join("\n")}`;
504
494
  }
505
495
  case "commit": {
506
- return `Scope: commit review for ${scope.sha}.\n- First capture the full commit patch with: \`git show --stat --patch ${scope.sha}\` (treat this patch as mandatory review context).\n- Focus only on changes introduced by this commit.`;
496
+ return `Scope: commit review for ${scope.sha}.\n- First capture the full commit patch with: \`git show --stat --patch ${scope.sha}\` (treat this patch as mandatory review context).`;
507
497
  }
508
498
  case "folder": {
509
499
  return `Scope: snapshot review of selected paths (not a diff).\n- Paths:\n${scope.paths
@@ -29,14 +29,9 @@ import {
29
29
  } from "./request.js";
30
30
  import { runReviewPipeline } from "./review.js";
31
31
  import {
32
- acquireReviewRunLock,
33
32
  createAgentRunTracker,
34
- handleReviewSessionShutdown,
35
- handleReviewSessionStart,
33
+ createReviewRuntime,
36
34
  notify,
37
- recordPromptEnd,
38
- recordPromptStart,
39
- releaseReviewRunLock,
40
35
  type AgentEndMessages,
41
36
  } from "./runtime.js";
42
37
  import { runTriagePipeline } from "./triage.js";
@@ -66,69 +61,39 @@ function stopAndFlushReviewQueue(
66
61
 
67
62
  type BackgroundRunResult = { ok: true } | { ok: false; error: string };
68
63
 
69
- function startQueuedBackgroundRun(
70
- ctx: ExtensionCommandContext,
71
- reviewMessageQueue: ReviewMessageQueue,
72
- options: {
73
- busyMessage: string;
74
- startMessage: string;
75
- failurePrefix: string;
76
- run: () => Promise<BackgroundRunResult>;
77
- },
78
- ): void {
79
- const sessionKey = acquireReviewRunLock(ctx, options.busyMessage);
80
- if (!sessionKey) return;
81
-
82
- const stopReviewQueue = reviewMessageQueue.start(ctx);
83
- notify(ctx, options.startMessage, "info");
84
- void (async () => {
85
- try {
86
- const result = await options.run();
87
- if (!result.ok) {
88
- notify(ctx, result.error, "error");
89
- }
90
- } catch (error) {
91
- notify(
92
- ctx,
93
- `${options.failurePrefix}: ${error instanceof Error ? error.message : String(error)}`,
94
- "error",
95
- );
96
- } finally {
97
- releaseReviewRunLock(sessionKey);
98
- stopAndFlushReviewQueue(ctx, reviewMessageQueue, stopReviewQueue);
99
- }
100
- })();
101
- }
102
-
103
64
  export default function reviewExtension(pi: ExtensionAPI) {
104
65
  const reviewMessageQueue = createReviewMessageQueue(pi);
105
66
  const agentTracker = createAgentRunTracker();
67
+ const runtime = createReviewRuntime(pi, (ctx) => {
68
+ reviewMessageQueue.retain(ctx);
69
+ });
70
+ let background: Promise<void> | undefined;
106
71
 
107
- pi.events.on("ui:prompt_start", () => {
108
- recordPromptStart();
72
+ pi.on("ui_prompt_start", async () => {
73
+ runtime.setPromptActive(true);
109
74
  });
110
75
 
111
- pi.events.on("ui:prompt_end", () => {
112
- recordPromptEnd();
76
+ pi.on("ui_prompt_end", async () => {
77
+ runtime.setPromptActive(false);
113
78
  });
114
79
 
115
80
  pi.on("input", async (event, ctx) => {
116
- if (!reviewMessageQueue.handleInput(event, ctx)) return { action: "continue" };
117
- return { action: "handled" };
81
+ return reviewMessageQueue.handleInput(event, ctx) ?? { action: "continue" };
118
82
  });
119
83
 
120
84
  pi.on("agent_start", async () => {
121
85
  agentTracker.handleStart();
122
86
  });
123
87
 
124
- pi.on("session_start", async (_event, ctx) => {
125
- handleReviewSessionStart(ctx);
88
+ pi.on("session_start", async () => {
126
89
  agentTracker.reset();
127
90
  });
128
91
 
129
92
  pi.on("session_shutdown", async (_event, ctx) => {
130
- handleReviewSessionShutdown(ctx);
93
+ const closing = runtime.shutdown();
131
94
  reviewMessageQueue.clear(ctx);
95
+ await closing;
96
+ await background;
132
97
  agentTracker.reset();
133
98
  });
134
99
 
@@ -153,11 +118,11 @@ export default function reviewExtension(pi: ExtensionAPI) {
153
118
  return;
154
119
  }
155
120
 
156
- startQueuedBackgroundRun(ctx, reviewMessageQueue, {
121
+ startQueuedBackgroundRun(ctx, {
157
122
  busyMessage: "A /review run is already active in this session.",
158
123
  startMessage: "Starting review in background...",
159
124
  failurePrefix: "Review run failed",
160
- run: () => runReviewPipeline(pi, ctx, parsed.value, "review"),
125
+ run: () => runReviewPipeline(pi, ctx, parsed.value, "review", runtime),
161
126
  });
162
127
  },
163
128
  });
@@ -172,12 +137,12 @@ export default function reviewExtension(pi: ExtensionAPI) {
172
137
  return;
173
138
  }
174
139
 
175
- startQueuedBackgroundRun(ctx, reviewMessageQueue, {
140
+ startQueuedBackgroundRun(ctx, {
176
141
  busyMessage:
177
142
  "A review-related run is already active in this session. Wait for it to finish before /triage.",
178
143
  startMessage: "Starting PR triage in background...",
179
144
  failurePrefix: "PR triage failed",
180
- run: () => runTriagePipeline(pi, ctx, parsed.value),
145
+ run: () => runTriagePipeline(pi, ctx, parsed.value, runtime),
181
146
  });
182
147
  },
183
148
  });
@@ -199,21 +164,23 @@ export default function reviewExtension(pi: ExtensionAPI) {
199
164
  return;
200
165
  }
201
166
 
202
- const sessionKey = acquireReviewRunLock(
203
- ctx,
204
- "A /review run is active in this session. Wait for it to finish before /fix.",
205
- );
206
- if (!sessionKey) return;
167
+ if (
168
+ !runtime.acquire(
169
+ ctx,
170
+ "A /review run is active in this session. Wait for it to finish before /fix.",
171
+ )
172
+ )
173
+ return;
207
174
 
208
175
  const stopReviewQueue = reviewMessageQueue.start(ctx);
209
176
  try {
210
177
  if (loop) {
211
178
  notify(ctx, "Starting fix loop...", "info");
212
- await runFixLoop(pi, ctx, request, agentTracker, reviewMessageQueue);
179
+ await runFixLoop(pi, ctx, request, agentTracker, reviewMessageQueue, runtime);
213
180
  return;
214
181
  }
215
182
 
216
- const reviewDetails = await prepareFixReviewDetails(pi, ctx, request);
183
+ const reviewDetails = await prepareFixReviewDetails(pi, ctx, request, runtime);
217
184
  if (!reviewDetails) return;
218
185
  await runFixPassFromReview(
219
186
  pi,
@@ -224,9 +191,40 @@ export default function reviewExtension(pi: ExtensionAPI) {
224
191
  reviewMessageQueue,
225
192
  );
226
193
  } finally {
227
- releaseReviewRunLock(sessionKey);
228
- stopAndFlushReviewQueue(ctx, reviewMessageQueue, stopReviewQueue);
194
+ runtime.release();
195
+ if (!runtime.closed) stopAndFlushReviewQueue(ctx, reviewMessageQueue, stopReviewQueue);
229
196
  }
230
197
  },
231
198
  });
199
+
200
+ function startQueuedBackgroundRun(
201
+ ctx: ExtensionCommandContext,
202
+ options: {
203
+ busyMessage: string;
204
+ startMessage: string;
205
+ failurePrefix: string;
206
+ run: () => Promise<BackgroundRunResult>;
207
+ },
208
+ ): void {
209
+ if (!runtime.acquire(ctx, options.busyMessage)) return;
210
+ const stopReviewQueue = reviewMessageQueue.start(ctx);
211
+ notify(ctx, options.startMessage, "info");
212
+ background = (async () => {
213
+ try {
214
+ const result = await options.run();
215
+ if (!result.ok && !runtime.closed) notify(ctx, result.error, "error");
216
+ } catch (error) {
217
+ if (!runtime.closed) {
218
+ notify(
219
+ ctx,
220
+ `${options.failurePrefix}: ${error instanceof Error ? error.message : String(error)}`,
221
+ "error",
222
+ );
223
+ }
224
+ } finally {
225
+ runtime.release();
226
+ if (!runtime.closed) stopAndFlushReviewQueue(ctx, reviewMessageQueue, stopReviewQueue);
227
+ }
228
+ })();
229
+ }
232
230
  }