pi-vscode-sr 1.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Serhioromano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # Pi VS Code
2
+
3
+ Pi extension that integrates with VS Code's diff editor for reviewing code changes proposed by the Pi agent.
4
+
5
+ ## Architecture
6
+
7
+ The project has **two components** in one repository:
8
+
9
+ ### 1. Pi Extension (root)
10
+
11
+ - **`src/index.ts`** — Extension loaded by Pi agent (`@earendil-works/pi-tui`)
12
+ - Published as npm package `pi-vscode-sr`
13
+ - **Overrides `write` tool** — instead of writing directly, creates a review request in `.pi/review-requests/{uuid}.json`
14
+ - Polls `.pi/review-results/{uuid}.json` for user's decision, then writes if approved
15
+
16
+ ### 2. VS Code Extension (`vscode-ext/`) — **Pi Companion**
17
+
18
+ - **`vscode-ext/src/extension.ts`** — VS Code extension (package name: `vscode-pi-sr`) with approve/reject buttons in diff editor
19
+ - Watches `.pi/review-requests/` for new review requests from Pi
20
+ - Opens diff editors with **✓ Approve / ✗ Reject** buttons in the editor title bar
21
+ - Writes results to `.pi/review-results/` for Pi to read
22
+
23
+ ## Protocol
24
+
25
+ ### Review Request (Pi → VS Code Extension)
26
+
27
+ File: `.pi/review-requests/{uuid}.json`
28
+
29
+ ```json
30
+ {
31
+ "id": "550e8400-e29b-41d4-a716-446655440000",
32
+ "title": "Add input validation to login",
33
+ "files": [
34
+ {
35
+ "path": "src/auth/login.ts",
36
+ "original": "export function login(email: string, password: string) {\n return api.post('/login', { email, password });\n}",
37
+ "proposed": "export function login(email: string, password: string) {\n if (!email.includes('@')) throw new Error('Invalid email');\n if (password.length < 8) throw new Error('Password too short');\n return api.post('/login', { email, password });\n}",
38
+ "description": "Added email and password validation",
39
+ "language": "typescript"
40
+ }
41
+ ]
42
+ }
43
+ ```
44
+
45
+ ### Review Result (VS Code Extension → Pi)
46
+
47
+ File: `.pi/review-results/{uuid}.json`
48
+
49
+ ```json
50
+ {
51
+ "id": "550e8400-e29b-41d4-a716-446655440000",
52
+ "status": "approved",
53
+ "files": [
54
+ {
55
+ "path": "src/auth/login.ts",
56
+ "status": "approved",
57
+ "final": "export function login(email: string, password: string) {\n if (!email.includes('@')) throw new Error('Invalid email');\n if (password.length < 8) throw new Error('Password too short');\n return api.post('/login', { email, password });\n}"
58
+ }
59
+ ]
60
+ }
61
+ ```
62
+
63
+ ## Quick Start
64
+
65
+ ### VS Code Extension Dev
66
+ 1. Open this project in VS Code
67
+ 2. Press **F5** (or run "Run VS Code Extension" in Run & Debug)
68
+ 3. A new VS Code window opens with the extension loaded
69
+ 4. In that window, create a test request:
70
+ ```bash
71
+ mkdir -p .pi/review-requests
72
+ cp /home/sergey/www/pi-vscode/.pi/review-requests/test.json .pi/review-requests/
73
+ ```
74
+ 5. The extension will open a diff editor with **✓ Approve / ✗ Reject** buttons
75
+ 6. Edit the right side, then click Approve or Reject
76
+
77
+ ### Pi Extension (npm)
78
+ Run Pi with the extension:
79
+ ```bash
80
+ pi -e /path/to/pi-vscode/src/index.ts
81
+ ```
82
+ When Pi calls `write`, the extension creates a review request instead of writing directly.
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ # Compile Pi extension (root)
88
+ npm run compile
89
+
90
+ # Compile VS Code extension
91
+ cd vscode-ext && npm run compile
92
+
93
+ # Watch mode (VS Code extension)
94
+ cd vscode-ext && npm run watch
95
+
96
+ # Package VS Code extension as .vsix
97
+ cd vscode-ext && npm run package
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export default function (pi: ExtensionAPI): void;
package/dist/index.js ADDED
@@ -0,0 +1,302 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = default_1;
4
+ const pi_coding_agent_1 = require("@earendil-works/pi-coding-agent");
5
+ const typebox_1 = require("typebox");
6
+ const crypto_1 = require("crypto");
7
+ const fs_1 = require("fs");
8
+ const path_1 = require("path");
9
+ // ─── Session state ───────────────────────────────────────────────────
10
+ const sessionReviewIds = new Set();
11
+ const sessionApproveAll = new Set(); // review IDs auto-approved
12
+ async function createReviewAndWait(ctx, filePath, original, proposed, description) {
13
+ const uuid = (0, crypto_1.randomUUID)();
14
+ const requestsDir = (0, path_1.join)(ctx.cwd, ".pi", "review-requests");
15
+ const resultsDir = (0, path_1.join)(ctx.cwd, ".pi", "review-results");
16
+ (0, fs_1.mkdirSync)(requestsDir, { recursive: true });
17
+ (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
+ sessionReviewIds.add(uuid);
26
+ // Check if approve-all was already chosen
27
+ if (sessionApproveAll.size > 0) {
28
+ writeSyncResult(resultsDir, uuid, "approved", proposed);
29
+ return { status: "approved", final: proposed };
30
+ }
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
+ }
48
+ return { status: "approved", final: proposed };
49
+ }
50
+ // Phase 2: show TUI and keep polling VS Code in parallel (every 500ms)
51
+ const tuiPromise = showTuiSelector(ctx, filePath);
52
+ const pollPromise = pollResultFile(resultPath, deadline, 500);
53
+ const outcome = await Promise.race([tuiPromise, pollPromise]);
54
+ // ── Process outcome (return result, never throw) ──
55
+ if (outcome === "abort") {
56
+ writeSyncResult(resultsDir, uuid, "rejected");
57
+ ctx.abort();
58
+ return { status: "rejected" };
59
+ }
60
+ if (outcome === "file-rejected" || outcome === "rejected") {
61
+ return { status: "rejected" };
62
+ }
63
+ if (outcome === "file-approved" || outcome === "approved") {
64
+ return { status: "approved", final: proposed };
65
+ }
66
+ if (outcome === "approve-all") {
67
+ for (const rid of sessionReviewIds) {
68
+ sessionApproveAll.add(rid);
69
+ }
70
+ writeSyncResult(resultsDir, uuid, "approved", proposed);
71
+ return { status: "approved", final: proposed };
72
+ }
73
+ return { status: "timeout" };
74
+ }
75
+ function writeSyncResult(resultsDir, uuid, status, content) {
76
+ (0, fs_1.writeFileSync)((0, path_1.join)(resultsDir, `${uuid}.json`), JSON.stringify({
77
+ id: uuid,
78
+ files: [{ path: "", status, final: content ?? "" }],
79
+ }, null, 2), "utf-8");
80
+ }
81
+ async function pollResultFile(resultPath, deadline, interval = 500) {
82
+ while (Date.now() < deadline) {
83
+ try {
84
+ if ((0, fs_1.existsSync)(resultPath)) {
85
+ const raw = (0, fs_1.readFileSync)(resultPath, "utf-8");
86
+ if (!raw.trim()) {
87
+ // File exists but is empty — still being written
88
+ await sleep(200);
89
+ continue;
90
+ }
91
+ const result = JSON.parse(raw);
92
+ const fileResult = result.files?.[0];
93
+ if (result.status === "rejected" || fileResult?.status === "rejected") {
94
+ return "file-rejected";
95
+ }
96
+ return "file-approved";
97
+ }
98
+ }
99
+ catch {
100
+ // File may be partially written or malformed — retry
101
+ }
102
+ await sleep(interval);
103
+ }
104
+ return "timeout";
105
+ }
106
+ async function showTuiSelector(ctx, filePath) {
107
+ const choice = await ctx.ui.select(`📝 Review: ${filePath}`, [
108
+ "✅ Approve",
109
+ "❌ Reject",
110
+ "⭐ Approve All for this session",
111
+ "🚪 Abort",
112
+ ]);
113
+ if (!choice)
114
+ return "timeout";
115
+ if (choice.startsWith("🚪"))
116
+ return "abort";
117
+ if (choice.startsWith("⭐"))
118
+ return "approve-all";
119
+ if (choice.startsWith("✅"))
120
+ return "approved";
121
+ if (choice.startsWith("❌"))
122
+ return "rejected";
123
+ return "timeout";
124
+ }
125
+ function sleep(ms) {
126
+ return new Promise((r) => setTimeout(r, ms));
127
+ }
128
+ // ─── Apply edits in-memory (mirrors built-in edit logic) ─────────────
129
+ function applyEdits(content, edits) {
130
+ let result = content;
131
+ for (const edit of edits) {
132
+ const idx = result.indexOf(edit.oldText);
133
+ if (idx === -1) {
134
+ throw new Error(`oldText not found in file`);
135
+ }
136
+ const nextIdx = result.indexOf(edit.oldText, idx + 1);
137
+ if (nextIdx !== -1) {
138
+ throw new Error(`oldText is not unique in file`);
139
+ }
140
+ result = result.replace(edit.oldText, edit.newText);
141
+ }
142
+ return result;
143
+ }
144
+ // ─── Override `write` ────────────────────────────────────────────────
145
+ function registerWriteOverride(pi) {
146
+ pi.registerTool({
147
+ name: "write",
148
+ label: "write (with review)",
149
+ description: "Write content to a file. Instead of writing directly, creates a review request so the user " +
150
+ "can approve or reject the change in VS Code or directly in the terminal. Returns only after " +
151
+ "the user makes a decision.",
152
+ promptSnippet: "Create or overwrite files with user review",
153
+ promptGuidelines: [
154
+ "Use write for any file creation or complete rewrite — user review is required.",
155
+ "The tool blocks until the user approves or rejects.",
156
+ "If content is identical to existing file, no review is created.",
157
+ ],
158
+ parameters: typebox_1.Type.Object({
159
+ path: typebox_1.Type.String({ description: "Path to the file to write (relative or absolute)" }),
160
+ content: typebox_1.Type.String({ description: "Content to write to the file" }),
161
+ }),
162
+ executionMode: "sequential",
163
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
164
+ const absolutePath = (0, path_1.resolve)(ctx.cwd, params.path);
165
+ let original = "";
166
+ let fileExists = false;
167
+ try {
168
+ original = (0, fs_1.readFileSync)(absolutePath, "utf-8");
169
+ fileExists = true;
170
+ }
171
+ catch {
172
+ // new file
173
+ }
174
+ if (fileExists && original === params.content) {
175
+ return {
176
+ content: [{ type: "text", text: `No changes — ${params.path} content is identical.` }],
177
+ details: { path: params.path, status: "no-change" },
178
+ };
179
+ }
180
+ const description = fileExists ? `Update: ${params.path}` : `Create: ${params.path}`;
181
+ const result = await createReviewAndWait(ctx, params.path, original, params.content, description);
182
+ switch (result.status) {
183
+ case "timeout":
184
+ return {
185
+ content: [{ type: "text", text: `⏰ Review timed out for ${params.path} (10m)` }],
186
+ details: { path: params.path, status: "timeout" },
187
+ };
188
+ case "approved":
189
+ return (0, pi_coding_agent_1.withFileMutationQueue)(absolutePath, async () => {
190
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(absolutePath), { recursive: true });
191
+ (0, fs_1.writeFileSync)(absolutePath, result.final, "utf-8");
192
+ return {
193
+ content: [{ type: "text", text: `✅ ${params.path} — approved (${result.final.length} bytes)` }],
194
+ details: { path: params.path, status: "approved", bytes: result.final.length },
195
+ };
196
+ });
197
+ case "rejected":
198
+ return {
199
+ isError: true,
200
+ content: [{ type: "text", text: `❌ ${params.path} — change REJECTED by user. File was NOT modified.` }],
201
+ details: { path: params.path, status: "rejected" },
202
+ };
203
+ default:
204
+ throw new Error(`Unexpected review status: ${result.status}`);
205
+ }
206
+ },
207
+ });
208
+ }
209
+ // ─── Override `edit` ─────────────────────────────────────────────────
210
+ function registerEditOverride(pi) {
211
+ pi.registerTool({
212
+ name: "edit",
213
+ label: "edit (with review)",
214
+ description: "Edit a file by replacing exact text passages. Instead of editing directly, creates a review " +
215
+ "request so the user can approve or reject in VS Code or directly in the terminal.",
216
+ promptSnippet: "Make targeted edits to existing files with user review",
217
+ promptGuidelines: [
218
+ "Use edit for targeted changes to existing files — user review is required.",
219
+ "The tool blocks until the user approves or rejects.",
220
+ ],
221
+ parameters: typebox_1.Type.Object({
222
+ path: typebox_1.Type.String({ description: "Path to the file to edit (relative or absolute)" }),
223
+ edits: typebox_1.Type.Array(typebox_1.Type.Object({
224
+ oldText: typebox_1.Type.String({ description: "Exact unique text to replace" }),
225
+ newText: typebox_1.Type.String({ description: "Replacement text" }),
226
+ }), {
227
+ description: "Targeted replacements. Each oldText must be unique and non-overlapping.",
228
+ }),
229
+ }),
230
+ executionMode: "sequential",
231
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
232
+ const absolutePath = (0, path_1.resolve)(ctx.cwd, params.path);
233
+ let original;
234
+ try {
235
+ original = (0, fs_1.readFileSync)(absolutePath, "utf-8");
236
+ }
237
+ catch {
238
+ return {
239
+ content: [{ type: "text", text: `❌ File not found: ${params.path}` }],
240
+ details: { path: params.path, status: "error", error: "not found" },
241
+ };
242
+ }
243
+ // Apply edits in-memory to get proposed content
244
+ let proposed;
245
+ try {
246
+ proposed = applyEdits(original, params.edits);
247
+ }
248
+ catch (e) {
249
+ return {
250
+ content: [{ type: "text", text: `❌ Edit failed: ${e.message} in ${params.path}` }],
251
+ details: { path: params.path, status: "error", error: e.message },
252
+ };
253
+ }
254
+ if (original === proposed) {
255
+ return {
256
+ content: [{ type: "text", text: `No changes — ${params.path} content is identical after edits.` }],
257
+ details: { path: params.path, status: "no-change" },
258
+ };
259
+ }
260
+ const result = await createReviewAndWait(ctx, params.path, original, proposed, `Edit: ${params.path}`);
261
+ switch (result.status) {
262
+ case "timeout":
263
+ return {
264
+ content: [{ type: "text", text: `⏰ Review timed out for ${params.path} (10m)` }],
265
+ details: { path: params.path, status: "timeout" },
266
+ };
267
+ case "approved":
268
+ return (0, pi_coding_agent_1.withFileMutationQueue)(absolutePath, async () => {
269
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(absolutePath), { recursive: true });
270
+ (0, fs_1.writeFileSync)(absolutePath, result.final, "utf-8");
271
+ return {
272
+ content: [{ type: "text", text: `✅ ${params.path} — edit approved (${result.final.length} bytes)` }],
273
+ details: { path: params.path, status: "approved", bytes: result.final.length },
274
+ };
275
+ });
276
+ case "rejected":
277
+ return {
278
+ isError: true,
279
+ content: [{ type: "text", text: `❌ ${params.path} — edit REJECTED by user. File was NOT modified.` }],
280
+ details: { path: params.path, status: "rejected" },
281
+ };
282
+ default:
283
+ throw new Error(`Unexpected review status: ${result.status}`);
284
+ }
285
+ },
286
+ });
287
+ }
288
+ // ─── Extension entry point ───────────────────────────────────────────
289
+ function default_1(pi) {
290
+ // Reset review ID tracking on new session
291
+ pi.on("session_start", () => {
292
+ sessionReviewIds.clear();
293
+ });
294
+ // Reset Approve All on message boundaries
295
+ const clearApproveAll = () => {
296
+ sessionApproveAll.clear();
297
+ };
298
+ pi.on("message_start", clearApproveAll);
299
+ pi.on("message_end", clearApproveAll);
300
+ registerWriteOverride(pi);
301
+ registerEditOverride(pi);
302
+ }
Binary file
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "pi-vscode-sr",
3
+ "version": "1.1.0",
4
+ "description": "Code diff assistant for VS Code.",
5
+ "license": "MIT",
6
+ "author": "Serhioromano",
7
+ "repository": "github:Serhioromano/pi-vscode",
8
+ "scripts": {
9
+ "postinstall": "mkdir -p ~/.pi"
10
+ },
11
+ "keywords": [
12
+ "pi",
13
+ "ai-agent",
14
+ "pi-extension",
15
+ "pi-package",
16
+ "security",
17
+ "protection",
18
+ "vs-code-extension"
19
+ ],
20
+ "dependencies": {
21
+ "@earendil-works/pi-tui": "^0.74.0",
22
+ "yaml": "^2.7.0"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/index.ts"
27
+ ],
28
+ "image": "https://raw.githubusercontent.com/Serhioromano/pi-vscode/refs/heads/master/images/pi-vscode.png"
29
+ },
30
+ "devDependencies": {
31
+ "@earendil-works/pi-coding-agent": "^0.74.0",
32
+ "@types/node": "^25.7.0",
33
+ "typescript": "^6.0.3"
34
+ }
35
+ }