@diegopetrucci/pi-extensions 0.1.50 → 0.1.51

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 (52) hide show
  1. package/.pi-fleet-tested-version +1 -1
  2. package/README.md +1 -1
  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/annotate-git-diff/.pi-fleet-tested-version +1 -1
  6. package/extensions/annotate-git-diff/package.json +1 -1
  7. package/extensions/annotate-last-message/.pi-fleet-tested-version +1 -1
  8. package/extensions/annotate-last-message/package.json +1 -1
  9. package/extensions/brrr/.pi-fleet-tested-version +1 -1
  10. package/extensions/brrr/package.json +1 -1
  11. package/extensions/claude-fast/.pi-fleet-tested-version +1 -1
  12. package/extensions/claude-fast/package.json +1 -1
  13. package/extensions/confirm-destructive/.pi-fleet-tested-version +1 -1
  14. package/extensions/confirm-destructive/package.json +1 -1
  15. package/extensions/context-cap/.pi-fleet-tested-version +1 -1
  16. package/extensions/context-cap/package.json +1 -1
  17. package/extensions/context-inspector/.pi-fleet-tested-version +1 -1
  18. package/extensions/context-inspector/package.json +1 -1
  19. package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
  20. package/extensions/dirty-repo-guard/package.json +1 -1
  21. package/extensions/git-footer/.pi-fleet-tested-version +1 -1
  22. package/extensions/git-footer/package.json +1 -1
  23. package/extensions/gnosis/.pi-fleet-tested-version +1 -1
  24. package/extensions/gnosis/package.json +1 -1
  25. package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -1
  26. package/extensions/illustrations-to-explain-things/package.json +1 -1
  27. package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
  28. package/extensions/inline-bash/package.json +1 -1
  29. package/extensions/librarian/.pi-fleet-tested-version +1 -1
  30. package/extensions/librarian/package.json +1 -1
  31. package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
  32. package/extensions/minimal-footer/README.md +32 -3
  33. package/extensions/minimal-footer/index.ts +539 -2
  34. package/extensions/minimal-footer/minimal-footer.example.json +6 -0
  35. package/extensions/minimal-footer/package.json +1 -1
  36. package/extensions/notify/.pi-fleet-tested-version +1 -1
  37. package/extensions/notify/package.json +1 -1
  38. package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
  39. package/extensions/openai-fast/package.json +1 -1
  40. package/extensions/oracle/.pi-fleet-tested-version +1 -1
  41. package/extensions/oracle/package.json +1 -1
  42. package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
  43. package/extensions/permission-gate/package.json +1 -1
  44. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  45. package/extensions/quiet-tools/package.json +1 -1
  46. package/extensions/review/.pi-fleet-tested-version +1 -1
  47. package/extensions/review/package.json +1 -1
  48. package/extensions/todo/.pi-fleet-tested-version +1 -1
  49. package/extensions/todo/package.json +1 -1
  50. package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
  51. package/extensions/triage-comments/package.json +1 -1
  52. package/package.json +1 -1
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
package/README.md CHANGED
@@ -16,7 +16,7 @@ A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions
16
16
  - [`inline-bash`](./extensions/inline-bash): Expands `!{command}` snippets in user prompts by running them through bash before the prompt reaches the agent.
17
17
  - [`illustrations-to-explain-things`](./extensions/illustrations-to-explain-things): Adds a skill for generating clean, absurd Xiaohei-style article illustrations, shot lists, image edits, and visual metaphors.
18
18
  - [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache disabled by default under the OS user cache directory, toggleable with `/librarian-cache`, configurable subagent model/thinking defaults via `/librarian-config`, and cached repos expiring after 7 days of non-use.
19
- - [`minimal-footer`](./extensions/minimal-footer): Replaces pi's built-in footer with a minimal configurable two-line layout: branch/repo on the first line, context/model on the second, optional `DUMB ZONE`, optional `xp` marker, plus OpenAI Codex 5-hour and 7-day usage when available.
19
+ - [`minimal-footer`](./extensions/minimal-footer): Replaces pi's built-in footer with a minimal configurable two-line layout: branch plus cached git dirty/ahead/PR status and repo on the first line, context/model on the second, optional `DUMB ZONE`, optional `xp` marker, plus OpenAI Codex 5-hour and 7-day usage when available.
20
20
  - [`notify`](./extensions/notify): Sends configurable terminal, desktop, bell, and sound notifications when pi finishes and is ready for input.
21
21
  - [`openai-fast`](./extensions/openai-fast): Adds `/fast` to enable OpenAI Codex Fast mode for ChatGPT-auth GPT-5.4 and GPT-5.5 by injecting the priority service tier.
22
22
  - [`oracle`](./extensions/oracle): Adds an Amp-style read-only oracle tool that auto-selects the strongest reasoning model on the current provider/subscription, supports persisted `/oracle` model/thinking defaults, requests xhigh reasoning by default and clamps to model capabilities, and shows live status while running.
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-agent-workflow-audit",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-annotate-git-diff",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A standalone pi extension that adds /annotate-git-diff, a native Glimpse UI for annotating git diffs and sending feedback to the editor.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-annotate-last-message",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A standalone pi extension that adds /annotate-last-message, a native Glimpse UI for annotating the latest assistant reply.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-brrr",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-claude-fast",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-confirm-destructive",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A pi extension that confirms destructive session actions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-cap",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-inspector",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-dirty-repo-guard",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-git-footer",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A TLH-style git status add-on for pi's built-in footer.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-gnosis",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-illustrations-to-explain-things",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A pi skill for generating clean, absurd Xiaohei-style article illustrations, shot lists, and visual metaphors.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-inline-bash",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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.78.0
1
+ 0.79.10
@@ -7,6 +7,7 @@ A minimal custom footer for pi.
7
7
  It replaces pi's built-in footer with a cleaner two-line layout that focuses on the information I care about most:
8
8
 
9
9
  - current git branch
10
+ - TLH-style git dirty counts, ahead/behind counts, and optional PR number
10
11
  - current repo name
11
12
  - current context percentage
12
13
  - red `DUMB ZONE` indicator when context usage is above 200k tokens
@@ -19,7 +20,7 @@ It replaces pi's built-in footer with a cleaner two-line layout that focuses on
19
20
  On wide terminals it renders two lines:
20
21
 
21
22
  ```text
22
- <git-branch> <repo-name>
23
+ <git-branch> · <git-status> <repo-name>
23
24
  <context-%> <model> <thinking>
24
25
  ```
25
26
 
@@ -30,6 +31,14 @@ fix/remove-detached-image-tasks SendItToMy
30
31
  44.1% gpt-5.4 high
31
32
  ```
32
33
 
34
+ When the project is trusted and the repo has local or remote git state, the top-left line includes the same cached summary as `git-footer`:
35
+
36
+ ```text
37
+ main · +1 ~2 ?3 ↑1 • PR #42
38
+ ```
39
+
40
+ The markers are `!` conflicts, `+` staged, `~` unstaged, `?` untracked, `↑` ahead, and `↓` behind. PR numbers are fetched best-effort with `gh pr view`. Git polling is skipped until Pi reports that the project is trusted, and `git status` is run with fsmonitor disabled.
41
+
33
42
  When context usage is above 200k tokens, the bottom-left line includes a red warning:
34
43
 
35
44
  ```text
@@ -119,6 +128,12 @@ Example:
119
128
  "enabled": true,
120
129
  "label": "xp",
121
130
  "color": "warning"
131
+ },
132
+ "gitStatus": {
133
+ "enabled": true,
134
+ "refreshIntervalMs": 8000,
135
+ "gitTimeoutMs": 1500,
136
+ "ghTimeoutMs": 3000
122
137
  }
123
138
  }
124
139
  ```
@@ -169,6 +184,16 @@ Disable the experimental-features marker:
169
184
  }
170
185
  ```
171
186
 
187
+ Disable git dirty/ahead/PR status:
188
+
189
+ ```json
190
+ {
191
+ "gitStatus": {
192
+ "enabled": false
193
+ }
194
+ }
195
+ ```
196
+
172
197
  ### Config fields
173
198
 
174
199
  - `context.showPercent`: show the context percentage
@@ -186,10 +211,14 @@ Disable the experimental-features marker:
186
211
  - `experimentalMarker.enabled`: show the marker when `PI_EXPERIMENTAL=1`
187
212
  - `experimentalMarker.label`: marker text
188
213
  - `experimentalMarker.color`: theme color for the marker (`error`, `warning`, `accent`, `text`, or `dim`)
214
+ - `gitStatus.enabled`: show cached git dirty counts, ahead/behind counts, and optional PR number for trusted projects
215
+ - `gitStatus.refreshIntervalMs`: background git status refresh interval
216
+ - `gitStatus.gitTimeoutMs`: timeout for `git status --porcelain=v2 --branch`
217
+ - `gitStatus.ghTimeoutMs`: timeout for best-effort `gh pr view`
189
218
 
190
219
  ## What it shows
191
220
 
192
- - **Top left:** current git branch
221
+ - **Top left:** current git branch plus dirty/ahead/PR status when available
193
222
  - **Top right:** current repo directory name
194
223
  - **Bottom left:** current context usage percentage, plus red `DUMB ZONE` above 200k context tokens
195
224
  - **Bottom left on `openai-codex`:** current context usage percentage plus 5-hour and 7-day Codex usage
@@ -203,7 +232,7 @@ This extension also lives inside the broader [`pi-extensions`](../../README.md)
203
232
  ## Notes
204
233
 
205
234
  - Replaces pi's built-in footer entirely.
206
- - Uses pi footer data for git branch updates.
235
+ - Uses pi footer data for git branch updates, plus background cached git/gh checks for dirty counts and PR number after project trust is granted.
207
236
  - Shows only context percentage, not context window size.
208
237
  - Shows `DUMB ZONE` only while context usage is above 200k tokens.
209
238
  - Shows the model id rather than a provider-specific display label.
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
2
3
  import { basename, dirname, join } from "node:path";
3
4
  import {
4
5
  AuthStorage,
@@ -46,6 +47,12 @@ const DEFAULT_CONFIG: MinimalFooterConfig = {
46
47
  label: "xp",
47
48
  color: "warning",
48
49
  },
50
+ gitStatus: {
51
+ enabled: true,
52
+ refreshIntervalMs: 8_000,
53
+ gitTimeoutMs: 1_500,
54
+ ghTimeoutMs: 3_000,
55
+ },
49
56
  };
50
57
 
51
58
  const DUMB_ZONE_COLORS = new Set<DumbZoneColor>([
@@ -92,6 +99,12 @@ interface MinimalFooterConfig {
92
99
  label: string;
93
100
  color: DumbZoneColor;
94
101
  };
102
+ gitStatus: {
103
+ enabled: boolean;
104
+ refreshIntervalMs: number;
105
+ gitTimeoutMs: number;
106
+ ghTimeoutMs: number;
107
+ };
95
108
  }
96
109
 
97
110
  type UsageSessionState = {
@@ -103,6 +116,7 @@ type UsageSessionState = {
103
116
  error?: string;
104
117
  inflight?: Promise<void>;
105
118
  requestRender?: () => void;
119
+ gitCache?: GitFooterCache;
106
120
  };
107
121
 
108
122
  type ProjectConfigContext = {
@@ -110,6 +124,67 @@ type ProjectConfigContext = {
110
124
  isProjectTrusted?: () => boolean;
111
125
  };
112
126
 
127
+ type GitStatusSnapshot = {
128
+ branch?: string;
129
+ staged: number;
130
+ unstaged: number;
131
+ untracked: number;
132
+ conflict: number;
133
+ ahead: number;
134
+ behind: number;
135
+ };
136
+
137
+ type PullRequestSnapshot = {
138
+ number?: number | string;
139
+ state?: string;
140
+ isDraft?: boolean;
141
+ url?: string;
142
+ title?: string;
143
+ };
144
+
145
+ type CommandResult = {
146
+ stdout: string;
147
+ stderr: string;
148
+ exitCode: number | null;
149
+ };
150
+
151
+ type CommandRunner = (
152
+ command: string,
153
+ args: readonly string[],
154
+ options: { cwd: string; signal: AbortSignal },
155
+ ) => Promise<CommandResult>;
156
+
157
+ type TimerHandle = unknown;
158
+
159
+ type Clock = {
160
+ setInterval(callback: () => void, ms: number): TimerHandle;
161
+ clearInterval(handle: TimerHandle): void;
162
+ };
163
+
164
+ type GitFooterCacheOptions = {
165
+ cwd: () => string;
166
+ canRun?: () => boolean;
167
+ runner?: CommandRunner;
168
+ clock?: Clock;
169
+ refreshIntervalMs?: number;
170
+ gitTimeoutMs?: number;
171
+ ghTimeoutMs?: number;
172
+ onChange?: () => void;
173
+ };
174
+
175
+ const BRANCH_HEAD_PREFIX = "# branch.head ";
176
+ const BRANCH_AB_PREFIX = "# branch.ab ";
177
+ const STATUS_SEPARATOR = " • ";
178
+ const GIT_STATUS_ARGS = [
179
+ "--no-optional-locks",
180
+ "-c",
181
+ "core.fsmonitor=false",
182
+ "status",
183
+ "--porcelain=v2",
184
+ "--branch",
185
+ ] as const;
186
+ const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number,state,isDraft,url,title"] as const;
187
+
113
188
  function readConfigFile(path: string): RecursivePartial<MinimalFooterConfig> {
114
189
  if (!existsSync(path)) return {};
115
190
 
@@ -131,6 +206,7 @@ function mergeConfig(
131
206
  const primaryWindow = codexUsage?.windows?.primary;
132
207
  const secondaryWindow = codexUsage?.windows?.secondary;
133
208
  const experimentalMarker = overrides.experimentalMarker;
209
+ const gitStatus = overrides.gitStatus;
134
210
 
135
211
  return {
136
212
  context: {
@@ -183,6 +259,21 @@ function mergeConfig(
183
259
  base.experimentalMarker.color,
184
260
  ),
185
261
  },
262
+ gitStatus: {
263
+ enabled: normalizeBoolean(gitStatus?.enabled, base.gitStatus.enabled),
264
+ refreshIntervalMs: normalizePositiveNumber(
265
+ gitStatus?.refreshIntervalMs,
266
+ base.gitStatus.refreshIntervalMs,
267
+ ),
268
+ gitTimeoutMs: normalizePositiveNumber(
269
+ gitStatus?.gitTimeoutMs,
270
+ base.gitStatus.gitTimeoutMs,
271
+ ),
272
+ ghTimeoutMs: normalizePositiveNumber(
273
+ gitStatus?.ghTimeoutMs,
274
+ base.gitStatus.ghTimeoutMs,
275
+ ),
276
+ },
186
277
  };
187
278
  }
188
279
 
@@ -239,6 +330,410 @@ function shouldShowExperimentalMarker(config: MinimalFooterConfig): boolean {
239
330
  return config.experimentalMarker.enabled && process.env.PI_EXPERIMENTAL === "1";
240
331
  }
241
332
 
333
+ function shouldShowGitStatus(config: MinimalFooterConfig): boolean {
334
+ return config.gitStatus.enabled;
335
+ }
336
+
337
+ function canRunProjectGit(ctx: ProjectConfigContext): boolean {
338
+ return canReadProjectConfig(ctx);
339
+ }
340
+
341
+ function createEmptyGitStatus(): GitStatusSnapshot {
342
+ return {
343
+ branch: undefined,
344
+ staged: 0,
345
+ unstaged: 0,
346
+ untracked: 0,
347
+ conflict: 0,
348
+ ahead: 0,
349
+ behind: 0,
350
+ };
351
+ }
352
+
353
+ function positiveCount(value: number): number {
354
+ return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
355
+ }
356
+
357
+ function addTrackedStatusCounts(status: GitStatusSnapshot, xy: string): void {
358
+ if (xy.length !== 2) return;
359
+ if (xy[0] !== ".") status.staged += 1;
360
+ if (xy[1] !== ".") status.unstaged += 1;
361
+ }
362
+
363
+ function parseBranchAheadBehind(line: string, status: GitStatusSnapshot): void {
364
+ const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
365
+ if (!match) return;
366
+ status.ahead = Number.parseInt(match[1]!, 10);
367
+ status.behind = Number.parseInt(match[2]!, 10);
368
+ }
369
+
370
+ function normalizeBranchHead(value: string): string {
371
+ const branch = value.trim();
372
+ return branch === "(detached)" ? "detached" : branch;
373
+ }
374
+
375
+ function parseGitStatusPorcelainV2(output: string): GitStatusSnapshot {
376
+ const status = createEmptyGitStatus();
377
+
378
+ for (const rawLine of output.split("\n")) {
379
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
380
+ if (!line) continue;
381
+
382
+ if (line.startsWith(BRANCH_HEAD_PREFIX)) {
383
+ status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined;
384
+ continue;
385
+ }
386
+
387
+ if (line.startsWith(BRANCH_AB_PREFIX)) {
388
+ parseBranchAheadBehind(line, status);
389
+ continue;
390
+ }
391
+
392
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
393
+ addTrackedStatusCounts(status, line.slice(2, 4));
394
+ continue;
395
+ }
396
+
397
+ if (line.startsWith("u ")) {
398
+ status.conflict += 1;
399
+ continue;
400
+ }
401
+
402
+ if (line.startsWith("? ")) status.untracked += 1;
403
+ }
404
+
405
+ return status;
406
+ }
407
+
408
+ function formatGitStatusFooterSegment(status: GitStatusSnapshot | undefined): string | undefined {
409
+ if (!status) return undefined;
410
+
411
+ const parts: string[] = [];
412
+ const indicators: Array<[string, number]> = [
413
+ ["!", positiveCount(status.conflict)],
414
+ ["+", positiveCount(status.staged)],
415
+ ["~", positiveCount(status.unstaged)],
416
+ ["?", positiveCount(status.untracked)],
417
+ ["↑", positiveCount(status.ahead)],
418
+ ["↓", positiveCount(status.behind)],
419
+ ];
420
+
421
+ for (const [prefix, count] of indicators) {
422
+ if (count > 0) parts.push(`${prefix}${count}`);
423
+ }
424
+
425
+ return parts.length > 0 ? parts.join(" ") : undefined;
426
+ }
427
+
428
+ function formatPullRequestFooterSegment(pullRequest: PullRequestSnapshot | undefined): string | undefined {
429
+ const value = pullRequest?.number;
430
+ if (Number.isSafeInteger(value) && Number(value) > 0) return `PR #${value}`;
431
+ if (typeof value === "string") {
432
+ const trimmed = value.trim();
433
+ if (/^[1-9]\d*$/.test(trimmed)) return `PR #${trimmed}`;
434
+ }
435
+ return undefined;
436
+ }
437
+
438
+ function formatGitFooterStatus(
439
+ status: GitStatusSnapshot | undefined,
440
+ pullRequest: PullRequestSnapshot | undefined,
441
+ ): string | undefined {
442
+ const parts = [
443
+ formatGitStatusFooterSegment(status),
444
+ formatPullRequestFooterSegment(pullRequest),
445
+ ].filter((part): part is string => !!part);
446
+ return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined;
447
+ }
448
+
449
+ function parsePullRequestJson(stdout: string): PullRequestSnapshot | undefined {
450
+ const trimmed = stdout.trim();
451
+ if (!trimmed) return undefined;
452
+ let parsed: unknown;
453
+ try {
454
+ parsed = JSON.parse(trimmed);
455
+ } catch {
456
+ return undefined;
457
+ }
458
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
459
+
460
+ const record = parsed as Record<string, unknown>;
461
+ const snapshot: PullRequestSnapshot = {};
462
+ if (typeof record.number === "number" || typeof record.number === "string") {
463
+ snapshot.number = record.number;
464
+ }
465
+ if (typeof record.state === "string") snapshot.state = record.state;
466
+ if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
467
+ if (typeof record.url === "string") snapshot.url = record.url;
468
+ if (typeof record.title === "string") snapshot.title = record.title;
469
+ return snapshot;
470
+ }
471
+
472
+ function gitStatusSnapshotsEqual(
473
+ left: GitStatusSnapshot | undefined,
474
+ right: GitStatusSnapshot | undefined,
475
+ ): boolean {
476
+ return (
477
+ left?.branch === right?.branch
478
+ && left?.staged === right?.staged
479
+ && left?.unstaged === right?.unstaged
480
+ && left?.untracked === right?.untracked
481
+ && left?.conflict === right?.conflict
482
+ && left?.ahead === right?.ahead
483
+ && left?.behind === right?.behind
484
+ );
485
+ }
486
+
487
+ function pullRequestSnapshotsEqual(
488
+ left: PullRequestSnapshot | undefined,
489
+ right: PullRequestSnapshot | undefined,
490
+ ): boolean {
491
+ return (
492
+ left?.number === right?.number
493
+ && left?.state === right?.state
494
+ && left?.isDraft === right?.isDraft
495
+ && left?.url === right?.url
496
+ && left?.title === right?.title
497
+ );
498
+ }
499
+
500
+ function defaultRunner(
501
+ command: string,
502
+ args: readonly string[],
503
+ options: { cwd: string; signal: AbortSignal },
504
+ ): Promise<CommandResult> {
505
+ return new Promise((resolve, reject) => {
506
+ let child;
507
+ try {
508
+ child = spawn(command, [...args], {
509
+ cwd: options.cwd,
510
+ stdio: ["ignore", "pipe", "pipe"],
511
+ windowsHide: true,
512
+ });
513
+ } catch (error) {
514
+ reject(error);
515
+ return;
516
+ }
517
+
518
+ let stdout = "";
519
+ let stderr = "";
520
+ let settled = false;
521
+
522
+ const finish = (result: CommandResult | Error) => {
523
+ if (settled) return;
524
+ settled = true;
525
+ options.signal.removeEventListener("abort", onAbort);
526
+ if (result instanceof Error) reject(result);
527
+ else resolve(result);
528
+ };
529
+
530
+ const onAbort = () => {
531
+ try {
532
+ child.kill("SIGTERM");
533
+ } catch {
534
+ // Ignore: process may already be gone.
535
+ }
536
+ finish(new Error("aborted"));
537
+ };
538
+
539
+ child.stdout?.on("data", (chunk: Buffer | string) => {
540
+ stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
541
+ });
542
+ child.stderr?.on("data", (chunk: Buffer | string) => {
543
+ stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
544
+ });
545
+ child.on("error", finish);
546
+ child.on("close", (code) => finish({ stdout, stderr, exitCode: code }));
547
+
548
+ if (options.signal.aborted) {
549
+ onAbort();
550
+ return;
551
+ }
552
+ options.signal.addEventListener("abort", onAbort, { once: true });
553
+ });
554
+ }
555
+
556
+ function defaultClock(): Clock {
557
+ return {
558
+ setInterval(callback, ms) {
559
+ const handle = setInterval(callback, ms);
560
+ (handle as { unref?: () => void }).unref?.();
561
+ return handle;
562
+ },
563
+ clearInterval(handle) {
564
+ clearInterval(handle as ReturnType<typeof setInterval>);
565
+ },
566
+ };
567
+ }
568
+
569
+ class GitFooterCache {
570
+ private readonly cwd: () => string;
571
+ private readonly canRun: () => boolean;
572
+ private readonly runner: CommandRunner;
573
+ private readonly clock: Clock;
574
+ private readonly refreshIntervalMs: number;
575
+ private readonly gitTimeoutMs: number;
576
+ private readonly ghTimeoutMs: number;
577
+ private readonly onChange: (() => void) | undefined;
578
+
579
+ private intervalHandle: TimerHandle | undefined;
580
+ private readonly inflightControllers = new Set<AbortController>();
581
+ private disposed = false;
582
+ private refreshInFlight: Promise<void> | undefined;
583
+ private statusSnapshot: GitStatusSnapshot | undefined;
584
+ private pullRequestSnapshot: PullRequestSnapshot | undefined;
585
+ private lastSeenBranch: string | undefined;
586
+
587
+ constructor(options: GitFooterCacheOptions) {
588
+ this.cwd = options.cwd;
589
+ this.canRun = options.canRun ?? (() => true);
590
+ this.runner = options.runner ?? defaultRunner;
591
+ this.clock = options.clock ?? defaultClock();
592
+ this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_CONFIG.gitStatus.refreshIntervalMs;
593
+ this.gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_CONFIG.gitStatus.gitTimeoutMs;
594
+ this.ghTimeoutMs = options.ghTimeoutMs ?? DEFAULT_CONFIG.gitStatus.ghTimeoutMs;
595
+ this.onChange = options.onChange;
596
+
597
+ this.intervalHandle = this.clock.setInterval(() => {
598
+ void this.refresh();
599
+ }, this.refreshIntervalMs);
600
+ void this.refresh();
601
+ }
602
+
603
+ getStatusSnapshot(): GitStatusSnapshot | undefined {
604
+ return this.statusSnapshot;
605
+ }
606
+
607
+ getPullRequestSnapshot(): PullRequestSnapshot | undefined {
608
+ return this.pullRequestSnapshot;
609
+ }
610
+
611
+ refresh(): Promise<void> {
612
+ if (this.disposed) return Promise.resolve();
613
+ if (!this.canRun()) {
614
+ const previousStatusSnapshot = this.statusSnapshot;
615
+ const previousPullRequestSnapshot = this.pullRequestSnapshot;
616
+ this.statusSnapshot = undefined;
617
+ this.pullRequestSnapshot = undefined;
618
+ this.lastSeenBranch = undefined;
619
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
620
+ return Promise.resolve();
621
+ }
622
+ if (this.refreshInFlight) return this.refreshInFlight;
623
+ const run = this.runRefresh()
624
+ .finally(() => {
625
+ this.refreshInFlight = undefined;
626
+ })
627
+ .catch(() => undefined);
628
+ this.refreshInFlight = run;
629
+ return run;
630
+ }
631
+
632
+ private async runRefresh(): Promise<void> {
633
+ const previousStatusSnapshot = this.statusSnapshot;
634
+ const previousPullRequestSnapshot = this.pullRequestSnapshot;
635
+
636
+ const result = await this.fetchGitStatus();
637
+ if (this.disposed) return;
638
+ if (result.kind === "transient") return;
639
+ if (result.kind === "not-a-repo") {
640
+ this.statusSnapshot = undefined;
641
+ this.pullRequestSnapshot = undefined;
642
+ this.lastSeenBranch = undefined;
643
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
644
+ return;
645
+ }
646
+
647
+ const status = result.status;
648
+ this.statusSnapshot = status;
649
+
650
+ const branch = typeof status.branch === "string" ? status.branch : undefined;
651
+ const isValidBranch = !!branch && branch !== "detached";
652
+ const branchChanged = branch !== this.lastSeenBranch;
653
+
654
+ if (branchChanged) this.pullRequestSnapshot = undefined;
655
+ this.lastSeenBranch = branch;
656
+
657
+ if (!isValidBranch) {
658
+ this.pullRequestSnapshot = undefined;
659
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
660
+ return;
661
+ }
662
+
663
+ const pr = await this.fetchPullRequest();
664
+ if (this.disposed) return;
665
+ if (pr !== undefined) this.pullRequestSnapshot = pr;
666
+ else if (branchChanged) this.pullRequestSnapshot = undefined;
667
+
668
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
669
+ }
670
+
671
+ private emitChangeIfSnapshotsChanged(
672
+ previousStatusSnapshot: GitStatusSnapshot | undefined,
673
+ previousPullRequestSnapshot: PullRequestSnapshot | undefined,
674
+ ): void {
675
+ if (this.disposed) return;
676
+ if (
677
+ gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot)
678
+ && pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot)
679
+ ) {
680
+ return;
681
+ }
682
+ try {
683
+ this.onChange?.();
684
+ } catch {
685
+ // Rendering hooks should not break refreshes.
686
+ }
687
+ }
688
+
689
+ private async fetchGitStatus(): Promise<
690
+ | { kind: "ok"; status: GitStatusSnapshot }
691
+ | { kind: "not-a-repo" }
692
+ | { kind: "transient" }
693
+ > {
694
+ const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
695
+ if (!result) return { kind: "transient" };
696
+ if (result.exitCode !== 0) return { kind: "not-a-repo" };
697
+ return { kind: "ok", status: parseGitStatusPorcelainV2(result.stdout) };
698
+ }
699
+
700
+ private async fetchPullRequest(): Promise<PullRequestSnapshot | undefined> {
701
+ const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
702
+ if (!result || result.exitCode !== 0) return undefined;
703
+ return parsePullRequestJson(result.stdout);
704
+ }
705
+
706
+ private async runCommandSafely(
707
+ command: string,
708
+ args: readonly string[],
709
+ timeoutMs: number,
710
+ ): Promise<CommandResult | undefined> {
711
+ if (this.disposed) return undefined;
712
+ const controller = new AbortController();
713
+ this.inflightControllers.add(controller);
714
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
715
+ try {
716
+ return await this.runner(command, args, { cwd: this.cwd(), signal: controller.signal });
717
+ } catch {
718
+ return undefined;
719
+ } finally {
720
+ clearTimeout(timeoutId);
721
+ this.inflightControllers.delete(controller);
722
+ }
723
+ }
724
+
725
+ dispose(): void {
726
+ if (this.disposed) return;
727
+ this.disposed = true;
728
+ if (this.intervalHandle !== undefined) {
729
+ this.clock.clearInterval(this.intervalHandle);
730
+ this.intervalHandle = undefined;
731
+ }
732
+ for (const controller of this.inflightControllers) controller.abort();
733
+ this.inflightControllers.clear();
734
+ }
735
+ }
736
+
242
737
  function clearUsageState(state: UsageSessionState): void {
243
738
  state.snapshot = undefined;
244
739
  state.lastFetchedAt = undefined;
@@ -302,6 +797,28 @@ async function refreshUsageIfNeeded(
302
797
  export default function (pi: ExtensionAPI) {
303
798
  const states = new WeakMap<object, UsageSessionState>();
304
799
 
800
+ function disposeGitCache(state: UsageSessionState): void {
801
+ state.gitCache?.dispose();
802
+ state.gitCache = undefined;
803
+ }
804
+
805
+ function ensureGitCache(ctx: ExtensionContext, state: UsageSessionState): void {
806
+ if (!shouldShowGitStatus(state.config) || !canRunProjectGit(ctx)) {
807
+ disposeGitCache(state);
808
+ return;
809
+ }
810
+
811
+ if (state.gitCache) return;
812
+ state.gitCache = new GitFooterCache({
813
+ cwd: () => ctx.cwd,
814
+ canRun: () => canRunProjectGit(ctx),
815
+ refreshIntervalMs: state.config.gitStatus.refreshIntervalMs,
816
+ gitTimeoutMs: state.config.gitStatus.gitTimeoutMs,
817
+ ghTimeoutMs: state.config.gitStatus.ghTimeoutMs,
818
+ onChange: () => state.requestRender?.(),
819
+ });
820
+ }
821
+
305
822
  pi.on("session_start", (_event, ctx) => {
306
823
  const state: UsageSessionState = {
307
824
  authStorage: AuthStorage.create(),
@@ -310,6 +827,8 @@ export default function (pi: ExtensionAPI) {
310
827
  };
311
828
  states.set(ctx.sessionManager, state);
312
829
 
830
+ ensureGitCache(ctx, state);
831
+
313
832
  ctx.ui.setFooter((tui, theme, footerData) => {
314
833
  const unsub = footerData.onBranchChange(() => tui.requestRender());
315
834
  state.requestRender = () => tui.requestRender();
@@ -318,12 +837,22 @@ export default function (pi: ExtensionAPI) {
318
837
  return {
319
838
  dispose() {
320
839
  if (state.requestRender) state.requestRender = undefined;
840
+ disposeGitCache(state);
321
841
  unsub();
322
842
  },
323
843
  invalidate() {},
324
844
  render(width: number): string[] {
325
845
  const repo = basename(ctx.cwd);
326
- const branch = footerData.getGitBranch() ?? "";
846
+ const gitStatus = shouldShowGitStatus(state.config)
847
+ ? formatGitFooterStatus(
848
+ state.gitCache?.getStatusSnapshot(),
849
+ state.gitCache?.getPullRequestSnapshot(),
850
+ )
851
+ : undefined;
852
+ const branch = footerData.getGitBranch()
853
+ ?? state.gitCache?.getStatusSnapshot()?.branch
854
+ ?? "";
855
+ const branchText = gitStatus ? [branch, gitStatus].filter(Boolean).join(" · ") : branch;
327
856
 
328
857
  const usage = ctx.getContextUsage();
329
858
  const context = usage?.percent == null ? "?" : `${usage.percent.toFixed(1)}%`;
@@ -337,7 +866,7 @@ export default function (pi: ExtensionAPI) {
337
866
  const thinking = pi.getThinkingLevel();
338
867
  const modelText = thinking === "off" ? model : `${model} ${thinking}`;
339
868
 
340
- const branchStyled = theme.fg("dim", branch);
869
+ const branchStyled = theme.fg("dim", branchText);
341
870
  const repoStyled = theme.fg("dim", repo);
342
871
  const contextParts: string[] = [];
343
872
  if (state.config.context.showPercent) contextParts.push(theme.fg("dim", context));
@@ -386,5 +915,13 @@ export default function (pi: ExtensionAPI) {
386
915
  const state = states.get(ctx.sessionManager);
387
916
  if (!state) return;
388
917
  void refreshUsageIfNeeded(ctx, state);
918
+ ensureGitCache(ctx, state);
919
+ void state.gitCache?.refresh();
920
+ });
921
+
922
+ pi.on("session_shutdown", (_event, ctx) => {
923
+ const state = states.get(ctx.sessionManager);
924
+ if (!state) return;
925
+ disposeGitCache(state);
389
926
  });
390
927
  }
@@ -27,5 +27,11 @@
27
27
  "enabled": true,
28
28
  "label": "xp",
29
29
  "color": "warning"
30
+ },
31
+ "gitStatus": {
32
+ "enabled": true,
33
+ "refreshIntervalMs": 8000,
34
+ "gitTimeoutMs": 1500,
35
+ "ghTimeoutMs": 3000
30
36
  }
31
37
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-minimal-footer",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "A minimal custom footer for pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-notify",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-openai-fast",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-oracle",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-permission-gate",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A pi extension that prompts before dangerous bash commands.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-quiet-tools",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-review",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-todo",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.78.0
1
+ 0.79.10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-triage-comments",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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.50",
3
+ "version": "0.1.51",
4
4
  "description": "A collection of pi extensions and skills for annotation UIs, context management, workflow audits, review-comment triage, notifications, brrr push alerts, safety guards, GitHub research, repo-local knowledge, todos, tool rendering, model/provider helpers, and Xiaohei-style article illustrations.",
5
5
  "keywords": [
6
6
  "pi-package",