@lasso-ai/cli 1.0.18 → 1.0.20

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.
@@ -14,6 +14,7 @@ export type BridgeMessage = {
14
14
  from: "overlay" | "cli";
15
15
  } | {
16
16
  type: "edit";
17
+ taskId: string;
17
18
  instruction: string;
18
19
  model: string;
19
20
  provider?: "anthropic" | "openai" | "google" | "ollama" | "cli";
@@ -48,6 +49,7 @@ export type BridgeMessage = {
48
49
  };
49
50
  } | {
50
51
  type: "ask";
52
+ taskId: string;
51
53
  question: string;
52
54
  model: string;
53
55
  provider?: "anthropic" | "openai" | "google" | "ollama" | "cli";
@@ -81,11 +83,14 @@ export type BridgeMessage = {
81
83
  details: string;
82
84
  } | {
83
85
  type: "apply";
86
+ taskId: string;
84
87
  changes: SourceChange[];
85
88
  } | {
86
89
  type: "undo";
90
+ taskId?: string;
87
91
  } | {
88
92
  type: "stop";
93
+ taskId?: string;
89
94
  } | {
90
95
  type: "git_status";
91
96
  } | {
@@ -101,6 +106,7 @@ export type BridgeMessage = {
101
106
  type: "git_push";
102
107
  } | {
103
108
  type: "agent_status";
109
+ taskId?: string;
104
110
  status: "thinking" | "working" | "review" | "error" | "stopped";
105
111
  message: string;
106
112
  } | {
@@ -134,18 +140,22 @@ export type ServerBridgeMessage = {
134
140
  error?: string;
135
141
  } | {
136
142
  type: "agent_status";
143
+ taskId?: string;
137
144
  status: "thinking" | "working" | "review" | "error" | "stopped";
138
145
  message: string;
139
146
  detail?: string;
140
147
  changes?: SourceChange[];
141
148
  } | {
142
149
  type: "assistant_message";
150
+ taskId?: string;
143
151
  message: string;
144
152
  } | {
145
153
  type: "applied";
154
+ taskId?: string;
146
155
  message: string;
147
156
  } | {
148
157
  type: "undone";
158
+ taskId?: string;
149
159
  message: string;
150
160
  } | {
151
161
  type: "transcribe_result";
@@ -147,10 +147,10 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
147
147
  const bridgeServer = node_http_1.default.createServer(); // dedicated, empty HTTP server
148
148
  const wss = new ws_1.WebSocketServer({ server: bridgeServer });
149
149
  let overlaySocket = null;
150
- let lastSnapshot = [];
151
- let lastEditRequest = null;
152
- let lastEditConfig = null;
153
- let reviewRefreshAttempts = 0;
150
+ const taskSnapshots = new Map();
151
+ const editRequests = new Map();
152
+ const editConfigs = new Map();
153
+ const reviewRefreshAttempts = new Map();
154
154
  const envRoots = [cwd, node_path_1.default.join(cwd, "web"), node_path_1.default.join(cwd, "server")];
155
155
  const fileEnv = envRoots.reduce((values, root) => ({
156
156
  ...values,
@@ -179,11 +179,15 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
179
179
  };
180
180
  })();
181
181
  let activeAgentController = null;
182
+ const taskControllers = new Map();
182
183
  let localAgents = new Set();
183
184
  bridgeServer.on("request", (req, res) => {
184
185
  if (req.method !== "POST" || req.url?.split("?", 1)[0] !== "/__lasso/bridge/restart")
185
186
  return;
186
187
  activeAgentController?.abort();
188
+ for (const controller of taskControllers.values())
189
+ controller.abort();
190
+ taskControllers.clear();
187
191
  activeAgentController = null;
188
192
  for (const socket of wss.clients)
189
193
  socket.close(1000, "Bridge restarted by the CLI");
@@ -270,40 +274,41 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
270
274
  socket.send(JSON.stringify({ type: "git_state", git }));
271
275
  });
272
276
  const runEditReview = (request, config, statusMessage) => {
273
- activeAgentController?.abort();
277
+ taskControllers.get(request.taskId)?.abort();
274
278
  const controller = new AbortController();
275
- activeAgentController = controller;
279
+ taskControllers.set(request.taskId, controller);
276
280
  if (statusMessage && socket.readyState === socket.OPEN) {
277
- socket.send(JSON.stringify({ type: "agent_status", status: "working", message: statusMessage }));
281
+ socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "working", message: statusMessage }));
278
282
  }
279
283
  void (0, agent_1.proposeChanges)(cwd, request, config, controller.signal, (message, detail) => {
280
284
  if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
281
- socket.send(JSON.stringify({ type: "agent_status", status: "working", message, detail }));
285
+ socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "working", message, detail }));
282
286
  }
283
287
  })
284
288
  .then((proposal) => {
285
289
  if (controller.signal.aborted || socket.readyState !== socket.OPEN)
286
290
  return;
287
291
  if (!proposalMatchesCurrentSource(cwd, proposal.changes)) {
288
- if (reviewRefreshAttempts < 1) {
289
- reviewRefreshAttempts += 1;
292
+ const refreshAttempts = reviewRefreshAttempts.get(request.taskId) || 0;
293
+ if (refreshAttempts < 1) {
294
+ reviewRefreshAttempts.set(request.taskId, refreshAttempts + 1);
290
295
  runEditReview(request, config, "The source changed while the proposal was being prepared. Refreshing the review…");
291
296
  }
292
297
  else {
293
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message: "The source is still changing. Stop the dev-server edit or try the request again." }));
298
+ socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "error", message: "The source is still changing. Stop the dev-server edit or try the request again." }));
294
299
  }
295
300
  return;
296
301
  }
297
- socket.send(JSON.stringify({ type: "agent_status", status: "review", message: proposal.summary, changes: proposal.changes }));
302
+ socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "review", message: proposal.summary, changes: proposal.changes }));
298
303
  })
299
304
  .catch((error) => {
300
305
  if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
301
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message: error instanceof Error ? error.message : "The agent could not prepare a change." }));
306
+ 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." }));
302
307
  }
303
308
  })
304
309
  .finally(() => {
305
- if (activeAgentController === controller)
306
- activeAgentController = null;
310
+ if (taskControllers.get(request.taskId) === controller)
311
+ taskControllers.delete(request.taskId);
307
312
  });
308
313
  };
309
314
  socket.on("message", async (raw) => {
@@ -318,56 +323,59 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
318
323
  const localProvider = msg.provider === "cli";
319
324
  const cliProvider = msg.model.startsWith("claude-code:") ? "claude-code" : msg.model.startsWith("opencode:") ? "opencode" : "codex";
320
325
  if (!lassoKeyConfigured && !localProvider) {
321
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message: "Set your Lasso API key before asking the hosted agent a question." }));
326
+ 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." }));
322
327
  return;
323
328
  }
324
329
  if (!agentConfig && !localProvider) {
325
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message: "No hosted agent is configured. Select Claude Code/Codex or add a provider key." }));
330
+ 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." }));
326
331
  return;
327
332
  }
328
- activeAgentController?.abort();
333
+ taskControllers.get(msg.taskId)?.abort();
329
334
  const controller = new AbortController();
330
- activeAgentController = controller;
335
+ taskControllers.set(msg.taskId, controller);
331
336
  const selectedConfig = localProvider
332
337
  ? { provider: cliProvider, model: msg.model }
333
338
  : { ...agentConfig, provider: (msg.provider || agentConfig.provider), model: msg.model };
334
339
  void (0, agent_1.answerQuestion)(cwd, { question: msg.question, context: msg.context, element: msg.element, messages: msg.messages }, selectedConfig, controller.signal, (message, detail) => {
335
340
  if (!controller.signal.aborted && socket.readyState === socket.OPEN)
336
- socket.send(JSON.stringify({ type: "agent_status", status: "working", message, detail }));
341
+ socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "working", message, detail }));
337
342
  }).then((answer) => {
338
343
  if (!controller.signal.aborted)
339
- socket.send(JSON.stringify({ type: "assistant_message", message: answer }));
344
+ socket.send(JSON.stringify({ type: "assistant_message", taskId: msg.taskId, message: answer }));
340
345
  }).catch((error) => {
341
346
  if (!controller.signal.aborted)
342
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message: error instanceof Error ? error.message : "The agent could not answer." }));
347
+ socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "error", message: error instanceof Error ? error.message : "The agent could not answer." }));
343
348
  }).finally(() => {
344
- if (activeAgentController === controller)
345
- activeAgentController = null;
349
+ if (taskControllers.get(msg.taskId) === controller)
350
+ taskControllers.delete(msg.taskId);
346
351
  });
347
352
  }
348
353
  else if (msg.type === "edit") {
349
354
  const localProvider = msg.provider === "cli";
350
355
  const cliProvider = msg.model.startsWith("claude-code:") ? "claude-code" : msg.model.startsWith("opencode:") ? "opencode" : "codex";
351
356
  if (!lassoKeyConfigured && !localProvider) {
352
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message: "Set VITE_LASSO_API_KEY or NEXT_LASSO_API_KEY in your app environment before sending an edit." }));
357
+ 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." }));
353
358
  return;
354
359
  }
355
360
  if (!agentConfig && !localProvider) {
356
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message: "Add a supported agent key: GOOGLE_GENERATIVE_AI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY." }));
361
+ 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." }));
357
362
  return;
358
363
  }
359
364
  const selectedConfig = localProvider
360
365
  ? { provider: cliProvider, model: msg.model }
361
366
  : { ...agentConfig, provider: (msg.provider || agentConfig.provider), model: msg.model };
362
- lastEditRequest = msg;
363
- lastEditConfig = selectedConfig;
364
- reviewRefreshAttempts = 0;
367
+ editRequests.set(msg.taskId, msg);
368
+ editConfigs.set(msg.taskId, selectedConfig);
369
+ reviewRefreshAttempts.set(msg.taskId, 0);
365
370
  runEditReview(msg, selectedConfig);
366
371
  }
367
372
  else if (msg.type === "stop") {
368
- activeAgentController?.abort();
369
- activeAgentController = null;
370
- socket.send(JSON.stringify({ type: "agent_status", status: "stopped", message: "Agent stopped." }));
373
+ if (msg.taskId)
374
+ taskControllers.get(msg.taskId)?.abort();
375
+ else
376
+ for (const controller of taskControllers.values())
377
+ controller.abort();
378
+ socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "stopped", message: "Agent stopped." }));
371
379
  }
372
380
  else if (msg.type === "runtime_error") {
373
381
  socket.send(JSON.stringify({ type: "agent_status", status: "error", message: `Runtime error detected${msg.selectionId ? ` for selection ${msg.selectionId}` : ""}: ${msg.details}` }));
@@ -468,7 +476,7 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
468
476
  }
469
477
  else if (msg.type === "apply") {
470
478
  try {
471
- lastSnapshot = [];
479
+ const snapshots = [];
472
480
  const planned = new Map();
473
481
  for (const change of msg.changes) {
474
482
  const filePath = resolveProposedFile(cwd, change.filePath);
@@ -483,36 +491,43 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
483
491
  }
484
492
  // Validate every change before writing any file, then apply each
485
493
  // file's replacements from the end toward the beginning.
494
+ taskSnapshots.set(msg.taskId, snapshots);
486
495
  for (const [filePath, changes] of planned) {
487
496
  const content = changes[0].content;
488
- lastSnapshot.push({ filePath, content });
497
+ snapshots.push({ filePath, content });
489
498
  const nextContent = [...changes]
490
499
  .sort((a, b) => b.start - a.start)
491
500
  .reduce((value, change) => value.slice(0, change.start) + change.newString + value.slice(change.end), content);
492
501
  node_fs_1.default.writeFileSync(filePath, nextContent);
493
502
  }
494
- socket.send(JSON.stringify({ type: "applied", message: `${msg.changes.length} file${msg.changes.length === 1 ? "" : "s"} updated. Your dev server will reload.` }));
503
+ socket.send(JSON.stringify({ type: "applied", taskId: msg.taskId, message: `${msg.changes.length} file${msg.changes.length === 1 ? "" : "s"} updated. Your dev server will reload.` }));
495
504
  }
496
505
  catch (error) {
497
- for (const snapshot of lastSnapshot)
506
+ // Validation happens before writes, but restore this task's snapshot
507
+ // if a filesystem error occurs during the write phase.
508
+ for (const snapshot of taskSnapshots.get(msg.taskId) || [])
498
509
  node_fs_1.default.writeFileSync(snapshot.filePath, snapshot.content);
499
- lastSnapshot = [];
510
+ taskSnapshots.delete(msg.taskId);
500
511
  const message = error instanceof Error ? error.message : "The change could not be applied.";
501
512
  const sourceChanged = message.includes("The source changed after the suggestion was generated");
502
- if (sourceChanged && lastEditRequest && lastEditConfig && reviewRefreshAttempts < 1) {
503
- runEditReview(lastEditRequest, lastEditConfig, "The source changed. Refreshing the review against the current file…");
513
+ const editRequest = editRequests.get(msg.taskId);
514
+ const editConfig = editConfigs.get(msg.taskId);
515
+ const refreshAttempts = reviewRefreshAttempts.get(msg.taskId) || 0;
516
+ if (sourceChanged && editRequest && editConfig && refreshAttempts < 1) {
517
+ reviewRefreshAttempts.set(msg.taskId, refreshAttempts + 1);
518
+ runEditReview(editRequest, editConfig, "The source changed. Refreshing the review against the current file…");
504
519
  }
505
520
  else {
506
- socket.send(JSON.stringify({ type: "agent_status", status: "error", message }));
521
+ socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "error", message }));
507
522
  }
508
523
  }
509
524
  }
510
525
  else if (msg.type === "undo") {
511
- for (const snapshot of lastSnapshot)
526
+ const snapshots = taskSnapshots.get(msg.taskId || "") || [];
527
+ for (const snapshot of snapshots)
512
528
  node_fs_1.default.writeFileSync(snapshot.filePath, snapshot.content);
513
- const count = lastSnapshot.length;
514
- lastSnapshot = [];
515
- socket.send(JSON.stringify({ type: "undone", message: count ? "The accepted change was reverted." : "There is no accepted change to undo." }));
529
+ taskSnapshots.delete(msg.taskId || "");
530
+ socket.send(JSON.stringify({ type: "undone", taskId: msg.taskId, message: snapshots.length ? "The accepted change was reverted." : "There is no accepted change to undo." }));
516
531
  }
517
532
  });
518
533
  socket.on("close", () => {