@diegopetrucci/pi-extensions 0.1.37 → 0.1.39

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 (48) hide show
  1. package/.pi-fleet-tested-version +1 -1
  2. package/README.md +1 -0
  3. package/extensions/agent-workflow-audit/.pi-fleet-tested-version +1 -1
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/brrr/.pi-fleet-tested-version +1 -1
  6. package/extensions/brrr/package.json +1 -1
  7. package/extensions/claude-fast/.pi-fleet-tested-version +1 -1
  8. package/extensions/claude-fast/README.md +1 -1
  9. package/extensions/claude-fast/index.ts +2 -2
  10. package/extensions/claude-fast/package.json +1 -1
  11. package/extensions/confirm-destructive/.pi-fleet-tested-version +1 -1
  12. package/extensions/confirm-destructive/package.json +1 -1
  13. package/extensions/context-cap/.pi-fleet-tested-version +1 -1
  14. package/extensions/context-cap/package.json +1 -1
  15. package/extensions/context-inspector/.pi-fleet-tested-version +1 -1
  16. package/extensions/context-inspector/package.json +1 -1
  17. package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
  18. package/extensions/dirty-repo-guard/package.json +1 -1
  19. package/extensions/git-footer/.pi-fleet-tested-version +1 -0
  20. package/extensions/git-footer/README.md +41 -0
  21. package/extensions/git-footer/index.ts +533 -0
  22. package/extensions/git-footer/package.json +35 -0
  23. package/extensions/gnosis/.pi-fleet-tested-version +1 -1
  24. package/extensions/gnosis/package.json +1 -1
  25. package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
  26. package/extensions/inline-bash/package.json +1 -1
  27. package/extensions/librarian/.pi-fleet-tested-version +1 -1
  28. package/extensions/librarian/package.json +1 -1
  29. package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
  30. package/extensions/minimal-footer/package.json +1 -1
  31. package/extensions/notify/.pi-fleet-tested-version +1 -1
  32. package/extensions/notify/package.json +1 -1
  33. package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
  34. package/extensions/openai-fast/package.json +1 -1
  35. package/extensions/oracle/.pi-fleet-tested-version +1 -1
  36. package/extensions/oracle/index.ts +8 -0
  37. package/extensions/oracle/package.json +1 -1
  38. package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
  39. package/extensions/permission-gate/package.json +1 -1
  40. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  41. package/extensions/quiet-tools/package.json +1 -1
  42. package/extensions/review/.pi-fleet-tested-version +1 -1
  43. package/extensions/review/package.json +1 -1
  44. package/extensions/todo/.pi-fleet-tested-version +1 -1
  45. package/extensions/todo/package.json +1 -1
  46. package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
  47. package/extensions/triage-comments/package.json +1 -1
  48. package/package.json +4 -4
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
package/README.md CHANGED
@@ -9,6 +9,7 @@ A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions
9
9
  - [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
10
10
  - [`context-inspector`](./extensions/context-inspector): Adds `/context`, a local self-contained HTML dashboard that breaks down where the current session context is going, with category overview, top offenders, and drilldown search.
11
11
  - [`dirty-repo-guard`](./extensions/dirty-repo-guard): Prompts before new sessions, session switches, or forks when the current git repo has uncommitted changes.
12
+ - [`git-footer`](./extensions/git-footer): Standalone footer extension that replaces pi's built-in footer with a TLH-style git summary: branch, dirty counts, ahead/behind, optional PR number, session name, plus context/model status. Not auto-loaded by the collection package because it conflicts with `minimal-footer`.
12
13
  - [`gnosis`](./extensions/gnosis): Exposes the `gn` repo-local knowledge base CLI as an agent tool for searching and recording durable project decisions, constraints, and intent.
13
14
  - [`inline-bash`](./extensions/inline-bash): Expands `!{command}` snippets in user prompts by running them through bash before the prompt reaches the agent.
14
15
  - [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache enabled by default under the OS user cache directory, toggleable with `/librarian-cache`, with cached repos expiring after 7 days of non-use.
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-agent-workflow-audit",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that runs an isolated repo workflow audit and returns only the final report to the main session.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-brrr",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that sends brrr push notifications when pi is ready for input.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -22,7 +22,7 @@ Fast mode is only injected when all of these are true:
22
22
 
23
23
  - The current provider is `anthropic`.
24
24
  - The current API is `anthropic-messages`.
25
- - The current model is `claude-opus-4-6` or `claude-opus-4-7`.
25
+ - The current model is `claude-opus-4-6`, `claude-opus-4-7`, or `claude-opus-4-8`.
26
26
  - The request payload does not already include `speed`.
27
27
 
28
28
  Claude Fast mode is available for API-key access and Claude Code subscription/OAuth access when the account has access to Anthropic's Fast mode research preview. For Claude Code subscription users, Anthropic documents this as extra usage credits, not included subscription usage.
@@ -12,7 +12,7 @@ const API_ID = "anthropic-messages";
12
12
  const FAST_SPEED = "fast";
13
13
  const FAST_BETA = "fast-mode-2026-02-01";
14
14
  const CLAUDE_CODE_OAUTH_BETAS = ["claude-code-20250219", "oauth-2025-04-20"];
15
- const SUPPORTED_MODELS = new Set(["claude-opus-4-6", "claude-opus-4-7"]);
15
+ const SUPPORTED_MODELS = new Set(["claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8"]);
16
16
 
17
17
  const DEFAULT_CONFIG: ClaudeFastConfig = {
18
18
  enabled: false,
@@ -143,7 +143,7 @@ function getEligibility(ctx: ExtensionContext): Eligibility {
143
143
  return {
144
144
  eligible: false,
145
145
  modelKey: key,
146
- reason: "Fast mode is only enabled for Claude Opus 4.6 and 4.7",
146
+ reason: "Fast mode is only enabled for Claude Opus 4.6, 4.7, and 4.8",
147
147
  };
148
148
  }
149
149
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-claude-fast",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A pi extension that enables Anthropic Claude Fast mode for supported Claude Opus models by injecting speed=fast.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-confirm-destructive",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A pi extension that confirms destructive session actions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-cap",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that caps effective model context windows at 200k tokens for earlier auto-compaction.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-inspector",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that opens a local HTML dashboard explaining where the current session context is going.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-dirty-repo-guard",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that prompts before session changes when the current git repo has uncommitted changes.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,41 @@
1
+ # git-footer
2
+
3
+ A TLH-style git status footer for pi.
4
+
5
+ This package is standalone-only and is not auto-loaded by the `@diegopetrucci/pi-extensions` collection package because only one custom footer should win.
6
+
7
+ It replaces pi's built-in footer with a compact two-line layout whose first line mirrors how [`the-last-harness`](https://github.com/diegopetrucci/the-last-harness) summarizes repository state:
8
+
9
+ ```text
10
+ <repo> • <branch> • <git-status> • PR #<number> • <session-name>
11
+ ctx <percent>% • <model> <thinking> • <extension-statuses>
12
+ ```
13
+
14
+ Git status indicators:
15
+
16
+ - `!N`: conflicted paths
17
+ - `+N`: staged paths
18
+ - `~N`: unstaged paths
19
+ - `?N`: untracked paths
20
+ - `↑N`: commits ahead of upstream
21
+ - `↓N`: commits behind upstream
22
+
23
+ The extension polls git status in the background, caches the latest snapshot, and keeps footer rendering synchronous. It also performs a best-effort `gh pr view` lookup for the current branch; if `gh` is unavailable or the branch has no PR, the PR segment is omitted.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pi install npm:@diegopetrucci/pi-git-footer
29
+ ```
30
+
31
+ Then reload pi:
32
+
33
+ ```text
34
+ /reload
35
+ ```
36
+
37
+ ## Notes
38
+
39
+ - Replaces pi's built-in footer entirely.
40
+ - Intended as a separate footer extension; do not enable it at the same time as another custom-footer extension such as `minimal-footer` unless you want the last-loaded footer to win.
41
+ - `render()` never spawns subprocesses. Git and GitHub CLI lookups run on a short background interval with timeouts, and the footer reads only cached snapshots.
@@ -0,0 +1,533 @@
1
+ import { spawn } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth } from "@earendil-works/pi-tui";
5
+
6
+ type GitStatusSnapshot = {
7
+ branch?: string;
8
+ staged: number;
9
+ unstaged: number;
10
+ untracked: number;
11
+ conflict: number;
12
+ ahead: number;
13
+ behind: number;
14
+ };
15
+
16
+ type PullRequestSnapshot = {
17
+ number?: number | string;
18
+ state?: string;
19
+ isDraft?: boolean;
20
+ url?: string;
21
+ title?: string;
22
+ };
23
+
24
+ type CommandResult = {
25
+ stdout: string;
26
+ stderr: string;
27
+ exitCode: number | null;
28
+ };
29
+
30
+ type CommandRunner = (
31
+ command: string,
32
+ args: readonly string[],
33
+ options: { cwd: string; signal: AbortSignal },
34
+ ) => Promise<CommandResult>;
35
+
36
+ type TimerHandle = unknown;
37
+
38
+ type Clock = {
39
+ setInterval(callback: () => void, ms: number): TimerHandle;
40
+ clearInterval(handle: TimerHandle): void;
41
+ };
42
+
43
+ type GitFooterCacheOptions = {
44
+ cwd: () => string;
45
+ runner?: CommandRunner;
46
+ clock?: Clock;
47
+ refreshIntervalMs?: number;
48
+ gitTimeoutMs?: number;
49
+ ghTimeoutMs?: number;
50
+ onChange?: () => void;
51
+ onBranchChangeSource?: (callback: () => void) => () => void;
52
+ };
53
+
54
+ const BRANCH_HEAD_PREFIX = "# branch.head ";
55
+ const BRANCH_AB_PREFIX = "# branch.ab ";
56
+ const FOOTER_SEPARATOR = " • ";
57
+ const DEFAULT_REFRESH_INTERVAL_MS = 8_000;
58
+ const DEFAULT_GIT_TIMEOUT_MS = 1_500;
59
+ const DEFAULT_GH_TIMEOUT_MS = 3_000;
60
+ const GIT_STATUS_ARGS = ["--no-optional-locks", "status", "--porcelain=v2", "--branch"] as const;
61
+ const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number,state,isDraft,url,title"] as const;
62
+
63
+ function createEmptyGitStatus(): GitStatusSnapshot {
64
+ return {
65
+ branch: undefined,
66
+ staged: 0,
67
+ unstaged: 0,
68
+ untracked: 0,
69
+ conflict: 0,
70
+ ahead: 0,
71
+ behind: 0,
72
+ };
73
+ }
74
+
75
+ function positiveCount(value: number): number {
76
+ return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
77
+ }
78
+
79
+ function addTrackedStatusCounts(status: GitStatusSnapshot, xy: string): void {
80
+ if (xy.length !== 2) return;
81
+ if (xy[0] !== ".") status.staged += 1;
82
+ if (xy[1] !== ".") status.unstaged += 1;
83
+ }
84
+
85
+ function parseBranchAheadBehind(line: string, status: GitStatusSnapshot): void {
86
+ const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
87
+ if (!match) return;
88
+ status.ahead = Number.parseInt(match[1]!, 10);
89
+ status.behind = Number.parseInt(match[2]!, 10);
90
+ }
91
+
92
+ function normalizeBranchHead(value: string): string {
93
+ const branch = value.trim();
94
+ return branch === "(detached)" ? "detached" : branch;
95
+ }
96
+
97
+ function parseGitStatusPorcelainV2(output: string): GitStatusSnapshot {
98
+ const status = createEmptyGitStatus();
99
+
100
+ for (const rawLine of output.split("\n")) {
101
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
102
+ if (!line) continue;
103
+
104
+ if (line.startsWith(BRANCH_HEAD_PREFIX)) {
105
+ status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined;
106
+ continue;
107
+ }
108
+
109
+ if (line.startsWith(BRANCH_AB_PREFIX)) {
110
+ parseBranchAheadBehind(line, status);
111
+ continue;
112
+ }
113
+
114
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
115
+ addTrackedStatusCounts(status, line.slice(2, 4));
116
+ continue;
117
+ }
118
+
119
+ if (line.startsWith("u ")) {
120
+ status.conflict += 1;
121
+ continue;
122
+ }
123
+
124
+ if (line.startsWith("? ")) status.untracked += 1;
125
+ }
126
+
127
+ return status;
128
+ }
129
+
130
+ function formatGitStatusFooterSegment(status: GitStatusSnapshot | undefined): string | undefined {
131
+ if (!status) return undefined;
132
+
133
+ const parts: string[] = [];
134
+ const indicators: Array<[string, number]> = [
135
+ ["!", positiveCount(status.conflict)],
136
+ ["+", positiveCount(status.staged)],
137
+ ["~", positiveCount(status.unstaged)],
138
+ ["?", positiveCount(status.untracked)],
139
+ ["↑", positiveCount(status.ahead)],
140
+ ["↓", positiveCount(status.behind)],
141
+ ];
142
+
143
+ for (const [prefix, count] of indicators) {
144
+ if (count > 0) parts.push(`${prefix}${count}`);
145
+ }
146
+
147
+ return parts.length > 0 ? parts.join(" ") : undefined;
148
+ }
149
+
150
+ function formatPullRequestFooterSegment(pullRequest: PullRequestSnapshot | undefined): string | undefined {
151
+ const value = pullRequest?.number;
152
+ if (Number.isSafeInteger(value) && Number(value) > 0) return `PR #${value}`;
153
+ if (typeof value === "string") {
154
+ const trimmed = value.trim();
155
+ if (/^[1-9]\d*$/.test(trimmed)) return `PR #${trimmed}`;
156
+ }
157
+ return undefined;
158
+ }
159
+
160
+ function formatGitFooterSegments(
161
+ status: GitStatusSnapshot | undefined,
162
+ pullRequest: PullRequestSnapshot | undefined,
163
+ ): string[] {
164
+ const segments: string[] = [];
165
+ const branch = typeof status?.branch === "string" ? status.branch.trim() : "";
166
+ if (branch) segments.push(branch);
167
+
168
+ const statusSegment = formatGitStatusFooterSegment(status);
169
+ if (statusSegment) segments.push(statusSegment);
170
+
171
+ const pullRequestSegment = formatPullRequestFooterSegment(pullRequest);
172
+ if (pullRequestSegment) segments.push(pullRequestSegment);
173
+
174
+ return segments;
175
+ }
176
+
177
+ function parsePullRequestJson(stdout: string): PullRequestSnapshot | undefined {
178
+ const trimmed = stdout.trim();
179
+ if (!trimmed) return undefined;
180
+ let parsed: unknown;
181
+ try {
182
+ parsed = JSON.parse(trimmed);
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
187
+
188
+ const record = parsed as Record<string, unknown>;
189
+ const snapshot: PullRequestSnapshot = {};
190
+ if (typeof record.number === "number" || typeof record.number === "string") {
191
+ snapshot.number = record.number;
192
+ }
193
+ if (typeof record.state === "string") snapshot.state = record.state;
194
+ if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
195
+ if (typeof record.url === "string") snapshot.url = record.url;
196
+ if (typeof record.title === "string") snapshot.title = record.title;
197
+ return snapshot;
198
+ }
199
+
200
+ function gitStatusSnapshotsEqual(
201
+ left: GitStatusSnapshot | undefined,
202
+ right: GitStatusSnapshot | undefined,
203
+ ): boolean {
204
+ return (
205
+ left?.branch === right?.branch
206
+ && left?.staged === right?.staged
207
+ && left?.unstaged === right?.unstaged
208
+ && left?.untracked === right?.untracked
209
+ && left?.conflict === right?.conflict
210
+ && left?.ahead === right?.ahead
211
+ && left?.behind === right?.behind
212
+ );
213
+ }
214
+
215
+ function pullRequestSnapshotsEqual(
216
+ left: PullRequestSnapshot | undefined,
217
+ right: PullRequestSnapshot | undefined,
218
+ ): boolean {
219
+ return (
220
+ left?.number === right?.number
221
+ && left?.state === right?.state
222
+ && left?.isDraft === right?.isDraft
223
+ && left?.url === right?.url
224
+ && left?.title === right?.title
225
+ );
226
+ }
227
+
228
+ function defaultRunner(
229
+ command: string,
230
+ args: readonly string[],
231
+ options: { cwd: string; signal: AbortSignal },
232
+ ): Promise<CommandResult> {
233
+ return new Promise((resolve, reject) => {
234
+ let child;
235
+ try {
236
+ child = spawn(command, [...args], {
237
+ cwd: options.cwd,
238
+ stdio: ["ignore", "pipe", "pipe"],
239
+ windowsHide: true,
240
+ });
241
+ } catch (error) {
242
+ reject(error);
243
+ return;
244
+ }
245
+
246
+ let stdout = "";
247
+ let stderr = "";
248
+ let settled = false;
249
+
250
+ const finish = (result: CommandResult | Error) => {
251
+ if (settled) return;
252
+ settled = true;
253
+ options.signal.removeEventListener("abort", onAbort);
254
+ if (result instanceof Error) reject(result);
255
+ else resolve(result);
256
+ };
257
+
258
+ const onAbort = () => {
259
+ try {
260
+ child.kill("SIGTERM");
261
+ } catch {
262
+ // Ignore: process may already be gone.
263
+ }
264
+ finish(new Error("aborted"));
265
+ };
266
+
267
+ child.stdout?.on("data", (chunk: Buffer | string) => {
268
+ stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
269
+ });
270
+ child.stderr?.on("data", (chunk: Buffer | string) => {
271
+ stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
272
+ });
273
+ child.on("error", finish);
274
+ child.on("close", (code) => finish({ stdout, stderr, exitCode: code }));
275
+
276
+ if (options.signal.aborted) {
277
+ onAbort();
278
+ return;
279
+ }
280
+ options.signal.addEventListener("abort", onAbort, { once: true });
281
+ });
282
+ }
283
+
284
+ function defaultClock(): Clock {
285
+ return {
286
+ setInterval(callback, ms) {
287
+ const handle = setInterval(callback, ms);
288
+ (handle as { unref?: () => void }).unref?.();
289
+ return handle;
290
+ },
291
+ clearInterval(handle) {
292
+ clearInterval(handle as ReturnType<typeof setInterval>);
293
+ },
294
+ };
295
+ }
296
+
297
+ class GitFooterCache {
298
+ private readonly cwd: () => string;
299
+ private readonly runner: CommandRunner;
300
+ private readonly clock: Clock;
301
+ private readonly refreshIntervalMs: number;
302
+ private readonly gitTimeoutMs: number;
303
+ private readonly ghTimeoutMs: number;
304
+ private readonly onChange: (() => void) | undefined;
305
+
306
+ private intervalHandle: TimerHandle | undefined;
307
+ private readonly inflightControllers = new Set<AbortController>();
308
+ private disposed = false;
309
+ private refreshInFlight: Promise<void> | undefined;
310
+ private branchChangeUnsubscribe: (() => void) | undefined;
311
+ private statusSnapshot: GitStatusSnapshot | undefined;
312
+ private pullRequestSnapshot: PullRequestSnapshot | undefined;
313
+ private lastSeenBranch: string | undefined;
314
+
315
+ constructor(options: GitFooterCacheOptions) {
316
+ this.cwd = options.cwd;
317
+ this.runner = options.runner ?? defaultRunner;
318
+ this.clock = options.clock ?? defaultClock();
319
+ this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
320
+ this.gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
321
+ this.ghTimeoutMs = options.ghTimeoutMs ?? DEFAULT_GH_TIMEOUT_MS;
322
+ this.onChange = options.onChange;
323
+
324
+ this.intervalHandle = this.clock.setInterval(() => {
325
+ void this.refresh();
326
+ }, this.refreshIntervalMs);
327
+ void this.refresh();
328
+
329
+ if (options.onBranchChangeSource) {
330
+ this.branchChangeUnsubscribe = options.onBranchChangeSource(() => {
331
+ void this.refresh();
332
+ });
333
+ }
334
+ }
335
+
336
+ getStatusSnapshot(): GitStatusSnapshot | undefined {
337
+ return this.statusSnapshot;
338
+ }
339
+
340
+ getPullRequestSnapshot(): PullRequestSnapshot | undefined {
341
+ return this.pullRequestSnapshot;
342
+ }
343
+
344
+ refresh(): Promise<void> {
345
+ if (this.disposed) return Promise.resolve();
346
+ if (this.refreshInFlight) return this.refreshInFlight;
347
+ const run = this.runRefresh()
348
+ .finally(() => {
349
+ this.refreshInFlight = undefined;
350
+ })
351
+ .catch(() => undefined);
352
+ this.refreshInFlight = run;
353
+ return run;
354
+ }
355
+
356
+ private async runRefresh(): Promise<void> {
357
+ const previousStatusSnapshot = this.statusSnapshot;
358
+ const previousPullRequestSnapshot = this.pullRequestSnapshot;
359
+
360
+ const result = await this.fetchGitStatus();
361
+ if (this.disposed) return;
362
+ if (result.kind === "transient") return;
363
+ if (result.kind === "not-a-repo") {
364
+ this.statusSnapshot = undefined;
365
+ this.pullRequestSnapshot = undefined;
366
+ this.lastSeenBranch = undefined;
367
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
368
+ return;
369
+ }
370
+
371
+ const status = result.status;
372
+ this.statusSnapshot = status;
373
+
374
+ const branch = typeof status.branch === "string" ? status.branch : undefined;
375
+ const isValidBranch = !!branch && branch !== "detached";
376
+ const branchChanged = branch !== this.lastSeenBranch;
377
+
378
+ if (branchChanged) this.pullRequestSnapshot = undefined;
379
+ this.lastSeenBranch = branch;
380
+
381
+ if (!isValidBranch) {
382
+ this.pullRequestSnapshot = undefined;
383
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
384
+ return;
385
+ }
386
+
387
+ const pr = await this.fetchPullRequest();
388
+ if (this.disposed) return;
389
+ if (pr !== undefined) this.pullRequestSnapshot = pr;
390
+ else if (branchChanged) this.pullRequestSnapshot = undefined;
391
+
392
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
393
+ }
394
+
395
+ private emitChangeIfSnapshotsChanged(
396
+ previousStatusSnapshot: GitStatusSnapshot | undefined,
397
+ previousPullRequestSnapshot: PullRequestSnapshot | undefined,
398
+ ): void {
399
+ if (this.disposed) return;
400
+ if (
401
+ gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot)
402
+ && pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot)
403
+ ) {
404
+ return;
405
+ }
406
+ try {
407
+ this.onChange?.();
408
+ } catch {
409
+ // Rendering hooks should not break refreshes.
410
+ }
411
+ }
412
+
413
+ private async fetchGitStatus(): Promise<
414
+ | { kind: "ok"; status: GitStatusSnapshot }
415
+ | { kind: "not-a-repo" }
416
+ | { kind: "transient" }
417
+ > {
418
+ const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
419
+ if (!result) return { kind: "transient" };
420
+ if (result.exitCode !== 0) return { kind: "not-a-repo" };
421
+ return { kind: "ok", status: parseGitStatusPorcelainV2(result.stdout) };
422
+ }
423
+
424
+ private async fetchPullRequest(): Promise<PullRequestSnapshot | undefined> {
425
+ const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
426
+ if (!result || result.exitCode !== 0) return undefined;
427
+ return parsePullRequestJson(result.stdout);
428
+ }
429
+
430
+ private async runCommandSafely(
431
+ command: string,
432
+ args: readonly string[],
433
+ timeoutMs: number,
434
+ ): Promise<CommandResult | undefined> {
435
+ if (this.disposed) return undefined;
436
+ const controller = new AbortController();
437
+ this.inflightControllers.add(controller);
438
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
439
+ try {
440
+ return await this.runner(command, args, { cwd: this.cwd(), signal: controller.signal });
441
+ } catch {
442
+ return undefined;
443
+ } finally {
444
+ clearTimeout(timeoutId);
445
+ this.inflightControllers.delete(controller);
446
+ }
447
+ }
448
+
449
+ dispose(): void {
450
+ if (this.disposed) return;
451
+ this.disposed = true;
452
+ if (this.intervalHandle !== undefined) {
453
+ this.clock.clearInterval(this.intervalHandle);
454
+ this.intervalHandle = undefined;
455
+ }
456
+ if (this.branchChangeUnsubscribe) {
457
+ try {
458
+ this.branchChangeUnsubscribe();
459
+ } catch {
460
+ // Ignore misbehaving notifier.
461
+ }
462
+ this.branchChangeUnsubscribe = undefined;
463
+ }
464
+ for (const controller of this.inflightControllers) controller.abort();
465
+ this.inflightControllers.clear();
466
+ }
467
+ }
468
+
469
+ function composeFooterFirstLine(input: {
470
+ cwd: string;
471
+ sessionName?: string | null;
472
+ status?: GitStatusSnapshot;
473
+ pullRequest?: PullRequestSnapshot;
474
+ }): string {
475
+ const segments = [input.cwd];
476
+ if (input.status !== undefined) {
477
+ segments.push(...formatGitFooterSegments(input.status, input.pullRequest));
478
+ }
479
+ if (input.sessionName) segments.push(input.sessionName);
480
+ return segments.join(FOOTER_SEPARATOR);
481
+ }
482
+
483
+ function sanitizeFooterSegment(value: string): string {
484
+ return value.replace(/[\r\n\t]+/g, " ").trim();
485
+ }
486
+
487
+ export default function (pi: ExtensionAPI) {
488
+ pi.on("session_start", (_event, ctx) => {
489
+ let cache: GitFooterCache | undefined;
490
+
491
+ ctx.ui.setFooter((tui, theme, footerData) => {
492
+ cache = new GitFooterCache({
493
+ cwd: () => ctx.cwd,
494
+ onChange: () => tui.requestRender(),
495
+ onBranchChangeSource: (callback) => footerData.onBranchChange(callback),
496
+ });
497
+
498
+ return {
499
+ dispose() {
500
+ cache?.dispose();
501
+ cache = undefined;
502
+ },
503
+ invalidate() {},
504
+ render(width: number): string[] {
505
+ const status = cache?.getStatusSnapshot();
506
+ const pullRequest = cache?.getPullRequestSnapshot();
507
+ const firstLine = composeFooterFirstLine({
508
+ cwd: basename(ctx.cwd),
509
+ status,
510
+ pullRequest,
511
+ sessionName: pi.getSessionName(),
512
+ });
513
+
514
+ const usage = ctx.getContextUsage();
515
+ const context = usage?.percent == null ? "ctx ?" : `ctx ${usage.percent.toFixed(1)}%`;
516
+ const thinking = pi.getThinkingLevel();
517
+ const model = ctx.model?.id ?? "no-model";
518
+ const modelText = thinking === "off" ? model : `${model} ${thinking}`;
519
+ const statuses = [...footerData.getExtensionStatuses().values()]
520
+ .map(sanitizeFooterSegment)
521
+ .filter(Boolean);
522
+ const secondLine = [theme.fg("dim", context), theme.fg("dim", modelText), ...statuses]
523
+ .join(theme.fg("dim", FOOTER_SEPARATOR));
524
+
525
+ return [
526
+ truncateToWidth(theme.fg("dim", firstLine), width),
527
+ truncateToWidth(secondLine, width),
528
+ ];
529
+ },
530
+ };
531
+ });
532
+ });
533
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@diegopetrucci/pi-git-footer",
3
+ "version": "0.1.0",
4
+ "description": "A TLH-style git status footer for pi.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "terminal",
9
+ "footer",
10
+ "git"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/diegopetrucci/pi-extensions.git",
16
+ "directory": "extensions/git-footer"
17
+ },
18
+ "files": [
19
+ "index.ts",
20
+ "README.md",
21
+ ".pi-fleet-tested-version"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "index.ts"
29
+ ]
30
+ },
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-coding-agent": "*",
33
+ "@earendil-works/pi-tui": "*"
34
+ }
35
+ }
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-gnosis",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that exposes the gnosis repo-local knowledge base CLI as an agent tool.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-inline-bash",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that expands inline bash commands in user prompts.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A pi GitHub research scout with a toggleable local repo checkout cache under the user's OS cache directory.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-minimal-footer",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "A minimal custom footer for pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-notify",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A pi extension that sends a notification when the agent is ready for input.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-openai-fast",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A pi extension that enables OpenAI Codex Fast mode for ChatGPT-auth GPT-5.4 and GPT-5.5 by injecting the priority service tier.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -75,6 +75,7 @@ const ORACLE_CONFIG_FILE = "oracle.json";
75
75
 
76
76
  const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
77
77
  "amazon-bedrock": [
78
+ "claude-opus-4-8",
78
79
  "claude-opus-4-7",
79
80
  "claude-opus-4-6",
80
81
  "claude-opus-4-5",
@@ -91,6 +92,8 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
91
92
  "zai.glm-5",
92
93
  ],
93
94
  anthropic: [
95
+ "claude-opus-4-8",
96
+ "claude-opus-4.8",
94
97
  "claude-opus-4-7",
95
98
  "claude-opus-4.7",
96
99
  "claude-opus-4-6",
@@ -156,6 +159,7 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
156
159
  "accounts/fireworks/models/gpt-oss-120b",
157
160
  ],
158
161
  "github-copilot": [
162
+ "claude-opus-4.8",
159
163
  "claude-opus-4.7",
160
164
  "claude-opus-4.6",
161
165
  "claude-opus-4.5",
@@ -257,6 +261,7 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
257
261
  "gpt-5.5",
258
262
  "gpt-5.4-pro",
259
263
  "gpt-5.4",
264
+ "claude-opus-4-8",
260
265
  "claude-opus-4-7",
261
266
  "claude-opus-4-6",
262
267
  "claude-opus-4-5",
@@ -284,6 +289,8 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
284
289
  "kimi-k2.5",
285
290
  ],
286
291
  openrouter: [
292
+ "anthropic/claude-opus-4.8",
293
+ "anthropic/claude-opus-4.8-fast",
287
294
  "anthropic/claude-opus-4.7",
288
295
  "anthropic/claude-opus-4.6-fast",
289
296
  "anthropic/claude-opus-4.6",
@@ -320,6 +327,7 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
320
327
  "MiniMaxAI/MiniMax-M2.5",
321
328
  ],
322
329
  "vercel-ai-gateway": [
330
+ "anthropic/claude-opus-4.8",
323
331
  "anthropic/claude-opus-4.7",
324
332
  "anthropic/claude-opus-4.6",
325
333
  "anthropic/claude-opus-4.5",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-oracle",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "An Amp-style oracle extension for pi that consults the strongest reasoning model on your current provider.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-permission-gate",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A pi extension that prompts before dangerous bash commands.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-quiet-tools",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A pi extension that visually compacts collapsed built-in tool rows in the TUI without changing tool results sent to the model.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-review",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A standalone pi extension that adds /review and /end-review commands adapted from mitsuhiko/agent-stuff.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-todo",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that adds a branch-aware todo tool and /todos viewer.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.76.0
1
+ 0.78.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-triage-comments",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A pi extension that adds /triage-comments and a read-only triage_comments subagent tool for review-comment triage.",
5
5
  "keywords": [
6
6
  "pi-package",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-extensions",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
4
4
  "description": "A collection of pi extensions for context management, workflow audits, review-comment triage, notifications, brrr push alerts, safety guards, GitHub research, repo-local knowledge, todos, tool rendering, and model/provider helpers.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -57,9 +57,9 @@
57
57
  "image": "https://raw.githubusercontent.com/diegopetrucci/pi-extensions/main/assets/oracle-preview.svg"
58
58
  },
59
59
  "devDependencies": {
60
- "@earendil-works/pi-ai": "^0.76.0",
61
- "@earendil-works/pi-coding-agent": "^0.76.0",
62
- "@earendil-works/pi-tui": "^0.76.0",
60
+ "@earendil-works/pi-ai": "^0.78.0",
61
+ "@earendil-works/pi-coding-agent": "^0.78.0",
62
+ "@earendil-works/pi-tui": "^0.78.0",
63
63
  "@types/node": "^25.9.1",
64
64
  "husky": "^9.1.7",
65
65
  "typebox": "^1.1.38",