@oh-my-pi/pi-coding-agent 16.0.0 → 16.0.1

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 (57) hide show
  1. package/CHANGELOG.md +115 -133
  2. package/dist/cli.js +158 -130
  3. package/dist/types/config/settings-schema.d.ts +22 -0
  4. package/dist/types/discovery/helpers.d.ts +7 -0
  5. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  6. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  7. package/dist/types/modes/types.d.ts +5 -0
  8. package/dist/types/session/agent-session.d.ts +11 -1
  9. package/dist/types/session/session-manager.d.ts +4 -1
  10. package/dist/types/task/index.d.ts +21 -0
  11. package/dist/types/tools/github-cache.d.ts +5 -4
  12. package/dist/types/tools/job.d.ts +1 -0
  13. package/dist/types/web/search/index.d.ts +2 -2
  14. package/dist/types/web/search/provider.d.ts +2 -0
  15. package/package.json +12 -12
  16. package/src/cli/args.ts +1 -0
  17. package/src/collab/host.ts +1 -1
  18. package/src/config/settings-schema.ts +23 -1
  19. package/src/discovery/claude-plugins.ts +3 -42
  20. package/src/discovery/github.ts +101 -6
  21. package/src/discovery/helpers.ts +11 -0
  22. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  23. package/src/eval/js/shared/prelude.txt +12 -3
  24. package/src/eval/py/prelude.py +26 -2
  25. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  26. package/src/extensibility/plugins/loader.ts +3 -2
  27. package/src/extensibility/plugins/manager.ts +4 -3
  28. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  29. package/src/extensibility/plugins/runtime-config.ts +9 -0
  30. package/src/internal-urls/docs-index.generated.ts +5 -5
  31. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  32. package/src/main.ts +5 -1
  33. package/src/modes/acp/acp-agent.ts +3 -3
  34. package/src/modes/components/settings-defs.ts +7 -0
  35. package/src/modes/components/tips.txt +1 -1
  36. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  37. package/src/modes/controllers/input-controller.ts +1 -0
  38. package/src/modes/controllers/selector-controller.ts +7 -0
  39. package/src/modes/interactive-mode.ts +47 -0
  40. package/src/modes/rpc/rpc-mode.ts +3 -3
  41. package/src/modes/runtime-init.ts +2 -1
  42. package/src/modes/types.ts +5 -0
  43. package/src/prompts/agents/designer.md +8 -0
  44. package/src/prompts/review-request.md +1 -1
  45. package/src/prompts/system/subagent-system-prompt.md +4 -1
  46. package/src/prompts/tools/eval.md +13 -3
  47. package/src/prompts/tools/irc.md +1 -1
  48. package/src/sdk.ts +9 -1
  49. package/src/session/agent-session.ts +125 -18
  50. package/src/session/session-manager.ts +3 -1
  51. package/src/slash-commands/builtin-registry.ts +5 -2
  52. package/src/task/executor.ts +5 -4
  53. package/src/task/index.ts +70 -9
  54. package/src/tools/github-cache.ts +32 -7
  55. package/src/tools/job.ts +14 -1
  56. package/src/web/search/index.ts +2 -2
  57. package/src/web/search/provider.ts +14 -2
@@ -519,7 +519,7 @@ if "__omp_prelude_loaded__" not in globals():
519
519
  text = res.get("text") if isinstance(res, dict) else res
520
520
  return json.loads(text) if schema is not None else text
521
521
 
522
- def agent(prompt, *, agent_type="task", model=None, label=None, schema=None):
522
+ def agent(prompt, *, agent_type="task", model=None, label=None, schema=None, return_handle=False):
523
523
  """Run a subagent and return its final output.
524
524
 
525
525
  `agent_type` selects the subagent definition (default "task"). Pass
@@ -527,6 +527,15 @@ if "__omp_prelude_loaded__" not in globals():
527
527
  id, and `schema` to request structured JSON output; when `schema` is
528
528
  supplied the parsed object is returned. Share background by writing a
529
529
  local:// file and referencing it in the prompt.
530
+
531
+ Set `return_handle=True` to receive a DAG node dict instead of bare
532
+ text: ``{"text", "output", "handle", "id", "agent"}`` where ``handle``
533
+ is the spawned agent's recoverable ``agent://<id>`` URI. A downstream
534
+ ``pipeline``/``parallel`` stage embeds that ``handle`` (or ``output``)
535
+ in its prompt so a large transcript flows through the graph by
536
+ reference, never re-inlined. When ``schema`` is also set the parsed
537
+ object lands under ``"data"``. If the bridge returns no recoverable id
538
+ the node still resolves with ``handle=None`` — the helper never throws.
530
539
  """
531
540
  args = {"prompt": prompt}
532
541
  if agent_type is not None:
@@ -539,7 +548,22 @@ if "__omp_prelude_loaded__" not in globals():
539
548
  args["schema"] = schema
540
549
  res = _bridge_call("__agent__", args)
541
550
  text = res.get("text") if isinstance(res, dict) else res
542
- return json.loads(text) if schema is not None else text
551
+ parsed = json.loads(text) if schema is not None else text
552
+ if not return_handle:
553
+ return parsed
554
+ details = res.get("details") if isinstance(res, dict) else None
555
+ if not isinstance(details, dict) or details.get("id") is None:
556
+ return {"text": text, "output": text, "handle": None, "id": None, "agent": None}
557
+ node = {
558
+ "text": text,
559
+ "output": text,
560
+ "handle": f"agent://{details['id']}",
561
+ "id": details["id"],
562
+ "agent": details.get("agent"),
563
+ }
564
+ if schema is not None:
565
+ node["data"] = parsed
566
+ return node
543
567
 
544
568
  def _concurrency_limit():
545
569
  """Worker-pool ceiling from the host ``task.maxConcurrency`` setting.
@@ -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
  }
@@ -9,6 +9,7 @@ import * as path from "node:path";
9
9
  import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils";
10
10
  import { getConfigDirPaths } from "../../config";
11
11
  import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
12
+ import { normalizePluginRuntimeConfig } from "./runtime-config";
12
13
  import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types";
13
14
 
14
15
  installLegacyPiSpecifierShim();
@@ -28,9 +29,9 @@ installLegacyPiSpecifierShim();
28
29
  async function loadRuntimeConfig(home?: string): Promise<PluginRuntimeConfig> {
29
30
  const lockPath = getPluginsLockfile(home);
30
31
  try {
31
- return await Bun.file(lockPath).json();
32
+ return normalizePluginRuntimeConfig(await Bun.file(lockPath).json());
32
33
  } catch (err) {
33
- if (isEnoent(err)) return { plugins: {}, settings: {} };
34
+ if (isEnoent(err)) return normalizePluginRuntimeConfig({});
34
35
  throw err;
35
36
  }
36
37
  }
@@ -15,6 +15,7 @@ import { type GitSource, parseGitUrl } from "./git-url";
15
15
  import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "./legacy-pi-compat";
16
16
  import { resolvePluginManifestEntries } from "./loader";
17
17
  import { extractPackageName, parsePluginSpec } from "./parser";
18
+ import { normalizePluginRuntimeConfig } from "./runtime-config";
18
19
  import type {
19
20
  DoctorCheck,
20
21
  DoctorOptions,
@@ -124,11 +125,11 @@ export class PluginManager {
124
125
  async #loadRuntimeConfig(): Promise<PluginRuntimeConfig> {
125
126
  const lockPath = getPluginsLockfile();
126
127
  try {
127
- return await Bun.file(lockPath).json();
128
+ return normalizePluginRuntimeConfig(await Bun.file(lockPath).json());
128
129
  } catch (err) {
129
- if (isEnoent(err)) return { plugins: {}, settings: {} };
130
+ if (isEnoent(err)) return normalizePluginRuntimeConfig({});
130
131
  logger.warn("Failed to load plugin runtime config", { path: lockPath, error: String(err) });
131
- return { plugins: {}, settings: {} };
132
+ return normalizePluginRuntimeConfig({});
132
133
  }
133
134
  }
134
135
 
@@ -192,8 +192,33 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark
192
192
 
193
193
  // ── fetchMarketplace ──────────────────────────────────────────────────
194
194
 
195
- /** Relative path from a marketplace root to its catalog file. */
196
- const CATALOG_RELATIVE_PATH = path.join(".claude-plugin", "marketplace.json");
195
+ /**
196
+ * Catalog paths tried in priority order: omp-namespaced override first, then
197
+ * the Claude Code-compatible fallback so existing marketplaces keep loading.
198
+ */
199
+ const CATALOG_RELATIVE_PATHS: readonly string[] = [
200
+ path.join(".omp-plugin", "marketplace.json"),
201
+ path.join(".claude-plugin", "marketplace.json"),
202
+ ];
203
+
204
+ async function readMarketplaceCatalog(root: string): Promise<{ catalogPath: string; content: string }> {
205
+ const tried: string[] = [];
206
+ for (const rel of CATALOG_RELATIVE_PATHS) {
207
+ const catalogPath = path.join(root, rel);
208
+ tried.push(catalogPath);
209
+ try {
210
+ const content = await Bun.file(catalogPath).text();
211
+ return { catalogPath, content };
212
+ } catch (err) {
213
+ if (isEnoent(err)) continue;
214
+ throw err;
215
+ }
216
+ }
217
+ throw new Error(
218
+ `Marketplace catalog not found at ${tried.map(p => `"${p}"`).join(" or ")}. ` +
219
+ `Ensure the directory exists and contains one of: ${CATALOG_RELATIVE_PATHS.join(", ")}.`,
220
+ );
221
+ }
197
222
 
198
223
  /**
199
224
  * Expand a `~/...` path to an absolute path using os.homedir().
@@ -220,21 +245,7 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
220
245
 
221
246
  if (type === "local") {
222
247
  const resolved = path.resolve(expandHome(source));
223
- const catalogPath = path.join(resolved, CATALOG_RELATIVE_PATH);
224
-
225
- let content: string;
226
- try {
227
- content = await Bun.file(catalogPath).text();
228
- } catch (err) {
229
- if (isEnoent(err)) {
230
- throw new Error(
231
- `Marketplace catalog not found at "${catalogPath}". ` +
232
- `Ensure the directory exists and contains a .claude-plugin/marketplace.json file.`,
233
- );
234
- }
235
- throw err;
236
- }
237
-
248
+ const { catalogPath, content } = await readMarketplaceCatalog(resolved);
238
249
  const catalog = parseMarketplaceCatalog(content, catalogPath);
239
250
  return { catalog };
240
251
  }
@@ -280,27 +291,14 @@ async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<Fetch
280
291
  logger.debug(`[marketplace] cloning ${url} → ${tmpDir}`);
281
292
  await git.clone(url, tmpDir);
282
293
 
283
- const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH);
284
- let content: string;
285
294
  try {
286
- content = await Bun.file(catalogPath).text();
287
- } catch (err) {
288
- await fs.rm(tmpDir, { recursive: true, force: true });
289
- if (isEnoent(err)) {
290
- throw new Error(`Cloned repository has no marketplace catalog at ${CATALOG_RELATIVE_PATH}`);
291
- }
292
- throw err;
293
- }
294
-
295
- let catalog: MarketplaceCatalog;
296
- try {
297
- catalog = parseMarketplaceCatalog(content, catalogPath);
295
+ const { catalogPath, content } = await readMarketplaceCatalog(tmpDir);
296
+ const catalog = parseMarketplaceCatalog(content, catalogPath);
297
+ return { catalog, clonePath: tmpDir };
298
298
  } catch (err) {
299
299
  await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
300
- throw err;
300
+ throw new Error(`Cloned repository ${url}: ${(err as Error).message}`, { cause: err });
301
301
  }
302
-
303
- return { catalog, clonePath: tmpDir };
304
302
  }
305
303
 
306
304
  /**
@@ -0,0 +1,9 @@
1
+ import type { PluginRuntimeConfig } from "./types";
2
+
3
+ /** Normalizes persisted plugin runtime config across legacy lockfile shapes. */
4
+ export function normalizePluginRuntimeConfig(config: Partial<PluginRuntimeConfig>): PluginRuntimeConfig {
5
+ return {
6
+ plugins: config.plugins ?? {},
7
+ settings: config.settings ?? {},
8
+ };
9
+ }