@lasso-ai/cli 1.4.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/cli/agent.d.ts +10 -89
  2. package/dist/cli/agent.js +66 -1412
  3. package/dist/cli/backends.d.ts +53 -0
  4. package/dist/cli/backends.js +267 -0
  5. package/dist/cli/bridge-old.d.ts +259 -0
  6. package/dist/cli/bridge-old.js +1350 -0
  7. package/dist/cli/bridge.d.ts +22 -12
  8. package/dist/cli/bridge.js +824 -1174
  9. package/dist/cli/host/install.js +12 -1
  10. package/dist/cli/host/next-host-entry.js +0 -2
  11. package/dist/cli/host/vite-host-entry.js +1 -3
  12. package/dist/cli/local-runtime.d.ts +28 -0
  13. package/dist/cli/local-runtime.js +294 -0
  14. package/dist/cli/oneshot.js +177 -961
  15. package/dist/cli/runtime-client.d.ts +310 -0
  16. package/dist/cli/runtime-client.js +178 -0
  17. package/dist/cli/snapshot.d.ts +3 -1
  18. package/dist/cli/snapshot.js +8 -1
  19. package/dist/overlay.js +44 -28
  20. package/dist/runtime/__tests__/adapter-normalization.test.d.ts +1 -0
  21. package/dist/runtime/__tests__/adapter-normalization.test.js +392 -0
  22. package/dist/runtime/__tests__/state-machine.test.d.ts +1 -0
  23. package/dist/runtime/__tests__/state-machine.test.js +107 -0
  24. package/dist/runtime/__tests__/verification-engine.test.d.ts +1 -0
  25. package/dist/runtime/__tests__/verification-engine.test.js +91 -0
  26. package/dist/runtime/__tests__/workspace-guard.test.d.ts +1 -0
  27. package/dist/runtime/__tests__/workspace-guard.test.js +104 -0
  28. package/dist/runtime/adapters/claude-code-adapter.d.ts +46 -0
  29. package/dist/runtime/adapters/claude-code-adapter.js +230 -0
  30. package/dist/runtime/adapters/codex-cli-adapter.d.ts +44 -0
  31. package/dist/runtime/adapters/codex-cli-adapter.js +202 -0
  32. package/dist/runtime/adapters/cursor-adapter.d.ts +47 -0
  33. package/dist/runtime/adapters/cursor-adapter.js +214 -0
  34. package/dist/runtime/adapters/factory.d.ts +19 -0
  35. package/dist/runtime/adapters/factory.js +48 -0
  36. package/dist/runtime/adapters/local-agent-adapter.d.ts +143 -0
  37. package/dist/runtime/adapters/local-agent-adapter.js +408 -0
  38. package/dist/runtime/adapters/opencode-adapter.d.ts +43 -0
  39. package/dist/runtime/adapters/opencode-adapter.js +210 -0
  40. package/dist/runtime/agent-runtime.d.ts +286 -0
  41. package/dist/runtime/agent-runtime.js +8 -0
  42. package/dist/runtime/agent-state-machine.d.ts +67 -0
  43. package/dist/runtime/agent-state-machine.js +234 -0
  44. package/dist/runtime/backend-interface.d.ts +272 -0
  45. package/dist/runtime/backend-interface.js +38 -0
  46. package/dist/runtime/event-mapper.d.ts +11 -0
  47. package/dist/runtime/event-mapper.js +40 -0
  48. package/dist/runtime/index.d.ts +20 -0
  49. package/dist/runtime/index.js +43 -0
  50. package/dist/runtime/local-session-runner.d.ts +60 -0
  51. package/dist/runtime/local-session-runner.js +185 -0
  52. package/dist/runtime/review.d.ts +24 -0
  53. package/dist/runtime/review.js +30 -0
  54. package/dist/runtime/verification-engine.d.ts +71 -0
  55. package/dist/runtime/verification-engine.js +374 -0
  56. package/dist/runtime/workspace-guard.d.ts +70 -0
  57. package/dist/runtime/workspace-guard.js +321 -0
  58. package/docs/local-coding-agents.md +183 -0
  59. package/package.json +22 -3
@@ -14,10 +14,12 @@ const node_path_1 = __importDefault(require("node:path"));
14
14
  const node_child_process_1 = require("node:child_process");
15
15
  const node_util_1 = require("node:util");
16
16
  const chalk_1 = __importDefault(require("chalk"));
17
- const agent_1 = require("./agent");
18
17
  const project_1 = require("./project");
19
18
  const auth_1 = require("./auth");
20
19
  const snapshot_1 = require("./snapshot");
20
+ const runtime_client_1 = require("./runtime-client");
21
+ const backends_1 = require("./backends");
22
+ const local_runtime_1 = require("./local-runtime");
21
23
  exports.DEFAULT_BRIDGE_PORT = 3056;
22
24
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
23
25
  async function restartBridge(port = exports.DEFAULT_BRIDGE_PORT) {
@@ -48,361 +50,7 @@ async function getGitState(cwd) {
48
50
  const remote = await gitCommand(cwd, ["remote", "get-url", "origin"]).catch(() => "");
49
51
  return { isRepo: true, branch, status, hasChanges: status.length > 0, hasRemote: Boolean(remote), remote };
50
52
  }
51
- function fileLineEnding(content) {
52
- return content.includes("\r\n") ? "\r\n" : "\n";
53
- }
54
- function withLineEnding(value, lineEnding) {
55
- return value.replace(/\r\n?|\n/g, lineEnding);
56
- }
57
- function resolveProposedFile(cwd, proposedPath, allowNew = false) {
58
- const root = node_path_1.default.resolve(cwd);
59
- const candidate = node_path_1.default.resolve(root, proposedPath);
60
- if (candidate.startsWith(`${root}${node_path_1.default.sep}`) && node_fs_1.default.existsSync(candidate))
61
- return candidate;
62
- // Some React/Next source maps contain an absolute path from an older
63
- // checkout. Rebase only the recognizable src/... suffix into this project.
64
- const normalized = proposedPath.replace(/\\/g, "/");
65
- const srcIndex = normalized.lastIndexOf("/src/");
66
- if (srcIndex >= 0) {
67
- const rebased = node_path_1.default.join(root, normalized.slice(srcIndex + 1));
68
- if (rebased.startsWith(`${root}${node_path_1.default.sep}`) && node_fs_1.default.existsSync(rebased))
69
- return rebased;
70
- if (allowNew && (rebased.startsWith(`${root}${node_path_1.default.sep}`) || rebased === root))
71
- return rebased;
72
- }
73
- if (candidate.startsWith(`${root}${node_path_1.default.sep}`) || candidate === root) {
74
- if (allowNew)
75
- return candidate;
76
- throw new Error(`Could not find the proposed file in the current project: ${proposedPath}`);
77
- }
78
- throw new Error(`The proposed file is outside the current project: ${proposedPath}`);
79
- }
80
- function occurrenceCount(content, needle) {
81
- if (!needle)
82
- return 0;
83
- let count = 0;
84
- let from = 0;
85
- while (true) {
86
- const index = content.indexOf(needle, from);
87
- if (index < 0)
88
- return count;
89
- count += 1;
90
- from = index + needle.length;
91
- }
92
- }
93
- function escapeRegExp(value) {
94
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
- }
96
- function whitespaceEquivalentRange(content, oldString) {
97
- const trimmed = oldString.trim();
98
- if (!trimmed)
99
- return null;
100
- const pattern = trimmed.split(/\s+/).map(escapeRegExp).join("\\s+");
101
- const matches = Array.from(content.matchAll(new RegExp(pattern, "g")));
102
- if (matches.length !== 1 || matches[0].index === undefined)
103
- return null;
104
- const start = matches[0].index;
105
- return { start, end: start + matches[0][0].length };
106
- }
107
- function prepareChange(content, change) {
108
- const lineEnding = fileLineEnding(content);
109
- const oldString = withLineEnding(change.oldString, lineEnding);
110
- const newString = withLineEnding(change.newString, lineEnding);
111
- const matches = occurrenceCount(content, oldString);
112
- if (matches === 0) {
113
- const range = whitespaceEquivalentRange(content, oldString);
114
- if (range)
115
- return { ...range, oldString: content.slice(range.start, range.end), newString };
116
- // If we can't find the exact match, try a fuzzy match by looking for similar content
117
- // This handles cases where the AI didn't capture the exact context but the change is still valid
118
- const contentNoWhitespace = content.replace(/\s+/g, " ");
119
- const oldStringNoWhitespace = oldString.replace(/\s+/g, " ");
120
- if (contentNoWhitespace.includes(oldStringNoWhitespace) && oldStringNoWhitespace.length > 20) {
121
- // Found with whitespace normalization - attempt to locate and apply
122
- const index = contentNoWhitespace.indexOf(oldStringNoWhitespace);
123
- console.warn(`[Lasso] Applying change with fuzzy match for ${change.filePath}`);
124
- // This is a best-effort approach - might not be perfect but better than failing
125
- throw new Error(`Could not safely apply ${change.filePath}. The source changed after the suggestion was generated. Regenerate the review so it uses the current source.`);
126
- }
127
- throw new Error(`Could not safely apply ${change.filePath}. The source changed after the suggestion was generated. Regenerate the review so it uses the current source.`);
128
- }
129
- if (matches > 1) {
130
- throw new Error(`Could not safely apply ${change.filePath}. The selected code is not unique (${matches} matches).`);
131
- }
132
- const start = content.indexOf(oldString);
133
- return { start, end: start + oldString.length, oldString, newString };
134
- }
135
- function proposalMatchesCurrentSource(cwd, changes) {
136
- try {
137
- for (const change of changes) {
138
- if (!change.oldString)
139
- continue;
140
- const filePath = resolveProposedFile(cwd, change.filePath);
141
- const content = node_fs_1.default.readFileSync(filePath, "utf8");
142
- // Try exact match first
143
- try {
144
- prepareChange(content, change);
145
- }
146
- catch {
147
- // If exact match fails, try with normalized whitespace
148
- const normalizedContent = content.replace(/\s+/g, " ").trim();
149
- const normalizedOldString = change.oldString.replace(/\s+/g, " ").trim();
150
- const normalizedNewString = change.newString.replace(/\s+/g, " ").trim();
151
- if (!normalizedContent.includes(normalizedOldString)) {
152
- throw new Error("Source content does not match proposed change (even with normalized whitespace)");
153
- }
154
- }
155
- }
156
- return true;
157
- }
158
- catch {
159
- return false;
160
- }
161
- }
162
- async function triggerBuildFix(cwd, taskId, buildErrors, buildCommand, originalChanges, resolvedLassoKey, agentConfig, collabConfig, socket, taskControllers, taskSnapshots, buildErrorContext) {
163
- const MAX_RETRIES = 3;
164
- const context = buildErrorContext.get(taskId);
165
- if (!context)
166
- return;
167
- context.retryCount += 1;
168
- if (context.retryCount > MAX_RETRIES) {
169
- // Max retries reached - revert changes and show error
170
- for (const snapshot of taskSnapshots.get(taskId) || []) {
171
- if (snapshot.content === null) {
172
- if (node_fs_1.default.existsSync(snapshot.filePath))
173
- node_fs_1.default.unlinkSync(snapshot.filePath);
174
- }
175
- else {
176
- node_fs_1.default.writeFileSync(snapshot.filePath, snapshot.content);
177
- }
178
- }
179
- taskSnapshots.delete(taskId);
180
- buildErrorContext.delete(taskId);
181
- socket.send(JSON.stringify({
182
- type: "agent_status",
183
- taskId,
184
- status: "error",
185
- message: `⚠️ Build validation failed after ${MAX_RETRIES} attempts. The change was reverted. Errors:\n${buildErrors.slice(0, 5).join("\n")}${buildErrors.length > 5 ? `\n... and ${buildErrors.length - 5} more errors` : ""}`
186
- }));
187
- return;
188
- }
189
- // Re-apply the original changes
190
- try {
191
- const planned = new Map();
192
- for (const change of originalChanges) {
193
- const isNew = !change.oldString;
194
- const filePath = resolveProposedFile(cwd, change.filePath, true);
195
- const exists = node_fs_1.default.existsSync(filePath);
196
- const content = exists ? node_fs_1.default.readFileSync(filePath, "utf8") : "";
197
- const prepared = (!exists || isNew)
198
- ? { start: 0, end: content.length, oldString: content, newString: change.newString }
199
- : prepareChange(content, change);
200
- const fileChanges = planned.get(filePath) || [];
201
- if (fileChanges.some((item) => prepared.start < item.end && item.start < prepared.end)) {
202
- throw new Error(`Could not safely apply ${change.filePath}. Proposed changes overlap.`);
203
- }
204
- fileChanges.push({ filePath, content, exists, ...prepared });
205
- planned.set(filePath, fileChanges);
206
- }
207
- for (const [filePath, changes] of planned) {
208
- const exists = changes[0].exists;
209
- const content = changes[0].content;
210
- const nextContent = exists
211
- ? [...changes]
212
- .sort((a, b) => b.start - a.start)
213
- .reduce((value, change) => value.slice(0, change.start) + change.newString + value.slice(change.end), content)
214
- : changes[0].newString;
215
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
216
- node_fs_1.default.writeFileSync(filePath, nextContent);
217
- }
218
- }
219
- catch (applyError) {
220
- socket.send(JSON.stringify({
221
- type: "agent_status",
222
- taskId,
223
- status: "error",
224
- message: `Failed to re-apply original changes: ${applyError instanceof Error ? applyError.message : "Unknown error"}`
225
- }));
226
- return;
227
- }
228
- // Run agent to fix build errors
229
- const { runOneShotAgent } = await import("./oneshot.js");
230
- const fixPrompt = `The previous change caused build errors. Please fix them. Build command: ${buildCommand}\n\nBuild errors:\n${buildErrors.slice(0, 10).join("\n")}${buildErrors.length > 10 ? `\n... and ${buildErrors.length - 10} more errors` : ""}\n\nFix these errors by making minimal changes to the files. Attempt ${context.retryCount} of ${MAX_RETRIES}.`;
231
- const controller = new AbortController();
232
- taskControllers.set(taskId, controller);
233
- socket.send(JSON.stringify({ type: "agent_status", taskId, status: "thinking", message: `Fixing build errors (attempt ${context.retryCount}/${MAX_RETRIES})...` }));
234
- try {
235
- const result = await runOneShotAgent(cwd, fixPrompt, "project", { provider: "google", model: "gemini-2.5-flash", lassoKey: resolvedLassoKey, serverUrl: "https://api.lasso.byorello.space" }, controller.signal, (status, message, detail) => {
236
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
237
- socket.send(JSON.stringify({ type: "agent_status", taskId, status, message, detail }));
238
- }
239
- }, (prompt) => {
240
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
241
- socket.send(JSON.stringify({ type: "agent_prompt", taskId, prompt }));
242
- }
243
- }, undefined, "https://api.lasso.byorello.space", resolvedLassoKey || agentConfig?.apiKey || collabConfig?.apiKey, taskId, undefined);
244
- if (result.ok && result.changes && result.changes.length > 0) {
245
- // Apply the fix
246
- const snapshots = [];
247
- const planned = new Map();
248
- for (const change of result.changes) {
249
- const isNew = !change.oldString;
250
- const filePath = resolveProposedFile(cwd, change.filePath, true);
251
- const exists = node_fs_1.default.existsSync(filePath);
252
- const content = exists ? node_fs_1.default.readFileSync(filePath, "utf8") : "";
253
- const prepared = (!exists || isNew)
254
- ? { start: 0, end: content.length, oldString: content, newString: change.newString }
255
- : prepareChange(content, change);
256
- const fileChanges = planned.get(filePath) || [];
257
- if (fileChanges.some((item) => prepared.start < item.end && item.start < prepared.end)) {
258
- throw new Error(`Could not safely apply ${change.filePath}. Proposed changes overlap.`);
259
- }
260
- fileChanges.push({ filePath, content, exists, ...prepared });
261
- planned.set(filePath, fileChanges);
262
- }
263
- taskSnapshots.set(taskId, snapshots);
264
- for (const [filePath, changes] of planned) {
265
- const exists = changes[0].exists;
266
- const content = changes[0].content;
267
- snapshots.push({ filePath, content: exists ? content : null });
268
- const nextContent = exists
269
- ? [...changes]
270
- .sort((a, b) => b.start - a.start)
271
- .reduce((value, change) => value.slice(0, change.start) + change.newString + value.slice(change.end), content)
272
- : changes[0].newString;
273
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
274
- node_fs_1.default.writeFileSync(filePath, nextContent);
275
- }
276
- // Validate the fix
277
- const validation = await validateBuild(cwd);
278
- if (validation.passed) {
279
- // Build fixed!
280
- buildErrorContext.delete(taskId);
281
- socket.send(JSON.stringify({
282
- type: "applied",
283
- taskId,
284
- message: `Build errors fixed after ${context.retryCount} attempt${context.retryCount > 1 ? "s" : ""}. ${result.changes.length} file${result.changes.length === 1 ? "" : "s"} updated.`,
285
- validation
286
- }));
287
- }
288
- else {
289
- // Still failing - trigger another retry
290
- socket.send(JSON.stringify({
291
- type: "agent_status",
292
- taskId,
293
- status: "working",
294
- message: `⚠️ Build still failing. Retrying...`,
295
- detail: `Build command: ${validation.command}\nErrors:\n${validation.errors.slice(0, 5).join("\n")}${validation.errors.length > 5 ? `\n... and ${validation.errors.length - 5} more errors` : ""}`
296
- }));
297
- // Update context with new errors
298
- buildErrorContext.set(taskId, {
299
- errors: validation.errors,
300
- command: validation.command,
301
- originalChanges: originalChanges,
302
- retryCount: context.retryCount
303
- });
304
- await triggerBuildFix(cwd, taskId, validation.errors, validation.command, originalChanges, resolvedLassoKey, agentConfig, collabConfig, socket, taskControllers, taskSnapshots, buildErrorContext);
305
- }
306
- }
307
- else {
308
- // Agent failed to produce a fix
309
- socket.send(JSON.stringify({ type: "agent_status", taskId, status: "error", message: result.error || "Failed to fix build errors" }));
310
- }
311
- }
312
- catch (error) {
313
- socket.send(JSON.stringify({ type: "agent_status", taskId, status: "error", message: error instanceof Error ? error.message : "Build fix encountered an error" }));
314
- }
315
- finally {
316
- if (taskControllers.get(taskId) === controller)
317
- taskControllers.delete(taskId);
318
- }
319
- }
320
- async function validateBuild(cwd) {
321
- const pkgPath = node_path_1.default.join(cwd, "package.json");
322
- if (!node_fs_1.default.existsSync(pkgPath)) {
323
- return { passed: true, errors: [], command: "none" };
324
- }
325
- const pkg = JSON.parse(node_fs_1.default.readFileSync(pkgPath, "utf8"));
326
- const scripts = pkg.scripts || {};
327
- // Determine which build command to run
328
- let buildCommand = "";
329
- if (scripts.build) {
330
- buildCommand = "build";
331
- }
332
- else if (scripts["build:cli"] || scripts["build:overlay"]) {
333
- // Monorepo with separate build scripts
334
- buildCommand = scripts["build:cli"] ? "build:cli" : "build:overlay";
335
- }
336
- else if (scripts.compile) {
337
- buildCommand = "compile";
338
- }
339
- else if (scripts.check) {
340
- buildCommand = "check";
341
- }
342
- else if (scripts.lint) {
343
- buildCommand = "lint";
344
- }
345
- else if (scripts.typecheck) {
346
- buildCommand = "typecheck";
347
- }
348
- else if (node_fs_1.default.existsSync(node_path_1.default.join(cwd, "tsconfig.json"))) {
349
- buildCommand = "tsc"; // Run TypeScript compiler directly
350
- }
351
- else {
352
- // No suitable build command found
353
- return { passed: true, errors: [], command: "none" };
354
- }
355
- try {
356
- const result = await execFileAsync(buildCommand === "tsc" ? "tsc" : "npm", buildCommand === "tsc" ? [] : ["run", buildCommand], { cwd, maxBuffer: 1024 * 1024 * 10, timeout: 60000 });
357
- const output = result.stdout + result.stderr;
358
- const errors = [];
359
- // Parse TypeScript errors
360
- if (output.includes("error TS")) {
361
- const tsErrors = output.match(/error TS\d+.*$/gm) || [];
362
- errors.push(...tsErrors);
363
- }
364
- // Parse general build errors
365
- if (output.includes("Error:") || output.includes("error:")) {
366
- const lines = output.split("\n");
367
- for (const line of lines) {
368
- if (line.includes("Error:") || line.includes("error:")) {
369
- errors.push(line.trim());
370
- }
371
- }
372
- }
373
- return {
374
- passed: errors.length === 0,
375
- errors,
376
- command: buildCommand === "tsc" ? "tsc" : `npm run ${buildCommand}`
377
- };
378
- }
379
- catch (error) {
380
- const stderr = error.stderr || "";
381
- const errors = [];
382
- if (stderr.includes("error TS")) {
383
- const tsErrors = stderr.match(/error TS\d+.*$/gm) || [];
384
- errors.push(...tsErrors);
385
- }
386
- if (stderr.includes("Error:") || stderr.includes("error:")) {
387
- const lines = stderr.split("\n");
388
- for (const line of lines) {
389
- if (line.includes("Error:") || line.includes("error:")) {
390
- errors.push(line.trim());
391
- }
392
- }
393
- }
394
- // If no specific errors found, include the full stderr
395
- if (errors.length === 0 && stderr) {
396
- errors.push(stderr);
397
- }
398
- return {
399
- passed: false,
400
- errors,
401
- command: buildCommand === "tsc" ? "tsc" : `npm run ${buildCommand}`
402
- };
403
- }
404
- }
405
- function readEnvFile(cwd, filename) {
53
+ function readEnvFile(filename, cwd) {
406
54
  try {
407
55
  return node_fs_1.default.readFileSync(node_path_1.default.join(cwd, filename), "utf8").split(/\r?\n/).reduce((values, line) => {
408
56
  const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
@@ -419,15 +67,83 @@ function readEnvFile(cwd, filename) {
419
67
  return {};
420
68
  }
421
69
  }
70
+ function detectProjectFramework(cwd) {
71
+ try {
72
+ const pkg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, "package.json"), "utf8"));
73
+ if (pkg.dependencies?.next || pkg.devDependencies?.next)
74
+ return "next";
75
+ if (pkg.dependencies?.react || pkg.devDependencies?.react)
76
+ return "react";
77
+ if (pkg.dependencies?.vue || pkg.devDependencies?.vue)
78
+ return "vue";
79
+ if (pkg.dependencies?.svelte || pkg.devDependencies?.svelte)
80
+ return "svelte";
81
+ if (pkg.dependencies?.solid || pkg.devDependencies?.solid)
82
+ return "solid";
83
+ }
84
+ catch { }
85
+ return "unknown";
86
+ }
87
+ // Helper to extract review changes from a runtime session.
88
+ // Prefers the authoritative guard diff (session.metadata.pendingChanges) and
89
+ // falls back to normalizing recorded actions (accepting filePath OR path).
90
+ function extractChangesFromSession(session) {
91
+ if (!session)
92
+ return [];
93
+ const metadata = session.metadata;
94
+ const pending = metadata?.pendingChanges;
95
+ if (Array.isArray(pending) && pending.length > 0) {
96
+ return pending.map((change) => ({
97
+ filePath: change.filePath,
98
+ oldString: change.oldString ?? "",
99
+ newString: change.newString ?? "",
100
+ operation: change.operation || (change.oldString === "" && change.newString === "" ? "deleted" : change.oldString === "" ? "created" : "modified"),
101
+ }));
102
+ }
103
+ const changes = [];
104
+ for (const action of session.actions || []) {
105
+ const data = (action.data || {});
106
+ const filePath = (data.filePath || data.path);
107
+ if (!filePath)
108
+ continue;
109
+ if (action.type === "edit_file") {
110
+ changes.push({
111
+ filePath,
112
+ oldString: data.oldString ?? "",
113
+ newString: data.newString ?? "",
114
+ operation: "modified",
115
+ });
116
+ }
117
+ else if (action.type === "create_file") {
118
+ changes.push({
119
+ filePath,
120
+ oldString: "",
121
+ newString: data.content ?? "",
122
+ operation: "created",
123
+ });
124
+ }
125
+ else if (action.type === "delete_file") {
126
+ changes.push({
127
+ filePath,
128
+ oldString: data.oldContent ?? "(deleted)",
129
+ newString: "",
130
+ operation: "deleted",
131
+ });
132
+ }
133
+ }
134
+ return changes;
135
+ }
422
136
  async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = exports.DEFAULT_BRIDGE_PORT) {
423
- const bridgeServer = node_http_1.default.createServer(); // dedicated, empty HTTP server
137
+ const bridgeServer = node_http_1.default.createServer();
424
138
  const wss = new ws_1.WebSocketServer({ server: bridgeServer });
425
139
  let overlaySocket = null;
426
140
  const taskSnapshots = new Map();
427
- const editRequests = new Map();
428
- const editConfigs = new Map();
429
- const reviewRefreshAttempts = new Map();
430
- const buildErrorContext = new Map();
141
+ const activeSessions = new Map();
142
+ // Per-task runtime facade: LocalRuntime for local coding agents (executed
143
+ // in this process), RuntimeClient for hosted models (via Lasso server).
144
+ const taskFacades = new Map();
145
+ const taskControllers = new Map();
146
+ const correctiveAttempts = new Map();
431
147
  const envRoots = [cwd, node_path_1.default.join(cwd, "web"), node_path_1.default.join(cwd, "server")];
432
148
  const fileEnv = envRoots.reduce((values, root) => ({
433
149
  ...values,
@@ -435,7 +151,6 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
435
151
  ...readEnvFile(root, ".env.local"),
436
152
  ...readEnvFile(root, ".env.production"),
437
153
  }), {});
438
- // Prioritize CLI auth credentials over env vars
439
154
  const cliCredentials = (0, auth_1.loadCredentials)();
440
155
  const resolvedLassoKey = cliCredentials?.apiKey ||
441
156
  (0, project_1.resolveLassoApiKey)(fileEnv) ||
@@ -447,164 +162,62 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
447
162
  fileEnv.NEXT_LASSO_API_KEY ||
448
163
  "";
449
164
  const lassoKeyConfigured = Boolean(resolvedLassoKey);
450
- // Task queue for concurrent execution
451
- const MAX_CONCURRENT_TASKS = 3;
452
- const taskQueue = [];
453
- const runningTasks = new Set();
454
- const taskPriority = new Map(); // Lower number = higher priority
455
- // Will be set when WebSocket connects
456
- let currentSocket = null;
457
- function processTaskQueue() {
458
- if (runningTasks.size >= MAX_CONCURRENT_TASKS || taskQueue.length === 0 || !currentSocket)
459
- return;
460
- const socket = currentSocket; // Capture non-null reference
461
- taskQueue.sort((a, b) => {
462
- const priorityA = taskPriority.get(a.taskId) ?? 999;
463
- const priorityB = taskPriority.get(b.taskId) ?? 999;
464
- return priorityA - priorityB;
465
- });
466
- const task = taskQueue.shift();
467
- if (!task)
468
- return;
469
- runningTasks.add(task.taskId);
470
- if (task.message.type === "edit") {
471
- const request = task.message;
472
- taskControllers.get(request.taskId)?.abort();
473
- const controller = new AbortController();
474
- taskControllers.set(request.taskId, controller);
475
- if (socket.readyState === socket.OPEN) {
476
- socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "working", message: "Working…" }));
477
- }
478
- void (0, agent_1.proposeChanges)(cwd, request, task.config, controller.signal, (message, detail, level) => {
479
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
480
- socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: level === "error" ? "error" : "working", message, detail }));
481
- }
482
- }, (prompt) => {
483
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
484
- socket.send(JSON.stringify({ type: "agent_prompt", taskId: request.taskId, prompt }));
485
- }
486
- })
487
- .then((proposal) => {
488
- if (controller.signal.aborted || socket.readyState !== socket.OPEN)
489
- return;
490
- if (!proposalMatchesCurrentSource(cwd, proposal.changes)) {
491
- const refreshAttempts = reviewRefreshAttempts.get(request.taskId) || 0;
492
- const MAX_REFRESH_ATTEMPTS = 3;
493
- if (refreshAttempts < MAX_REFRESH_ATTEMPTS) {
494
- reviewRefreshAttempts.set(request.taskId, refreshAttempts + 1);
495
- setTimeout(() => {
496
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
497
- enqueueTask(request.taskId, request, task.config, 0);
498
- socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "working", message: `The source changed while the proposal was being prepared. Refreshing the review… (${refreshAttempts + 1}/${MAX_REFRESH_ATTEMPTS})` }));
499
- }
500
- }, 500 * (refreshAttempts + 1));
501
- }
502
- else {
503
- socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "error", message: "The source is still changing after multiple refresh attempts. Stop the dev-server edit or try the request again." }));
504
- }
505
- return;
506
- }
507
- socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "review", message: proposal.summary, changes: proposal.changes }));
508
- })
509
- .catch((error) => {
510
- if (socket.readyState !== socket.OPEN)
511
- return;
512
- if (controller.signal.aborted) {
513
- socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "stopped", message: "Agent stopped." }));
514
- return;
515
- }
516
- socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "error", message: error instanceof Error ? error.message : "The agent could not prepare a change." }));
517
- })
518
- .finally(() => {
519
- if (taskControllers.get(request.taskId) === controller)
520
- taskControllers.delete(request.taskId);
521
- runningTasks.delete(request.taskId);
522
- taskPriority.delete(request.taskId);
523
- processTaskQueue();
524
- });
525
- }
526
- else if (task.message.type === "ask") {
527
- const controller = new AbortController();
528
- taskControllers.set(task.taskId, controller);
529
- socket.send(JSON.stringify({ type: "agent_status", taskId: task.taskId, status: "working", message: "Working…" }));
530
- void (0, agent_1.answerQuestion)(cwd, {
531
- taskId: task.taskId,
532
- question: task.message.question,
533
- context: task.message.context,
534
- element: task.message.element,
535
- messages: task.message.messages,
536
- }, task.config, controller.signal, (message, detail, level) => {
537
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
538
- socket.send(JSON.stringify({ type: "agent_status", taskId: task.taskId, status: level === "error" ? "error" : "working", message, detail }));
539
- }
540
- }, (prompt) => {
541
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
542
- socket.send(JSON.stringify({ type: "agent_prompt", taskId: task.taskId, prompt }));
543
- }
544
- }).then((answer) => {
545
- if (!controller.signal.aborted)
546
- socket.send(JSON.stringify({ type: "assistant_message", taskId: task.taskId, message: answer }));
547
- }).catch((error) => {
548
- if (!controller.signal.aborted)
549
- socket.send(JSON.stringify({ type: "agent_status", taskId: task.taskId, status: "error", message: error instanceof Error ? error.message : "The agent could not answer." }));
550
- }).finally(() => {
551
- if (taskControllers.get(task.taskId) === controller)
552
- taskControllers.delete(task.taskId);
553
- runningTasks.delete(task.taskId);
554
- taskPriority.delete(task.taskId);
555
- processTaskQueue();
556
- });
557
- }
558
- }
559
- function enqueueTask(taskId, message, config, priority = 0) {
560
- taskPriority.set(taskId, priority);
561
- taskQueue.push({ taskId, message, config });
562
- processTaskQueue();
563
- }
564
- // Fetch workspace configuration from server (dashboard-configured providers)
565
- let workspaceConfig = null;
566
- if (lassoKeyConfigured) {
567
- workspaceConfig = await (0, project_1.resolveWorkspaceConfig)(fileEnv);
568
- }
569
- // Fetch available models from server
570
- let dynamicModels = { anthropic: [], openai: [], google: [], nvidia: [] };
165
+ const serverUrl = (0, auth_1.serverUrlFrom)(fileEnv) || "https://api.lasso.byorello.space";
166
+ const runtimeClient = new runtime_client_1.RuntimeClient(serverUrl, resolvedLassoKey || undefined);
167
+ // Load project info
168
+ const workspaceConfig = await (0, project_1.resolveWorkspaceConfig)(fileEnv);
169
+ const projectId = workspaceConfig?.workspaceId || "";
170
+ const framework = detectProjectFramework(cwd);
171
+ // Fetch models
172
+ let models = [];
571
173
  try {
572
- dynamicModels = await (0, project_1.fetchModels)(fileEnv);
174
+ const modelsResponse = await (0, project_1.fetchModels)(fileEnv);
175
+ const allModels = [
176
+ ...(modelsResponse.anthropic || []),
177
+ ...(modelsResponse.openai || []),
178
+ ...(modelsResponse.google || []),
179
+ ...(modelsResponse.nvidia || []),
180
+ ];
181
+ models = allModels.map((m) => ({
182
+ id: m.id,
183
+ label: m.name || m.id,
184
+ provider: m.provider,
185
+ locked: false,
186
+ }));
573
187
  }
574
- catch (error) {
575
- console.warn('[Lasso] Failed to fetch dynamic models, using fallback');
188
+ catch {
189
+ models = [
190
+ { id: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet", provider: "anthropic", locked: false },
191
+ { id: "gpt-4o", label: "GPT-4o", provider: "openai", locked: false },
192
+ { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash", provider: "google", locked: false },
193
+ ];
576
194
  }
577
- const localProviderKeys = {
578
- google: process.env.GOOGLE_GENERATIVE_AI_API_KEY || fileEnv.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY || fileEnv.GEMINI_API_KEY,
579
- openai: process.env.OPENAI_API_KEY || fileEnv.OPENAI_API_KEY,
580
- anthropic: process.env.ANTHROPIC_API_KEY || fileEnv.ANTHROPIC_API_KEY,
581
- nvidia: process.env.NVIDIA_API_KEY || fileEnv.NVIDIA_API_KEY,
582
- ollama: process.env.OLLAMA_BASE_URL || fileEnv.OLLAMA_BASE_URL || process.env.OLLAMA_MODEL || fileEnv.OLLAMA_MODEL ? "ollama" : undefined,
195
+ // Detect local coding agents (installed + authenticated) on this machine
196
+ const detectedBackends = await (0, backends_1.detectBackends)();
197
+ const BACKEND_LABELS = {
198
+ claude_code: "Claude Code",
199
+ opencode: "OpenCode",
200
+ codex_cli: "Codex CLI",
201
+ cursor: "Cursor",
583
202
  };
584
- let agentConfig = (() => {
585
- const provider = (process.env.LASSO_AGENT_PROVIDER || fileEnv.LASSO_AGENT_PROVIDER || process.env.AI_PROVIDER || fileEnv.AI_PROVIDER || "").toLowerCase();
586
- const selectedProvider = provider === "google" || provider === "gemini" ? "google" : provider === "openai" ? "openai" : provider === "ollama" ? "ollama" : provider === "anthropic" ? "anthropic" : provider === "nvidia" ? "nvidia" : localProviderKeys.ollama ? "ollama" : localProviderKeys.nvidia ? "nvidia" : localProviderKeys.google ? "google" : localProviderKeys.openai ? "openai" : localProviderKeys.anthropic ? "anthropic" : (cliCredentials ? "anthropic" : "google");
587
- const apiKey = selectedProvider === "ollama" ? "ollama" : localProviderKeys[selectedProvider];
588
- // If user has CLI auth, use Lasso gateway by default
589
- if (!apiKey && !lassoKeyConfigured)
590
- return null;
591
- return {
592
- provider: selectedProvider,
593
- apiKey: apiKey || resolvedLassoKey,
594
- model: process.env.LASSO_AGENT_MODEL || fileEnv.LASSO_AGENT_MODEL || process.env.AI_MODEL || fileEnv.AI_MODEL,
595
- baseUrl: selectedProvider === "nvidia" ? "https://integrate.api.nvidia.com/v1" : process.env.OLLAMA_BASE_URL || fileEnv.OLLAMA_BASE_URL ? `${(process.env.OLLAMA_BASE_URL || fileEnv.OLLAMA_BASE_URL || "http://localhost:11434/api").replace(/\/api\/?$/, "")}/v1` : undefined,
596
- };
597
- })();
598
- let activeAgentController = null;
599
- const taskControllers = new Map();
600
- let localAgents = new Set();
203
+ for (const backend of detectedBackends) {
204
+ if (!backend.installed)
205
+ continue;
206
+ for (const option of backend.models) {
207
+ models.push({
208
+ id: option.id,
209
+ label: `${BACKEND_LABELS[backend.provider]} · ${option.label}`,
210
+ provider: backend.provider,
211
+ locked: !backend.authenticated,
212
+ lockedReason: backend.authenticated ? undefined : backend.error || `${BACKEND_LABELS[backend.provider]} is not authenticated.`,
213
+ });
214
+ }
215
+ }
601
216
  const pageIgnoredDirs = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo", "out", "coverage", ".vercel", ".cache"]);
602
217
  const resolveInsideProject = (root, relative) => {
603
218
  const target = node_path_1.default.resolve(root, relative && relative !== "." ? relative : "");
604
219
  return target === root || target.startsWith(`${root}${node_path_1.default.sep}`) ? target : null;
605
220
  };
606
- // Directories are returned first so the overlay can render an explorer-style
607
- // tree that mirrors how VS Code orders folders above files.
608
221
  const pageEntries = (root, relative) => {
609
222
  const directory = resolveInsideProject(root, relative);
610
223
  if (!directory)
@@ -621,730 +234,767 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
621
234
  });
622
235
  }
623
236
  }
624
- catch {
625
- // Folders that cannot be read are treated as empty.
626
- }
237
+ catch { }
627
238
  return entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1);
628
239
  };
629
240
  bridgeServer.on("request", (req, res) => {
630
241
  if (req.method !== "POST" || req.url?.split("?", 1)[0] !== "/__lasso/bridge/restart")
631
242
  return;
632
- activeAgentController?.abort();
633
243
  for (const controller of taskControllers.values())
634
244
  controller.abort();
635
245
  taskControllers.clear();
636
- activeAgentController = null;
637
246
  for (const socket of wss.clients)
638
247
  socket.close(1000, "Bridge restarted by the CLI");
639
248
  res.writeHead(200, { "content-type": "application/json" });
640
249
  res.end(JSON.stringify({ ok: true }));
641
250
  });
642
- async function localModels() {
643
- const base = process.env.OLLAMA_BASE_URL || fileEnv.OLLAMA_BASE_URL || "http://localhost:11434/api";
644
- try {
645
- const response = await fetch(`${base.replace(/\/$/, "")}/tags`);
646
- if (!response.ok)
647
- return [];
648
- const payload = await response.json();
649
- return (payload.models || []).filter((model) => model.name).map((model) => ({ id: model.name, label: `${model.name} · Local`, provider: "ollama" }));
650
- }
651
- catch {
652
- return [];
251
+ // Shared runtime event handling for edit/ask, oneshot, and corrective runs.
252
+ // review=true opens the diff review on agent_completed (edit/ask flow).
253
+ function handleRuntimeEvent(event, socket, taskId, session, instruction, review) {
254
+ if (taskControllers.get(taskId)?.signal.aborted)
255
+ return;
256
+ switch (event.type) {
257
+ case "agent_started":
258
+ case "agent_reasoning":
259
+ socket.send(JSON.stringify({
260
+ type: "agent_status",
261
+ taskId,
262
+ status: "thinking",
263
+ message: event.data.summary || "Working...",
264
+ }));
265
+ break;
266
+ case "tool_started":
267
+ socket.send(JSON.stringify({
268
+ type: "agent_status",
269
+ taskId,
270
+ status: "working",
271
+ message: `Running ${event.data.tool || event.data.actionType || "tool"}...`,
272
+ }));
273
+ break;
274
+ case "tool_completed":
275
+ socket.send(JSON.stringify({
276
+ type: "agent_status",
277
+ taskId,
278
+ status: "working",
279
+ message: "Completed tool",
280
+ }));
281
+ break;
282
+ case "phase_changed": {
283
+ const phase = event.data.phase;
284
+ socket.send(JSON.stringify({
285
+ type: "agent_status",
286
+ taskId,
287
+ status: "working",
288
+ message: `Phase: ${phase}`,
289
+ }));
290
+ break;
291
+ }
292
+ case "verification_started":
293
+ socket.send(JSON.stringify({
294
+ type: "agent_status",
295
+ taskId,
296
+ status: "review",
297
+ message: "Verifying changes...",
298
+ }));
299
+ break;
300
+ case "verification_completed": {
301
+ const passed = event.data.success ?? event.data.passed;
302
+ socket.send(JSON.stringify({
303
+ type: "agent_status",
304
+ taskId,
305
+ status: passed ? "working" : "error",
306
+ message: passed ? "Verification passed" : "Verification failed",
307
+ }));
308
+ break;
309
+ }
310
+ case "agent_completed":
311
+ if (!review)
312
+ break;
313
+ void handleReviewReady(session, socket, taskId, instruction);
314
+ break;
315
+ case "error":
316
+ socket.send(JSON.stringify({
317
+ type: "agent_status",
318
+ taskId,
319
+ status: "error",
320
+ message: event.data.error || "Unknown error",
321
+ }));
322
+ break;
323
+ case "waiting_for_user":
324
+ socket.send(JSON.stringify({
325
+ type: "agent_prompt",
326
+ taskId,
327
+ prompt: {
328
+ message: event.data?.question || "Input required",
329
+ kind: "input",
330
+ },
331
+ }));
332
+ break;
653
333
  }
654
334
  }
655
- async function openCodeModels() {
656
- if (!localAgents.has("opencode"))
657
- return [];
335
+ // The server rolled the workspace back before review, so the current files
336
+ // are the originals: capture them for undo, then open the review panel.
337
+ async function handleReviewReady(session, socket, taskId, instruction) {
658
338
  try {
659
- const result = await execFileAsync("opencode", ["models"], { maxBuffer: 1024 * 1024 });
660
- return result.stdout
661
- .split(/\r?\n/)
662
- .map((line) => line.trim())
663
- .filter((line) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.:@/-]+$/.test(line))
664
- .map((model) => ({ id: `opencode:${model}`, label: `OpenCode · ${model}`, provider: "cli" }));
339
+ const facade = taskFacades.get(taskId) || runtimeClient;
340
+ const finalSession = await facade.getSession(session.id);
341
+ const changes = extractChangesFromSession(finalSession);
342
+ if (changes.length > 0) {
343
+ taskSnapshots.set(taskId, changes.map((change) => {
344
+ const abs = node_path_1.default.join(cwd, change.filePath);
345
+ let content = null;
346
+ try {
347
+ content = node_fs_1.default.readFileSync(abs, "utf8");
348
+ }
349
+ catch {
350
+ content = null; // did not exist before the task (created file)
351
+ }
352
+ return { filePath: change.filePath, content };
353
+ }));
354
+ (0, snapshot_1.createSnapshot)(cwd, taskId, instruction, changes.map((change) => change.filePath), undefined, session.id);
355
+ }
356
+ socket.send(JSON.stringify({
357
+ type: "agent_status",
358
+ taskId,
359
+ status: "review",
360
+ message: changes.length > 0 ? "Review changes" : "No file changes were produced",
361
+ changes,
362
+ }));
665
363
  }
666
- catch {
667
- return [];
364
+ catch (error) {
365
+ socket.send(JSON.stringify({
366
+ type: "agent_status",
367
+ taskId,
368
+ status: "error",
369
+ message: error instanceof Error ? error.message : "Failed to load review changes",
370
+ }));
668
371
  }
669
372
  }
670
- async function cursorModels() {
671
- if (!localAgents.has("cursor"))
672
- return [];
373
+ // Handler for edit/ask - uses runtime
374
+ async function handleEditOrAsk(message, socket, taskId, instruction, model, provider, context, element, messages) {
375
+ taskControllers.get(taskId)?.abort();
376
+ const controller = new AbortController();
377
+ taskControllers.set(taskId, controller);
378
+ socket.send(JSON.stringify({
379
+ type: "agent_status",
380
+ taskId,
381
+ status: "thinking",
382
+ message: "Starting...",
383
+ }));
673
384
  try {
674
- const result = await execFileAsync("cursor", ["model", "list"], { maxBuffer: 1024 * 1024 });
675
- return result.stdout
676
- .split(/\r?\n/)
677
- .map((line) => line.trim())
678
- .filter((line) => line && !line.startsWith("Available models"))
679
- .map((line) => {
680
- const parts = line.split(/\s+/);
681
- const id = parts[0]?.trim();
682
- const name = parts.slice(1).join(" ").trim();
683
- return id ? { id: `cursor:${id}`, label: `Cursor · ${name}`, provider: "cli" } : null;
684
- })
685
- .filter((model) => model !== null);
385
+ const isLocalAgent = ["claude_code", "opencode", "codex_cli", "cursor"].includes(provider || "");
386
+ // Overlay model option ids are prefixed ("claude_code:sonnet") — the
387
+ // agent CLIs need the unprefixed/normalized form.
388
+ const backendModelId = (0, backends_1.modelIdForBackend)(model || "", provider || "");
389
+ const sessionInput = {
390
+ userPrompt: instruction,
391
+ project: {
392
+ projectId,
393
+ framework,
394
+ hasTypeScript: node_fs_1.default.existsSync(node_path_1.default.join(cwd, "tsconfig.json")),
395
+ hasReact: node_fs_1.default.existsSync(node_path_1.default.join(cwd, "package.json")) &&
396
+ node_fs_1.default.readFileSync(node_path_1.default.join(cwd, "package.json"), "utf8").includes("react"),
397
+ },
398
+ model: {
399
+ provider: (provider || "anthropic"),
400
+ id: model || "claude-3-5-sonnet-20241022",
401
+ },
402
+ selection: context ? {
403
+ element: element ? {
404
+ tag: element.tag,
405
+ text: element.label,
406
+ source: element.sourceHint,
407
+ attributes: context.attributes,
408
+ } : undefined,
409
+ viewport: context?.viewport,
410
+ route: context?.position?.route,
411
+ screenshot: context?.screenshots?.full,
412
+ } : undefined,
413
+ backendConfig: isLocalAgent ? {
414
+ type: "local_agent",
415
+ provider: provider,
416
+ modelId: backendModelId,
417
+ projectPath: cwd,
418
+ } : undefined,
419
+ };
420
+ const facade = isLocalAgent ? (0, local_runtime_1.getLocalRuntime)() : runtimeClient;
421
+ const session = await facade.createSession(sessionInput);
422
+ activeSessions.set(taskId, session);
423
+ taskFacades.set(taskId, facade);
424
+ // Originals are captured when the review is ready (the workspace was
425
+ // already rolled back at that point).
426
+ await facade.runSession(session.id, cwd, (event) => {
427
+ handleRuntimeEvent(event, socket, taskId, session, `Runtime session: ${instruction}`, true);
428
+ }, controller.signal);
686
429
  }
687
- catch {
688
- return [];
430
+ catch (error) {
431
+ socket.send(JSON.stringify({
432
+ type: "agent_status",
433
+ taskId,
434
+ status: "error",
435
+ message: error instanceof Error ? error.message : "Unknown error",
436
+ }));
689
437
  }
690
438
  }
691
- const availableModels = async (collabConfig = null, workspaceConfig = null) => {
692
- localAgents = await (0, agent_1.detectLocalAgents)();
693
- const locals = await localModels();
694
- const discoveredOpenCodeModels = await openCodeModels();
695
- const discoveredCursorModels = await cursorModels();
696
- if (locals.length && !agentConfig) {
697
- const base = process.env.OLLAMA_BASE_URL || fileEnv.OLLAMA_BASE_URL || "http://localhost:11434/api";
698
- agentConfig = { provider: "ollama", apiKey: "ollama", model: process.env.OLLAMA_MODEL || fileEnv.OLLAMA_MODEL, baseUrl: `${base.replace(/\/api\/?$/, "")}/v1` };
699
- }
700
- // Check if user has CLI auth credentials (lasso auth login)
701
- const cliCredentials = (0, auth_1.loadCredentials)();
702
- const hasCliAuth = Boolean(cliCredentials?.apiKey);
703
- // Prioritize workspace config (dashboard) over project config
704
- // Workspace config is fetched independently of project config
705
- const workspacePlan = (workspaceConfig?.plan || "free").toLowerCase();
706
- const workspaceProviders = workspaceConfig?.configuredProviders || [];
707
- const projectPlan = (collabConfig?.plan || "free").toLowerCase();
708
- const projectProviders = collabConfig?.configuredProviders || [];
709
- // Use the highest-tier plan available
710
- const isPaidPlan = workspacePlan !== "free" || projectPlan !== "free";
711
- // Use workspace providers if available, otherwise fall back to project providers
712
- const configuredProviders = workspaceProviders.length > 0 ? workspaceProviders : projectProviders;
713
- // Build model list from dynamic server fetch, falling back to hardcoded models
714
- const allModels = [
715
- // Dynamic models from server API
716
- ...dynamicModels.anthropic.map(m => ({ id: m.id, label: m.name, provider: m.provider })),
717
- ...dynamicModels.openai.map(m => ({ id: m.id, label: m.name, provider: m.provider })),
718
- ...dynamicModels.google.map(m => ({ id: m.id, label: m.name, provider: m.provider })),
719
- ...dynamicModels.nvidia.map(m => ({ id: m.id, label: m.name, provider: m.provider })),
720
- // Fallback hardcoded models (only if dynamic fetch failed)
721
- ...(dynamicModels.anthropic.length === 0 ? [
722
- { id: "claude-opus-4-5-20251101", label: "Claude Opus 4.5", provider: "anthropic" },
723
- { id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5", provider: "anthropic" },
724
- { id: "claude-haiku-4-5-20251015", label: "Claude Haiku 4.5", provider: "anthropic" },
725
- ] : []),
726
- ...(dynamicModels.openai.length === 0 ? [
727
- { id: "gpt-5.1", label: "GPT-5.1", provider: "openai" },
728
- { id: "gpt-5.1-codex", label: "GPT-5.1 Codex", provider: "openai" },
729
- { id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini", provider: "openai" },
730
- { id: "o3", label: "OpenAI o3", provider: "openai" },
731
- { id: "o3-mini", label: "OpenAI o3-mini", provider: "openai" },
732
- ] : []),
733
- ...(dynamicModels.google.length === 0 ? [
734
- { id: "gemini-3-pro-preview", label: "Gemini 3 Pro", provider: "google" },
735
- { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", provider: "google" },
736
- { id: "gemini-2.5-flash-preview-09-2025", label: "Gemini 2.5 Flash", provider: "google" },
737
- { id: "gemini-2.5-flash-lite-preview-09-2025", label: "Gemini 2.5 Flash Lite", provider: "google" },
738
- ] : []),
739
- ...(dynamicModels.nvidia.length === 0 ? [
740
- { id: "nvidia/llama-3.3-nemotron-super-49b-v1", label: "Llama 3.3 Nemotron Super 49B", provider: "nvidia" },
741
- { id: "deepseek-ai/deepseek-r1-distill-llama-70b", label: "DeepSeek R1 Distill Llama 70B", provider: "nvidia" },
742
- { id: "deepseek-ai/deepseek-r1-distill-llama-8b", label: "DeepSeek R1 Distill Llama 8B", provider: "nvidia" },
743
- { id: "google/gemma-3-1b-it", label: "Gemma 3 1B Instruct", provider: "nvidia" },
744
- { id: "openai/gpt-oss-120b", label: "GPT-OSS 120B", provider: "nvidia" },
745
- ] : []),
746
- // Local CLI agents (always included)
747
- ...(localAgents.has("claude-code") ? [
748
- { id: "claude-code:sonnet", label: "Claude Code · Sonnet 4.5", provider: "cli" },
749
- { id: "claude-code:opus", label: "Claude Code · Opus 4.5", provider: "cli" },
750
- { id: "claude-code:haiku", label: "Claude Code · Haiku 4.5", provider: "cli" },
751
- ] : []),
752
- ...(localAgents.has("codex") ? [
753
- { id: "codex:gpt-5.1", label: "Codex · GPT-5.1", provider: "cli" },
754
- { id: "codex:gpt-5.1-codex", label: "Codex · GPT-5.1-Codex", provider: "cli" },
755
- ] : []),
756
- ...(localAgents.has("opencode") ? [
757
- { id: "opencode:opencode/big-pickle", label: "OpenCode · Big Pickle", provider: "cli" },
758
- { id: "opencode:deepseek/deepseek-chat", label: "OpenCode · DeepSeek Chat", provider: "cli" },
759
- { id: "opencode:anthropic/claude-sonnet-4-5", label: "OpenCode · Claude Sonnet 4.5", provider: "cli" },
760
- { id: "opencode:openai/gpt-5.1", label: "OpenCode · GPT-5.1", provider: "cli" },
761
- ] : []),
762
- ...(localAgents.has("cursor") ? [
763
- { id: "cursor:claude-sonnet-4.5", label: "Cursor · Claude Sonnet 4.5", provider: "cli" },
764
- { id: "cursor:claude-opus-4", label: "Cursor · Claude Opus 4", provider: "cli" },
765
- { id: "cursor:claude-3.5-sonnet", label: "Cursor · Claude 3.5 Sonnet", provider: "cli" },
766
- { id: "cursor:gpt-4o", label: "Cursor · GPT-4o", provider: "cli" },
767
- { id: "cursor:gpt-4o-mini", label: "Cursor · GPT-4o mini", provider: "cli" },
768
- ] : []),
769
- ...discoveredOpenCodeModels,
770
- ...discoveredCursorModels,
771
- ...locals,
772
- ];
773
- // Lock models based on provider API key configuration
774
- // Paid plans have all models unlocked
775
- // Free plans only have models unlocked for providers with configured API keys
776
- const providerKeyMapping = {
777
- anthropic: "ANTHROPIC_API_KEY",
778
- openai: "OPENAI_API_KEY",
779
- google: "GOOGLE_GENERATIVE_AI_API_KEY",
780
- nvidia: "NVIDIA_API_KEY",
781
- ollama: "OLLAMA_BASE_URL",
782
- };
783
- return allModels.map((model) => {
784
- if (model.provider === "cli") {
785
- // CLI agents are always unlocked (user manages their own credentials)
786
- return model;
787
- }
788
- if (isPaidPlan) {
789
- // Paid plans have all hosted models unlocked
790
- return model;
791
- }
792
- // Free plans: check if provider is configured
793
- let isConfigured = false;
794
- let lockedReason = "Configure your API key in the dashboard to use this model";
795
- if (hasCliAuth) {
796
- // User has CLI auth: check dashboard-configured providers first
797
- isConfigured = configuredProviders.includes(model.provider);
798
- lockedReason = "Configure this provider in your workspace dashboard to use this model";
799
- }
800
- // If not configured via dashboard, fall back to env-based detection
801
- if (!isConfigured) {
802
- const envKey = providerKeyMapping[model.provider];
803
- isConfigured = Boolean(process.env[envKey]) ||
804
- Boolean(fileEnv[envKey]) ||
805
- Boolean(localProviderKeys[model.provider]);
806
- if (!isConfigured) {
807
- lockedReason = hasCliAuth
808
- ? "Configure this provider in your workspace dashboard, or set the provider API key in your .env file"
809
- : "Run `lasso auth login` to configure providers in your dashboard, or set the provider API key in your .env file";
810
- }
811
- else {
812
- lockedReason = "";
813
- }
814
- }
815
- if (!isConfigured) {
816
- return {
817
- ...model,
818
- locked: true,
819
- lockedReason: lockedReason || "Configure your API key to use this model",
820
- };
821
- }
822
- return model;
823
- });
824
- };
825
- wss.on("connection", (socket) => {
826
- overlaySocket = socket;
827
- currentSocket = socket;
828
- console.log(chalk_1.default.green("✓") + " Overlay connected");
829
- const creds = (0, auth_1.loadCredentials)();
830
- const userInfo = creds?.userName
831
- ? { name: creds.userName, email: creds.userEmail }
832
- : (process.env.USER ? { name: process.env.USER } : null);
833
- socket.send(JSON.stringify({ type: "config", apiKeyConfigured: lassoKeyConfigured, agentConfigured: lassoKeyConfigured || Boolean(agentConfig) || localAgents.size > 0, models: [], collab: collabConfig?.registered ? { ...collabConfig, plan: collabConfig.plan, configuredProviders: collabConfig.configuredProviders } : null, user: userInfo }));
834
- void availableModels(collabConfig, workspaceConfig).then((models) => {
835
- if (socket.readyState === socket.OPEN)
836
- socket.send(JSON.stringify({ type: "config", apiKeyConfigured: lassoKeyConfigured, agentConfigured: lassoKeyConfigured || Boolean(agentConfig) || localAgents.size > 0, models, collab: collabConfig?.registered ? { ...collabConfig, plan: collabConfig.plan, configuredProviders: collabConfig.configuredProviders } : null, user: userInfo }));
837
- });
838
- void getGitState(cwd).then((git) => {
839
- if (socket.readyState === socket.OPEN)
840
- socket.send(JSON.stringify({ type: "git_state", git }));
841
- });
842
- socket.on("message", async (raw) => {
843
- const msg = JSON.parse(raw.toString());
844
- if (msg.type === "edit" || msg.type === "ask") {
845
- console.log(`[lasso] received ${msg.type}:`, { model: msg.model, provider: msg.provider, selectionId: msg.context?.selectionId, messages: msg.messages?.length || 0, changes: msg.type === "edit" ? msg.changesHistory?.length || 0 : 0, screenshots: Boolean(msg.context?.screenshots?.full || msg.context?.screenshots?.element) });
846
- }
847
- else {
848
- console.log("[lasso] received from overlay:", msg);
849
- }
850
- if (msg.type === "list_page_folders") {
851
- const requested = msg.path || ".";
852
- const root = node_path_1.default.resolve(cwd);
853
- const directory = resolveInsideProject(root, requested);
854
- if (!directory) {
855
- socket.send(JSON.stringify({ type: "page_folders", path: requested, entries: [], error: "That folder is outside the current project." }));
856
- return;
857
- }
858
- socket.send(JSON.stringify({ type: "page_folders", path: requested, entries: pageEntries(root, requested), project: node_path_1.default.basename(root) }));
859
- return;
860
- }
861
- if (msg.type === "create_page_folder") {
862
- const root = node_path_1.default.resolve(cwd);
863
- const name = node_path_1.default.basename(msg.name || "");
864
- const parent = resolveInsideProject(root, msg.parent || ".");
865
- if (!parent || !name || name !== msg.name || name === "." || name === "..") {
866
- socket.send(JSON.stringify({ type: "page_folder_created", path: msg.name || "", error: "Enter a folder name without slashes or dots." }));
867
- return;
868
- }
869
- const target = node_path_1.default.resolve(parent, name);
870
- if (!target.startsWith(`${root}${node_path_1.default.sep}`)) {
871
- socket.send(JSON.stringify({ type: "page_folder_created", path: name, error: "Choose a folder inside the current project." }));
872
- return;
873
- }
439
+ // Handler for apply - applies approved pending changes, then runs server-side
440
+ // verification. On failure a corrective run is started automatically.
441
+ async function handleApply(message, socket, cwd) {
442
+ const { taskId, changes } = message;
443
+ const session = activeSessions.get(taskId);
444
+ const filePaths = changes.map((c) => c.filePath);
445
+ // The server rolled the workspace back before review, so the disk still
446
+ // holds the originals — capture them now (if the review didn't already).
447
+ if (filePaths.length > 0 && (!taskSnapshots.has(taskId) || (taskSnapshots.get(taskId) || []).length === 0)) {
448
+ taskSnapshots.set(taskId, changes.map((change) => {
449
+ const abs = node_path_1.default.join(cwd, change.filePath);
450
+ let content = null;
874
451
  try {
875
- node_fs_1.default.mkdirSync(target);
876
- socket.send(JSON.stringify({ type: "page_folder_created", path: node_path_1.default.relative(root, target) }));
452
+ content = node_fs_1.default.readFileSync(abs, "utf8");
877
453
  }
878
- catch (error) {
879
- socket.send(JSON.stringify({
880
- type: "page_folder_created",
881
- path: node_path_1.default.relative(root, target),
882
- error: error instanceof Error && error.message.includes("EEXIST") ? "A folder with that name already exists." : "The folder could not be created.",
883
- }));
884
- }
885
- return;
886
- }
887
- if (msg.type === "create_page") {
888
- const root = node_path_1.default.resolve(cwd);
889
- const folder = resolveInsideProject(root, msg.folder || ".");
890
- // A page name may contain "/" so the overlay can create nested routes in
891
- // one step, but every segment is validated and resolved inside the project.
892
- const segments = (msg.fileName || "").split("/").map((segment) => segment.trim()).filter(Boolean);
893
- const fileName = segments.pop() || "";
894
- if (!folder || !fileName || segments.some((segment) => segment === "." || segment === "..") || fileName === "." || fileName === "..") {
895
- socket.send(JSON.stringify({ type: "page_created", path: msg.fileName || "", error: "Choose a valid project folder and file name." }));
896
- return;
897
- }
898
- const target = node_path_1.default.resolve(folder, ...segments, fileName);
899
- if (!target.startsWith(`${root}${node_path_1.default.sep}`)) {
900
- socket.send(JSON.stringify({ type: "page_created", path: msg.fileName, error: "Choose a valid project folder and file name." }));
901
- return;
454
+ catch {
455
+ content = null;
902
456
  }
457
+ return { filePath: change.filePath, content };
458
+ }));
459
+ }
460
+ if (filePaths.length > 0) {
461
+ (0, snapshot_1.createSnapshot)(cwd, taskId, `Apply changes for task ${taskId}`, filePaths, undefined, session?.id);
462
+ }
463
+ const applyError = applyChanges(changes, cwd);
464
+ if (applyError) {
465
+ // Roll back partial application so the project stays consistent
466
+ const originals = taskSnapshots.get(taskId) || [];
467
+ for (const item of originals) {
903
468
  try {
904
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(target), { recursive: true });
905
- node_fs_1.default.writeFileSync(target, msg.content.slice(0, 200000), { encoding: "utf8", flag: "wx" });
906
- socket.send(JSON.stringify({ type: "page_created", path: node_path_1.default.relative(root, target) }));
907
- }
908
- catch (error) {
909
- socket.send(JSON.stringify({ type: "page_created", path: node_path_1.default.relative(root, target), error: error instanceof Error && error.message.includes("EEXIST") ? "A file with that name already exists." : "The page could not be created." }));
910
- }
911
- return;
912
- }
913
- if (msg.type === "ask") {
914
- const localProvider = msg.provider === "cli";
915
- const cliProvider = msg.model?.startsWith("claude-code:") ? "claude-code" : msg.model?.startsWith("opencode:") ? "opencode" : msg.model?.startsWith("cursor:") ? "cursor" : "codex";
916
- if (!lassoKeyConfigured && !localProvider) {
917
- socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "error", message: "Set your Lasso API key before asking the hosted agent a question." }));
918
- return;
919
- }
920
- if (!agentConfig && !localProvider) {
921
- socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "error", message: "No hosted agent is configured. Select Claude Code/Codex or add a provider key." }));
922
- return;
923
- }
924
- taskControllers.get(msg.taskId)?.abort();
925
- const controller = new AbortController();
926
- taskControllers.set(msg.taskId, controller);
927
- let selectedConfig;
928
- if (localProvider) {
929
- selectedConfig = { provider: cliProvider, model: msg.model, lassoKey: resolvedLassoKey, serverUrl: collabConfig?.serverUrl || (0, auth_1.serverUrlFrom)(fileEnv) };
930
- }
931
- else {
932
- const requestedProvider = (msg.provider || agentConfig.provider);
933
- const providerLocalKey = localProviderKeys[requestedProvider];
934
- const apiKey = providerLocalKey || resolvedLassoKey;
935
- const baseUrl = requestedProvider === "nvidia"
936
- ? "https://integrate.api.nvidia.com/v1"
937
- : requestedProvider === "ollama"
938
- ? (agentConfig.baseUrl || "http://localhost:11434/v1")
939
- : agentConfig.baseUrl;
940
- selectedConfig = {
941
- ...agentConfig,
942
- provider: requestedProvider,
943
- model: msg.model,
944
- apiKey,
945
- baseUrl,
946
- lassoKey: resolvedLassoKey,
947
- serverUrl: collabConfig?.serverUrl || (0, auth_1.serverUrlFrom)(fileEnv),
948
- };
469
+ const abs = node_path_1.default.join(cwd, item.filePath);
470
+ if (item.content === null) {
471
+ if (node_fs_1.default.existsSync(abs))
472
+ node_fs_1.default.unlinkSync(abs);
473
+ }
474
+ else {
475
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(abs), { recursive: true });
476
+ node_fs_1.default.writeFileSync(abs, item.content);
477
+ }
949
478
  }
950
- // Enqueue task for concurrent execution
951
- enqueueTask(msg.taskId, msg, selectedConfig, 1); // Priority 1 for ask tasks
479
+ catch { }
952
480
  }
953
- else if (msg.type === "edit") {
954
- const localProvider = msg.provider === "cli";
955
- const cliProvider = msg.model?.startsWith("claude-code:") ? "claude-code" : msg.model?.startsWith("opencode:") ? "opencode" : msg.model?.startsWith("cursor:") ? "cursor" : "codex";
956
- if (!lassoKeyConfigured && !localProvider) {
957
- socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "error", message: "Set VITE_LASSO_API_KEY or NEXT_LASSO_API_KEY in your app environment before sending an edit." }));
958
- return;
959
- }
960
- if (!agentConfig && !localProvider) {
961
- socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "error", message: "Add a supported agent key: GOOGLE_GENERATIVE_AI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY." }));
962
- return;
963
- }
964
- let selectedConfig;
965
- if (localProvider) {
966
- selectedConfig = { provider: cliProvider, model: msg.model, lassoKey: resolvedLassoKey, serverUrl: collabConfig?.serverUrl || (0, auth_1.serverUrlFrom)(fileEnv) };
967
- }
968
- else {
969
- const requestedProvider = (msg.provider || agentConfig.provider);
970
- const providerLocalKey = localProviderKeys[requestedProvider];
971
- const apiKey = providerLocalKey || resolvedLassoKey;
972
- const baseUrl = requestedProvider === "nvidia"
973
- ? "https://integrate.api.nvidia.com/v1"
974
- : requestedProvider === "ollama"
975
- ? (agentConfig.baseUrl || "http://localhost:11434/v1")
976
- : agentConfig.baseUrl;
977
- selectedConfig = {
978
- ...agentConfig,
979
- provider: requestedProvider,
980
- model: msg.model,
981
- apiKey,
982
- baseUrl,
983
- lassoKey: resolvedLassoKey,
984
- serverUrl: collabConfig?.serverUrl || (0, auth_1.serverUrlFrom)(fileEnv),
985
- };
986
- }
987
- editRequests.set(msg.taskId, msg);
988
- editConfigs.set(msg.taskId, selectedConfig);
989
- reviewRefreshAttempts.set(msg.taskId, 0);
990
- // Enqueue task for concurrent execution with higher priority (0)
991
- enqueueTask(msg.taskId, msg, selectedConfig, 0);
481
+ socket.send(JSON.stringify({
482
+ type: "agent_status",
483
+ taskId,
484
+ status: "error",
485
+ message: applyError,
486
+ }));
487
+ return;
488
+ }
489
+ // Post-apply verification (inconclusive when the engine can't run —
490
+ // never reported as a failure of the changes themselves).
491
+ let validation;
492
+ const facade = taskFacades.get(taskId) || runtimeClient;
493
+ if (session) {
494
+ try {
495
+ const result = await facade.verifySession(session.id, cwd);
496
+ validation = {
497
+ passed: result.passed,
498
+ errors: result.errors.map((e) => e.message),
499
+ command: result.command || "verification",
500
+ };
992
501
  }
993
- else if (msg.type === "agent_prompt_response") {
994
- (0, agent_1.respondToAgentPrompt)(msg.taskId, msg.response);
502
+ catch {
503
+ validation = undefined;
995
504
  }
996
- else if (msg.type === "stop") {
997
- if (msg.taskId) {
998
- taskControllers.get(msg.taskId)?.abort();
999
- // Remove from queue and running tasks
1000
- const queueIndex = taskQueue.findIndex(t => t.taskId === msg.taskId);
1001
- if (queueIndex !== -1)
1002
- taskQueue.splice(queueIndex, 1);
1003
- runningTasks.delete(msg.taskId);
1004
- taskPriority.delete(msg.taskId);
1005
- }
1006
- else {
1007
- for (const controller of taskControllers.values())
1008
- controller.abort();
1009
- taskQueue.length = 0;
1010
- runningTasks.clear();
1011
- taskPriority.clear();
505
+ }
506
+ socket.send(JSON.stringify({
507
+ type: "applied",
508
+ taskId,
509
+ message: "Changes applied successfully",
510
+ ...(validation ? { validation } : {}),
511
+ }));
512
+ if (validation && !validation.passed && session) {
513
+ await runCorrectiveFix(session, facade, taskId, socket);
514
+ }
515
+ }
516
+ /** Apply approved changes to disk. Returns an error message or null. */
517
+ function applyChanges(changes, cwd) {
518
+ for (const change of changes) {
519
+ const filePath = node_path_1.default.join(cwd, change.filePath);
520
+ const dir = node_path_1.default.dirname(filePath);
521
+ const oldString = typeof change.oldString === "string" ? change.oldString : "";
522
+ const newString = typeof change.newString === "string" ? change.newString : "";
523
+ const operation = change.operation ||
524
+ (oldString === "" && newString === "" ? "deleted" : oldString === "" ? "created" : "modified");
525
+ try {
526
+ if (!node_fs_1.default.existsSync(dir)) {
527
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
1012
528
  }
1013
- socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "stopped", message: "Agent stopped." }));
1014
- }
1015
- else if (msg.type === "runtime_error") {
1016
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message: `Runtime error detected${msg.selectionId ? ` for selection ${msg.selectionId}` : ""}: ${msg.details}` }));
1017
- }
1018
- else if (msg.type === "git_status") {
1019
- socket.send(JSON.stringify({ type: "git_state", git: await getGitState(cwd) }));
1020
- }
1021
- else if (msg.type === "git_generate_message") {
1022
- const localProvider = msg.provider === "cli";
1023
- const cliProvider = msg.model?.startsWith("claude-code:") ? "claude-code" : msg.model?.startsWith("opencode:") ? "opencode" : msg.model?.startsWith("cursor:") ? "cursor" : "codex";
1024
- if (!agentConfig && !localProvider) {
1025
- socket.send(JSON.stringify({ type: "git_commit_message", error: "No AI provider is configured." }));
1026
- return;
529
+ if (operation === "deleted") {
530
+ if (node_fs_1.default.existsSync(filePath)) {
531
+ node_fs_1.default.unlinkSync(filePath);
532
+ }
1027
533
  }
1028
- const selectedConfig = localProvider
1029
- ? { provider: cliProvider, model: msg.model }
1030
- : { ...agentConfig, provider: (msg.provider || agentConfig.provider), model: msg.model };
1031
- const controller = new AbortController();
1032
- activeAgentController?.abort();
1033
- activeAgentController = controller;
1034
- socket.send(JSON.stringify({ type: "git_progress", message: "Generating a commit message…" }));
1035
- void getGitState(cwd).then((git) => (0, agent_1.generateCommitMessage)(cwd, git.status || [], selectedConfig, controller.signal, (message, detail) => {
1036
- if (!controller.signal.aborted && socket.readyState === socket.OPEN)
1037
- socket.send(JSON.stringify({ type: "git_progress", message: detail ? `${message} · ${detail}` : message }));
1038
- })).then((message) => {
1039
- if (!controller.signal.aborted)
1040
- socket.send(JSON.stringify({ type: "git_commit_message", message }));
1041
- }).catch((error) => {
1042
- if (!controller.signal.aborted)
1043
- socket.send(JSON.stringify({ type: "git_commit_message", error: error instanceof Error ? error.message : "Unable to generate a commit message." }));
1044
- }).finally(() => { if (activeAgentController === controller)
1045
- activeAgentController = null; });
1046
- }
1047
- else if (msg.type === "git_init") {
1048
- try {
1049
- await gitCommand(cwd, ["init"]);
1050
- socket.send(JSON.stringify({ type: "git_result", message: "Git repository initialized." }));
1051
- socket.send(JSON.stringify({ type: "git_state", git: await getGitState(cwd) }));
534
+ else if (operation === "created" || (oldString === "" && operation !== "modified")) {
535
+ node_fs_1.default.writeFileSync(filePath, newString);
1052
536
  }
1053
- catch (error) {
1054
- socket.send(JSON.stringify({ type: "git_result", error: error instanceof Error ? error.message : "Git could not be initialized." }));
537
+ else {
538
+ // Full-content replacement; split/join avoids String.replace's
539
+ // special $-pattern handling in the replacement text.
540
+ const content = node_fs_1.default.readFileSync(filePath, "utf8");
541
+ let next;
542
+ if (content === oldString) {
543
+ next = newString;
544
+ }
545
+ else if (oldString && content.includes(oldString)) {
546
+ next = content.split(oldString).join(newString);
547
+ }
548
+ else {
549
+ return `Cannot apply ${change.filePath}: the file changed since the diff was generated.`;
550
+ }
551
+ node_fs_1.default.writeFileSync(filePath, next);
1055
552
  }
1056
553
  }
1057
- else if (msg.type === "git_commit") {
1058
- try {
1059
- const commitMessage = msg.message.trim().slice(0, 120);
1060
- if (!commitMessage)
1061
- throw new Error("Enter a commit message first.");
1062
- await gitCommand(cwd, ["add", "-A"]);
1063
- await gitCommand(cwd, ["commit", "-m", commitMessage]);
1064
- socket.send(JSON.stringify({ type: "git_result", message: "Changes committed." }));
1065
- socket.send(JSON.stringify({ type: "git_state", git: await getGitState(cwd) }));
1066
- }
1067
- catch (error) {
1068
- socket.send(JSON.stringify({ type: "git_result", error: error instanceof Error ? error.message : "Changes could not be committed." }));
1069
- }
554
+ catch (error) {
555
+ return `Failed to apply ${change.filePath}: ${error instanceof Error ? error.message : "Unknown error"}`;
1070
556
  }
1071
- else if (msg.type === "git_push") {
557
+ }
558
+ return null;
559
+ }
560
+ // Automatic corrective run after failed post-apply verification (bounded).
561
+ async function runCorrectiveFix(session, facade, taskId, socket) {
562
+ const attempts = correctiveAttempts.get(taskId) || 0;
563
+ if (attempts >= 2)
564
+ return;
565
+ correctiveAttempts.set(taskId, attempts + 1);
566
+ taskControllers.get(taskId)?.abort();
567
+ const controller = new AbortController();
568
+ taskControllers.set(taskId, controller);
569
+ socket.send(JSON.stringify({
570
+ type: "agent_status",
571
+ taskId,
572
+ status: "thinking",
573
+ message: "Fixing verification errors...",
574
+ }));
575
+ try {
576
+ await facade.runSession(session.id, cwd, (event) => {
577
+ handleRuntimeEvent(event, socket, taskId, session, "Corrective run", true);
578
+ }, controller.signal);
579
+ }
580
+ catch (error) {
581
+ socket.send(JSON.stringify({
582
+ type: "agent_status",
583
+ taskId,
584
+ status: "error",
585
+ message: error instanceof Error ? error.message : "Corrective run failed",
586
+ }));
587
+ }
588
+ }
589
+ // Handler for undo - restores snapshot
590
+ async function handleUndo(message, socket, cwd) {
591
+ const { taskId } = message;
592
+ const snapshot = taskSnapshots.get(taskId) || [];
593
+ for (const item of snapshot) {
594
+ if (item.content === null) {
595
+ // File didn't exist, delete if it was created
1072
596
  try {
1073
- await gitCommand(cwd, ["push"]);
1074
- socket.send(JSON.stringify({ type: "git_result", message: "Changes pushed to the configured remote." }));
1075
- socket.send(JSON.stringify({ type: "git_state", git: await getGitState(cwd) }));
1076
- }
1077
- catch (error) {
1078
- socket.send(JSON.stringify({ type: "git_result", error: error instanceof Error ? error.message : "Changes could not be pushed." }));
597
+ node_fs_1.default.unlinkSync(node_path_1.default.join(cwd, item.filePath));
1079
598
  }
599
+ catch { }
1080
600
  }
1081
- else if (msg.type === "transcribe") {
1082
- const serverUrl = (0, auth_1.serverUrlFrom)(fileEnv);
1083
- const creds = (0, auth_1.loadCredentials)();
1084
- const apiKey = resolvedLassoKey || agentConfig?.apiKey || collabConfig?.apiKey || creds?.apiKey;
1085
- try {
1086
- const resp = await fetch(`${serverUrl}/api/v1/transcribe`, {
1087
- method: "POST",
1088
- headers: {
1089
- "Content-Type": "application/json",
1090
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
1091
- },
1092
- body: JSON.stringify({
1093
- audio: msg.audio,
1094
- mimeType: msg.mimeType,
1095
- language: msg.language,
1096
- }),
1097
- });
1098
- const data = await resp.json();
1099
- socket.send(JSON.stringify({
1100
- type: "transcribe_result",
1101
- requestId: msg.requestId,
1102
- success: Boolean(data.success),
1103
- text: data.text || "",
1104
- provider: data.provider || "gradium",
1105
- error: data.message,
1106
- }));
1107
- }
1108
- catch (error) {
1109
- socket.send(JSON.stringify({
1110
- type: "transcribe_result",
1111
- requestId: msg.requestId,
1112
- success: false,
1113
- error: error instanceof Error ? error.message : "Failed to contact transcription service",
1114
- }));
1115
- }
601
+ else {
602
+ // Restore original content
603
+ node_fs_1.default.writeFileSync(node_path_1.default.join(cwd, item.filePath), item.content);
1116
604
  }
1117
- else if (msg.type === "oneshot") {
1118
- console.log("[lasso] one-shot request:", msg.prompt, msg.scope);
1119
- const taskId = msg.taskId || `oneshot-${Date.now()}`;
1120
- const localProvider = msg.provider === "cli" || msg.model?.startsWith("claude-code:") || msg.model?.startsWith("opencode:") || msg.model?.startsWith("cursor:") || msg.model?.startsWith("codex:");
1121
- const cliProvider = msg.model?.startsWith("claude-code:") ? "claude-code" : msg.model?.startsWith("opencode:") ? "opencode" : msg.model?.startsWith("cursor:") ? "cursor" : "codex";
1122
- const apiKey = resolvedLassoKey || agentConfig?.apiKey || collabConfig?.apiKey;
1123
- if (!localProvider && !lassoKeyConfigured && !agentConfig && !apiKey) {
605
+ }
606
+ socket.send(JSON.stringify({
607
+ type: "undone",
608
+ taskId,
609
+ message: "Changes undone",
610
+ }));
611
+ }
612
+ // Handler for stop
613
+ async function handleStop(message, socket) {
614
+ const { taskId } = message;
615
+ if (taskId) {
616
+ taskControllers.get(taskId)?.abort();
617
+ const session = activeSessions.get(taskId);
618
+ const facade = taskFacades.get(taskId);
619
+ if (session && facade)
620
+ void facade.cancelSession(session.id).catch(() => { });
621
+ activeSessions.delete(taskId);
622
+ taskFacades.delete(taskId);
623
+ }
624
+ else {
625
+ for (const controller of taskControllers.values())
626
+ controller.abort();
627
+ taskControllers.clear();
628
+ for (const [id, session] of activeSessions) {
629
+ const facade = taskFacades.get(id);
630
+ if (facade)
631
+ void facade.cancelSession(session.id).catch(() => { });
632
+ }
633
+ activeSessions.clear();
634
+ taskFacades.clear();
635
+ }
636
+ socket.send(JSON.stringify({
637
+ type: "agent_status",
638
+ taskId,
639
+ status: "stopped",
640
+ message: "Agent stopped",
641
+ }));
642
+ }
643
+ // Handler for switch_model
644
+ async function handleSwitchModel(message, socket) {
645
+ const { taskId, provider, modelId } = message;
646
+ const session = activeSessions.get(taskId);
647
+ if (!session) {
648
+ socket.send(JSON.stringify({
649
+ type: "agent_status",
650
+ taskId,
651
+ status: "error",
652
+ message: "No active session for this task",
653
+ }));
654
+ return;
655
+ }
656
+ try {
657
+ const isLocalAgent = ["claude_code", "opencode", "codex_cli", "cursor"].includes(provider || "");
658
+ const facade = taskFacades.get(taskId) || runtimeClient;
659
+ await facade.switchModel(session.id, {
660
+ provider: provider,
661
+ id: modelId,
662
+ }, undefined, isLocalAgent
663
+ ? {
664
+ type: "local_agent",
665
+ provider: provider,
666
+ modelId: (0, backends_1.modelIdForBackend)(modelId || "", provider || ""),
667
+ projectPath: cwd,
668
+ }
669
+ : undefined);
670
+ socket.send(JSON.stringify({
671
+ type: "agent_status",
672
+ taskId,
673
+ status: "working",
674
+ message: `Switched to ${provider}/${modelId}`,
675
+ }));
676
+ }
677
+ catch (error) {
678
+ socket.send(JSON.stringify({
679
+ type: "agent_status",
680
+ taskId,
681
+ status: "error",
682
+ message: error instanceof Error ? error.message : "Failed to switch model",
683
+ }));
684
+ }
685
+ }
686
+ // Handler for oneshot
687
+ async function handleOneshot(message, socket, cwd) {
688
+ const { taskId, prompt, model, provider } = message;
689
+ const instruction = prompt;
690
+ taskControllers.get(taskId)?.abort();
691
+ const controller = new AbortController();
692
+ taskControllers.set(taskId, controller);
693
+ socket.send(JSON.stringify({
694
+ type: "agent_status",
695
+ taskId,
696
+ status: "thinking",
697
+ message: "Starting oneshot...",
698
+ }));
699
+ try {
700
+ const isLocalAgent = ["claude_code", "opencode", "codex_cli", "cursor"].includes(provider || "");
701
+ const backendModelId = (0, backends_1.modelIdForBackend)(model || "", provider || "");
702
+ const sessionInput = {
703
+ userPrompt: prompt,
704
+ project: {
705
+ projectId,
706
+ framework,
707
+ hasTypeScript: node_fs_1.default.existsSync(node_path_1.default.join(cwd, "tsconfig.json")),
708
+ hasReact: node_fs_1.default.existsSync(node_path_1.default.join(cwd, "package.json")) &&
709
+ node_fs_1.default.readFileSync(node_path_1.default.join(cwd, "package.json"), "utf8").includes("react"),
710
+ },
711
+ model: {
712
+ provider: (provider || "anthropic"),
713
+ id: model || "claude-3-5-sonnet-20241022",
714
+ },
715
+ backendConfig: isLocalAgent ? {
716
+ type: "local_agent",
717
+ provider: provider,
718
+ modelId: backendModelId,
719
+ projectPath: cwd,
720
+ } : undefined,
721
+ };
722
+ const facade = isLocalAgent ? (0, local_runtime_1.getLocalRuntime)() : runtimeClient;
723
+ const session = await facade.createSession(sessionInput);
724
+ activeSessions.set(taskId, session);
725
+ taskFacades.set(taskId, facade);
726
+ await facade.runSession(session.id, cwd, (event) => {
727
+ // Oneshot results are reviewed exactly like edit/ask: the workspace is
728
+ // rolled back and only approval re-applies the changes.
729
+ handleRuntimeEvent(event, socket, taskId, session, `Oneshot: ${instruction}`, true);
730
+ if (event.type === "agent_completed") {
1124
731
  socket.send(JSON.stringify({
1125
- type: "agent_status",
732
+ type: "assistant_message",
1126
733
  taskId,
1127
- status: "error",
1128
- message: "No AI provider or Lasso key configured. Sign in with 'lasso auth login' or configure a provider key in .env."
1129
- }));
1130
- return;
1131
- }
1132
- const provider = (msg.provider || agentConfig?.provider || "google");
1133
- const model = msg.model || agentConfig?.model;
1134
- if (!model) {
1135
- socket.send(JSON.stringify({
1136
- type: "error",
1137
- message: "No model selected. Please select a model in settings."
734
+ message: "Oneshot completed",
1138
735
  }));
1139
- return;
1140
736
  }
1141
- const localKey = localProviderKeys[provider];
1142
- const serverUrl = collabConfig?.serverUrl || (0, auth_1.serverUrlFrom)(fileEnv);
1143
- const targetApiUrl = (serverUrl.includes("collab.lasso.byorello.space") ? "https://api.lasso.byorello.space" : serverUrl).replace(/\/api\/v1\/?$/, "");
1144
- const selectedConfig = localProvider
1145
- ? { provider: cliProvider, model: msg.model || "claude-code:sonnet", lassoKey: resolvedLassoKey, serverUrl: targetApiUrl }
1146
- : {
1147
- ...(agentConfig || {}),
1148
- provider,
1149
- model,
1150
- apiKey: localKey || apiKey,
1151
- lassoKey: resolvedLassoKey,
1152
- serverUrl: targetApiUrl,
1153
- baseUrl: provider === "nvidia" ? "https://integrate.api.nvidia.com/v1" : provider === "ollama" ? `${(process.env.OLLAMA_BASE_URL || fileEnv.OLLAMA_BASE_URL || "http://localhost:11434/api").replace(/\/api\/?$/, "")}/v1` : undefined,
1154
- };
1155
- const controller = new AbortController();
1156
- taskControllers.set(taskId, controller);
1157
- socket.send(JSON.stringify({ type: "agent_status", taskId, status: "thinking", message: "Planning your request..." }));
1158
- const { runOneShotAgent } = await import("./oneshot.js");
1159
- runOneShotAgent(cwd, msg.prompt, msg.scope || "project", selectedConfig, controller.signal, (status, message, detail) => {
1160
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
1161
- socket.send(JSON.stringify({ type: "agent_status", taskId, status, message, detail }));
1162
- }
1163
- }, (prompt) => {
1164
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
1165
- socket.send(JSON.stringify({ type: "agent_prompt", taskId, prompt }));
1166
- }
1167
- }, msg.messages, targetApiUrl, apiKey, taskId, msg.pageContext).then((result) => {
1168
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
1169
- if (result.ok) {
1170
- const hasChanges = Array.isArray(result.changes) && result.changes.length > 0;
737
+ }, controller.signal);
738
+ }
739
+ catch (error) {
740
+ socket.send(JSON.stringify({
741
+ type: "agent_status",
742
+ taskId,
743
+ status: "error",
744
+ message: error instanceof Error ? error.message : "Unknown oneshot error",
745
+ }));
746
+ }
747
+ }
748
+ // Handler for restore_snapshot
749
+ async function handleRestoreSnapshot(message, socket, cwd) {
750
+ const { snapshotId } = message;
751
+ try {
752
+ await (0, snapshot_1.restoreSnapshot)(cwd, snapshotId);
753
+ socket.send(JSON.stringify({
754
+ type: "agent_status",
755
+ status: "working",
756
+ message: "Snapshot restored",
757
+ }));
758
+ }
759
+ catch (error) {
760
+ socket.send(JSON.stringify({
761
+ type: "agent_status",
762
+ status: "error",
763
+ message: error instanceof Error ? error.message : "Failed to restore snapshot",
764
+ }));
765
+ }
766
+ }
767
+ // Handler for get_timeline
768
+ async function handleGetTimeline(socket, cwd) {
769
+ try {
770
+ const timeline = await (0, snapshot_1.getTimeline)(cwd);
771
+ socket.send(JSON.stringify({
772
+ type: "timeline",
773
+ snapshots: timeline,
774
+ }));
775
+ }
776
+ catch (error) {
777
+ socket.send(JSON.stringify({
778
+ type: "agent_status",
779
+ status: "error",
780
+ message: error instanceof Error ? error.message : "Failed to get timeline",
781
+ }));
782
+ }
783
+ }
784
+ // Handler for delete_snapshot
785
+ async function handleDeleteSnapshot(message, socket, cwd) {
786
+ const { snapshotId } = message;
787
+ try {
788
+ await (0, snapshot_1.deleteSnapshot)(cwd, snapshotId);
789
+ socket.send(JSON.stringify({
790
+ type: "agent_status",
791
+ status: "working",
792
+ message: "Snapshot deleted",
793
+ }));
794
+ }
795
+ catch (error) {
796
+ socket.send(JSON.stringify({
797
+ type: "agent_status",
798
+ status: "error",
799
+ message: error instanceof Error ? error.message : "Failed to delete snapshot",
800
+ }));
801
+ }
802
+ }
803
+ // Git handlers
804
+ async function handleGitStatus(socket, cwd) {
805
+ socket.send(JSON.stringify({ type: "git_state", git: await getGitState(cwd) }));
806
+ }
807
+ async function handleGitGenerateMessage(message, socket, cwd) {
808
+ const git = await getGitState(cwd);
809
+ if (!git.isRepo) {
810
+ socket.send(JSON.stringify({ type: "git_commit_message", error: "Not a git repository" }));
811
+ return;
812
+ }
813
+ const diff = await gitCommand(cwd, ["diff", "--cached"]).catch(() => "");
814
+ if (!diff) {
815
+ socket.send(JSON.stringify({ type: "git_commit_message", error: "No staged changes" }));
816
+ return;
817
+ }
818
+ // TODO: Use runtime for commit message generation
819
+ socket.send(JSON.stringify({ type: "git_commit_message", message: "feat: changes" }));
820
+ }
821
+ async function handleGitInit(socket, cwd) {
822
+ try {
823
+ await gitCommand(cwd, ["init"]);
824
+ socket.send(JSON.stringify({ type: "git_result", message: "Git repository initialized" }));
825
+ }
826
+ catch (error) {
827
+ socket.send(JSON.stringify({ type: "git_result", error: error instanceof Error ? error.message : "Failed to initialize git" }));
828
+ }
829
+ }
830
+ async function handleGitCommit(message, socket, cwd) {
831
+ try {
832
+ await gitCommand(cwd, ["commit", "-m", message.message]);
833
+ socket.send(JSON.stringify({ type: "git_result", message: "Changes committed" }));
834
+ }
835
+ catch (error) {
836
+ socket.send(JSON.stringify({ type: "git_result", error: error instanceof Error ? error.message : "Failed to commit" }));
837
+ }
838
+ }
839
+ async function handleGitPush(socket, cwd) {
840
+ try {
841
+ await gitCommand(cwd, ["push"]);
842
+ socket.send(JSON.stringify({ type: "git_result", message: "Changes pushed" }));
843
+ }
844
+ catch (error) {
845
+ socket.send(JSON.stringify({ type: "git_result", error: error instanceof Error ? error.message : "Failed to push" }));
846
+ }
847
+ }
848
+ wss.on("connection", (socket) => {
849
+ overlaySocket = socket;
850
+ console.log(chalk_1.default.green("✓") + " Overlay connected");
851
+ const creds = (0, auth_1.loadCredentials)();
852
+ const userInfo = creds?.userName
853
+ ? { name: creds.userName, email: creds.userEmail }
854
+ : (process.env.USER ? { name: process.env.USER } : null);
855
+ socket.send(JSON.stringify({
856
+ type: "config",
857
+ apiKeyConfigured: lassoKeyConfigured,
858
+ agentConfigured: lassoKeyConfigured,
859
+ models,
860
+ collab: collabConfig?.registered ? { ...collabConfig, plan: collabConfig.plan, configuredProviders: collabConfig.configuredProviders } : null,
861
+ user: userInfo
862
+ }));
863
+ void getGitState(cwd).then((git) => {
864
+ if (socket.readyState === socket.OPEN)
865
+ socket.send(JSON.stringify({ type: "git_state", git }));
866
+ });
867
+ socket.on("message", async (raw) => {
868
+ const msg = JSON.parse(raw.toString());
869
+ console.log("[lasso] received from overlay:", msg.type);
870
+ try {
871
+ switch (msg.type) {
872
+ case "hello":
873
+ break;
874
+ case "edit":
875
+ case "ask":
876
+ await handleEditOrAsk(msg, socket, msg.taskId, msg.type === "edit" ? msg.instruction : msg.question, msg.model, msg.provider || "anthropic", msg.context, msg.element, msg.messages);
877
+ break;
878
+ case "apply":
879
+ await handleApply(msg, socket, cwd);
880
+ break;
881
+ case "undo":
882
+ await handleUndo(msg, socket, cwd);
883
+ break;
884
+ case "stop":
885
+ await handleStop(msg, socket);
886
+ break;
887
+ case "switch_model":
888
+ await handleSwitchModel(msg, socket);
889
+ break;
890
+ case "oneshot":
891
+ await handleOneshot(msg, socket, cwd);
892
+ break;
893
+ case "restore_snapshot":
894
+ await handleRestoreSnapshot(msg, socket, cwd);
895
+ break;
896
+ case "get_timeline":
897
+ await handleGetTimeline(socket, cwd);
898
+ break;
899
+ case "delete_snapshot":
900
+ await handleDeleteSnapshot(msg, socket, cwd);
901
+ break;
902
+ case "git_status":
903
+ await handleGitStatus(socket, cwd);
904
+ break;
905
+ case "git_generate_message":
906
+ await handleGitGenerateMessage(msg, socket, cwd);
907
+ break;
908
+ case "git_init":
909
+ await handleGitInit(socket, cwd);
910
+ break;
911
+ case "git_commit":
912
+ await handleGitCommit(msg, socket, cwd);
913
+ break;
914
+ case "git_push":
915
+ await handleGitPush(socket, cwd);
916
+ break;
917
+ case "list_page_folders":
918
+ const requested = msg.path || ".";
919
+ const root = node_path_1.default.resolve(cwd);
920
+ const directory = resolveInsideProject(root, requested);
921
+ if (!directory) {
922
+ socket.send(JSON.stringify({ type: "page_folders", path: requested, entries: [], error: "That folder is outside the current project." }));
923
+ return;
924
+ }
925
+ socket.send(JSON.stringify({ type: "page_folders", path: requested, entries: pageEntries(root, requested), project: node_path_1.default.basename(root) }));
926
+ break;
927
+ case "create_page_folder":
928
+ const root2 = node_path_1.default.resolve(cwd);
929
+ const name = node_path_1.default.basename(msg.name || "");
930
+ const parent = resolveInsideProject(root2, msg.parent || ".");
931
+ if (!parent || !name || name !== msg.name || name === "." || name === "..") {
932
+ socket.send(JSON.stringify({ type: "page_folder_created", path: msg.name || "", error: "Enter a folder name without slashes or dots." }));
933
+ return;
934
+ }
935
+ const target = node_path_1.default.resolve(parent, name);
936
+ if (!target.startsWith(`${root2}${node_path_1.default.sep}`)) {
937
+ socket.send(JSON.stringify({ type: "page_folder_created", path: name, error: "Choose a folder inside the current project." }));
938
+ return;
939
+ }
940
+ try {
941
+ node_fs_1.default.mkdirSync(target);
942
+ socket.send(JSON.stringify({ type: "page_folder_created", path: node_path_1.default.relative(root2, target) }));
943
+ }
944
+ catch (error) {
1171
945
  socket.send(JSON.stringify({
1172
- type: "agent_status",
1173
- taskId,
1174
- status: hasChanges ? "review" : "complete",
1175
- message: result.summary,
1176
- todo: result.todo,
1177
- changes: result.changes,
1178
- thinking: result.thinking,
1179
- totalThinkingTimeMs: result.totalThinkingTimeMs,
946
+ type: "page_folder_created",
947
+ path: node_path_1.default.relative(root2, target),
948
+ error: error instanceof Error && error.message.includes("EEXIST") ? "A folder with that name already exists." : "The folder could not be created.",
1180
949
  }));
1181
950
  }
1182
- else {
1183
- socket.send(JSON.stringify({ type: "agent_status", taskId, status: "error", message: result.error || "One-shot agent failed" }));
951
+ break;
952
+ case "create_page":
953
+ const root3 = node_path_1.default.resolve(cwd);
954
+ const folder = resolveInsideProject(root3, msg.folder || ".");
955
+ if (!folder) {
956
+ socket.send(JSON.stringify({ type: "page_created", path: msg.fileName, error: "That folder is outside the current project." }));
957
+ return;
1184
958
  }
1185
- }
1186
- }).catch((error) => {
1187
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
1188
- socket.send(JSON.stringify({ type: "agent_status", taskId, status: "error", message: error instanceof Error ? error.message : "One-shot agent encountered an error" }));
1189
- }
1190
- }).finally(() => {
1191
- if (taskControllers.get(taskId) === controller)
1192
- taskControllers.delete(taskId);
1193
- });
1194
- }
1195
- else if (msg.type === "apply") {
1196
- try {
1197
- // Create snapshot before applying changes
1198
- const filePaths = [...new Set(msg.changes.map(c => c.filePath))];
1199
- const parentSnapshot = (0, snapshot_1.getSnapshotByTaskId)(cwd, msg.taskId);
1200
- // Create pre-apply snapshot
1201
- const preSnapshot = (0, snapshot_1.createSnapshot)(cwd, msg.taskId, `Pre-apply: ${msg.changes.length} file${msg.changes.length === 1 ? "" : "s"}`, filePaths, parentSnapshot?.id);
1202
- const snapshots = [];
1203
- const planned = new Map();
1204
- for (const change of msg.changes) {
1205
- const isNew = !change.oldString;
1206
- const filePath = resolveProposedFile(cwd, change.filePath, true);
1207
- const exists = node_fs_1.default.existsSync(filePath);
1208
- const content = exists ? node_fs_1.default.readFileSync(filePath, "utf8") : "";
1209
- const prepared = (!exists || isNew)
1210
- ? { start: 0, end: content.length, oldString: content, newString: change.newString }
1211
- : prepareChange(content, change);
1212
- const fileChanges = planned.get(filePath) || [];
1213
- if (fileChanges.some((item) => prepared.start < item.end && item.start < prepared.end)) {
1214
- throw new Error(`Could not safely apply ${change.filePath}. Proposed changes overlap.`);
959
+ const targetFile = node_path_1.default.resolve(folder, msg.fileName);
960
+ if (!targetFile.startsWith(`${root3}${node_path_1.default.sep}`)) {
961
+ socket.send(JSON.stringify({ type: "page_created", path: msg.fileName, error: "Choose a location inside the current project." }));
962
+ return;
1215
963
  }
1216
- fileChanges.push({ filePath, content, exists, ...prepared });
1217
- planned.set(filePath, fileChanges);
1218
- }
1219
- // Apply each file's replacements from the end toward the beginning
1220
- taskSnapshots.set(msg.taskId, snapshots);
1221
- for (const [filePath, changes] of planned) {
1222
- const exists = changes[0].exists;
1223
- const content = changes[0].content;
1224
- snapshots.push({ filePath, content: exists ? content : null });
1225
- const nextContent = exists
1226
- ? [...changes]
1227
- .sort((a, b) => b.start - a.start)
1228
- .reduce((value, change) => value.slice(0, change.start) + change.newString + value.slice(change.end), content)
1229
- : changes[0].newString;
1230
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
1231
- node_fs_1.default.writeFileSync(filePath, nextContent);
1232
- }
1233
- // Create post-apply snapshot
1234
- (0, snapshot_1.createSnapshot)(cwd, msg.taskId, `Applied: ${msg.changes.length} file${msg.changes.length === 1 ? "" : "s"}`, filePaths, preSnapshot.id);
1235
- // Run build validation after applying changes
1236
- const validation = await validateBuild(cwd);
1237
- if (!validation.passed) {
1238
- // Build failed - automatically trigger retry with build error context
1239
- const errorContext = validation.errors.slice(0, 10).join("\n");
1240
- socket.send(JSON.stringify({
1241
- type: "agent_status",
1242
- taskId: msg.taskId,
1243
- status: "working",
1244
- message: `⚠️ Build validation failed. Attempting to fix build errors automatically...`,
1245
- detail: `Build command: ${validation.command}\nErrors:\n${errorContext}${validation.errors.length > 10 ? `\n... and ${validation.errors.length - 10} more errors` : ""}`
1246
- }));
1247
- // Store context for retry
1248
- buildErrorContext.set(msg.taskId, {
1249
- errors: validation.errors,
1250
- command: validation.command,
1251
- originalChanges: msg.changes,
1252
- retryCount: 0
1253
- });
1254
- // Trigger automatic build fix
1255
- await triggerBuildFix(cwd, msg.taskId, validation.errors, validation.command, msg.changes, resolvedLassoKey, agentConfig, collabConfig, socket, taskControllers, taskSnapshots, buildErrorContext);
1256
- return;
1257
- }
1258
- socket.send(JSON.stringify({
1259
- type: "applied",
1260
- taskId: msg.taskId,
1261
- message: `${msg.changes.length} file${msg.changes.length === 1 ? "" : "s"} updated. Your dev server will reload.`,
1262
- validation
1263
- }));
1264
- }
1265
- catch (error) {
1266
- // Restore this task's snapshot if a filesystem error occurs
1267
- for (const snapshot of taskSnapshots.get(msg.taskId) || []) {
1268
- if (snapshot.content === null) {
1269
- if (node_fs_1.default.existsSync(snapshot.filePath))
1270
- node_fs_1.default.unlinkSync(snapshot.filePath);
964
+ try {
965
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(targetFile), { recursive: true });
966
+ node_fs_1.default.writeFileSync(targetFile, msg.content);
967
+ socket.send(JSON.stringify({ type: "page_created", path: node_path_1.default.relative(root3, targetFile) }));
1271
968
  }
1272
- else {
1273
- node_fs_1.default.writeFileSync(snapshot.filePath, snapshot.content);
969
+ catch (error) {
970
+ socket.send(JSON.stringify({
971
+ type: "page_created",
972
+ path: node_path_1.default.relative(root3, targetFile),
973
+ error: error instanceof Error ? error.message : "The file could not be created.",
974
+ }));
1274
975
  }
1275
- }
1276
- taskSnapshots.delete(msg.taskId);
1277
- const message = error instanceof Error ? error.message : "The change could not be applied.";
1278
- socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "error", message }));
1279
- }
1280
- finally {
1281
- if (taskControllers.get(msg.taskId))
1282
- taskControllers.delete(msg.taskId);
976
+ break;
977
+ default:
978
+ console.warn("Unknown bridge message type:", msg.type);
1283
979
  }
1284
980
  }
1285
- else if (msg.type === "undo") {
1286
- const snapshots = taskSnapshots.get(msg.taskId || "") || [];
1287
- for (const snapshot of snapshots) {
1288
- if (snapshot.content === null) {
1289
- if (node_fs_1.default.existsSync(snapshot.filePath))
1290
- node_fs_1.default.unlinkSync(snapshot.filePath);
1291
- }
1292
- else {
1293
- node_fs_1.default.writeFileSync(snapshot.filePath, snapshot.content);
1294
- }
1295
- }
1296
- taskSnapshots.delete(msg.taskId || "");
1297
- socket.send(JSON.stringify({ type: "undone", taskId: msg.taskId, message: snapshots.length ? "The accepted change was reverted." : "There is no accepted change to undo." }));
1298
- }
1299
- else if (msg.type === "restore_snapshot") {
1300
- const success = (0, snapshot_1.restoreSnapshot)(cwd, msg.snapshotId);
1301
- socket.send(JSON.stringify({
1302
- type: "agent_status",
1303
- message: success ? "Snapshot restored successfully." : "Failed to restore snapshot."
1304
- }));
1305
- }
1306
- else if (msg.type === "get_timeline") {
1307
- const timeline = (0, snapshot_1.getTimeline)(cwd);
1308
- socket.send(JSON.stringify({ type: "timeline", snapshots: timeline }));
1309
- }
1310
- else if (msg.type === "delete_snapshot") {
1311
- const success = (0, snapshot_1.deleteSnapshot)(cwd, msg.snapshotId);
981
+ catch (error) {
982
+ console.error("Error handling bridge message:", error);
1312
983
  socket.send(JSON.stringify({
1313
984
  type: "agent_status",
1314
- message: success ? "Snapshot deleted." : "Failed to delete snapshot."
985
+ taskId: msg.taskId,
986
+ status: "error",
987
+ message: error instanceof Error ? error.message : "Unknown error",
1315
988
  }));
1316
989
  }
1317
990
  });
1318
991
  socket.on("close", () => {
1319
- overlaySocket = null;
1320
- currentSocket = null;
1321
- console.log(chalk_1.default.yellow("!") + " Overlay disconnected");
992
+ if (socket === overlaySocket)
993
+ overlaySocket = null;
1322
994
  });
1323
995
  });
1324
- let bridgeListening = false;
1325
- bridgeServer.on("listening", () => {
1326
- bridgeListening = true;
996
+ bridgeServer.listen(bridgePort, "127.0.0.1", () => {
997
+ console.log(chalk_1.default.green("✓") + ` Bridge listening on ${chalk_1.default.cyan(`http://127.0.0.1:${bridgePort}`)}`);
1327
998
  });
1328
- bridgeServer.on("error", (error) => {
1329
- if (error.code === "EADDRINUSE") {
1330
- console.warn(chalk_1.default.yellow("!") + ` Lasso bridge port ${bridgePort} is already in use; reusing the existing bridge.`);
1331
- return;
1332
- }
1333
- console.error(chalk_1.default.red("Lasso bridge error:"), error.message);
1334
- });
1335
- bridgeServer.listen(bridgePort, "127.0.0.1");
1336
- function send(msg) {
1337
- const socket = currentSocket;
1338
- if (socket && socket.readyState === socket.OPEN) {
1339
- socket.send(JSON.stringify(msg));
1340
- }
1341
- }
1342
- return {
1343
- send,
1344
- close() {
1345
- if (bridgeListening)
1346
- bridgeServer.close();
1347
- wss.close();
1348
- },
1349
- };
999
+ return { ok: true };
1350
1000
  }