mitsupi 1.0.1 → 1.0.3

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/TODO.md DELETED
@@ -1,11 +0,0 @@
1
- - [ ] We need a way for the div viewer to select small hunks because when the review div tool actually comes by and is used for large changes it just throws way too much stuff into the UI.
2
-
3
-
4
- 1. Global state is module-scoped — If the extension is loaded once but used across multiple concurrent sessions (hypothetically),
5
- they'd share state. Is that a concern in pi's architecture?
6
- 2. session_switch skips UI restoration (restoreUI = false) — Why? If I switch to a session that was mid-review, I'd expect the widget
7
- to reappear.
8
- 3. Comments aren't cleared when entering a new review — executeReview() sets reviewActive = true but doesn't reset comments = []. If a
9
- previous review left stale comments and reconstructState didn't run (e.g., same session), they'd accumulate.
10
- 4. No explicit persistence of comments — They survive only because tool results are in the session log. But if the session is truncated
11
- or the tool result is pruned, comments could be lost silently.
@@ -1,632 +0,0 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@mariozechner/pi-coding-agent";
3
- import { Type } from "@sinclair/typebox";
4
- import { constants, existsSync } from "fs";
5
- import { access, mkdir, readFile, unlink, writeFile } from "fs/promises";
6
- import { tmpdir } from "os";
7
- import { dirname, resolve } from "path";
8
- import { randomBytes } from "crypto";
9
-
10
- const applyPatchSchema = Type.Object({
11
- input: Type.String({
12
- description: "The entire contents of the apply_patch command",
13
- }),
14
- });
15
-
16
- const shellSchema = Type.Object({
17
- command: Type.Array(Type.String({ description: "Command arguments" })),
18
- workdir: Type.Optional(Type.String({ description: "Working directory for the command" })),
19
- timeout_ms: Type.Optional(Type.Number({ description: "Timeout in milliseconds" })),
20
- sandbox_permissions: Type.Optional(
21
- Type.String({ description: "Sandbox permissions (ignored in pi extension)" }),
22
- ),
23
- justification: Type.Optional(Type.String({ description: "Escalation justification (ignored in pi extension)" })),
24
- });
25
-
26
- const shellCommandSchema = Type.Object({
27
- command: Type.String({ description: "Shell script to execute" }),
28
- workdir: Type.Optional(Type.String({ description: "Working directory for the command" })),
29
- login: Type.Optional(Type.Boolean({ description: "Run shell with login semantics" })),
30
- timeout_ms: Type.Optional(Type.Number({ description: "Timeout in milliseconds" })),
31
- sandbox_permissions: Type.Optional(
32
- Type.String({ description: "Sandbox permissions (ignored in pi extension)" }),
33
- ),
34
- justification: Type.Optional(Type.String({ description: "Escalation justification (ignored in pi extension)" })),
35
- });
36
-
37
- type PatchHunk =
38
- | { type: "add"; path: string; lines: string[] }
39
- | { type: "delete"; path: string }
40
- | { type: "update"; path: string; newPath?: string; lines: string[]; endOfFile: boolean };
41
-
42
- type AppliedChange = {
43
- path: string;
44
- action: "add" | "delete" | "update" | "move";
45
- message: string;
46
- };
47
-
48
- function normalizeNewlines(value: string): string {
49
- return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
50
- }
51
-
52
- function detectLineEnding(content: string): "\r\n" | "\n" {
53
- return content.includes("\r\n") ? "\r\n" : "\n";
54
- }
55
-
56
- function stripBom(content: string): { bom: string; text: string } {
57
- return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
58
- }
59
-
60
- function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
61
- return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
62
- }
63
-
64
- function buildShellCommand(command: string, login?: boolean): string[] {
65
- if (process.platform === "win32") {
66
- return ["powershell.exe", "-Command", command];
67
- }
68
-
69
- const envShell = process.env.SHELL;
70
- if (envShell && existsSync(envShell)) {
71
- return login === false ? [envShell, "-c", command] : [envShell, "-lc", command];
72
- }
73
-
74
- if (existsSync("/bin/bash")) {
75
- return login === false ? ["/bin/bash", "-c", command] : ["/bin/bash", "-lc", command];
76
- }
77
-
78
- return ["sh", "-c", command];
79
- }
80
-
81
- function parsePatch(input: string): PatchHunk[] {
82
- const normalized = normalizeNewlines(input);
83
- const startIndex = normalized.indexOf("*** Begin Patch");
84
- if (startIndex === -1) {
85
- throw new Error("Patch must include '*** Begin Patch' header.");
86
- }
87
-
88
- const payload = normalized.slice(startIndex);
89
- const rawLines = payload.split("\n");
90
- if (rawLines[rawLines.length - 1] === "") {
91
- rawLines.pop();
92
- }
93
-
94
- if (rawLines[0] !== "*** Begin Patch") {
95
- throw new Error("Patch must start with '*** Begin Patch'.");
96
- }
97
-
98
- const hunks: PatchHunk[] = [];
99
- let index = 1;
100
-
101
- const isHunkStart = (line: string) =>
102
- line.startsWith("*** Add File: ") ||
103
- line.startsWith("*** Delete File: ") ||
104
- line.startsWith("*** Update File: ");
105
-
106
- while (index < rawLines.length) {
107
- const line = rawLines[index];
108
-
109
- if (line === "*** End Patch") {
110
- return hunks;
111
- }
112
-
113
- if (line.startsWith("*** Add File: ")) {
114
- const path = line.slice("*** Add File: ".length).trim();
115
- const lines: string[] = [];
116
- index += 1;
117
- while (index < rawLines.length && !isHunkStart(rawLines[index]) && rawLines[index] !== "*** End Patch") {
118
- const addLine = rawLines[index];
119
- if (!addLine.startsWith("+")) {
120
- throw new Error(`Invalid add line '${addLine}'. Add file hunks must use '+' prefixes.`);
121
- }
122
- lines.push(addLine.slice(1));
123
- index += 1;
124
- }
125
- if (lines.length === 0) {
126
- throw new Error(`Add file hunk for '${path}' has no content.`);
127
- }
128
- hunks.push({ type: "add", path, lines });
129
- continue;
130
- }
131
-
132
- if (line.startsWith("*** Delete File: ")) {
133
- const path = line.slice("*** Delete File: ".length).trim();
134
- hunks.push({ type: "delete", path });
135
- index += 1;
136
- continue;
137
- }
138
-
139
- if (line.startsWith("*** Update File: ")) {
140
- const path = line.slice("*** Update File: ".length).trim();
141
- let newPath: string | undefined;
142
- const lines: string[] = [];
143
- let endOfFile = false;
144
- index += 1;
145
-
146
- if (rawLines[index]?.startsWith("*** Move to: ")) {
147
- newPath = rawLines[index].slice("*** Move to: ".length).trim();
148
- index += 1;
149
- }
150
-
151
- while (index < rawLines.length && !isHunkStart(rawLines[index]) && rawLines[index] !== "*** End Patch") {
152
- const changeLine = rawLines[index];
153
- if (changeLine === "*** End of File") {
154
- endOfFile = true;
155
- index += 1;
156
- break;
157
- }
158
- if (!changeLine.startsWith("@@") && !changeLine.startsWith("+") && !changeLine.startsWith("-") && !changeLine.startsWith(" ")) {
159
- throw new Error(`Invalid patch line '${changeLine}'. Lines must start with ' ', '+', '-', or '@@'.`);
160
- }
161
- lines.push(changeLine);
162
- index += 1;
163
- }
164
-
165
- if (lines.length === 0 && !newPath) {
166
- throw new Error(`Update hunk for '${path}' has no changes.`);
167
- }
168
-
169
- hunks.push({ type: "update", path, newPath, lines, endOfFile });
170
- continue;
171
- }
172
-
173
- throw new Error(`Unexpected patch line '${line}'.`);
174
- }
175
-
176
- throw new Error("Patch must end with '*** End Patch'.");
177
- }
178
-
179
- function splitIntoChunks(lines: string[]): string[][] {
180
- const chunks: string[][] = [];
181
- let current: string[] = [];
182
-
183
- for (const line of lines) {
184
- if (line.startsWith("@@")) {
185
- if (current.length > 0) {
186
- chunks.push(current);
187
- current = [];
188
- }
189
- continue;
190
- }
191
- current.push(line);
192
- }
193
-
194
- if (current.length > 0) {
195
- chunks.push(current);
196
- }
197
-
198
- return chunks;
199
- }
200
-
201
- function findPatternStart(originalLines: string[], start: number, pattern: string[]): number {
202
- if (pattern.length === 0) {
203
- return start;
204
- }
205
-
206
- for (let i = start; i <= originalLines.length - pattern.length; i++) {
207
- let matches = true;
208
- for (let j = 0; j < pattern.length; j++) {
209
- if (originalLines[i + j] !== pattern[j]) {
210
- matches = false;
211
- break;
212
- }
213
- }
214
- if (matches) {
215
- return i;
216
- }
217
- }
218
-
219
- return -1;
220
- }
221
-
222
- function applyUpdatePatch(originalLines: string[], patchLines: string[]): string[] {
223
- let cursor = 0;
224
- const output: string[] = [];
225
- const chunks = splitIntoChunks(patchLines);
226
-
227
- for (const chunk of chunks) {
228
- const matchLines = chunk
229
- .filter((line) => line.startsWith(" ") || line.startsWith("-"))
230
- .map((line) => line.slice(1));
231
- const matchIndex = findPatternStart(originalLines, cursor, matchLines);
232
- if (matchIndex === -1) {
233
- throw new Error("Failed to locate patch context in target file.");
234
- }
235
-
236
- output.push(...originalLines.slice(cursor, matchIndex));
237
- let localIndex = matchIndex;
238
-
239
- for (const line of chunk) {
240
- if (line === "") {
241
- throw new Error("Patch line missing prefix. Every line must start with ' ', '+', or '-'.");
242
- }
243
- const prefix = line[0];
244
- const text = line.slice(1);
245
-
246
- switch (prefix) {
247
- case " ": {
248
- if (originalLines[localIndex] !== text) {
249
- throw new Error(`Context mismatch: expected '${text}', found '${originalLines[localIndex] ?? ""}'.`);
250
- }
251
- output.push(originalLines[localIndex]);
252
- localIndex += 1;
253
- break;
254
- }
255
- case "-": {
256
- if (originalLines[localIndex] !== text) {
257
- throw new Error(`Delete mismatch: expected '${text}', found '${originalLines[localIndex] ?? ""}'.`);
258
- }
259
- localIndex += 1;
260
- break;
261
- }
262
- case "+": {
263
- output.push(text);
264
- break;
265
- }
266
- default:
267
- throw new Error(`Invalid patch line '${line}'. Every line must start with ' ', '+', or '-'.`);
268
- }
269
- }
270
-
271
- cursor = localIndex;
272
- }
273
-
274
- output.push(...originalLines.slice(cursor));
275
- return output;
276
- }
277
-
278
- async function applyPatchHunk(hunk: PatchHunk, cwd: string): Promise<AppliedChange[]> {
279
- switch (hunk.type) {
280
- case "add": {
281
- const targetPath = resolve(cwd, hunk.path);
282
- try {
283
- await access(targetPath, constants.F_OK);
284
- throw new Error(`File already exists: ${hunk.path}`);
285
- } catch (error: any) {
286
- if (error?.code && error.code !== "ENOENT") {
287
- throw error;
288
- }
289
- }
290
-
291
- await mkdir(dirname(targetPath), { recursive: true });
292
- const content = hunk.lines.join("\n");
293
- await writeFile(targetPath, content, "utf-8");
294
- return [{ path: hunk.path, action: "add", message: "File added" }];
295
- }
296
- case "delete": {
297
- const targetPath = resolve(cwd, hunk.path);
298
- await access(targetPath, constants.F_OK);
299
- await unlink(targetPath);
300
- return [{ path: hunk.path, action: "delete", message: "File deleted" }];
301
- }
302
- case "update": {
303
- const sourcePath = resolve(cwd, hunk.path);
304
- await access(sourcePath, constants.F_OK);
305
- const rawContent = await readFile(sourcePath, "utf-8");
306
- const { bom, text } = stripBom(rawContent);
307
- const lineEnding = detectLineEnding(rawContent);
308
- const normalizedText = normalizeNewlines(text);
309
- const originalLines = normalizedText.split("\n");
310
- const targetPath = hunk.newPath ? resolve(cwd, hunk.newPath) : sourcePath;
311
-
312
- let updatedText = bom + restoreLineEndings(normalizedText, lineEnding);
313
- if (hunk.lines.length > 0) {
314
- let patchedLines = applyUpdatePatch(originalLines, hunk.lines);
315
- if (hunk.endOfFile && patchedLines.length > 0 && patchedLines[patchedLines.length - 1] === "") {
316
- patchedLines = patchedLines.slice(0, -1);
317
- }
318
- updatedText = bom + restoreLineEndings(patchedLines.join("\n"), lineEnding);
319
- }
320
-
321
- await mkdir(dirname(targetPath), { recursive: true });
322
- await writeFile(targetPath, updatedText, "utf-8");
323
-
324
- const changes: AppliedChange[] = [{ path: hunk.path, action: "update", message: "File updated" }];
325
- if (hunk.newPath && targetPath !== sourcePath) {
326
- await unlink(sourcePath);
327
- changes.push({ path: hunk.newPath, action: "move", message: "File moved" });
328
- }
329
- return changes;
330
- }
331
- }
332
- }
333
-
334
- function isCodexModel(model: { id?: string; provider?: string } | string | undefined): boolean {
335
- if (!model) return false;
336
- if (typeof model === "string") {
337
- return model.toLowerCase().includes("codex");
338
- }
339
- const providerMatch = model.provider?.toLowerCase().includes("codex") ?? false;
340
- const idMatch = model.id?.toLowerCase().includes("codex") ?? false;
341
- return providerMatch || idMatch;
342
- }
343
-
344
- function formatChanges(changes: AppliedChange[]): string {
345
- const lines = changes.map((change) => `- ${change.action.toUpperCase()}: ${change.path} (${change.message})`);
346
- return ["apply_patch results:", ...lines].join("\n");
347
- }
348
-
349
- function middleTruncateByBytes(content: string, maxBytes: number): { text: string; truncated: boolean } {
350
- const totalBytes = Buffer.byteLength(content, "utf-8");
351
- if (totalBytes <= maxBytes) {
352
- return { text: content, truncated: false };
353
- }
354
- if (maxBytes === 0) {
355
- return { text: "…content truncated…", truncated: true };
356
- }
357
-
358
- const leftBudget = Math.floor(maxBytes / 2);
359
- const rightBudget = maxBytes - leftBudget;
360
- const totalChars = Array.from(content).length;
361
- let prefixEnd = 0;
362
- let suffixStart = content.length;
363
- let prefixChars = 0;
364
- let suffixChars = 0;
365
- let byteOffset = 0;
366
- let suffixStarted = false;
367
-
368
- for (let i = 0; i < content.length; ) {
369
- const codePoint = content.codePointAt(i);
370
- if (codePoint === undefined) {
371
- break;
372
- }
373
- const char = String.fromCodePoint(codePoint);
374
- const charBytes = Buffer.byteLength(char, "utf-8");
375
- const charLength = char.length;
376
-
377
- if (byteOffset + charBytes <= leftBudget) {
378
- prefixEnd = i + charLength;
379
- prefixChars += 1;
380
- }
381
-
382
- if (byteOffset >= totalBytes - rightBudget) {
383
- if (!suffixStarted) {
384
- suffixStart = i;
385
- suffixStarted = true;
386
- }
387
- suffixChars += 1;
388
- }
389
-
390
- byteOffset += charBytes;
391
- i += charLength;
392
- }
393
-
394
- if (suffixStart < prefixEnd) {
395
- suffixStart = prefixEnd;
396
- }
397
-
398
- const removedChars = Math.max(0, totalChars - prefixChars - suffixChars);
399
- const marker = `…${removedChars} chars truncated…`;
400
- const truncated = content.slice(0, prefixEnd) + marker + content.slice(suffixStart);
401
- return { text: truncated, truncated: true };
402
- }
403
-
404
- function formatShellOutput(result: {
405
- output: string;
406
- exitCode: number;
407
- durationMs: number;
408
- timedOut: boolean;
409
- }): string {
410
- const wallTimeSeconds = (result.durationMs / 1000).toFixed(1);
411
- let content = result.output;
412
- if (result.timedOut) {
413
- content = `command timed out after ${result.durationMs} milliseconds\n${content}`.trim();
414
- }
415
-
416
- const totalLines = content.length === 0 ? 0 : content.split("\n").length;
417
- const truncated = middleTruncateByBytes(content, DEFAULT_MAX_BYTES);
418
- const truncatedLines = truncated.text.length === 0 ? 0 : truncated.text.split("\n").length;
419
-
420
- const sections = [
421
- `Exit code: ${result.exitCode}`,
422
- `Wall time: ${wallTimeSeconds} seconds`,
423
- ];
424
-
425
- if (totalLines !== truncatedLines) {
426
- sections.push(`Total output lines: ${totalLines}`);
427
- }
428
-
429
- sections.push("Output:");
430
- sections.push(truncated.text);
431
-
432
- return sections.join("\n");
433
- }
434
-
435
- async function truncateOutput(output: string): Promise<string> {
436
- const truncation = truncateHead(output, {
437
- maxBytes: DEFAULT_MAX_BYTES,
438
- maxLines: DEFAULT_MAX_LINES,
439
- });
440
-
441
- if (!truncation.truncated) {
442
- return truncation.content;
443
- }
444
-
445
- const tempPath = resolve(
446
- tmpdir(),
447
- `pi-apply-patch-${Date.now()}-${randomBytes(4).toString("hex")}.log`,
448
- );
449
- await writeFile(tempPath, output, "utf-8");
450
-
451
- return (
452
- truncation.content +
453
- `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines ` +
454
- `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). ` +
455
- `Full output saved to: ${tempPath}]`
456
- );
457
- }
458
-
459
- async function executeApplyPatch(
460
- patchInput: string,
461
- ctx: { cwd: string },
462
- signal?: AbortSignal,
463
- ): Promise<{ content: Array<{ type: "text"; text: string }>; details: { changes: AppliedChange[] } }> {
464
- if (signal?.aborted) {
465
- return {
466
- content: [{ type: "text", text: "apply_patch cancelled." }],
467
- details: { changes: [] },
468
- };
469
- }
470
-
471
- const hunks = parsePatch(patchInput);
472
- const applied: AppliedChange[] = [];
473
-
474
- for (const hunk of hunks) {
475
- if (signal?.aborted) {
476
- throw new Error("Operation aborted");
477
- }
478
- const results = await applyPatchHunk(hunk, ctx.cwd);
479
- applied.push(...results);
480
- }
481
-
482
- const output = await truncateOutput(formatChanges(applied));
483
- return {
484
- content: [{ type: "text", text: output }],
485
- details: { changes: applied },
486
- };
487
- }
488
-
489
- export default function (pi: ExtensionAPI) {
490
- let applyPatchToolRegistered = false;
491
- let shellToolRegistered = false;
492
-
493
- const registerApplyPatchTool = () => {
494
- if (applyPatchToolRegistered) return;
495
- applyPatchToolRegistered = true;
496
-
497
- pi.registerTool({
498
- name: "apply_patch",
499
- label: "apply_patch",
500
- description:
501
- "Apply a patch in Codex format. Provide the full patch starting with '*** Begin Patch' and ending with '*** End Patch'.",
502
- parameters: applyPatchSchema,
503
- async execute(_toolCallId, params, _onUpdate, ctx, signal) {
504
- return executeApplyPatch(params.input, ctx, signal);
505
- },
506
- });
507
- };
508
-
509
- const registerShellTool = () => {
510
- if (shellToolRegistered) return;
511
- shellToolRegistered = true;
512
-
513
- const executeShellArgs = async (
514
- command: string[],
515
- params: { workdir?: string; timeout_ms?: number },
516
- ctx: { cwd: string },
517
- signal?: AbortSignal,
518
- allowApplyPatch = false,
519
- ) => {
520
- if (!command || command.length === 0) {
521
- throw new Error("shell command must include at least one argument");
522
- }
523
-
524
- if (allowApplyPatch && command[0] === "apply_patch") {
525
- const patchInput = command.length === 2 ? command[1] : command.slice(1).join(" ");
526
- return executeApplyPatch(patchInput, ctx, signal);
527
- }
528
-
529
- const resolvedCwd = params.workdir ? resolve(ctx.cwd, params.workdir) : ctx.cwd;
530
- const start = Date.now();
531
- const result = await pi.exec(command[0], command.slice(1), {
532
- cwd: resolvedCwd,
533
- timeout: params.timeout_ms,
534
- signal,
535
- });
536
-
537
- const durationMs = Date.now() - start;
538
- const timedOut = Boolean(params.timeout_ms && result.killed && !signal?.aborted);
539
- const combinedOutput = `${result.stdout}${result.stderr}`.trimEnd();
540
- const formatted = formatShellOutput({
541
- output: combinedOutput,
542
- exitCode: result.code,
543
- durationMs,
544
- timedOut,
545
- });
546
-
547
- return {
548
- content: [{ type: "text", text: formatted }],
549
- details: { exitCode: result.code, cwd: resolvedCwd, timedOut },
550
- };
551
- };
552
-
553
- const registerShellVariant = (
554
- name: string,
555
- label: string,
556
- description: string,
557
- schema: typeof shellSchema,
558
- ) => {
559
- pi.registerTool({
560
- name,
561
- label,
562
- description,
563
- parameters: schema,
564
- async execute(_toolCallId, params, _onUpdate, ctx, signal) {
565
- return executeShellArgs(params.command, params, ctx, signal, true);
566
- },
567
- });
568
- };
569
-
570
- registerShellVariant(
571
- "shell",
572
- "shell",
573
- "Runs a shell command and returns its output. Provide the command as an argument array; prefer ['bash', '-lc', '...'] for POSIX shells.",
574
- shellSchema,
575
- );
576
- registerShellVariant(
577
- "local_shell",
578
- "local_shell",
579
- "Runs a local shell command and returns its output.",
580
- shellSchema,
581
- );
582
- pi.registerTool({
583
- name: "shell_command",
584
- label: "shell_command",
585
- description:
586
- "Runs a shell script and returns its output. Provide the script as a single string.",
587
- parameters: shellCommandSchema,
588
- async execute(_toolCallId, params, _onUpdate, ctx, signal) {
589
- const commandArgs = buildShellCommand(params.command, params.login);
590
- return executeShellArgs(commandArgs, params, ctx, signal, false);
591
- },
592
- });
593
- };
594
-
595
- const updateActiveTools = (model?: { id?: string; provider?: string }) => {
596
- const activeTools = new Set(pi.getActiveTools());
597
- const shouldEnable = isCodexModel(model);
598
- let changed = false;
599
-
600
- if (shouldEnable) {
601
- const exposedTools = ["apply_patch", "shell"];
602
- for (const tool of exposedTools) {
603
- if (!activeTools.has(tool)) {
604
- activeTools.add(tool);
605
- changed = true;
606
- }
607
- }
608
- } else {
609
- const codexTools = ["apply_patch", "shell", "shell_command", "local_shell"];
610
- for (const tool of codexTools) {
611
- if (activeTools.delete(tool)) {
612
- changed = true;
613
- }
614
- }
615
- }
616
-
617
- if (changed) {
618
- pi.setActiveTools(Array.from(activeTools));
619
- }
620
- };
621
-
622
- registerApplyPatchTool();
623
- registerShellTool();
624
-
625
- pi.on("session_start", (_event, ctx) => {
626
- updateActiveTools(ctx.model);
627
- });
628
-
629
- pi.on("model_select", (event) => {
630
- updateActiveTools(event.model);
631
- });
632
- }