@narumitw/pi-files 0.0.0-stage → 0.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -2
  4. package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
  5. package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
  6. package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
  7. package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
  8. package/dist/chunks/chunk-F5QFC255.ts +101 -0
  9. package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
  10. package/dist/chunks/chunk-NFNGJETD.ts +77 -0
  11. package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
  12. package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
  13. package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
  14. package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
  15. package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
  16. package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
  17. package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
  18. package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
  19. package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
  20. package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
  21. package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
  22. package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
  23. package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
  24. package/dist/chunks/menu-P6IBMROH.ts +876 -0
  25. package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
  26. package/dist/index.ts +1789 -0
  27. package/dist/index.ts.map +7 -0
  28. package/docs/edit-scope-reference.md +142 -0
  29. package/docs/file-context-reference.md +91 -0
  30. package/docs/migration.md +105 -0
  31. package/docs/verification.md +99 -0
  32. package/package.json +58 -4
  33. package/src/content-search-session.ts +204 -0
  34. package/src/content-search-ui.ts +159 -0
  35. package/src/content-search.ts +170 -0
  36. package/src/context-service.ts +515 -0
  37. package/src/external-editor.ts +143 -0
  38. package/src/file-browser-ui.ts +128 -0
  39. package/src/file-browser.ts +76 -0
  40. package/src/file-context-explorer.ts +992 -0
  41. package/src/file-context-menu.ts +360 -0
  42. package/src/file-context-preview-ui.ts +354 -0
  43. package/src/file-context-settings.ts +301 -0
  44. package/src/file-context.ts +321 -0
  45. package/src/file-search.ts +246 -0
  46. package/src/files-menu.ts +125 -0
  47. package/src/files-settings.ts +314 -0
  48. package/src/files-widget.ts +65 -0
  49. package/src/files.ts +148 -0
  50. package/src/git-context.ts +485 -0
  51. package/src/index.ts +1 -0
  52. package/src/scope/contracts.ts +243 -0
  53. package/src/scope/display.ts +27 -0
  54. package/src/scope/edit-scope.ts +435 -0
  55. package/src/scope/files.ts +118 -0
  56. package/src/scope/input.ts +207 -0
  57. package/src/scope/menu.ts +277 -0
  58. package/src/scope/paths.ts +142 -0
  59. package/src/scope/picker-keys.ts +154 -0
  60. package/src/scope/picker.ts +484 -0
  61. package/src/scope/policy.ts +55 -0
  62. package/src/scope/settings.ts +138 -0
  63. package/src/scope/state.ts +91 -0
  64. package/src/scope/ui.ts +106 -0
package/dist/index.ts ADDED
@@ -0,0 +1,1789 @@
1
+ // @generated by scripts/build-runtime.mjs; do not edit.
2
+ // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
3
+ import {
4
+ MAX_PENDING_QUOTES,
5
+ MAX_PENDING_QUOTE_BYTES,
6
+ MAX_QUOTE_BYTES,
7
+ MAX_QUOTE_LINES,
8
+ appendPendingQuote,
9
+ discoverProjectFiles,
10
+ formatQuoteContext,
11
+ loadProjectTextFile
12
+ } from "./chunks/chunk-WOTDNRTG.ts";
13
+ import {
14
+ STATE_TYPE,
15
+ createSnapshot,
16
+ parseSnapshot,
17
+ restoreScope,
18
+ sameScope
19
+ } from "./chunks/chunk-NFNGJETD.ts";
20
+ import {
21
+ awaitFileContextSettingsWrites,
22
+ fileContextSettingsPath,
23
+ loadFileContextSettings,
24
+ updateFileContextSettings
25
+ } from "./chunks/chunk-OPGTWNJI.ts";
26
+ import {
27
+ assertScopeTarget,
28
+ resolveScopeTarget
29
+ } from "./chunks/chunk-F5QFC255.ts";
30
+ import {
31
+ displayError,
32
+ displayPath
33
+ } from "./chunks/chunk-AXW73HDJ.ts";
34
+
35
+ // src/external-editor.ts
36
+ import { spawn } from "node:child_process";
37
+ import { lstat, realpath } from "node:fs/promises";
38
+ import { isAbsolute, relative, resolve, sep } from "node:path";
39
+ import { getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
40
+ async function editProjectFileInExternalEditor(options) {
41
+ options.signal?.throwIfAborted();
42
+ const target = await resolveEditableProjectFile(options.root, options.projectPath, options.signal);
43
+ options.signal?.throwIfAborted();
44
+ const command = options.command ?? SettingsManager.create(options.root, options.agentDir ?? getAgentDir(), {
45
+ projectTrusted: options.projectTrusted
46
+ }).getExternalEditorCommand();
47
+ const [editor, ...editorArgs] = command.trim().split(/\s+/u);
48
+ if (!editor) throw new Error("External editor command is empty");
49
+ assertEditorTargetSafeForPlatform(target);
50
+ await withFileMutationQueue(target, async () => {
51
+ options.signal?.throwIfAborted();
52
+ let stopped = false;
53
+ try {
54
+ options.tui.stop();
55
+ stopped = true;
56
+ process.stdout.write("Launching external editor. Pi will resume when the editor exits.\n");
57
+ await runExternalEditorProcess(editor, [...editorArgs, target], options.signal, options.spawnProcess);
58
+ options.signal?.throwIfAborted();
59
+ } finally {
60
+ if (stopped && !options.signal?.aborted && (options.isCurrent === void 0 || options.isCurrent())) {
61
+ options.tui.start();
62
+ options.tui.requestRender(true);
63
+ }
64
+ }
65
+ });
66
+ }
67
+ async function resolveEditableProjectFile(root, projectPath, signal) {
68
+ signal?.throwIfAborted();
69
+ if (!projectPath || isAbsolute(projectPath) || projectPath.includes("\0")) {
70
+ throw new Error("File path is outside the project");
71
+ }
72
+ const canonicalRoot = await realpath(root);
73
+ signal?.throwIfAborted();
74
+ const candidate = resolve(canonicalRoot, projectPath);
75
+ if (!isInside(canonicalRoot, candidate)) throw new Error("File path is outside the project");
76
+ const candidateInfo = await lstat(candidate);
77
+ signal?.throwIfAborted();
78
+ if (candidateInfo.isSymbolicLink()) throw new Error(`${projectPath} is a symbolic link`);
79
+ const canonicalFile = await realpath(candidate);
80
+ signal?.throwIfAborted();
81
+ if (!isInside(canonicalRoot, canonicalFile)) throw new Error("File path is outside the project");
82
+ const info = await lstat(canonicalFile);
83
+ signal?.throwIfAborted();
84
+ if (!info.isFile()) throw new Error(`${projectPath} is not a regular file`);
85
+ return canonicalFile;
86
+ }
87
+ async function runExternalEditorProcess(command, args, signal, spawnProcess = defaultSpawnExternalEditorProcess) {
88
+ signal?.throwIfAborted();
89
+ const child = spawnProcess(command, args);
90
+ await new Promise((resolvePromise, reject) => {
91
+ let settled = false;
92
+ let aborted = signal?.aborted ?? false;
93
+ const finish = (error) => {
94
+ if (settled) return;
95
+ settled = true;
96
+ signal?.removeEventListener("abort", abort);
97
+ if (error) reject(error);
98
+ else resolvePromise();
99
+ };
100
+ const abort = () => {
101
+ aborted = true;
102
+ if (!child.killed) child.kill();
103
+ };
104
+ child.once("error", (error) => finish(aborted ? abortError() : error));
105
+ child.once("close", (code) => {
106
+ if (aborted) {
107
+ finish(abortError());
108
+ return;
109
+ }
110
+ if (code === 0) {
111
+ finish();
112
+ return;
113
+ }
114
+ finish(new Error(`External editor exited with code ${code ?? "unknown"}`));
115
+ });
116
+ signal?.addEventListener("abort", abort, { once: true });
117
+ if (signal?.aborted) abort();
118
+ });
119
+ }
120
+ function assertEditorTargetSafeForPlatform(target, platform = process.platform) {
121
+ if (platform === "win32" && /[\r\n"&|<>^()%!]/u.test(target)) {
122
+ throw new Error("File path contains characters unsafe for the Windows external-editor shell");
123
+ }
124
+ }
125
+ function defaultSpawnExternalEditorProcess(command, args) {
126
+ return spawn(command, [...args], {
127
+ stdio: "inherit",
128
+ shell: process.platform === "win32"
129
+ });
130
+ }
131
+ function isInside(root, candidate) {
132
+ const result = relative(root, candidate);
133
+ return result === "" || !result.startsWith(`..${sep}`) && result !== ".." && !isAbsolute(result);
134
+ }
135
+ function abortError() {
136
+ return new DOMException("External editor cancelled", "AbortError");
137
+ }
138
+
139
+ // src/files-widget.ts
140
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
141
+ import { EditorStatusWidget } from "@narumitw/pi-tui-kit/editor-status-widget";
142
+ import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text";
143
+ function publishFilesWidget(ctx, quotes, scope) {
144
+ if (!ctx.hasUI) return;
145
+ if (!quotes.length && (!scope || scope.state === "inactive")) {
146
+ ctx.ui.setWidget("files", void 0);
147
+ return;
148
+ }
149
+ const totalBytes = quotes.reduce((total, quote) => total + Buffer.byteLength(quote.text, "utf8"), 0);
150
+ const lines = [];
151
+ if (scope && scope.state !== "inactive") {
152
+ lines.push({
153
+ role: scope.state === "blocked" ? "error" : "accent",
154
+ text: `${scope.state === "blocked" ? "Edit Scope unavailable \xB7 edits blocked" : `Editing limited to ${scope.targets.length} file${scope.targets.length === 1 ? "" : "s"}`} \xB7 /files off`
155
+ });
156
+ lines.push({
157
+ role: "muted",
158
+ text: `${scope.targets.slice(0, 2).map((target) => displayPath(target.path)).join(" \xB7 ")}${scope.targets.length > 2 ? " \xB7 \u2026" : ""}`
159
+ });
160
+ }
161
+ if (quotes.length) {
162
+ lines.push({
163
+ role: "accent",
164
+ text: `Next prompt context \xB7 ${quotes.length} ${quotes.length === 1 ? "snippet" : "snippets"} \xB7 ~${Math.max(1, Math.ceil(totalBytes / 4))} tokens \xB7 /file-context to review`
165
+ });
166
+ lines.push(
167
+ ...quotes.map((quote, index) => ({
168
+ role: "muted",
169
+ text: `${index + 1}. ${sanitizeTerminalText(escapeControls(quote.path))} \xB7 lines ${quote.startLine}-${quote.endLine} \xB7 ~${Math.max(1, Math.ceil(Buffer.byteLength(quote.text, "utf8") / 4))} tokens`
170
+ }))
171
+ );
172
+ }
173
+ const render = (theme) => lines.map((line) => theme.fg(line.role, line.text));
174
+ if (ctx.mode !== "tui") {
175
+ ctx.ui.setWidget("files", render(ctx.ui.theme));
176
+ return;
177
+ }
178
+ ctx.ui.setWidget(
179
+ "files",
180
+ (_tui, theme) => new EditorStatusWidget({
181
+ theme,
182
+ renderBody: (width) => width === 0 ? lines.map(() => "") : render(theme).flatMap((line) => wrapTextWithAnsi(line, width))
183
+ })
184
+ );
185
+ }
186
+ function escapeControls(text) {
187
+ return [...text].map((character) => {
188
+ const code = character.charCodeAt(0);
189
+ return code <= 31 || code >= 127 && code <= 159 ? `\\x${code.toString(16).padStart(2, "0")}` : character;
190
+ }).join("");
191
+ }
192
+
193
+ // src/git-context.ts
194
+ import { execFile } from "node:child_process";
195
+ import { realpath as realpath2 } from "node:fs/promises";
196
+ import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
197
+ import { promisify } from "node:util";
198
+ var execFileAsync = promisify(execFile);
199
+ var GIT_TIMEOUT_MS = 5e3;
200
+ var GIT_MAX_BUFFER = 11e5;
201
+ var MAX_HISTORY_ENTRIES = 20;
202
+ var GitContext = class {
203
+ constructor(projectRoot, project2, statuses) {
204
+ this.projectRoot = projectRoot;
205
+ this.project = project2;
206
+ this.statusMap = new Map(statuses);
207
+ }
208
+ projectRoot;
209
+ project;
210
+ statusMap;
211
+ work = /* @__PURE__ */ new Set();
212
+ get statuses() {
213
+ return this.statusMap;
214
+ }
215
+ async getFileContext(projectPath, signal) {
216
+ this.assertProjectPath(projectPath);
217
+ const [blobResult, diffResult] = await Promise.all([
218
+ this.run(["rev-parse", "--verify", `HEAD:${this.repositoryPath(projectPath)}`], true, signal),
219
+ this.run(["diff", "--no-ext-diff", "--no-textconv", "--unified=3", "HEAD", "--", projectPath], true, signal)
220
+ ]);
221
+ signal?.throwIfAborted();
222
+ return {
223
+ status: this.statusMap.get(projectPath),
224
+ blob: blobResult.ok ? blobResult.stdout.trim() || void 0 : void 0,
225
+ hunks: diffResult.ok ? parseUnifiedDiff(diffResult.stdout) : []
226
+ };
227
+ }
228
+ async refreshFileContext(projectPath, signal) {
229
+ const [fileContext, statusResult] = await Promise.all([
230
+ this.getFileContext(projectPath, signal),
231
+ this.run(
232
+ [
233
+ "-c",
234
+ "status.relativePaths=true",
235
+ "status",
236
+ "--porcelain=v1",
237
+ "-z",
238
+ "--untracked-files=all",
239
+ "--ignored=traditional",
240
+ "--",
241
+ "."
242
+ ],
243
+ true,
244
+ signal
245
+ )
246
+ ]);
247
+ signal?.throwIfAborted();
248
+ if (!statusResult.ok) return fileContext;
249
+ const statuses = parseStatuses(statusResult.stdout, this.project.projectPrefix);
250
+ this.statusMap.clear();
251
+ for (const [path, status] of statuses) this.statusMap.set(path, status);
252
+ this.project.dirty = [...statuses.values()].some((status) => !status.ignored);
253
+ return { ...fileContext, status: this.statusMap.get(projectPath) };
254
+ }
255
+ async getBlame(projectPath, line, revision, signal) {
256
+ this.assertProjectPath(projectPath);
257
+ if (!Number.isSafeInteger(line) || line < 1) throw new Error("Blame line must be positive");
258
+ if (revision && !/^[0-9a-f]{40}$/i.test(revision)) throw new Error("Invalid blame revision");
259
+ const result = await this.run(
260
+ [
261
+ "blame",
262
+ "--no-textconv",
263
+ "--line-porcelain",
264
+ "-L",
265
+ `${line},${line}`,
266
+ ...revision ? [revision] : [],
267
+ "--",
268
+ projectPath
269
+ ],
270
+ true,
271
+ signal
272
+ );
273
+ signal?.throwIfAborted();
274
+ if (!result.ok) return void 0;
275
+ return parseBlame(result.stdout);
276
+ }
277
+ async getHistory(projectPath, signal) {
278
+ this.assertProjectPath(projectPath);
279
+ const result = await this.run(
280
+ [
281
+ "log",
282
+ `--max-count=${MAX_HISTORY_ENTRIES}`,
283
+ "--format=%x1e%H%x1f%an%x1f%at%x1f%s",
284
+ "--name-only",
285
+ "-z",
286
+ "--follow",
287
+ "--",
288
+ projectPath
289
+ ],
290
+ true,
291
+ signal
292
+ );
293
+ signal?.throwIfAborted();
294
+ return result.ok ? parseHistory(result.stdout) : [];
295
+ }
296
+ async loadRevision(projectPath, revision, historicalPath, signal) {
297
+ this.assertProjectPath(projectPath);
298
+ const normalizedRevision = revision.trim();
299
+ if (!normalizedRevision || /[\0\r\n]/.test(normalizedRevision)) {
300
+ throw new Error("Revision is required");
301
+ }
302
+ const resolved = await this.run(
303
+ ["rev-parse", "--verify", "--end-of-options", `${normalizedRevision}^{commit}`],
304
+ true,
305
+ signal
306
+ );
307
+ if (!resolved.ok || !/^[0-9a-f]{40}$/i.test(resolved.stdout.trim())) {
308
+ throw new Error(`Unknown Git revision: ${normalizedRevision}`);
309
+ }
310
+ signal?.throwIfAborted();
311
+ const commit = resolved.stdout.trim().toLowerCase();
312
+ const repositoryPath = historicalPath ? this.assertRepositoryPath(historicalPath) : this.repositoryPath(projectPath);
313
+ const [contentsResult, blobResult] = await Promise.all([
314
+ this.run(["show", `${commit}:${repositoryPath}`], true, signal),
315
+ this.run(["rev-parse", "--verify", `${commit}:${repositoryPath}`], true, signal)
316
+ ]);
317
+ signal?.throwIfAborted();
318
+ if (!contentsResult.ok) {
319
+ throw new Error(`${projectPath} does not exist at ${normalizedRevision}`);
320
+ }
321
+ if (Buffer.byteLength(contentsResult.stdout, "utf8") > 1e6) {
322
+ throw new Error(`${projectPath} exceeds 1000000 bytes at ${normalizedRevision}`);
323
+ }
324
+ if (contentsResult.stdout.includes("\0")) {
325
+ throw new Error(`${projectPath} appears to be binary at ${normalizedRevision}`);
326
+ }
327
+ return {
328
+ path: historicalPath ?? projectPath,
329
+ lines: normalizeLines(contentsResult.stdout),
330
+ revision: normalizedRevision,
331
+ commit,
332
+ blob: blobResult.ok ? blobResult.stdout.trim() || void 0 : void 0
333
+ };
334
+ }
335
+ /** Drain processes after the owning explorer cancels its request controllers. */
336
+ async whenIdle() {
337
+ await Promise.allSettled([...this.work]);
338
+ }
339
+ run(args, allowFailure = false, signal) {
340
+ const work = runGit(this.projectRoot, args, allowFailure, signal);
341
+ this.work.add(work);
342
+ void work.then(
343
+ () => this.work.delete(work),
344
+ () => this.work.delete(work)
345
+ );
346
+ return work;
347
+ }
348
+ repositoryPath(projectPath) {
349
+ return `${this.project.projectPrefix}${projectPath}`.replaceAll("\\", "/");
350
+ }
351
+ assertRepositoryPath(repositoryPath) {
352
+ const normalized = repositoryPath.replaceAll("\\", "/");
353
+ if (!normalized || isAbsolute2(normalized) || normalized.includes("\0")) {
354
+ throw new Error("Invalid historical Git path");
355
+ }
356
+ const candidate = resolve2(this.project.repositoryRoot, normalized);
357
+ const result = relative2(this.project.repositoryRoot, candidate);
358
+ if (result === ".." || result.startsWith(`..${sep2}`)) {
359
+ throw new Error("Historical Git path is outside the repository");
360
+ }
361
+ return normalized;
362
+ }
363
+ assertProjectPath(projectPath) {
364
+ if (!projectPath || isAbsolute2(projectPath) || projectPath.includes("\0")) {
365
+ throw new Error("Invalid project path");
366
+ }
367
+ const candidate = resolve2(this.projectRoot, projectPath);
368
+ const result = relative2(this.projectRoot, candidate);
369
+ if (result === ".." || result.startsWith(`..${sep2}`)) {
370
+ throw new Error("Git path is outside the project");
371
+ }
372
+ }
373
+ };
374
+ async function createGitContext(root, signal) {
375
+ signal?.throwIfAborted();
376
+ const projectRoot = await realpath2(root);
377
+ signal?.throwIfAborted();
378
+ const repositoryResult = await runGit(projectRoot, ["rev-parse", "--show-toplevel"], true, signal);
379
+ if (!repositoryResult.ok) return void 0;
380
+ const repositoryRoot = stripLineEnding(repositoryResult.stdout);
381
+ if (!repositoryRoot) return void 0;
382
+ const [prefixResult, headResult, branchResult, statusResult] = await settleGitReads([
383
+ runGit(projectRoot, ["rev-parse", "--show-prefix"], true, signal),
384
+ runGit(projectRoot, ["rev-parse", "--verify", "HEAD"], true, signal),
385
+ runGit(projectRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"], true, signal),
386
+ runGit(
387
+ projectRoot,
388
+ [
389
+ "-c",
390
+ "status.relativePaths=true",
391
+ "status",
392
+ "--porcelain=v1",
393
+ "-z",
394
+ "--untracked-files=all",
395
+ "--ignored=traditional",
396
+ "--",
397
+ "."
398
+ ],
399
+ true,
400
+ signal
401
+ )
402
+ ]);
403
+ signal?.throwIfAborted();
404
+ const head = headResult.ok ? headResult.stdout.trim().toLowerCase() : "unborn";
405
+ const branch = branchResult.ok ? branchResult.stdout.trim() : head === "unborn" ? "unborn" : head.slice(0, 12);
406
+ const projectPrefix = prefixResult.ok ? stripLineEnding(prefixResult.stdout) : "";
407
+ const statuses = statusResult.ok ? parseStatuses(statusResult.stdout, projectPrefix) : /* @__PURE__ */ new Map();
408
+ return new GitContext(
409
+ projectRoot,
410
+ {
411
+ repositoryRoot,
412
+ projectPrefix,
413
+ branch,
414
+ head,
415
+ dirty: [...statuses.values()].some((status) => !status.ignored)
416
+ },
417
+ statuses
418
+ );
419
+ }
420
+ async function runGit(cwd, args, allowFailure, signal) {
421
+ try {
422
+ const task = execFileAsync("git", ["--no-pager", "-c", "core.pager=cat", "-c", "core.fsmonitor=false", ...args], {
423
+ cwd,
424
+ encoding: "utf8",
425
+ timeout: GIT_TIMEOUT_MS,
426
+ maxBuffer: GIT_MAX_BUFFER,
427
+ signal,
428
+ env: {
429
+ ...process.env,
430
+ GIT_OPTIONAL_LOCKS: "0",
431
+ GIT_PAGER: "cat",
432
+ PAGER: "cat"
433
+ }
434
+ });
435
+ const closed = new Promise((resolve5) => {
436
+ task.child.once("close", () => resolve5());
437
+ });
438
+ let stdout;
439
+ try {
440
+ ({ stdout } = await task);
441
+ } finally {
442
+ await closed;
443
+ }
444
+ return { ok: true, stdout };
445
+ } catch (error) {
446
+ if (signal?.aborted) throw error;
447
+ if (allowFailure) return { ok: false, stdout: "" };
448
+ throw error;
449
+ }
450
+ }
451
+ async function settleGitReads(reads) {
452
+ const results = await Promise.allSettled(reads);
453
+ return results.map((result) => {
454
+ if (result.status === "rejected") throw result.reason;
455
+ return result.value;
456
+ });
457
+ }
458
+ function parseStatuses(output, projectPrefix) {
459
+ const statuses = /* @__PURE__ */ new Map();
460
+ const records = output.split("\0");
461
+ for (let index = 0; index < records.length; index += 1) {
462
+ const record = records[index];
463
+ if (!record || record.length < 4) continue;
464
+ const code = record.slice(0, 2);
465
+ const repositoryPath = record.slice(3).replaceAll("\\", "/");
466
+ const projectPath = projectPrefix ? repositoryPath.startsWith(projectPrefix) ? repositoryPath.slice(projectPrefix.length) : void 0 : repositoryPath;
467
+ if (projectPath) statuses.set(projectPath, statusFromCode(code));
468
+ if (code.includes("R") || code.includes("C")) index += 1;
469
+ }
470
+ return new Map([...statuses].sort(([left], [right]) => compareStrings(left, right)));
471
+ }
472
+ function statusFromCode(code) {
473
+ const conflicted = /^(DD|AU|UD|UA|DU|AA|UU)$/.test(code);
474
+ const untracked = code === "??";
475
+ const ignored = code === "!!";
476
+ const staged = !untracked && !ignored && code[0] !== " ";
477
+ const unstaged = !untracked && !ignored && code[1] !== " ";
478
+ let label;
479
+ if (conflicted) label = "conflicted";
480
+ else if (untracked) label = "untracked";
481
+ else if (ignored) label = "ignored";
482
+ else {
483
+ const character = unstaged ? code[1] : code[0];
484
+ const action = character === "A" ? "added" : character === "D" ? "deleted" : character === "R" ? "renamed" : character === "C" ? "copied" : character === "T" ? "type changed" : "modified";
485
+ const location = staged && unstaged ? "staged + unstaged" : staged ? "staged" : "unstaged";
486
+ label = `${action} (${location})`;
487
+ }
488
+ return { code, label, staged, unstaged, untracked, ignored, conflicted };
489
+ }
490
+ function parseUnifiedDiff(output) {
491
+ const hunks = [];
492
+ let current;
493
+ let newLine = 0;
494
+ for (const line of output.replaceAll("\r\n", "\n").split("\n")) {
495
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line);
496
+ if (match) {
497
+ current = {
498
+ header: line,
499
+ oldStart: Number(match[1]),
500
+ oldCount: Number(match[2] ?? 1),
501
+ newStart: Number(match[3]),
502
+ newCount: Number(match[4] ?? 1),
503
+ lines: [line],
504
+ changedLines: []
505
+ };
506
+ newLine = current.newStart;
507
+ hunks.push(current);
508
+ continue;
509
+ }
510
+ if (!current) continue;
511
+ current.lines.push(line);
512
+ if (line.startsWith("+") && !line.startsWith("+++")) {
513
+ current.changedLines.push(newLine);
514
+ newLine += 1;
515
+ } else if (!line.startsWith("-") && !line.startsWith("\\")) {
516
+ newLine += 1;
517
+ }
518
+ }
519
+ for (const hunk of hunks) {
520
+ while (hunk.lines.at(-1) === "") hunk.lines.pop();
521
+ }
522
+ return hunks;
523
+ }
524
+ function parseBlame(output) {
525
+ const lines = output.split("\n");
526
+ const commit = lines[0]?.split(" ")[0];
527
+ if (!commit) return void 0;
528
+ const field = (name) => lines.find((line) => line.startsWith(`${name} `))?.slice(name.length + 1);
529
+ const authorTime = Number(field("author-time"));
530
+ return {
531
+ commit,
532
+ author: field("author") ?? "Unknown",
533
+ authorTime: Number.isFinite(authorTime) ? authorTime : void 0,
534
+ summary: field("summary") ?? "",
535
+ committed: !/^0+$/.test(commit)
536
+ };
537
+ }
538
+ function parseHistory(output) {
539
+ return output.split("").filter(Boolean).flatMap((record) => {
540
+ const [metadata = "", ...pathRecords] = record.split("\0");
541
+ const [commit, author, authorTime, ...summaryParts] = metadata.replace(/^\n+/, "").split("");
542
+ const path = pathRecords.map((value) => value.replace(/^\n+/, "")).find(Boolean);
543
+ const time = Number(authorTime);
544
+ return commit && author && path && Number.isFinite(time) ? [{ commit, author, authorTime: time, summary: summaryParts.join(""), path }] : [];
545
+ });
546
+ }
547
+ function compareStrings(left, right) {
548
+ return left < right ? -1 : left > right ? 1 : 0;
549
+ }
550
+ function stripLineEnding(value) {
551
+ return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value;
552
+ }
553
+ function normalizeLines(contents) {
554
+ if (contents === "") return [];
555
+ const lines = contents.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
556
+ if (lines.at(-1) === "") lines.pop();
557
+ return lines;
558
+ }
559
+
560
+ // src/context-service.ts
561
+ async function registerFileContext(pi, dependencies = {}) {
562
+ const settingsPath = dependencies.settingsPath ?? fileContextSettingsPath();
563
+ const loadSettings = dependencies.loadSettings ?? (() => loadFileContextSettings(settingsPath));
564
+ const updateSettings = dependencies.updateSettings ?? updateFileContextSettings;
565
+ const initialSettings = await loadSettings();
566
+ const discoverFiles = dependencies.discoverFiles ?? discoverProjectFiles;
567
+ const createGit = dependencies.createGit ?? createGitContext;
568
+ const owners = /* @__PURE__ */ new Map();
569
+ const requireOwner = (ctx) => {
570
+ const owner = owners.get(ctx.sessionManager);
571
+ if (!owner || owner.controller.signal.aborted) throw new Error("File Context is not initialized; reload first");
572
+ return owner;
573
+ };
574
+ function createOwner(context) {
575
+ let loadedSettings = initialSettings;
576
+ let pendingQuotes = [];
577
+ let nextPendingQuoteId = 1;
578
+ const activeSessionManager = context.sessionManager;
579
+ const sessionGeneration = 0;
580
+ const sessionController = new AbortController();
581
+ const activeExplorers = /* @__PURE__ */ new Set();
582
+ const nativeWork = /* @__PURE__ */ new Set();
583
+ const gitContexts = /* @__PURE__ */ new Set();
584
+ const track = (work) => {
585
+ nativeWork.add(work);
586
+ void work.then(
587
+ () => nativeWork.delete(work),
588
+ () => nativeWork.delete(work)
589
+ );
590
+ return work;
591
+ };
592
+ let activeExplorerLaunch;
593
+ let activeMenuLaunch;
594
+ const updatePendingWidget = (ctx) => (dependencies.onChange ?? publishFilesWidget)(
595
+ ctx,
596
+ pendingQuotes.map((item) => item.quote)
597
+ );
598
+ const clearPending = (ctx) => {
599
+ pendingQuotes = [];
600
+ nextPendingQuoteId = 1;
601
+ updatePendingWidget(ctx);
602
+ };
603
+ const validatePending = (quote) => {
604
+ appendPendingQuote(
605
+ pendingQuotes.map((item) => item.quote),
606
+ quote
607
+ );
608
+ };
609
+ const appendPending = (quote, ctx) => {
610
+ validatePending(quote);
611
+ pendingQuotes = [...pendingQuotes, { id: `quote-${nextPendingQuoteId++}`, quote }];
612
+ updatePendingWidget(ctx);
613
+ };
614
+ const isCurrentSession = (key, generation) => key === activeSessionManager && generation === sessionGeneration && owners.get(activeSessionManager) === runtime && !sessionController.signal.aborted;
615
+ const menuQuote = ({ id, quote }) => ({
616
+ id,
617
+ path: quote.path,
618
+ startLine: quote.startLine,
619
+ endLine: quote.endLine,
620
+ text: quote.text
621
+ });
622
+ const cancelExplorers = () => {
623
+ for (const explorer of activeExplorers) {
624
+ explorer.controller.abort(new DOMException("File Context explorer closed", "AbortError"));
625
+ explorer.component?.dispose();
626
+ }
627
+ activeExplorers.clear();
628
+ };
629
+ const runExplorer = async (ctx, options = {}) => {
630
+ if (ctx.mode !== "tui") {
631
+ rejectCommand(ctx, "File Context requires Pi's interactive TUI.");
632
+ return "close";
633
+ }
634
+ const owner = ctx.sessionManager;
635
+ const generation = sessionGeneration;
636
+ const activeExplorer = { controller: new AbortController() };
637
+ let explorerGit;
638
+ const { controller } = activeExplorer;
639
+ const flowSignal = AbortSignal.any([
640
+ controller.signal,
641
+ sessionController.signal,
642
+ ...ctx.signal ? [ctx.signal] : [],
643
+ ...options.signal ? [options.signal] : []
644
+ ]);
645
+ activeExplorers.add(activeExplorer);
646
+ try {
647
+ const [{ runCustomInteraction, runTask }, { FileQuoteExplorer }] = await Promise.all([
648
+ import("@narumitw/pi-tui-kit"),
649
+ import("./chunks/file-context-explorer-WR6GQZHF.ts")
650
+ ]);
651
+ if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close";
652
+ const task = await runTask(ctx, {
653
+ label: "Scanning project files\u2026",
654
+ signal: flowSignal,
655
+ isCurrent: () => isCurrentSession(owner, generation) && !flowSignal.aborted,
656
+ task: ({ signal }) => Promise.all([track(discoverFiles(ctx.cwd, { signal })), track(createGit(ctx.cwd, signal))]),
657
+ onError: (_taskContext, error) => {
658
+ ctx.ui.notify(
659
+ `File Context could not scan project files: ${escapeTerminalControls(formatError(error))}. Open File Context to retry.`,
660
+ "error"
661
+ );
662
+ }
663
+ });
664
+ if (task.kind === "cancelled" || task.kind === "error") {
665
+ return options.menuOwned ? "stay" : "close";
666
+ }
667
+ if (task.kind === "stale") return "close";
668
+ const discovery = task.value;
669
+ if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close";
670
+ const [files, gitContext] = discovery;
671
+ if (gitContext) {
672
+ explorerGit = gitContext;
673
+ gitContexts.add(gitContext);
674
+ }
675
+ if (files.length === 0) {
676
+ ctx.ui.notify("File Context found no project files. Choose Add context snippet to retry.", "warning");
677
+ return options.menuOwned ? "stay" : "close";
678
+ }
679
+ const interaction = await runCustomInteraction(ctx, {
680
+ signal: flowSignal,
681
+ isCurrent: () => isCurrentSession(owner, generation),
682
+ onError: () => {
683
+ },
684
+ create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
685
+ const component = new FileQuoteExplorer({
686
+ tui,
687
+ theme,
688
+ keybindings,
689
+ files,
690
+ cwd: ctx.cwd,
691
+ loadFile: (path, signal) => track(
692
+ loadProjectTextFile(ctx.cwd, path, {
693
+ signal: signal ? AbortSignal.any([signal, interactionSignal]) : interactionSignal
694
+ })
695
+ ),
696
+ editFile: (path, signal) => track(
697
+ editProjectFileInExternalEditor({
698
+ root: ctx.cwd,
699
+ projectPath: path,
700
+ tui,
701
+ projectTrusted: ctx.isProjectTrusted(),
702
+ signal: signal ? AbortSignal.any([signal, interactionSignal]) : interactionSignal,
703
+ isCurrent: () => isCurrentSession(owner, generation) && !interactionSignal.aborted
704
+ })
705
+ ),
706
+ gitContext,
707
+ rootNavigation: options.menuOwned,
708
+ getSelectedContextState: () => ({
709
+ count: pendingQuotes.length,
710
+ totalBytes: pendingQuotes.reduce(
711
+ (total, item) => total + Buffer.byteLength(item.quote.text, "utf8"),
712
+ 0
713
+ ),
714
+ maximumCount: MAX_PENDING_QUOTES,
715
+ maximumBytes: MAX_PENDING_QUOTE_BYTES,
716
+ maximumSnippetLines: MAX_QUOTE_LINES,
717
+ maximumSnippetBytes: MAX_QUOTE_BYTES
718
+ }),
719
+ validateQuote: validatePending,
720
+ onAddAndContinue: (quote) => {
721
+ if (!isCurrentSession(owner, generation) || interactionSignal.aborted) {
722
+ throw new DOMException("File Context session replaced", "AbortError");
723
+ }
724
+ appendPending(quote, ctx);
725
+ try {
726
+ ctx.ui.notify(
727
+ `Added to next prompt context: ${escapeTerminalControls(quote.path)} \xB7 lines ${quote.startLine}-${quote.endLine}.`,
728
+ "info"
729
+ );
730
+ } catch {
731
+ }
732
+ },
733
+ done: complete
734
+ });
735
+ activeExplorer.component = component;
736
+ return component;
737
+ }
738
+ });
739
+ if (interaction.kind === "error") throw interaction.error;
740
+ const result = interaction.kind === "completed" ? interaction.value : void 0;
741
+ if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close";
742
+ if (result?.kind === "quote") {
743
+ appendPending(result.quote, ctx);
744
+ return "close";
745
+ }
746
+ if (result?.kind === "reference") {
747
+ ctx.ui.pasteToEditor(formatFileReference(result.path));
748
+ return "close";
749
+ }
750
+ return result?.kind === "back" ? "stay" : "close";
751
+ } catch (error) {
752
+ if (!isCurrentSession(owner, generation) || flowSignal.aborted || isAbortError(error)) {
753
+ return "close";
754
+ }
755
+ try {
756
+ ctx.ui.notify(
757
+ `File Context failed: ${escapeTerminalControls(formatError(error))}. Open File Context to retry.`,
758
+ "error"
759
+ );
760
+ } catch {
761
+ }
762
+ return options.menuOwned ? "stay" : "close";
763
+ } finally {
764
+ activeExplorers.delete(activeExplorer);
765
+ if (explorerGit) {
766
+ await explorerGit.whenIdle();
767
+ gitContexts.delete(explorerGit);
768
+ }
769
+ }
770
+ };
771
+ const openExplorer = (ctx) => {
772
+ if (activeExplorerLaunch) return activeExplorerLaunch.promise;
773
+ if (activeMenuLaunch) return activeMenuLaunch.promise;
774
+ const launch = { promise: runExplorer(ctx).then(() => void 0) };
775
+ activeExplorerLaunch = launch;
776
+ void launch.promise.finally(() => {
777
+ if (activeExplorerLaunch === launch) activeExplorerLaunch = void 0;
778
+ }).catch(() => {
779
+ });
780
+ return launch.promise;
781
+ };
782
+ const openMenu = (ctx, start = "main") => {
783
+ if (ctx.mode !== "tui") {
784
+ rejectCommand(ctx, "File Context requires Pi's interactive TUI.");
785
+ return Promise.resolve();
786
+ }
787
+ if (activeMenuLaunch) return activeMenuLaunch.promise;
788
+ if (activeExplorerLaunch) return activeExplorerLaunch.promise;
789
+ if (start === "remove" && pendingQuotes.length === 0) {
790
+ ctx.ui.notify("File Context has no context selected for the next prompt.", "warning");
791
+ return Promise.resolve();
792
+ }
793
+ const owner = ctx.sessionManager;
794
+ const generation = sessionGeneration;
795
+ const ownerController = {
796
+ signal: AbortSignal.any([sessionController.signal, ...ctx.signal ? [ctx.signal] : []])
797
+ };
798
+ const isCurrent = () => isCurrentSession(owner, generation) && !ownerController.signal.aborted;
799
+ const promise = import("./chunks/file-context-menu-VPLHUUQ6.ts").then(({ showFileContextMenu }) => {
800
+ if (!isCurrent()) return;
801
+ return showFileContextMenu(ctx, {
802
+ start,
803
+ signal: ownerController.signal,
804
+ isCurrent,
805
+ getState: () => {
806
+ if (!isCurrent()) throw new DOMException("File Context session replaced", "AbortError");
807
+ const quotes = pendingQuotes.map(menuQuote);
808
+ return {
809
+ quotes,
810
+ shortcut: loadedSettings.settings.openShortcut,
811
+ maximumQuotes: MAX_PENDING_QUOTES,
812
+ maximumBytes: MAX_PENDING_QUOTE_BYTES,
813
+ totalBytes: quotes.reduce((total, quote) => total + Buffer.byteLength(quote.text, "utf8"), 0),
814
+ settingsPath,
815
+ settingsInvalidReason: loadedSettings.invalidReason
816
+ };
817
+ },
818
+ addQuote: (signal) => runExplorer(ctx, { menuOwned: true, signal }),
819
+ saveShortcut: async (shortcut, signal) => {
820
+ if (!isCurrent() || signal.aborted) {
821
+ throw new DOMException("File Context session replaced", "AbortError");
822
+ }
823
+ if (pendingQuotes.length > 0)
824
+ throw new Error("Submit or remove selected context before changing the shortcut");
825
+ const saved = await updateSettings(shortcut, { settingsPath, signal });
826
+ if (!isCurrent() || signal.aborted) return;
827
+ loadedSettings = { settings: saved };
828
+ },
829
+ removeQuote: (id, signal) => {
830
+ if (!isCurrent() || signal.aborted) return { kind: "missing" };
831
+ const index = pendingQuotes.findIndex((item) => item.id === id);
832
+ const selected = pendingQuotes[index];
833
+ if (!selected) return { kind: "missing" };
834
+ pendingQuotes = pendingQuotes.filter((_item, itemIndex) => itemIndex !== index);
835
+ updatePendingWidget(ctx);
836
+ return {
837
+ kind: "removed",
838
+ quote: menuQuote(selected),
839
+ remaining: pendingQuotes.length
840
+ };
841
+ }
842
+ });
843
+ }).then(() => void 0);
844
+ const launch = { promise };
845
+ activeMenuLaunch = launch;
846
+ const clearLaunch = () => {
847
+ if (activeMenuLaunch === launch) activeMenuLaunch = void 0;
848
+ };
849
+ void promise.then(clearLaunch, clearLaunch);
850
+ return promise;
851
+ };
852
+ const runtime = {
853
+ controller: sessionController,
854
+ open: openMenu,
855
+ browse: openExplorer,
856
+ getState: () => ({
857
+ quotes: pendingQuotes.map(menuQuote),
858
+ shortcut: loadedSettings.settings.openShortcut,
859
+ maximumQuotes: MAX_PENDING_QUOTES,
860
+ maximumBytes: MAX_PENDING_QUOTE_BYTES,
861
+ totalBytes: pendingQuotes.reduce((total, item) => total + Buffer.byteLength(item.quote.text, "utf8"), 0),
862
+ settingsPath,
863
+ settingsInvalidReason: loadedSettings.invalidReason
864
+ }),
865
+ saveShortcut: async (ctx, shortcut, signal) => {
866
+ if (pendingQuotes.length) throw new Error("Submit or remove selected context before changing the shortcut");
867
+ const operation = AbortSignal.any([signal, sessionController.signal, ...ctx.signal ? [ctx.signal] : []]);
868
+ operation.throwIfAborted();
869
+ const saved = await updateSettings(shortcut, { settingsPath, signal: operation });
870
+ if (!isCurrentSession(activeSessionManager, sessionGeneration) || operation.aborted) return;
871
+ loadedSettings = { settings: saved };
872
+ },
873
+ refresh: async (ctx) => {
874
+ const refreshed = await loadSettings();
875
+ if (!isCurrentSession(activeSessionManager, sessionGeneration)) return;
876
+ loadedSettings = refreshed;
877
+ if (refreshed.warning && ctx.hasUI) ctx.ui.notify(escapeTerminalControls(refreshed.warning), "warning");
878
+ },
879
+ consume: (ctx) => {
880
+ const quotes = pendingQuotes.map((item) => item.quote);
881
+ clearPending(ctx);
882
+ return quotes;
883
+ },
884
+ release: async (ctx) => {
885
+ const work = [activeExplorerLaunch?.promise];
886
+ sessionController.abort(new DOMException("File Context session ended", "AbortError"));
887
+ cancelExplorers();
888
+ const settled = await Promise.allSettled([
889
+ awaitFileContextSettingsWrites(settingsPath),
890
+ Promise.resolve().then(() => clearPending(ctx)),
891
+ ...work,
892
+ ...nativeWork,
893
+ ...[...gitContexts].map((git) => git.whenIdle())
894
+ ]);
895
+ for (const result of settled.slice(0, 2)) if (result.status === "rejected") throw result.reason;
896
+ }
897
+ };
898
+ return runtime;
899
+ }
900
+ const service = {
901
+ open: (ctx, start) => requireOwner(ctx).open(ctx, start),
902
+ browse: (ctx) => requireOwner(ctx).browse(ctx),
903
+ getState: (ctx) => requireOwner(ctx).getState(),
904
+ saveShortcut: (ctx, shortcut, signal) => requireOwner(ctx).saveShortcut(ctx, shortcut, signal),
905
+ signal: (ctx) => requireOwner(ctx).controller.signal
906
+ };
907
+ if (initialSettings.settings.openShortcut) {
908
+ pi.registerShortcut(initialSettings.settings.openShortcut, {
909
+ description: "Open File Context",
910
+ handler: service.browse
911
+ });
912
+ }
913
+ pi.registerCommand("file-context", {
914
+ description: "Open the File Context menu",
915
+ getArgumentCompletions: (prefix) => {
916
+ const items = [
917
+ { value: "browse", label: "browse", description: "Open the file browser directly" },
918
+ { value: "remove", label: "remove", description: "Review selected context directly" }
919
+ ].filter((item) => item.value.startsWith(prefix.trimStart().toLowerCase()));
920
+ return items.length ? items : null;
921
+ },
922
+ handler: async (args, ctx) => {
923
+ const route = args.trim().toLowerCase();
924
+ if (route !== "" && route !== "browse" && route !== "remove")
925
+ return rejectCommand(ctx, "Usage: /file-context [browse|remove]");
926
+ if (ctx.mode !== "tui") return rejectCommand(ctx, "File Context requires Pi's interactive TUI.");
927
+ if (route === "browse") await service.browse(ctx);
928
+ else await service.open(ctx, route === "remove" ? "remove" : "main");
929
+ }
930
+ });
931
+ pi.on("session_start", async (_event, ctx) => {
932
+ const key = ctx.sessionManager;
933
+ const previous = owners.get(key);
934
+ const owner = createOwner(ctx);
935
+ owners.set(key, owner);
936
+ if (previous) await previous.release(ctx);
937
+ if (owners.get(key) !== owner || owner.controller.signal.aborted) return;
938
+ (dependencies.onChange ?? publishFilesWidget)(ctx, []);
939
+ await owner.refresh(ctx);
940
+ });
941
+ pi.on("before_agent_start", (event, ctx) => {
942
+ const owner = owners.get(ctx.sessionManager);
943
+ if (event.messageType === "custom" || !owner || owner.controller.signal.aborted || !owner.getState().quotes.length)
944
+ return;
945
+ return {
946
+ message: { customType: "file-context-quotes", content: formatQuoteContext(owner.consume(ctx)), display: false }
947
+ };
948
+ });
949
+ pi.on("session_shutdown", async (_event, ctx) => {
950
+ const key = ctx.sessionManager;
951
+ const owner = owners.get(key);
952
+ if (!owner) return;
953
+ try {
954
+ await owner.release(ctx);
955
+ } finally {
956
+ if (owners.get(key) === owner) owners.delete(key);
957
+ }
958
+ });
959
+ return service;
960
+ }
961
+ function formatFileReference(path) {
962
+ const escaped = path.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
963
+ return /\s|["\\]/.test(path) ? `@"${escaped}" ` : `@${path} `;
964
+ }
965
+ function escapeTerminalControls(text) {
966
+ return [...text].map((character) => {
967
+ const code = character.charCodeAt(0);
968
+ return code <= 31 || code >= 127 && code <= 159 ? `\\x${code.toString(16).padStart(2, "0")}` : character;
969
+ }).join("");
970
+ }
971
+ function rejectCommand(ctx, message) {
972
+ if (ctx.hasUI) {
973
+ ctx.ui.notify(message, "warning");
974
+ return;
975
+ }
976
+ throw new Error(message);
977
+ }
978
+ function isAbortError(error) {
979
+ return error instanceof Error && error.name === "AbortError";
980
+ }
981
+ function formatError(error) {
982
+ return error instanceof Error ? error.message : String(error);
983
+ }
984
+
985
+ // src/scope/edit-scope.ts
986
+ import { join as join2, resolve as resolve4 } from "node:path";
987
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
988
+ import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
989
+
990
+ // src/scope/contracts.ts
991
+ import { createHash } from "node:crypto";
992
+ import { pathToFileURL } from "node:url";
993
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
994
+ var NOTICE_TYPE = "scope-contract";
995
+ var ANCHOR_TYPE = "scope-context-anchor";
996
+ var MAX_NOTICE_BYTES = 48e3;
997
+ var NON_MODEL_FIELDS = /* @__PURE__ */ new Set([
998
+ "timestamp",
999
+ "durationMs",
1000
+ "usage",
1001
+ "stopReason",
1002
+ "errorMessage",
1003
+ "api",
1004
+ "provider",
1005
+ "model",
1006
+ "details",
1007
+ "nestedCalls"
1008
+ ]);
1009
+ function createScopeNotice(state, timestamp = 0) {
1010
+ if (!state.entryId) return void 0;
1011
+ let content;
1012
+ if (state.state === "blocked") {
1013
+ content = "Scope state is unavailable. Do not edit files or use writing commands. The user must repair it with /scope or exit with /scope off. Reading remains available.";
1014
+ } else if (state.state === "inactive") {
1015
+ content = "The user ended the Scope editing restriction. Earlier Scope notices no longer apply. All other existing instructions and tool restrictions remain unchanged.";
1016
+ } else {
1017
+ content = [
1018
+ "Scope editing restriction is active. This notice replaces earlier Scope notices.",
1019
+ "Only write or edit these exact selected file targets. Use their file URLs as tool paths to preserve literal Unicode and control characters:",
1020
+ ...(state.snapshot?.targets ?? []).map(
1021
+ (target) => `- ${JSON.stringify(pathToFileURL(target.path).href)}${target.path === target.canonical ? "" : ` (canonical target ${displayPath(target.canonical)})`}`
1022
+ ),
1023
+ "Other files may be read. Shell commands and unsupported tools cannot bypass this editing boundary. Only the user can change or end the scope with /scope."
1024
+ ].join("\n");
1025
+ }
1026
+ if (Buffer.byteLength(content) > MAX_NOTICE_BYTES)
1027
+ throw new Error(`Scope notice exceeds ${MAX_NOTICE_BYTES} bytes; select fewer files`);
1028
+ return {
1029
+ role: "custom",
1030
+ customType: NOTICE_TYPE,
1031
+ content,
1032
+ display: false,
1033
+ details: { version: 1, stateId: state.entryId },
1034
+ timestamp
1035
+ };
1036
+ }
1037
+ function contractKey(message) {
1038
+ if (message.role !== "custom" || message.customType !== NOTICE_TYPE || typeof message.content !== "string") return;
1039
+ const details = message.details;
1040
+ if (details?.version !== 1 || typeof details.stateId !== "string") return;
1041
+ return JSON.stringify([details.stateId, message.content]);
1042
+ }
1043
+ function noticeKey(message) {
1044
+ const key = contractKey(message);
1045
+ if (!key) return;
1046
+ const recovery = message.details;
1047
+ return JSON.stringify([key, typeof recovery?.recovery === "number" ? recovery.recovery : 0]);
1048
+ }
1049
+ function anchoredNotice(notice, anchor) {
1050
+ return anchor.recovery === void 0 ? notice : { ...notice, details: { version: 1, stateId: anchor.stateId, recovery: anchor.recovery } };
1051
+ }
1052
+ function prefixDigests(messages, counts) {
1053
+ const hash = createHash("sha256");
1054
+ const results = /* @__PURE__ */ new Map();
1055
+ if (counts.has(0)) results.set(0, hash.copy().digest("hex"));
1056
+ for (let index = 0; index < messages.length; index += 1) {
1057
+ const normalized = convertToLlm([messages[index]]).map(
1058
+ (message) => Object.fromEntries(Object.entries(message).filter(([key]) => !NON_MODEL_FIELDS.has(key)))
1059
+ );
1060
+ hash.update(JSON.stringify(normalized));
1061
+ hash.update("\n");
1062
+ if (counts.has(index + 1)) results.set(index + 1, hash.copy().digest("hex"));
1063
+ }
1064
+ return results;
1065
+ }
1066
+ function readAnchors(branch) {
1067
+ const states = new Map(
1068
+ branch.filter((entry) => entry.type === "custom" && entry.customType === STATE_TYPE).map((entry) => [entry.id, entry])
1069
+ );
1070
+ const found = /* @__PURE__ */ new Map();
1071
+ for (const entry of branch) {
1072
+ if (entry.type !== "custom" || entry.customType !== ANCHOR_TYPE || !entry.data || typeof entry.data !== "object")
1073
+ continue;
1074
+ const value = entry.data;
1075
+ if (value.version !== 1 || typeof value.stateId !== "string" || !["active", "inactive", "blocked"].includes(value.state ?? "") || !Number.isSafeInteger(value.count) || value.count < 0 || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest) || typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt) || value.recovery !== void 0 && (!Number.isSafeInteger(value.recovery) || value.recovery <= 0))
1076
+ continue;
1077
+ const source = states.get(value.stateId);
1078
+ if (source?.type !== "custom") continue;
1079
+ try {
1080
+ const state = value.state === "blocked" ? { state: "blocked", entryId: source.id } : { state: value.state, entryId: source.id, snapshot: parseSnapshot(source.data) };
1081
+ if (state.state !== "blocked" && state.snapshot?.targets === null !== (state.state === "inactive")) continue;
1082
+ const anchor = value;
1083
+ const notice = createScopeNotice(state, anchor.sentAt);
1084
+ if (!notice) continue;
1085
+ const key = JSON.stringify([anchor.stateId, anchor.state, anchor.count, anchor.digest, anchor.recovery ?? 0]);
1086
+ if (!found.has(key)) found.set(key, { anchor, notice: anchoredNotice(notice, anchor) });
1087
+ } catch {
1088
+ }
1089
+ }
1090
+ return [...found.values()];
1091
+ }
1092
+ function baseFor(messages, anchors, exclude = []) {
1093
+ const keys = /* @__PURE__ */ new Set([...anchors.map(({ notice }) => noticeKey(notice)), ...exclude.map(noticeKey)]);
1094
+ return messages.filter((message) => !keys.has(noticeKey(message)));
1095
+ }
1096
+ function project(messages, candidates, exclude = []) {
1097
+ const anchors = [...candidates];
1098
+ let base;
1099
+ for (; ; ) {
1100
+ base = baseFor(messages, anchors, exclude);
1101
+ const hashes = prefixDigests(base, new Set(anchors.map(({ anchor }) => anchor.count)));
1102
+ const firstSlot = base[0]?.role === "system" ? 1 : 0;
1103
+ const stale = anchors.findIndex(
1104
+ ({ anchor }) => anchor.count < firstSlot || hashes.get(anchor.count) !== anchor.digest
1105
+ );
1106
+ if (stale < 0) break;
1107
+ anchors.splice(stale, 1);
1108
+ }
1109
+ const at = /* @__PURE__ */ new Map();
1110
+ for (const { anchor, notice } of anchors) {
1111
+ const notices = at.get(anchor.count) ?? [];
1112
+ notices.push(notice);
1113
+ at.set(anchor.count, notices);
1114
+ }
1115
+ const placed = [];
1116
+ for (let index = 0; index <= base.length; index += 1) {
1117
+ placed.push(...at.get(index) ?? []);
1118
+ if (index < base.length) placed.push(base[index]);
1119
+ }
1120
+ return { messages: placed, base, anchors };
1121
+ }
1122
+ function hasCurrentScopeNotice(messages, state) {
1123
+ const expected = createScopeNotice(state);
1124
+ if (!expected) return true;
1125
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
1126
+ if (messages[index].role === "custom" && messages[index].customType === NOTICE_TYPE) {
1127
+ return contractKey(messages[index]) === contractKey(expected);
1128
+ }
1129
+ }
1130
+ return false;
1131
+ }
1132
+ function reconcileScopeContext(messages, branch, state, sentAt = Date.now()) {
1133
+ const candidates = readAnchors(branch);
1134
+ const placed = project(messages, candidates);
1135
+ if (hasCurrentScopeNotice(placed.messages, state)) return { messages: placed.messages };
1136
+ const notice = createScopeNotice(state, sentAt);
1137
+ if (!notice || !state.entryId) return { messages: placed.messages };
1138
+ const repeated = placed.messages.some((message) => contractKey(message) === contractKey(notice));
1139
+ const prepared = project(messages, placed.anchors, repeated ? [] : [notice]);
1140
+ const count = prepared.base.length;
1141
+ const digest = prefixDigests(prepared.base, /* @__PURE__ */ new Set([count])).get(count);
1142
+ const anchor = { version: 1, stateId: state.entryId, state: state.state, count, digest, sentAt };
1143
+ if (repeated) {
1144
+ anchor.recovery = candidates.reduce(
1145
+ (latest, candidate) => candidate.anchor.stateId === state.entryId ? Math.max(latest, candidate.anchor.recovery ?? 0) : latest,
1146
+ 0
1147
+ ) + 1;
1148
+ if (!Number.isSafeInteger(anchor.recovery)) throw new Error("Scope recovery sequence is exhausted");
1149
+ }
1150
+ const next = project(messages, [...prepared.anchors, { anchor, notice: anchoredNotice(notice, anchor) }]);
1151
+ return { messages: next.messages, anchor };
1152
+ }
1153
+
1154
+ // src/scope/policy.ts
1155
+ var READERS = /* @__PURE__ */ new Set(["read", "ls", "find", "grep"]);
1156
+ var WRITERS = /* @__PURE__ */ new Set(["write", "edit"]);
1157
+ function isStockBuiltin(tool, name) {
1158
+ return tool?.sourceInfo.source === "builtin" && tool.sourceInfo.path === `builtin:${name}`;
1159
+ }
1160
+ function assertUsableLoadout(pi) {
1161
+ const tools = pi.getAllTools();
1162
+ const active = new Set(pi.getActiveTools());
1163
+ const codemode = tools.find((tool) => tool.name === "codemode");
1164
+ if (active.has("codemode") && isStockBuiltin(codemode, "codemode") && pi.getSettings().codemode?.mode === "only") {
1165
+ throw new Error(
1166
+ "Scope cannot start with active codemode.mode=only: codemode can bypass tool guards and hides the direct editing tools. Set Pi's codemode.mode to on and reload first; Scope does not change that setting."
1167
+ );
1168
+ }
1169
+ if (![...WRITERS].some(
1170
+ (name) => active.has(name) && isStockBuiltin(
1171
+ tools.find((tool) => tool.name === name),
1172
+ name
1173
+ )
1174
+ )) {
1175
+ throw new Error(
1176
+ "Scope requires an active, unmodified built-in write or edit tool; custom replacements are unsupported"
1177
+ );
1178
+ }
1179
+ }
1180
+ function decideTool(name, tools, scope) {
1181
+ if (scope.state === "inactive") return { kind: "pass" };
1182
+ const tool = tools.find((candidate) => candidate.name === name);
1183
+ if (READERS.has(name) && isStockBuiltin(tool, name)) return { kind: "pass" };
1184
+ if (scope.state === "blocked") {
1185
+ return {
1186
+ kind: "block",
1187
+ reason: scope.error ?? "Scope is not initialized; edits remain blocked. Use /scope to repair or /scope off to exit."
1188
+ };
1189
+ }
1190
+ if (WRITERS.has(name) && isStockBuiltin(tool, name)) return { kind: "target" };
1191
+ return {
1192
+ kind: "block",
1193
+ reason: `${JSON.stringify(name)} is blocked while Scope is active. Only stock readers and target-checked write/edit calls are supported. Shell, codemode, delegation, and opaque tools require /scope off.`
1194
+ };
1195
+ }
1196
+
1197
+ // src/scope/settings.ts
1198
+ import { randomUUID } from "node:crypto";
1199
+ import fs from "node:fs/promises";
1200
+ import { basename, dirname, join, resolve as resolve3 } from "node:path";
1201
+ var queues = /* @__PURE__ */ new Map();
1202
+ function enqueue(path, operation) {
1203
+ const result = (queues.get(path) ?? Promise.resolve()).then(operation);
1204
+ const settled = result.then(
1205
+ () => {
1206
+ },
1207
+ () => {
1208
+ }
1209
+ );
1210
+ queues.set(path, settled);
1211
+ void settled.then(() => {
1212
+ if (queues.get(path) === settled) queues.delete(path);
1213
+ });
1214
+ return result;
1215
+ }
1216
+ function isMissing(error) {
1217
+ return !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
1218
+ }
1219
+ function validateDocument(value, path) {
1220
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1221
+ throw new Error(`Invalid Scope settings at ${JSON.stringify(path)}: expected a JSON object.`);
1222
+ }
1223
+ const document = value;
1224
+ if (Object.hasOwn(document, "showHiddenFiles") && typeof document.showHiddenFiles !== "boolean") {
1225
+ throw new Error(`Invalid Scope settings at ${JSON.stringify(path)}: showHiddenFiles must be a boolean.`);
1226
+ }
1227
+ return document;
1228
+ }
1229
+ async function readDocument(path) {
1230
+ let stats;
1231
+ try {
1232
+ stats = await fs.lstat(path);
1233
+ } catch (error) {
1234
+ if (isMissing(error)) return { document: {}, mode: 384 };
1235
+ throw error;
1236
+ }
1237
+ if (stats.isSymbolicLink() || !stats.isFile()) {
1238
+ throw new Error(`Scope settings must be a regular file, not a symlink: ${JSON.stringify(path)}.`);
1239
+ }
1240
+ const text = await fs.readFile(path, "utf8");
1241
+ let parsed;
1242
+ try {
1243
+ parsed = JSON.parse(text);
1244
+ } catch (error) {
1245
+ throw new Error(`Invalid Scope settings JSON at ${JSON.stringify(path)}. Repair the file before saving.`, {
1246
+ cause: error
1247
+ });
1248
+ }
1249
+ return { document: validateDocument(parsed, path), mode: Number(stats.mode) & 511 };
1250
+ }
1251
+ var ScopeSettingsStore = class {
1252
+ #filePath;
1253
+ #current = { showHiddenFiles: false };
1254
+ constructor(filePath) {
1255
+ this.#filePath = resolve3(filePath);
1256
+ }
1257
+ get current() {
1258
+ return { ...this.#current };
1259
+ }
1260
+ /** Side-effect-free load; a failed read leaves the last effective value intact. */
1261
+ load() {
1262
+ return enqueue(this.#filePath, async () => {
1263
+ const { document } = await readDocument(this.#filePath);
1264
+ this.#current = { showHiddenFiles: document.showHiddenFiles ?? false };
1265
+ return this.current;
1266
+ });
1267
+ }
1268
+ /** Read the latest valid document and change only the owned field. */
1269
+ saveShowHiddenFiles(value) {
1270
+ return enqueue(this.#filePath, async () => {
1271
+ if (typeof value !== "boolean") throw new Error("showHiddenFiles must be a boolean.");
1272
+ const { document, mode } = await readDocument(this.#filePath);
1273
+ const directory = dirname(this.#filePath);
1274
+ await fs.mkdir(directory, { recursive: true });
1275
+ const temporary = join(directory, `.${basename(this.#filePath)}.${process.pid}.${randomUUID()}.tmp`);
1276
+ let handle;
1277
+ let ownedTemporary = false;
1278
+ try {
1279
+ handle = await fs.open(temporary, "wx", mode);
1280
+ ownedTemporary = true;
1281
+ await handle.writeFile(`${JSON.stringify({ ...document, showHiddenFiles: value }, null, 2)}
1282
+ `, "utf8");
1283
+ await handle.close();
1284
+ handle = void 0;
1285
+ await fs.rename(temporary, this.#filePath);
1286
+ ownedTemporary = false;
1287
+ } catch (error) {
1288
+ const failures = [error];
1289
+ if (handle) {
1290
+ try {
1291
+ await handle.close();
1292
+ } catch (closeError) {
1293
+ failures.push(closeError);
1294
+ }
1295
+ }
1296
+ if (ownedTemporary) {
1297
+ try {
1298
+ await fs.unlink(temporary);
1299
+ } catch (cleanupError) {
1300
+ if (!isMissing(cleanupError)) failures.push(cleanupError);
1301
+ }
1302
+ }
1303
+ if (failures.length > 1) {
1304
+ throw new AggregateError(failures, `Scope settings save and cleanup failed at ${JSON.stringify(temporary)}.`);
1305
+ }
1306
+ throw error;
1307
+ }
1308
+ this.#current = { showHiddenFiles: value };
1309
+ return this.current;
1310
+ });
1311
+ }
1312
+ /** Drain prior operations; load/save promises carry their own errors to the caller. */
1313
+ flush() {
1314
+ return enqueue(this.#filePath, async () => {
1315
+ });
1316
+ }
1317
+ };
1318
+
1319
+ // src/scope/edit-scope.ts
1320
+ var uninitialized = () => ({ state: "blocked", error: "Scope is not initialized; edits remain blocked" });
1321
+ var showMenu = async (ctx, options) => {
1322
+ const { showScopeMenu } = await import("./chunks/menu-P6IBMROH.ts");
1323
+ options.signal.throwIfAborted();
1324
+ if (options.isCurrent()) await showScopeMenu(ctx, options);
1325
+ };
1326
+ function registerScope(pi, options = {}) {
1327
+ const show = options.showMenu ?? showMenu;
1328
+ const owners = /* @__PURE__ */ new Map();
1329
+ const starts = /* @__PURE__ */ new WeakMap();
1330
+ function current(owner, _ctx) {
1331
+ return !owner.controller.signal.aborted && owners.get(owner.key) === owner;
1332
+ }
1333
+ function check(owner, ctx, flow) {
1334
+ owner.controller.signal.throwIfAborted();
1335
+ flow?.signal.throwIfAborted();
1336
+ if (!current(owner, ctx) || flow && (owner.flow !== flow || owner.revision !== flow.revision)) {
1337
+ throw new DOMException("Scope session or selection changed", "AbortError");
1338
+ }
1339
+ ctx.signal?.throwIfAborted();
1340
+ }
1341
+ function mutationCheck(owner, ctx, flow) {
1342
+ check(owner, ctx, flow);
1343
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
1344
+ throw new Error("Stop the current run and clear pending messages before changing Scope");
1345
+ }
1346
+ }
1347
+ function view(owner, ctx) {
1348
+ return {
1349
+ state: owner.scope.state,
1350
+ cwd: ctx.cwd,
1351
+ targets: (owner.scope.snapshot?.targets ?? []).map((target) => ({ ...target })),
1352
+ ...owner.scope.error ? { error: owner.scope.error } : {}
1353
+ };
1354
+ }
1355
+ function updateView(owner, ctx) {
1356
+ if (!current(owner, ctx)) return;
1357
+ options.onChange?.(ctx, view(owner, ctx));
1358
+ }
1359
+ function notify(ctx, message, kind = "info") {
1360
+ if (ctx.hasUI) ctx.ui.notify(displayError(message).slice(0, 4e3), kind);
1361
+ }
1362
+ async function release(owner, ctx) {
1363
+ let report;
1364
+ if (!owner.controller.signal.aborted) {
1365
+ owner.controller.abort(new DOMException("Scope session ended", "AbortError"));
1366
+ report = Promise.resolve().then(() => options.onChange?.(ctx, void 0));
1367
+ }
1368
+ const settled = await Promise.allSettled([owner.flow?.promise, owner.settings.flush(), report, ...owner.checks]);
1369
+ if (settled[1].status === "rejected") throw settled[1].reason;
1370
+ if (settled[2].status === "rejected") throw settled[2].reason;
1371
+ }
1372
+ async function initialize(ctx) {
1373
+ const key = ctx.sessionManager;
1374
+ const token = {};
1375
+ starts.set(key, token);
1376
+ const previous = owners.get(key);
1377
+ const owner = {
1378
+ key,
1379
+ controller: new AbortController(),
1380
+ // Keep the existing settings path so the package rename preserves user preferences.
1381
+ settings: new ScopeSettingsStore(join2(getAgentDir2(), "pi-scope.json")),
1382
+ scope: uninitialized(),
1383
+ revision: 0,
1384
+ checks: /* @__PURE__ */ new Set()
1385
+ };
1386
+ owners.set(key, owner);
1387
+ if (previous) await release(previous, ctx);
1388
+ if (starts.get(key) !== token || !current(owner, ctx)) return;
1389
+ try {
1390
+ owner.scope = restoreScope(ctx.sessionManager.getBranch(), ctx.cwd);
1391
+ } catch {
1392
+ owner.scope = {
1393
+ state: "blocked",
1394
+ error: "Scope restoration failed; edits remain blocked. Use /scope to repair or /scope off to exit."
1395
+ };
1396
+ }
1397
+ if (owner.scope.state === "active") {
1398
+ try {
1399
+ assertUsableLoadout(pi);
1400
+ } catch (error) {
1401
+ owner.scope = { ...owner.scope, state: "blocked", error: displayError(error) };
1402
+ }
1403
+ }
1404
+ try {
1405
+ await owner.settings.load();
1406
+ } catch (error) {
1407
+ if (current(owner, ctx)) notify(ctx, `Scope settings were not loaded: ${displayError(error)}`, "warning");
1408
+ }
1409
+ if (starts.get(key) !== token || !current(owner, ctx)) return;
1410
+ updateView(owner, ctx);
1411
+ if (owner.scope.state === "blocked") notify(ctx, owner.scope.error ?? "Scope restoration failed", "error");
1412
+ }
1413
+ function publish(owner, ctx) {
1414
+ const notice = createScopeNotice(owner.scope);
1415
+ if (!notice) return;
1416
+ try {
1417
+ pi.sendMessage(notice, { triggerTurn: false });
1418
+ } catch (error) {
1419
+ notify(ctx, `Scope is applied, but its model notice needs recovery: ${displayError(error)}`, "warning");
1420
+ }
1421
+ }
1422
+ function commit(owner, ctx, snapshot, flow, signal) {
1423
+ signal?.throwIfAborted();
1424
+ mutationCheck(owner, ctx, flow);
1425
+ owner.scope = { state: "blocked", error: "Scope update is incomplete; edits remain blocked" };
1426
+ owner.revision += 1;
1427
+ if (flow) flow.revision = owner.revision;
1428
+ try {
1429
+ pi.appendEntry(STATE_TYPE, snapshot);
1430
+ check(owner, ctx, flow);
1431
+ const restored = restoreScope(ctx.sessionManager.getBranch(), ctx.cwd);
1432
+ if (restored.state === "blocked" || !sameScope(restored.snapshot, snapshot) || !restored.entryId) {
1433
+ throw new Error("Scope state was not retained by the session");
1434
+ }
1435
+ owner.scope = restored;
1436
+ } catch (error) {
1437
+ if (current(owner, ctx)) {
1438
+ owner.scope = {
1439
+ state: "blocked",
1440
+ error: `Scope state could not be saved; edits remain blocked. Repair with /scope or /scope off. ${displayError(error)}`
1441
+ };
1442
+ updateView(owner, ctx);
1443
+ }
1444
+ throw error;
1445
+ }
1446
+ updateView(owner, ctx);
1447
+ publish(owner, ctx);
1448
+ }
1449
+ async function apply(owner, ctx, targets, flow, newFile = false, operation) {
1450
+ const signal = AbortSignal.any([owner.controller.signal, flow.signal, ...operation ? [operation] : []]);
1451
+ signal.throwIfAborted();
1452
+ mutationCheck(owner, ctx, flow);
1453
+ const proposed = createSnapshot(resolve4(ctx.cwd), targets);
1454
+ const existing = new Set(
1455
+ targets.filter((target) => "exists" in target && target.exists === true).map((target) => target.canonical)
1456
+ );
1457
+ if (newFile && proposed.targets?.length !== 1) throw new Error("New file requires exactly one target");
1458
+ assertUsableLoadout(pi);
1459
+ const checked = [];
1460
+ for (const target of proposed.targets ?? []) {
1461
+ const candidate = await resolveScopeTarget(
1462
+ pathToFileURL2(target.path).href,
1463
+ ctx.cwd,
1464
+ newFile ? "new" : existing.has(target.canonical) ? "existing" : "either",
1465
+ signal
1466
+ );
1467
+ signal.throwIfAborted();
1468
+ mutationCheck(owner, ctx, flow);
1469
+ if (candidate.canonical !== target.canonical)
1470
+ throw new Error(`Scope target changed: ${displayPath(target.path)}; select it again`);
1471
+ checked.push({ path: candidate.path, canonical: candidate.canonical });
1472
+ }
1473
+ assertUsableLoadout(pi);
1474
+ const snapshot = createSnapshot(resolve4(ctx.cwd), checked);
1475
+ createScopeNotice({ state: "active", snapshot, entryId: "preview" });
1476
+ if (owner.scope.state !== "blocked" && sameScope(owner.scope.snapshot, snapshot)) {
1477
+ notify(ctx, "Scope unchanged");
1478
+ return;
1479
+ }
1480
+ commit(owner, ctx, snapshot, flow, signal);
1481
+ notify(ctx, `Editing limited to ${checked.length} file${checked.length === 1 ? "" : "s"}`);
1482
+ }
1483
+ function off(owner, ctx, flow) {
1484
+ mutationCheck(owner, ctx, flow);
1485
+ if (owner.scope.state === "inactive") {
1486
+ notify(ctx, "Scope is already inactive");
1487
+ return;
1488
+ }
1489
+ commit(owner, ctx, createSnapshot(resolve4(ctx.cwd), null), flow);
1490
+ notify(ctx, "Scope restriction ended");
1491
+ }
1492
+ pi.on("session_start", (_event, ctx) => initialize(ctx));
1493
+ pi.on("session_tree", (_event, ctx) => initialize(ctx));
1494
+ pi.on("session_shutdown", async (_event, ctx) => {
1495
+ const key = ctx.sessionManager;
1496
+ starts.delete(key);
1497
+ const owner = owners.get(key);
1498
+ if (!owner) return;
1499
+ try {
1500
+ await release(owner, ctx);
1501
+ } finally {
1502
+ if (owners.get(key) === owner) owners.delete(key);
1503
+ }
1504
+ });
1505
+ pi.on("tool_call", async (event, ctx) => {
1506
+ const owner = owners.get(ctx.sessionManager);
1507
+ const scope = owner && current(owner) ? owner.scope : uninitialized();
1508
+ if (scope.state === "inactive") return;
1509
+ const decision = decideTool(event.toolName, pi.getAllTools(), scope);
1510
+ if (decision.kind === "pass") return;
1511
+ if (decision.kind === "block") return { block: true, reason: displayError(decision.reason).slice(0, 4e3) };
1512
+ const revision = owner?.revision;
1513
+ let work;
1514
+ try {
1515
+ const path = event.input && typeof event.input === "object" && "path" in event.input ? event.input.path : void 0;
1516
+ if (!owner || !scope.snapshot?.targets || typeof path !== "string")
1517
+ throw new Error("Invalid Scope write/edit call");
1518
+ check(owner, ctx);
1519
+ const signal = ctx.signal ? AbortSignal.any([owner.controller.signal, ctx.signal]) : owner.controller.signal;
1520
+ work = assertScopeTarget(path, ctx.cwd, scope.snapshot.targets, signal);
1521
+ owner.checks.add(work);
1522
+ await work;
1523
+ check(owner, ctx);
1524
+ if (owner.revision !== revision || owner.scope !== scope)
1525
+ throw new Error("Scope changed while checking this call");
1526
+ } catch (error) {
1527
+ return { block: true, reason: `Scope blocked this edit: ${displayError(error).slice(0, 4e3)}` };
1528
+ } finally {
1529
+ if (work) owner?.checks.delete(work);
1530
+ }
1531
+ });
1532
+ pi.on("context_with_system", (event, ctx) => {
1533
+ const owner = owners.get(ctx.sessionManager);
1534
+ if (!owner || !current(owner, ctx) || ctx.signal?.aborted) return;
1535
+ try {
1536
+ if (owner.scope.state === "active") {
1537
+ try {
1538
+ assertUsableLoadout(pi);
1539
+ } catch (error) {
1540
+ owner.scope = { ...owner.scope, state: "blocked", error: displayError(error) };
1541
+ owner.revision += 1;
1542
+ owner.flow?.controller.abort(new DOMException("Scope tool configuration changed", "AbortError"));
1543
+ updateView(owner, ctx);
1544
+ notify(ctx, owner.scope.error ?? "Scope tool configuration is unsupported", "error");
1545
+ }
1546
+ }
1547
+ const result = reconcileScopeContext(event.messages, ctx.sessionManager.getBranch(), owner.scope);
1548
+ if (result.anchor) {
1549
+ pi.appendEntry(ANCHOR_TYPE, result.anchor);
1550
+ check(owner, ctx);
1551
+ }
1552
+ return { messages: result.messages };
1553
+ } catch (error) {
1554
+ if (!current(owner) || ctx.signal?.aborted) return;
1555
+ owner.scope = {
1556
+ ...owner.scope,
1557
+ state: "blocked",
1558
+ error: "Scope context could not be restored; edits remain blocked. Repair with /scope or /scope off."
1559
+ };
1560
+ owner.revision += 1;
1561
+ owner.flow?.controller.abort(new DOMException("Scope context restoration failed", "AbortError"));
1562
+ updateView(owner, ctx);
1563
+ throw error;
1564
+ }
1565
+ });
1566
+ function getOwner(ctx) {
1567
+ const owner = owners.get(ctx.sessionManager);
1568
+ if (!owner) throw new Error("Scope is not initialized; reload the session first");
1569
+ check(owner, ctx);
1570
+ return owner;
1571
+ }
1572
+ function assertNoFlow(owner) {
1573
+ if (owner.flow) throw new Error("A Scope menu is already open; close it before issuing another Scope command");
1574
+ }
1575
+ const service = {
1576
+ async open(ctx, signal) {
1577
+ if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error("/edit-scope requires Pi TUI mode");
1578
+ const owner = getOwner(ctx);
1579
+ mutationCheck(owner, ctx);
1580
+ assertNoFlow(owner);
1581
+ signal?.throwIfAborted();
1582
+ const controller = new AbortController();
1583
+ const flow = {
1584
+ controller,
1585
+ signal: AbortSignal.any([
1586
+ owner.controller.signal,
1587
+ controller.signal,
1588
+ ...ctx.signal ? [ctx.signal] : [],
1589
+ ...signal ? [signal] : []
1590
+ ]),
1591
+ revision: owner.revision
1592
+ };
1593
+ owner.flow = flow;
1594
+ const menu = {
1595
+ signal: flow.signal,
1596
+ isCurrent: () => current(owner, ctx) && !flow.signal.aborted && owner.flow === flow && owner.revision === flow.revision,
1597
+ getView: () => {
1598
+ check(owner, ctx, flow);
1599
+ return service.getView(ctx);
1600
+ },
1601
+ getSettings: () => {
1602
+ check(owner, ctx, flow);
1603
+ return service.getSettings(ctx);
1604
+ },
1605
+ saveShowHiddenFiles: async (value) => {
1606
+ check(owner, ctx, flow);
1607
+ await service.saveShowHiddenFiles(ctx, value);
1608
+ check(owner, ctx, flow);
1609
+ },
1610
+ apply: (targets, newFile, operation) => {
1611
+ const work = apply(owner, ctx, targets, flow, newFile, operation);
1612
+ owner.checks.add(work);
1613
+ void work.then(
1614
+ () => owner.checks.delete(work),
1615
+ () => owner.checks.delete(work)
1616
+ );
1617
+ return work;
1618
+ },
1619
+ off: async () => off(owner, ctx, flow)
1620
+ };
1621
+ flow.promise = Promise.resolve().then(() => {
1622
+ check(owner, ctx, flow);
1623
+ return show(ctx, menu);
1624
+ });
1625
+ try {
1626
+ await flow.promise;
1627
+ check(owner, ctx, flow);
1628
+ } catch (error) {
1629
+ if (current(owner, ctx)) throw error;
1630
+ } finally {
1631
+ controller.abort(new DOMException("Scope menu closed", "AbortError"));
1632
+ if (owner.flow === flow) owner.flow = void 0;
1633
+ }
1634
+ },
1635
+ off(ctx) {
1636
+ const owner = getOwner(ctx);
1637
+ mutationCheck(owner, ctx);
1638
+ assertNoFlow(owner);
1639
+ off(owner, ctx);
1640
+ },
1641
+ getView(ctx) {
1642
+ return view(getOwner(ctx), ctx);
1643
+ },
1644
+ getSettings(ctx) {
1645
+ return getOwner(ctx).settings.current;
1646
+ },
1647
+ async saveShowHiddenFiles(ctx, value) {
1648
+ const owner = getOwner(ctx);
1649
+ await owner.settings.saveShowHiddenFiles(value);
1650
+ check(owner, ctx);
1651
+ },
1652
+ signal(ctx) {
1653
+ return getOwner(ctx).controller.signal;
1654
+ }
1655
+ };
1656
+ for (const commandName of ["edit-scope", "scope"]) {
1657
+ pi.registerCommand(commandName, {
1658
+ description: commandName === "scope" ? "Compatibility alias for /edit-scope" : "Temporarily restrict editing to selected files",
1659
+ getArgumentCompletions: (prefix) => "off".startsWith(prefix) ? [{ value: "off", label: "off", description: "End the editing restriction" }] : [],
1660
+ handler: async (args, ctx) => {
1661
+ const route = args.trim();
1662
+ if (route !== "" && route !== "off") throw new Error(`Usage: /${commandName} or /${commandName} off`);
1663
+ if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error(`/${commandName} requires Pi TUI mode`);
1664
+ if (route === "off") return service.off(ctx);
1665
+ await service.open(ctx);
1666
+ }
1667
+ });
1668
+ }
1669
+ return service;
1670
+ }
1671
+
1672
+ // src/files.ts
1673
+ async function registerFiles(pi, options = {}) {
1674
+ const flows = /* @__PURE__ */ new Map();
1675
+ const displays = /* @__PURE__ */ new Map();
1676
+ const display = (ctx) => {
1677
+ let state = displays.get(ctx.sessionManager);
1678
+ if (!state) {
1679
+ state = { quotes: [] };
1680
+ displays.set(ctx.sessionManager, state);
1681
+ }
1682
+ return state;
1683
+ };
1684
+ const redraw = (ctx) => {
1685
+ const state = display(ctx);
1686
+ publishFilesWidget(ctx, state.quotes, state.scope);
1687
+ };
1688
+ const runFlow = (ctx, kind, run) => {
1689
+ const key = ctx.sessionManager;
1690
+ const previous = flows.get(key);
1691
+ if (previous) {
1692
+ if (kind === "context" && previous.kind === kind) return previous.promise;
1693
+ throw new Error(
1694
+ kind === "scope" && previous.kind === "scope" ? "A Scope menu is already open; close it before issuing another Scope command" : "A Files menu is already open; close it before opening another file flow"
1695
+ );
1696
+ }
1697
+ const flow = { kind, controller: new AbortController(), promise: Promise.resolve() };
1698
+ const signal = AbortSignal.any([flow.controller.signal, ...ctx.signal ? [ctx.signal] : []]);
1699
+ const scoped = Object.create(ctx);
1700
+ Object.defineProperty(scoped, "signal", { value: signal });
1701
+ flows.set(key, flow);
1702
+ flow.promise = Promise.resolve().then(() => {
1703
+ signal.throwIfAborted();
1704
+ return run(scoped);
1705
+ }).finally(() => {
1706
+ if (flows.get(key) === flow) flows.delete(key);
1707
+ });
1708
+ return flow.promise;
1709
+ };
1710
+ const cancelFlow = (ctx) => {
1711
+ const flow = flows.get(ctx.sessionManager);
1712
+ if (!flow) return;
1713
+ flow.controller.abort(new DOMException("Files session or branch changed", "AbortError"));
1714
+ if (flows.get(ctx.sessionManager) === flow) flows.delete(ctx.sessionManager);
1715
+ };
1716
+ pi.on("session_start", (_event, ctx) => cancelFlow(ctx));
1717
+ pi.on("session_tree", (_event, ctx) => cancelFlow(ctx));
1718
+ pi.on("session_shutdown", (_event, ctx) => cancelFlow(ctx));
1719
+ const serviceAPI = {
1720
+ ...pi,
1721
+ registerCommand(name, command) {
1722
+ pi.registerCommand(name, {
1723
+ ...command,
1724
+ handler: (args, ctx) => {
1725
+ const route = name === "file-context" ? args.trim().toLowerCase() : args.trim();
1726
+ const valid = name === "file-context" ? ["", "browse", "remove"].includes(route) : ["", "off"].includes(route);
1727
+ if (!valid || ctx.mode !== "tui") return command.handler(args, ctx);
1728
+ return runFlow(
1729
+ ctx,
1730
+ name === "file-context" ? "context" : "scope",
1731
+ (scoped) => command.handler(args, scoped)
1732
+ );
1733
+ }
1734
+ });
1735
+ },
1736
+ registerShortcut(key, shortcut) {
1737
+ pi.registerShortcut(key, {
1738
+ ...shortcut,
1739
+ handler: (ctx) => runFlow(ctx, "context", (scoped) => shortcut.handler(scoped))
1740
+ });
1741
+ }
1742
+ };
1743
+ const scope = registerScope(serviceAPI, {
1744
+ ...options.scope,
1745
+ onChange: (ctx, state) => {
1746
+ display(ctx).scope = state;
1747
+ redraw(ctx);
1748
+ }
1749
+ });
1750
+ const context = await registerFileContext(serviceAPI, {
1751
+ ...options.context,
1752
+ onChange: (ctx, quotes) => {
1753
+ display(ctx).quotes = quotes;
1754
+ redraw(ctx);
1755
+ }
1756
+ });
1757
+ pi.registerCommand("files", {
1758
+ description: "Browse file context and manage allowed edit targets",
1759
+ getArgumentCompletions: (prefix) => [
1760
+ { value: "browse", label: "browse", description: "Open the context browser" },
1761
+ { value: "off", label: "off", description: "End the editing restriction" }
1762
+ ].filter((item) => item.value.startsWith(prefix)),
1763
+ handler: async (args, ctx) => {
1764
+ const route = args.trim();
1765
+ if (route !== "" && route !== "browse" && route !== "off") throw new Error("Usage: /files [browse|off]");
1766
+ if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error("/files requires Pi TUI mode");
1767
+ await runFlow(ctx, route === "browse" ? "context" : "manager", async (scoped) => {
1768
+ if (route === "browse") return context.browse(scoped);
1769
+ const commandCtx = scoped;
1770
+ if (route === "off") {
1771
+ scope.off(commandCtx);
1772
+ return;
1773
+ }
1774
+ const { showFilesMenu } = await import("./chunks/files-menu-MES4MQ4F.ts");
1775
+ scoped.signal?.throwIfAborted();
1776
+ await showFilesMenu(commandCtx, context, scope);
1777
+ });
1778
+ }
1779
+ });
1780
+ pi.on("session_shutdown", (_event, ctx) => {
1781
+ displays.delete(ctx.sessionManager);
1782
+ if (ctx.hasUI) ctx.ui.setWidget("files", void 0);
1783
+ });
1784
+ }
1785
+ var files_default = registerFiles;
1786
+ export {
1787
+ files_default as default
1788
+ };
1789
+ //# sourceMappingURL=index.ts.map