bermudis-pi-goodies 0.28.0 → 0.29.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.
package/README.md CHANGED
@@ -14,7 +14,7 @@ extensions. One entry point, thirteen independent features.
14
14
  | `clean-tui` | tool overrides (no command) | Collapse built-in tool output for a cleaner TUI: back-to-back same-tool calls share one block (e.g. `read ×2`) until a boundary — visible text (assistant prose or a typed user message) or a thinking block (even an empty one, as OpenAI emits between tool calls) — so reasoning-per-call models render one block per call. Images stay visible without expanding, expand a row with ctrl+o to see the full command and results/diffs. Long bash commands get an AI-generated summary once you pick a model with `/goodies summary-model <provider/model>` (see "Smart summaries" below) — expanding a row swaps the summary back out for the raw command. While enabled, also flips `@bermudi/pi-codex`'s `apply_patch`/`web_search` into the same burst style. Long thinking runs can show a live plain-English line above the editor (`/goodies thinking-summaries on`) instead of a static `Thinking...` — see "Smart summaries". |
15
15
  | `review` | `/review`, `/end-review` | Code review workflow: review uncommitted changes, a branch, a commit, a commit range (`A..B`/`A...B`), a GitHub PR, or folders. Prioritized findings with actionable follow-ups. Targets tab-complete. Works in the terminal and in RPC hosts like T3 Code (plain selectors replace the search-as-you-type ones outside the TUI). |
16
16
  | `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
17
- | `provider-balance` | footer (no command) | Show remaining Kilo, OpenRouter, or CommandCode credits, z.ai token-plan quota, or OpenAI Codex quota on the right side of the working-directory footer line. |
17
+ | `provider-balance` | footer (no command) | Show remaining Kilo, OpenRouter, or CommandCode credits, z.ai token-plan quota, Anthropic Pro/Max subscription usage (including `claude-code`), or OpenAI Codex quota on the right side of the working-directory footer line. |
18
18
  | `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
19
19
  | `vision` | `vision` tool, `/vision` | Ask a vision model targeted questions about an image file and get a text answer — for models that can't see images. Follow-ups via `followUp: true`. Self-hides when the active model has image input. |
20
20
  | `side` | `/side`, `/end-side-session` | Consult a second model alongside the session. `/side [provider/model-id]` parks the main agent (branch tip + model recorded in a marker entry) and opens a side conversation on its own tree limb; the side model sees the main conversation as one attributed quote — “a transcript of a session you were not part of”, untrusted evidence, not its own history — via a per-request context lens. `/end-side-session [trajectory\|summary\|nothing]` picks the handoff at exit: full transcript quote, a summary written by the side model, or nothing; the main branch stays clean either way. Arguments tab-complete. |
@@ -25,7 +25,7 @@ extensions. One entry point, thirteen independent features.
25
25
  First install, pinned to a published version:
26
26
 
27
27
  ```bash
28
- pi install npm:bermudis-pi-goodies@0.28.0
28
+ pi install npm:bermudis-pi-goodies@0.29.0
29
29
  ```
30
30
 
31
31
  Picking up a new release on an existing install (`pi install` is not the
@@ -301,6 +301,23 @@ OpenAI Codex's ChatGPT subscription quota when using `openai-codex` OAuth, and
301
301
  CommandCode credit balance plus 5-hour/weekly usage windows for
302
302
  `commandcode` and `commandcode-anthropic` (same account, same key);
303
303
  it skips platform API-key auth because that has no ChatGPT subscription quota.
304
+ For `anthropic` OAuth (the `/login anthropic` Claude Pro/Max subscription), the
305
+ footer shows the same two windows Claude Code's `/usage` panel renders —
306
+ `Current session` (5-hour) and `This week` — plus per-model weekly windows
307
+ (Opus/Sonnet, and newer model-scoped plans) and remaining usage credits when
308
+ extra usage is enabled with a cap. Anthropic API-key auth has no subscription
309
+ quota, so nothing is shown or fetched without the OAuth login.
310
+ The same read backs the **`claude-code`** provider from
311
+ `@earendil-works/pi-claude-code` (models named `claude-code/claude-*`). That
312
+ provider holds no token of its own — it deliberately resolves auth to the
313
+ sentinel string `external-process` because the Claude Code CLI owns the login —
314
+ so the footer reads Claude Code's ambient credential instead:
315
+ `CLAUDE_CODE_OAUTH_TOKEN` when set, otherwise
316
+ `claudeAiOauth.accessToken` in `<config dir>/.credentials.json`, where the
317
+ config dir is `PI_CLAUDE_CODE_CONFIG_DIR`, then `CLAUDE_CONFIG_DIR`, then
318
+ `~/.claude`. The token is used only to sign that one request; it is never
319
+ logged or cached. It is gated on OAuth too, and the provider reports `oauth`
320
+ only for a subscription login, so an API-key Claude Code install is skipped.
304
321
  For `openai` OAuth (Sign in with ChatGPT), OpenAI rejects the new token on its
305
322
  usage endpoints (`401 rejected_by_access_enforcement`), so the footer reads the
306
323
  plan windows through a companion `openai-codex` login on the same ChatGPT
@@ -312,7 +329,12 @@ OpenRouter uses `GET /api/v1/credits`; z.ai uses
312
329
  `GET /api/monitor/usage/quota/limit`; Codex and OpenAI use `GET /wham/usage`
313
330
  (plus `/chatpass/apps` for OpenAI); CommandCode uses
314
331
  `GET /alpha/billing/credits` (a private endpoint its CLI
315
- also uses; `COMMANDCODE_API_URL` can override the base URL). z.ai, Codex, and
332
+ also uses; `COMMANDCODE_API_URL` can override the base URL); Anthropic and
333
+ Claude Code both use
334
+ `GET /api/oauth/usage` on `api.anthropic.com` — the endpoint Claude Code's own
335
+ `/usage` panel reads (`ANTHROPIC_USAGE_URL` overrides the base URL, and is
336
+ deliberately independent of `ANTHROPIC_BASE_URL`: a proxy base URL carries no
337
+ subscription quota). z.ai, Codex, and
316
338
  CommandCode show both each quota's window length and its `nextResetTime`/`reset_at`
317
339
  countdown (when supplied), and `CODEX_API_URL` or `CHATGPT_BASE_URL` can
318
340
  override the Codex base URL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
@@ -24,6 +24,7 @@ import {
24
24
  unlinkSync,
25
25
  } from "node:fs";
26
26
  import { join } from "node:path";
27
+ import { homedir } from "node:os";
27
28
  import {
28
29
  writeJsonFileAtomic,
29
30
  describeError,
@@ -64,6 +65,16 @@ const COMMANDCODE_API_BASE = (
64
65
  process.env.COMMANDCODE_API_URL || "https://api.commandcode.ai"
65
66
  ).replace(/\/+$/, "");
66
67
  const COMMANDCODE_CREDITS_ENDPOINT = `${COMMANDCODE_API_BASE}/alpha/billing/credits`;
68
+ /**
69
+ * Subscription usage for Anthropic Pro/Max plans. Same host and bearer as the
70
+ * chat completions pi already sends to api.anthropic.com, and the endpoint
71
+ * Claude Code's own /usage panel reads. Deliberately NOT derived from
72
+ * ANTHROPIC_BASE_URL: proxy/gateway base URLs carry no subscription quota, so
73
+ * only the dedicated override moves this read.
74
+ */
75
+ const ANTHROPIC_USAGE_ENDPOINT = `${(
76
+ process.env.ANTHROPIC_USAGE_URL || "https://api.anthropic.com"
77
+ ).replace(/\/+$/, "")}/api/oauth/usage`;
67
78
  const BALANCE_FETCH_TIMEOUT_MS = 5_000;
68
79
  /**
69
80
  * While Pi is waiting for input, periodically adopt another session's fresh
@@ -759,7 +770,7 @@ export interface CommandcodeBalance {
759
770
  const COMMANDCODE_FIVE_HOUR_SECONDS = 5 * 60 * 60;
760
771
  const COMMANDCODE_WEEK_SECONDS = 7 * 24 * 60 * 60;
761
772
 
762
- function parseCommandcodeResetAtSeconds(value: unknown): number | undefined {
773
+ function parseResetAtSeconds(value: unknown): number | undefined {
763
774
  let millis: number | null = null;
764
775
  if (typeof value === "number" && Number.isFinite(value)) {
765
776
  // Millisecond epochs are ~1e12 and up; second epochs are ~1e9.
@@ -768,7 +779,10 @@ function parseCommandcodeResetAtSeconds(value: unknown): number | undefined {
768
779
  const parsed = Date.parse(value.trim());
769
780
  millis = Number.isNaN(parsed) ? null : parsed;
770
781
  }
771
- return millis !== null && millis > 0 ? millis / 1000 : undefined;
782
+ // Whole seconds: ISO inputs carry sub-second precision (the live Claude
783
+ // endpoint returns microseconds), but every consumer compares these against
784
+ // second-granular countdowns and caches them as numbers.
785
+ return millis !== null && millis > 0 ? Math.floor(millis / 1000) : undefined;
772
786
  }
773
787
 
774
788
  function parseCommandcodeWindow(
@@ -778,7 +792,7 @@ function parseCommandcodeWindow(
778
792
  const used = numericProperty(value, "used");
779
793
  const cap = numericProperty(value, "cap");
780
794
  if (used === null || cap === null || cap <= 0) return null;
781
- const resetAt = parseCommandcodeResetAtSeconds(asRecord(value)?.resetAt);
795
+ const resetAt = parseResetAtSeconds(asRecord(value)?.resetAt);
782
796
  return {
783
797
  usedPercent: (used / cap) * 100,
784
798
  windowSeconds,
@@ -855,6 +869,246 @@ async function fetchCommandcodeBalance(
855
869
  return commandcodeBalanceToBalance(balance);
856
870
  }
857
871
 
872
+ // --- Claude (Anthropic Pro/Max subscription) ------------------------------
873
+ // Anthropic exposes subscription usage at GET /api/oauth/usage on
874
+ // api.anthropic.com — the same host and bearer Claude Code's /usage panel
875
+ // reads. The response body is the utilization object itself (verified live
876
+ // 2026-10-31 against Claude Code 2.1.314): window utilization is 0–100
877
+ // "percent used" and resets_at is an ISO-8601 string. The panel renders
878
+ // "Current session" (five_hour) and "This week" (seven_day) from it; Max
879
+ // plans can add per-model weekly windows (seven_day_opus/seven_day_sonnet
880
+ // and limits[] rows with kind "weekly_scoped"), which get segment labels.
881
+ // API-key auth has no subscription quota, so the adapter is OAuth-gated.
882
+
883
+ export interface ClaudeUsageWindow {
884
+ usedPercent: number;
885
+ windowSeconds: number;
886
+ /** Unix timestamp in seconds, from the API's ISO `resets_at`. */
887
+ resetAt?: number;
888
+ }
889
+
890
+ export interface ClaudeUsage {
891
+ fiveHour: ClaudeUsageWindow | null;
892
+ sevenDay: ClaudeUsageWindow | null;
893
+ /** Per-model weekly windows ("Opus", "Sonnet", or a model display name). */
894
+ scoped: Array<ClaudeUsageWindow & { label: string }>;
895
+ /** Remaining usage-credit dollars when extra usage is enabled with a cap. */
896
+ credits: number | null;
897
+ }
898
+
899
+ const CLAUDE_FIVE_HOUR_SECONDS = 5 * 60 * 60;
900
+ const CLAUDE_WEEK_SECONDS = 7 * 24 * 60 * 60;
901
+
902
+ function parseClaudeUsageWindow(
903
+ value: unknown,
904
+ windowSeconds: number,
905
+ ): ClaudeUsageWindow | null {
906
+ const record = asRecord(value);
907
+ if (!record) return null;
908
+ const usedPercent = numericProperty(record, "utilization");
909
+ if (usedPercent === null) return null;
910
+ const resetAt = parseResetAtSeconds(record.resets_at);
911
+ return {
912
+ usedPercent,
913
+ windowSeconds,
914
+ ...(resetAt !== undefined ? { resetAt } : {}),
915
+ };
916
+ }
917
+
918
+ export function parseClaudeUsage(value: unknown): ClaudeUsage | null {
919
+ const payload = asRecord(value);
920
+ if (!payload) return null;
921
+
922
+ const fiveHour = parseClaudeUsageWindow(
923
+ payload.five_hour,
924
+ CLAUDE_FIVE_HOUR_SECONDS,
925
+ );
926
+ const sevenDay = parseClaudeUsageWindow(
927
+ payload.seven_day,
928
+ CLAUDE_WEEK_SECONDS,
929
+ );
930
+
931
+ // Per-model weekly windows. The explicit fields cover Opus/Sonnet on Max
932
+ // plans; limits[] rows with kind "weekly_scoped" carry newer model-scoped
933
+ // windows (Claude Code renders those as "Current week (<model>)").
934
+ const scoped: Array<ClaudeUsageWindow & { label: string }> = [];
935
+ for (const [field, label] of [
936
+ ["seven_day_opus", "Opus"],
937
+ ["seven_day_sonnet", "Sonnet"],
938
+ ] as const) {
939
+ const window = parseClaudeUsageWindow(payload[field], CLAUDE_WEEK_SECONDS);
940
+ if (window) scoped.push({ label, ...window });
941
+ }
942
+ if (Array.isArray(payload.limits)) {
943
+ for (const candidate of payload.limits) {
944
+ if (stringProperty(candidate, "kind") !== "weekly_scoped") continue;
945
+ const displayName = stringProperty(
946
+ objectProperty(objectProperty(candidate, "scope"), "model"),
947
+ "display_name",
948
+ );
949
+ if (!displayName) continue;
950
+ const usedPercent = numericProperty(candidate, "percent");
951
+ if (usedPercent === null) continue;
952
+ const resetAt = parseResetAtSeconds(asRecord(candidate)?.resets_at);
953
+ scoped.push({
954
+ label: displayName,
955
+ usedPercent,
956
+ windowSeconds: CLAUDE_WEEK_SECONDS,
957
+ ...(resetAt !== undefined ? { resetAt } : {}),
958
+ });
959
+ }
960
+ }
961
+
962
+ // Usage credits (extra usage): a monthly dollar cap, not a prepaid pool
963
+ // balance — show remaining-to-cap only when the feature is on.
964
+ const extraUsage = asRecord(payload.extra_usage);
965
+ let credits: number | null = null;
966
+ if (extraUsage?.is_enabled === true) {
967
+ const monthlyLimit = numericProperty(extraUsage, "monthly_limit");
968
+ const usedCredits = numericProperty(extraUsage, "used_credits");
969
+ if (monthlyLimit !== null && usedCredits !== null) {
970
+ credits = Math.max(0, monthlyLimit - usedCredits);
971
+ }
972
+ }
973
+
974
+ if (!fiveHour && !sevenDay && scoped.length === 0 && credits === null) {
975
+ return null;
976
+ }
977
+ return { fiveHour, sevenDay, scoped, credits };
978
+ }
979
+
980
+ export function claudeUsageToBalance(usage: ClaudeUsage): Balance {
981
+ const segments: BalanceSegment[] = [];
982
+ for (const window of [usage.fiveHour, usage.sevenDay]) {
983
+ if (window) segments.push({ quota: windowToQuota(window) });
984
+ }
985
+ for (const window of usage.scoped) {
986
+ segments.push({ label: window.label, quota: windowToQuota(window) });
987
+ }
988
+ if (usage.credits !== null) segments.push({ credits: usage.credits });
989
+ return segments;
990
+ }
991
+
992
+ export function formatClaudeUsage(
993
+ usage: ClaudeUsage,
994
+ nowMs = Date.now(),
995
+ ): string {
996
+ return formatBalance(claudeUsageToBalance(usage), nowMs);
997
+ }
998
+
999
+ export async function fetchClaudeUsage(
1000
+ token: string,
1001
+ signal: AbortSignal,
1002
+ ): Promise<Balance> {
1003
+ const response = await fetch(ANTHROPIC_USAGE_ENDPOINT, {
1004
+ headers: {
1005
+ Authorization: `Bearer ${token}`,
1006
+ Accept: "application/json",
1007
+ },
1008
+ signal: timeoutSignal(BALANCE_FETCH_TIMEOUT_MS, signal),
1009
+ });
1010
+ if (!response.ok) {
1011
+ throw new Error(`Claude usage request failed: ${response.status}`);
1012
+ }
1013
+ const usage = parseClaudeUsage(await response.json());
1014
+ if (usage === null) {
1015
+ throw new Error("Claude usage response was invalid");
1016
+ }
1017
+ return claudeUsageToBalance(usage);
1018
+ }
1019
+
1020
+ // --- Claude Code provider (pi-claude-code) ---------------------------------
1021
+ // The `claude-code` provider (@earendil-works/pi-claude-code) does NOT hold a
1022
+ // token: its auth resolves to the sentinel string "external-process" because
1023
+ // the Claude Code CLI owns the login and performs the requests itself. Pi's
1024
+ // registry therefore cannot hand this adapter a usable bearer, so the read
1025
+ // resolves Claude Code's own ambient credential instead.
1026
+ //
1027
+ // That credential is only ever read into this process to become one
1028
+ // Authorization header: it is never logged, echoed, cached, or returned. Failing
1029
+ // to find it is an expected, reported condition (the session simply shows no
1030
+ // Claude balance) — never a silently wrong number.
1031
+
1032
+ export function claudeCodeConfigDir(
1033
+ env: Record<string, string | undefined> = process.env,
1034
+ ): string {
1035
+ // Mirrors the provider's own resolution order (see provider.ts
1036
+ // PROVIDER_ENV_NAMES): PI_CLAUDE_CODE_CONFIG_DIR, then CLAUDE_CONFIG_DIR,
1037
+ // then Claude Code's default.
1038
+ return (
1039
+ env.PI_CLAUDE_CODE_CONFIG_DIR ||
1040
+ env.CLAUDE_CONFIG_DIR ||
1041
+ join(homedir(), ".claude")
1042
+ );
1043
+ }
1044
+
1045
+ interface ClaudeCodeCredentials {
1046
+ accessToken: string;
1047
+ /** Epoch milliseconds, as Claude Code stores it. */
1048
+ expiresAt?: number;
1049
+ }
1050
+
1051
+ /**
1052
+ * Claude Code's ambient OAuth credential. `CLAUDE_CODE_OAUTH_TOKEN` (a token
1053
+ * minted by `claude setup-token`) wins when set; otherwise the credential file
1054
+ * Claude Code maintains in its config directory is used.
1055
+ */
1056
+ export function readClaudeCodeCredentials(
1057
+ env: Record<string, string | undefined> = process.env,
1058
+ ): ClaudeCodeCredentials {
1059
+ const fromEnv = env.CLAUDE_CODE_OAUTH_TOKEN?.trim();
1060
+ if (fromEnv) return { accessToken: fromEnv };
1061
+
1062
+ const path = join(claudeCodeConfigDir(env), ".credentials.json");
1063
+ const raw = readRegularJsonFile(path);
1064
+ if (raw === null) {
1065
+ throw new Error(
1066
+ `Claude Code has no readable credential file at ${path}; run \`claude auth login\``,
1067
+ );
1068
+ }
1069
+ let parsed: unknown;
1070
+ try {
1071
+ parsed = JSON.parse(raw);
1072
+ } catch {
1073
+ throw new Error(`Claude Code credential file at ${path} is not valid JSON`);
1074
+ }
1075
+ const oauth = objectProperty(parsed, "claudeAiOauth");
1076
+ const accessToken = stringProperty(oauth, "accessToken");
1077
+ if (!accessToken) {
1078
+ throw new Error(
1079
+ `Claude Code credential file at ${path} has no subscription token; run \`claude auth login\``,
1080
+ );
1081
+ }
1082
+ const expiresAt = numericProperty(oauth, "expiresAt");
1083
+ return {
1084
+ accessToken,
1085
+ ...(expiresAt !== null ? { expiresAt } : {}),
1086
+ };
1087
+ }
1088
+
1089
+ /**
1090
+ * Balance for the `claude-code` provider. The `token` argument is the registry
1091
+ * sentinel and is deliberately ignored: the real credential comes from Claude
1092
+ * Code, which owns the login.
1093
+ */
1094
+ export async function fetchClaudeCodeUsage(
1095
+ _token: string,
1096
+ signal: AbortSignal,
1097
+ ): Promise<Balance> {
1098
+ const credentials = readClaudeCodeCredentials();
1099
+ // Claude Code refreshes this token itself on its next run. Reporting the
1100
+ // expiry beats sending a stale bearer that can only answer 401.
1101
+ if (
1102
+ credentials.expiresAt !== undefined &&
1103
+ credentials.expiresAt <= Date.now()
1104
+ ) {
1105
+ throw new Error(
1106
+ `Claude Code's OAuth token expired at ${new Date(credentials.expiresAt).toISOString()}; it refreshes on Claude Code's next run`,
1107
+ );
1108
+ }
1109
+ return fetchClaudeUsage(credentials.accessToken, signal);
1110
+ }
1111
+
858
1112
  const BALANCE_ADAPTERS: Readonly<Record<string, BalanceAdapter>> = {
859
1113
  kilo: { fetch: fetchKiloBalance },
860
1114
  openrouter: { fetch: fetchOpenRouterBalance },
@@ -869,6 +1123,15 @@ const BALANCE_ADAPTERS: Readonly<Record<string, BalanceAdapter>> = {
869
1123
  // Both CommandCode transports share one account and one key.
870
1124
  commandcode: { fetch: fetchCommandcodeBalance },
871
1125
  "commandcode-anthropic": { fetch: fetchCommandcodeBalance },
1126
+ // Anthropic Pro/Max subscription windows ("Current session", "This week").
1127
+ // API-key auth has no subscription quota, so never fetch it without OAuth.
1128
+ anthropic: { fetch: fetchClaudeUsage, requiresOAuth: true },
1129
+ // The pi-claude-code provider delegates login to the Claude Code CLI and
1130
+ // resolves its own auth to a sentinel, so this adapter reads Claude Code's
1131
+ // ambient credential itself. Pi still gates it on OAuth: the provider
1132
+ // reports "oauth" only for a Claude subscription login, so an API-key
1133
+ // Claude Code setup is skipped.
1134
+ "claude-code": { fetch: fetchClaudeCodeUsage, requiresOAuth: true },
872
1135
  };
873
1136
 
874
1137
  // --- Shared balance cache ---------------------------------------------------
@@ -915,7 +1178,9 @@ export function balanceCacheKey(
915
1178
  : provider === "commandcode" ||
916
1179
  provider === "commandcode-anthropic"
917
1180
  ? COMMANDCODE_CREDITS_ENDPOINT
918
- : provider;
1181
+ : provider === "anthropic" || provider === "claude-code"
1182
+ ? ANTHROPIC_USAGE_ENDPOINT
1183
+ : provider;
919
1184
  const fingerprint = createHash("sha256").update(identity).digest("hex");
920
1185
  return `v2:${provider}:${endpoint}:${fingerprint}`;
921
1186
  }
package/review.ts CHANGED
@@ -302,7 +302,7 @@ const COMMIT_PROMPT =
302
302
  "Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
303
303
 
304
304
  const COMMIT_RANGE_PROMPT =
305
- "Review the code changes introduced by the commit range {range}. Run `git log --oneline {range}` to see the commits in the range, then run `git diff {range}` to inspect the combined diff. Provide prioritized, actionable findings.";
305
+ "Review the code changes introduced by the commit range {range}. Run `git log --oneline {logRange}` to see the commits in the range, then run `git diff {range}` to inspect the combined diff. Provide prioritized, actionable findings.";
306
306
 
307
307
  const COMMIT_RANGE_THREE_DOT_NOTE =
308
308
  " The three-dot form diffs the second endpoint against the merge base of the two endpoints.";
@@ -310,6 +310,12 @@ const COMMIT_RANGE_THREE_DOT_NOTE =
310
310
  const COMMIT_RANGE_PROMPT_WITH_COMMITS =
311
311
  "Review the code changes introduced by the commit range {range}. The commits in the range are:\n\n{commits}\n\nRun `git diff {range}` to inspect the combined diff. Provide prioritized, actionable findings.";
312
312
 
313
+ /** Max commits embedded in the review prompt; the rest is noted as truncated. */
314
+ export const COMMIT_RANGE_COMMIT_LIMIT = 100;
315
+
316
+ const COMMIT_RANGE_TRUNCATED_NOTE =
317
+ "\n\n(Commit list truncated to the first {limit} commits — run `git log --oneline {logRange}` for the full list.)";
318
+
313
319
  const PULL_REQUEST_PROMPT =
314
320
  "Review pull request #{prNumber} (\"{title}\") against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes that would be merged. Provide prioritized, actionable findings.";
315
321
 
@@ -723,7 +729,11 @@ export function parseReviewPaths(tokens: string[]): string[] {
723
729
  * against the merge base of A and B, like a PR). Endpoints are any git rev
724
730
  * (SHA, branch, tag, `HEAD~3`, …). Open-ended ranges (`A..`, `..B`) and
725
731
  * option-like endpoints (`-x`) are rejected so a malformed range fails here
726
- * instead of as a confusing git error in the review prompt.
732
+ * instead of as a confusing git error in the review prompt. Endpoints are
733
+ * also restricted to a git-ref character set: the range is interpolated
734
+ * verbatim into the prompt's `git log`/`git diff` instructions, which the
735
+ * reviewing agent runs through a shell, so shell metacharacters (`;`, `$()`,
736
+ * backticks, `|`, …) must never reach it.
727
737
  */
728
738
  export function parseCommitRange(value: string): {
729
739
  from: string;
@@ -741,6 +751,7 @@ export function parseCommitRange(value: string): {
741
751
  if (!endpoint) return null;
742
752
  if (endpoint.includes("..") || /\s/.test(endpoint)) return null;
743
753
  if (endpoint.startsWith("-") || endpoint.startsWith(".")) return null;
754
+ if (!/^[A-Za-z0-9._/~^@{}-]+$/.test(endpoint)) return null;
744
755
  }
745
756
  return { from, to, threeDot, range: trimmed };
746
757
  }
@@ -1018,24 +1029,51 @@ export async function buildReviewPrompt(
1018
1029
  return COMMIT_PROMPT.replace("{sha}", () => target.sha);
1019
1030
 
1020
1031
  case "commitRange": {
1021
- const { range, threeDot, title } = target;
1032
+ const { range, from, to, threeDot, title } = target;
1022
1033
  const note = threeDot ? COMMIT_RANGE_THREE_DOT_NOTE : "";
1034
+ // Always list with the two-dot range: `git log A...B` is a symmetric
1035
+ // difference and would include A-only commits on diverged endpoints,
1036
+ // while the reviewed `git diff A...B` contains exactly A..B's commits.
1037
+ const logRange = `${from}..${to}`;
1038
+ const fallbackPrompt = () =>
1039
+ COMMIT_RANGE_PROMPT.replace(/{range}/g, () => range).replace(
1040
+ /{logRange}/g,
1041
+ () => logRange,
1042
+ );
1023
1043
  let prompt: string;
1024
1044
  try {
1025
1045
  const { stdout, code } = await pi.exec("git", [
1026
1046
  "log",
1027
1047
  "--oneline",
1028
- range,
1048
+ "-n",
1049
+ `${COMMIT_RANGE_COMMIT_LIMIT + 1}`,
1050
+ logRange,
1029
1051
  ]);
1030
- const commits = code === 0 ? stdout.trim() : "";
1031
- prompt = commits
1032
- ? COMMIT_RANGE_PROMPT_WITH_COMMITS.replace(
1033
- /{range}/g,
1034
- () => range,
1035
- ).replace("{commits}", () => commits)
1036
- : COMMIT_RANGE_PROMPT.replace(/{range}/g, () => range);
1052
+ const lines =
1053
+ code === 0
1054
+ ? stdout
1055
+ .trim()
1056
+ .split("\n")
1057
+ .filter((line) => line.trim())
1058
+ : [];
1059
+ if (lines.length === 0) {
1060
+ prompt = fallbackPrompt();
1061
+ } else {
1062
+ const truncated = lines.length > COMMIT_RANGE_COMMIT_LIMIT;
1063
+ const commits = lines.slice(0, COMMIT_RANGE_COMMIT_LIMIT).join("\n");
1064
+ prompt = COMMIT_RANGE_PROMPT_WITH_COMMITS.replace(
1065
+ /{range}/g,
1066
+ () => range,
1067
+ ).replace("{commits}", () => commits);
1068
+ if (truncated) {
1069
+ prompt += COMMIT_RANGE_TRUNCATED_NOTE.replace(
1070
+ "{limit}",
1071
+ String(COMMIT_RANGE_COMMIT_LIMIT),
1072
+ ).replace(/{logRange}/g, () => logRange);
1073
+ }
1074
+ }
1037
1075
  } catch {
1038
- prompt = COMMIT_RANGE_PROMPT.replace(/{range}/g, () => range);
1076
+ prompt = fallbackPrompt();
1039
1077
  }
1040
1078
  if (title) prompt += ` The range is described as: "${title}".`;
1041
1079
  return prompt + note;