@oh-my-pi/pi-coding-agent 16.0.0 → 16.0.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +140 -133
  2. package/dist/cli.js +250 -218
  3. package/dist/types/config/model-resolver.d.ts +14 -0
  4. package/dist/types/config/settings-schema.d.ts +22 -0
  5. package/dist/types/discovery/helpers.d.ts +7 -0
  6. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  7. package/dist/types/exec/non-interactive-env.d.ts +2 -0
  8. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  9. package/dist/types/modes/types.d.ts +5 -0
  10. package/dist/types/session/agent-session.d.ts +11 -1
  11. package/dist/types/session/messages.d.ts +3 -0
  12. package/dist/types/session/session-manager.d.ts +4 -1
  13. package/dist/types/task/index.d.ts +21 -0
  14. package/dist/types/tools/github-cache.d.ts +5 -4
  15. package/dist/types/tools/job.d.ts +1 -0
  16. package/dist/types/utils/markit.d.ts +8 -0
  17. package/dist/types/web/search/index.d.ts +2 -2
  18. package/dist/types/web/search/provider.d.ts +2 -0
  19. package/package.json +12 -12
  20. package/src/advisor/__tests__/advisor.test.ts +44 -0
  21. package/src/cli/args.ts +2 -0
  22. package/src/collab/host.ts +1 -1
  23. package/src/config/model-resolver.ts +35 -1
  24. package/src/config/settings-schema.ts +23 -1
  25. package/src/discovery/claude-plugins.ts +3 -42
  26. package/src/discovery/github.ts +189 -6
  27. package/src/discovery/helpers.ts +11 -0
  28. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  29. package/src/eval/js/shared/prelude.txt +12 -3
  30. package/src/eval/py/prelude.py +26 -2
  31. package/src/exec/bash-executor.ts +2 -2
  32. package/src/exec/non-interactive-env.ts +71 -0
  33. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  34. package/src/extensibility/extensions/runner.ts +17 -1
  35. package/src/extensibility/plugins/loader.ts +157 -23
  36. package/src/extensibility/plugins/manager.ts +44 -36
  37. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  38. package/src/extensibility/plugins/runtime-config.ts +9 -0
  39. package/src/internal-urls/docs-index.generated.ts +9 -9
  40. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  41. package/src/main.ts +5 -1
  42. package/src/modes/acp/acp-agent.ts +3 -3
  43. package/src/modes/components/settings-defs.ts +7 -0
  44. package/src/modes/components/tips.txt +1 -1
  45. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  46. package/src/modes/controllers/input-controller.ts +1 -0
  47. package/src/modes/controllers/selector-controller.ts +7 -0
  48. package/src/modes/interactive-mode.ts +47 -0
  49. package/src/modes/rpc/rpc-mode.ts +3 -3
  50. package/src/modes/runtime-init.ts +2 -1
  51. package/src/modes/types.ts +5 -0
  52. package/src/prompts/agents/designer.md +8 -0
  53. package/src/prompts/review-request.md +1 -1
  54. package/src/prompts/system/subagent-system-prompt.md +4 -1
  55. package/src/prompts/tools/eval.md +13 -3
  56. package/src/prompts/tools/irc.md +1 -1
  57. package/src/sdk.ts +9 -1
  58. package/src/session/agent-session.ts +260 -50
  59. package/src/session/messages.ts +1 -1
  60. package/src/session/session-manager.ts +3 -1
  61. package/src/slash-commands/builtin-registry.ts +5 -2
  62. package/src/system-prompt.ts +7 -1
  63. package/src/task/executor.ts +105 -8
  64. package/src/task/index.ts +70 -9
  65. package/src/tools/github-cache.ts +32 -7
  66. package/src/tools/job.ts +14 -1
  67. package/src/utils/lang-from-path.ts +5 -0
  68. package/src/utils/markit.ts +24 -1
  69. package/src/web/search/index.ts +2 -2
  70. package/src/web/search/provider.ts +14 -2
@@ -17,6 +17,7 @@ import type { HookCommandContext } from "../../../../extensibility/hooks/types";
17
17
  import reviewCustomRequestTemplate from "../../../../prompts/review-custom-request.md" with { type: "text" };
18
18
  import reviewHeadlessRequestTemplate from "../../../../prompts/review-headless-request.md" with { type: "text" };
19
19
  import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
20
+ import * as gh from "../../../../tools/gh";
20
21
  import * as git from "../../../../utils/git";
21
22
  import * as jj from "../../../../utils/jj";
22
23
 
@@ -45,6 +46,25 @@ interface CurrentReviewDiff {
45
46
  mode: string;
46
47
  }
47
48
 
49
+ interface ReviewPrRef {
50
+ repo: string;
51
+ number: number;
52
+ raw: string;
53
+ kind: "github-url" | "pr-url";
54
+ }
55
+
56
+ interface ParsedReviewArgs {
57
+ prRef: ReviewPrRef | undefined;
58
+ extraInstructions: string;
59
+ }
60
+
61
+ type ReviewMenuChoice =
62
+ | { kind: "detected-pr"; ref: ReviewPrRef }
63
+ | { kind: "base-branch" }
64
+ | { kind: "uncommitted" }
65
+ | { kind: "commit" }
66
+ | { kind: "custom" };
67
+
48
68
  // ─────────────────────────────────────────────────────────────────────────────
49
69
  // Exclusion patterns for noise files
50
70
  // ─────────────────────────────────────────────────────────────────────────────
@@ -204,6 +224,7 @@ function getDiffPreview(hunks: string, maxLines: number): string {
204
224
  const MAX_DIFF_CHARS = 50_000; // Don't include diff above this
205
225
  const MAX_FILES_FOR_INLINE_DIFF = 20; // Don't include diff if more files than this
206
226
  const DEFAULT_LARGE_DIFF_INSTRUCTION = "MUST run `git diff`/`git show` for assigned files";
227
+ const DEFAULT_CONTEXT_INSTRUCTION = "MAY read full file context as needed via `read`";
207
228
  const GIT_UNCOMMITTED_DIFF_INSTRUCTION =
208
229
  "MUST run both `git diff -- <path>` and `git diff --cached -- <path>` for assigned files";
209
230
  const JJ_UNCOMMITTED_DIFF_INSTRUCTION = "MUST run `jj --ignore-working-copy diff --git -- <path>` for assigned files";
@@ -215,7 +236,7 @@ function buildReviewPrompt(
215
236
  mode: string,
216
237
  stats: DiffStats,
217
238
  rawDiff: string,
218
- options: { additionalInstructions?: string; diffInstruction?: string } = {},
239
+ options: { additionalInstructions?: string; diffInstruction?: string; contextInstruction?: string } = {},
219
240
  ): string {
220
241
  const agentCount = getRecommendedAgentCount(stats);
221
242
  const skipDiff = rawDiff.length > MAX_DIFF_CHARS || stats.files.length > MAX_FILES_FOR_INLINE_DIFF;
@@ -242,6 +263,7 @@ function buildReviewPrompt(
242
263
  linesPerFile,
243
264
  additionalInstructions: options.additionalInstructions,
244
265
  diffInstruction: options.diffInstruction ?? DEFAULT_LARGE_DIFF_INSTRUCTION,
266
+ contextInstruction: options.contextInstruction ?? DEFAULT_CONTEXT_INSTRUCTION,
245
267
  });
246
268
  }
247
269
 
@@ -253,6 +275,203 @@ function buildHeadlessReviewPrompt(focus?: string): string {
253
275
  return prompt.render(reviewHeadlessRequestTemplate, { focus });
254
276
  }
255
277
 
278
+ const REVIEW_CONTEXT_PR_LIMIT = 3;
279
+ const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9_.-]+$/;
280
+ const PR_SCHEME_PATTERN = /^pr:\/\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/([1-9]\d*)(?:\/diff(?:\/(?:all|[1-9]\d*))?)?$/;
281
+ const PR_REF_TEXT_PATTERN = /https:\/\/github\.com\/[^\s<>"']+|pr:\/\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[^\s<>"']+/g;
282
+
283
+ function stripTrailingPrRefPunctuation(text: string): string {
284
+ return text.replace(/[.,)\]>]+$/g, "");
285
+ }
286
+
287
+ function isValidRepoSegment(segment: string | undefined): segment is string {
288
+ return segment !== undefined && REPO_SEGMENT_PATTERN.test(segment);
289
+ }
290
+
291
+ function parsePositivePrNumber(value: string | undefined): number | undefined {
292
+ if (value === undefined || !/^[1-9]\d*$/.test(value)) return undefined;
293
+ const parsed = Number(value);
294
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
295
+ }
296
+
297
+ function parseGithubPrUrl(text: string): ReviewPrRef | undefined {
298
+ let url: URL;
299
+ try {
300
+ url = new URL(text);
301
+ } catch {
302
+ return undefined;
303
+ }
304
+
305
+ if (url.protocol !== "https:" || url.hostname !== "github.com") return undefined;
306
+
307
+ const parts = url.pathname.split("/").filter(Boolean);
308
+ if (parts.length < 4 || parts[2] !== "pull") return undefined;
309
+
310
+ const [owner, repo, , numberPart] = parts;
311
+ if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return undefined;
312
+
313
+ const number = parsePositivePrNumber(numberPart);
314
+ if (number === undefined) return undefined;
315
+
316
+ return { repo: `${owner}/${repo}`, number, raw: text, kind: "github-url" };
317
+ }
318
+
319
+ function parsePrSchemeRef(text: string): ReviewPrRef | undefined {
320
+ const match = PR_SCHEME_PATTERN.exec(text);
321
+ if (!match) return undefined;
322
+
323
+ const [, owner, repo, numberPart] = match;
324
+ const number = parsePositivePrNumber(numberPart);
325
+ if (number === undefined) return undefined;
326
+
327
+ return { repo: `${owner}/${repo}`, number, raw: text, kind: "pr-url" };
328
+ }
329
+
330
+ function parseReviewPrRef(text: string): ReviewPrRef | undefined {
331
+ const candidate = stripTrailingPrRefPunctuation(text);
332
+ return parseGithubPrUrl(candidate) ?? parsePrSchemeRef(candidate);
333
+ }
334
+
335
+ function buildPrLargeDiffInstruction(ref: ReviewPrRef): string {
336
+ const prDiffUrl = `pr://${ref.repo}/${ref.number}/diff`;
337
+ return `MUST read assigned PR file diffs from \`${prDiffUrl}/all\` or per-file \`${prDiffUrl}/<index>\`; NEVER use local \`git diff\`/\`git show\` for PR diff content`;
338
+ }
339
+
340
+ function buildPrContextInstruction(ref: ReviewPrRef): string {
341
+ const prDiffUrl = `pr://${ref.repo}/${ref.number}/diff`;
342
+ return `MUST NOT read local workspace files for PR file context; use the fetched PR diff and \`${prDiffUrl}/all\` or per-file \`${prDiffUrl}/<index>\` only`;
343
+ }
344
+
345
+ function extractReviewPrRefFromArgs(args: string[]): ParsedReviewArgs {
346
+ let prRef: ReviewPrRef | undefined;
347
+ let prRefIndex = -1;
348
+ for (const [idx, arg] of args.entries()) {
349
+ const parsed = parseReviewPrRef(arg);
350
+ if (parsed) {
351
+ prRef = parsed;
352
+ prRefIndex = idx;
353
+ break;
354
+ }
355
+ }
356
+
357
+ return {
358
+ prRef,
359
+ extraInstructions: args.filter((_, idx) => idx !== prRefIndex).join(" "),
360
+ };
361
+ }
362
+
363
+ function extractReviewPrRefsFromText(text: string): ReviewPrRef[] {
364
+ return Array.from(text.matchAll(PR_REF_TEXT_PATTERN), match => parseReviewPrRef(match[0])).filter(
365
+ (ref): ref is ReviewPrRef => ref !== undefined,
366
+ );
367
+ }
368
+
369
+ function buildReviewPromptFromDiff(
370
+ ctx: HookCommandContext,
371
+ mode: string,
372
+ diffText: string,
373
+ extraInstructions: string | undefined,
374
+ emptyMessage: string,
375
+ options: { diffInstruction?: string; filteredMessage?: string; contextInstruction?: string } = {},
376
+ ): string | undefined {
377
+ if (!diffText.trim()) {
378
+ if (ctx.hasUI) ctx.ui.notify(emptyMessage, "warning");
379
+ return undefined;
380
+ }
381
+
382
+ const stats = parseDiff(diffText);
383
+ if (stats.files.length === 0) {
384
+ if (ctx.hasUI)
385
+ ctx.ui.notify(options.filteredMessage ?? "No reviewable files (all changes filtered out)", "warning");
386
+ return undefined;
387
+ }
388
+
389
+ return buildReviewPrompt(mode, stats, diffText, {
390
+ additionalInstructions: extraInstructions,
391
+ diffInstruction: options.diffInstruction,
392
+ contextInstruction: options.contextInstruction,
393
+ });
394
+ }
395
+
396
+ async function buildPrReviewPrompt(
397
+ api: CustomCommandAPI,
398
+ ctx: HookCommandContext,
399
+ ref: ReviewPrRef,
400
+ extraInstructions: string,
401
+ ): Promise<string | undefined> {
402
+ let diffText: string;
403
+ try {
404
+ const lookup = await gh.getOrFetchPrDiff({ cwd: api.cwd, repo: ref.repo, number: ref.number });
405
+ diffText = lookup.payload.unified;
406
+ } catch (err) {
407
+ const message = err instanceof Error ? err.message : String(err);
408
+ const failure = `Failed to fetch PR diff for ${ref.repo}#${ref.number}: ${message}`;
409
+ if (ctx.hasUI) {
410
+ ctx.ui.notify(failure, "error");
411
+ return undefined;
412
+ }
413
+ return failure;
414
+ }
415
+
416
+ const promptText = buildReviewPromptFromDiff(
417
+ ctx,
418
+ `PR ${ref.repo}#${ref.number}`,
419
+ diffText,
420
+ extraInstructions || undefined,
421
+ `PR ${ref.repo}#${ref.number} has no diff content available`,
422
+ { diffInstruction: buildPrLargeDiffInstruction(ref), contextInstruction: buildPrContextInstruction(ref) },
423
+ );
424
+ if (promptText !== undefined || ctx.hasUI) return promptText;
425
+ return `Unable to review PR ${ref.repo}#${ref.number}: no diff content available.`;
426
+ }
427
+
428
+ function isRecord(value: unknown): value is Record<string, unknown> {
429
+ return typeof value === "object" && value !== null;
430
+ }
431
+
432
+ function getTextContentParts(content: unknown): string[] {
433
+ if (typeof content === "string") return [content];
434
+ if (!Array.isArray(content)) return [];
435
+
436
+ const parts: string[] = [];
437
+ for (const item of content) {
438
+ if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
439
+ parts.push(item.text);
440
+ }
441
+ }
442
+ return parts;
443
+ }
444
+
445
+ function findRecentPrRefs(ctx: HookCommandContext, limit: number): ReviewPrRef[] {
446
+ const refs: ReviewPrRef[] = [];
447
+ const seen = new Set<string>();
448
+ const entries = ctx.sessionManager.getBranch();
449
+
450
+ for (let idx = entries.length - 1; idx >= 0 && refs.length < limit; idx--) {
451
+ const entry = entries[idx];
452
+ if (entry?.type !== "message") continue;
453
+ const message = entry.message;
454
+ if (message.role !== "user" && message.role !== "assistant") continue;
455
+
456
+ const parts = getTextContentParts(message.content);
457
+ for (let partIdx = parts.length - 1; partIdx >= 0; partIdx--) {
458
+ const part = parts[partIdx];
459
+ const partRefs = extractReviewPrRefsFromText(part);
460
+ for (let refIdx = partRefs.length - 1; refIdx >= 0; refIdx--) {
461
+ const ref = partRefs[refIdx];
462
+ const key = `${ref.repo.toLowerCase()}#${ref.number}`;
463
+ if (seen.has(key)) continue;
464
+ seen.add(key);
465
+ refs.push(ref);
466
+ if (refs.length >= limit) break;
467
+ }
468
+ if (refs.length >= limit) break;
469
+ }
470
+ }
471
+
472
+ return refs;
473
+ }
474
+
256
475
  export class ReviewCommand implements CustomCommand {
257
476
  name = "review";
258
477
  description = "Launch interactive code review";
@@ -260,36 +479,56 @@ export class ReviewCommand implements CustomCommand {
260
479
  constructor(private api: CustomCommandAPI) {}
261
480
 
262
481
  async execute(args: string[], ctx: HookCommandContext): Promise<string | undefined> {
263
- if (!ctx.hasUI) {
264
- return buildHeadlessReviewPrompt(args.length > 0 ? args.join(" ") : undefined);
482
+ const parsedArgs = extractReviewPrRefFromArgs(args);
483
+ if (parsedArgs.prRef) {
484
+ return buildPrReviewPrompt(this.api, ctx, parsedArgs.prRef, parsedArgs.extraInstructions);
265
485
  }
266
486
 
267
- // Inline args act as additional instructions appended to the generated prompt.
268
- // When present, skip option 4 (editor) — the args already provide the instructions.
269
- const extraInstructions = args.length > 0 ? args.join(" ") : undefined;
487
+ const extraInstructions = parsedArgs.extraInstructions || undefined;
488
+ if (!ctx.hasUI) {
489
+ return buildHeadlessReviewPrompt(extraInstructions);
490
+ }
270
491
 
271
- const menuItems = extraInstructions
272
- ? [
273
- "1. Review against a base branch (PR Style)",
274
- "2. Review uncommitted changes",
275
- "3. Review a specific commit",
276
- ]
277
- : [
278
- "1. Review against a base branch (PR Style)",
279
- "2. Review uncommitted changes",
280
- "3. Review a specific commit",
281
- "4. Custom review instructions",
282
- ];
492
+ const choices: Array<{ label: string; value: ReviewMenuChoice }> = [
493
+ ...findRecentPrRefs(ctx, REVIEW_CONTEXT_PR_LIMIT).map(ref => ({
494
+ label: `Review PR ${ref.repo}#${ref.number} from conversation`,
495
+ value: { kind: "detected-pr" as const, ref },
496
+ })),
497
+ {
498
+ label: "1. Review against a base branch (PR Style)",
499
+ value: { kind: "base-branch" },
500
+ },
501
+ {
502
+ label: "2. Review uncommitted changes",
503
+ value: { kind: "uncommitted" },
504
+ },
505
+ {
506
+ label: "3. Review a specific commit",
507
+ value: { kind: "commit" },
508
+ },
509
+ ];
510
+
511
+ if (!extraInstructions) {
512
+ choices.push({
513
+ label: "4. Custom review instructions",
514
+ value: { kind: "custom" },
515
+ });
516
+ }
283
517
 
284
- const mode = await ctx.ui.select("Review Mode", menuItems);
518
+ const selected = await ctx.ui.select(
519
+ "Review Mode",
520
+ choices.map(choice => choice.label),
521
+ );
522
+ if (!selected) return undefined;
285
523
 
286
- if (!mode) return undefined;
524
+ const selectedChoice = choices.find(choice => choice.label === selected)?.value;
525
+ if (!selectedChoice) return undefined;
287
526
 
288
- const modeNum = parseInt(mode[0], 10);
527
+ switch (selectedChoice.kind) {
528
+ case "detected-pr":
529
+ return buildPrReviewPrompt(this.api, ctx, selectedChoice.ref, extraInstructions ?? "");
289
530
 
290
- switch (modeNum) {
291
- case 1: {
292
- // PR-style review against base branch
531
+ case "base-branch": {
293
532
  const branches = await getGitBranches(this.api);
294
533
  if (branches.length === 0) {
295
534
  ctx.ui.notify("No git branches found", "error");
@@ -308,62 +547,43 @@ export class ReviewCommand implements CustomCommand {
308
547
  return undefined;
309
548
  }
310
549
 
311
- if (!diffText.trim()) {
312
- ctx.ui.notify(`No changes between ${baseBranch} and ${currentBranch}`, "warning");
313
- return undefined;
314
- }
315
-
316
- const stats = parseDiff(diffText);
317
- if (stats.files.length === 0) {
318
- ctx.ui.notify("No reviewable files (all changes filtered out)", "warning");
319
- return undefined;
320
- }
321
-
322
- return buildReviewPrompt(
550
+ return buildReviewPromptFromDiff(
551
+ ctx,
323
552
  `Reviewing changes between \`${baseBranch}\` and \`${currentBranch}\` (PR-style)`,
324
- stats,
325
553
  diffText,
326
- { additionalInstructions: extraInstructions },
554
+ extraInstructions,
555
+ `No changes between ${baseBranch} and ${currentBranch}`,
327
556
  );
328
557
  }
329
558
 
330
- case 2: {
559
+ case "uncommitted": {
331
560
  const reviewDiff = await getUncommittedReviewDiff(this.api).catch(err => {
332
561
  ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error");
333
562
  return undefined;
334
563
  });
335
564
  if (!reviewDiff) return undefined;
336
565
 
337
- if (!reviewDiff.diffText.trim()) {
338
- ctx.ui.notify(reviewDiff.emptyMessage ?? "No diff content found", "warning");
339
- return undefined;
340
- }
341
-
342
- const stats = parseDiff(reviewDiff.diffText);
343
- if (stats.files.length === 0) {
344
- ctx.ui.notify("No reviewable files (all changes filtered out)", "warning");
345
- return undefined;
346
- }
347
-
348
- return buildReviewPrompt(reviewDiff.mode, stats, reviewDiff.diffText, {
349
- additionalInstructions: extraInstructions,
350
- diffInstruction: reviewDiff.diffInstruction,
351
- });
566
+ return buildReviewPromptFromDiff(
567
+ ctx,
568
+ reviewDiff.mode,
569
+ reviewDiff.diffText,
570
+ extraInstructions,
571
+ reviewDiff.emptyMessage ?? "No diff content found",
572
+ { diffInstruction: reviewDiff.diffInstruction },
573
+ );
352
574
  }
353
575
 
354
- case 3: {
355
- // Specific commit
576
+ case "commit": {
356
577
  const commits = await getRecentCommits(this.api, 20);
357
578
  if (commits.length === 0) {
358
579
  ctx.ui.notify("No commits found", "error");
359
580
  return undefined;
360
581
  }
361
582
 
362
- const selected = await ctx.ui.select("Select commit to review", commits);
363
- if (!selected) return undefined;
583
+ const selectedCommit = await ctx.ui.select("Select commit to review", commits);
584
+ if (!selectedCommit) return undefined;
364
585
 
365
- // Extract commit hash from selection (format: "abc1234 message")
366
- const hash = selected.split(" ")[0];
586
+ const hash = selectedCommit.split(" ")[0];
367
587
 
368
588
  let diffText: string;
369
589
  try {
@@ -373,24 +593,17 @@ export class ReviewCommand implements CustomCommand {
373
593
  return undefined;
374
594
  }
375
595
 
376
- if (!diffText.trim()) {
377
- ctx.ui.notify("Commit has no diff content", "warning");
378
- return undefined;
379
- }
380
-
381
- const stats = parseDiff(diffText);
382
- if (stats.files.length === 0) {
383
- ctx.ui.notify("No reviewable files in commit (all changes filtered out)", "warning");
384
- return undefined;
385
- }
386
-
387
- return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, diffText, {
388
- additionalInstructions: extraInstructions,
389
- });
596
+ return buildReviewPromptFromDiff(
597
+ ctx,
598
+ `Reviewing commit \`${hash}\``,
599
+ diffText,
600
+ extraInstructions,
601
+ "Commit has no diff content",
602
+ { filteredMessage: "No reviewable files in commit (all changes filtered out)" },
603
+ );
390
604
  }
391
605
 
392
- case 4: {
393
- // Custom instructions with opportunistic current-diff context.
606
+ case "custom": {
394
607
  const instructions = await ctx.ui.editor(
395
608
  "Enter custom review instructions",
396
609
  "Review the following:\n\n",
@@ -403,7 +616,6 @@ export class ReviewCommand implements CustomCommand {
403
616
 
404
617
  if (reviewDiff?.diffText.trim()) {
405
618
  const stats = parseDiff(reviewDiff.diffText);
406
- // Even if all files filtered, include the custom instructions
407
619
  return buildReviewPrompt(
408
620
  `Custom review: ${instructions.split("\n")[0].slice(0, 60)}…`,
409
621
  stats,
@@ -417,9 +629,6 @@ export class ReviewCommand implements CustomCommand {
417
629
 
418
630
  return buildCustomReviewPrompt(instructions);
419
631
  }
420
-
421
- default:
422
- return undefined;
423
632
  }
424
633
  }
425
634
  }
@@ -543,7 +543,9 @@ export class ExtensionRunner {
543
543
  event.type === "session_before_tree"
544
544
  );
545
545
  }
546
-
546
+ #isSessionShutdownEvent(event: RunnerEmitEvent): event is Extract<RunnerEmitEvent, { type: "session_shutdown" }> {
547
+ return event.type === "session_shutdown";
548
+ }
547
549
  async #runHandlerWithTimeout<TEvent extends { type: string }, TResult>(
548
550
  handler: (event: TEvent, ctx: ExtensionContext) => Promise<TResult | undefined> | TResult | undefined,
549
551
  event: TEvent,
@@ -588,6 +590,20 @@ export class ExtensionRunner {
588
590
  const ctx = this.createContext();
589
591
  let result: SessionBeforeEventResult | SessionCompactingResult | undefined;
590
592
 
593
+ if (this.#isSessionShutdownEvent(event)) {
594
+ const timeoutMs = handlerTimeoutForEvent(event.type);
595
+ const promises: Promise<unknown>[] = [];
596
+ for (const ext of this.extensions) {
597
+ const handlers = ext.handlers.get(event.type);
598
+ if (!handlers || handlers.length === 0) continue;
599
+ for (const handler of handlers) {
600
+ promises.push(this.#runHandlerWithTimeout(handler, event, ctx, ext, timeoutMs));
601
+ }
602
+ }
603
+ await Promise.all(promises);
604
+ return result as RunnerEmitResult<TEvent>;
605
+ }
606
+
591
607
  for (const ext of this.extensions) {
592
608
  const handlers = ext.handlers.get(event.type);
593
609
  if (!handlers || handlers.length === 0) continue;