@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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 (108) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +17 -12
  3. package/extensions/agent-workflow-audit/README.md +1 -1
  4. package/extensions/agent-workflow-audit/index.ts +1631 -1009
  5. package/extensions/agent-workflow-audit/package.json +1 -1
  6. package/extensions/annotate-git-diff/README.md +3 -1
  7. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  8. package/extensions/annotate-git-diff/git.ts +846 -768
  9. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  10. package/extensions/annotate-git-diff/index.ts +712 -671
  11. package/extensions/annotate-git-diff/package.json +1 -1
  12. package/extensions/annotate-git-diff/prompt.ts +45 -45
  13. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  14. package/extensions/annotate-git-diff/review-server.ts +337 -311
  15. package/extensions/annotate-git-diff/types.ts +132 -132
  16. package/extensions/annotate-git-diff/ui.ts +27 -27
  17. package/extensions/annotate-git-diff/watch.ts +74 -74
  18. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  19. package/extensions/annotate-git-diff/web/index.html +5 -6
  20. package/extensions/annotate-last-message/README.md +1 -1
  21. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  22. package/extensions/annotate-last-message/index.ts +210 -206
  23. package/extensions/annotate-last-message/package.json +1 -1
  24. package/extensions/annotate-last-message/prompt.ts +87 -66
  25. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  26. package/extensions/annotate-last-message/session.ts +94 -89
  27. package/extensions/annotate-last-message/theme.ts +199 -193
  28. package/extensions/annotate-last-message/types.ts +23 -23
  29. package/extensions/annotate-last-message/ui.ts +27 -25
  30. package/extensions/annotate-last-message/web/app.js +4 -7
  31. package/extensions/annotate-last-message/web/index.html +3 -3
  32. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  33. package/extensions/brrr/README.md +1 -1
  34. package/extensions/brrr/index.ts +213 -191
  35. package/extensions/brrr/package.json +1 -1
  36. package/extensions/claude-fast/README.md +5 -79
  37. package/extensions/claude-fast/index.ts +31 -289
  38. package/extensions/claude-fast/package.json +2 -3
  39. package/extensions/code-reviewer/README.md +6 -6
  40. package/extensions/code-reviewer/index.ts +1825 -1373
  41. package/extensions/code-reviewer/package.json +1 -1
  42. package/extensions/confirm-destructive/README.md +1 -1
  43. package/extensions/confirm-destructive/index.ts +50 -52
  44. package/extensions/confirm-destructive/package.json +1 -1
  45. package/extensions/context-cap/README.md +9 -9
  46. package/extensions/context-cap/index.ts +313 -305
  47. package/extensions/context-cap/package.json +2 -2
  48. package/extensions/context-inspector/index.ts +1211 -1064
  49. package/extensions/context-inspector/package.json +1 -1
  50. package/extensions/contrarian/README.md +4 -3
  51. package/extensions/contrarian/index.ts +1863 -1737
  52. package/extensions/contrarian/package.json +1 -1
  53. package/extensions/dirty-repo-guard/index.ts +37 -41
  54. package/extensions/dirty-repo-guard/package.json +1 -1
  55. package/extensions/dynamic-context-pruning/README.md +11 -43
  56. package/extensions/dynamic-context-pruning/index.ts +2131 -2105
  57. package/extensions/dynamic-context-pruning/package.json +1 -1
  58. package/extensions/fast/README.md +52 -14
  59. package/extensions/fast/index.ts +513 -317
  60. package/extensions/fast/package.json +1 -1
  61. package/extensions/git-footer/README.md +1 -1
  62. package/extensions/git-footer/index.ts +458 -411
  63. package/extensions/git-footer/package.json +2 -3
  64. package/extensions/gnosis/index.ts +180 -130
  65. package/extensions/gnosis/package.json +1 -1
  66. package/extensions/inline-bash/index.ts +87 -103
  67. package/extensions/inline-bash/package.json +1 -1
  68. package/extensions/librarian/README.md +1 -1
  69. package/extensions/librarian/index.ts +1877 -1419
  70. package/extensions/librarian/package.json +1 -1
  71. package/extensions/minimal-footer/README.md +5 -5
  72. package/extensions/minimal-footer/index.ts +987 -1012
  73. package/extensions/minimal-footer/minimal-footer.example.json +1 -1
  74. package/extensions/minimal-footer/openai-usage.ts +195 -190
  75. package/extensions/minimal-footer/package.json +1 -1
  76. package/extensions/notify/index.ts +222 -230
  77. package/extensions/notify/package.json +1 -1
  78. package/extensions/openai-fast/README.md +5 -73
  79. package/extensions/openai-fast/index.ts +32 -272
  80. package/extensions/openai-fast/package.json +2 -3
  81. package/extensions/oracle/README.md +4 -3
  82. package/extensions/oracle/index.ts +1740 -1627
  83. package/extensions/oracle/package.json +1 -1
  84. package/extensions/permission-gate/README.md +3 -0
  85. package/extensions/permission-gate/index.ts +785 -612
  86. package/extensions/permission-gate/package.json +1 -1
  87. package/extensions/permission-gate/powershell-safety.ts +840 -793
  88. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  89. package/extensions/project-mcp-json/README.md +199 -0
  90. package/extensions/project-mcp-json/config.ts +564 -0
  91. package/extensions/project-mcp-json/index.ts +119 -0
  92. package/extensions/project-mcp-json/package.json +38 -0
  93. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  94. package/extensions/quiet-tools/README.md +30 -19
  95. package/extensions/quiet-tools/index.ts +376 -291
  96. package/extensions/quiet-tools/package.json +2 -2
  97. package/extensions/review/README.md +2 -2
  98. package/extensions/review/index.ts +1745 -1872
  99. package/extensions/review/package.json +1 -1
  100. package/extensions/todo/index.ts +280 -280
  101. package/extensions/todo/package.json +1 -1
  102. package/extensions/triage-comments/README.md +6 -4
  103. package/extensions/triage-comments/index.ts +2620 -1888
  104. package/extensions/triage-comments/package.json +1 -1
  105. package/package.json +9 -6
  106. package/assets/social-preview.png +0 -0
  107. package/extensions/claude-fast/claude-fast.example.json +0 -4
  108. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -2,55 +2,61 @@ import { spawn } from "node:child_process";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  type GitStatusSnapshot = {
5
- branch?: string;
6
- staged: number;
7
- unstaged: number;
8
- untracked: number;
9
- conflict: number;
10
- ahead: number;
11
- behind: number;
5
+ branch?: string;
6
+ staged: number;
7
+ unstaged: number;
8
+ untracked: number;
9
+ conflict: number;
10
+ ahead: number;
11
+ behind: number;
12
12
  };
13
13
 
14
14
  type PullRequestSnapshot = {
15
- number?: number | string;
16
- state?: string;
17
- isDraft?: boolean;
18
- url?: string;
19
- title?: string;
15
+ number?: number | string;
16
+ state?: string;
17
+ isDraft?: boolean;
18
+ url?: string;
19
+ title?: string;
20
20
  };
21
21
 
22
22
  type CommandResult = {
23
- stdout: string;
24
- stderr: string;
25
- exitCode: number | null;
23
+ stdout: string;
24
+ stderr: string;
25
+ exitCode: number | null;
26
26
  };
27
27
 
28
28
  type CommandRunner = (
29
- command: string,
30
- args: readonly string[],
31
- options: { cwd: string; signal: AbortSignal },
29
+ command: string,
30
+ args: readonly string[],
31
+ options: { cwd: string; signal: AbortSignal },
32
32
  ) => Promise<CommandResult>;
33
33
 
34
34
  type RunCommandSafelyResult =
35
- | { kind: "ok"; result: CommandResult }
36
- | { kind: "transient" }
37
- | { kind: "unavailable" };
35
+ | { kind: "ok"; result: CommandResult }
36
+ | { kind: "transient" }
37
+ | { kind: "unavailable" };
38
38
 
39
39
  type TimerHandle = unknown;
40
40
 
41
41
  type Clock = {
42
- setInterval(callback: () => void, ms: number): TimerHandle;
43
- clearInterval(handle: TimerHandle): void;
42
+ setInterval(callback: () => void, ms: number): TimerHandle;
43
+ clearInterval(handle: TimerHandle): void;
44
+ };
45
+
46
+ type ProjectGitContext = {
47
+ cwd: string;
48
+ isProjectTrusted?: () => boolean;
44
49
  };
45
50
 
46
51
  type GitFooterCacheOptions = {
47
- cwd: () => string;
48
- runner?: CommandRunner;
49
- clock?: Clock;
50
- refreshIntervalMs?: number;
51
- gitTimeoutMs?: number;
52
- ghTimeoutMs?: number;
53
- onChange?: () => void;
52
+ cwd: () => string;
53
+ canRun?: () => boolean;
54
+ runner?: CommandRunner;
55
+ clock?: Clock;
56
+ refreshIntervalMs?: number;
57
+ gitTimeoutMs?: number;
58
+ ghTimeoutMs?: number;
59
+ onChange?: () => void;
54
60
  };
55
61
 
56
62
  const BRANCH_HEAD_PREFIX = "# branch.head ";
@@ -60,449 +66,490 @@ const STATUS_SEPARATOR = " • ";
60
66
  const DEFAULT_REFRESH_INTERVAL_MS = 8_000;
61
67
  const DEFAULT_GIT_TIMEOUT_MS = 1_500;
62
68
  const DEFAULT_GH_TIMEOUT_MS = 3_000;
63
- const GIT_STATUS_ARGS = ["--no-optional-locks", "status", "--porcelain=v2", "--branch"] as const;
69
+ const GIT_STATUS_ARGS = [
70
+ "--no-optional-locks",
71
+ "-c",
72
+ "core.fsmonitor=false",
73
+ "status",
74
+ "--porcelain=v2",
75
+ "--branch",
76
+ ] as const;
64
77
  const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number,state,isDraft,url,title"] as const;
65
78
 
66
79
  function createEmptyGitStatus(): GitStatusSnapshot {
67
- return {
68
- branch: undefined,
69
- staged: 0,
70
- unstaged: 0,
71
- untracked: 0,
72
- conflict: 0,
73
- ahead: 0,
74
- behind: 0,
75
- };
80
+ return {
81
+ branch: undefined,
82
+ staged: 0,
83
+ unstaged: 0,
84
+ untracked: 0,
85
+ conflict: 0,
86
+ ahead: 0,
87
+ behind: 0,
88
+ };
76
89
  }
77
90
 
78
91
  function positiveCount(value: number): number {
79
- return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
92
+ return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
80
93
  }
81
94
 
82
95
  function addTrackedStatusCounts(status: GitStatusSnapshot, xy: string): void {
83
- if (xy.length !== 2) return;
84
- if (xy[0] !== ".") status.staged += 1;
85
- if (xy[1] !== ".") status.unstaged += 1;
96
+ if (xy.length !== 2) return;
97
+ if (xy[0] !== ".") status.staged += 1;
98
+ if (xy[1] !== ".") status.unstaged += 1;
86
99
  }
87
100
 
88
101
  function parseBranchAheadBehind(line: string, status: GitStatusSnapshot): void {
89
- const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
90
- if (!match) return;
91
- status.ahead = Number.parseInt(match[1]!, 10);
92
- status.behind = Number.parseInt(match[2]!, 10);
102
+ const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
103
+ if (!match) return;
104
+ status.ahead = Number.parseInt(match[1]!, 10);
105
+ status.behind = Number.parseInt(match[2]!, 10);
93
106
  }
94
107
 
95
108
  function normalizeBranchHead(value: string): string {
96
- const branch = value.trim();
97
- return branch === "(detached)" ? "detached" : branch;
109
+ const branch = value.trim();
110
+ return branch === "(detached)" ? "detached" : branch;
98
111
  }
99
112
 
100
113
  function parseGitStatusPorcelainV2(output: string): GitStatusSnapshot {
101
- const status = createEmptyGitStatus();
114
+ const status = createEmptyGitStatus();
102
115
 
103
- for (const rawLine of output.split("\n")) {
104
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
105
- if (!line) continue;
116
+ for (const rawLine of output.split("\n")) {
117
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
118
+ if (!line) continue;
106
119
 
107
- if (line.startsWith(BRANCH_HEAD_PREFIX)) {
108
- status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined;
109
- continue;
110
- }
120
+ if (line.startsWith(BRANCH_HEAD_PREFIX)) {
121
+ status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined;
122
+ continue;
123
+ }
111
124
 
112
- if (line.startsWith(BRANCH_AB_PREFIX)) {
113
- parseBranchAheadBehind(line, status);
114
- continue;
115
- }
125
+ if (line.startsWith(BRANCH_AB_PREFIX)) {
126
+ parseBranchAheadBehind(line, status);
127
+ continue;
128
+ }
116
129
 
117
- if (line.startsWith("1 ") || line.startsWith("2 ")) {
118
- addTrackedStatusCounts(status, line.slice(2, 4));
119
- continue;
120
- }
130
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
131
+ addTrackedStatusCounts(status, line.slice(2, 4));
132
+ continue;
133
+ }
121
134
 
122
- if (line.startsWith("u ")) {
123
- status.conflict += 1;
124
- continue;
125
- }
135
+ if (line.startsWith("u ")) {
136
+ status.conflict += 1;
137
+ continue;
138
+ }
126
139
 
127
- if (line.startsWith("? ")) status.untracked += 1;
128
- }
140
+ if (line.startsWith("? ")) status.untracked += 1;
141
+ }
129
142
 
130
- return status;
143
+ return status;
131
144
  }
132
145
 
133
146
  function formatGitStatusFooterSegment(status: GitStatusSnapshot | undefined): string | undefined {
134
- if (!status) return undefined;
135
-
136
- const parts: string[] = [];
137
- const indicators: Array<[string, number]> = [
138
- ["!", positiveCount(status.conflict)],
139
- ["+", positiveCount(status.staged)],
140
- ["~", positiveCount(status.unstaged)],
141
- ["?", positiveCount(status.untracked)],
142
- ["↑", positiveCount(status.ahead)],
143
- ["↓", positiveCount(status.behind)],
144
- ];
145
-
146
- for (const [prefix, count] of indicators) {
147
- if (count > 0) parts.push(`${prefix}${count}`);
148
- }
149
-
150
- return parts.length > 0 ? parts.join(" ") : undefined;
147
+ if (!status) return undefined;
148
+
149
+ const parts: string[] = [];
150
+ const indicators: Array<[string, number]> = [
151
+ ["!", positiveCount(status.conflict)],
152
+ ["+", positiveCount(status.staged)],
153
+ ["~", positiveCount(status.unstaged)],
154
+ ["?", positiveCount(status.untracked)],
155
+ ["↑", positiveCount(status.ahead)],
156
+ ["↓", positiveCount(status.behind)],
157
+ ];
158
+
159
+ for (const [prefix, count] of indicators) {
160
+ if (count > 0) parts.push(`${prefix}${count}`);
161
+ }
162
+
163
+ return parts.length > 0 ? parts.join(" ") : undefined;
151
164
  }
152
165
 
153
- function formatPullRequestFooterSegment(pullRequest: PullRequestSnapshot | undefined): string | undefined {
154
- const value = pullRequest?.number;
155
- if (Number.isSafeInteger(value) && Number(value) > 0) return `PR #${value}`;
156
- if (typeof value === "string") {
157
- const trimmed = value.trim();
158
- if (/^[1-9]\d*$/.test(trimmed)) return `PR #${trimmed}`;
159
- }
160
- return undefined;
166
+ function formatPullRequestFooterSegment(
167
+ pullRequest: PullRequestSnapshot | undefined,
168
+ ): string | undefined {
169
+ const value = pullRequest?.number;
170
+ if (Number.isSafeInteger(value) && Number(value) > 0) return `PR #${value}`;
171
+ if (typeof value === "string") {
172
+ const trimmed = value.trim();
173
+ if (/^[1-9]\d*$/.test(trimmed)) return `PR #${trimmed}`;
174
+ }
175
+ return undefined;
161
176
  }
162
177
 
163
178
  function formatGitFooterStatus(
164
- status: GitStatusSnapshot | undefined,
165
- pullRequest: PullRequestSnapshot | undefined,
179
+ status: GitStatusSnapshot | undefined,
180
+ pullRequest: PullRequestSnapshot | undefined,
166
181
  ): string | undefined {
167
- const parts = [
168
- formatGitStatusFooterSegment(status),
169
- formatPullRequestFooterSegment(pullRequest),
170
- ].filter((part): part is string => !!part);
171
- return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined;
182
+ const parts = [
183
+ formatGitStatusFooterSegment(status),
184
+ formatPullRequestFooterSegment(pullRequest),
185
+ ].filter((part): part is string => !!part);
186
+ return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined;
172
187
  }
173
188
 
174
189
  function parsePullRequestJson(stdout: string): PullRequestSnapshot | undefined {
175
- const trimmed = stdout.trim();
176
- if (!trimmed) return undefined;
177
- let parsed: unknown;
178
- try {
179
- parsed = JSON.parse(trimmed);
180
- } catch {
181
- return undefined;
182
- }
183
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
184
-
185
- const record = parsed as Record<string, unknown>;
186
- const snapshot: PullRequestSnapshot = {};
187
- if (typeof record.number === "number" || typeof record.number === "string") {
188
- snapshot.number = record.number;
189
- }
190
- if (typeof record.state === "string") snapshot.state = record.state;
191
- if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
192
- if (typeof record.url === "string") snapshot.url = record.url;
193
- if (typeof record.title === "string") snapshot.title = record.title;
194
- return snapshot;
190
+ const trimmed = stdout.trim();
191
+ if (!trimmed) return undefined;
192
+ let parsed: unknown;
193
+ try {
194
+ parsed = JSON.parse(trimmed);
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
199
+
200
+ const record = parsed as Record<string, unknown>;
201
+ const snapshot: PullRequestSnapshot = {};
202
+ if (typeof record.number === "number" || typeof record.number === "string") {
203
+ snapshot.number = record.number;
204
+ }
205
+ if (typeof record.state === "string") snapshot.state = record.state;
206
+ if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
207
+ if (typeof record.url === "string") snapshot.url = record.url;
208
+ if (typeof record.title === "string") snapshot.title = record.title;
209
+ return snapshot;
195
210
  }
196
211
 
197
212
  function gitStatusSnapshotsEqual(
198
- left: GitStatusSnapshot | undefined,
199
- right: GitStatusSnapshot | undefined,
213
+ left: GitStatusSnapshot | undefined,
214
+ right: GitStatusSnapshot | undefined,
200
215
  ): boolean {
201
- return (
202
- left?.branch === right?.branch
203
- && left?.staged === right?.staged
204
- && left?.unstaged === right?.unstaged
205
- && left?.untracked === right?.untracked
206
- && left?.conflict === right?.conflict
207
- && left?.ahead === right?.ahead
208
- && left?.behind === right?.behind
209
- );
216
+ return (
217
+ left?.branch === right?.branch &&
218
+ left?.staged === right?.staged &&
219
+ left?.unstaged === right?.unstaged &&
220
+ left?.untracked === right?.untracked &&
221
+ left?.conflict === right?.conflict &&
222
+ left?.ahead === right?.ahead &&
223
+ left?.behind === right?.behind
224
+ );
210
225
  }
211
226
 
212
227
  function pullRequestSnapshotsEqual(
213
- left: PullRequestSnapshot | undefined,
214
- right: PullRequestSnapshot | undefined,
228
+ left: PullRequestSnapshot | undefined,
229
+ right: PullRequestSnapshot | undefined,
215
230
  ): boolean {
216
- return (
217
- left?.number === right?.number
218
- && left?.state === right?.state
219
- && left?.isDraft === right?.isDraft
220
- && left?.url === right?.url
221
- && left?.title === right?.title
222
- );
231
+ return (
232
+ left?.number === right?.number &&
233
+ left?.state === right?.state &&
234
+ left?.isDraft === right?.isDraft &&
235
+ left?.url === right?.url &&
236
+ left?.title === right?.title
237
+ );
223
238
  }
224
239
 
225
240
  function defaultRunner(
226
- command: string,
227
- args: readonly string[],
228
- options: { cwd: string; signal: AbortSignal },
241
+ command: string,
242
+ args: readonly string[],
243
+ options: { cwd: string; signal: AbortSignal },
229
244
  ): Promise<CommandResult> {
230
- return new Promise((resolve, reject) => {
231
- let child;
232
- try {
233
- child = spawn(command, [...args], {
234
- cwd: options.cwd,
235
- stdio: ["ignore", "pipe", "pipe"],
236
- windowsHide: true,
237
- });
238
- } catch (error) {
239
- reject(error);
240
- return;
241
- }
242
-
243
- let stdout = "";
244
- let stderr = "";
245
- let settled = false;
246
-
247
- const finish = (result: CommandResult | Error) => {
248
- if (settled) return;
249
- settled = true;
250
- options.signal.removeEventListener("abort", onAbort);
251
- if (result instanceof Error) reject(result);
252
- else resolve(result);
253
- };
254
-
255
- const onAbort = () => {
256
- try {
257
- child.kill("SIGTERM");
258
- } catch {
259
- // Ignore: process may already be gone.
260
- }
261
- finish(new Error("aborted"));
262
- };
263
-
264
- child.stdout?.on("data", (chunk: Buffer | string) => {
265
- stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
266
- });
267
- child.stderr?.on("data", (chunk: Buffer | string) => {
268
- stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
269
- });
270
- child.on("error", finish);
271
- child.on("close", (code) => finish({ stdout, stderr, exitCode: code }));
272
-
273
- if (options.signal.aborted) {
274
- onAbort();
275
- return;
276
- }
277
- options.signal.addEventListener("abort", onAbort, { once: true });
278
- });
245
+ return new Promise((resolve, reject) => {
246
+ let child;
247
+ try {
248
+ child = spawn(command, [...args], {
249
+ cwd: options.cwd,
250
+ stdio: ["ignore", "pipe", "pipe"],
251
+ windowsHide: true,
252
+ });
253
+ } catch (error) {
254
+ reject(error);
255
+ return;
256
+ }
257
+
258
+ let stdout = "";
259
+ let stderr = "";
260
+ let settled = false;
261
+
262
+ const finish = (result: CommandResult | Error) => {
263
+ if (settled) return;
264
+ settled = true;
265
+ options.signal.removeEventListener("abort", onAbort);
266
+ if (result instanceof Error) reject(result);
267
+ else resolve(result);
268
+ };
269
+
270
+ const onAbort = () => {
271
+ try {
272
+ child.kill("SIGTERM");
273
+ } catch {
274
+ // Ignore: process may already be gone.
275
+ }
276
+ finish(new Error("aborted"));
277
+ };
278
+
279
+ child.stdout?.on("data", (chunk: Buffer | string) => {
280
+ stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
281
+ });
282
+ child.stderr?.on("data", (chunk: Buffer | string) => {
283
+ stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
284
+ });
285
+ child.on("error", finish);
286
+ child.on("close", (code) => finish({ stdout, stderr, exitCode: code }));
287
+
288
+ if (options.signal.aborted) {
289
+ onAbort();
290
+ return;
291
+ }
292
+ options.signal.addEventListener("abort", onAbort, { once: true });
293
+ });
279
294
  }
280
295
 
281
296
  function defaultClock(): Clock {
282
- return {
283
- setInterval(callback, ms) {
284
- const handle = setInterval(callback, ms);
285
- (handle as { unref?: () => void }).unref?.();
286
- return handle;
287
- },
288
- clearInterval(handle) {
289
- clearInterval(handle as ReturnType<typeof setInterval>);
290
- },
291
- };
297
+ return {
298
+ setInterval(callback, ms) {
299
+ const handle = setInterval(callback, ms);
300
+ (handle as { unref?: () => void }).unref?.();
301
+ return handle;
302
+ },
303
+ clearInterval(handle) {
304
+ clearInterval(handle as ReturnType<typeof setInterval>);
305
+ },
306
+ };
292
307
  }
293
308
 
294
309
  function isCommandUnavailableError(error: unknown): boolean {
295
- return !!error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT";
310
+ return !!error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT";
311
+ }
312
+
313
+ function canRunProjectGit(ctx: ProjectGitContext): boolean {
314
+ return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted();
296
315
  }
297
316
 
298
317
  class GitFooterCache {
299
- private readonly cwd: () => string;
300
- private readonly runner: CommandRunner;
301
- private readonly clock: Clock;
302
- private readonly refreshIntervalMs: number;
303
- private readonly gitTimeoutMs: number;
304
- private readonly ghTimeoutMs: number;
305
- private readonly onChange: (() => void) | undefined;
306
-
307
- private intervalHandle: TimerHandle | undefined;
308
- private readonly inflightControllers = new Set<AbortController>();
309
- private disposed = false;
310
- private refreshInFlight: Promise<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
-
330
- getStatusSnapshot(): GitStatusSnapshot | undefined {
331
- return this.statusSnapshot;
332
- }
333
-
334
- getPullRequestSnapshot(): PullRequestSnapshot | undefined {
335
- return this.pullRequestSnapshot;
336
- }
337
-
338
- refresh(): Promise<void> {
339
- if (this.disposed) return Promise.resolve();
340
- if (this.refreshInFlight) return this.refreshInFlight;
341
- const run = this.runRefresh()
342
- .finally(() => {
343
- this.refreshInFlight = undefined;
344
- })
345
- .catch(() => undefined);
346
- this.refreshInFlight = run;
347
- return run;
348
- }
349
-
350
- private async runRefresh(): Promise<void> {
351
- const previousStatusSnapshot = this.statusSnapshot;
352
- const previousPullRequestSnapshot = this.pullRequestSnapshot;
353
-
354
- const result = await this.fetchGitStatus();
355
- if (this.disposed) return;
356
- if (result.kind === "transient") return;
357
- if (result.kind === "not-a-repo" || result.kind === "unavailable") {
358
- this.statusSnapshot = undefined;
359
- this.pullRequestSnapshot = undefined;
360
- this.lastSeenBranch = undefined;
361
- this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
362
- return;
363
- }
364
-
365
- const status = result.status;
366
- this.statusSnapshot = status;
367
-
368
- const branch = typeof status.branch === "string" ? status.branch : undefined;
369
- const isValidBranch = !!branch && branch !== "detached";
370
- const branchChanged = branch !== this.lastSeenBranch;
371
-
372
- if (branchChanged) this.pullRequestSnapshot = undefined;
373
- this.lastSeenBranch = branch;
374
-
375
- if (!isValidBranch) {
376
- this.pullRequestSnapshot = undefined;
377
- this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
378
- return;
379
- }
380
-
381
- const pr = await this.fetchPullRequest();
382
- if (this.disposed) return;
383
- if (pr.kind === "ok") this.pullRequestSnapshot = pr.pullRequest;
384
- else if (pr.kind === "not-found" || pr.kind === "unavailable") this.pullRequestSnapshot = undefined;
385
-
386
- this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
387
- }
388
-
389
- private emitChangeIfSnapshotsChanged(
390
- previousStatusSnapshot: GitStatusSnapshot | undefined,
391
- previousPullRequestSnapshot: PullRequestSnapshot | undefined,
392
- ): void {
393
- if (this.disposed) return;
394
- if (
395
- gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot)
396
- && pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot)
397
- ) {
398
- return;
399
- }
400
- try {
401
- this.onChange?.();
402
- } catch {
403
- // Rendering hooks should not break refreshes.
404
- }
405
- }
406
-
407
- private async fetchGitStatus(): Promise<
408
- | { kind: "ok"; status: GitStatusSnapshot }
409
- | { kind: "not-a-repo" }
410
- | { kind: "transient" }
411
- | { kind: "unavailable" }
412
- > {
413
- const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
414
- if (result.kind !== "ok") return result;
415
- if (result.result.exitCode !== 0) return { kind: "not-a-repo" };
416
- return { kind: "ok", status: parseGitStatusPorcelainV2(result.result.stdout) };
417
- }
418
-
419
- private async fetchPullRequest(): Promise<
420
- | { kind: "ok"; pullRequest: PullRequestSnapshot | undefined }
421
- | { kind: "not-found" }
422
- | { kind: "transient" }
423
- | { kind: "unavailable" }
424
- > {
425
- const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
426
- if (result.kind !== "ok") return result;
427
- if (result.result.exitCode !== 0) return { kind: "not-found" };
428
- return { kind: "ok", pullRequest: parsePullRequestJson(result.result.stdout) };
429
- }
430
-
431
- private async runCommandSafely(
432
- command: string,
433
- args: readonly string[],
434
- timeoutMs: number,
435
- ): Promise<RunCommandSafelyResult> {
436
- if (this.disposed) return { kind: "transient" };
437
- const controller = new AbortController();
438
- this.inflightControllers.add(controller);
439
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
440
- try {
441
- const result = await this.runner(command, args, { cwd: this.cwd(), signal: controller.signal });
442
- return { kind: "ok", result };
443
- } catch (error) {
444
- return isCommandUnavailableError(error) ? { kind: "unavailable" } : { kind: "transient" };
445
- } finally {
446
- clearTimeout(timeoutId);
447
- this.inflightControllers.delete(controller);
448
- }
449
- }
450
-
451
- dispose(): void {
452
- if (this.disposed) return;
453
- this.disposed = true;
454
- if (this.intervalHandle !== undefined) {
455
- this.clock.clearInterval(this.intervalHandle);
456
- this.intervalHandle = undefined;
457
- }
458
- for (const controller of this.inflightControllers) controller.abort();
459
- this.inflightControllers.clear();
460
- }
318
+ private readonly cwd: () => string;
319
+ private readonly canRun: () => boolean;
320
+ private readonly runner: CommandRunner;
321
+ private readonly clock: Clock;
322
+ private readonly refreshIntervalMs: number;
323
+ private readonly gitTimeoutMs: number;
324
+ private readonly ghTimeoutMs: number;
325
+ private readonly onChange: (() => void) | undefined;
326
+
327
+ private intervalHandle: TimerHandle | undefined;
328
+ private readonly inflightControllers = new Set<AbortController>();
329
+ private disposed = false;
330
+ private refreshInFlight: Promise<void> | undefined;
331
+ private statusSnapshot: GitStatusSnapshot | undefined;
332
+ private pullRequestSnapshot: PullRequestSnapshot | undefined;
333
+ private lastSeenBranch: string | undefined;
334
+
335
+ constructor(options: GitFooterCacheOptions) {
336
+ this.cwd = options.cwd;
337
+ this.canRun = options.canRun ?? (() => true);
338
+ this.runner = options.runner ?? defaultRunner;
339
+ this.clock = options.clock ?? defaultClock();
340
+ this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
341
+ this.gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
342
+ this.ghTimeoutMs = options.ghTimeoutMs ?? DEFAULT_GH_TIMEOUT_MS;
343
+ this.onChange = options.onChange;
344
+
345
+ this.intervalHandle = this.clock.setInterval(() => {
346
+ void this.refresh();
347
+ }, this.refreshIntervalMs);
348
+ void this.refresh();
349
+ }
350
+
351
+ getStatusSnapshot(): GitStatusSnapshot | undefined {
352
+ return this.statusSnapshot;
353
+ }
354
+
355
+ getPullRequestSnapshot(): PullRequestSnapshot | undefined {
356
+ return this.pullRequestSnapshot;
357
+ }
358
+
359
+ refresh(): Promise<void> {
360
+ if (this.disposed) return Promise.resolve();
361
+ if (!this.canRun()) {
362
+ const previousStatusSnapshot = this.statusSnapshot;
363
+ const previousPullRequestSnapshot = this.pullRequestSnapshot;
364
+ this.statusSnapshot = undefined;
365
+ this.pullRequestSnapshot = undefined;
366
+ this.lastSeenBranch = undefined;
367
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
368
+ return Promise.resolve();
369
+ }
370
+ if (this.refreshInFlight) return this.refreshInFlight;
371
+ const run = this.runRefresh()
372
+ .finally(() => {
373
+ this.refreshInFlight = undefined;
374
+ })
375
+ .catch(() => undefined);
376
+ this.refreshInFlight = run;
377
+ return run;
378
+ }
379
+
380
+ private async runRefresh(): Promise<void> {
381
+ const previousStatusSnapshot = this.statusSnapshot;
382
+ const previousPullRequestSnapshot = this.pullRequestSnapshot;
383
+
384
+ const result = await this.fetchGitStatus();
385
+ if (this.disposed) return;
386
+ if (result.kind === "transient") return;
387
+ if (result.kind === "not-a-repo" || result.kind === "unavailable") {
388
+ this.statusSnapshot = undefined;
389
+ this.pullRequestSnapshot = undefined;
390
+ this.lastSeenBranch = undefined;
391
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
392
+ return;
393
+ }
394
+
395
+ const status = result.status;
396
+ this.statusSnapshot = status;
397
+
398
+ const branch = typeof status.branch === "string" ? status.branch : undefined;
399
+ const isValidBranch = !!branch && branch !== "detached";
400
+ const branchChanged = branch !== this.lastSeenBranch;
401
+
402
+ if (branchChanged) this.pullRequestSnapshot = undefined;
403
+ this.lastSeenBranch = branch;
404
+
405
+ if (!isValidBranch) {
406
+ this.pullRequestSnapshot = undefined;
407
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
408
+ return;
409
+ }
410
+
411
+ const pr = await this.fetchPullRequest();
412
+ if (this.disposed) return;
413
+ if (pr.kind === "ok") this.pullRequestSnapshot = pr.pullRequest;
414
+ else if (pr.kind === "not-found" || pr.kind === "unavailable")
415
+ this.pullRequestSnapshot = undefined;
416
+
417
+ this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
418
+ }
419
+
420
+ private emitChangeIfSnapshotsChanged(
421
+ previousStatusSnapshot: GitStatusSnapshot | undefined,
422
+ previousPullRequestSnapshot: PullRequestSnapshot | undefined,
423
+ ): void {
424
+ if (this.disposed) return;
425
+ if (
426
+ gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot) &&
427
+ pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot)
428
+ ) {
429
+ return;
430
+ }
431
+ try {
432
+ this.onChange?.();
433
+ } catch {
434
+ // Rendering hooks should not break refreshes.
435
+ }
436
+ }
437
+
438
+ private async fetchGitStatus(): Promise<
439
+ | { kind: "ok"; status: GitStatusSnapshot }
440
+ | { kind: "not-a-repo" }
441
+ | { kind: "transient" }
442
+ | { kind: "unavailable" }
443
+ > {
444
+ const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
445
+ if (result.kind !== "ok") return result;
446
+ if (result.result.exitCode !== 0) return { kind: "not-a-repo" };
447
+ return { kind: "ok", status: parseGitStatusPorcelainV2(result.result.stdout) };
448
+ }
449
+
450
+ private async fetchPullRequest(): Promise<
451
+ | { kind: "ok"; pullRequest: PullRequestSnapshot | undefined }
452
+ | { kind: "not-found" }
453
+ | { kind: "transient" }
454
+ | { kind: "unavailable" }
455
+ > {
456
+ const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
457
+ if (result.kind !== "ok") return result;
458
+ if (result.result.exitCode !== 0) return { kind: "not-found" };
459
+ return { kind: "ok", pullRequest: parsePullRequestJson(result.result.stdout) };
460
+ }
461
+
462
+ private async runCommandSafely(
463
+ command: string,
464
+ args: readonly string[],
465
+ timeoutMs: number,
466
+ ): Promise<RunCommandSafelyResult> {
467
+ if (this.disposed) return { kind: "transient" };
468
+ const controller = new AbortController();
469
+ this.inflightControllers.add(controller);
470
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
471
+ try {
472
+ const result = await this.runner(command, args, {
473
+ cwd: this.cwd(),
474
+ signal: controller.signal,
475
+ });
476
+ return { kind: "ok", result };
477
+ } catch (error) {
478
+ return isCommandUnavailableError(error) ? { kind: "unavailable" } : { kind: "transient" };
479
+ } finally {
480
+ clearTimeout(timeoutId);
481
+ this.inflightControllers.delete(controller);
482
+ }
483
+ }
484
+
485
+ dispose(): void {
486
+ if (this.disposed) return;
487
+ this.disposed = true;
488
+ if (this.intervalHandle !== undefined) {
489
+ this.clock.clearInterval(this.intervalHandle);
490
+ this.intervalHandle = undefined;
491
+ }
492
+ for (const controller of this.inflightControllers) controller.abort();
493
+ this.inflightControllers.clear();
494
+ }
461
495
  }
462
496
 
463
497
  export default function (pi: ExtensionAPI) {
464
- let cache: GitFooterCache | undefined;
465
-
466
- function disposeCache(): void {
467
- cache?.dispose();
468
- cache = undefined;
469
- }
470
-
471
- pi.on("session_start", (_event, ctx) => {
472
- disposeCache();
473
-
474
- const updateStatus = () => {
475
- const text = formatGitFooterStatus(
476
- cache?.getStatusSnapshot(),
477
- cache?.getPullRequestSnapshot(),
478
- );
479
- ctx.ui.setStatus(STATUS_KEY, text ? ctx.ui.theme.fg("dim", text) : undefined);
480
- };
481
-
482
- cache = new GitFooterCache({
483
- cwd: () => ctx.cwd,
484
- onChange: updateStatus,
485
- });
486
- updateStatus();
487
- });
488
-
489
- pi.on("turn_end", () => {
490
- void cache?.refresh();
491
- });
492
-
493
- pi.on("session_shutdown", (_event, ctx) => {
494
- disposeCache();
495
- ctx.ui.setStatus(STATUS_KEY, undefined);
496
- });
498
+ let cache: GitFooterCache | undefined;
499
+ let updateStatus: (() => void) | undefined;
500
+
501
+ function disposeCache(): void {
502
+ cache?.dispose();
503
+ cache = undefined;
504
+ }
505
+
506
+ function ensureCache(ctx: ProjectGitContext): void {
507
+ if (!canRunProjectGit(ctx)) {
508
+ disposeCache();
509
+ updateStatus?.();
510
+ return;
511
+ }
512
+
513
+ if (cache) return;
514
+ cache = new GitFooterCache({
515
+ cwd: () => ctx.cwd,
516
+ canRun: () => canRunProjectGit(ctx),
517
+ onChange: () => updateStatus?.(),
518
+ });
519
+ }
520
+
521
+ pi.on("session_start", (_event, ctx) => {
522
+ disposeCache();
523
+ updateStatus = () => {
524
+ const text = formatGitFooterStatus(
525
+ cache?.getStatusSnapshot(),
526
+ cache?.getPullRequestSnapshot(),
527
+ );
528
+ ctx.ui.setStatus(STATUS_KEY, text ? ctx.ui.theme.fg("dim", text) : undefined);
529
+ };
530
+ ensureCache(ctx);
531
+ updateStatus();
532
+ });
533
+
534
+ pi.on("turn_end", (_event, ctx) => {
535
+ ensureCache(ctx);
536
+ void cache?.refresh();
537
+ });
538
+
539
+ pi.on("session_shutdown", (_event, ctx) => {
540
+ disposeCache();
541
+ updateStatus = undefined;
542
+ ctx.ui.setStatus(STATUS_KEY, undefined);
543
+ });
497
544
  }
498
545
 
499
546
  export const __testing = {
500
- GitFooterCache,
501
- GIT_STATUS_ARGS,
502
- GH_PR_VIEW_ARGS,
503
- parseGitStatusPorcelainV2,
504
- parsePullRequestJson,
505
- formatGitStatusFooterSegment,
506
- formatPullRequestFooterSegment,
507
- formatGitFooterStatus,
547
+ GitFooterCache,
548
+ GIT_STATUS_ARGS,
549
+ GH_PR_VIEW_ARGS,
550
+ parseGitStatusPorcelainV2,
551
+ parsePullRequestJson,
552
+ formatGitStatusFooterSegment,
553
+ formatPullRequestFooterSegment,
554
+ formatGitFooterStatus,
508
555
  };