@bdsqqq/pi 0.0.1

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 (99) hide show
  1. package/dist/chunk-DsIazq42.js +26 -0
  2. package/dist/command-palette-B97yKqne.js +406 -0
  3. package/dist/core/agents-md.d.ts +22 -0
  4. package/dist/core/agents-md.js +174 -0
  5. package/dist/core/box-chrome.d.ts +56 -0
  6. package/dist/core/box-chrome.js +207 -0
  7. package/dist/core/box-format.d.ts +82 -0
  8. package/dist/core/box-format.js +381 -0
  9. package/dist/core/config.d.ts +14 -0
  10. package/dist/core/config.js +201 -0
  11. package/dist/core/file-tracker.d.ts +65 -0
  12. package/dist/core/file-tracker.js +439 -0
  13. package/dist/core/github-api.d.ts +45 -0
  14. package/dist/core/github-api.js +101 -0
  15. package/dist/core/html-to-md.d.ts +5 -0
  16. package/dist/core/html-to-md.js +289 -0
  17. package/dist/core/interpolate.d.ts +44 -0
  18. package/dist/core/interpolate.js +475 -0
  19. package/dist/core/mutex.d.ts +8 -0
  20. package/dist/core/mutex.js +142 -0
  21. package/dist/core/output-buffer.d.ts +80 -0
  22. package/dist/core/output-buffer.js +517 -0
  23. package/dist/core/permissions.d.ts +19 -0
  24. package/dist/core/permissions.js +140 -0
  25. package/dist/core/pi-spawn.d.ts +62 -0
  26. package/dist/core/pi-spawn.js +244 -0
  27. package/dist/core/prompt-patch.d.ts +11 -0
  28. package/dist/core/prompt-patch.js +16 -0
  29. package/dist/core/show-renderer.d.ts +10 -0
  30. package/dist/core/show-renderer.js +39 -0
  31. package/dist/core/show.d.ts +29 -0
  32. package/dist/core/show.js +288 -0
  33. package/dist/core/sub-agent-render.d.ts +56 -0
  34. package/dist/core/sub-agent-render.js +579 -0
  35. package/dist/core/tool-cost.d.ts +17 -0
  36. package/dist/core/tool-cost.js +58 -0
  37. package/dist/core/tui.d.ts +9 -0
  38. package/dist/core/tui.js +33 -0
  39. package/dist/editor-BljciPdx.js +537 -0
  40. package/dist/extensions/bash.d.ts +12 -0
  41. package/dist/extensions/bash.js +261 -0
  42. package/dist/extensions/code-review.d.ts +14 -0
  43. package/dist/extensions/code-review.js +175 -0
  44. package/dist/extensions/command-palette.d.ts +6 -0
  45. package/dist/extensions/command-palette.js +2 -0
  46. package/dist/extensions/create-file.d.ts +7 -0
  47. package/dist/extensions/create-file.js +88 -0
  48. package/dist/extensions/edit-file.d.ts +7 -0
  49. package/dist/extensions/edit-file.js +395 -0
  50. package/dist/extensions/editor.d.ts +6 -0
  51. package/dist/extensions/editor.js +2 -0
  52. package/dist/extensions/finder.d.ts +13 -0
  53. package/dist/extensions/finder.js +108 -0
  54. package/dist/extensions/format-file.d.ts +11 -0
  55. package/dist/extensions/format-file.js +145 -0
  56. package/dist/extensions/github.d.ts +13 -0
  57. package/dist/extensions/github.js +527 -0
  58. package/dist/extensions/glob.d.ts +10 -0
  59. package/dist/extensions/glob.js +163 -0
  60. package/dist/extensions/grep.d.ts +13 -0
  61. package/dist/extensions/grep.js +356 -0
  62. package/dist/extensions/handoff.d.ts +6 -0
  63. package/dist/extensions/handoff.js +277 -0
  64. package/dist/extensions/librarian.d.ts +13 -0
  65. package/dist/extensions/librarian.js +113 -0
  66. package/dist/extensions/look-at.d.ts +13 -0
  67. package/dist/extensions/look-at.js +121 -0
  68. package/dist/extensions/ls.d.ts +8 -0
  69. package/dist/extensions/ls.js +87 -0
  70. package/dist/extensions/mermaid.d.ts +20 -0
  71. package/dist/extensions/mermaid.js +2 -0
  72. package/dist/extensions/oracle.d.ts +13 -0
  73. package/dist/extensions/oracle.js +143 -0
  74. package/dist/extensions/read-session.d.ts +13 -0
  75. package/dist/extensions/read-session.js +265 -0
  76. package/dist/extensions/read-web-page.d.ts +11 -0
  77. package/dist/extensions/read-web-page.js +234 -0
  78. package/dist/extensions/read.d.ts +23 -0
  79. package/dist/extensions/read.js +323 -0
  80. package/dist/extensions/search-sessions.d.ts +29 -0
  81. package/dist/extensions/search-sessions.js +426 -0
  82. package/dist/extensions/session-name.d.ts +6 -0
  83. package/dist/extensions/session-name.js +54 -0
  84. package/dist/extensions/skill.d.ts +7 -0
  85. package/dist/extensions/skill.js +232 -0
  86. package/dist/extensions/system-prompt.d.ts +6 -0
  87. package/dist/extensions/system-prompt.js +31 -0
  88. package/dist/extensions/task.d.ts +11 -0
  89. package/dist/extensions/task.js +114 -0
  90. package/dist/extensions/tool-harness.d.ts +6 -0
  91. package/dist/extensions/tool-harness.js +37 -0
  92. package/dist/extensions/undo-edit.d.ts +7 -0
  93. package/dist/extensions/undo-edit.js +127 -0
  94. package/dist/extensions/web-search.d.ts +12 -0
  95. package/dist/extensions/web-search.js +254 -0
  96. package/dist/extensions.d.ts +29 -0
  97. package/dist/extensions.js +29 -0
  98. package/dist/mermaid-C7xCVBrw.js +95296 -0
  99. package/package.json +67 -0
@@ -0,0 +1,517 @@
1
+ //#region packages/core/output-buffer/index.ts
2
+ /**
3
+ * output buffer with fixed head + rolling tail.
4
+ *
5
+ * maintains constant memory regardless of output size by keeping:
6
+ * - first N lines (head, fill once then lock)
7
+ * - last M lines (tail, ring buffer, always rolling)
8
+ * - total line count (for truncation message)
9
+ *
10
+ * used by bash tool to show beginning + end of long outputs,
11
+ * rather than only the tail.
12
+ */
13
+ const DEFAULT_HEAD_LINES = 50;
14
+ const DEFAULT_TAIL_LINES = 50;
15
+ /**
16
+ * truncate an array to head + tail, returning formatted result.
17
+ * simpler than OutputBuffer — for when you have all items upfront (not streaming).
18
+ *
19
+ * @param items - array to truncate
20
+ * @param maxItems - total items to show (split evenly between head/tail)
21
+ * @returns { head, tail, truncated, truncatedCount }
22
+ */
23
+ function headTail(items, maxItems = 100) {
24
+ if (items.length <= maxItems) return {
25
+ head: items,
26
+ tail: [],
27
+ truncated: [],
28
+ truncatedCount: 0
29
+ };
30
+ const half = Math.floor(maxItems / 2);
31
+ const head = items.slice(0, half);
32
+ const tail = items.slice(-half);
33
+ const truncated = items.slice(half, -half);
34
+ return {
35
+ head,
36
+ tail,
37
+ truncated,
38
+ truncatedCount: truncated.length
39
+ };
40
+ }
41
+ /**
42
+ * format head+tail arrays with truncation marker.
43
+ * returns a single string with items joined by newlines.
44
+ */
45
+ function formatHeadTail(items, maxItems = 100, truncatedMsg = (n) => `... [${n} lines truncated] ...`) {
46
+ const { head, tail, truncatedCount } = headTail(items, maxItems);
47
+ if (truncatedCount === 0) return head.map(String).join("\n");
48
+ return [
49
+ ...head.map(String),
50
+ "",
51
+ truncatedMsg(truncatedCount),
52
+ "",
53
+ ...tail.map(String)
54
+ ].join("\n");
55
+ }
56
+ /**
57
+ * truncate raw text to head + tail by characters.
58
+ * for when you have a single string (not lines) that needs truncation.
59
+ */
60
+ function headTailChars(text, maxChars = 64e3) {
61
+ const total = text.length;
62
+ if (total <= maxChars) return {
63
+ text,
64
+ truncated: false,
65
+ totalChars: total
66
+ };
67
+ const half = Math.floor(maxChars / 2);
68
+ const head = text.slice(0, half);
69
+ const tail = text.slice(-half);
70
+ return {
71
+ text: `${head}\n\n... [${total - maxChars} characters truncated] ...\n\n${tail}`,
72
+ truncated: true,
73
+ totalChars: total
74
+ };
75
+ }
76
+ var OutputBuffer = class {
77
+ head = [];
78
+ tail = [];
79
+ headComplete = false;
80
+ pendingLine = "";
81
+ totalLines = 0;
82
+ constructor(maxHead = DEFAULT_HEAD_LINES, maxTail = DEFAULT_TAIL_LINES) {
83
+ this.maxHead = maxHead;
84
+ this.maxTail = maxTail;
85
+ }
86
+ /**
87
+ * add a chunk of output. handles partial lines at boundaries.
88
+ * chunks may end mid-line, so we buffer the incomplete part.
89
+ */
90
+ add(chunk) {
91
+ const lines = (this.pendingLine + chunk).split("\n");
92
+ this.pendingLine = lines.pop() ?? "";
93
+ for (const line of lines) {
94
+ this.totalLines++;
95
+ this.addLine(line);
96
+ }
97
+ }
98
+ /**
99
+ * add a complete line to the appropriate buffer.
100
+ * fills head first, then rolls tail.
101
+ */
102
+ addLine(line) {
103
+ if (!this.headComplete && this.head.length < this.maxHead) {
104
+ this.head.push(line);
105
+ if (this.head.length === this.maxHead) this.headComplete = true;
106
+ }
107
+ this.tail.push(line);
108
+ if (this.tail.length > this.maxTail) this.tail.shift();
109
+ }
110
+ /**
111
+ * finalize and format the output.
112
+ * returns the formatted text and count of truncated lines.
113
+ *
114
+ * small output (<= head + tail): dedupes overlap, no truncation marker
115
+ * large output: head + marker + tail
116
+ */
117
+ format() {
118
+ if (this.pendingLine) {
119
+ this.totalLines++;
120
+ this.addLine(this.pendingLine);
121
+ this.pendingLine = "";
122
+ }
123
+ const allLines = this.totalLines;
124
+ if (allLines <= this.maxHead + this.maxTail) return {
125
+ text: this.dedupe(allLines).join("\n"),
126
+ truncatedLines: 0
127
+ };
128
+ const truncated = allLines - this.head.length - this.tail.length;
129
+ return {
130
+ text: [
131
+ ...this.head,
132
+ "",
133
+ `... [${truncated} lines truncated] ...`,
134
+ "",
135
+ ...this.tail
136
+ ].join("\n"),
137
+ truncatedLines: truncated
138
+ };
139
+ }
140
+ /**
141
+ * deduplicate overlapping head/tail for small outputs.
142
+ *
143
+ * when total lines <= maxHead + maxTail, the tail buffer
144
+ * may contain lines already in head. we merge them using
145
+ * positional overlap (not value matching) to avoid dropping
146
+ * legitimate lines when content has duplicates.
147
+ */
148
+ dedupe(totalLines) {
149
+ if (totalLines <= this.maxHead) return this.head;
150
+ if (totalLines <= this.maxTail) return this.tail;
151
+ const overlapLen = Math.max(0, this.head.length + this.tail.length - totalLines);
152
+ if (overlapLen === 0) return [...this.head, ...this.tail];
153
+ return [...this.head.slice(0, this.head.length - overlapLen), ...this.tail];
154
+ }
155
+ /**
156
+ * get current buffer state for debugging.
157
+ */
158
+ debug() {
159
+ return {
160
+ head: [...this.head],
161
+ tail: [...this.tail],
162
+ totalLines: this.totalLines,
163
+ pendingLine: this.pendingLine
164
+ };
165
+ }
166
+ };
167
+ if (import.meta.vitest) {
168
+ const { describe, it, expect } = import.meta.vitest;
169
+ describe("OutputBuffer", () => {
170
+ describe("small output (no truncation)", () => {
171
+ it("returns all lines when output < head + tail", () => {
172
+ const buf = new OutputBuffer(50, 50);
173
+ buf.add("line1\nline2\nline3\n");
174
+ const { text, truncatedLines } = buf.format();
175
+ expect(truncatedLines).toBe(0);
176
+ expect(text).toBe("line1\nline2\nline3");
177
+ });
178
+ it("handles empty output", () => {
179
+ const { text, truncatedLines } = new OutputBuffer(50, 50).format();
180
+ expect(truncatedLines).toBe(0);
181
+ expect(text).toBe("");
182
+ });
183
+ it("handles single line", () => {
184
+ const buf = new OutputBuffer(50, 50);
185
+ buf.add("only line\n");
186
+ const { text, truncatedLines } = buf.format();
187
+ expect(truncatedLines).toBe(0);
188
+ expect(text).toBe("only line");
189
+ });
190
+ it("handles single line without trailing newline", () => {
191
+ const buf = new OutputBuffer(50, 50);
192
+ buf.add("no newline here");
193
+ const { text, truncatedLines } = buf.format();
194
+ expect(truncatedLines).toBe(0);
195
+ expect(text).toBe("no newline here");
196
+ });
197
+ it("counts empty lines", () => {
198
+ const buf = new OutputBuffer(50, 50);
199
+ buf.add("a\n\nb\n");
200
+ const { text, truncatedLines } = buf.format();
201
+ expect(truncatedLines).toBe(0);
202
+ expect(buf.totalLines).toBe(3);
203
+ expect(text).toBe("a\n\nb");
204
+ });
205
+ });
206
+ describe("medium output (overlap dedup)", () => {
207
+ it("dedupes when output smaller than head", () => {
208
+ const buf = new OutputBuffer(3, 3);
209
+ buf.add("1\n2\n");
210
+ const { text, truncatedLines } = buf.format();
211
+ expect(truncatedLines).toBe(0);
212
+ expect(text.split("\n")).toEqual(["1", "2"]);
213
+ });
214
+ it("dedupes when head and tail overlap exactly", () => {
215
+ const buf = new OutputBuffer(3, 3);
216
+ buf.add("1\n2\n3\n");
217
+ const { text, truncatedLines } = buf.format();
218
+ expect(truncatedLines).toBe(0);
219
+ expect(text.split("\n")).toEqual([
220
+ "1",
221
+ "2",
222
+ "3"
223
+ ]);
224
+ });
225
+ it("dedupes when tail starts in head region", () => {
226
+ const buf = new OutputBuffer(3, 3);
227
+ buf.add("1\n2\n3\n4\n");
228
+ const { text, truncatedLines } = buf.format();
229
+ expect(truncatedLines).toBe(0);
230
+ expect(text.split("\n")).toEqual([
231
+ "1",
232
+ "2",
233
+ "3",
234
+ "4"
235
+ ]);
236
+ });
237
+ it("dedupes when output smaller than tail capacity", () => {
238
+ const buf = new OutputBuffer(50, 50);
239
+ buf.add("a\nb\nc\n");
240
+ const { text, truncatedLines } = buf.format();
241
+ expect(truncatedLines).toBe(0);
242
+ expect(text).toBe("a\nb\nc");
243
+ });
244
+ it("does not drop lines when content has duplicates (regression)", () => {
245
+ const buf = new OutputBuffer(3, 3);
246
+ buf.add("A\nB\nA\nC\n");
247
+ const { text, truncatedLines } = buf.format();
248
+ expect(truncatedLines).toBe(0);
249
+ expect(text.split("\n")).toEqual([
250
+ "A",
251
+ "B",
252
+ "A",
253
+ "C"
254
+ ]);
255
+ });
256
+ it("handles all-identical lines without dropping (regression)", () => {
257
+ const buf = new OutputBuffer(3, 3);
258
+ buf.add("x\nx\nx\nx\nx\n");
259
+ const { text, truncatedLines } = buf.format();
260
+ expect(truncatedLines).toBe(0);
261
+ const lines = text.split("\n");
262
+ expect(lines).toHaveLength(5);
263
+ expect(lines.every((l) => l === "x")).toBe(true);
264
+ });
265
+ });
266
+ describe("large output (truncation)", () => {
267
+ it("shows head + marker + tail when truncated", () => {
268
+ const buf = new OutputBuffer(2, 2);
269
+ for (let i = 1; i <= 10; i++) buf.add(`line${i}\n`);
270
+ const { text, truncatedLines } = buf.format();
271
+ expect(truncatedLines).toBe(6);
272
+ expect(text).toContain("line1");
273
+ expect(text).toContain("line2");
274
+ expect(text).toContain("line9");
275
+ expect(text).toContain("line10");
276
+ expect(text).toContain("6 lines truncated");
277
+ const idx1 = text.indexOf("line1");
278
+ const idxMarker = text.indexOf("truncated");
279
+ const idx10 = text.indexOf("line10");
280
+ expect(idx1).toBeLessThan(idxMarker);
281
+ expect(idxMarker).toBeLessThan(idx10);
282
+ });
283
+ it("tracks totalLines correctly", () => {
284
+ const buf = new OutputBuffer(5, 5);
285
+ buf.add("a\nb\nc\nd\ne\nf\ng\nh\n");
286
+ expect(buf.totalLines).toBe(8);
287
+ });
288
+ it("shows exact truncated count", () => {
289
+ const buf = new OutputBuffer(5, 5);
290
+ for (let i = 1; i <= 100; i++) buf.add(`line${i}\n`);
291
+ const { truncatedLines } = buf.format();
292
+ expect(truncatedLines).toBe(90);
293
+ });
294
+ it("handles output exactly at head+tail boundary", () => {
295
+ const buf = new OutputBuffer(5, 5);
296
+ for (let i = 1; i <= 10; i++) buf.add(`line${i}\n`);
297
+ const { text, truncatedLines } = buf.format();
298
+ expect(truncatedLines).toBe(0);
299
+ expect(text.split("\n")).toHaveLength(10);
300
+ });
301
+ it("handles output just over head+tail boundary", () => {
302
+ const buf = new OutputBuffer(5, 5);
303
+ for (let i = 1; i <= 11; i++) buf.add(`line${i}\n`);
304
+ const { text, truncatedLines } = buf.format();
305
+ expect(truncatedLines).toBe(1);
306
+ expect(text).toContain("line1");
307
+ expect(text).toContain("line11");
308
+ });
309
+ });
310
+ describe("streaming (chunk handling)", () => {
311
+ it("handles partial lines at chunk boundaries", () => {
312
+ const buf = new OutputBuffer(5, 5);
313
+ buf.add("hel");
314
+ buf.add("lo\nworld");
315
+ buf.add("\n");
316
+ const { text, truncatedLines } = buf.format();
317
+ expect(truncatedLines).toBe(0);
318
+ expect(text).toBe("hello\nworld");
319
+ });
320
+ it("handles multiple chunks without newlines", () => {
321
+ const buf = new OutputBuffer(5, 5);
322
+ buf.add("abc");
323
+ buf.add("def");
324
+ buf.add("ghi\n");
325
+ const { text, truncatedLines } = buf.format();
326
+ expect(truncatedLines).toBe(0);
327
+ expect(text).toBe("abcdefghi");
328
+ });
329
+ it("handles empty chunks", () => {
330
+ const buf = new OutputBuffer(5, 5);
331
+ buf.add("");
332
+ buf.add("test\n");
333
+ buf.add("");
334
+ const { text } = buf.format();
335
+ expect(text).toBe("test");
336
+ });
337
+ it("handles chunk ending with newline then more data", () => {
338
+ const buf = new OutputBuffer(5, 5);
339
+ buf.add("line1\n");
340
+ buf.add("line2\n");
341
+ buf.add("line3\n");
342
+ const { text } = buf.format();
343
+ expect(text).toBe("line1\nline2\nline3");
344
+ });
345
+ });
346
+ describe("reversion guards (fail on old behavior)", () => {
347
+ it("shows FIRST lines, not just tail", () => {
348
+ const buf = new OutputBuffer(5, 5);
349
+ for (let i = 1; i <= 100; i++) buf.add(`line ${i}\n`);
350
+ const { text } = buf.format();
351
+ expect(text).toContain("line 1");
352
+ expect(text).toContain("line 5");
353
+ expect(text).toContain("line 96");
354
+ expect(text).toContain("line 100");
355
+ });
356
+ it("head comes BEFORE tail in output", () => {
357
+ const buf = new OutputBuffer(5, 5);
358
+ for (let i = 1; i <= 100; i++) buf.add(`line ${i}\n`);
359
+ const { text } = buf.format();
360
+ const firstHeadIdx = text.indexOf("line 1");
361
+ const lastHeadIdx = text.indexOf("line 5");
362
+ const firstTailIdx = text.indexOf("line 96");
363
+ const lastTailIdx = text.indexOf("line 100");
364
+ expect(lastHeadIdx).toBeLessThan(firstTailIdx);
365
+ expect(firstHeadIdx).toBeLessThan(lastTailIdx);
366
+ });
367
+ it("truncation marker between head and tail", () => {
368
+ const buf = new OutputBuffer(5, 5);
369
+ for (let i = 1; i <= 100; i++) buf.add(`line ${i}\n`);
370
+ const { text } = buf.format();
371
+ const lastHeadIdx = text.indexOf("line 5");
372
+ const markerIdx = text.indexOf("truncated");
373
+ const firstTailIdx = text.indexOf("line 96");
374
+ expect(lastHeadIdx).toBeLessThan(markerIdx);
375
+ expect(markerIdx).toBeLessThan(firstTailIdx);
376
+ });
377
+ });
378
+ describe("edge cases", () => {
379
+ it("handles very long single line (no newlines)", () => {
380
+ const buf = new OutputBuffer(50, 50);
381
+ buf.add("x".repeat(1e5));
382
+ const { text, truncatedLines } = buf.format();
383
+ expect(truncatedLines).toBe(0);
384
+ expect(text.length).toBe(1e5);
385
+ });
386
+ it("handles many short lines", () => {
387
+ const buf = new OutputBuffer(5, 5);
388
+ for (let i = 0; i < 1e3; i++) buf.add("x\n");
389
+ const { text, truncatedLines } = buf.format();
390
+ expect(truncatedLines).toBe(990);
391
+ expect(text.split("\n").filter((l) => l === "x").length).toBe(10);
392
+ });
393
+ it("handles mixed empty and non-empty lines", () => {
394
+ const buf = new OutputBuffer(50, 50);
395
+ buf.add("a\n\nb\n\nc\n");
396
+ const { text, truncatedLines } = buf.format();
397
+ expect(truncatedLines).toBe(0);
398
+ expect(text).toBe("a\n\nb\n\nc");
399
+ expect(buf.totalLines).toBe(5);
400
+ });
401
+ it("handles carriage returns (treats as part of line)", () => {
402
+ const buf = new OutputBuffer(50, 50);
403
+ buf.add("line1\r\nline2\r\n");
404
+ const { text } = buf.format();
405
+ expect(text).toBe("line1\r\nline2\r");
406
+ });
407
+ });
408
+ });
409
+ describe("headTail helper", () => {
410
+ it("returns all items when under limit", () => {
411
+ const { head, tail, truncated, truncatedCount } = headTail([
412
+ 1,
413
+ 2,
414
+ 3,
415
+ 4,
416
+ 5
417
+ ], 10);
418
+ expect(head).toEqual([
419
+ 1,
420
+ 2,
421
+ 3,
422
+ 4,
423
+ 5
424
+ ]);
425
+ expect(tail).toEqual([]);
426
+ expect(truncated).toEqual([]);
427
+ expect(truncatedCount).toBe(0);
428
+ });
429
+ it("splits evenly at limit", () => {
430
+ const { head, tail, truncatedCount } = headTail([
431
+ 1,
432
+ 2,
433
+ 3,
434
+ 4,
435
+ 5,
436
+ 6,
437
+ 7,
438
+ 8,
439
+ 9,
440
+ 10
441
+ ], 10);
442
+ expect(head).toEqual([
443
+ 1,
444
+ 2,
445
+ 3,
446
+ 4,
447
+ 5,
448
+ 6,
449
+ 7,
450
+ 8,
451
+ 9,
452
+ 10
453
+ ]);
454
+ expect(tail).toEqual([]);
455
+ expect(truncatedCount).toBe(0);
456
+ });
457
+ it("splits into head + tail when over limit", () => {
458
+ const { head, tail, truncated, truncatedCount } = headTail(Array.from({ length: 100 }, (_, i) => i + 1), 20);
459
+ expect(head).toEqual([
460
+ 1,
461
+ 2,
462
+ 3,
463
+ 4,
464
+ 5,
465
+ 6,
466
+ 7,
467
+ 8,
468
+ 9,
469
+ 10
470
+ ]);
471
+ expect(tail).toEqual([
472
+ 91,
473
+ 92,
474
+ 93,
475
+ 94,
476
+ 95,
477
+ 96,
478
+ 97,
479
+ 98,
480
+ 99,
481
+ 100
482
+ ]);
483
+ expect(truncatedCount).toBe(80);
484
+ expect(truncated.length).toBe(80);
485
+ });
486
+ it("uses even split for head and tail", () => {
487
+ const { head, tail, truncatedCount } = headTail(Array.from({ length: 100 }, (_, i) => i + 1), 21);
488
+ expect(head.length).toBe(10);
489
+ expect(tail.length).toBe(10);
490
+ expect(truncatedCount).toBe(80);
491
+ });
492
+ });
493
+ describe("formatHeadTail helper", () => {
494
+ it("joins items when under limit", () => {
495
+ expect(formatHeadTail([
496
+ "a",
497
+ "b",
498
+ "c"
499
+ ], 10)).toBe("a\nb\nc");
500
+ });
501
+ it("formats with truncation marker when over limit", () => {
502
+ const result = formatHeadTail(Array.from({ length: 100 }, (_, i) => `item ${i + 1}`), 20);
503
+ expect(result).toContain("item 1");
504
+ expect(result).toContain("item 10");
505
+ expect(result).toContain("item 91");
506
+ expect(result).toContain("item 100");
507
+ expect(result).toContain("80 lines truncated");
508
+ expect(result.indexOf("item 10")).toBeLessThan(result.indexOf("truncated"));
509
+ expect(result.indexOf("truncated")).toBeLessThan(result.indexOf("item 91"));
510
+ });
511
+ it("uses custom truncation message", () => {
512
+ expect(formatHeadTail(Array.from({ length: 50 }, () => `x`), 10, (n) => `-- ${n} hidden --`)).toContain("-- 40 hidden --");
513
+ });
514
+ });
515
+ }
516
+ //#endregion
517
+ export { OutputBuffer, formatHeadTail, headTail, headTailChars };
@@ -0,0 +1,19 @@
1
+ //#region packages/core/permissions/index.d.ts
2
+ interface PermissionRule {
3
+ tool: string;
4
+ matches?: {
5
+ cmd?: string | string[];
6
+ };
7
+ action: "allow" | "reject";
8
+ message?: string;
9
+ }
10
+ interface PermissionVerdict {
11
+ action: "allow" | "reject";
12
+ message?: string;
13
+ }
14
+ declare function evaluatePermission(toolName: string, params: {
15
+ cmd?: string;
16
+ }, rules: PermissionRule[]): PermissionVerdict;
17
+ declare function loadPermissions(): PermissionRule[];
18
+ //#endregion
19
+ export { PermissionRule, PermissionVerdict, evaluatePermission, loadPermissions };
@@ -0,0 +1,140 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ //#region packages/core/permissions/index.ts
5
+ /**
6
+ * permission evaluation for tool calls.
7
+ *
8
+ * reads rules from ~/.pi/agent/permissions.json (separate from
9
+ * settings.json since this is extension-owned config). rules are
10
+ * evaluated first-match-wins, matching tool name and params via
11
+ * glob patterns. default action when no rule matches: allow.
12
+ *
13
+ * format mirrors amp's amp.permissions schema:
14
+ * { tool, matches?, action, message? }
15
+ *
16
+ * only "allow" and "reject" actions for now — no "ask" or "delegate"
17
+ * because pi's tool execute API has no confirmation mechanism.
18
+ */
19
+ /**
20
+ * convert a simple glob pattern (only `*` wildcards) to a regex.
21
+ * covers all patterns amp documents: `*git push*`, `rm *`, `*`.
22
+ */
23
+ function globToRegex(pattern) {
24
+ const withWildcards = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
25
+ return new RegExp(`^${withWildcards}$`, "i");
26
+ }
27
+ function evaluatePermission(toolName, params, rules) {
28
+ for (const rule of rules) {
29
+ if (!globToRegex(rule.tool).test(toolName)) continue;
30
+ if (rule.matches?.cmd) {
31
+ if (!(Array.isArray(rule.matches.cmd) ? rule.matches.cmd : [rule.matches.cmd]).some((p) => globToRegex(p).test(params.cmd ?? ""))) continue;
32
+ }
33
+ return {
34
+ action: rule.action,
35
+ message: rule.message
36
+ };
37
+ }
38
+ return { action: "allow" };
39
+ }
40
+ const PERMISSIONS_PATH = path.join(os.homedir(), ".pi", "agent", "permissions.json");
41
+ function loadPermissions() {
42
+ try {
43
+ const raw = fs.readFileSync(PERMISSIONS_PATH, "utf-8");
44
+ const parsed = JSON.parse(raw);
45
+ if (!Array.isArray(parsed)) return [];
46
+ return parsed;
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+ if (import.meta.vitest) {
52
+ const { describe, it, expect } = import.meta.vitest;
53
+ const RULES = [
54
+ {
55
+ tool: "Bash",
56
+ matches: { cmd: ["*git add -A*", "*git add .*"] },
57
+ action: "reject",
58
+ message: "stage files explicitly with 'git add <file>' — unstaged changes may not be yours"
59
+ },
60
+ {
61
+ tool: "Bash",
62
+ matches: { cmd: [
63
+ "*git push --force*",
64
+ "*git push -f*",
65
+ "*--force-with-lease*"
66
+ ] },
67
+ action: "reject",
68
+ message: "never force push. if diverged: 'git fetch origin && git rebase origin/main && git push'"
69
+ },
70
+ {
71
+ tool: "Bash",
72
+ matches: { cmd: [
73
+ "rm *",
74
+ "* && rm *",
75
+ "* || rm *",
76
+ "* ; rm *"
77
+ ] },
78
+ action: "reject",
79
+ message: "use 'trash <file>' instead of rm — recoverable deletion"
80
+ },
81
+ {
82
+ tool: "*",
83
+ action: "allow"
84
+ }
85
+ ];
86
+ describe("evaluatePermission", () => {
87
+ it("allows normal commands", () => {
88
+ expect(evaluatePermission("Bash", { cmd: "git status" }, RULES)).toEqual({ action: "allow" });
89
+ expect(evaluatePermission("Bash", { cmd: "ls -la" }, RULES)).toEqual({ action: "allow" });
90
+ expect(evaluatePermission("Bash", { cmd: "nix build .#foo" }, RULES)).toEqual({ action: "allow" });
91
+ });
92
+ it("rejects git add -A", () => {
93
+ const v = evaluatePermission("Bash", { cmd: "git add -A" }, RULES);
94
+ expect(v.action).toBe("reject");
95
+ expect(v.message).toContain("stage files explicitly");
96
+ });
97
+ it("rejects git add .", () => {
98
+ expect(evaluatePermission("Bash", { cmd: "git add ." }, RULES).action).toBe("reject");
99
+ });
100
+ it("allows explicit git add", () => {
101
+ expect(evaluatePermission("Bash", { cmd: "git add src/foo.ts" }, RULES).action).toBe("allow");
102
+ });
103
+ it("rejects force push variants", () => {
104
+ expect(evaluatePermission("Bash", { cmd: "git push --force" }, RULES).action).toBe("reject");
105
+ expect(evaluatePermission("Bash", { cmd: "git push -f origin main" }, RULES).action).toBe("reject");
106
+ expect(evaluatePermission("Bash", { cmd: "git push --force-with-lease" }, RULES).action).toBe("reject");
107
+ });
108
+ it("allows normal git push", () => {
109
+ expect(evaluatePermission("Bash", { cmd: "git push" }, RULES).action).toBe("allow");
110
+ expect(evaluatePermission("Bash", { cmd: "git push origin main" }, RULES).action).toBe("allow");
111
+ });
112
+ it("rejects rm commands", () => {
113
+ expect(evaluatePermission("Bash", { cmd: "rm foo.txt" }, RULES).action).toBe("reject");
114
+ expect(evaluatePermission("Bash", { cmd: "rm -rf /tmp/junk" }, RULES).action).toBe("reject");
115
+ expect(evaluatePermission("Bash", { cmd: "ls && rm foo" }, RULES).action).toBe("reject");
116
+ expect(evaluatePermission("Bash", { cmd: "false || rm foo" }, RULES).action).toBe("reject");
117
+ expect(evaluatePermission("Bash", { cmd: "echo hi ; rm foo" }, RULES).action).toBe("reject");
118
+ });
119
+ it("allows non-Bash tools via wildcard catch-all", () => {
120
+ expect(evaluatePermission("Read", { cmd: "/etc/passwd" }, RULES)).toEqual({ action: "allow" });
121
+ });
122
+ it("allows everything when no rules", () => {
123
+ expect(evaluatePermission("Bash", { cmd: "rm -rf /" }, [])).toEqual({ action: "allow" });
124
+ });
125
+ it("matches tool name with glob", () => {
126
+ const rules = [{
127
+ tool: "mcp__*",
128
+ action: "reject",
129
+ message: "no mcp"
130
+ }, {
131
+ tool: "*",
132
+ action: "allow"
133
+ }];
134
+ expect(evaluatePermission("mcp__playwright_click", {}, rules).action).toBe("reject");
135
+ expect(evaluatePermission("Bash", { cmd: "ls" }, rules).action).toBe("allow");
136
+ });
137
+ });
138
+ }
139
+ //#endregion
140
+ export { evaluatePermission, loadPermissions };