@oh-my-pi/pi-coding-agent 3.30.0 → 3.31.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 (155) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/edit-diff.ts +11 -4
  16. package/src/core/tools/edit.ts +7 -13
  17. package/src/core/tools/find.ts +111 -50
  18. package/src/core/tools/gemini-image.ts +128 -147
  19. package/src/core/tools/grep.ts +397 -415
  20. package/src/core/tools/index.test.ts +5 -1
  21. package/src/core/tools/index.ts +6 -8
  22. package/src/core/tools/ls.ts +12 -10
  23. package/src/core/tools/lsp/client.ts +58 -9
  24. package/src/core/tools/lsp/config.ts +205 -656
  25. package/src/core/tools/lsp/defaults.json +465 -0
  26. package/src/core/tools/lsp/index.ts +55 -32
  27. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  28. package/src/core/tools/lsp/types.ts +1 -0
  29. package/src/core/tools/lsp/utils.ts +1 -1
  30. package/src/core/tools/read.ts +150 -74
  31. package/src/core/tools/render-utils.ts +70 -10
  32. package/src/core/tools/review.ts +38 -126
  33. package/src/core/tools/task/artifacts.ts +5 -4
  34. package/src/core/tools/task/executor.ts +94 -83
  35. package/src/core/tools/task/index.ts +129 -92
  36. package/src/core/tools/task/parallel.ts +30 -3
  37. package/src/core/tools/task/render.ts +85 -39
  38. package/src/core/tools/task/types.ts +15 -6
  39. package/src/core/tools/task/worker.ts +124 -89
  40. package/src/core/tools/web-fetch.ts +112 -377
  41. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  42. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  43. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  49. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  50. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  51. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  52. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  53. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  54. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  57. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  59. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  60. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  61. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  62. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  63. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  64. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  71. package/src/core/tools/web-scrapers/index.ts +250 -0
  72. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  73. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  74. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  75. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  76. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  79. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  82. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  83. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  84. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  86. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  87. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  90. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  93. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  96. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  99. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  102. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  103. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  104. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  105. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  106. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  107. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  111. package/src/core/tools/web-scrapers/utils.ts +162 -0
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  113. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  114. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  116. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  117. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  118. package/src/core/tools/write.ts +21 -18
  119. package/src/core/voice.ts +3 -2
  120. package/src/lib/worktree/collapse.ts +2 -1
  121. package/src/lib/worktree/git.ts +2 -18
  122. package/src/main.ts +59 -3
  123. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  124. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  125. package/src/modes/interactive/components/hook-editor.ts +2 -1
  126. package/src/modes/interactive/components/model-selector.ts +19 -4
  127. package/src/modes/interactive/interactive-mode.ts +41 -38
  128. package/src/modes/interactive/theme/theme.ts +58 -58
  129. package/src/modes/rpc/rpc-mode.ts +10 -9
  130. package/src/prompts/review-request.md +27 -0
  131. package/src/prompts/reviewer.md +64 -68
  132. package/src/prompts/tools/output.md +22 -3
  133. package/src/prompts/tools/task.md +32 -33
  134. package/src/utils/clipboard.ts +2 -1
  135. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  136. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  137. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  138. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  139. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  140. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,77 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.31.0] - 2026-01-08
6
+
7
+ ### Added
8
+
9
+ - Added temporary model selection: `Ctrl+Y` opens model selector for session-only model switching (not persisted to settings)
10
+ - Added `setModelTemporary()` method to AgentSession for ephemeral model changes
11
+ - Added empty Enter to flush queued messages: pressing Enter with empty editor while streaming aborts current stream
12
+ - Added auto-chdir to temp directories when starting in home unless `--allow-home` is set
13
+ - Added upfront diff parsing and filtering for code review command to exclude lock files, generated code, and binary assets
14
+
15
+ ### Fixed
16
+
17
+ - Fixed auto-chdir to only use existing directories and fall back to `tmpdir()`
18
+ - Added automatic reviewer agent count recommendation based on diff weight and file count
19
+ - Added file grouping guidance for parallel review distribution across multiple agents
20
+ - Added diff preview mode for large changesets that exceed size thresholds
21
+ - Added in-memory session storage implementation for testing and ephemeral sessions
22
+ - Added `createToolUIKit` helper to consolidate common UI formatting utilities across tool renderers
23
+ - Added configurable bash interceptor rules via `bashInterceptor.patterns` setting for custom command blocking
24
+ - Added `bashInterceptor.simpleLs` setting to control interception of bare ls commands
25
+ - Added LSP server configuration via external JSON defaults file for easier customization
26
+ - Added abort signal propagation to web scrapers for improved cancellation handling
27
+ - Added `diagnosticsVersion` tracking to LSP client for more reliable diagnostic polling
28
+ - Added 80+ specialized web scrapers for structured content extraction from popular sites including GitHub, GitLab, npm, PyPI, crates.io, Wikipedia, YouTube, Stack Overflow, Hacker News, Reddit, arXiv, PubMed, and many more
29
+ - Added site-specific API integrations for package registries (npm, PyPI, crates.io, Hex, Hackage, NuGet, Maven, RubyGems, Packagist, pub.dev, Go packages)
30
+ - Added scrapers for social platforms (Mastodon, Bluesky, Lemmy, Lobsters, Dev.to, Discourse)
31
+ - Added scrapers for academic sources (arXiv, bioRxiv, PubMed, Semantic Scholar, ORCID, CrossRef, IACR)
32
+ - Added scrapers for security databases (NVD, OSV, CISA KEV)
33
+ - Added scrapers for documentation sites (MDN, Read the Docs, RFC Editor, W3C, SPDX, tldr, cheat.sh)
34
+ - Added scrapers for media platforms (YouTube, Vimeo, Spotify, Discogs, MusicBrainz)
35
+ - Added scrapers for AI/ML platforms (Hugging Face, Ollama)
36
+ - Added scrapers for app stores and marketplaces (VS Code Marketplace, JetBrains Marketplace, Firefox Add-ons, Open VSX, Flathub, F-Droid, Snapcraft)
37
+ - Added scrapers for business data (SEC EDGAR, OpenCorporates, CoinGecko)
38
+ - Added scrapers for reference sources (Wikipedia, Wikidata, OpenLibrary, Choose a License)
39
+
40
+ ### Changed
41
+
42
+ - Changed `Ctrl+P` to cycle through role models (slow → default → smol) instead of all available models
43
+ - Changed `Shift+Ctrl+P` to cycle role models temporarily (not persisted)
44
+ - Changed Extension Control Center to scale with terminal height instead of fixed 25-line limit
45
+ - Changed review command to parse git diff upfront and provide structured context to reviewer agents
46
+ - Changed session persistence to use structured logging instead of console.error for persistence failures
47
+ - Changed find tool to use fd command for .gitignore discovery instead of Bun.Glob for better abort handling
48
+ - Changed LSP config loading to only mark overrides when servers are actually defined
49
+ - Changed task tool to require explicit task `id` field instead of auto-generating names from agent type
50
+ - Changed grep and find tools to use native Bun file APIs instead of Node.js fs module for improved performance
51
+ - Changed YouTube scraper to use async command execution with proper stream handling
52
+ - Improved rust-analyzer diagnostic polling to use version-based stability detection instead of time-based delays
53
+ - Changed theme icons for extension types to use Unicode symbols (✧, ⚒) instead of text abbreviations (SK, TL, MCP)
54
+ - Changed task tool to use short CamelCase task IDs instead of agent-based naming (e.g., 'SessionStore' instead of 'explore_0')
55
+ - Changed task tool to accept single `agent` parameter at top level instead of per-task agent specification
56
+ - Changed reviewer agent to use `complete` tool instead of `submit_review` for finishing reviews
57
+ - Changed theme icons for extensions to use Unicode symbols instead of text abbreviations
58
+ - Changed LSP file type matching to support exact filename matches in addition to extensions
59
+ - Improved rust-analyzer diagnostic polling to use version-based stability detection
60
+ - Refactored web-fetch tool to use modular scraper architecture for improved maintainability
61
+
62
+ ### Removed
63
+
64
+ - Removed `submit_review` tool - reviewers now finish via `complete` tool with structured output
65
+
66
+ ### Fixed
67
+
68
+ - Fixed session persistence to call fsync before renaming temp file for durability
69
+ - Fixed duplicate persistence error logging by tracking whether error was already reported
70
+ - Fixed byte counting in task output truncation to correctly handle multi-byte Unicode characters
71
+ - Fixed parallel task execution to propagate abort signals and fail fast on first error
72
+ - Fixed task worker abort handling to properly clean up on cancellation
73
+ - Fixed parallel task execution to fail fast on first error instead of waiting for all workers
74
+ - Fixed byte counting in task output truncation to handle multi-byte Unicode characters correctly
75
+
5
76
  ## [3.30.0] - 2026-01-07
6
77
  ### Added
7
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.30.0",
3
+ "version": "3.31.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@mariozechner/pi-ai": "^0.37.4",
43
- "@oh-my-pi/pi-agent-core": "3.30.0",
44
- "@oh-my-pi/pi-git-tool": "3.30.0",
45
- "@oh-my-pi/pi-tui": "3.30.0",
42
+ "@mariozechner/pi-ai": "^0.37.8",
43
+ "@oh-my-pi/pi-agent-core": "3.31.0",
44
+ "@oh-my-pi/pi-git-tool": "3.31.0",
45
+ "@oh-my-pi/pi-tui": "3.31.0",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
package/src/cli/args.ts CHANGED
@@ -11,6 +11,7 @@ export type Mode = "text" | "json" | "rpc";
11
11
 
12
12
  export interface Args {
13
13
  cwd?: string;
14
+ allowHome?: boolean;
14
15
  provider?: string;
15
16
  model?: string;
16
17
  smol?: string;
@@ -62,6 +63,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
62
63
  result.help = true;
63
64
  } else if (arg === "--version" || arg === "-v") {
64
65
  result.version = true;
66
+ } else if (arg === "--allow-home") {
67
+ result.allowHome = true;
65
68
  } else if (arg === "--mode" && i + 1 < args.length) {
66
69
  const mode = args[++i];
67
70
  if (mode === "text" || mode === "json" || mode === "rpc") {
@@ -176,6 +179,7 @@ ${chalk.bold("Options:")}
176
179
  --api-key <key> API key (defaults to env vars)
177
180
  --system-prompt <text> System prompt (default: coding assistant prompt)
178
181
  --append-system-prompt <text> Append text or file contents to the system prompt
182
+ --allow-home Allow starting in ~ without auto-switching to a temp dir
179
183
  --mode <mode> Output mode: text (default), json, or rpc
180
184
  --print, -p Non-interactive mode: process prompt and exit
181
185
  --continue, -c Continue previous session
@@ -1128,6 +1128,24 @@ export class AgentSession {
1128
1128
  this.setThinkingLevel(this.thinkingLevel);
1129
1129
  }
1130
1130
 
1131
+ /**
1132
+ * Set model temporarily (for this session only).
1133
+ * Validates API key, saves to session log but NOT to settings.
1134
+ * @throws Error if no API key available for the model
1135
+ */
1136
+ async setModelTemporary(model: Model<any>): Promise<void> {
1137
+ const apiKey = await this._modelRegistry.getApiKey(model);
1138
+ if (!apiKey) {
1139
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
1140
+ }
1141
+
1142
+ this.agent.setModel(model);
1143
+ this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
1144
+
1145
+ // Re-clamp thinking level for new model's capabilities
1146
+ this.setThinkingLevel(this.thinkingLevel);
1147
+ }
1148
+
1131
1149
  /**
1132
1150
  * Cycle to next/previous model.
1133
1151
  * Uses scoped models (from --models flag) if available, otherwise all available models.
@@ -1144,8 +1162,13 @@ export class AgentSession {
1144
1162
  /**
1145
1163
  * Cycle through configured role models in a fixed order.
1146
1164
  * Skips missing roles and deduplicates models.
1165
+ * @param roleOrder - Order of roles to cycle through (e.g., ["slow", "default", "smol"])
1166
+ * @param options - Optional settings: `temporary` to not persist to settings
1147
1167
  */
1148
- async cycleRoleModels(roleOrder: string[]): Promise<RoleModelCycleResult | undefined> {
1168
+ async cycleRoleModels(
1169
+ roleOrder: string[],
1170
+ options?: { temporary?: boolean },
1171
+ ): Promise<RoleModelCycleResult | undefined> {
1149
1172
  const availableModels = this._modelRegistry.getAvailable();
1150
1173
  if (availableModels.length === 0) return undefined;
1151
1174
 
@@ -1185,7 +1208,11 @@ export class AgentSession {
1185
1208
  const nextIndex = (currentIndex + 1) % roleModels.length;
1186
1209
  const next = roleModels[nextIndex];
1187
1210
 
1188
- await this.setModel(next.model, next.role);
1211
+ if (options?.temporary) {
1212
+ await this.setModelTemporary(next.model);
1213
+ } else {
1214
+ await this.setModel(next.model, next.role);
1215
+ }
1189
1216
 
1190
1217
  return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
1191
1218
  }
@@ -10,6 +10,7 @@ import { createWriteStream, type WriteStream } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import type { Subprocess } from "bun";
13
+ import { nanoid } from "nanoid";
13
14
  import stripAnsi from "strip-ansi";
14
15
  import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell";
15
16
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
@@ -77,7 +78,7 @@ function createOutputSink(
77
78
 
78
79
  // Spill to temp file if needed
79
80
  if (totalBytes > spillThreshold && !fullOutputPath) {
80
- fullOutputPath = join(tmpdir(), `omp-${crypto.randomUUID()}.buffer`);
81
+ fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
81
82
  const ts = createWriteStream(fullOutputPath);
82
83
  chunks.forEach((c) => {
83
84
  ts.write(c);
@@ -6,11 +6,296 @@
6
6
  * 2. Review uncommitted changes
7
7
  * 3. Review a specific commit
8
8
  * 4. Custom review instructions
9
+ *
10
+ * Runs git diff upfront, parses results, filters noise, and provides
11
+ * rich context for the orchestrating agent to distribute work across
12
+ * multiple reviewer agents based on diff weight and locality.
9
13
  */
10
14
 
15
+ import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
11
16
  import type { HookCommandContext } from "../../../hooks/types";
12
17
  import type { CustomCommand, CustomCommandAPI } from "../../types";
13
18
 
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Types
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ interface FileDiff {
24
+ path: string;
25
+ linesAdded: number;
26
+ linesRemoved: number;
27
+ hunks: string;
28
+ }
29
+
30
+ interface DiffStats {
31
+ files: FileDiff[];
32
+ totalAdded: number;
33
+ totalRemoved: number;
34
+ excluded: { path: string; reason: string; linesAdded: number; linesRemoved: number }[];
35
+ }
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Exclusion patterns for noise files
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+
41
+ const EXCLUDED_PATTERNS: { pattern: RegExp; reason: string }[] = [
42
+ // Lock files
43
+ { pattern: /\.lock$/, reason: "lock file" },
44
+ { pattern: /-lock\.(json|yaml|yml)$/, reason: "lock file" },
45
+ { pattern: /package-lock\.json$/, reason: "lock file" },
46
+ { pattern: /yarn\.lock$/, reason: "lock file" },
47
+ { pattern: /pnpm-lock\.yaml$/, reason: "lock file" },
48
+ { pattern: /Cargo\.lock$/, reason: "lock file" },
49
+ { pattern: /Gemfile\.lock$/, reason: "lock file" },
50
+ { pattern: /poetry\.lock$/, reason: "lock file" },
51
+ { pattern: /composer\.lock$/, reason: "lock file" },
52
+ { pattern: /flake\.lock$/, reason: "lock file" },
53
+
54
+ // Generated/build artifacts
55
+ { pattern: /\.min\.(js|css)$/, reason: "minified" },
56
+ { pattern: /\.generated\./, reason: "generated" },
57
+ { pattern: /\.snap$/, reason: "snapshot" },
58
+ { pattern: /\.map$/, reason: "source map" },
59
+ { pattern: /^dist\//, reason: "build output" },
60
+ { pattern: /^build\//, reason: "build output" },
61
+ { pattern: /^out\//, reason: "build output" },
62
+ { pattern: /node_modules\//, reason: "vendor" },
63
+ { pattern: /vendor\//, reason: "vendor" },
64
+
65
+ // Binary/assets (usually shown as binary in diff anyway)
66
+ { pattern: /\.(png|jpg|jpeg|gif|ico|webp|avif)$/i, reason: "image" },
67
+ { pattern: /\.(woff|woff2|ttf|eot|otf)$/i, reason: "font" },
68
+ { pattern: /\.(pdf|zip|tar|gz|rar|7z)$/i, reason: "binary" },
69
+ ];
70
+
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Diff parsing
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Check if a file path should be excluded from review.
77
+ * Returns the exclusion reason if excluded, undefined otherwise.
78
+ */
79
+ function getExclusionReason(path: string): string | undefined {
80
+ for (const { pattern, reason } of EXCLUDED_PATTERNS) {
81
+ if (pattern.test(path)) return reason;
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ /**
87
+ * Parse unified diff output into per-file stats.
88
+ * Splits on file boundaries, counts +/- lines, and filters excluded files.
89
+ */
90
+ function parseDiff(diffOutput: string): DiffStats {
91
+ const files: FileDiff[] = [];
92
+ const excluded: DiffStats["excluded"] = [];
93
+ let totalAdded = 0;
94
+ let totalRemoved = 0;
95
+
96
+ // Split by file boundary: "diff --git a/... b/..."
97
+ const fileChunks = diffOutput.split(/^diff --git /m).filter(Boolean);
98
+
99
+ for (const chunk of fileChunks) {
100
+ // Extract file path from "a/path b/path" line
101
+ const headerMatch = chunk.match(/^a\/(.+?) b\/(.+)/);
102
+ if (!headerMatch) continue;
103
+
104
+ const path = headerMatch[2];
105
+
106
+ // Count added/removed lines (lines starting with + or - but not ++ or --)
107
+ let linesAdded = 0;
108
+ let linesRemoved = 0;
109
+
110
+ const lines = chunk.split("\n");
111
+ for (const line of lines) {
112
+ if (line.startsWith("+") && !line.startsWith("+++")) {
113
+ linesAdded++;
114
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
115
+ linesRemoved++;
116
+ }
117
+ }
118
+
119
+ const exclusionReason = getExclusionReason(path);
120
+ if (exclusionReason) {
121
+ excluded.push({ path, reason: exclusionReason, linesAdded, linesRemoved });
122
+ } else {
123
+ files.push({
124
+ path,
125
+ linesAdded,
126
+ linesRemoved,
127
+ hunks: `diff --git ${chunk}`,
128
+ });
129
+ totalAdded += linesAdded;
130
+ totalRemoved += linesRemoved;
131
+ }
132
+ }
133
+
134
+ return { files, totalAdded, totalRemoved, excluded };
135
+ }
136
+
137
+ /**
138
+ * Get file extension for display purposes.
139
+ */
140
+ function getFileExt(path: string): string {
141
+ const match = path.match(/\.([^.]+)$/);
142
+ return match ? match[1] : "";
143
+ }
144
+
145
+ /**
146
+ * Determine recommended number of reviewer agents based on diff weight.
147
+ * Uses total lines changed as the primary metric.
148
+ */
149
+ function getRecommendedAgentCount(stats: DiffStats): number {
150
+ const totalLines = stats.totalAdded + stats.totalRemoved;
151
+ const fileCount = stats.files.length;
152
+
153
+ // Heuristics:
154
+ // - Tiny (<100 lines or 1-2 files): 1 agent
155
+ // - Small (<500 lines): 1-2 agents
156
+ // - Medium (<2000 lines): 2-4 agents
157
+ // - Large (<5000 lines): 4-8 agents
158
+ // - Huge (>5000 lines): 8-16 agents
159
+
160
+ if (totalLines < 100 || fileCount <= 2) return 1;
161
+ if (totalLines < 500) return Math.min(2, fileCount);
162
+ if (totalLines < 2000) return Math.min(4, Math.ceil(fileCount / 3));
163
+ if (totalLines < 5000) return Math.min(8, Math.ceil(fileCount / 2));
164
+ return Math.min(16, fileCount);
165
+ }
166
+
167
+ /**
168
+ * Format diff stats as a markdown table for the prompt.
169
+ */
170
+ function formatFileTable(files: FileDiff[]): string {
171
+ if (files.length === 0) return "_No files to review._";
172
+
173
+ const rows = files.map((f) => {
174
+ const ext = getFileExt(f.path);
175
+ return `| ${f.path} | +${f.linesAdded}/-${f.linesRemoved} | ${ext} |`;
176
+ });
177
+
178
+ return `| File | +/- | Type |\n|------|-----|------|\n${rows.join("\n")}`;
179
+ }
180
+
181
+ /**
182
+ * Extract first N lines of actual diff content (excluding headers) for preview.
183
+ */
184
+ function getDiffPreview(hunks: string, maxLines: number): string {
185
+ const lines = hunks.split("\n");
186
+ const contentLines: string[] = [];
187
+
188
+ for (const line of lines) {
189
+ // Skip diff headers, keep actual content
190
+ if (
191
+ line.startsWith("diff --git") ||
192
+ line.startsWith("index ") ||
193
+ line.startsWith("---") ||
194
+ line.startsWith("+++") ||
195
+ line.startsWith("@@")
196
+ ) {
197
+ continue;
198
+ }
199
+ contentLines.push(line);
200
+ if (contentLines.length >= maxLines) break;
201
+ }
202
+
203
+ return contentLines.join("\n");
204
+ }
205
+
206
+ /**
207
+ * Format condensed diff previews for large changesets.
208
+ */
209
+ function formatDiffPreviews(files: FileDiff[], linesPerFile: number): string {
210
+ const parts: string[] = [];
211
+
212
+ for (const f of files) {
213
+ const preview = getDiffPreview(f.hunks, linesPerFile);
214
+ if (preview.trim()) {
215
+ parts.push(`#### ${f.path}\n\`\`\`diff\n${preview}\n\`\`\``);
216
+ }
217
+ }
218
+
219
+ return parts.join("\n\n");
220
+ }
221
+
222
+ /**
223
+ * Format excluded files list for the prompt.
224
+ */
225
+ function formatExcluded(excluded: DiffStats["excluded"]): string {
226
+ if (excluded.length === 0) return "";
227
+
228
+ const items = excluded.map((e) => `- \`${e.path}\` (+${e.linesAdded}/-${e.linesRemoved}) — ${e.reason}`);
229
+
230
+ return `### Excluded Files (${excluded.length})\n\n${items.join("\n")}`;
231
+ }
232
+
233
+ // Thresholds for diff inclusion
234
+ const MAX_DIFF_CHARS = 50_000; // Don't include diff above this
235
+ const MAX_FILES_FOR_INLINE_DIFF = 20; // Don't include diff if more files than this
236
+
237
+ /**
238
+ * Build the full review prompt with diff stats and distribution guidance.
239
+ */
240
+ function buildReviewPrompt(mode: string, stats: DiffStats, rawDiff: string): string {
241
+ const agentCount = getRecommendedAgentCount(stats);
242
+ const skipDiff = rawDiff.length > MAX_DIFF_CHARS || stats.files.length > MAX_FILES_FOR_INLINE_DIFF;
243
+ const totalLines = stats.totalAdded + stats.totalRemoved;
244
+
245
+ // Build distribution guidance
246
+ const distributionGuidance =
247
+ `Based on the diff weight (~${totalLines} lines across ${stats.files.length} files), ` +
248
+ (agentCount === 1 ? `use **1 reviewer agent**.` : `spawn **${agentCount} reviewer agents** in parallel.`);
249
+
250
+ // Build grouping guidance (only for multi-agent)
251
+ const groupingGuidance =
252
+ agentCount > 1
253
+ ? `Group files by locality (related changes together). For example:
254
+ - Files in the same directory or module → same agent
255
+ - Files that implement related functionality → same agent
256
+ - Test files with their implementation files → same agent
257
+
258
+ Use the Task tool with \`agent: "reviewer"\` and the batch \`tasks\` array to run reviews in parallel.`
259
+ : "";
260
+
261
+ // Build diff section
262
+ let diffSection: string;
263
+ if (!skipDiff) {
264
+ diffSection = `### Diff
265
+
266
+ <diff>
267
+ ${rawDiff.trim()}
268
+ </diff>`;
269
+ } else {
270
+ const linesPerFile = Math.max(5, Math.floor(100 / stats.files.length));
271
+ diffSection = `### Diff Previews
272
+
273
+ _Full diff too large (${stats.files.length} files). Showing first ~${linesPerFile} lines per file. Reviewers should fetch full diffs for assigned files._
274
+
275
+ ${formatDiffPreviews(stats.files, linesPerFile)}`;
276
+ }
277
+
278
+ // Build diff instruction
279
+ const diffInstruction = skipDiff
280
+ ? "Run `git diff` or `git show` to get the diff for assigned files"
281
+ : "Use the diff hunks provided below (don't re-run git diff)";
282
+
283
+ // Replace template variables
284
+ return reviewRequestTemplate
285
+ .replace("{MODE}", mode)
286
+ .replace("{FILE_COUNT}", String(stats.files.length))
287
+ .replace("{LINES_ADDED}", String(stats.totalAdded))
288
+ .replace("{LINES_REMOVED}", String(stats.totalRemoved))
289
+ .replace("{FILE_TABLE}", formatFileTable(stats.files))
290
+ .replace("{EXCLUDED_SECTION}", stats.excluded.length > 0 ? formatExcluded(stats.excluded) : "")
291
+ .replace("{DISTRIBUTION_GUIDANCE}", distributionGuidance)
292
+ .replace("{GROUPING_GUIDANCE}", groupingGuidance)
293
+ .replace("{DIFF_INSTRUCTION}", diffInstruction)
294
+ .replace("{DIFF_SECTION}", diffSection)
295
+ .replace(/\n{3,}/g, "\n\n") // Collapse multiple blank lines
296
+ .trim();
297
+ }
298
+
14
299
  export function createReviewCommand(api: CustomCommandAPI): CustomCommand {
15
300
  return {
16
301
  name: "review",
@@ -21,7 +306,6 @@ export function createReviewCommand(api: CustomCommandAPI): CustomCommand {
21
306
  return "Use the Task tool to run the 'reviewer' agent to review recent code changes.";
22
307
  }
23
308
 
24
- // Main menu
25
309
  const mode = await ctx.ui.select("Review Mode", [
26
310
  "1. Review against a base branch (PR Style)",
27
311
  "2. Review uncommitted changes",
@@ -46,26 +330,59 @@ export function createReviewCommand(api: CustomCommandAPI): CustomCommand {
46
330
  if (!baseBranch) return undefined;
47
331
 
48
332
  const currentBranch = await getCurrentBranch(api);
49
- return `Use the Task tool to run the "reviewer" agent with this task:
333
+ const diffResult = await api.exec("git", ["diff", `${baseBranch}...${currentBranch}`], {
334
+ timeout: 30000,
335
+ });
336
+ if (diffResult.code !== 0) {
337
+ ctx.ui.notify(`Failed to get diff: ${diffResult.stderr}`, "error");
338
+ return undefined;
339
+ }
50
340
 
51
- Review the changes between "${baseBranch}" and "${currentBranch}".
341
+ if (!diffResult.stdout.trim()) {
342
+ ctx.ui.notify(`No changes between ${baseBranch} and ${currentBranch}`, "warning");
343
+ return undefined;
344
+ }
52
345
 
53
- Run \`git diff ${baseBranch}...${currentBranch}\` to see the changes, then analyze the modified files.`;
346
+ const stats = parseDiff(diffResult.stdout);
347
+ if (stats.files.length === 0) {
348
+ ctx.ui.notify("No reviewable files (all changes filtered out)", "warning");
349
+ return undefined;
350
+ }
351
+
352
+ return buildReviewPrompt(
353
+ `Reviewing changes between \`${baseBranch}\` and \`${currentBranch}\` (PR-style)`,
354
+ stats,
355
+ diffResult.stdout,
356
+ );
54
357
  }
55
358
 
56
359
  case 2: {
57
- // Uncommitted changes
360
+ // Uncommitted changes - combine staged and unstaged
58
361
  const status = await getGitStatus(api);
59
362
  if (!status.trim()) {
60
363
  ctx.ui.notify("No uncommitted changes found", "warning");
61
364
  return undefined;
62
365
  }
63
366
 
64
- return `Use the Task tool to run the "reviewer" agent with this task:
367
+ const [unstagedResult, stagedResult] = await Promise.all([
368
+ api.exec("git", ["diff"], { timeout: 30000 }),
369
+ api.exec("git", ["diff", "--cached"], { timeout: 30000 }),
370
+ ]);
65
371
 
66
- Review all uncommitted changes in the working directory.
372
+ const combinedDiff = [unstagedResult.stdout, stagedResult.stdout].filter(Boolean).join("\n");
373
+
374
+ if (!combinedDiff.trim()) {
375
+ ctx.ui.notify("No diff content found", "warning");
376
+ return undefined;
377
+ }
378
+
379
+ const stats = parseDiff(combinedDiff);
380
+ if (stats.files.length === 0) {
381
+ ctx.ui.notify("No reviewable files (all changes filtered out)", "warning");
382
+ return undefined;
383
+ }
67
384
 
68
- Run \`git diff\` for unstaged changes and \`git diff --cached\` for staged changes.`;
385
+ return buildReviewPrompt("Reviewing uncommitted changes (staged + unstaged)", stats, combinedDiff);
69
386
  }
70
387
 
71
388
  case 3: {
@@ -82,24 +399,62 @@ Run \`git diff\` for unstaged changes and \`git diff --cached\` for staged chang
82
399
  // Extract commit hash from selection (format: "abc1234 message")
83
400
  const hash = selected.split(" ")[0];
84
401
 
85
- return `Use the Task tool to run the "reviewer" agent with this task:
402
+ // Get the commit diff (with timeout)
403
+ const showResult = await api.exec("git", ["show", "--format=", hash], { timeout: 30000 });
404
+ if (showResult.code !== 0) {
405
+ ctx.ui.notify(`Failed to get commit: ${showResult.stderr}`, "error");
406
+ return undefined;
407
+ }
86
408
 
87
- Review commit ${hash}.
409
+ if (!showResult.stdout.trim()) {
410
+ ctx.ui.notify("Commit has no diff content", "warning");
411
+ return undefined;
412
+ }
413
+
414
+ const stats = parseDiff(showResult.stdout);
415
+ if (stats.files.length === 0) {
416
+ ctx.ui.notify("No reviewable files in commit (all changes filtered out)", "warning");
417
+ return undefined;
418
+ }
88
419
 
89
- Run \`git show ${hash}\` to see the changes introduced by this commit.`;
420
+ return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, showResult.stdout);
90
421
  }
91
422
 
92
423
  case 4: {
93
- // Custom instructions
424
+ // Custom instructions - still uses the old approach since user provides context
94
425
  const instructions = await ctx.ui.editor(
95
426
  "Enter custom review instructions",
96
427
  "Review the following:\n\n",
97
428
  );
98
429
  if (!instructions?.trim()) return undefined;
99
430
 
100
- return `Use the Task tool to run the "reviewer" agent with this task:
431
+ // For custom, we still try to get current diff for context
432
+ const diffResult = await api.exec("git", ["diff", "HEAD"], { timeout: 30000 });
433
+ const hasDiff = diffResult.code === 0 && diffResult.stdout.trim();
434
+
435
+ if (hasDiff) {
436
+ const stats = parseDiff(diffResult.stdout);
437
+ // Even if all files filtered, include the custom instructions
438
+ return (
439
+ buildReviewPrompt(
440
+ `Custom review: ${instructions.split("\n")[0].slice(0, 60)}...`,
441
+ stats,
442
+ diffResult.stdout,
443
+ ) + `\n\n### Additional Instructions\n\n${instructions}`
444
+ );
445
+ }
446
+
447
+ // No diff available, just pass instructions
448
+ return `## Code Review Request
449
+
450
+ ### Mode
451
+ Custom review instructions
452
+
453
+ ### Instructions
454
+
455
+ ${instructions}
101
456
 
102
- ${instructions}`;
457
+ Use the Task tool with \`agent: "reviewer"\` to execute this review.`;
103
458
  }
104
459
 
105
460
  default:
@@ -300,7 +300,7 @@ async function handleSpawn(args: SpawnArgs, ctx: HookCommandContext): Promise<st
300
300
  async function handleParallel(args: ParallelTask[], ctx: HookCommandContext): Promise<string> {
301
301
  validateDisjointScopes(args.map((t) => t.scope));
302
302
 
303
- const sessionId = `parallel-${Date.now()}`;
303
+ const sessionId = `parallel-${nanoid()}`;
304
304
  const agent = await pickAgent(ctx.cwd);
305
305
 
306
306
  const worktrees: Array<{ task: ParallelTask; wt: worktree.Worktree; session: worktree.WorktreeSession }> = [];