@diegopetrucci/pi-extensions 0.1.72 → 0.1.73

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 (53) hide show
  1. package/README.md +2 -2
  2. package/extensions/agent-workflow-audit/index.ts +41 -8
  3. package/extensions/agent-workflow-audit/package.json +1 -1
  4. package/extensions/annotate-git-diff/README.md +7 -3
  5. package/extensions/annotate-git-diff/git.ts +3 -2
  6. package/extensions/annotate-git-diff/index.ts +366 -81
  7. package/extensions/annotate-git-diff/package.json +2 -2
  8. package/extensions/annotate-git-diff/quiet-glimpse.ts +4 -2
  9. package/extensions/annotate-git-diff/types.ts +6 -0
  10. package/extensions/annotate-git-diff/web/app.js +54 -4
  11. package/extensions/annotate-git-diff/web/index.html +2 -2
  12. package/extensions/annotate-last-message/README.md +9 -1
  13. package/extensions/annotate-last-message/index.ts +53 -13
  14. package/extensions/annotate-last-message/package.json +2 -1
  15. package/extensions/annotate-last-message/quiet-glimpse.ts +4 -2
  16. package/extensions/annotate-last-message/theme.ts +247 -0
  17. package/extensions/annotate-last-message/ui.ts +28 -2
  18. package/extensions/annotate-last-message/web/app.js +351 -162
  19. package/extensions/annotate-last-message/web/index.html +153 -24
  20. package/extensions/annotate-last-message/web/md-renderer.js +458 -0
  21. package/extensions/code-reviewer/index.ts +12 -1
  22. package/extensions/code-reviewer/package.json +2 -1
  23. package/extensions/context-cap/README.md +33 -4
  24. package/extensions/context-cap/index.ts +271 -68
  25. package/extensions/context-cap/package.json +1 -1
  26. package/extensions/context-inspector/README.md +2 -0
  27. package/extensions/context-inspector/index.ts +90 -9
  28. package/extensions/context-inspector/package.json +1 -1
  29. package/extensions/contrarian/index.ts +21 -15
  30. package/extensions/contrarian/package.json +1 -1
  31. package/extensions/dynamic-context-pruning/README.md +17 -5
  32. package/extensions/dynamic-context-pruning/index.ts +17 -4
  33. package/extensions/dynamic-context-pruning/package.json +1 -1
  34. package/extensions/fast/README.md +24 -3
  35. package/extensions/fast/index.ts +2 -2
  36. package/extensions/fast/package.json +1 -1
  37. package/extensions/git-footer/README.md +1 -0
  38. package/extensions/git-footer/package.json +1 -1
  39. package/extensions/librarian/index.ts +7 -0
  40. package/extensions/librarian/package.json +2 -1
  41. package/extensions/minimal-footer/README.md +16 -4
  42. package/extensions/minimal-footer/index.ts +263 -14
  43. package/extensions/minimal-footer/openai-usage.ts +62 -8
  44. package/extensions/minimal-footer/package.json +1 -1
  45. package/extensions/oracle/index.ts +19 -18
  46. package/extensions/oracle/package.json +1 -1
  47. package/extensions/permission-gate/README.md +6 -0
  48. package/extensions/permission-gate/index.ts +37 -16
  49. package/extensions/permission-gate/package.json +1 -1
  50. package/extensions/quiet-tools/README.md +1 -1
  51. package/extensions/quiet-tools/package.json +1 -1
  52. package/extensions/triage-comments/package.json +2 -1
  53. package/package.json +4 -4
@@ -8,50 +8,118 @@ import { composeReviewPrompt } from "./prompt.js";
8
8
  import { openQuietGlimpse, type QuietGlimpseWindow } from "./quiet-glimpse.js";
9
9
  import { startReviewUiServer, type ReviewUiServer } from "./review-server.js";
10
10
  import type {
11
+ CommentSide,
12
+ DiffReviewComment,
11
13
  ReviewCancelPayload,
12
14
  ReviewClipboardReadPayload,
13
15
  ReviewClipboardWritePayload,
16
+ ReviewCommitKind,
14
17
  ReviewFile,
15
18
  ReviewFileContents,
16
19
  ReviewHostMessage,
17
20
  ReviewRequestCommitPayload,
18
21
  ReviewRequestFilePayload,
19
22
  ReviewRequestReviewDataPayload,
23
+ ReviewScope,
20
24
  ReviewSubmitPayload,
21
25
  ReviewWindowMessage,
22
26
  } from "./types.js";
23
27
  import { createRepoChangeWatcher, type RepoChangeWatcher } from "./watch.js";
24
28
 
25
- function hasMessageType(value: unknown, type: ReviewWindowMessage["type"]): boolean {
26
- return typeof value === "object" && value != null && "type" in value && value.type === type;
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
31
  }
28
32
 
29
- function isSubmitPayload(value: unknown): value is ReviewSubmitPayload {
30
- return hasMessageType(value, "submit");
33
+ function isString(value: unknown): value is string {
34
+ return typeof value === "string";
35
+ }
36
+
37
+ function isNullableString(value: unknown): value is string | null | undefined {
38
+ return value == null || typeof value === "string";
39
+ }
40
+
41
+ function isCommentSide(value: unknown): value is CommentSide {
42
+ return value === "original" || value === "modified" || value === "file";
43
+ }
44
+
45
+ function isReviewScope(value: unknown): value is ReviewScope {
46
+ return value === "branch" || value === "commits" || value === "all";
47
+ }
48
+
49
+ function isReviewCommitKind(value: unknown): value is ReviewCommitKind | null | undefined {
50
+ return value == null || value === "commit" || value === "working-tree";
51
+ }
52
+
53
+ function hasNullableInteger(value: Record<string, unknown>, key: string): boolean {
54
+ if (!Object.prototype.hasOwnProperty.call(value, key)) return false;
55
+ const field = value[key];
56
+ return field === null || (typeof field === "number" && Number.isInteger(field));
57
+ }
58
+
59
+ function isDiffReviewComment(value: unknown): value is DiffReviewComment {
60
+ if (!isRecord(value)) return false;
61
+ return (
62
+ isString(value.id) &&
63
+ isString(value.fileId) &&
64
+ isReviewScope(value.scope) &&
65
+ isNullableString(value.commitSha) &&
66
+ isNullableString(value.commitShort) &&
67
+ isReviewCommitKind(value.commitKind) &&
68
+ isCommentSide(value.side) &&
69
+ hasNullableInteger(value, "startLine") &&
70
+ hasNullableInteger(value, "endLine") &&
71
+ isString(value.body)
72
+ );
73
+ }
74
+
75
+ function parseSubmitPayload(value: unknown): ReviewSubmitPayload | null {
76
+ if (!isRecord(value) || value.type !== "submit") return null;
77
+ if (!isString(value.overallComment) || !Array.isArray(value.comments) || !value.comments.every(isDiffReviewComment)) {
78
+ return null;
79
+ }
80
+
81
+ // Fail-safe: only a literal false means the user explicitly clicked Submit.
82
+ // Missing, true, and malformed discriminator values remain editor-only drafts.
83
+ return {
84
+ type: "submit",
85
+ overallComment: value.overallComment,
86
+ comments: value.comments,
87
+ draft: value.draft !== false,
88
+ };
31
89
  }
32
90
 
33
91
  function isCancelPayload(value: unknown): value is ReviewCancelPayload {
34
- return hasMessageType(value, "cancel");
92
+ return isRecord(value) && value.type === "cancel";
93
+ }
94
+
95
+ function hasMessageType(value: unknown, type: ReviewWindowMessage["type"]): value is Record<string, unknown> {
96
+ return isRecord(value) && value.type === type;
35
97
  }
36
98
 
37
99
  function isRequestFilePayload(value: unknown): value is ReviewRequestFilePayload {
38
- return hasMessageType(value, "request-file");
100
+ return (
101
+ hasMessageType(value, "request-file") &&
102
+ isString(value.requestId) &&
103
+ isString(value.fileId) &&
104
+ isReviewScope(value.scope) &&
105
+ isNullableString(value.commitSha)
106
+ );
39
107
  }
40
108
 
41
109
  function isRequestCommitPayload(value: unknown): value is ReviewRequestCommitPayload {
42
- return hasMessageType(value, "request-commit");
110
+ return hasMessageType(value, "request-commit") && isString(value.requestId) && isString(value.sha);
43
111
  }
44
112
 
45
113
  function isRequestReviewDataPayload(value: unknown): value is ReviewRequestReviewDataPayload {
46
- return hasMessageType(value, "request-review-data");
114
+ return hasMessageType(value, "request-review-data") && isString(value.requestId);
47
115
  }
48
116
 
49
117
  function isClipboardReadPayload(value: unknown): value is ReviewClipboardReadPayload {
50
- return hasMessageType(value, "clipboard-read");
118
+ return hasMessageType(value, "clipboard-read") && isString(value.requestId);
51
119
  }
52
120
 
53
121
  function isClipboardWritePayload(value: unknown): value is ReviewClipboardWritePayload {
54
- return hasMessageType(value, "clipboard-write");
122
+ return hasMessageType(value, "clipboard-write") && isString(value.text);
55
123
  }
56
124
 
57
125
  function escapeForInlineScript(value: string): string {
@@ -143,21 +211,85 @@ export default function (pi: ExtensionAPI) {
143
211
  if (window.failure != null) throw window.failure;
144
212
  if (window.closed) throw new Error("Glimpse closed while the review window was starting.");
145
213
 
146
- const fileMap = new Map(reviewData.files.map((file) => [file.id, file]));
214
+ const historicalFiles = new Map<string, ReviewFile>();
215
+ const rememberFiles = (files: ReviewFile[]): void => {
216
+ for (const file of files) {
217
+ // A submitted comment may outlive a refresh. Keep the first metadata
218
+ // advertised for an id so refreshed authorization cannot rewrite its
219
+ // historical path in the composed prompt.
220
+ if (!historicalFiles.has(file.id)) historicalFiles.set(file.id, file);
221
+ }
222
+ };
223
+ rememberFiles(reviewData.files);
224
+
225
+ type ReviewReadSnapshot = {
226
+ generation: number;
227
+ data: typeof reviewData;
228
+ branchFiles: Map<string, ReviewFile>;
229
+ allFiles: Map<string, ReviewFile>;
230
+ commitFiles: Map<string, Map<string, ReviewFile>>;
231
+ };
232
+
233
+ const createReviewReadSnapshot = (
234
+ data: typeof reviewData,
235
+ generation: number,
236
+ previous: ReviewReadSnapshot | null,
237
+ ): ReviewReadSnapshot => {
238
+ const branchFiles = new Map<string, ReviewFile>();
239
+ const allFiles = new Map<string, ReviewFile>();
240
+ for (const file of data.files) {
241
+ allFiles.set(file.id, file);
242
+ if (file.inGitDiff) branchFiles.set(file.id, file);
243
+ }
244
+
245
+ const commitFiles = new Map<string, Map<string, ReviewFile>>();
246
+ for (const commit of data.commits) {
247
+ if (commit.kind !== "commit") continue;
248
+ const retainedFiles = previous?.commitFiles.get(commit.sha);
249
+ if (retainedFiles != null) commitFiles.set(commit.sha, retainedFiles);
250
+ }
251
+
252
+ return { generation, data, branchFiles, allFiles, commitFiles };
253
+ };
254
+
147
255
  const commitFileCache = new Map<string, Promise<ReviewFile[]>>();
148
256
  const contentCache = new Map<string, Promise<ReviewFileContents>>();
257
+ let currentSnapshot = createReviewReadSnapshot(reviewData, 0, null);
258
+ const isLiveWindow = (): boolean => activeWindow === window && attempt === reviewAttempt && !window.closed;
259
+
260
+ const cacheCommitSha = (cacheKey: string): string | null => {
261
+ if (!cacheKey.startsWith("commits:")) return null;
262
+ const rest = cacheKey.slice("commits:".length);
263
+ const separator = rest.indexOf(":");
264
+ return separator < 0 ? null : rest.slice(0, separator);
265
+ };
149
266
 
150
- const clearRefreshableCaches = (): void => {
151
- contentCache.clear();
267
+ const pruneCachesForReviewData = (nextReviewData: typeof reviewData): void => {
268
+ const retainedImmutableCommits = new Set(
269
+ nextReviewData.commits.filter((commit) => commit.kind === "commit").map((commit) => commit.sha),
270
+ );
152
271
  for (const sha of commitFileCache.keys()) {
153
- if (isWorkingTreeCommitSha(sha)) {
154
- commitFileCache.delete(sha);
272
+ if (!retainedImmutableCommits.has(sha)) commitFileCache.delete(sha);
273
+ }
274
+ for (const cacheKey of contentCache.keys()) {
275
+ const commitSha = cacheCommitSha(cacheKey);
276
+ if (commitSha == null || !retainedImmutableCommits.has(commitSha)) {
277
+ contentCache.delete(cacheKey);
155
278
  }
156
279
  }
157
280
  };
158
281
 
159
- const sendWindowMessage = (message: ReviewHostMessage): void => {
160
- if (activeWindow !== window) return;
282
+ const replaceReviewData = (nextReviewData: typeof reviewData): void => {
283
+ pruneCachesForReviewData(nextReviewData);
284
+ const previousSnapshot = currentSnapshot;
285
+ reviewData = nextReviewData;
286
+ rememberFiles(nextReviewData.files);
287
+ currentSnapshot = createReviewReadSnapshot(nextReviewData, previousSnapshot.generation + 1, previousSnapshot);
288
+ };
289
+
290
+ const sendWindowMessage = (message: ReviewHostMessage, guard?: () => boolean): void => {
291
+ if (!isLiveWindow()) return;
292
+ if (guard != null && !guard()) return;
161
293
  const payload = escapeForInlineScript(JSON.stringify(message));
162
294
  window.send(`window.__reviewReceive(${payload});`);
163
295
  };
@@ -184,9 +316,16 @@ export default function (pi: ExtensionAPI) {
184
316
  commitFileCache.set(sha, pending);
185
317
  pending
186
318
  .then((commitFiles) => {
187
- for (const cf of commitFiles) fileMap.set(cf.id, cf);
319
+ if (commitFileCache.get(sha) !== pending || !isLiveWindow()) return;
320
+ if (!currentSnapshot.data.commits.some((commit) => commit.sha === sha)) return;
321
+ rememberFiles(commitFiles);
322
+ currentSnapshot.commitFiles.set(sha, new Map(commitFiles.map((file) => [file.id, file])));
188
323
  })
189
- .catch(() => {});
324
+ .catch(() => {
325
+ // A rejected promise is retryable, but an old rejection must not
326
+ // evict a replacement request for the same key.
327
+ if (isLiveWindow() && commitFileCache.get(sha) === pending) commitFileCache.delete(sha);
328
+ });
190
329
  return pending;
191
330
  };
192
331
 
@@ -194,16 +333,53 @@ export default function (pi: ExtensionAPI) {
194
333
  file: ReviewFile,
195
334
  scope: ReviewRequestFilePayload["scope"],
196
335
  commitSha: string | null,
336
+ snapshot: ReviewReadSnapshot,
197
337
  ): Promise<ReviewFileContents> => {
198
338
  const cacheKey = `${scope}:${commitSha ?? ""}:${file.id}`;
199
339
  const cached = contentCache.get(cacheKey);
200
340
  if (cached != null) return cached;
201
341
 
202
- const pending = loadReviewFileContents(pi, repoRoot, file, scope, commitSha, reviewData.branchMergeBaseSha);
342
+ const pending = loadReviewFileContents(pi, repoRoot, file, scope, commitSha, snapshot.data.branchMergeBaseSha);
203
343
  contentCache.set(cacheKey, pending);
344
+ pending.catch(() => {
345
+ // Do not let an older rejection remove a newer retry's promise.
346
+ if (isLiveWindow() && contentCache.get(cacheKey) === pending) contentCache.delete(cacheKey);
347
+ });
204
348
  return pending;
205
349
  };
206
350
 
351
+ const isAdvertisedCommitSha = (snapshot: ReviewReadSnapshot, sha: string): boolean =>
352
+ snapshot.data.commits.some((commit) => commit.sha === sha);
353
+
354
+ const isCurrentCommitRequest = (
355
+ snapshot: ReviewReadSnapshot,
356
+ sha: string,
357
+ pending: Promise<ReviewFile[]> | null = null,
358
+ ): boolean => {
359
+ if (!isLiveWindow()) return false;
360
+ const commit = currentSnapshot.data.commits.find((item) => item.sha === sha);
361
+ if (commit == null) return false;
362
+ if (currentSnapshot === snapshot) return true;
363
+ return commit.kind === "commit" && pending != null && commitFileCache.get(sha) === pending;
364
+ };
365
+
366
+ const isCurrentFileRequest = (
367
+ snapshot: ReviewReadSnapshot,
368
+ scope: ReviewRequestFilePayload["scope"],
369
+ commitSha: string | null,
370
+ file: ReviewFile,
371
+ ): boolean => {
372
+ if (!isLiveWindow()) return false;
373
+ if (scope === "commits") {
374
+ if (commitSha == null || !isAdvertisedCommitSha(currentSnapshot, commitSha)) return false;
375
+ if (isWorkingTreeCommitSha(commitSha) && currentSnapshot !== snapshot) return false;
376
+ return currentSnapshot.commitFiles.get(commitSha)?.get(file.id) === file;
377
+ }
378
+ if (currentSnapshot !== snapshot) return false;
379
+ const files = scope === "branch" ? currentSnapshot.branchFiles : currentSnapshot.allFiles;
380
+ return files.get(file.id) === file;
381
+ };
382
+
207
383
  const terminalMessagePromise = new Promise<ReviewSubmitPayload | ReviewCancelPayload | null>(
208
384
  (resolve, reject) => {
209
385
  let settled = false;
@@ -244,59 +420,146 @@ export default function (pi: ExtensionAPI) {
244
420
  };
245
421
 
246
422
  const handleRequestFile = async (message: ReviewRequestFilePayload): Promise<void> => {
423
+ const snapshot = currentSnapshot;
424
+ const currentGuard = (): boolean => isLiveWindow() && currentSnapshot === snapshot;
425
+ const commitSha = message.commitSha ?? null;
426
+ if (message.scope === "commits") {
427
+ if (commitSha == null) {
428
+ sendWindowMessage(
429
+ {
430
+ type: "file-error",
431
+ requestId: message.requestId,
432
+ fileId: message.fileId,
433
+ scope: message.scope,
434
+ commitSha: null,
435
+ message: "A commit SHA is required for commit-scoped file requests.",
436
+ },
437
+ currentGuard,
438
+ );
439
+ return;
440
+ }
441
+ if (!isAdvertisedCommitSha(snapshot, commitSha)) {
442
+ sendWindowMessage(
443
+ {
444
+ type: "file-error",
445
+ requestId: message.requestId,
446
+ fileId: message.fileId,
447
+ scope: message.scope,
448
+ commitSha,
449
+ message: "Unknown commit requested.",
450
+ },
451
+ currentGuard,
452
+ );
453
+ return;
454
+ }
455
+ } else if (commitSha != null) {
456
+ sendWindowMessage(
457
+ {
458
+ type: "file-error",
459
+ requestId: message.requestId,
460
+ fileId: message.fileId,
461
+ scope: message.scope,
462
+ commitSha,
463
+ message: "A commit SHA is only valid for commit-scoped file requests.",
464
+ },
465
+ currentGuard,
466
+ );
467
+ return;
468
+ }
469
+
470
+ const fileMap =
471
+ message.scope === "branch"
472
+ ? snapshot.branchFiles
473
+ : message.scope === "all"
474
+ ? snapshot.allFiles
475
+ : snapshot.commitFiles.get(commitSha as string) ?? new Map<string, ReviewFile>();
247
476
  const file = fileMap.get(message.fileId);
248
477
  if (file == null) {
249
- sendWindowMessage({
250
- type: "file-error",
251
- requestId: message.requestId,
252
- fileId: message.fileId,
253
- scope: message.scope,
254
- commitSha: message.commitSha ?? null,
255
- message: "Unknown file requested.",
256
- });
478
+ sendWindowMessage(
479
+ {
480
+ type: "file-error",
481
+ requestId: message.requestId,
482
+ fileId: message.fileId,
483
+ scope: message.scope,
484
+ commitSha,
485
+ message: "Unknown file requested.",
486
+ },
487
+ currentGuard,
488
+ );
257
489
  return;
258
490
  }
259
491
 
260
492
  try {
261
- const contents = await loadContents(file, message.scope, message.commitSha ?? null);
262
- sendWindowMessage({
263
- type: "file-data",
264
- requestId: message.requestId,
265
- fileId: message.fileId,
266
- scope: message.scope,
267
- commitSha: message.commitSha ?? null,
268
- originalContent: contents.originalContent,
269
- modifiedContent: contents.modifiedContent,
270
- kind: contents.kind,
271
- mimeType: contents.mimeType,
272
- originalExists: contents.originalExists,
273
- modifiedExists: contents.modifiedExists,
274
- originalPreviewUrl: contents.originalPreviewUrl,
275
- modifiedPreviewUrl: contents.modifiedPreviewUrl,
276
- });
493
+ const contents = await loadContents(file, message.scope, commitSha, snapshot);
494
+ if (!isCurrentFileRequest(snapshot, message.scope, commitSha, file)) return;
495
+ sendWindowMessage(
496
+ {
497
+ type: "file-data",
498
+ requestId: message.requestId,
499
+ fileId: message.fileId,
500
+ scope: message.scope,
501
+ commitSha,
502
+ originalContent: contents.originalContent,
503
+ modifiedContent: contents.modifiedContent,
504
+ kind: contents.kind,
505
+ mimeType: contents.mimeType,
506
+ originalExists: contents.originalExists,
507
+ modifiedExists: contents.modifiedExists,
508
+ originalPreviewUrl: contents.originalPreviewUrl,
509
+ modifiedPreviewUrl: contents.modifiedPreviewUrl,
510
+ },
511
+ () => isCurrentFileRequest(snapshot, message.scope, commitSha, file),
512
+ );
277
513
  } catch (error) {
514
+ if (!isCurrentFileRequest(snapshot, message.scope, commitSha, file)) return;
278
515
  const messageText = error instanceof Error ? error.message : String(error);
279
- sendWindowMessage({
280
- type: "file-error",
281
- requestId: message.requestId,
282
- fileId: message.fileId,
283
- scope: message.scope,
284
- commitSha: message.commitSha ?? null,
285
- message: messageText,
286
- });
516
+ sendWindowMessage(
517
+ {
518
+ type: "file-error",
519
+ requestId: message.requestId,
520
+ fileId: message.fileId,
521
+ scope: message.scope,
522
+ commitSha,
523
+ message: messageText,
524
+ },
525
+ () => isCurrentFileRequest(snapshot, message.scope, commitSha, file),
526
+ );
287
527
  }
288
528
  };
289
529
 
290
530
  const handleRequestCommit = async (message: ReviewRequestCommitPayload): Promise<void> => {
531
+ const snapshot = currentSnapshot;
532
+ const currentGuard = (): boolean => isLiveWindow() && currentSnapshot === snapshot;
533
+ if (!isAdvertisedCommitSha(snapshot, message.sha)) {
534
+ sendWindowMessage(
535
+ {
536
+ type: "commit-error",
537
+ requestId: message.requestId,
538
+ sha: message.sha,
539
+ message: "Unknown commit requested.",
540
+ },
541
+ currentGuard,
542
+ );
543
+ return;
544
+ }
545
+
546
+ const pending = loadCommitFiles(message.sha);
291
547
  try {
292
- const commitFiles = await loadCommitFiles(message.sha);
293
- sendWindowMessage({
294
- type: "commit-data",
295
- requestId: message.requestId,
296
- sha: message.sha,
297
- files: commitFiles,
298
- });
548
+ const commitFiles = await pending;
549
+ if (!isCurrentCommitRequest(snapshot, message.sha, pending)) return;
550
+ if (currentSnapshot.commitFiles.get(message.sha) == null) return;
551
+ sendWindowMessage(
552
+ {
553
+ type: "commit-data",
554
+ requestId: message.requestId,
555
+ sha: message.sha,
556
+ files: commitFiles,
557
+ },
558
+ () =>
559
+ isCurrentCommitRequest(snapshot, message.sha, pending) && currentSnapshot.commitFiles.get(message.sha) != null,
560
+ );
299
561
  } catch (error) {
562
+ if (!isCurrentCommitRequest(snapshot, message.sha, pending)) return;
300
563
  const messageText = error instanceof Error ? error.message : String(error);
301
564
  sendWindowMessage({
302
565
  type: "commit-error",
@@ -307,22 +570,29 @@ export default function (pi: ExtensionAPI) {
307
570
  }
308
571
  };
309
572
 
573
+ let refreshRequestSequence = 0;
574
+ let newestRefreshRequest = 0;
310
575
  const handleRequestReviewData = async (message: ReviewRequestReviewDataPayload): Promise<void> => {
576
+ const refreshRequest = ++refreshRequestSequence;
577
+ newestRefreshRequest = refreshRequest;
311
578
  try {
312
579
  const nextReviewData = await getReviewWindowData(pi, repoRoot);
313
- clearRefreshableCaches();
314
- reviewData = nextReviewData;
315
- for (const file of reviewData.files) fileMap.set(file.id, file);
316
- sendWindowMessage({
317
- type: "review-data",
318
- requestId: message.requestId,
319
- files: reviewData.files,
320
- commits: reviewData.commits,
321
- branchBaseRef: reviewData.branchBaseRef,
322
- branchMergeBaseSha: reviewData.branchMergeBaseSha,
323
- repositoryHasHead: reviewData.repositoryHasHead,
324
- });
580
+ if (!isLiveWindow() || refreshRequest !== newestRefreshRequest) return;
581
+ replaceReviewData(nextReviewData);
582
+ sendWindowMessage(
583
+ {
584
+ type: "review-data",
585
+ requestId: message.requestId,
586
+ files: nextReviewData.files,
587
+ commits: nextReviewData.commits,
588
+ branchBaseRef: nextReviewData.branchBaseRef,
589
+ branchMergeBaseSha: nextReviewData.branchMergeBaseSha,
590
+ repositoryHasHead: nextReviewData.repositoryHasHead,
591
+ },
592
+ () => isLiveWindow() && refreshRequest === newestRefreshRequest,
593
+ );
325
594
  } catch (error) {
595
+ if (!isLiveWindow() || refreshRequest !== newestRefreshRequest) return;
326
596
  const messageText = error instanceof Error ? error.message : String(error);
327
597
  sendWindowMessage({
328
598
  type: "review-data-error",
@@ -360,6 +630,20 @@ export default function (pi: ExtensionAPI) {
360
630
  };
361
631
 
362
632
  const onMessage = (message: unknown): void => {
633
+ // Terminal messages remain recoverable during the native close grace
634
+ // period; all other work must stop once the window is closed or retired.
635
+ const submit = parseSubmitPayload(message);
636
+ if (submit != null) {
637
+ settle(submit);
638
+ requestWindowClose();
639
+ return;
640
+ }
641
+ if (isCancelPayload(message)) {
642
+ settle(message);
643
+ requestWindowClose();
644
+ return;
645
+ }
646
+ if (!isLiveWindow()) return;
363
647
  if (isRequestFilePayload(message)) {
364
648
  void handleRequestFile(message);
365
649
  return;
@@ -378,11 +662,6 @@ export default function (pi: ExtensionAPI) {
378
662
  }
379
663
  if (isClipboardWritePayload(message)) {
380
664
  handleClipboardWrite(message);
381
- return;
382
- }
383
- if (isSubmitPayload(message) || isCancelPayload(message)) {
384
- requestWindowClose();
385
- settle(message);
386
665
  }
387
666
  };
388
667
 
@@ -415,7 +694,7 @@ export default function (pi: ExtensionAPI) {
415
694
  void (async () => {
416
695
  try {
417
696
  const message = await terminalMessagePromise;
418
- if (suppressedWindows.has(window)) return;
697
+ if (suppressedWindows.has(window) || attempt !== reviewAttempt) return;
419
698
  if (message == null) return;
420
699
  if (message.type === "cancel") {
421
700
  ctx.ui.notify("Review cancelled.", "info");
@@ -423,11 +702,16 @@ export default function (pi: ExtensionAPI) {
423
702
  }
424
703
  if (!hasReviewFeedback(message)) return;
425
704
 
426
- const prompt = composeReviewPrompt([...fileMap.values()], message);
427
- appendReviewPrompt(ctx, prompt);
428
- ctx.ui.notify("Appended review feedback to the editor.", "info");
705
+ const prompt = composeReviewPrompt([...historicalFiles.values()], message);
706
+ if (message.draft === true) {
707
+ appendReviewPrompt(ctx, prompt);
708
+ ctx.ui.notify("Appended review feedback to the editor.", "info");
709
+ } else {
710
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
711
+ ctx.ui.notify("Review feedback sent to the agent.", "info");
712
+ }
429
713
  } catch (error) {
430
- if (suppressedWindows.has(window)) return;
714
+ if (suppressedWindows.has(window) || attempt !== reviewAttempt) return;
431
715
  const message = error instanceof Error ? error.message : String(error);
432
716
  ctx.ui.notify(`Review failed: ${message}`, "error");
433
717
  }
@@ -435,6 +719,7 @@ export default function (pi: ExtensionAPI) {
435
719
 
436
720
  ctx.ui.notify("Opened native review window.", "info");
437
721
  } catch (error) {
722
+ if (attempt !== reviewAttempt) return;
438
723
  closeActiveWindow({ suppressResults: true });
439
724
  const message = error instanceof Error ? error.message : String(error);
440
725
  ctx.ui.notify(`Review failed: ${message}`, "error");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-annotate-git-diff",
3
- "version": "0.1.11",
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.",
3
+ "version": "0.1.12",
4
+ "description": "A standalone pi extension that adds /annotate-git-diff, a native Glimpse UI for annotating git diffs and sending explicit feedback directly to the agent; close recovery keeps unsent drafts in the editor.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -57,13 +57,15 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
57
57
 
58
58
  const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
59
59
  rl.on("line", (line) => {
60
- let message: GlimpseProtocolMessage;
60
+ let parsed: unknown;
61
61
  try {
62
- message = JSON.parse(line) as GlimpseProtocolMessage;
62
+ parsed = JSON.parse(line);
63
63
  } catch {
64
64
  this.#reportError(new Error(`Malformed glimpse protocol line: ${line}`));
65
65
  return;
66
66
  }
67
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return;
68
+ const message = parsed as GlimpseProtocolMessage;
67
69
 
68
70
  switch (message.type) {
69
71
  case "ready":
@@ -71,6 +71,12 @@ export interface ReviewSubmitPayload {
71
71
  type: "submit";
72
72
  overallComment: string;
73
73
  comments: DiffReviewComment[];
74
+ /**
75
+ * True means this is a close-recovery draft and must stay editor-only.
76
+ * Only literal false permits an explicit submission to trigger an agent turn;
77
+ * malformed or missing values are normalized to true by the host.
78
+ */
79
+ draft: boolean;
74
80
  }
75
81
 
76
82
  export interface ReviewCancelPayload {