@lasso-ai/cli 1.5.0 → 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 (52) hide show
  1. package/dist/cli/agent.d.ts +1 -0
  2. package/dist/cli/backends.d.ts +53 -0
  3. package/dist/cli/backends.js +267 -0
  4. package/dist/cli/bridge.d.ts +8 -5
  5. package/dist/cli/bridge.js +357 -151
  6. package/dist/cli/host/install.js +12 -1
  7. package/dist/cli/local-runtime.d.ts +28 -0
  8. package/dist/cli/local-runtime.js +294 -0
  9. package/dist/cli/oneshot.js +28 -10
  10. package/dist/cli/runtime-client.d.ts +91 -10
  11. package/dist/cli/runtime-client.js +38 -3
  12. package/dist/overlay.js +44 -28
  13. package/dist/runtime/__tests__/adapter-normalization.test.d.ts +1 -0
  14. package/dist/runtime/__tests__/adapter-normalization.test.js +392 -0
  15. package/dist/runtime/__tests__/state-machine.test.d.ts +1 -0
  16. package/dist/runtime/__tests__/state-machine.test.js +107 -0
  17. package/dist/runtime/__tests__/verification-engine.test.d.ts +1 -0
  18. package/dist/runtime/__tests__/verification-engine.test.js +91 -0
  19. package/dist/runtime/__tests__/workspace-guard.test.d.ts +1 -0
  20. package/dist/runtime/__tests__/workspace-guard.test.js +104 -0
  21. package/dist/runtime/adapters/claude-code-adapter.d.ts +46 -0
  22. package/dist/runtime/adapters/claude-code-adapter.js +230 -0
  23. package/dist/runtime/adapters/codex-cli-adapter.d.ts +44 -0
  24. package/dist/runtime/adapters/codex-cli-adapter.js +202 -0
  25. package/dist/runtime/adapters/cursor-adapter.d.ts +47 -0
  26. package/dist/runtime/adapters/cursor-adapter.js +214 -0
  27. package/dist/runtime/adapters/factory.d.ts +19 -0
  28. package/dist/runtime/adapters/factory.js +48 -0
  29. package/dist/runtime/adapters/local-agent-adapter.d.ts +143 -0
  30. package/dist/runtime/adapters/local-agent-adapter.js +408 -0
  31. package/dist/runtime/adapters/opencode-adapter.d.ts +43 -0
  32. package/dist/runtime/adapters/opencode-adapter.js +210 -0
  33. package/dist/runtime/agent-runtime.d.ts +286 -0
  34. package/dist/runtime/agent-runtime.js +8 -0
  35. package/dist/runtime/agent-state-machine.d.ts +67 -0
  36. package/dist/runtime/agent-state-machine.js +234 -0
  37. package/dist/runtime/backend-interface.d.ts +272 -0
  38. package/dist/runtime/backend-interface.js +38 -0
  39. package/dist/runtime/event-mapper.d.ts +11 -0
  40. package/dist/runtime/event-mapper.js +40 -0
  41. package/dist/runtime/index.d.ts +20 -0
  42. package/dist/runtime/index.js +43 -0
  43. package/dist/runtime/local-session-runner.d.ts +60 -0
  44. package/dist/runtime/local-session-runner.js +185 -0
  45. package/dist/runtime/review.d.ts +24 -0
  46. package/dist/runtime/review.js +30 -0
  47. package/dist/runtime/verification-engine.d.ts +71 -0
  48. package/dist/runtime/verification-engine.js +374 -0
  49. package/dist/runtime/workspace-guard.d.ts +70 -0
  50. package/dist/runtime/workspace-guard.js +321 -0
  51. package/docs/local-coding-agents.md +183 -0
  52. package/package.json +22 -3
@@ -18,6 +18,8 @@ const project_1 = require("./project");
18
18
  const auth_1 = require("./auth");
19
19
  const snapshot_1 = require("./snapshot");
20
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) {
@@ -82,31 +84,50 @@ function detectProjectFramework(cwd) {
82
84
  catch { }
83
85
  return "unknown";
84
86
  }
85
- // Helper to extract SourceChange from runtime session
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).
86
90
  function extractChangesFromSession(session) {
87
91
  if (!session)
88
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
+ }
89
103
  const changes = [];
90
104
  for (const action of session.actions || []) {
91
- if (action.type === "edit_file" && action.data) {
105
+ const data = (action.data || {});
106
+ const filePath = (data.filePath || data.path);
107
+ if (!filePath)
108
+ continue;
109
+ if (action.type === "edit_file") {
92
110
  changes.push({
93
- filePath: action.data.filePath,
94
- oldString: action.data.oldString,
95
- newString: action.data.newString,
111
+ filePath,
112
+ oldString: data.oldString ?? "",
113
+ newString: data.newString ?? "",
114
+ operation: "modified",
96
115
  });
97
116
  }
98
- else if (action.type === "create_file" && action.data) {
117
+ else if (action.type === "create_file") {
99
118
  changes.push({
100
- filePath: action.data.filePath,
119
+ filePath,
101
120
  oldString: "",
102
- newString: action.data.content || "",
121
+ newString: data.content ?? "",
122
+ operation: "created",
103
123
  });
104
124
  }
105
- else if (action.type === "delete_file" && action.data) {
125
+ else if (action.type === "delete_file") {
106
126
  changes.push({
107
- filePath: action.data.filePath,
108
- oldString: "(deleted)",
127
+ filePath,
128
+ oldString: data.oldContent ?? "(deleted)",
109
129
  newString: "",
130
+ operation: "deleted",
110
131
  });
111
132
  }
112
133
  }
@@ -118,7 +139,11 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
118
139
  let overlaySocket = null;
119
140
  const taskSnapshots = new Map();
120
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();
121
145
  const taskControllers = new Map();
146
+ const correctiveAttempts = new Map();
122
147
  const envRoots = [cwd, node_path_1.default.join(cwd, "web"), node_path_1.default.join(cwd, "server")];
123
148
  const fileEnv = envRoots.reduce((values, root) => ({
124
149
  ...values,
@@ -167,6 +192,27 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
167
192
  { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash", provider: "google", locked: false },
168
193
  ];
169
194
  }
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",
202
+ };
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
+ }
170
216
  const pageIgnoredDirs = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo", "out", "coverage", ".vercel", ".cache"]);
171
217
  const resolveInsideProject = (root, relative) => {
172
218
  const target = node_path_1.default.resolve(root, relative && relative !== "." ? relative : "");
@@ -202,6 +248,128 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
202
248
  res.writeHead(200, { "content-type": "application/json" });
203
249
  res.end(JSON.stringify({ ok: true }));
204
250
  });
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;
333
+ }
334
+ }
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) {
338
+ try {
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
+ }));
363
+ }
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
+ }));
371
+ }
372
+ }
205
373
  // Handler for edit/ask - uses runtime
206
374
  async function handleEditOrAsk(message, socket, taskId, instruction, model, provider, context, element, messages) {
207
375
  taskControllers.get(taskId)?.abort();
@@ -214,6 +382,10 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
214
382
  message: "Starting...",
215
383
  }));
216
384
  try {
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 || "");
217
389
  const sessionInput = {
218
390
  userPrompt: instruction,
219
391
  project: {
@@ -238,103 +410,21 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
238
410
  route: context?.position?.route,
239
411
  screenshot: context?.screenshots?.full,
240
412
  } : undefined,
413
+ backendConfig: isLocalAgent ? {
414
+ type: "local_agent",
415
+ provider: provider,
416
+ modelId: backendModelId,
417
+ projectPath: cwd,
418
+ } : undefined,
241
419
  };
242
- const session = await runtimeClient.createSession(sessionInput);
420
+ const facade = isLocalAgent ? (0, local_runtime_1.getLocalRuntime)() : runtimeClient;
421
+ const session = await facade.createSession(sessionInput);
243
422
  activeSessions.set(taskId, session);
244
- // Create snapshot before changes
245
- taskSnapshots.set(taskId, []);
246
- // Note: Snapshot will be created after changes are extracted from session
247
- await runtimeClient.runSession(session.id, cwd, (event) => {
248
- if (controller.signal.aborted)
249
- return;
250
- switch (event.type) {
251
- case "agent_started":
252
- case "agent_reasoning":
253
- socket.send(JSON.stringify({
254
- type: "agent_status",
255
- taskId,
256
- status: "thinking",
257
- message: event.data.summary || "Working...",
258
- }));
259
- break;
260
- case "tool_started":
261
- socket.send(JSON.stringify({
262
- type: "agent_status",
263
- taskId,
264
- status: "working",
265
- message: `Running ${event.data.tool || "tool"}...`,
266
- }));
267
- break;
268
- case "tool_completed":
269
- socket.send(JSON.stringify({
270
- type: "agent_status",
271
- taskId,
272
- status: "working",
273
- message: "Completed tool",
274
- }));
275
- break;
276
- case "phase_changed":
277
- const phase = event.data.phase;
278
- socket.send(JSON.stringify({
279
- type: "agent_status",
280
- taskId,
281
- status: "working",
282
- message: `Phase: ${phase}`,
283
- }));
284
- break;
285
- case "verification_started":
286
- socket.send(JSON.stringify({
287
- type: "agent_status",
288
- taskId,
289
- status: "review",
290
- message: "Verifying changes...",
291
- }));
292
- break;
293
- case "verification_completed":
294
- const passed = event.data.passed;
295
- socket.send(JSON.stringify({
296
- type: "agent_status",
297
- taskId,
298
- status: passed ? "working" : "error",
299
- message: passed ? "Verification passed" : "Verification failed",
300
- }));
301
- break;
302
- case "agent_completed":
303
- runtimeClient.getSession(session.id).then((finalSession) => {
304
- const changes = extractChangesFromSession(finalSession);
305
- // Create snapshot with affected files
306
- const filePaths = changes.map(c => c.filePath);
307
- if (filePaths.length > 0) {
308
- (0, snapshot_1.createSnapshot)(cwd, taskId, `Runtime session: ${instruction}`, filePaths, undefined, session.id);
309
- }
310
- socket.send(JSON.stringify({
311
- type: "agent_status",
312
- taskId,
313
- status: "review",
314
- message: "Review changes",
315
- changes,
316
- }));
317
- });
318
- break;
319
- case "error":
320
- socket.send(JSON.stringify({
321
- type: "agent_status",
322
- taskId,
323
- status: "error",
324
- message: event.data.error || "Unknown error",
325
- }));
326
- break;
327
- case "waiting_for_user":
328
- socket.send(JSON.stringify({
329
- type: "agent_prompt",
330
- taskId,
331
- prompt: {
332
- message: event.data?.question || "Input required",
333
- kind: "input",
334
- },
335
- }));
336
- break;
337
- }
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);
338
428
  }, controller.signal);
339
429
  }
340
430
  catch (error) {
@@ -346,55 +436,155 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
346
436
  }));
347
437
  }
348
438
  }
349
- // Handler for apply - applies pending changes
439
+ // Handler for apply - applies approved pending changes, then runs server-side
440
+ // verification. On failure a corrective run is started automatically.
350
441
  async function handleApply(message, socket, cwd) {
351
442
  const { taskId, changes } = message;
352
443
  const session = activeSessions.get(taskId);
353
- const snapshot = taskSnapshots.get(taskId) || [];
354
- // Create snapshot before applying changes
355
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;
451
+ try {
452
+ content = node_fs_1.default.readFileSync(abs, "utf8");
453
+ }
454
+ catch {
455
+ content = null;
456
+ }
457
+ return { filePath: change.filePath, content };
458
+ }));
459
+ }
356
460
  if (filePaths.length > 0) {
357
461
  (0, snapshot_1.createSnapshot)(cwd, taskId, `Apply changes for task ${taskId}`, filePaths, undefined, session?.id);
358
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) {
468
+ try {
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
+ }
478
+ }
479
+ catch { }
480
+ }
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
+ };
501
+ }
502
+ catch {
503
+ validation = undefined;
504
+ }
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) {
359
518
  for (const change of changes) {
360
519
  const filePath = node_path_1.default.join(cwd, change.filePath);
361
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");
362
525
  try {
363
526
  if (!node_fs_1.default.existsSync(dir)) {
364
527
  node_fs_1.default.mkdirSync(dir, { recursive: true });
365
528
  }
366
- if (change.oldString === "" && change.newString === "") {
367
- // Delete file
529
+ if (operation === "deleted") {
368
530
  if (node_fs_1.default.existsSync(filePath)) {
369
531
  node_fs_1.default.unlinkSync(filePath);
370
532
  }
371
533
  }
372
- else if (change.oldString === "") {
373
- // Create file
374
- node_fs_1.default.writeFileSync(filePath, change.newString);
534
+ else if (operation === "created" || (oldString === "" && operation !== "modified")) {
535
+ node_fs_1.default.writeFileSync(filePath, newString);
375
536
  }
376
537
  else {
377
- // Edit file
538
+ // Full-content replacement; split/join avoids String.replace's
539
+ // special $-pattern handling in the replacement text.
378
540
  const content = node_fs_1.default.readFileSync(filePath, "utf8");
379
- const newContent = content.replace(change.oldString, change.newString);
380
- node_fs_1.default.writeFileSync(filePath, newContent);
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);
381
552
  }
382
553
  }
383
554
  catch (error) {
384
- socket.send(JSON.stringify({
385
- type: "agent_status",
386
- taskId,
387
- status: "error",
388
- message: `Failed to apply ${change.filePath}: ${error instanceof Error ? error.message : "Unknown error"}`,
389
- }));
390
- return;
555
+ return `Failed to apply ${change.filePath}: ${error instanceof Error ? error.message : "Unknown error"}`;
391
556
  }
392
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);
393
569
  socket.send(JSON.stringify({
394
- type: "applied",
570
+ type: "agent_status",
395
571
  taskId,
396
- message: "Changes applied successfully",
572
+ status: "thinking",
573
+ message: "Fixing verification errors...",
397
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
+ }
398
588
  }
399
589
  // Handler for undo - restores snapshot
400
590
  async function handleUndo(message, socket, cwd) {
@@ -424,13 +614,24 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
424
614
  const { taskId } = message;
425
615
  if (taskId) {
426
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(() => { });
427
621
  activeSessions.delete(taskId);
622
+ taskFacades.delete(taskId);
428
623
  }
429
624
  else {
430
625
  for (const controller of taskControllers.values())
431
626
  controller.abort();
432
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
+ }
433
633
  activeSessions.clear();
634
+ taskFacades.clear();
434
635
  }
435
636
  socket.send(JSON.stringify({
436
637
  type: "agent_status",
@@ -453,10 +654,19 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
453
654
  return;
454
655
  }
455
656
  try {
456
- await runtimeClient.switchModel(session.id, {
657
+ const isLocalAgent = ["claude_code", "opencode", "codex_cli", "cursor"].includes(provider || "");
658
+ const facade = taskFacades.get(taskId) || runtimeClient;
659
+ await facade.switchModel(session.id, {
457
660
  provider: provider,
458
661
  id: modelId,
459
- });
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);
460
670
  socket.send(JSON.stringify({
461
671
  type: "agent_status",
462
672
  taskId,
@@ -487,6 +697,8 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
487
697
  message: "Starting oneshot...",
488
698
  }));
489
699
  try {
700
+ const isLocalAgent = ["claude_code", "opencode", "codex_cli", "cursor"].includes(provider || "");
701
+ const backendModelId = (0, backends_1.modelIdForBackend)(model || "", provider || "");
490
702
  const sessionInput = {
491
703
  userPrompt: prompt,
492
704
  project: {
@@ -500,32 +712,26 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
500
712
  provider: (provider || "anthropic"),
501
713
  id: model || "claude-3-5-sonnet-20241022",
502
714
  },
715
+ backendConfig: isLocalAgent ? {
716
+ type: "local_agent",
717
+ provider: provider,
718
+ modelId: backendModelId,
719
+ projectPath: cwd,
720
+ } : undefined,
503
721
  };
504
- const session = await runtimeClient.createSession(sessionInput);
722
+ const facade = isLocalAgent ? (0, local_runtime_1.getLocalRuntime)() : runtimeClient;
723
+ const session = await facade.createSession(sessionInput);
505
724
  activeSessions.set(taskId, session);
506
- await runtimeClient.runSession(session.id, cwd, (event) => {
507
- if (controller.signal.aborted)
508
- return;
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);
509
730
  if (event.type === "agent_completed") {
510
- runtimeClient.getSession(session.id).then((finalSession) => {
511
- const changes = extractChangesFromSession(finalSession);
512
- const filePaths = changes.map(c => c.filePath);
513
- if (filePaths.length > 0) {
514
- (0, snapshot_1.createSnapshot)(cwd, taskId, `Oneshot: ${instruction}`, filePaths, undefined, session.id);
515
- }
516
- socket.send(JSON.stringify({
517
- type: "assistant_message",
518
- taskId,
519
- message: "Oneshot completed",
520
- }));
521
- });
522
- }
523
- else if (event.type === "error") {
524
731
  socket.send(JSON.stringify({
525
- type: "agent_status",
732
+ type: "assistant_message",
526
733
  taskId,
527
- status: "error",
528
- message: event.data.error || "Runtime error",
734
+ message: "Oneshot completed",
529
735
  }));
530
736
  }
531
737
  }, controller.signal);
@@ -543,7 +749,7 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
543
749
  async function handleRestoreSnapshot(message, socket, cwd) {
544
750
  const { snapshotId } = message;
545
751
  try {
546
- await (0, snapshot_1.restoreSnapshot)(snapshotId, cwd);
752
+ await (0, snapshot_1.restoreSnapshot)(cwd, snapshotId);
547
753
  socket.send(JSON.stringify({
548
754
  type: "agent_status",
549
755
  status: "working",
@@ -579,7 +785,7 @@ async function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort
579
785
  async function handleDeleteSnapshot(message, socket, cwd) {
580
786
  const { snapshotId } = message;
581
787
  try {
582
- await (0, snapshot_1.deleteSnapshot)(snapshotId, cwd);
788
+ await (0, snapshot_1.deleteSnapshot)(cwd, snapshotId);
583
789
  socket.send(JSON.stringify({
584
790
  type: "agent_status",
585
791
  status: "working",