@diegopetrucci/pi-extensions 0.1.50 → 0.1.52

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 (56) hide show
  1. package/.pi-fleet-tested-version +1 -1
  2. package/README.md +5 -2
  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/contrarian/.pi-fleet-tested-version +1 -0
  20. package/extensions/contrarian/README.md +85 -0
  21. package/extensions/contrarian/index.ts +1463 -0
  22. package/extensions/contrarian/package.json +37 -0
  23. package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
  24. package/extensions/dirty-repo-guard/package.json +1 -1
  25. package/extensions/git-footer/.pi-fleet-tested-version +1 -1
  26. package/extensions/git-footer/package.json +1 -1
  27. package/extensions/gnosis/.pi-fleet-tested-version +1 -1
  28. package/extensions/gnosis/package.json +1 -1
  29. package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -1
  30. package/extensions/illustrations-to-explain-things/package.json +1 -1
  31. package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
  32. package/extensions/inline-bash/package.json +1 -1
  33. package/extensions/librarian/.pi-fleet-tested-version +1 -1
  34. package/extensions/librarian/package.json +1 -1
  35. package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
  36. package/extensions/minimal-footer/README.md +32 -3
  37. package/extensions/minimal-footer/index.ts +539 -2
  38. package/extensions/minimal-footer/minimal-footer.example.json +6 -0
  39. package/extensions/minimal-footer/package.json +1 -1
  40. package/extensions/notify/.pi-fleet-tested-version +1 -1
  41. package/extensions/notify/package.json +1 -1
  42. package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
  43. package/extensions/openai-fast/package.json +1 -1
  44. package/extensions/oracle/.pi-fleet-tested-version +1 -1
  45. package/extensions/oracle/package.json +1 -1
  46. package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
  47. package/extensions/permission-gate/package.json +1 -1
  48. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  49. package/extensions/quiet-tools/package.json +1 -1
  50. package/extensions/review/.pi-fleet-tested-version +1 -1
  51. package/extensions/review/package.json +1 -1
  52. package/extensions/todo/.pi-fleet-tested-version +1 -1
  53. package/extensions/todo/package.json +1 -1
  54. package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
  55. package/extensions/triage-comments/package.json +1 -1
  56. package/package.json +3 -2
@@ -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