pi-vscode-sr 1.4.6 → 1.4.7

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.
package/README.md CHANGED
@@ -17,8 +17,8 @@ Secure code review bridge between **Pi coding agent** and **VS Code**. Every fil
17
17
  | ✅ **Approve** | Apply this file's changes |
18
18
  | ❌ **Reject** | Discard this file's changes — agent sees an error and must retry |
19
19
  | 💭 **Rethink** | Open a text input dialog to give the agent feedback — e.g. «use async/await instead of promise chains». Changes are not applied, agent sees your feedback and can retry with corrections. |
20
- | ⭐ **Approve All** | Auto-approve every future change for this session |
21
- | 🚪 **Abort** | Stop the agent session immediately |
20
+ | ⭐ **Approve All** | Auto-approve every future change for this prompt run. Clear on next prompt. |
21
+ | 🚪 **Abort** | Stop the agent session immediately. |
22
22
 
23
23
  You can also approve/reject from the diff tab.
24
24
 
@@ -31,6 +31,7 @@ You can also approve/reject from the diff tab.
31
31
  > - .pi/review-requests/
32
32
  > - .pi/review-results/
33
33
  > - .pi/tmp/
34
+ > - .pi/.vscode-ready
34
35
 
35
36
  ### 1. Pi Extension
36
37
 
@@ -38,7 +39,11 @@ You can also approve/reject from the diff tab.
38
39
  pi install npm:pi-vscode-sr
39
40
  ```
40
41
 
41
- Or install locally:
42
+ Or install locally (**Recommended**):
43
+
44
+ > [!IMPORTANT]
45
+ > I recommend to install it locally because not every folder is a project of Visual Studio Code. Although there is a mechanism to detect if project is opened in VS Code or not anyway it could lead to unexpected behavior.
46
+
42
47
 
43
48
  ```bash
44
49
  pi install npm:pi-vscode-sr -l
package/dist/index.js CHANGED
@@ -9,61 +9,89 @@ const path_1 = require("path");
9
9
  // ─── Session state ───────────────────────────────────────────────────
10
10
  const sessionReviewIds = new Set();
11
11
  const sessionApproveAll = new Set(); // review IDs auto-approved
12
+ let projectCwd = null;
13
+ let vscodeNotOpenWarned = false;
14
+ // ─── Helper: check if VS Code is watching this project ─────────────
15
+ function isVscodeReady(cwd) {
16
+ try {
17
+ const readyFile = (0, path_1.join)(cwd, '.pi', '.vscode-ready');
18
+ if (!(0, fs_1.existsSync)(readyFile))
19
+ return false;
20
+ const ts = parseInt((0, fs_1.readFileSync)(readyFile, 'utf-8').trim(), 10);
21
+ if (isNaN(ts))
22
+ return false;
23
+ // Timestamp must be within last 30 seconds (heartbeat = 15s interval)
24
+ return Date.now() - ts < 30_000;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
12
30
  async function createReviewAndWait(ctx, filePath, original, proposed, description) {
31
+ // Normalize path: LLM may pass absolute-looking path without leading /
32
+ const normalizedPath = resolveSafe(ctx.cwd, filePath);
13
33
  const uuid = (0, crypto_1.randomUUID)();
14
- const requestsDir = (0, path_1.join)(ctx.cwd, ".pi", "review-requests");
15
34
  const resultsDir = (0, path_1.join)(ctx.cwd, ".pi", "review-results");
16
- (0, fs_1.mkdirSync)(requestsDir, { recursive: true });
17
35
  (0, fs_1.mkdirSync)(resultsDir, { recursive: true });
18
- const reviewRequest = {
19
- id: uuid,
20
- title: description,
21
- files: [{ path: filePath, original, proposed, description }],
22
- };
23
- (0, fs_1.writeFileSync)((0, path_1.join)(requestsDir, `${uuid}.json`), JSON.stringify(reviewRequest, null, 2), "utf-8");
24
- ctx.ui.notify(`📝 Review: ${filePath} — check VS Code diff`, "info");
25
36
  sessionReviewIds.add(uuid);
26
37
  // Check if approve-all was already chosen
27
38
  if (sessionApproveAll.size > 0) {
28
39
  writeSyncResult(resultsDir, uuid, "approved", proposed);
29
40
  return { status: "approved", final: proposed };
30
41
  }
31
- // Phase 1: give VS Code a head start (2s, poll every 100ms) so TUI doesn't
32
- // pop up when the user is already reviewing in editor.
33
- // 2 seconds with 100ms intervals = 20 checks catches VS Code response quickly.
34
- const resultPath = (0, path_1.join)(resultsDir, `${uuid}.json`);
35
- const deadline = Date.now() + 10 * 60 * 1000;
36
- const early = await pollResultFile(resultPath, Date.now() + 2000, 100);
37
- if (early !== "timeout") {
38
- if (early === "file-rejected")
39
- return { status: "rejected" };
40
- return { status: "approved", final: proposed };
41
- }
42
- // Sync check: VS Code may have written the result file between poll intervals.
43
- if ((0, fs_1.existsSync)(resultPath)) {
44
- const result = JSON.parse((0, fs_1.readFileSync)(resultPath, "utf-8"));
45
- if (result.status === "rejected" || result.files?.[0]?.status === "rejected") {
46
- return { status: "rejected" };
47
- }
42
+ // Detect whether VS Code is open with this project.
43
+ // If not: bypass review entirely direct write, no TUI, no polling.
44
+ // Warning is shown once at session_start, not here on every tool call.
45
+ if (!isVscodeReady(ctx.cwd)) {
48
46
  return { status: "approved", final: proposed };
49
47
  }
50
- // Phase 2: show TUI and keep polling VS Code in parallel (every 500ms)
51
- const tuiPromise = showTuiSelector(ctx, filePath);
48
+ // VS Code is open create review request, poll for results, show TUI
49
+ const requestsDir = (0, path_1.join)(ctx.cwd, ".pi", "review-requests");
50
+ (0, fs_1.mkdirSync)(requestsDir, { recursive: true });
51
+ const reviewRequest = {
52
+ id: uuid,
53
+ title: description,
54
+ files: [{ path: normalizedPath, original, proposed, description }],
55
+ };
56
+ (0, fs_1.writeFileSync)((0, path_1.join)(requestsDir, `${uuid}.json`), JSON.stringify(reviewRequest, null, 2), "utf-8");
57
+ ctx.ui.notify(`📝 Review: ${filePath} — check VS Code diff`, "info");
58
+ // TUI selector races with VS Code result polling
59
+ const resultPath = (0, path_1.join)(resultsDir, `${uuid}.json`);
60
+ const deadline = Date.now() + 10 * 60 * 1000;
61
+ const tuiController = new AbortController();
62
+ const tuiPromise = showTuiSelector(ctx, filePath, { signal: tuiController.signal });
52
63
  const pollPromise = pollResultFile(resultPath, deadline, 500);
53
64
  const outcome = await Promise.race([tuiPromise, pollPromise]);
65
+ // If poll resolved first (VS Code responded), dismiss the TUI selector
66
+ if (outcome.action === "file-approved" || outcome.action === "file-rejected") {
67
+ tuiController.abort();
68
+ await tuiPromise.catch(() => { });
69
+ }
54
70
  // ── Process outcome (return result, never throw) ──
55
- if (outcome === "abort") {
71
+ if (outcome.action === "abort") {
56
72
  writeSyncResult(resultsDir, uuid, "rejected");
57
73
  ctx.abort();
58
74
  return { status: "rejected" };
59
75
  }
60
- if (outcome === "file-rejected" || outcome === "rejected") {
76
+ if (outcome.action === "rethink") {
77
+ writeSyncResult(resultsDir, uuid, "rejected");
78
+ return { status: "rethink", prompt: outcome.prompt };
79
+ }
80
+ if (outcome.action === "file-rejected") {
81
+ return { status: "rejected" };
82
+ }
83
+ if (outcome.action === "rejected") {
84
+ writeSyncResult(resultsDir, uuid, "rejected");
61
85
  return { status: "rejected" };
62
86
  }
63
- if (outcome === "file-approved" || outcome === "approved") {
87
+ if (outcome.action === "file-approved") {
88
+ return { status: "approved", final: proposed };
89
+ }
90
+ if (outcome.action === "approved") {
91
+ writeSyncResult(resultsDir, uuid, "approved", proposed);
64
92
  return { status: "approved", final: proposed };
65
93
  }
66
- if (outcome === "approve-all") {
94
+ if (outcome.action === "approve-all") {
67
95
  for (const rid of sessionReviewIds) {
68
96
  sessionApproveAll.add(rid);
69
97
  }
@@ -84,16 +112,16 @@ async function pollResultFile(resultPath, deadline, interval = 500) {
84
112
  if ((0, fs_1.existsSync)(resultPath)) {
85
113
  const raw = (0, fs_1.readFileSync)(resultPath, "utf-8");
86
114
  if (!raw.trim()) {
87
- // File exists but is empty — still being written
88
- await sleep(200);
115
+ // File exists but is empty — still being written, retry next cycle
116
+ await sleep(interval);
89
117
  continue;
90
118
  }
91
119
  const result = JSON.parse(raw);
92
120
  const fileResult = result.files?.[0];
93
121
  if (result.status === "rejected" || fileResult?.status === "rejected") {
94
- return "file-rejected";
122
+ return { action: "file-rejected" };
95
123
  }
96
- return "file-approved";
124
+ return { action: "file-approved" };
97
125
  }
98
126
  }
99
127
  catch {
@@ -101,26 +129,33 @@ async function pollResultFile(resultPath, deadline, interval = 500) {
101
129
  }
102
130
  await sleep(interval);
103
131
  }
104
- return "timeout";
132
+ return { action: "timeout" };
105
133
  }
106
- async function showTuiSelector(ctx, filePath) {
134
+ async function showTuiSelector(ctx, filePath, opts) {
107
135
  const choice = await ctx.ui.select(`📝 Review: ${filePath}`, [
108
136
  "✅ Approve",
109
137
  "❌ Reject",
138
+ "💭 Rethink",
110
139
  "⭐ Approve All for this session",
111
140
  "🚪 Abort",
112
- ]);
141
+ ], opts);
113
142
  if (!choice)
114
- return "timeout";
143
+ return { action: "timeout" };
115
144
  if (choice.startsWith("🚪"))
116
- return "abort";
145
+ return { action: "abort" };
117
146
  if (choice.startsWith("⭐"))
118
- return "approve-all";
147
+ return { action: "approve-all" };
148
+ if (choice.startsWith("💭")) {
149
+ const prompt = await ctx.ui.input("🔄 Rethink — what should the agent reconsider?", "Describe what needs to change...", opts);
150
+ if (!prompt || !prompt.trim())
151
+ return { action: "rejected" };
152
+ return { action: "rethink", prompt: prompt.trim() };
153
+ }
119
154
  if (choice.startsWith("✅"))
120
- return "approved";
155
+ return { action: "approved" };
121
156
  if (choice.startsWith("❌"))
122
- return "rejected";
123
- return "timeout";
157
+ return { action: "rejected" };
158
+ return { action: "timeout" };
124
159
  }
125
160
  function sleep(ms) {
126
161
  return new Promise((r) => setTimeout(r, ms));
@@ -141,6 +176,22 @@ function applyEdits(content, edits) {
141
176
  }
142
177
  return result;
143
178
  }
179
+ // ─── Path normalization ──────────────────────────────────────────────
180
+ /**
181
+ * Safe path resolution. If the LLM passes an absolute-looking path
182
+ * without a leading slash (e.g. "home/user/project/file.ts"), resolve()
183
+ * treats it as relative and doubles the cwd. Detect and fix this.
184
+ */
185
+ function resolveSafe(cwd, filePath) {
186
+ // Strip leading/trailing slashes from cwd for comparison
187
+ const cwdClean = cwd.replace(/\/+$/, "").replace(/^\//, "");
188
+ // If filePath starts with cwdClean/ (LLM forgot the leading /),
189
+ // strip it so resolve doesn't double.
190
+ if (filePath.startsWith(cwdClean + "/")) {
191
+ filePath = filePath.substring(cwdClean.length + 1);
192
+ }
193
+ return (0, path_1.resolve)(cwd, filePath);
194
+ }
144
195
  // ─── Override `write` ────────────────────────────────────────────────
145
196
  function registerWriteOverride(pi) {
146
197
  pi.registerTool({
@@ -161,7 +212,8 @@ function registerWriteOverride(pi) {
161
212
  }),
162
213
  executionMode: "sequential",
163
214
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
164
- const absolutePath = (0, path_1.resolve)(ctx.cwd, params.path);
215
+ projectCwd = ctx.cwd;
216
+ const absolutePath = resolveSafe(ctx.cwd, params.path);
165
217
  let original = "";
166
218
  let fileExists = false;
167
219
  try {
@@ -194,6 +246,12 @@ function registerWriteOverride(pi) {
194
246
  details: { path: params.path, status: "approved", bytes: result.final.length },
195
247
  };
196
248
  });
249
+ case "rethink":
250
+ return {
251
+ isError: true,
252
+ content: [{ type: "text", text: `🔄 ${params.path} — rethinking requested: "${result.prompt}"\nPlease reconsider your changes based on this feedback.` }],
253
+ details: { path: params.path, status: "rethink", prompt: result.prompt },
254
+ };
197
255
  case "rejected":
198
256
  return {
199
257
  isError: true,
@@ -229,7 +287,8 @@ function registerEditOverride(pi) {
229
287
  }),
230
288
  executionMode: "sequential",
231
289
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
232
- const absolutePath = (0, path_1.resolve)(ctx.cwd, params.path);
290
+ projectCwd = ctx.cwd;
291
+ const absolutePath = resolveSafe(ctx.cwd, params.path);
233
292
  let original;
234
293
  try {
235
294
  original = (0, fs_1.readFileSync)(absolutePath, "utf-8");
@@ -273,6 +332,12 @@ function registerEditOverride(pi) {
273
332
  details: { path: params.path, status: "approved", bytes: result.final.length },
274
333
  };
275
334
  });
335
+ case "rethink":
336
+ return {
337
+ isError: true,
338
+ content: [{ type: "text", text: `🔄 ${params.path} — rethinking requested: "${result.prompt}"\nPlease reconsider your changes based on this feedback.` }],
339
+ details: { path: params.path, status: "rethink", prompt: result.prompt },
340
+ };
276
341
  case "rejected":
277
342
  return {
278
343
  isError: true,
@@ -287,16 +352,47 @@ function registerEditOverride(pi) {
287
352
  }
288
353
  // ─── Extension entry point ───────────────────────────────────────────
289
354
  function default_1(pi) {
290
- // Reset review ID tracking on new session
355
+ // Reset review ID tracking on new session.
356
+ // Re-check VS Code availability — user may have opened/closed VS Code since last session.
291
357
  pi.on("session_start", () => {
292
358
  sessionReviewIds.clear();
359
+ sessionApproveAll.clear();
360
+ vscodeNotOpenWarned = false;
361
+ const cwd = process.cwd();
362
+ if (!isVscodeReady(cwd)) {
363
+ console.warn("⚠️ VS Code not detected — working without diff review. " +
364
+ "All file changes will be applied directly. " +
365
+ "Open this project in VS Code with Serhioromano.vscode-pi-sr extension " +
366
+ "installed to enable visual review.");
367
+ vscodeNotOpenWarned = true;
368
+ }
293
369
  });
294
- // Reset Approve All on message boundaries
295
- const clearApproveAll = () => {
370
+ // Approve All persists across turns within one prompt.
371
+ // before_agent_start fires once per user prompt — clears here.
372
+ pi.on("before_agent_start", () => {
296
373
  sessionApproveAll.clear();
297
- };
298
- pi.on("message_start", clearApproveAll);
299
- pi.on("message_end", clearApproveAll);
374
+ });
375
+ pi.on("message_end", () => {
376
+ cleanupPiDir();
377
+ });
300
378
  registerWriteOverride(pi);
301
379
  registerEditOverride(pi);
302
380
  }
381
+ function cleanupPiDir() {
382
+ if (!projectCwd)
383
+ return;
384
+ // Clean up all .pi subdirectories: tmp files, pending requests, and results.
385
+ // After message_end, every review is resolved — no files are needed anymore.
386
+ for (const sub of ["tmp", "review-requests", "review-results"]) {
387
+ const dir = (0, path_1.join)(projectCwd, ".pi", sub);
388
+ try {
389
+ const files = (0, fs_1.readdirSync)(dir);
390
+ for (const f of files) {
391
+ (0, fs_1.rmSync)((0, path_1.join)(dir, f), { recursive: true, force: true });
392
+ }
393
+ }
394
+ catch {
395
+ // Directory doesn't exist or is empty — ok
396
+ }
397
+ }
398
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vscode-sr",
3
- "version": "1.4.6",
3
+ "version": "1.4.7",
4
4
  "description": "Code diff assistant for VS Code.",
5
5
  "license": "MIT",
6
6
  "author": "Serhioromano",
package/src/index.ts CHANGED
@@ -10,9 +10,23 @@ import { dirname, join, resolve } from "path";
10
10
  const sessionReviewIds = new Set<string>();
11
11
  const sessionApproveAll = new Set<string>(); // review IDs auto-approved
12
12
  let projectCwd: string | null = null;
13
+ let vscodeNotOpenWarned = false;
13
14
 
14
15
 
15
- // ─── Helper: create review request and wait for result ───────────────
16
+ // ─── Helper: check if VS Code is watching this project ─────────────
17
+
18
+ function isVscodeReady(cwd: string): boolean {
19
+ try {
20
+ const readyFile = join(cwd, '.pi', '.vscode-ready');
21
+ if (!existsSync(readyFile)) return false;
22
+ const ts = parseInt(readFileSync(readyFile, 'utf-8').trim(), 10);
23
+ if (isNaN(ts)) return false;
24
+ // Timestamp must be within last 30 seconds (heartbeat = 15s interval)
25
+ return Date.now() - ts < 30_000;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
16
30
 
17
31
  type ReviewOutcome =
18
32
  | { status: "approved"; final: string }
@@ -30,20 +44,9 @@ async function createReviewAndWait(
30
44
  // Normalize path: LLM may pass absolute-looking path without leading /
31
45
  const normalizedPath = resolveSafe(ctx.cwd, filePath);
32
46
  const uuid = randomUUID();
33
- const requestsDir = join(ctx.cwd, ".pi", "review-requests");
34
47
  const resultsDir = join(ctx.cwd, ".pi", "review-results");
35
- mkdirSync(requestsDir, { recursive: true });
36
48
  mkdirSync(resultsDir, { recursive: true });
37
49
 
38
- const reviewRequest = {
39
- id: uuid,
40
- title: description,
41
- files: [{ path: normalizedPath, original, proposed, description }],
42
- };
43
-
44
- writeFileSync(join(requestsDir, `${uuid}.json`), JSON.stringify(reviewRequest, null, 2), "utf-8");
45
- ctx.ui.notify(`📝 Review: ${filePath} — check VS Code diff`, "info");
46
-
47
50
  sessionReviewIds.add(uuid);
48
51
 
49
52
  // Check if approve-all was already chosen
@@ -52,8 +55,26 @@ async function createReviewAndWait(
52
55
  return { status: "approved", final: proposed };
53
56
  }
54
57
 
55
- // Show TUI immediately and poll VS Code in parallel (every 500ms).
56
- // AbortController dismisses the TUI if VS Code responds first.
58
+ // Detect whether VS Code is open with this project.
59
+ // If not: bypass review entirely direct write, no TUI, no polling.
60
+ // Warning is shown once at session_start, not here on every tool call.
61
+ if (!isVscodeReady(ctx.cwd)) {
62
+ return { status: "approved", final: proposed };
63
+ }
64
+
65
+ // VS Code is open — create review request, poll for results, show TUI
66
+ const requestsDir = join(ctx.cwd, ".pi", "review-requests");
67
+ mkdirSync(requestsDir, { recursive: true });
68
+
69
+ const reviewRequest = {
70
+ id: uuid,
71
+ title: description,
72
+ files: [{ path: normalizedPath, original, proposed, description }],
73
+ };
74
+ writeFileSync(join(requestsDir, `${uuid}.json`), JSON.stringify(reviewRequest, null, 2), "utf-8");
75
+ ctx.ui.notify(`📝 Review: ${filePath} — check VS Code diff`, "info");
76
+
77
+ // TUI selector races with VS Code result polling
57
78
  const resultPath = join(resultsDir, `${uuid}.json`);
58
79
  const deadline = Date.now() + 10 * 60 * 1000;
59
80
  const tuiController = new AbortController();
@@ -62,11 +83,9 @@ async function createReviewAndWait(
62
83
 
63
84
  const outcome = await Promise.race([tuiPromise, pollPromise]);
64
85
 
65
- // If poll resolved first (VS Code responded), dismiss the TUI selector.
66
- // Without this, the TUI stays on screen even after the review is done.
86
+ // If poll resolved first (VS Code responded), dismiss the TUI selector
67
87
  if (outcome.action === "file-approved" || outcome.action === "file-rejected") {
68
88
  tuiController.abort();
69
- // Wait for TUI to close gracefully (aborted select resolves quickly)
70
89
  await tuiPromise.catch(() => { });
71
90
  }
72
91
 
@@ -400,18 +419,32 @@ function registerEditOverride(pi: ExtensionAPI) {
400
419
  // ─── Extension entry point ───────────────────────────────────────────
401
420
 
402
421
  export default function (pi: ExtensionAPI) {
403
- // Reset review ID tracking on new session
422
+ // Reset review ID tracking on new session.
423
+ // Re-check VS Code availability — user may have opened/closed VS Code since last session.
404
424
  pi.on("session_start", () => {
405
425
  sessionReviewIds.clear();
426
+ sessionApproveAll.clear();
427
+ vscodeNotOpenWarned = false;
428
+
429
+ const cwd = process.cwd();
430
+ if (!isVscodeReady(cwd)) {
431
+ console.warn(
432
+ "⚠️ VS Code not detected — working without diff review. " +
433
+ "All file changes will be applied directly. " +
434
+ "Open this project in VS Code with Serhioromano.vscode-pi-sr extension " +
435
+ "installed to enable visual review."
436
+ );
437
+ vscodeNotOpenWarned = true;
438
+ }
406
439
  });
407
440
 
408
- // Reset Approve All on message boundaries
409
- const clearApproveAll = () => {
441
+ // Approve All persists across turns within one prompt.
442
+ // before_agent_start fires once per user prompt — clears here.
443
+ pi.on("before_agent_start", () => {
410
444
  sessionApproveAll.clear();
411
- };
412
- pi.on("message_start", clearApproveAll);
445
+ });
446
+
413
447
  pi.on("message_end", () => {
414
- clearApproveAll();
415
448
  cleanupPiDir();
416
449
  });
417
450