wispy-cli 2.7.11 → 2.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/harness.mjs CHANGED
@@ -13,12 +13,145 @@
13
13
  */
14
14
 
15
15
  import { EventEmitter } from "node:events";
16
- import { readFile } from "node:fs/promises";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
17
  import path from "node:path";
18
18
  import os from "node:os";
19
19
 
20
20
  import { EVENT_TYPES } from "./audit.mjs";
21
21
 
22
+ // ── Approval gate constants ────────────────────────────────────────────────────
23
+
24
+ // Tools that require approval depending on security mode
25
+ const DANGEROUS_TOOLS = new Set([
26
+ "run_command", "write_file", "file_edit", "delete_file",
27
+ "spawn_subagent", "browser_navigate",
28
+ ]);
29
+
30
+ // Tools that ALWAYS require approval (even in yolo mode)
31
+ const ALWAYS_APPROVE = new Set(["delete_file"]);
32
+
33
+ // System-path prefixes — writing here requires approval even in balanced mode
34
+ const SYSTEM_PATH_PREFIXES = ["/usr", "/etc", "/System", "/bin", "/sbin", "/Library/LaunchDaemons"];
35
+
36
+ const APPROVALS_PATH = path.join(os.homedir(), ".wispy", "approvals.json");
37
+
38
+ const DEFAULT_ALLOWLIST = {
39
+ run_command: ["npm test", "npm run build", "git status", "git diff", "ls"],
40
+ write_file: [],
41
+ file_edit: [],
42
+ delete_file: [],
43
+ spawn_subagent: [],
44
+ browser_navigate: [],
45
+ };
46
+
47
+ // ── Allowlist manager ─────────────────────────────────────────────────────────
48
+
49
+ export class ApprovalAllowlist {
50
+ constructor() {
51
+ this._list = null; // lazy load
52
+ }
53
+
54
+ async _load() {
55
+ if (this._list !== null) return;
56
+ try {
57
+ const raw = await readFile(APPROVALS_PATH, "utf8");
58
+ this._list = JSON.parse(raw);
59
+ } catch {
60
+ this._list = { ...DEFAULT_ALLOWLIST };
61
+ }
62
+ }
63
+
64
+ async _save() {
65
+ await mkdir(path.dirname(APPROVALS_PATH), { recursive: true });
66
+ await writeFile(APPROVALS_PATH, JSON.stringify(this._list, null, 2) + "\n", "utf8");
67
+ }
68
+
69
+ async matches(toolName, args) {
70
+ await this._load();
71
+ const patterns = this._list[toolName] ?? [];
72
+ if (patterns.length === 0) return false;
73
+
74
+ // Get a string representation of args for matching
75
+ const argStr = _getArgString(toolName, args);
76
+ if (!argStr) return false;
77
+
78
+ return patterns.some(pattern => {
79
+ // Glob-style: "*" matches everything
80
+ if (pattern === "*") return true;
81
+ // Prefix match or exact match
82
+ if (pattern.endsWith("*")) return argStr.startsWith(pattern.slice(0, -1));
83
+ return argStr === pattern || argStr.startsWith(pattern);
84
+ });
85
+ }
86
+
87
+ async add(toolName, pattern) {
88
+ await this._load();
89
+ if (!this._list[toolName]) this._list[toolName] = [];
90
+ if (!this._list[toolName].includes(pattern)) {
91
+ this._list[toolName].push(pattern);
92
+ await this._save();
93
+ }
94
+ }
95
+
96
+ async clear() {
97
+ this._list = {};
98
+ await this._save();
99
+ }
100
+
101
+ async reset() {
102
+ this._list = { ...DEFAULT_ALLOWLIST };
103
+ await this._save();
104
+ }
105
+
106
+ async getAll() {
107
+ await this._load();
108
+ return { ...this._list };
109
+ }
110
+ }
111
+
112
+ // Singleton allowlist instance
113
+ const globalAllowlist = new ApprovalAllowlist();
114
+
115
+ function _getArgString(toolName, args) {
116
+ if (!args) return "";
117
+ if (toolName === "run_command") return args.command ?? "";
118
+ if (toolName === "write_file" || toolName === "file_edit") return args.path ?? "";
119
+ if (toolName === "delete_file") return args.path ?? "";
120
+ if (toolName === "browser_navigate") return args.url ?? "";
121
+ if (toolName === "spawn_subagent") return args.task ?? "";
122
+ return JSON.stringify(args);
123
+ }
124
+
125
+ /**
126
+ * Determine if a tool+args needs approval based on security mode.
127
+ * @param {string} toolName
128
+ * @param {object} args
129
+ * @param {string} mode - "careful" | "balanced" | "yolo"
130
+ * @returns {boolean}
131
+ */
132
+ function _needsApproval(toolName, args, mode) {
133
+ if (ALWAYS_APPROVE.has(toolName)) return true;
134
+ if (!DANGEROUS_TOOLS.has(toolName)) return false;
135
+
136
+ if (mode === "careful") return true;
137
+
138
+ if (mode === "balanced") {
139
+ // Only destructive tools or writes to system paths
140
+ if (toolName === "delete_file") return true;
141
+ if (toolName === "write_file" || toolName === "file_edit") {
142
+ const filePath = args?.path ?? "";
143
+ const resolved = filePath.replace(/^~/, os.homedir());
144
+ return SYSTEM_PATH_PREFIXES.some(prefix => resolved.startsWith(prefix));
145
+ }
146
+ return false;
147
+ }
148
+
149
+ // yolo: only ALWAYS_APPROVE (handled above)
150
+ return false;
151
+ }
152
+
153
+ export { globalAllowlist };
154
+
22
155
  // ── Receipt ────────────────────────────────────────────────────────────────────
23
156
 
24
157
  export class Receipt {
@@ -349,6 +482,7 @@ export class Harness extends EventEmitter {
349
482
  this.permissions = permissions;
350
483
  this.audit = audit;
351
484
  this.config = config;
485
+ this.allowlist = globalAllowlist;
352
486
 
353
487
  // Sandbox config per-tool: "preview" | "diff" | null
354
488
  this._sandboxModes = {
@@ -402,6 +536,56 @@ export class Harness extends EventEmitter {
402
536
  receipt.approved = permResult.approved;
403
537
  }
404
538
 
539
+ // ── 1b. Approval gate (security mode) ────────────────────────────────────
540
+ const securityMode = this.config.securityLevel ?? context.securityLevel ?? "balanced";
541
+ if (_needsApproval(toolName, args, securityMode) && permResult.allowed) {
542
+ // Check allowlist first
543
+ const allowlisted = await this.allowlist.matches(toolName, args);
544
+ if (!allowlisted) {
545
+ // Non-interactive (no TTY): auto-deny in careful, auto-allow in balanced/yolo
546
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
547
+ if (!isTTY) {
548
+ if (securityMode === "careful") {
549
+ receipt.approved = false;
550
+ receipt.success = false;
551
+ receipt.error = `Auto-denied (careful mode, non-interactive): ${toolName}`;
552
+ receipt.duration = Date.now() - callStart;
553
+ this.emit("tool:denied", { toolName, args, receipt, context, reason: "non-interactive-careful" });
554
+ return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
555
+ }
556
+ // balanced/yolo non-interactive → auto-allow
557
+ receipt.approved = true;
558
+ } else {
559
+ // Emit approval_required event and wait for response
560
+ const approved = await this._requestApproval(toolName, args, receipt, context, securityMode);
561
+ receipt.approved = approved;
562
+ if (!approved) {
563
+ receipt.success = false;
564
+ receipt.error = `Approval denied for: ${toolName}`;
565
+ receipt.duration = Date.now() - callStart;
566
+ this.audit.log({
567
+ type: EVENT_TYPES.APPROVAL_DENIED,
568
+ sessionId: context.sessionId,
569
+ tool: toolName,
570
+ args,
571
+ }).catch(() => {});
572
+ this.emit("tool:denied", { toolName, args, receipt, context, reason: "approval-denied" });
573
+ return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
574
+ }
575
+ // User approved — add to allowlist if they want to remember
576
+ // (The auto-approve-and-remember logic is handled by the approval handler)
577
+ this.audit.log({
578
+ type: EVENT_TYPES.APPROVAL_GRANTED ?? "approval_granted",
579
+ sessionId: context.sessionId,
580
+ tool: toolName,
581
+ args,
582
+ }).catch(() => {});
583
+ }
584
+ } else {
585
+ receipt.approved = true; // allowlisted
586
+ }
587
+ }
588
+
405
589
  // ── 2. Dry-run mode ──────────────────────────────────────────────────────
406
590
  if (receipt.dryRun) {
407
591
  const preview = simulateDryRun(toolName, args);
@@ -516,6 +700,43 @@ export class Harness extends EventEmitter {
516
700
  return new HarnessResult({ result, receipt });
517
701
  }
518
702
 
703
+ /**
704
+ * Emit 'approval_required' and wait for user response.
705
+ * Resolves to true (approved) or false (denied).
706
+ */
707
+ async _requestApproval(toolName, args, receipt, context, mode) {
708
+ return new Promise((resolve) => {
709
+ // Timeout after 60s → auto-deny in careful, auto-allow otherwise
710
+ const timer = setTimeout(() => {
711
+ if (mode === "careful") {
712
+ resolve(false);
713
+ } else {
714
+ resolve(true);
715
+ }
716
+ }, 60_000);
717
+
718
+ const respond = (approved, remember = false) => {
719
+ clearTimeout(timer);
720
+ if (remember && approved) {
721
+ const pattern = _getArgString(toolName, args);
722
+ if (pattern) {
723
+ this.allowlist.add(toolName, pattern).catch(() => {});
724
+ }
725
+ }
726
+ resolve(approved);
727
+ };
728
+
729
+ this.emit("approval_required", {
730
+ tool: toolName,
731
+ args,
732
+ receipt,
733
+ context,
734
+ mode,
735
+ respond,
736
+ });
737
+ });
738
+ }
739
+
519
740
  /**
520
741
  * Set sandbox mode for a tool.
521
742
  * @param {string} toolName
package/core/index.mjs CHANGED
@@ -9,7 +9,8 @@ export { SessionManager, Session, sessionManager } from "./session.mjs";
9
9
  export { ProviderRegistry } from "./providers.mjs";
10
10
  export { ToolRegistry } from "./tools.mjs";
11
11
  export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
12
- export { loadConfig, saveConfig, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
12
+ export { loadConfig, saveConfig, loadConfigWithProfile, listProfiles, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
13
+ export { FeatureManager, getFeatureManager, FEATURE_REGISTRY } from "./features.mjs";
13
14
  export { OnboardingWizard, isFirstRun as checkFirstRun, printStatus } from "./onboarding.mjs";
14
15
  export { MemoryManager } from "./memory.mjs";
15
16
  export { CronManager } from "./cron.mjs";
@@ -169,6 +169,16 @@ export class ProviderRegistry {
169
169
  functionCall: { name: tc.name, args: tc.args },
170
170
  })),
171
171
  });
172
+ } else if (m.images && m.images.length > 0) {
173
+ // Multimodal message with images (Google format)
174
+ const parts = m.images.map(img => ({
175
+ inlineData: { mimeType: img.mimeType, data: img.data },
176
+ }));
177
+ if (m.content) parts.push({ text: m.content });
178
+ contents.push({
179
+ role: m.role === "assistant" ? "model" : "user",
180
+ parts,
181
+ });
172
182
  } else {
173
183
  contents.push({
174
184
  role: m.role === "assistant" ? "model" : "user",
@@ -280,6 +290,17 @@ export class ProviderRegistry {
280
290
  type: "tool_use", id: tc.id ?? tc.name, name: tc.name, input: tc.args,
281
291
  })),
282
292
  });
293
+ } else if (m.images && m.images.length > 0) {
294
+ // Multimodal message with images (Anthropic format)
295
+ const contentParts = m.images.map(img => ({
296
+ type: "image",
297
+ source: { type: "base64", media_type: img.mimeType, data: img.data },
298
+ }));
299
+ if (m.content) contentParts.push({ type: "text", text: m.content });
300
+ anthropicMessages.push({
301
+ role: m.role === "assistant" ? "assistant" : "user",
302
+ content: contentParts,
303
+ });
283
304
  } else {
284
305
  anthropicMessages.push({
285
306
  role: m.role === "assistant" ? "assistant" : "user",
@@ -390,6 +411,15 @@ export class ProviderRegistry {
390
411
  })),
391
412
  };
392
413
  }
414
+ // Multimodal message with images (OpenAI format)
415
+ if (m.images && m.images.length > 0) {
416
+ const contentParts = m.images.map(img => ({
417
+ type: "image_url",
418
+ image_url: { url: `data:${img.mimeType};base64,${img.data}` },
419
+ }));
420
+ if (m.content) contentParts.push({ type: "text", text: m.content });
421
+ return { role: m.role === "assistant" ? "assistant" : "user", content: contentParts };
422
+ }
393
423
  return { role: m.role, content: m.content };
394
424
  });
395
425
 
package/core/session.mjs CHANGED
@@ -93,6 +93,109 @@ export class SessionManager {
93
93
  });
94
94
  }
95
95
 
96
+ /**
97
+ * List all sessions from disk with metadata (for CLI listing / picking).
98
+ * Returns sorted by updatedAt descending (most recent first).
99
+ *
100
+ * @param {object} options
101
+ * @param {string} [options.workstream] - Filter by workstream (default: current dir sessions only via all=false)
102
+ * @param {boolean} [options.all] - Include sessions from all workstreams
103
+ * @param {number} [options.limit] - Max sessions to return (default: 50)
104
+ * @returns {Array<{id, workstream, channel, chatId, createdAt, updatedAt, model, messageCount, firstMessage, cwd}>}
105
+ */
106
+ async listSessions(options = {}) {
107
+ const { workstream = null, all: showAll = false, limit = 50 } = options;
108
+ let files;
109
+ try {
110
+ files = await readdir(SESSIONS_DIR);
111
+ } catch {
112
+ return [];
113
+ }
114
+
115
+ const sessionFiles = files.filter(f => f.endsWith(".json"));
116
+ const results = [];
117
+
118
+ for (const file of sessionFiles) {
119
+ const id = file.replace(".json", "");
120
+ const filePath = path.join(SESSIONS_DIR, file);
121
+ try {
122
+ const [raw, fileStat] = await Promise.all([
123
+ readFile(filePath, "utf8"),
124
+ stat(filePath),
125
+ ]);
126
+ let data;
127
+ try { data = JSON.parse(raw); } catch { continue; }
128
+ if (!data || !data.id) continue;
129
+
130
+ // Apply workstream filter
131
+ if (!showAll && workstream && data.workstream !== workstream) continue;
132
+
133
+ const messages = Array.isArray(data.messages) ? data.messages : [];
134
+ const userMessages = messages.filter(m => m.role === "user");
135
+ const firstMessage = userMessages[0]?.content ?? messages[0]?.content ?? "";
136
+
137
+ results.push({
138
+ id: data.id,
139
+ workstream: data.workstream ?? "default",
140
+ channel: data.channel ?? null,
141
+ chatId: data.chatId ?? null,
142
+ createdAt: data.createdAt ?? fileStat.birthtime.toISOString(),
143
+ updatedAt: data.updatedAt ?? fileStat.mtime.toISOString(),
144
+ model: data.model ?? null,
145
+ messageCount: messages.length,
146
+ firstMessage: firstMessage.slice(0, 120),
147
+ cwd: data.cwd ?? null,
148
+ });
149
+ } catch {
150
+ continue;
151
+ }
152
+ }
153
+
154
+ // Sort by updatedAt descending
155
+ results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
156
+ return results.slice(0, limit);
157
+ }
158
+
159
+ /**
160
+ * Load a session by ID and return the full session (alias for load with better name).
161
+ * Returns null if not found.
162
+ */
163
+ async loadSession(id) {
164
+ return this.getOrLoad(id);
165
+ }
166
+
167
+ /**
168
+ * Fork a session: copy its message history into a new session.
169
+ * The new session starts from the same history but diverges from here.
170
+ *
171
+ * @param {string} id - Source session ID
172
+ * @param {object} opts - Additional options for the new session (workstream, etc.)
173
+ * @returns {Session} - The new forked session
174
+ */
175
+ async forkSession(id, opts = {}) {
176
+ // Load source session
177
+ const source = await this.getOrLoad(id);
178
+ if (!source) {
179
+ throw new Error(`Session not found: ${id}`);
180
+ }
181
+
182
+ // Create a new session with same metadata
183
+ const forked = this.create({
184
+ workstream: opts.workstream ?? source.workstream,
185
+ channel: opts.channel ?? null,
186
+ chatId: opts.chatId ?? null,
187
+ });
188
+
189
+ // Copy message history (deep copy)
190
+ forked.messages = source.messages.map(m => ({ ...m }));
191
+ forked.updatedAt = new Date().toISOString();
192
+
193
+ // Save the forked session
194
+ await this.save(forked.id);
195
+
196
+ return forked;
197
+ }
198
+
96
199
  /**
97
200
  * Add a message to a session.
98
201
  */
package/core/tools.mjs CHANGED
@@ -326,6 +326,21 @@ export class ToolRegistry {
326
326
  },
327
327
  },
328
328
  },
329
+ // ── apply_patch (Part 3) ─────────────────────────────────────────────────
330
+ {
331
+ name: "apply_patch",
332
+ description: "Apply multi-file patches. Supports creating, editing, and deleting files in one atomic operation. All operations succeed or all rollback.",
333
+ parameters: {
334
+ type: "object",
335
+ properties: {
336
+ patch: {
337
+ type: "string",
338
+ description: "Patch in structured format:\n*** Begin Patch\n*** Add File: path\n+line1\n+line2\n*** Edit File: path\n@@@ context line @@@\n-old line\n+new line\n*** Delete File: path\n*** End Patch",
339
+ },
340
+ },
341
+ required: ["patch"],
342
+ },
343
+ },
329
344
  ];
330
345
 
331
346
  for (const def of builtins) {
@@ -631,6 +646,9 @@ export class ToolRegistry {
631
646
  return { success: false, error: "action must be 'copy' or 'paste'" };
632
647
  }
633
648
 
649
+ case "apply_patch":
650
+ return this._executeApplyPatch(args.patch);
651
+
634
652
  // Agent tools — these are handled by the engine level
635
653
  case "spawn_agent":
636
654
  case "list_agents":
@@ -656,10 +674,195 @@ export class ToolRegistry {
656
674
  return { success: false, error: `Tool "${name}" requires engine context. Call via WispyEngine.processMessage().` };
657
675
 
658
676
  default:
659
- return { success: false, error: `Unknown tool: ${name}. Available built-in tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard` };
677
+ return { success: false, error: `Unknown tool: ${name}. Available built-in tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, apply_patch` };
660
678
  }
661
679
  } catch (err) {
662
680
  return { success: false, error: err.message };
663
681
  }
664
682
  }
683
+
684
+ /**
685
+ * Execute an apply_patch operation atomically.
686
+ * Supports: Add File, Edit File, Delete File
687
+ *
688
+ * Format:
689
+ * *** Begin Patch
690
+ * *** Add File: path/to/file.txt
691
+ * +line content
692
+ * *** Edit File: path/to/file.txt
693
+ * @@@ context @@@
694
+ * -old line
695
+ * +new line
696
+ * *** Delete File: path/to/file.txt
697
+ * *** End Patch
698
+ */
699
+ async _executeApplyPatch(patchText) {
700
+ if (!patchText || typeof patchText !== "string") {
701
+ return { success: false, error: "patch parameter is required and must be a string" };
702
+ }
703
+
704
+ const { readFile: rf, writeFile: wf, unlink, mkdir: mkdirFs } = await import("node:fs/promises");
705
+
706
+ const lines = patchText.split("\n");
707
+ const operations = [];
708
+
709
+ // ── Parse operations ──────────────────────────────────────────────────────
710
+ let i = 0;
711
+ // Skip to Begin Patch
712
+ while (i < lines.length && !lines[i].startsWith("*** Begin Patch")) i++;
713
+ if (i >= lines.length) {
714
+ return { success: false, error: 'Patch must start with "*** Begin Patch"' };
715
+ }
716
+ i++;
717
+
718
+ while (i < lines.length) {
719
+ const line = lines[i];
720
+
721
+ if (line.startsWith("*** End Patch")) break;
722
+
723
+ if (line.startsWith("*** Add File:")) {
724
+ const filePath = line.slice("*** Add File:".length).trim();
725
+ const addLines = [];
726
+ i++;
727
+ while (i < lines.length && !lines[i].startsWith("***")) {
728
+ const l = lines[i];
729
+ if (l.startsWith("+")) addLines.push(l.slice(1));
730
+ else if (l.startsWith(" ")) addLines.push(l.slice(1));
731
+ // Lines starting with - are ignored for Add File
732
+ i++;
733
+ }
734
+ operations.push({ type: "add", path: filePath, content: addLines.join("\n") });
735
+ continue;
736
+ }
737
+
738
+ if (line.startsWith("*** Edit File:")) {
739
+ const filePath = line.slice("*** Edit File:".length).trim();
740
+ const hunks = [];
741
+ i++;
742
+ // Parse edit hunks
743
+ while (i < lines.length && !lines[i].startsWith("*** ")) {
744
+ const hunkLine = lines[i];
745
+ if (hunkLine.startsWith("@@@")) {
746
+ // Start of a hunk: @@@ context @@@
747
+ const contextText = hunkLine.replace(/^@@@\s*/, "").replace(/\s*@@@$/, "").trim();
748
+ const removals = [];
749
+ const additions = [];
750
+ i++;
751
+ while (i < lines.length && !lines[i].startsWith("@@@") && !lines[i].startsWith("***")) {
752
+ const hl = lines[i];
753
+ if (hl.startsWith("-")) removals.push(hl.slice(1));
754
+ else if (hl.startsWith("+")) additions.push(hl.slice(1));
755
+ i++;
756
+ }
757
+ hunks.push({ context: contextText, removals, additions });
758
+ continue;
759
+ }
760
+ i++;
761
+ }
762
+ operations.push({ type: "edit", path: filePath, hunks });
763
+ continue;
764
+ }
765
+
766
+ if (line.startsWith("*** Delete File:")) {
767
+ const filePath = line.slice("*** Delete File:".length).trim();
768
+ operations.push({ type: "delete", path: filePath });
769
+ i++;
770
+ continue;
771
+ }
772
+
773
+ i++;
774
+ }
775
+
776
+ if (operations.length === 0) {
777
+ return { success: false, error: "No valid operations found in patch" };
778
+ }
779
+
780
+ // ── Resolve paths ─────────────────────────────────────────────────────────
781
+ const resolvePatchPath = (p) => {
782
+ let resolved = p.replace(/^~/, os.homedir());
783
+ if (!path.isAbsolute(resolved)) resolved = path.resolve(process.cwd(), resolved);
784
+ return resolved;
785
+ };
786
+
787
+ // ── Pre-flight: load original file contents for rollback ──────────────────
788
+ const originalContents = new Map(); // path → string | null (null = didn't exist)
789
+ for (const op of operations) {
790
+ const resolved = resolvePatchPath(op.path);
791
+ try {
792
+ originalContents.set(resolved, await rf(resolved, "utf8"));
793
+ } catch {
794
+ originalContents.set(resolved, null);
795
+ }
796
+ }
797
+
798
+ // ── Apply operations ──────────────────────────────────────────────────────
799
+ const applied = [];
800
+ const results = [];
801
+
802
+ try {
803
+ for (const op of operations) {
804
+ const resolved = resolvePatchPath(op.path);
805
+
806
+ if (op.type === "add") {
807
+ await mkdirFs(path.dirname(resolved), { recursive: true });
808
+ await wf(resolved, op.content, "utf8");
809
+ applied.push({ op, resolved });
810
+ results.push(`✅ Added: ${op.path}`);
811
+
812
+ } else if (op.type === "edit") {
813
+ const original = originalContents.get(resolved);
814
+ if (original === null) {
815
+ throw new Error(`Cannot edit non-existent file: ${op.path}`);
816
+ }
817
+ let current = original;
818
+ for (const hunk of op.hunks) {
819
+ const oldText = hunk.removals.join("\n");
820
+ const newText = hunk.additions.join("\n");
821
+ if (oldText && !current.includes(oldText)) {
822
+ throw new Error(`Edit hunk not found in ${op.path}: "${oldText.slice(0, 60)}"`);
823
+ }
824
+ current = oldText ? current.replace(oldText, newText) : current + "\n" + newText;
825
+ }
826
+ await wf(resolved, current, "utf8");
827
+ applied.push({ op, resolved });
828
+ results.push(`✅ Edited: ${op.path}`);
829
+
830
+ } else if (op.type === "delete") {
831
+ await unlink(resolved);
832
+ applied.push({ op, resolved });
833
+ results.push(`✅ Deleted: ${op.path}`);
834
+ }
835
+ }
836
+ } catch (err) {
837
+ // ── Rollback all applied operations ──────────────────────────────────────
838
+ for (const { op, resolved } of applied.reverse()) {
839
+ try {
840
+ const original = originalContents.get(resolved);
841
+ if (op.type === "delete" && original !== null) {
842
+ // Restore deleted file
843
+ await wf(resolved, original, "utf8");
844
+ } else if ((op.type === "add") && original === null) {
845
+ // Remove newly created file
846
+ await unlink(resolved).catch(() => {});
847
+ } else if (op.type === "edit" && original !== null) {
848
+ // Restore original content
849
+ await wf(resolved, original, "utf8");
850
+ }
851
+ } catch { /* best-effort rollback */ }
852
+ }
853
+
854
+ return {
855
+ success: false,
856
+ error: `Patch failed (rolled back): ${err.message}`,
857
+ applied: applied.length,
858
+ total: operations.length,
859
+ };
860
+ }
861
+
862
+ return {
863
+ success: true,
864
+ message: `Applied ${operations.length} operation(s)`,
865
+ results,
866
+ };
867
+ }
665
868
  }