pi-mono-all 1.0.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.
Files changed (161) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENCE.md +7 -0
  3. package/node_modules/pi-common/package.json +22 -0
  4. package/node_modules/pi-common/src/auth-config.ts +290 -0
  5. package/node_modules/pi-common/src/auth.ts +63 -0
  6. package/node_modules/pi-common/src/cache.ts +60 -0
  7. package/node_modules/pi-common/src/errors.ts +47 -0
  8. package/node_modules/pi-common/src/http-client.ts +118 -0
  9. package/node_modules/pi-common/src/index.ts +7 -0
  10. package/node_modules/pi-common/src/rate-limiter.ts +32 -0
  11. package/node_modules/pi-common/src/tool-result.ts +27 -0
  12. package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
  13. package/node_modules/pi-mono-ask-user-question/README.md +226 -0
  14. package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
  15. package/node_modules/pi-mono-ask-user-question/package.json +29 -0
  16. package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
  17. package/node_modules/pi-mono-auto-fix/README.md +77 -0
  18. package/node_modules/pi-mono-auto-fix/index.ts +488 -0
  19. package/node_modules/pi-mono-auto-fix/package.json +23 -0
  20. package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
  21. package/node_modules/pi-mono-btw/README.md +24 -0
  22. package/node_modules/pi-mono-btw/index.ts +499 -0
  23. package/node_modules/pi-mono-btw/package.json +29 -0
  24. package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
  25. package/node_modules/pi-mono-clear/README.md +40 -0
  26. package/node_modules/pi-mono-clear/index.ts +45 -0
  27. package/node_modules/pi-mono-clear/package.json +29 -0
  28. package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
  29. package/node_modules/pi-mono-context/README.md +74 -0
  30. package/node_modules/pi-mono-context/index.ts +641 -0
  31. package/node_modules/pi-mono-context/package.json +29 -0
  32. package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
  33. package/node_modules/pi-mono-context-guard/README.md +81 -0
  34. package/node_modules/pi-mono-context-guard/index.ts +212 -0
  35. package/node_modules/pi-mono-context-guard/package.json +23 -0
  36. package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
  37. package/node_modules/pi-mono-figma/README.md +236 -0
  38. package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
  39. package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
  40. package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
  41. package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
  42. package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
  43. package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
  44. package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
  45. package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
  46. package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
  47. package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
  48. package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
  49. package/node_modules/pi-mono-figma/index.ts +6 -0
  50. package/node_modules/pi-mono-figma/package.json +33 -0
  51. package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
  52. package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
  53. package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
  54. package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
  55. package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
  56. package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
  57. package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
  58. package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
  59. package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
  60. package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
  61. package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
  62. package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
  63. package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
  64. package/node_modules/pi-mono-linear/README.md +159 -0
  65. package/node_modules/pi-mono-linear/index.ts +6 -0
  66. package/node_modules/pi-mono-linear/package.json +30 -0
  67. package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
  68. package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
  69. package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
  70. package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
  71. package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
  72. package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
  73. package/node_modules/pi-mono-loop/README.md +54 -0
  74. package/node_modules/pi-mono-loop/index.ts +291 -0
  75. package/node_modules/pi-mono-loop/package.json +26 -0
  76. package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
  77. package/node_modules/pi-mono-multi-edit/README.md +244 -0
  78. package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
  79. package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
  80. package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
  81. package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
  82. package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
  83. package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
  84. package/node_modules/pi-mono-multi-edit/index.ts +266 -0
  85. package/node_modules/pi-mono-multi-edit/package.json +37 -0
  86. package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
  87. package/node_modules/pi-mono-multi-edit/types.ts +53 -0
  88. package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
  89. package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
  90. package/node_modules/pi-mono-review/README.md +30 -0
  91. package/node_modules/pi-mono-review/common.ts +930 -0
  92. package/node_modules/pi-mono-review/index.ts +8 -0
  93. package/node_modules/pi-mono-review/package.json +29 -0
  94. package/node_modules/pi-mono-review/review-tui.ts +194 -0
  95. package/node_modules/pi-mono-review/review.ts +119 -0
  96. package/node_modules/pi-mono-review/reviewer.ts +339 -0
  97. package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
  98. package/node_modules/pi-mono-sentinel/README.md +87 -0
  99. package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
  100. package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
  101. package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
  102. package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
  103. package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
  104. package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
  105. package/node_modules/pi-mono-sentinel/index.ts +43 -0
  106. package/node_modules/pi-mono-sentinel/package.json +26 -0
  107. package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
  108. package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
  109. package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
  110. package/node_modules/pi-mono-sentinel/session.ts +95 -0
  111. package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
  112. package/node_modules/pi-mono-sentinel/types.ts +39 -0
  113. package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
  114. package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
  115. package/node_modules/pi-mono-simplify/README.md +56 -0
  116. package/node_modules/pi-mono-simplify/index.ts +78 -0
  117. package/node_modules/pi-mono-simplify/package.json +29 -0
  118. package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
  119. package/node_modules/pi-mono-status-line/README.md +96 -0
  120. package/node_modules/pi-mono-status-line/basic.ts +89 -0
  121. package/node_modules/pi-mono-status-line/expert.ts +689 -0
  122. package/node_modules/pi-mono-status-line/index.ts +54 -0
  123. package/node_modules/pi-mono-status-line/package.json +29 -0
  124. package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
  125. package/node_modules/pi-mono-team-mode/README.md +246 -0
  126. package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
  127. package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
  128. package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
  129. package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
  130. package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
  131. package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
  132. package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
  133. package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
  134. package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
  135. package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
  136. package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
  137. package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
  138. package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
  139. package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
  140. package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
  141. package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
  142. package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
  143. package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
  144. package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
  145. package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
  146. package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
  147. package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
  148. package/node_modules/pi-mono-team-mode/index.ts +825 -0
  149. package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
  150. package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
  151. package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
  152. package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
  153. package/node_modules/pi-mono-team-mode/package.json +33 -0
  154. package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
  155. package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
  156. package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
  157. package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
  158. package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
  159. package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
  160. package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
  161. package/package.json +76 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Codex-style apply_patch engine.
3
+ *
4
+ * Accepts payloads bracketed by `*** Begin Patch` / `*** End Patch` and
5
+ * supports three operations: Add File, Delete File, Update File.
6
+ *
7
+ * Design — this is a recursive-descent parser over a line cursor. Each
8
+ * grammar rule owns a small function; there is no shared mutable index
9
+ * bookkeeping or nested-loop state machine. Hunks are stored as raw
10
+ * `oldBlock`/`newBlock` strings so the applier can run `indexOf` directly
11
+ * instead of reconstructing line arrays on each apply.
12
+ *
13
+ * Compatibility notes (vs the original Codex apply_patch format):
14
+ * - Hunks MUST start with a "@@" header. Missing headers are rejected.
15
+ * - Only exact-match hunk anchoring — no 4-pass fuzzy `seekSequence`.
16
+ * - `*** End of File` sentinel hunks are not recognized.
17
+ * - `*** Move to:` is rejected.
18
+ */
19
+
20
+ import { isAbsolute, resolve as resolvePath } from "path";
21
+
22
+ import { generateDiffString } from "./diff.ts";
23
+ import type {
24
+ Hunk,
25
+ PatchOperation,
26
+ PatchOpResult,
27
+ Workspace,
28
+ } from "./types.ts";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Line cursor
32
+ // ---------------------------------------------------------------------------
33
+
34
+ class LineCursor {
35
+ private pos = 0;
36
+ constructor(private readonly lines: readonly string[]) {}
37
+
38
+ peek(): string | undefined {
39
+ return this.lines[this.pos];
40
+ }
41
+
42
+ next(): string | undefined {
43
+ return this.lines[this.pos++];
44
+ }
45
+
46
+ hasMore(): boolean {
47
+ return this.pos < this.lines.length;
48
+ }
49
+
50
+ /** Consume lines while the predicate holds. Returns the number consumed. */
51
+ skipWhile(pred: (line: string) => boolean): number {
52
+ let count = 0;
53
+ while (this.hasMore() && pred(this.peek()!)) {
54
+ this.pos++;
55
+ count++;
56
+ }
57
+ return count;
58
+ }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Parser
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const DIRECTIVE_BEGIN = "*** Begin Patch";
66
+ const DIRECTIVE_END = "*** End Patch";
67
+ const DIRECTIVE_ADD = "*** Add File: ";
68
+ const DIRECTIVE_DELETE = "*** Delete File: ";
69
+ const DIRECTIVE_UPDATE = "*** Update File: ";
70
+ const DIRECTIVE_MOVE = "*** Move to: ";
71
+
72
+ const isBlank = (line: string): boolean => line.trim() === "";
73
+ const isDirective = (line: string): boolean =>
74
+ line.trimEnd().startsWith("*** ");
75
+
76
+ export function parsePatch(patchText: string): PatchOperation[] {
77
+ const normalized = patchText.replace(/\r\n/g, "\n").trim();
78
+ if (normalized.length === 0) {
79
+ throw new Error("Patch is empty or invalid");
80
+ }
81
+
82
+ const lines = normalized.split("\n");
83
+ if (lines[0].trim() !== DIRECTIVE_BEGIN) {
84
+ throw new Error(`The first line of the patch must be '${DIRECTIVE_BEGIN}'`);
85
+ }
86
+ if (lines[lines.length - 1].trim() !== DIRECTIVE_END) {
87
+ throw new Error(`The last line of the patch must be '${DIRECTIVE_END}'`);
88
+ }
89
+
90
+ // Cursor over the interior (strip Begin and End sentinels).
91
+ const cursor = new LineCursor(lines.slice(1, -1));
92
+ const operations: PatchOperation[] = [];
93
+
94
+ while (cursor.hasMore()) {
95
+ cursor.skipWhile(isBlank);
96
+ if (!cursor.hasMore()) break;
97
+
98
+ const header = cursor.next()!.trimEnd();
99
+
100
+ if (header.startsWith(DIRECTIVE_ADD)) {
101
+ operations.push(parseAddFile(header.slice(DIRECTIVE_ADD.length), cursor));
102
+ continue;
103
+ }
104
+ if (header.startsWith(DIRECTIVE_DELETE)) {
105
+ operations.push({
106
+ kind: "delete",
107
+ path: header.slice(DIRECTIVE_DELETE.length),
108
+ });
109
+ continue;
110
+ }
111
+ if (header.startsWith(DIRECTIVE_UPDATE)) {
112
+ operations.push(
113
+ parseUpdateFile(header.slice(DIRECTIVE_UPDATE.length), cursor),
114
+ );
115
+ continue;
116
+ }
117
+
118
+ throw new Error(
119
+ `'${header}' is not a valid hunk header. Valid headers: '${DIRECTIVE_ADD.trim()}', '${DIRECTIVE_DELETE.trim()}', '${DIRECTIVE_UPDATE.trim()}'`,
120
+ );
121
+ }
122
+
123
+ return operations;
124
+ }
125
+
126
+ function parseAddFile(path: string, cursor: LineCursor): PatchOperation {
127
+ const bodyLines: string[] = [];
128
+
129
+ while (cursor.hasMore()) {
130
+ const line = cursor.peek()!;
131
+ if (isDirective(line)) break;
132
+ cursor.next();
133
+ if (!line.startsWith("+")) {
134
+ throw new Error(
135
+ `Invalid add-file line '${line}'. Add-file lines must start with '+'`,
136
+ );
137
+ }
138
+ bodyLines.push(line.slice(1));
139
+ }
140
+
141
+ const contents = bodyLines.length > 0 ? `${bodyLines.join("\n")}\n` : "";
142
+ return { kind: "add", path, contents };
143
+ }
144
+
145
+ function parseUpdateFile(path: string, cursor: LineCursor): PatchOperation {
146
+ // Move-to is explicitly rejected — we only support in-place updates.
147
+ const lookahead = cursor.peek();
148
+ if (
149
+ lookahead !== undefined &&
150
+ lookahead.trimEnd().startsWith(DIRECTIVE_MOVE)
151
+ ) {
152
+ throw new Error("Patch move operations (*** Move to:) are not supported.");
153
+ }
154
+
155
+ const hunks: Hunk[] = [];
156
+
157
+ while (cursor.hasMore()) {
158
+ cursor.skipWhile(isBlank);
159
+ if (!cursor.hasMore()) break;
160
+
161
+ const line = cursor.peek()!;
162
+ if (isDirective(line)) break;
163
+
164
+ hunks.push(parseHunk(path, cursor));
165
+ }
166
+
167
+ if (hunks.length === 0) {
168
+ throw new Error(`Update file hunk for path '${path}' is empty`);
169
+ }
170
+
171
+ return { kind: "update", path, hunks };
172
+ }
173
+
174
+ function parseHunk(path: string, cursor: LineCursor): Hunk {
175
+ const header = cursor.next();
176
+ if (header === undefined) {
177
+ throw new Error(`Expected @@ hunk header in '${path}', got end of patch`);
178
+ }
179
+
180
+ const trimmed = header.trimEnd();
181
+ let contextPrefix: string | undefined;
182
+ if (trimmed === "@@") {
183
+ contextPrefix = undefined;
184
+ } else if (trimmed.startsWith("@@ ")) {
185
+ contextPrefix = trimmed.slice(3);
186
+ } else {
187
+ throw new Error(
188
+ `Expected update hunk to start with @@ context marker, got: '${header}'`,
189
+ );
190
+ }
191
+
192
+ const oldLines: string[] = [];
193
+ const newLines: string[] = [];
194
+
195
+ while (cursor.hasMore()) {
196
+ const raw = cursor.peek()!;
197
+ const trimEnd = raw.trimEnd();
198
+
199
+ // Any directive or next hunk header ends the current hunk.
200
+ if (trimEnd.startsWith("@@") || isDirective(raw)) break;
201
+
202
+ cursor.next();
203
+
204
+ if (raw.length === 0) {
205
+ // Blank line inside a hunk is treated as an unchanged empty line.
206
+ oldLines.push("");
207
+ newLines.push("");
208
+ continue;
209
+ }
210
+
211
+ const marker = raw[0];
212
+ const body = raw.slice(1);
213
+
214
+ if (marker === " ") {
215
+ oldLines.push(body);
216
+ newLines.push(body);
217
+ } else if (marker === "-") {
218
+ oldLines.push(body);
219
+ } else if (marker === "+") {
220
+ newLines.push(body);
221
+ } else {
222
+ throw new Error(
223
+ `Unexpected line found in update hunk for '${path}': '${raw}'. Every line should start with ' ', '+', or '-'.`,
224
+ );
225
+ }
226
+ }
227
+
228
+ if (oldLines.length === 0 && newLines.length === 0) {
229
+ throw new Error(`Update hunk for '${path}' does not contain any lines`);
230
+ }
231
+
232
+ return {
233
+ contextPrefix,
234
+ oldBlock: oldLines.join("\n"),
235
+ newBlock: newLines.join("\n"),
236
+ };
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Applier
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Apply a list of hunks to a file's content. Operates directly on the raw
245
+ * string via `indexOf` — no intermediate line-array reconstruction. A search
246
+ * cursor advances after each hunk so repeated `oldBlock` strings are matched
247
+ * in top-to-bottom order.
248
+ */
249
+ /**
250
+ * Find `needle` in `haystack` starting from `offset`. Tries exact match
251
+ * first; if that fails, retries with per-line trimEnd on both sides.
252
+ * Returns `{ pos, matchLength }` referencing the *original* haystack, or
253
+ * undefined when no match is found in either pass.
254
+ */
255
+ function findBlock(
256
+ haystack: string,
257
+ needle: string,
258
+ offset: number,
259
+ ): { pos: number; matchLength: number } | undefined {
260
+ const exact = haystack.indexOf(needle, offset);
261
+ if (exact !== -1) return { pos: exact, matchLength: needle.length };
262
+
263
+ // trimEnd pass: strip trailing whitespace per line on both sides.
264
+ const trimLine = (s: string) =>
265
+ s
266
+ .split("\n")
267
+ .map((l) => l.trimEnd())
268
+ .join("\n");
269
+
270
+ const normNeedle = trimLine(needle);
271
+ const normHaystack = trimLine(haystack);
272
+ if (normNeedle === needle && normHaystack === haystack) return undefined;
273
+
274
+ const normPos = normHaystack.indexOf(normNeedle, offset);
275
+ if (normPos === -1) return undefined;
276
+
277
+ // Map normalised position back to original haystack. Because trimEnd only
278
+ // removes characters (never adds), character positions can only shift
279
+ // right. Walk original lines to find the real byte offset for the matched
280
+ // line index.
281
+ const normPrefix = normHaystack.slice(0, normPos);
282
+ const startLineIdx = normPrefix.split("\n").length - 1;
283
+
284
+ const origLines = haystack.split("\n");
285
+ let realPos = 0;
286
+ for (let i = 0; i < startLineIdx; i++) realPos += origLines[i].length + 1;
287
+
288
+ // Compute the real length: count original bytes for the matched lines.
289
+ const matchedLineCount = normNeedle.split("\n").length;
290
+ let realEnd = realPos;
291
+ for (let i = startLineIdx; i < startLineIdx + matchedLineCount; i++) {
292
+ realEnd += origLines[i].length + 1;
293
+ }
294
+ realEnd--; // exclude trailing \n after last line
295
+
296
+ // If the needle ended with \n, include it.
297
+ if (needle.endsWith("\n") && realEnd + 1 <= haystack.length) realEnd++;
298
+
299
+ return { pos: realPos, matchLength: realEnd - realPos };
300
+ }
301
+
302
+ function applyHunks(filePath: string, content: string, hunks: Hunk[]): string {
303
+ let result = content;
304
+ let cursor = 0;
305
+
306
+ for (const hunk of hunks) {
307
+ let searchFrom = cursor;
308
+
309
+ if (hunk.contextPrefix !== undefined) {
310
+ const ctxMatch = findBlock(result, hunk.contextPrefix, searchFrom);
311
+ if (ctxMatch === undefined) {
312
+ throw new Error(
313
+ `Failed to find context '${hunk.contextPrefix}' in ${filePath}`,
314
+ );
315
+ }
316
+ searchFrom = ctxMatch.pos + ctxMatch.matchLength;
317
+ }
318
+
319
+ if (hunk.oldBlock === "") {
320
+ // Pure insertion: append newBlock at the anchor (or end-of-file).
321
+ const insertAt =
322
+ hunk.contextPrefix !== undefined ? searchFrom : result.length;
323
+ const needsNewline = insertAt > 0 && result[insertAt - 1] !== "\n";
324
+ const prefix = needsNewline ? "\n" : "";
325
+ result =
326
+ result.slice(0, insertAt) +
327
+ prefix +
328
+ hunk.newBlock +
329
+ result.slice(insertAt);
330
+ cursor = insertAt + prefix.length + hunk.newBlock.length;
331
+ continue;
332
+ }
333
+
334
+ const match = findBlock(result, hunk.oldBlock, searchFrom);
335
+ if (match === undefined) {
336
+ throw new Error(
337
+ `Failed to find expected lines in ${filePath}:\n${hunk.oldBlock}`,
338
+ );
339
+ }
340
+
341
+ result =
342
+ result.slice(0, match.pos) +
343
+ hunk.newBlock +
344
+ result.slice(match.pos + match.matchLength);
345
+ cursor = match.pos + hunk.newBlock.length;
346
+ }
347
+
348
+ // Preserve the "file ends with newline" invariant upstream relies on.
349
+ if (!result.endsWith("\n")) {
350
+ result = `${result}\n`;
351
+ }
352
+
353
+ return result;
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Orchestration
358
+ // ---------------------------------------------------------------------------
359
+
360
+ function resolvePatchPath(cwd: string, filePath: string): string {
361
+ const trimmed = filePath.trim();
362
+ if (!trimmed) {
363
+ throw new Error("Patch path cannot be empty");
364
+ }
365
+ return isAbsolute(trimmed) ? resolvePath(trimmed) : resolvePath(cwd, trimmed);
366
+ }
367
+
368
+ function ensureTrailingNewline(content: string): string {
369
+ return content.endsWith("\n") ? content : `${content}\n`;
370
+ }
371
+
372
+ export async function applyPatchOperations(
373
+ ops: PatchOperation[],
374
+ workspace: Workspace,
375
+ cwd: string,
376
+ signal?: AbortSignal,
377
+ options?: { collectDiff?: boolean },
378
+ ): Promise<PatchOpResult[]> {
379
+ const results: PatchOpResult[] = [];
380
+ const collectDiff = options?.collectDiff ?? false;
381
+
382
+ for (const op of ops) {
383
+ if (signal?.aborted) {
384
+ throw new Error("Operation aborted");
385
+ }
386
+
387
+ switch (op.kind) {
388
+ case "add": {
389
+ const abs = resolvePatchPath(cwd, op.path);
390
+ const oldText =
391
+ collectDiff && (await workspace.exists(abs))
392
+ ? await workspace.readText(abs)
393
+ : "";
394
+ const newText = ensureTrailingNewline(op.contents);
395
+ await workspace.writeText(abs, newText);
396
+ results.push(
397
+ buildOpResult(
398
+ op.path,
399
+ `Added file ${op.path}.`,
400
+ oldText,
401
+ newText,
402
+ collectDiff,
403
+ ),
404
+ );
405
+ break;
406
+ }
407
+
408
+ case "delete": {
409
+ const abs = resolvePatchPath(cwd, op.path);
410
+ if (!(await workspace.exists(abs))) {
411
+ throw new Error(`Failed to delete ${op.path}: file does not exist`);
412
+ }
413
+ const oldText = collectDiff ? await workspace.readText(abs) : "";
414
+ await workspace.deleteFile(abs);
415
+ results.push(
416
+ buildOpResult(
417
+ op.path,
418
+ `Deleted file ${op.path}.`,
419
+ oldText,
420
+ "",
421
+ collectDiff,
422
+ ),
423
+ );
424
+ break;
425
+ }
426
+
427
+ case "update": {
428
+ const abs = resolvePatchPath(cwd, op.path);
429
+ const sourceText = await workspace.readText(abs);
430
+ const updated = applyHunks(op.path, sourceText, op.hunks);
431
+ await workspace.writeText(abs, updated);
432
+ results.push(
433
+ buildOpResult(
434
+ op.path,
435
+ `Updated ${op.path}.`,
436
+ sourceText,
437
+ updated,
438
+ collectDiff,
439
+ ),
440
+ );
441
+ break;
442
+ }
443
+ }
444
+ }
445
+
446
+ return results;
447
+ }
448
+
449
+ function buildOpResult(
450
+ path: string,
451
+ message: string,
452
+ oldText: string,
453
+ newText: string,
454
+ collectDiff: boolean,
455
+ ): PatchOpResult {
456
+ const result: PatchOpResult = { path, message };
457
+ if (collectDiff) {
458
+ const { diff, firstChangedLine } = generateDiffString(oldText, newText);
459
+ result.diff = diff;
460
+ result.firstChangedLine = firstChangedLine;
461
+ }
462
+ return result;
463
+ }
@@ -0,0 +1,53 @@
1
+ export interface EditItem {
2
+ path: string;
3
+ oldText: string;
4
+ newText: string;
5
+ }
6
+
7
+ export interface EditResult {
8
+ path: string;
9
+ success: boolean;
10
+ message: string;
11
+ diff?: string;
12
+ firstChangedLine?: number;
13
+ }
14
+
15
+ /**
16
+ * A single edit window inside an Update File operation.
17
+ *
18
+ * `oldBlock` is the exact literal substring to find in the target file;
19
+ * `newBlock` is what it should be replaced with. Both are raw strings (not
20
+ * arrays of lines) so the applier can work directly via `indexOf` without
21
+ * reconstructing line arrays.
22
+ *
23
+ * `contextPrefix` is an optional anchor from a "@@ foo" hunk header. When set,
24
+ * the applier must find `contextPrefix` before searching for `oldBlock`, so
25
+ * the same oldBlock can appear multiple times in the file and be disambiguated
26
+ * by the anchor.
27
+ */
28
+ export interface Hunk {
29
+ contextPrefix?: string;
30
+ oldBlock: string;
31
+ newBlock: string;
32
+ }
33
+
34
+ export type PatchOperation =
35
+ | { kind: "add"; path: string; contents: string }
36
+ | { kind: "delete"; path: string }
37
+ | { kind: "update"; path: string; hunks: Hunk[] };
38
+
39
+ export interface PatchOpResult {
40
+ path: string;
41
+ message: string;
42
+ diff?: string;
43
+ firstChangedLine?: number;
44
+ }
45
+
46
+ export interface Workspace {
47
+ readText: (absolutePath: string) => Promise<string>;
48
+ writeText: (absolutePath: string, content: string) => Promise<void>;
49
+ deleteFile: (absolutePath: string) => Promise<void>;
50
+ exists: (absolutePath: string) => Promise<boolean>;
51
+ /** Check that the file is writable. Rejects if not. Virtual implementations may still touch the real FS so preflights fail fast on read-only files. */
52
+ checkWriteAccess: (absolutePath: string) => Promise<void>;
53
+ }
@@ -0,0 +1,85 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { constants } from "fs";
3
+ import { access as fsAccess, readFile as fsReadFile, unlink as fsUnlink, writeFile as fsWriteFile } from "fs/promises";
4
+
5
+ import type { Workspace } from "./types.ts";
6
+
7
+ export function createRealWorkspace(pi: ExtensionAPI): Workspace {
8
+ const readCache = new Map<string, string>();
9
+ return {
10
+ readText: async (absolutePath: string) => {
11
+ if (readCache.has(absolutePath)) return readCache.get(absolutePath)!;
12
+ const content = await fsReadFile(absolutePath, "utf-8");
13
+ readCache.set(absolutePath, content);
14
+ return content;
15
+ },
16
+ writeText: async (absolutePath: string, content: string) => {
17
+ // Skip the write (and the file-modified event) when content is
18
+ // identical to what we last read. Prevents thrashing downstream
19
+ // consumers (watchers, context-guard) after no-op dedups.
20
+ const existing = readCache.get(absolutePath);
21
+ if (existing === content) return;
22
+ readCache.delete(absolutePath);
23
+ await fsWriteFile(absolutePath, content, "utf-8");
24
+ pi.events.emit("context-guard:file-modified", { path: absolutePath });
25
+ },
26
+ deleteFile: async (absolutePath: string) => {
27
+ readCache.delete(absolutePath);
28
+ await fsUnlink(absolutePath);
29
+ pi.events.emit("context-guard:file-modified", { path: absolutePath });
30
+ },
31
+ exists: async (absolutePath: string) => {
32
+ try {
33
+ await fsAccess(absolutePath, constants.F_OK);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ },
39
+ checkWriteAccess: (absolutePath: string) => fsAccess(absolutePath, constants.R_OK | constants.W_OK),
40
+ };
41
+ }
42
+
43
+ export function createVirtualWorkspace(cwd: string): Workspace {
44
+ const state = new Map<string, string | null>();
45
+
46
+ async function ensureLoaded(absolutePath: string): Promise<void> {
47
+ if (state.has(absolutePath)) return;
48
+ try {
49
+ const content = await fsReadFile(absolutePath, "utf-8");
50
+ state.set(absolutePath, content);
51
+ } catch {
52
+ state.set(absolutePath, null);
53
+ }
54
+ }
55
+
56
+ return {
57
+ readText: async (absolutePath) => {
58
+ await ensureLoaded(absolutePath);
59
+ const content = state.get(absolutePath);
60
+ if (content === null || content === undefined) {
61
+ throw new Error(`File not found: ${absolutePath.replace(`${cwd}/`, "")}`);
62
+ }
63
+ return content;
64
+ },
65
+ writeText: async (absolutePath, content) => {
66
+ state.set(absolutePath, content);
67
+ },
68
+ deleteFile: async (absolutePath) => {
69
+ await ensureLoaded(absolutePath);
70
+ if (state.get(absolutePath) === null) {
71
+ throw new Error(`File not found: ${absolutePath.replace(`${cwd}/`, "")}`);
72
+ }
73
+ state.set(absolutePath, null);
74
+ },
75
+ exists: async (absolutePath) => {
76
+ await ensureLoaded(absolutePath);
77
+ return state.get(absolutePath) !== null;
78
+ },
79
+ checkWriteAccess: async (absolutePath: string) => {
80
+ // Check real-fs write permission during the virtual preflight so
81
+ // that read-only files fail fast *before* any real file is touched.
82
+ await fsAccess(absolutePath, constants.W_OK);
83
+ },
84
+ };
85
+ }