pum-agent 0.2.34-beta.2 → 0.2.35-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -1
  3. package/src/animation.tsx +20 -7
  4. package/src/app.tsx +786 -40
  5. package/src/check-mode-prompt.ts +13 -13
  6. package/src/check-mode.ts +105 -10
  7. package/src/cli.ts +36 -1
  8. package/src/commands.ts +37 -1
  9. package/src/context-estimate.ts +55 -0
  10. package/src/context-guidance.ts +5 -5
  11. package/src/context-recovery.ts +22 -0
  12. package/src/context-window.ts +127 -48
  13. package/src/conversation-branch-popup.tsx +46 -0
  14. package/src/conversation-branch.ts +227 -0
  15. package/src/file-checkpoints.ts +337 -0
  16. package/src/file-mutation-guard.ts +224 -0
  17. package/src/filesystem-sandbox.ts +5 -2
  18. package/src/headless.ts +87 -9
  19. package/src/help-popup.tsx +6 -0
  20. package/src/index.tsx +2 -0
  21. package/src/lsp-files.ts +102 -0
  22. package/src/lsp-protocol.ts +275 -0
  23. package/src/lsp.ts +203 -0
  24. package/src/main.tsx +154 -16
  25. package/src/mcp-config.ts +100 -0
  26. package/src/mcp-process.ts +244 -0
  27. package/src/mcp-protocol.ts +385 -0
  28. package/src/mcp.ts +230 -0
  29. package/src/memory-context.ts +91 -0
  30. package/src/memory.ts +25 -23
  31. package/src/operational-context.ts +91 -0
  32. package/src/plan-mode.ts +139 -0
  33. package/src/project-validation.ts +331 -0
  34. package/src/replay.ts +6 -0
  35. package/src/request-diagnostics-access.ts +25 -0
  36. package/src/request-diagnostics.ts +210 -0
  37. package/src/runtime-settings.ts +352 -0
  38. package/src/sandbox/linux.ts +31 -6
  39. package/src/sandbox/types.ts +5 -0
  40. package/src/session-lock-runtime.ts +332 -25
  41. package/src/status-bar.tsx +2 -0
  42. package/src/status-metadata.ts +8 -1
  43. package/src/subagents/manager.ts +137 -23
  44. package/src/subagents/readonly.ts +34 -11
  45. package/src/theme.ts +10 -9
  46. package/src/tool-groups.ts +60 -26
  47. package/src/transcript-history.ts +93 -18
  48. package/src/web-search.ts +228 -33
@@ -0,0 +1,337 @@
1
+ import {
2
+ createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, withFileMutationQueue,
3
+ type AgentSession, type InlineExtension,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { constants, type Stats } from "node:fs";
7
+ import { access, lstat, mkdir, open, writeFile } from "node:fs/promises";
8
+ import { dirname, join } from "node:path";
9
+ import { AGENT_DIR } from "./config";
10
+ import { getCheckModeConfig } from "./check-mode";
11
+ import { pathSensitivity } from "./check-mutation";
12
+ import { validateSandboxPath } from "./filesystem-sandbox";
13
+ import { canonicalPathIdentityAllowMissing, isPathInsideOrSame } from "./platform";
14
+ import { FileMutationGuard, type MutationAdmission } from "./file-mutation-guard";
15
+
16
+ export const CHECKPOINT_MAX_FILE_BYTES = 1024 * 1024;
17
+ export const CHECKPOINT_MAX_BYTES = 8 * 1024 * 1024;
18
+ export const CHECKPOINT_MAX_RECORDS = 32;
19
+
20
+ type Snapshot = { bytes: Buffer; fingerprint: string };
21
+ export type FileCheckpointInfo = {
22
+ id: string; path: string; toolName: "write" | "edit"; createdAt: number;
23
+ bytes: number; priorAbsent: boolean;
24
+ };
25
+ type Record = FileCheckpointInfo & { before: Buffer | null; after: string };
26
+ type Pending = { path: string; before: Buffer | null; after: string };
27
+ const controllers = new Map<string, FileCheckpointController>();
28
+ type CheckpointBinding = { controller?: FileCheckpointController; readAutoResize?: () => boolean };
29
+ const bindings = new Map<string, CheckpointBinding>();
30
+ /** AgentSession.dispose does NOT emit session_shutdown (notably worker closure). */
31
+ export function bindFileCheckpointSession(session: Pick<AgentSession, "sessionId" | "dispose">
32
+ & { settingsManager?: Pick<AgentSession["settingsManager"], "getImageAutoResize"> }): void {
33
+ const id = session.sessionId;
34
+ const settings = session.settingsManager;
35
+ const binding: CheckpointBinding = {
36
+ controller: controllers.get(id),
37
+ readAutoResize: settings ? () => settings.getImageAutoResize() : undefined,
38
+ };
39
+ bindings.set(id, binding);
40
+ const dispose = session.dispose.bind(session);
41
+ session.dispose = () => {
42
+ try { dispose(); }
43
+ finally {
44
+ binding.controller?.dispose();
45
+ if (controllers.get(id) === binding.controller) controllers.delete(id);
46
+ if (bindings.get(id) === binding) bindings.delete(id);
47
+ }
48
+ };
49
+ }
50
+ export function checkpointControllerForSession(sessionId: string): FileCheckpointController | undefined {
51
+ return controllers.get(sessionId);
52
+ }
53
+ function metadata(stat: Stats): string {
54
+ return [stat.dev, stat.ino, stat.mode, stat.nlink, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
55
+ }
56
+ function digest(bytes: Buffer): string { return createHash("sha256").update(bytes).digest("hex"); }
57
+ function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; }
58
+ class CheckpointConflictError extends Error {}
59
+ class RecoveryError extends Error {}
60
+ /** Only our finite recovery errors may reach the UI; raw IO failures stay private. */
61
+ export function checkpointRecoveryFailureText(error: unknown): string {
62
+ return error instanceof RecoveryError ? error.message
63
+ : "Checkpoint recovery failed; no in-place restore was attempted. List checkpoints to check availability.";
64
+ }
65
+
66
+ /** Runtime-private bytes. No persistence, model restore tool, or original-file restore. */
67
+ export class FileCheckpointController {
68
+ private records: Record[] = [];
69
+ private retainedBytes = 0;
70
+ private skipped = 0;
71
+ private evicted = 0;
72
+ private disposed = false;
73
+ private generation = 0;
74
+ readonly mutationGuard: FileMutationGuard;
75
+ constructor(
76
+ private readonly cwd: string,
77
+ private readonly allowedPaths: () => readonly string[] = () => getCheckModeConfig().additionalPaths ?? [],
78
+ private readonly agentDir = AGENT_DIR,
79
+ private readonly checkpoints = true,
80
+ ) { this.mutationGuard = new FileMutationGuard(cwd, allowedPaths); }
81
+
82
+ private async validate(path: string): Promise<string> {
83
+ const target = await validateSandboxPath(this.cwd, path, this.allowedPaths(), "write");
84
+ const identity = await canonicalPathIdentityAllowMissing(target.absolute);
85
+ const config = await canonicalPathIdentityAllowMissing(this.agentDir);
86
+ if (isPathInsideOrSame(config, identity) || pathSensitivity(identity).credential) {
87
+ throw new Error("Checkpoint path is private or credential-sensitive.");
88
+ }
89
+ return target.absolute;
90
+ }
91
+
92
+ private async snapshot(path: string): Promise<Snapshot | null> {
93
+ await this.validate(path);
94
+ let stat: Stats;
95
+ try { stat = await lstat(path); } catch (error) { if (missing(error)) return null; throw error; }
96
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > CHECKPOINT_MAX_FILE_BYTES) {
97
+ throw new Error("Checkpoint requires a singly linked regular file of at most 1 MiB.");
98
+ }
99
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
100
+ try {
101
+ const initial = await handle.stat();
102
+ if (metadata(initial) !== metadata(stat)) throw new CheckpointConflictError("Checkpoint file changed while opening; mutation refused.");
103
+ // Never use readFile: a concurrently growing file must not allocate unbounded memory.
104
+ const bytes = Buffer.alloc(stat.size + 1);
105
+ let offset = 0;
106
+ while (offset < bytes.length) {
107
+ const result = await handle.read(bytes, offset, bytes.length - offset, offset);
108
+ if (!result.bytesRead) break;
109
+ offset += result.bytesRead;
110
+ }
111
+ const final = await handle.stat();
112
+ await this.validate(path);
113
+ const named = await lstat(path);
114
+ if (offset !== stat.size || metadata(final) !== metadata(stat) || metadata(named) !== metadata(stat)) {
115
+ throw new CheckpointConflictError("Checkpoint file changed while reading; mutation refused.");
116
+ }
117
+ const content = Buffer.from(bytes.subarray(0, offset));
118
+ return { bytes: content, fingerprint: `${metadata(stat)}:${digest(content)}` };
119
+ } finally { await handle.close(); }
120
+ }
121
+
122
+ list(): FileCheckpointInfo[] {
123
+ return this.records.map(({ before: _before, after: _after, ...info }) => ({ ...info }));
124
+ }
125
+ summary(): string {
126
+ return `Runtime-only file-tool recovery copies: ${this.records.length}/32 checkpoints, ${this.retainedBytes}/8388608 bytes; ${this.skipped} skipped, ${this.evicted} evicted. Maximum file 1 MiB; oldest evicted first. No Bash coverage. Lost on clear, runtime replacement, worker closure or exit. Originals are never restored in place.`;
127
+ }
128
+ clear(): void {
129
+ this.generation++;
130
+ this.records = [];
131
+ this.retainedBytes = 0;
132
+ this.skipped = 0;
133
+ this.evicted = 0;
134
+ }
135
+ dispose(): void { this.disposed = true; this.clear(); this.mutationGuard.dispose(); }
136
+
137
+ /** Execute through pi's native queue and exact write/edit argument and diff contracts. */
138
+ async execute(
139
+ toolName: "write" | "edit", toolCallId: string, input: any,
140
+ signal?: AbortSignal, onUpdate?: any, requirePrepared = false,
141
+ ): Promise<any> {
142
+ if (this.disposed) throw new Error("Checkpoint runtime is disposed.");
143
+ const generation = this.generation;
144
+ return this.mutationGuard.run(toolName, toolCallId, input, signal,
145
+ (admission) => this.executeAdmitted(toolName, toolCallId, input, admission, generation, signal, onUpdate), requirePrepared);
146
+ }
147
+
148
+ private async executeAdmitted(
149
+ toolName: "write" | "edit", toolCallId: string, input: any, admission: MutationAdmission,
150
+ generation: number, signal?: AbortSignal, onUpdate?: any,
151
+ ): Promise<any> {
152
+ let writtenContent: string | undefined;
153
+ let pending: Pending | undefined;
154
+ let readState: Snapshot | undefined;
155
+ let skip = false;
156
+ const captureWrite = async (path: string, content: string) => {
157
+ // This runs inside the SDK mutation queue, immediately at its write boundary.
158
+ await validateSandboxPath(this.cwd, path, this.allowedPaths(), "write");
159
+ let before: Snapshot | null | undefined;
160
+ try {
161
+ // Stable-read protection also applies in headless mode. Disabling
162
+ // recovery retention must not turn a supported edit into a failure.
163
+ before = await this.snapshot(path);
164
+ if (readState && before?.fingerprint !== readState.fingerprint) {
165
+ throw new CheckpointConflictError("Checkpoint conflict: file changed since edit read; mutation refused.");
166
+ }
167
+ } catch (error) {
168
+ if (readState || error instanceof CheckpointConflictError) throw error;
169
+ skip = true;
170
+ }
171
+ // A skipped capture must never bypass a boundary that changed during IO.
172
+ await validateSandboxPath(this.cwd, path, this.allowedPaths(), "write");
173
+ await this.mutationGuard.assertUnchanged(admission);
174
+ if (signal?.aborted || this.disposed) throw new Error("Operation aborted");
175
+ await writeFile(path, content, "utf8");
176
+ writtenContent = content;
177
+ if (!this.checkpoints) return;
178
+ if (skip || before === undefined || Buffer.byteLength(content) > CHECKPOINT_MAX_FILE_BYTES) { skip = true; return; }
179
+ try {
180
+ const after = await this.snapshot(path);
181
+ if (!after || !after.bytes.equals(Buffer.from(content, "utf8"))) { skip = true; return; }
182
+ pending = { path, before: before?.bytes ?? null, after: after.fingerprint };
183
+ } catch { skip = true; }
184
+ };
185
+ const write = createWriteToolDefinition(this.cwd, { operations: {
186
+ mkdir: async (path) => {
187
+ await validateSandboxPath(this.cwd, path, this.allowedPaths(), "write");
188
+ if (signal?.aborted || this.disposed) throw new Error("Operation aborted");
189
+ await mkdir(path, { recursive: true });
190
+ }, writeFile: captureWrite,
191
+ } });
192
+ const edit = createEditToolDefinition(this.cwd, { operations: {
193
+ access: async (path) => { await access(path, constants.R_OK | constants.W_OK); },
194
+ readFile: async (path) => {
195
+ // Oversized edits retain native functionality, without checkpoint coverage.
196
+ await validateSandboxPath(this.cwd, path, this.allowedPaths(), "write");
197
+ try { readState = (await this.snapshot(path)) ?? undefined; }
198
+ catch (error) {
199
+ if (error instanceof CheckpointConflictError) throw error;
200
+ skip = true;
201
+ }
202
+ if (readState) return Buffer.from(readState.bytes);
203
+ const { readFile } = await import("node:fs/promises");
204
+ return readFile(path);
205
+ }, writeFile: captureWrite,
206
+ } });
207
+ const tool = toolName === "write" ? write : edit;
208
+ let result = await tool.execute(toolCallId, input, signal, onUpdate, { cwd: this.cwd } as any);
209
+ if (signal?.aborted || this.disposed) return result;
210
+ if (writtenContent !== undefined) {
211
+ try { await this.mutationGuard.didWrite(admission, writtenContent); }
212
+ catch { admission.notice = "File changed or became unavailable after writing; read it again before further mutations."; }
213
+ }
214
+ if (admission.notice) result = { ...result, content: [...result.content, { type: "text", text: admission.notice }] };
215
+ if (!this.checkpoints || generation !== this.generation) return result;
216
+ let note: string;
217
+ if (!skip && pending) {
218
+ const record: Record = {
219
+ id: randomUUID(), path: pending.path, toolName, createdAt: Date.now(),
220
+ bytes: pending.before?.length ?? 0, priorAbsent: pending.before === null,
221
+ before: pending.before, after: pending.after,
222
+ };
223
+ while (this.records.length >= CHECKPOINT_MAX_RECORDS || this.retainedBytes + record.bytes > CHECKPOINT_MAX_BYTES) {
224
+ this.retainedBytes -= this.records.shift()!.bytes;
225
+ this.evicted++;
226
+ }
227
+ this.records.push(record);
228
+ this.retainedBytes += record.bytes;
229
+ note = record.priorAbsent
230
+ ? "Checkpoint records prior absence; no preimage to recover and no automatic deletion."
231
+ : "Runtime-only file checkpoint retained; user may export a recovery copy with /checkpoint. No in-place rewind.";
232
+ } else {
233
+ this.skipped++;
234
+ note = "No file checkpoint retained (unsupported, oversized, unavailable or concurrently changed file).";
235
+ }
236
+ return { ...result, content: [...result.content, { type: "text", text: note }] };
237
+ }
238
+
239
+ /** User-only export. Never write, rename over, chmod or unlink the original. */
240
+ async recover(id: string): Promise<string> {
241
+ const record = this.records.find((item) => item.id === id);
242
+ if (!record || this.disposed) throw new RecoveryError("Checkpoint unavailable or expired.");
243
+ if (record.before === null) throw new RecoveryError("File was newly created: no prior bytes to recover. Original was not deleted.");
244
+ const generation = this.generation;
245
+ const preimage = record.before;
246
+ return withFileMutationQueue(record.path, async () => {
247
+ const current = await this.snapshot(record.path);
248
+ if (current?.fingerprint !== record.after) throw new RecoveryError("Checkpoint conflict: original changed; no recovery file created.");
249
+ if (generation !== this.generation || this.disposed) throw new RecoveryError("Checkpoint expired during recovery.");
250
+ // Random flat sibling, independent of a potentially special source basename.
251
+ const destination = join(dirname(record.path), `pum-recovery-${randomUUID()}.txt`);
252
+ await this.validate(destination);
253
+ let handle;
254
+ try { handle = await open(destination, "wx", 0o600); }
255
+ catch { throw new RecoveryError("Recovery file could not be exclusively created. Original is unchanged."); }
256
+ try {
257
+ // Validate the opened name again BEFORE releasing any private preimage bytes.
258
+ await this.validate(destination);
259
+ const opened = await handle.stat();
260
+ const linked = await lstat(destination);
261
+ if (!opened.isFile() || opened.nlink !== 1 || opened.size !== 0
262
+ || metadata(opened) !== metadata(linked)
263
+ || generation !== this.generation || this.disposed) {
264
+ throw new Error("Recovery destination changed before write");
265
+ }
266
+ await handle.writeFile(preimage);
267
+ await handle.sync();
268
+ await this.validate(destination);
269
+ const exported = await this.snapshot(destination);
270
+ const own = await handle.stat();
271
+ const named = await lstat(destination);
272
+ const final = await this.snapshot(record.path);
273
+ if (metadata(own) !== metadata(named) || !exported?.bytes.equals(preimage)
274
+ || final?.fingerprint !== record.after || generation !== this.generation || this.disposed) {
275
+ throw new Error("Recovery validation conflict");
276
+ }
277
+ } catch {
278
+ // Never unlink by name: another writer may have replaced/edited the artifact.
279
+ throw new RecoveryError(`Recovery failed or conflicted; possible partial recovery file: ${JSON.stringify(destination)}. Original was not changed by recovery.`);
280
+ } finally {
281
+ try { await handle.close(); }
282
+ catch { throw new RecoveryError(`Recovery close failed; inspect possible recovery file: ${JSON.stringify(destination)}. Original was not changed by recovery.`); }
283
+ }
284
+ return destination;
285
+ });
286
+ }
287
+ }
288
+
289
+ /** A fresh extension instance per runtime; no state survives session replacement. */
290
+ export function createFileCheckpointExtension(options: { readonly?: boolean; checkpoints?: boolean } = {}): InlineExtension {
291
+ return {
292
+ name: "pum-file-checkpoints",
293
+ factory(pi) {
294
+ if (options.readonly) return;
295
+ let controller: FileCheckpointController | undefined;
296
+ let sessionId: string | undefined;
297
+ let retired = false;
298
+ const obtain = (ctx: { cwd: string; sessionManager: { getSessionId(): string } }) => {
299
+ const id = ctx.sessionManager.getSessionId();
300
+ if (retired || (sessionId !== undefined && sessionId !== id)) throw new Error("Checkpoint runtime is unavailable.");
301
+ if (!controller) {
302
+ controller = new FileCheckpointController(ctx.cwd, undefined, undefined, options.checkpoints !== false);
303
+ sessionId = id;
304
+ controllers.set(id, controller);
305
+ const binding = bindings.get(id);
306
+ if (binding) binding.controller = controller;
307
+ }
308
+ return controller;
309
+ };
310
+ pi.on("session_start", (_event, ctx) => { obtain(ctx); });
311
+ pi.on("session_shutdown", () => {
312
+ retired = true;
313
+ controller?.dispose();
314
+ if (sessionId && controllers.get(sessionId) === controller) controllers.delete(sessionId);
315
+ controller = undefined;
316
+ });
317
+ pi.on("before_agent_start", (event) => ({
318
+ systemPrompt: `${event.systemPrompt}\n\n## Shared file mutation guard\n\nRead existing files before write/edit. Changed read/proposal baselines reject stale writes and overlapping edits; exact unique unchanged edit targets can preserve other changes. On conflict, reread and reconcile, coordinate ownership or use spawn_subagent with worktree: true. This is process-local conflict detection, not filesystem isolation: Bash, user shells and external editors are not locked or checkpointed.`,
319
+ }));
320
+ pi.on("tool_call", async (event, ctx) => {
321
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
322
+ try { await obtain(ctx).mutationGuard.prepare(event.toolName, event.toolCallId, event.input); }
323
+ catch (error) { return { block: true, reason: error instanceof Error ? error.message : "File mutation proposal unavailable." }; }
324
+ });
325
+ pi.on("tool_result", (event) => { controller?.mutationGuard.forgetProposal(event.input); });
326
+ pi.on("agent_end", () => { controller?.mutationGuard.clearProposals(); });
327
+ const read = createReadToolDefinition(process.cwd());
328
+ pi.registerTool({ ...read, execute: (id, args, signal, onUpdate, ctx) => obtain(ctx).mutationGuard.read(
329
+ id, args, signal, onUpdate, ctx, bindings.get(ctx.sessionManager.getSessionId())?.readAutoResize?.(),
330
+ ) });
331
+ const write = createWriteToolDefinition(process.cwd());
332
+ const edit = createEditToolDefinition(process.cwd());
333
+ pi.registerTool({ ...write, execute: (id, args, signal, onUpdate, ctx) => obtain(ctx).execute("write", id, args, signal, onUpdate, true) });
334
+ pi.registerTool({ ...edit, execute: (id, args, signal, onUpdate, ctx) => obtain(ctx).execute("edit", id, args, signal, onUpdate, true) });
335
+ },
336
+ };
337
+ }
@@ -0,0 +1,224 @@
1
+ import { createReadToolDefinition, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { createHash } from "node:crypto";
3
+ import { constants, type Stats } from "node:fs";
4
+ import { lstat, open } from "node:fs/promises";
5
+ import { getCheckModeConfig } from "./check-mode";
6
+ import { validateSandboxPath } from "./filesystem-sandbox";
7
+ import { canonicalPathIdentityAllowMissing } from "./platform";
8
+
9
+ export const MUTATION_BASELINE_MAX_FILE_BYTES = 1024 * 1024;
10
+ export const MUTATION_BASELINE_MAX_BYTES = 8 * 1024 * 1024;
11
+ export const MUTATION_BASELINE_MAX_FILES = 128;
12
+ const guidance = "Read the file again, reconcile the changes, and retry. Coordinate overlapping work or use spawn_subagent with worktree: true. Bash and external editors are not locked by this guard.";
13
+ export class FileMutationConflictError extends Error {
14
+ constructor(reason: string) { super(`File mutation conflict: ${reason}. ${guidance}`); }
15
+ }
16
+ type Snapshot = { fingerprint: string; bytes?: Buffer; absent: boolean };
17
+ type Proposal = {
18
+ path: string; key: string; inputKey: string; id: string; tool: "write" | "edit";
19
+ baseline: Snapshot; observed: boolean;
20
+ };
21
+ export type MutationAdmission = { path: string; current: Snapshot; rebased: boolean; notice?: string };
22
+ const queues = new Map<string, Promise<void>>();
23
+
24
+ function metadata(stat: Stats): string {
25
+ return [stat.dev, stat.ino, stat.mode, stat.nlink, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
26
+ }
27
+ function digest(bytes: Buffer): string { return createHash("sha256").update(bytes).digest("hex"); }
28
+ function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; }
29
+ function normalized(bytes: Buffer): string { return bytes.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n"); }
30
+ function uniqueLines(text: string, needle: string): string | undefined {
31
+ const at = text.indexOf(needle);
32
+ if (!needle.length || at < 0 || text.indexOf(needle, at + 1) >= 0) return undefined;
33
+ // Include the whole touched lines, not just the substring: "value = 1" still
34
+ // matches "value = 10", but overwriting that prefix would clobber a newer edit.
35
+ const start = at === 0 ? 0 : text.lastIndexOf("\n", at - 1) + 1;
36
+ const end = text.indexOf("\n", at + needle.length - 1);
37
+ return text.slice(start, end < 0 ? text.length : end + 1);
38
+ }
39
+ /** Deliberately stricter than pi's fuzzy matcher when rebasing a stale proposal. */
40
+ function unchangedEditTargets(before: Snapshot, now: Snapshot, input: any): boolean {
41
+ if (!before.bytes || !now.bytes || !Array.isArray(input.edits) || !input.edits.length) return false;
42
+ // Invalid UTF-8 / binary bytes cannot safely be reasoned about as text.
43
+ if (!Buffer.from(before.bytes.toString("utf8")).equals(before.bytes)
44
+ || !Buffer.from(now.bytes.toString("utf8")).equals(now.bytes)
45
+ || before.bytes.includes(0) || now.bytes.includes(0)) return false;
46
+ const currentText = now.bytes.toString("utf8");
47
+ // pi normalizes every line ending before editing and restores one style for
48
+ // the whole file. A stale rebase must not erase another writer's untouched
49
+ // mixed/bare-CR endings while claiming to preserve their changes. Uniform LF
50
+ // and CRLF round-trip; fresh (non-rebased) edits retain native behavior.
51
+ if (currentText.replace(/\r\n/g, "").includes("\r")
52
+ || (currentText.includes("\r\n") && /(?<!\r)\n/.test(currentText))) return false;
53
+ const a = normalized(before.bytes), b = normalized(now.bytes);
54
+ return input.edits.every((edit: any) => {
55
+ if (typeof edit?.oldText !== "string") return false;
56
+ const needle = normalized(Buffer.from(edit.oldText));
57
+ const original = uniqueLines(a, needle);
58
+ return original !== undefined && original === uniqueLines(b, needle);
59
+ });
60
+ }
61
+
62
+ /** Separate from pi's queue: canonical Windows/missing-path identities must also serialize. */
63
+ async function inQueue<T>(key: string, run: () => Promise<T>): Promise<T> {
64
+ const previous = queues.get(key) ?? Promise.resolve();
65
+ let release!: () => void;
66
+ const current = new Promise<void>((resolve) => { release = resolve; });
67
+ const tail = previous.then(() => current);
68
+ queues.set(key, tail);
69
+ await previous;
70
+ try { return await run(); }
71
+ finally { release(); if (queues.get(key) === tail) queues.delete(key); }
72
+ }
73
+
74
+ /** Runtime-owned observations, never persisted, never populated from replay/model input. */
75
+ export class FileMutationGuard {
76
+ private baselines = new Map<string, Snapshot>();
77
+ private retainedBytes = 0;
78
+ private proposals = new Map<object, Proposal>();
79
+ private proposalBytes = 0;
80
+ private disposed = false;
81
+ constructor(
82
+ private readonly cwd: string,
83
+ private readonly allowedPaths: () => readonly string[] = () => getCheckModeConfig().additionalPaths ?? [],
84
+ ) {}
85
+ dispose(): void {
86
+ this.disposed = true;
87
+ this.baselines.clear(); this.clearProposals(); this.retainedBytes = 0;
88
+ }
89
+ clearProposals(): void { this.proposals.clear(); this.proposalBytes = 0; }
90
+ forgetProposal(input: object): void {
91
+ this.proposalBytes -= this.proposals.get(input)?.baseline.bytes?.length ?? 0;
92
+ this.proposals.delete(input);
93
+ }
94
+ private live(): void { if (this.disposed) throw new Error("File mutation guard runtime is disposed."); }
95
+ private remember(key: string, value: Snapshot): void {
96
+ this.live();
97
+ const prior = this.baselines.get(key);
98
+ this.retainedBytes -= prior?.bytes?.length ?? 0;
99
+ this.baselines.delete(key);
100
+ while (this.baselines.size >= MUTATION_BASELINE_MAX_FILES
101
+ || this.retainedBytes + (value.bytes?.length ?? 0) > MUTATION_BASELINE_MAX_BYTES) {
102
+ const first = this.baselines.keys().next().value!;
103
+ this.retainedBytes -= this.baselines.get(first)?.bytes?.length ?? 0;
104
+ this.baselines.delete(first);
105
+ }
106
+ this.baselines.set(key, value);
107
+ this.retainedBytes += value.bytes?.length ?? 0;
108
+ }
109
+ private async target(path: string, operation: "read" | "write") {
110
+ this.live();
111
+ const { absolute } = await validateSandboxPath(this.cwd, path, this.allowedPaths(), operation);
112
+ return { path: absolute, key: await canonicalPathIdentityAllowMissing(absolute) };
113
+ }
114
+ private async snapshot(path: string, operation: "read" | "write"): Promise<Snapshot> {
115
+ await this.target(path, operation);
116
+ let stat: Stats;
117
+ try { stat = await lstat(path); }
118
+ catch (error) { if (missing(error)) return { fingerprint: "absent", absent: true }; throw error; }
119
+ if (!stat.isFile()) throw new FileMutationConflictError("target is not a regular file");
120
+ // Large files keep metadata-only observations, never allocate unbounded retained bytes.
121
+ if (stat.size > MUTATION_BASELINE_MAX_FILE_BYTES) return { fingerprint: metadata(stat), absent: false };
122
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
123
+ try {
124
+ if (metadata(await handle.stat()) !== metadata(stat)) throw new FileMutationConflictError("file changed while opening");
125
+ const bytes = Buffer.alloc(stat.size + 1);
126
+ let size = 0;
127
+ while (size < bytes.length) {
128
+ const read = await handle.read(bytes, size, bytes.length - size, size);
129
+ if (!read.bytesRead) break;
130
+ size += read.bytesRead;
131
+ }
132
+ await this.target(path, operation);
133
+ if (size !== stat.size || metadata(await handle.stat()) !== metadata(stat)
134
+ || metadata(await lstat(path)) !== metadata(stat)) throw new FileMutationConflictError("file changed while reading");
135
+ const content = Buffer.from(bytes.subarray(0, size));
136
+ return { fingerprint: `${metadata(stat)}:${digest(content)}`, bytes: content, absent: false };
137
+ } finally { await handle.close(); }
138
+ }
139
+
140
+ /** Preserve native image handling and filename retries; observe only a stable successful read. */
141
+ async read(id: string, input: any, signal?: AbortSignal, onUpdate?: any, ctx?: ExtensionContext, autoResizeImages?: boolean): Promise<any> {
142
+ const target = await this.target(input.path, "read");
143
+ const before = await this.snapshot(target.path, "read");
144
+ const result = await createReadToolDefinition(this.cwd, { autoResizeImages }).execute(
145
+ id, input, signal, onUpdate, ctx ?? { cwd: this.cwd } as ExtensionContext,
146
+ );
147
+ const after = await this.snapshot(target.path, "read");
148
+ if (!signal?.aborted && !this.disposed && before.fingerprint === after.fingerprint) this.remember(target.key, before);
149
+ else if (!this.disposed) {
150
+ return { ...result, content: [...result.content, { type: "text", text: "File changed during read; no fresh mutation baseline recorded. Read it again before editing." }] };
151
+ }
152
+ return result;
153
+ }
154
+
155
+ /** Called at tool_call, before asynchronous Check review and before any parallel execution. */
156
+ async prepare(tool: "write" | "edit", id: string, input: any): Promise<void> {
157
+ const target = await this.target(input.path, "write");
158
+ const current = await this.snapshot(target.path, "write");
159
+ this.live();
160
+ const observed = this.baselines.get(target.key);
161
+ if (this.proposals.has(input)) throw new FileMutationConflictError("proposal object was reused");
162
+ const baseline = observed ?? current;
163
+ if (this.proposals.size >= MUTATION_BASELINE_MAX_FILES
164
+ || this.proposalBytes + (baseline.bytes?.length ?? 0) > MUTATION_BASELINE_MAX_BYTES) {
165
+ throw new FileMutationConflictError("too many pending file proposals or baseline bytes");
166
+ }
167
+ this.proposals.set(input, {
168
+ ...target, tool, id, inputKey: JSON.stringify(input), baseline, observed: !!observed,
169
+ });
170
+ this.proposalBytes += baseline.bytes?.length ?? 0;
171
+ }
172
+
173
+ /** Proposals are consumed exactly once; every queued execution rechecks its immutable baseline. */
174
+ async run<T>(tool: "write" | "edit", id: string, input: any, signal: AbortSignal | undefined,
175
+ execute: (admission: MutationAdmission) => Promise<T>, requirePrepared = false): Promise<T> {
176
+ if (!this.proposals.has(input)) {
177
+ if (requirePrepared) throw new FileMutationConflictError("prepared proposal is missing or expired");
178
+ await this.prepare(tool, id, input);
179
+ }
180
+ const proposal = this.proposals.get(input)!;
181
+ this.forgetProposal(input);
182
+ if (proposal.id !== id || proposal.tool !== tool || proposal.inputKey !== JSON.stringify(input)) throw new FileMutationConflictError("proposal arguments changed");
183
+ return inQueue(proposal.key, async () => {
184
+ this.live();
185
+ if (signal?.aborted) throw new Error("Operation aborted");
186
+ const target = await this.target(input.path, "write");
187
+ if (target.key !== proposal.key) throw new FileMutationConflictError("target identity changed");
188
+ const current = await this.snapshot(target.path, "write");
189
+ const changed = proposal.baseline.fingerprint !== current.fingerprint;
190
+ if (changed && !(tool === "edit" && unchangedEditTargets(proposal.baseline, current, input))) {
191
+ throw new FileMutationConflictError("baseline changed since this runtime's read or mutation proposal");
192
+ }
193
+ const admission: MutationAdmission = {
194
+ path: target.path, current, rebased: changed,
195
+ notice: changed
196
+ ? "File baseline changed outside the exact, unique edit targets and their lines; preserved those other changes. Read again before further mutations."
197
+ : !proposal.observed && !current.absent
198
+ ? "No prior read baseline was available; only changes since this proposal were checked. Read existing files before mutating them; use worktrees for overlapping work."
199
+ : undefined,
200
+ };
201
+ if (!current.absent && !current.bytes) admission.notice = (admission.notice ? `${admission.notice} ` : "")
202
+ + "Large-file conflict detection uses metadata only (over 1 MiB); no stale edit rebasing.";
203
+ // Waiting for another runtime and filesystem validation are asynchronous;
204
+ // the exact prepared object must still carry the originally pinned args.
205
+ if (proposal.inputKey !== JSON.stringify(input)) throw new FileMutationConflictError("proposal arguments changed");
206
+ return execute(admission);
207
+ });
208
+ }
209
+
210
+ /** Invoke immediately before the actual write, inside both mutation queues. Not filesystem CAS. */
211
+ async assertUnchanged(admission: MutationAdmission): Promise<void> {
212
+ this.live();
213
+ const current = await this.snapshot(admission.path, "write");
214
+ if (current.fingerprint !== admission.current.fingerprint) throw new FileMutationConflictError("file changed during mutation execution");
215
+ }
216
+ async didWrite(admission: MutationAdmission, content: string): Promise<void> {
217
+ if (this.disposed || admission.rebased) return; // Do not bless unseen changes from a safe rebase.
218
+ const after = await this.snapshot(admission.path, "write");
219
+ if (after.bytes?.equals(Buffer.from(content)) || (!after.absent && !after.bytes)) {
220
+ const { key } = await this.target(admission.path, "write");
221
+ if (!this.disposed) this.remember(key, after);
222
+ } else throw new FileMutationConflictError("postimage changed after writing");
223
+ }
224
+ }
@@ -219,12 +219,15 @@ function toolPath(input: Record<string, unknown>): string | undefined {
219
219
  /** Enforce the process-local filesystem sandbox before built-in tool execution. */
220
220
  export type FilesystemSandboxExtensionOptions = {
221
221
  readonly?: boolean;
222
+ /** Names the restricted role in refusals: a readonly child, or plan mode (#51). */
223
+ roleLabel?: string;
222
224
  };
223
225
 
224
226
  export function createFilesystemSandboxExtension(
225
227
  options: FilesystemSandboxExtensionOptions = {},
226
228
  ): InlineExtension {
227
229
  const readonly = options.readonly === true;
230
+ const role = options.roleLabel ?? "readonly child";
228
231
  return {
229
232
  name: readonly ? "pum-readonly-filesystem-sandbox" : "pum-filesystem-sandbox",
230
233
  factory(pi) {
@@ -232,7 +235,7 @@ export function createFilesystemSandboxExtension(
232
235
  pi.on("before_agent_start", (event) => ({
233
236
  systemPrompt: `${event.systemPrompt}\n\n## Filesystem sandbox\n\n`
234
237
  + "- The read, write, and edit tools are limited to the project and configured allowed roots.\n"
235
- + (readonly ? "- This readonly child cannot use write or edit.\n" : "")
238
+ + (readonly ? `- This ${role} cannot use write or edit.\n` : "")
236
239
  + "- The read tool may also read the temporary files PUM stages for you, such as pasted text and full bash output.\n"
237
240
  + "- Do not access credential-sensitive paths or paths through symbolic links or junctions.\n"
238
241
  + "- Do not attempt to bypass the filesystem sandbox with alternate path spellings.",
@@ -243,7 +246,7 @@ export function createFilesystemSandboxExtension(
243
246
  const toolName = event.toolName as FilesystemSandboxToolName;
244
247
  try {
245
248
  if (readonly && toolName !== "read") {
246
- throw new Error(`readonly child cannot use ${toolName}`);
249
+ throw new Error(`${role} cannot use ${toolName}`);
247
250
  }
248
251
  const allowedPaths = getCheckModeConfig().additionalPaths;
249
252
  const path = toolPath(event.input);