@diegopetrucci/pi-extensions 0.1.65 → 0.1.66

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 (69) 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/README.md +3 -0
  7. package/extensions/annotate-git-diff/index.ts +49 -5
  8. package/extensions/annotate-git-diff/package.json +2 -1
  9. package/extensions/annotate-git-diff/quiet-glimpse.ts +24 -3
  10. package/extensions/annotate-git-diff/review-server.ts +379 -0
  11. package/extensions/annotate-git-diff/ui.ts +17 -41
  12. package/extensions/annotate-last-message/.pi-fleet-tested-version +1 -1
  13. package/extensions/annotate-last-message/index.ts +28 -2
  14. package/extensions/annotate-last-message/package.json +1 -1
  15. package/extensions/annotate-last-message/quiet-glimpse.ts +24 -3
  16. package/extensions/brrr/.pi-fleet-tested-version +1 -1
  17. package/extensions/brrr/package.json +1 -1
  18. package/extensions/claude-fast/.pi-fleet-tested-version +1 -1
  19. package/extensions/claude-fast/package.json +1 -1
  20. package/extensions/code-reviewer/.pi-fleet-tested-version +1 -1
  21. package/extensions/code-reviewer/package.json +1 -1
  22. package/extensions/confirm-destructive/.pi-fleet-tested-version +1 -1
  23. package/extensions/confirm-destructive/package.json +1 -1
  24. package/extensions/context-cap/.pi-fleet-tested-version +1 -1
  25. package/extensions/context-cap/package.json +1 -1
  26. package/extensions/context-inspector/.pi-fleet-tested-version +1 -1
  27. package/extensions/context-inspector/package.json +1 -1
  28. package/extensions/contrarian/.pi-fleet-tested-version +1 -1
  29. package/extensions/contrarian/index.ts +46 -17
  30. package/extensions/contrarian/package.json +1 -1
  31. package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
  32. package/extensions/dirty-repo-guard/package.json +1 -1
  33. package/extensions/dynamic-context-pruning/.pi-fleet-tested-version +1 -1
  34. package/extensions/dynamic-context-pruning/package.json +1 -1
  35. package/extensions/fast/.pi-fleet-tested-version +1 -1
  36. package/extensions/fast/package.json +1 -1
  37. package/extensions/git-footer/.pi-fleet-tested-version +1 -1
  38. package/extensions/git-footer/package.json +1 -1
  39. package/extensions/gnosis/.pi-fleet-tested-version +1 -1
  40. package/extensions/gnosis/package.json +1 -1
  41. package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -1
  42. package/extensions/illustrations-to-explain-things/package.json +1 -1
  43. package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
  44. package/extensions/inline-bash/package.json +1 -1
  45. package/extensions/librarian/.pi-fleet-tested-version +1 -1
  46. package/extensions/librarian/package.json +1 -1
  47. package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
  48. package/extensions/minimal-footer/package.json +1 -1
  49. package/extensions/notify/.pi-fleet-tested-version +1 -1
  50. package/extensions/notify/package.json +1 -1
  51. package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
  52. package/extensions/openai-fast/package.json +1 -1
  53. package/extensions/oracle/.pi-fleet-tested-version +1 -1
  54. package/extensions/oracle/index.ts +46 -17
  55. package/extensions/oracle/package.json +1 -1
  56. package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
  57. package/extensions/permission-gate/README.md +11 -3
  58. package/extensions/permission-gate/index.ts +70 -7
  59. package/extensions/permission-gate/package.json +4 -2
  60. package/extensions/permission-gate/powershell-safety.ts +1006 -0
  61. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  62. package/extensions/quiet-tools/package.json +1 -1
  63. package/extensions/review/.pi-fleet-tested-version +1 -1
  64. package/extensions/review/package.json +1 -1
  65. package/extensions/todo/.pi-fleet-tested-version +1 -1
  66. package/extensions/todo/package.json +1 -1
  67. package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
  68. package/extensions/triage-comments/package.json +1 -1
  69. package/package.json +4 -4
@@ -1 +1 @@
1
- 0.84.2
1
+ 0.84.4
package/README.md CHANGED
@@ -39,7 +39,7 @@ A collection of [pi](https://pi.dev) agent extensions I made. (Btw, [I am also b
39
39
 
40
40
  - [`confirm-destructive`](./extensions/confirm-destructive): Confirms before destructive session actions like clear, switch, and fork.
41
41
  - [`dirty-repo-guard`](./extensions/dirty-repo-guard): Prompts before new sessions, session switches, or forks when the current git repo has uncommitted changes.
42
- - [`permission-gate`](./extensions/permission-gate): Prompts for confirmation before dangerous bash commands and protected `write`/`edit` targets like `.git`, `node_modules`, and secret-bearing `.env` files.
42
+ - [`permission-gate`](./extensions/permission-gate): Prompts for confirmation before dangerous bash/PowerShell commands and protected `write`/`edit` targets like `.git`, `node_modules`, and secret-bearing `.env` files.
43
43
 
44
44
  ### Quality of life
45
45
 
@@ -1 +1 @@
1
- 0.84.2
1
+ 0.84.4
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-agent-workflow-audit",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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.84.2
1
+ 0.84.4
@@ -29,6 +29,8 @@ Run `/annotate-git-diff` inside a git repository. The command opens a native rev
29
29
 
30
30
  Submitting feedback does not auto-apply code changes. The extension appends a structured prompt to the current editor buffer so you can send that feedback back to the active agent.
31
31
 
32
+ The review UI does not fetch assets from a CDN. While the window is open, the extension serves its packaged Monaco graph from an ephemeral, tokenized HTTP server bound only to `127.0.0.1`; closing, cancelling, submitting, startup failure, or Pi shutdown stops that server.
33
+
32
34
  ## Requirements
33
35
 
34
36
  - Run inside a git repository.
@@ -41,3 +43,4 @@ Submitting feedback does not auto-apply code changes. The extension appends a st
41
43
  - `Review failed: Not inside a git repository.` → change into a git repo and rerun `/annotate-git-diff`.
42
44
  - `No reviewable files found.` → make or fetch reviewable changes, then rerun.
43
45
  - `Review failed: Glimpse host not found ...` → the native window runtime is unavailable; reinstall/update the package and rerun from a machine/session that can open native windows.
46
+ - `Review failed: Unable to locate packaged ... runtime` → reinstall/update the package so its declared Monaco and Tailwind dependencies are present, then rerun.
@@ -6,6 +6,7 @@ import { readSystemClipboard, writeSystemClipboard } from "./clipboard.js";
6
6
  import { getCommitFiles, getReviewWindowData, isWorkingTreeCommitSha, loadReviewFileContents } from "./git.js";
7
7
  import { composeReviewPrompt } from "./prompt.js";
8
8
  import { openQuietGlimpse, type QuietGlimpseWindow } from "./quiet-glimpse.js";
9
+ import { startReviewUiServer, type ReviewUiServer } from "./review-server.js";
9
10
  import type {
10
11
  ReviewCancelPayload,
11
12
  ReviewClipboardReadPayload,
@@ -19,7 +20,6 @@ import type {
19
20
  ReviewSubmitPayload,
20
21
  ReviewWindowMessage,
21
22
  } from "./types.js";
22
- import { buildReviewHtml } from "./ui.js";
23
23
  import { createRepoChangeWatcher, type RepoChangeWatcher } from "./watch.js";
24
24
 
25
25
  function hasMessageType(value: unknown, type: ReviewWindowMessage["type"]): boolean {
@@ -69,7 +69,10 @@ function appendReviewPrompt(ctx: ExtensionCommandContext, prompt: string): void
69
69
 
70
70
  export default function (pi: ExtensionAPI) {
71
71
  let activeWindow: QuietGlimpseWindow | null = null;
72
+ let activeUiServer: ReviewUiServer | null = null;
72
73
  let activeWatcher: RepoChangeWatcher | null = null;
74
+ let reviewOpening = false;
75
+ let reviewAttempt = 0;
73
76
  const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
74
77
 
75
78
  function stopActiveWatcher(): void {
@@ -79,10 +82,13 @@ export default function (pi: ExtensionAPI) {
79
82
  }
80
83
 
81
84
  function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
82
- if (activeWindow == null) return;
85
+ reviewAttempt += 1;
83
86
  const windowToClose = activeWindow;
84
87
  activeWindow = null;
88
+ activeUiServer?.dispose();
89
+ activeUiServer = null;
85
90
  stopActiveWatcher();
91
+ if (windowToClose == null) return;
86
92
  if (options.suppressResults) {
87
93
  suppressedWindows.add(windowToClose);
88
94
  }
@@ -99,26 +105,43 @@ export default function (pi: ExtensionAPI) {
99
105
  return;
100
106
  }
101
107
 
102
- if (activeWindow != null) {
108
+ if (reviewOpening || activeWindow != null || activeUiServer != null) {
103
109
  ctx.ui.notify("A review window is already open.", "warning");
104
110
  return;
105
111
  }
106
112
 
113
+ reviewOpening = true;
114
+ const attempt = ++reviewAttempt;
107
115
  try {
108
116
  let reviewData = await getReviewWindowData(pi, ctx.cwd);
117
+ if (attempt !== reviewAttempt) return;
109
118
  const { repoRoot } = reviewData;
110
119
  if (reviewData.files.length === 0 && reviewData.commits.length === 0) {
111
120
  ctx.ui.notify("No reviewable files found.", "info");
112
121
  return;
113
122
  }
114
123
 
115
- const html = buildReviewHtml(reviewData);
116
- const window = await openQuietGlimpse(html, {
124
+ const uiServer = await startReviewUiServer(reviewData);
125
+ if (attempt !== reviewAttempt) {
126
+ uiServer.dispose();
127
+ return;
128
+ }
129
+ activeUiServer = uiServer;
130
+ const window = await openQuietGlimpse(uiServer.html, {
117
131
  width: 1680,
118
132
  height: 1020,
119
133
  title: "annotate-git-diff",
120
134
  });
135
+ if (attempt !== reviewAttempt) {
136
+ suppressedWindows.add(window);
137
+ window.close();
138
+ uiServer.dispose();
139
+ return;
140
+ }
121
141
  activeWindow = window;
142
+ if (uiServer.failure != null) throw uiServer.failure;
143
+ if (window.failure != null) throw window.failure;
144
+ if (window.closed) throw new Error("Glimpse closed while the review window was starting.");
122
145
 
123
146
  const fileMap = new Map(reviewData.files.map((file) => [file.id, file]));
124
147
  const commitFileCache = new Map<string, Promise<ReviewFile[]>>();
@@ -185,6 +208,15 @@ export default function (pi: ExtensionAPI) {
185
208
  (resolve, reject) => {
186
209
  let settled = false;
187
210
  let closeTimer: ReturnType<typeof setTimeout> | null = null;
211
+ let removeUiServerErrorListener = (): void => {};
212
+
213
+ const requestWindowClose = (): void => {
214
+ try {
215
+ window.close();
216
+ } catch {
217
+ // Ignore races when the native process has already exited.
218
+ }
219
+ };
188
220
 
189
221
  const cleanup = (): void => {
190
222
  if (closeTimer != null) {
@@ -194,10 +226,14 @@ export default function (pi: ExtensionAPI) {
194
226
  window.removeListener("message", onMessage);
195
227
  window.removeListener("closed", onClosed);
196
228
  window.removeListener("error", onError);
229
+ removeUiServerErrorListener();
230
+ removeUiServerErrorListener = (): void => {};
197
231
  if (activeWindow === window) {
198
232
  activeWindow = null;
199
233
  stopActiveWatcher();
200
234
  }
235
+ if (activeUiServer === uiServer) activeUiServer = null;
236
+ uiServer.dispose();
201
237
  };
202
238
 
203
239
  const settle = (value: ReviewSubmitPayload | ReviewCancelPayload | null): void => {
@@ -345,6 +381,7 @@ export default function (pi: ExtensionAPI) {
345
381
  return;
346
382
  }
347
383
  if (isSubmitPayload(message) || isCancelPayload(message)) {
384
+ requestWindowClose();
348
385
  settle(message);
349
386
  }
350
387
  };
@@ -360,6 +397,7 @@ export default function (pi: ExtensionAPI) {
360
397
  const onError = (error: Error): void => {
361
398
  if (settled) return;
362
399
  settled = true;
400
+ requestWindowClose();
363
401
  cleanup();
364
402
  reject(error);
365
403
  };
@@ -367,6 +405,10 @@ export default function (pi: ExtensionAPI) {
367
405
  window.on("message", onMessage);
368
406
  window.on("closed", onClosed);
369
407
  window.on("error", onError);
408
+ removeUiServerErrorListener = uiServer.onError(onError);
409
+ if (uiServer.failure != null) onError(uiServer.failure);
410
+ else if (window.failure != null) onError(window.failure);
411
+ else if (window.closed) onClosed();
370
412
  },
371
413
  );
372
414
 
@@ -396,6 +438,8 @@ export default function (pi: ExtensionAPI) {
396
438
  closeActiveWindow({ suppressResults: true });
397
439
  const message = error instanceof Error ? error.message : String(error);
398
440
  ctx.ui.notify(`Review failed: ${message}`, "error");
441
+ } finally {
442
+ reviewOpening = false;
399
443
  }
400
444
  }
401
445
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-annotate-git-diff",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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",
@@ -22,6 +22,7 @@
22
22
  "git.ts",
23
23
  "prompt.ts",
24
24
  "quiet-glimpse.ts",
25
+ "review-server.ts",
25
26
  "types.ts",
26
27
  "ui.ts",
27
28
  "watch.ts",
@@ -23,6 +23,8 @@ interface GlimpseProtocolMessage {
23
23
  }
24
24
 
25
25
  export interface QuietGlimpseWindow {
26
+ readonly closed: boolean;
27
+ readonly failure: Error | null;
26
28
  on(event: "message", listener: (data: unknown) => void): this;
27
29
  on(event: "closed", listener: () => void): this;
28
30
  on(event: "error", listener: (error: Error) => void): this;
@@ -38,11 +40,15 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
38
40
  #closed = false;
39
41
  #pendingHTML: string | null;
40
42
  #stderr = "";
43
+ #failure: Error | null = null;
41
44
 
42
45
  constructor(proc: ChildProcessWithoutNullStreams, initialHTML: string) {
43
46
  super();
44
47
  this.#proc = proc;
45
48
  this.#pendingHTML = initialHTML;
49
+ // EventEmitter treats an unobserved `error` as fatal. Keep a permanent
50
+ // fallback while callers attach and detach their lifecycle listeners.
51
+ this.on("error", () => {});
46
52
 
47
53
  proc.stdin.on("error", () => {});
48
54
  proc.stderr.on("data", (chunk) => {
@@ -55,7 +61,7 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
55
61
  try {
56
62
  message = JSON.parse(line) as GlimpseProtocolMessage;
57
63
  } catch {
58
- this.emit("error", new Error(`Malformed glimpse protocol line: ${line}`));
64
+ this.#reportError(new Error(`Malformed glimpse protocol line: ${line}`));
59
65
  return;
60
66
  }
61
67
 
@@ -77,16 +83,18 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
77
83
  }
78
84
  });
79
85
 
80
- proc.on("error", (error) => this.emit("error", error));
86
+ proc.on("error", (error) => this.#reportError(error));
81
87
  proc.on("exit", (code, signal) => {
82
88
  const stderr = this.#stderr.trim();
83
89
  const failed = signal != null || (code != null && code !== 0);
84
90
  if (!this.#closed && failed) {
85
91
  const message = stderr || `Glimpse process exited abnormally (code: ${code}, signal: ${signal}).`;
86
- this.emit("error", new Error(message));
92
+ this.#reportError(new Error(message));
87
93
  }
88
94
  this.#markClosed();
89
95
  });
96
+ // Spawn failures can emit `error` and then `close` without `exit`.
97
+ proc.on("close", () => this.#markClosed());
90
98
  }
91
99
 
92
100
  send(js: string): void {
@@ -97,6 +105,19 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
97
105
  this.#write({ type: "close" });
98
106
  }
99
107
 
108
+ get closed(): boolean {
109
+ return this.#closed;
110
+ }
111
+
112
+ get failure(): Error | null {
113
+ return this.#failure;
114
+ }
115
+
116
+ #reportError(error: Error): void {
117
+ this.#failure ??= error;
118
+ this.emit("error", error);
119
+ }
120
+
100
121
  #markClosed(): void {
101
122
  if (this.#closed) return;
102
123
  this.#closed = true;
@@ -0,0 +1,379 @@
1
+ import { createReadStream, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+ import { buildReviewHtml } from "./ui.js";
7
+ import type { ReviewWindowData } from "./types.js";
8
+
9
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
10
+ const loopbackHost = "127.0.0.1";
11
+
12
+ export interface ReviewUiServer {
13
+ readonly failure: Error | null;
14
+ html: string;
15
+ url: string;
16
+ onError(listener: (error: Error) => void): () => void;
17
+ dispose(): void;
18
+ }
19
+
20
+ export interface ReviewUiAssetPaths {
21
+ tailwindBrowserPath: string;
22
+ monacoLoaderPath: string;
23
+ monacoVsDirectory: string;
24
+ }
25
+
26
+ interface StartReviewUiServerOptions {
27
+ extensionDir?: string;
28
+ token?: string;
29
+ }
30
+
31
+ function parentDirectories(startPath: string): string[] {
32
+ const directories: string[] = [];
33
+ let current = resolve(startPath);
34
+
35
+ while (true) {
36
+ directories.push(current);
37
+ const parent = dirname(current);
38
+ if (parent === current) return directories;
39
+ current = parent;
40
+ }
41
+ }
42
+
43
+ function findInstalledAsset(extensionDir: string, dependencyPath: string, label: string, expected: "file" | "directory"): string {
44
+ const searched: string[] = [];
45
+ const starts = [extensionDir];
46
+
47
+ try {
48
+ const realExtensionDir = realpathSync(extensionDir);
49
+ if (realExtensionDir !== resolve(extensionDir)) starts.push(realExtensionDir);
50
+ } catch {
51
+ // The direct path below will produce the useful not-found diagnostic.
52
+ }
53
+
54
+ for (const start of starts) {
55
+ for (const ancestor of parentDirectories(start)) {
56
+ const candidate = join(ancestor, "node_modules", dependencyPath);
57
+ if (searched.includes(candidate)) continue;
58
+ searched.push(candidate);
59
+
60
+ try {
61
+ const stats = statSync(candidate);
62
+ const matches = expected === "file" ? stats.isFile() : stats.isDirectory();
63
+ if (matches) return realpathSync(candidate);
64
+ } catch {
65
+ // Keep walking toward the package-manager installation root.
66
+ }
67
+ }
68
+ }
69
+
70
+ throw new Error(`Unable to locate packaged ${label}. Searched: ${searched.join(", ")}`);
71
+ }
72
+
73
+ function readDependencyVersion(packageRoot: string, packageName: string): string {
74
+ try {
75
+ const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
76
+ name?: unknown;
77
+ version?: unknown;
78
+ };
79
+ if (manifest.name !== packageName || typeof manifest.version !== "string") throw new Error("invalid manifest");
80
+ return manifest.version;
81
+ } catch {
82
+ throw new Error(`Unable to validate packaged ${packageName} runtime at ${packageRoot}.`);
83
+ }
84
+ }
85
+
86
+ function isCompatibleTailwindBrowserVersion(version: string): boolean {
87
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
88
+ if (match == null) return false;
89
+ const [, major, minor, patch] = match.map(Number);
90
+ return major === 4 && (minor > 3 || (minor === 3 && patch >= 2));
91
+ }
92
+
93
+ export function resolveTailwindBrowserPath(extensionDir = moduleDir): string {
94
+ const browserPath = findInstalledAsset(
95
+ extensionDir,
96
+ join("@tailwindcss", "browser", "dist", "index.global.js"),
97
+ "@tailwindcss/browser runtime",
98
+ "file",
99
+ );
100
+ const version = readDependencyVersion(dirname(dirname(browserPath)), "@tailwindcss/browser");
101
+ if (!isCompatibleTailwindBrowserVersion(version)) {
102
+ throw new Error(`Incompatible packaged @tailwindcss/browser runtime ${version}; expected ^4.3.2.`);
103
+ }
104
+ return browserPath;
105
+ }
106
+
107
+ export function resolveMonacoVsDirectory(extensionDir = moduleDir): string {
108
+ const vsDirectory = findInstalledAsset(extensionDir, join("monaco-editor", "min", "vs"), "monaco-editor runtime", "directory");
109
+ const version = readDependencyVersion(dirname(dirname(vsDirectory)), "monaco-editor");
110
+ if (version !== "0.52.2") {
111
+ throw new Error(`Incompatible packaged monaco-editor runtime ${version}; expected 0.52.2.`);
112
+ }
113
+ return vsDirectory;
114
+ }
115
+
116
+ export function resolveReviewUiAssetPaths(extensionDir = moduleDir): ReviewUiAssetPaths {
117
+ const tailwindBrowserPath = resolveTailwindBrowserPath(extensionDir);
118
+ const monacoVsDirectory = resolveMonacoVsDirectory(extensionDir);
119
+ const monacoLoaderPath = join(monacoVsDirectory, "loader.js");
120
+
121
+ try {
122
+ if (!statSync(monacoLoaderPath).isFile()) throw new Error("not a file");
123
+ } catch {
124
+ throw new Error(`Unable to locate packaged Monaco loader: ${monacoLoaderPath}`);
125
+ }
126
+
127
+ return {
128
+ tailwindBrowserPath,
129
+ monacoLoaderPath: realpathSync(monacoLoaderPath),
130
+ monacoVsDirectory,
131
+ };
132
+ }
133
+
134
+ function contentType(filePath: string): string {
135
+ switch (extname(filePath).toLowerCase()) {
136
+ case ".css":
137
+ return "text/css; charset=utf-8";
138
+ case ".html":
139
+ return "text/html; charset=utf-8";
140
+ case ".js":
141
+ case ".mjs":
142
+ return "text/javascript; charset=utf-8";
143
+ case ".json":
144
+ return "application/json; charset=utf-8";
145
+ case ".svg":
146
+ return "image/svg+xml";
147
+ case ".ttf":
148
+ return "font/ttf";
149
+ case ".wasm":
150
+ return "application/wasm";
151
+ default:
152
+ return "application/octet-stream";
153
+ }
154
+ }
155
+
156
+ function setSecurityHeaders(response: ServerResponse): void {
157
+ response.setHeader("Cache-Control", "no-store");
158
+ response.setHeader(
159
+ "Content-Security-Policy",
160
+ "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:; connect-src 'self'",
161
+ );
162
+ response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
163
+ response.setHeader("Referrer-Policy", "no-referrer");
164
+ response.setHeader("X-Content-Type-Options", "nosniff");
165
+ }
166
+
167
+ function sendText(response: ServerResponse, statusCode: number, body: string): void {
168
+ response.statusCode = statusCode;
169
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
170
+ response.setHeader("Content-Length", Buffer.byteLength(body));
171
+ response.end(body);
172
+ }
173
+
174
+ function isContainedPath(rootPath: string, candidatePath: string): boolean {
175
+ const relativePath = relative(rootPath, candidatePath);
176
+ return relativePath.length > 0 && !relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath);
177
+ }
178
+
179
+ function decodeAssetPath(encodedPath: string): string | null {
180
+ try {
181
+ const decoded = decodeURIComponent(encodedPath);
182
+ if (!decoded || decoded.includes("\0") || decoded.includes("\\")) return null;
183
+ const segments = decoded.split("/");
184
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return null;
185
+ return segments.join(sep);
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ function serveMonacoAsset(
192
+ request: IncomingMessage,
193
+ response: ServerResponse,
194
+ monacoVsDirectory: string,
195
+ encodedPath: string,
196
+ ): void {
197
+ const relativePath = decodeAssetPath(encodedPath);
198
+ if (relativePath == null) {
199
+ sendText(response, 404, "Not found");
200
+ return;
201
+ }
202
+
203
+ let assetPath: string;
204
+ let assetSize: number;
205
+ try {
206
+ const candidate = resolve(monacoVsDirectory, relativePath);
207
+ if (!isContainedPath(monacoVsDirectory, candidate)) throw new Error("outside asset root");
208
+ assetPath = realpathSync(candidate);
209
+ const stats = statSync(assetPath);
210
+ if (!isContainedPath(monacoVsDirectory, assetPath) || !stats.isFile()) throw new Error("not a file");
211
+ assetSize = stats.size;
212
+ } catch {
213
+ sendText(response, 404, "Not found");
214
+ return;
215
+ }
216
+
217
+ response.statusCode = 200;
218
+ response.setHeader("Content-Type", contentType(assetPath));
219
+ response.setHeader("Content-Length", assetSize);
220
+ if (request.method === "HEAD") {
221
+ response.end();
222
+ return;
223
+ }
224
+
225
+ const stream = createReadStream(assetPath);
226
+ stream.on("error", () => {
227
+ if (!response.headersSent) sendText(response, 500, "Unable to read asset");
228
+ else response.destroy();
229
+ });
230
+ response.on("close", () => stream.destroy());
231
+ stream.pipe(response);
232
+ }
233
+
234
+ function redirectHtml(url: string): string {
235
+ const safeUrl = JSON.stringify(url).replace(/</g, "\\u003c");
236
+ return `<!doctype html><html><head><meta charset="utf-8"><title>annotate-git-diff</title></head><body><p>Loading review…</p><script>location.replace(${safeUrl});</script></body></html>`;
237
+ }
238
+
239
+ function disposeServer(server: Server): void {
240
+ server.close();
241
+ server.closeAllConnections();
242
+ }
243
+
244
+ export async function startReviewUiServer(
245
+ data: ReviewWindowData,
246
+ options: StartReviewUiServerOptions = {},
247
+ ): Promise<ReviewUiServer> {
248
+ const assetPaths = resolveReviewUiAssetPaths(options.extensionDir ?? moduleDir);
249
+ const token = options.token ?? randomBytes(24).toString("hex");
250
+ if (!/^[A-Za-z0-9_-]{24,128}$/.test(token)) throw new Error("Invalid review UI server token.");
251
+
252
+ const routeRoot = `/${token}/`;
253
+ const monacoRoutePrefix = `${routeRoot}vs/`;
254
+ let expectedHost = "";
255
+ let reviewHtml = "";
256
+
257
+ const server = createServer((request, response) => {
258
+ setSecurityHeaders(response);
259
+ if (request.headers.host !== expectedHost) {
260
+ sendText(response, 403, "Forbidden");
261
+ return;
262
+ }
263
+ if (request.method !== "GET" && request.method !== "HEAD") {
264
+ response.setHeader("Allow", "GET, HEAD");
265
+ sendText(response, 405, "Method not allowed");
266
+ return;
267
+ }
268
+
269
+ let pathname: string;
270
+ try {
271
+ pathname = new URL(request.url ?? "/", `http://${expectedHost}`).pathname;
272
+ } catch {
273
+ sendText(response, 400, "Bad request");
274
+ return;
275
+ }
276
+
277
+ if (pathname === routeRoot || pathname === `${routeRoot}index.html`) {
278
+ response.statusCode = 200;
279
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
280
+ response.setHeader("Content-Length", Buffer.byteLength(reviewHtml));
281
+ response.end(request.method === "HEAD" ? undefined : reviewHtml);
282
+ return;
283
+ }
284
+
285
+ if (pathname.startsWith(monacoRoutePrefix)) {
286
+ serveMonacoAsset(request, response, assetPaths.monacoVsDirectory, pathname.slice(monacoRoutePrefix.length));
287
+ return;
288
+ }
289
+
290
+ sendText(response, 404, "Not found");
291
+ });
292
+ // Keep Node's special EventEmitter `error` event non-fatal across disposal
293
+ // races; the scoped listener below still records and reports live failures.
294
+ server.on("error", () => {});
295
+ server.maxHeadersCount = 50;
296
+ server.headersTimeout = 5_000;
297
+ server.keepAliveTimeout = 1_000;
298
+ server.requestTimeout = 15_000;
299
+ server.on("clientError", (_error, socket) => socket.destroy());
300
+ const errorListeners = new Set<(error: Error) => void>();
301
+ let disposed = false;
302
+ let runtimeFailure: Error | null = null;
303
+ const onRuntimeServerError = (error: Error): void => {
304
+ if (disposed || runtimeFailure != null) return;
305
+ runtimeFailure = error;
306
+ disposeServer(server);
307
+ for (const listener of [...errorListeners]) {
308
+ try {
309
+ listener(error);
310
+ } catch {
311
+ // A lifecycle observer must not turn a server failure into an
312
+ // uncaught process-level exception.
313
+ }
314
+ }
315
+ };
316
+
317
+ try {
318
+ await new Promise<void>((resolveListen, rejectListen) => {
319
+ const onError = (error: Error): void => {
320
+ server.off("listening", onListening);
321
+ rejectListen(error);
322
+ };
323
+ const onListening = (): void => {
324
+ server.off("error", onError);
325
+ resolveListen();
326
+ };
327
+ server.once("error", onError);
328
+ server.once("listening", onListening);
329
+ server.listen({ host: loopbackHost, port: 0, exclusive: true });
330
+ });
331
+ server.on("error", onRuntimeServerError);
332
+
333
+ const address = server.address();
334
+ if (address == null || typeof address === "string") throw new Error("Review UI server did not expose a TCP address.");
335
+ expectedHost = `${loopbackHost}:${address.port}`;
336
+ const url = `http://${expectedHost}${routeRoot}`;
337
+ reviewHtml = buildReviewHtml(data, {
338
+ tailwindBrowserJs: readFileSync(assetPaths.tailwindBrowserPath, "utf8"),
339
+ monacoLoaderJs: readFileSync(assetPaths.monacoLoaderPath, "utf8"),
340
+ monacoVsBaseUrl: `${url}vs`,
341
+ });
342
+ server.unref();
343
+
344
+ return {
345
+ get failure() {
346
+ return runtimeFailure;
347
+ },
348
+ html: redirectHtml(url),
349
+ url,
350
+ onError(listener) {
351
+ if (runtimeFailure != null) {
352
+ let active = true;
353
+ queueMicrotask(() => {
354
+ if (active) listener(runtimeFailure as Error);
355
+ });
356
+ return () => {
357
+ active = false;
358
+ };
359
+ }
360
+ if (disposed) return () => {};
361
+ errorListeners.add(listener);
362
+ return () => errorListeners.delete(listener);
363
+ },
364
+ dispose() {
365
+ if (disposed) return;
366
+ disposed = true;
367
+ errorListeners.clear();
368
+ server.off("error", onRuntimeServerError);
369
+ disposeServer(server);
370
+ },
371
+ };
372
+ } catch (error) {
373
+ disposed = true;
374
+ errorListeners.clear();
375
+ server.off("error", onRuntimeServerError);
376
+ disposeServer(server);
377
+ throw error;
378
+ }
379
+ }