bermudis-pi-goodies 0.28.0 → 0.30.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 +29 -7
- package/copy-trajectory.ts +71 -38
- package/package.json +1 -1
- package/provider-balance.ts +269 -4
- package/review.ts +50 -12
package/README.md
CHANGED
|
@@ -6,18 +6,18 @@ extensions. One entry point, thirteen independent features.
|
|
|
6
6
|
| Feature | Command / hook | What it does |
|
|
7
7
|
| ------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
8
8
|
| `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
|
|
9
|
-
| `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks.
|
|
9
|
+
| `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, plus injected context later turns may answer — displayed custom messages such as side-consultation handoffs and external-change diffs, plus branch summaries; tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. Compaction summaries are omitted because the raw originals stay on the copied branch. |
|
|
10
10
|
| `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
|
|
11
|
-
| `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash <path>` (and `trash-rm`/`trash-empty`, which delete permanently), `python`/`pip`/`pytest`/`mypy` are not blocked (removed 2026-10-24 — steering to `uv` is AGENTS.md's job, not the hook's). Bash `rg` is allowed, except the replace flag: `-r` (including clusters like `-rn`) is blocked because it is `--replace` and silently replaces matched text — use `--replace` explicitly if you mean it. The built-in grep tool still works.
|
|
11
|
+
| `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash <path>` (and `trash-rm`/`trash-empty`, which delete permanently), `python`/`pip`/`pytest`/`mypy` are not blocked (removed 2026-10-24 — steering to `uv` is AGENTS.md's job, not the hook's). Bash `rg` is allowed, except the replace flag: `-r` (including clusters like `-rn`) is blocked because it is `--replace` and silently replaces matched text — use `--replace` explicitly if you mean it. The built-in grep tool still works. |
|
|
12
12
|
| `keep-model-on-new` | hook (no command) | Keep the active model when `/new` starts a fresh session instead of reverting to pi's saved default model. |
|
|
13
13
|
| `model-thinking` | `/model-thinking` | Per-model default thinking levels: save the current level as this model's default and get it back on every switch to that model, instead of pi's global default. |
|
|
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
|
-
| `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).
|
|
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
|
-
| `side` | `/side`, `/end-side-session`
|
|
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. |
|
|
21
21
|
| `goodies` | `/goodies` | Toggle individual features on/off without losing the rest. Also supports `/goodies summary-model [provider/model]` to pick the model used for AI bash-command summaries, and `/goodies thinking-summaries <on\|off>` for live thinking summaries. State persists to `~/.pi/agent/goodies.json`. Arguments tab-complete. |
|
|
22
22
|
|
|
23
23
|
## Install
|
|
@@ -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
|
+
pi install npm:bermudis-pi-goodies@0.30.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)
|
|
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/copy-trajectory.ts
CHANGED
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
* copy-trajectory — /copy-trajectory
|
|
3
3
|
*
|
|
4
4
|
* Copies the current session's trajectory to the system clipboard as plain
|
|
5
|
-
* text, keeping only the
|
|
6
|
-
* assistant text
|
|
5
|
+
* text, keeping only what the conversation actually ran on: user and
|
|
6
|
+
* assistant text, plus injected context that later turns may answer —
|
|
7
|
+
* displayed custom_message entries (side-consultation handoffs,
|
|
8
|
+
* external-change diffs, ketamine summaries) and branch summaries (pi-native,
|
|
9
|
+
* the only record of an abandoned limb). Without those, turns replying to
|
|
10
|
+
* injected material ("the consultant's other points…") dangle without
|
|
11
|
+
* their referent.
|
|
12
|
+
* Tool calls, tool results, and session metadata are stripped. Compaction
|
|
13
|
+
* summaries are skipped on purpose: getBranch() is the raw parent chain, so
|
|
14
|
+
* the pre-compaction originals are still on the branch and the summary
|
|
15
|
+
* would only duplicate them.
|
|
7
16
|
*
|
|
8
|
-
* /copy-trajectory copy user + assistant text
|
|
17
|
+
* /copy-trajectory copy user + assistant text + injected context
|
|
9
18
|
* /copy-trajectory thinking also include assistant thinking blocks
|
|
10
19
|
*
|
|
11
20
|
* Uses ctx.sessionManager.getBranch() for the active branch (the raw parent
|
|
@@ -38,10 +47,13 @@ const extractThinkingParts = (content: unknown): string[] => {
|
|
|
38
47
|
};
|
|
39
48
|
|
|
40
49
|
type Turn = {
|
|
41
|
-
role: "User" | "Assistant";
|
|
50
|
+
role: "User" | "Assistant" | "Context";
|
|
42
51
|
/** For assistant turns: the model that actually served the reply (or the
|
|
43
52
|
* requested model when the provider doesn't report the resolved one). */
|
|
44
53
|
model?: string;
|
|
54
|
+
/** For context turns: what injected the content (customType, or a fixed
|
|
55
|
+
* label for pi-native entries like branch summaries). */
|
|
56
|
+
source?: string;
|
|
45
57
|
body: string;
|
|
46
58
|
};
|
|
47
59
|
|
|
@@ -53,39 +65,58 @@ export function buildTrajectory(
|
|
|
53
65
|
const turns: Turn[] = [];
|
|
54
66
|
|
|
55
67
|
for (const entry of entries) {
|
|
56
|
-
// getBranch() returns the full SessionEntry union;
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
// getBranch() returns the full SessionEntry union; `message` entries are
|
|
69
|
+
// the conversation itself, custom_message/branch_summary entries are
|
|
70
|
+
// injected context the model saw and later turns may lean on.
|
|
71
|
+
if (entry.type === "message") {
|
|
72
|
+
const message = entry.message;
|
|
73
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
74
|
+
|
|
75
|
+
const chunks: string[] = [...extractTextParts(message.content)];
|
|
76
|
+
|
|
77
|
+
let model: string | undefined;
|
|
78
|
+
if (message.role === "assistant") {
|
|
79
|
+
// AssistantMessage carries both the requested `model` and, when the
|
|
80
|
+
// provider echoes it back, `responseModel` (the model that actually ran).
|
|
81
|
+
model = message.responseModel ?? message.model;
|
|
82
|
+
if (includeThinking) {
|
|
83
|
+
for (const t of extractThinkingParts(message.content)) {
|
|
84
|
+
chunks.push(
|
|
85
|
+
t
|
|
86
|
+
.split("\n")
|
|
87
|
+
.map((line) => `> ${line}`)
|
|
88
|
+
.join("\n"),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
77
91
|
}
|
|
78
92
|
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const body = chunks.join("\n").trim();
|
|
82
|
-
if (!body) continue; // skip empty / tool-only turns
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
const body = chunks.join("\n").trim();
|
|
95
|
+
if (!body) continue; // skip empty / tool-only turns
|
|
96
|
+
|
|
97
|
+
turns.push({
|
|
98
|
+
role: message.role === "user" ? "User" : "Assistant",
|
|
99
|
+
model,
|
|
100
|
+
body,
|
|
101
|
+
});
|
|
102
|
+
} else if (entry.type === "custom_message") {
|
|
103
|
+
// Displayed injections (side handoffs, external-change diffs, ketamine
|
|
104
|
+
// summaries, …) are part of what the model answered; hidden ones
|
|
105
|
+
// (display: false) are machinery the reader never saw. The content is
|
|
106
|
+
// self-describing — side handoffs embed their own model/turn header —
|
|
107
|
+
// so no per-producer knowledge is needed here.
|
|
108
|
+
if (!entry.display) continue;
|
|
109
|
+
const body = extractTextParts(entry.content).join("\n").trim();
|
|
110
|
+
if (!body) continue; // image-only injections carry no text
|
|
111
|
+
turns.push({ role: "Context", source: entry.customType, body });
|
|
112
|
+
} else if (entry.type === "branch_summary") {
|
|
113
|
+
// The summarized limb's entries are not on this branch — the summary
|
|
114
|
+
// is the only record of them.
|
|
115
|
+
const body = entry.summary.trim();
|
|
116
|
+
if (body) turns.push({ role: "Context", source: "branch summary", body });
|
|
117
|
+
}
|
|
118
|
+
// compaction: deliberately skipped — see file header. Everything else
|
|
119
|
+
// (markers, model_change, usage, …) is machinery.
|
|
89
120
|
}
|
|
90
121
|
|
|
91
122
|
return turns;
|
|
@@ -95,9 +126,11 @@ export const renderTrajectory = (turns: readonly Turn[]): string =>
|
|
|
95
126
|
turns
|
|
96
127
|
.map((t) => {
|
|
97
128
|
const header =
|
|
98
|
-
t.role === "
|
|
99
|
-
? `##
|
|
100
|
-
:
|
|
129
|
+
t.role === "Context"
|
|
130
|
+
? `## Injected context${t.source ? ` (${t.source})` : ""}`
|
|
131
|
+
: t.role === "Assistant" && t.model
|
|
132
|
+
? `## Assistant (${t.model})`
|
|
133
|
+
: `## ${t.role}`;
|
|
101
134
|
return `${header}\n\n${t.body}`;
|
|
102
135
|
})
|
|
103
136
|
.join("\n\n");
|
|
@@ -105,7 +138,7 @@ export const renderTrajectory = (turns: readonly Turn[]): string =>
|
|
|
105
138
|
export default function (pi: ExtensionAPI) {
|
|
106
139
|
pi.registerCommand("copy-trajectory", {
|
|
107
140
|
description:
|
|
108
|
-
"Copy the conversation (user + assistant text, no tool calls) to the clipboard",
|
|
141
|
+
"Copy the conversation (user + assistant text + injected context, no tool calls) to the clipboard",
|
|
109
142
|
getArgumentCompletions: (prefix) => {
|
|
110
143
|
const opts = ["thinking"].filter((o) => o.startsWith(prefix));
|
|
111
144
|
return opts.length > 0 ? opts.map((o) => ({ value: o, label: o })) : null;
|
package/package.json
CHANGED
package/provider-balance.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
1048
|
+
"-n",
|
|
1049
|
+
`${COMMIT_RANGE_COMMIT_LIMIT + 1}`,
|
|
1050
|
+
logRange,
|
|
1029
1051
|
]);
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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 =
|
|
1076
|
+
prompt = fallbackPrompt();
|
|
1039
1077
|
}
|
|
1040
1078
|
if (title) prompt += ` The range is described as: "${title}".`;
|
|
1041
1079
|
return prompt + note;
|