@lasso-ai/cli 1.0.22 → 1.0.24

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 (2) hide show
  1. package/dist/cli/agent.js +119 -79
  2. package/package.json +1 -1
package/dist/cli/agent.js CHANGED
@@ -28,22 +28,31 @@ async function detectLocalAgents() {
28
28
  const ignored = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo"]);
29
29
  const sourceExtensions = /\.(tsx?|jsx?|vue|svelte|css|scss|html)$/i;
30
30
  const sourceFileCache = new Map();
31
- async function sourceFiles(directory) {
31
+ async function sourceFiles(directory, budget = { remaining: 40 }) {
32
+ if (budget.remaining <= 0)
33
+ return [];
32
34
  const cached = sourceFileCache.get(directory);
33
- if (cached && cached.expiresAt > Date.now())
34
- return cached.files;
35
+ if (cached && cached.expiresAt > Date.now()) {
36
+ const files = cached.files.slice(0, budget.remaining);
37
+ budget.remaining -= files.length;
38
+ return files;
39
+ }
35
40
  const entries = await promises_1.default.readdir(directory, { withFileTypes: true });
36
41
  const files = [];
37
42
  for (const entry of entries) {
38
43
  if (ignored.has(entry.name) || entry.name.startsWith("."))
39
44
  continue;
40
45
  const fullPath = node_path_1.default.join(directory, entry.name);
41
- if (entry.isDirectory())
42
- files.push(...(await sourceFiles(fullPath)));
43
- else if (sourceExtensions.test(entry.name))
44
- files.push(fullPath);
45
- if (files.length >= 40)
46
+ if (budget.remaining <= 0)
46
47
  break;
48
+ if (entry.isDirectory()) {
49
+ const nested = await sourceFiles(fullPath, budget);
50
+ files.push(...nested);
51
+ }
52
+ else if (sourceExtensions.test(entry.name)) {
53
+ files.push(fullPath);
54
+ budget.remaining -= 1;
55
+ }
47
56
  }
48
57
  sourceFileCache.set(directory, { expiresAt: Date.now() + 5000, files });
49
58
  return files;
@@ -346,52 +355,113 @@ function localCommand(provider, model, prompt) {
346
355
  }
347
356
  return { command: "codex", args: ["exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", ...(selectedModel ? ["--model", selectedModel] : []), prompt || ""] };
348
357
  }
358
+ function runLocalCommand(cwd, command, args, provider, signal, onProgress) {
359
+ return new Promise((resolve, reject) => {
360
+ const child = (0, node_child_process_1.spawn)(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
361
+ const label = provider === "opencode" ? "OpenCode" : provider === "codex" ? "Codex" : "Claude Code";
362
+ let stdout = "";
363
+ let stderr = "";
364
+ let pendingStdout = "";
365
+ let pendingStderr = "";
366
+ let settled = false;
367
+ let timedOut = false;
368
+ const timeout = setTimeout(() => {
369
+ timedOut = true;
370
+ onProgress?.(`${label} did not respond; stopping this task…`);
371
+ child.kill("SIGTERM");
372
+ setTimeout(() => child.kill("SIGKILL"), 3000).unref();
373
+ }, 5 * 60 * 1000);
374
+ timeout.unref();
375
+ const consume = (chunk, stream) => {
376
+ const value = String(chunk);
377
+ if (stream === "stdout") {
378
+ stdout += value;
379
+ pendingStdout += value;
380
+ }
381
+ else {
382
+ stderr += value;
383
+ pendingStderr += value;
384
+ }
385
+ const pending = stream === "stdout" ? pendingStdout : pendingStderr;
386
+ const lines = pending.split(/\r?\n/);
387
+ const remainder = lines.pop() || "";
388
+ if (stream === "stdout")
389
+ pendingStdout = remainder;
390
+ else
391
+ pendingStderr = remainder;
392
+ for (const line of lines) {
393
+ const progress = progressFromLine(line, provider);
394
+ if (progress)
395
+ onProgress?.(progress.message, progress.detail);
396
+ else if (stream === "stderr" && line.trim())
397
+ onProgress?.(`${label} · ${snippet(line, 110)}`);
398
+ }
399
+ };
400
+ child.stdout.on("data", (chunk) => consume(chunk, "stdout"));
401
+ child.stderr.on("data", (chunk) => consume(chunk, "stderr"));
402
+ child.once("spawn", () => onProgress?.(`${label} process started${child.pid ? ` · PID ${child.pid}` : ""}`));
403
+ const heartbeat = setInterval(() => {
404
+ if (!signal?.aborted)
405
+ onProgress?.(`${label} is still running${child.pid ? ` · PID ${child.pid}` : ""}…`);
406
+ }, 15000);
407
+ heartbeat.unref();
408
+ const abort = () => child.kill("SIGTERM");
409
+ if (signal?.aborted)
410
+ abort();
411
+ else
412
+ signal?.addEventListener("abort", abort, { once: true });
413
+ child.once("error", (error) => {
414
+ if (settled)
415
+ return;
416
+ settled = true;
417
+ clearTimeout(timeout);
418
+ clearInterval(heartbeat);
419
+ signal?.removeEventListener("abort", abort);
420
+ reject(error);
421
+ });
422
+ child.once("close", (code) => {
423
+ if (settled)
424
+ return;
425
+ settled = true;
426
+ clearTimeout(timeout);
427
+ clearInterval(heartbeat);
428
+ signal?.removeEventListener("abort", abort);
429
+ if (timedOut) {
430
+ reject(new Error(`${label} did not finish within 5 minutes. Check its login or approval prompt, then retry.`));
431
+ return;
432
+ }
433
+ const stdoutTail = pendingStdout.trim();
434
+ const stderrTail = pendingStderr.trim();
435
+ if (stdoutTail) {
436
+ stdout += pendingStdout;
437
+ const progress = progressFromLine(stdoutTail, provider);
438
+ if (progress)
439
+ onProgress?.(progress.message, progress.detail);
440
+ }
441
+ if (stderrTail) {
442
+ const progress = progressFromLine(stderrTail, provider);
443
+ if (progress)
444
+ onProgress?.(progress.message, progress.detail);
445
+ }
446
+ resolve({ stdout, stderr, exitCode: code ?? 1 });
447
+ });
448
+ });
449
+ }
349
450
  async function proposeWithLocalAgent(cwd, instruction, context, config, signal, onProgress) {
350
451
  const outputContract = `Return ONLY valid JSON in this exact shape: {"summary":"short explanation","changes":[{"filePath":"relative/path","oldString":"exact existing text","newString":"replacement text"}]}. Treat the supplied source context as read-only. Before returning, verify every oldString against that context. Use project-relative paths only. Do not edit files, run write commands, commit, or produce markdown fences.`;
351
452
  const prompt = `${instruction}\n\n${outputContract}\n\nLasso has already assembled this source context:\n${context || "No matching source context was found."}`;
352
453
  const local = localCommand(config.provider, config.model, prompt);
353
454
  const command = local.command;
354
455
  const args = local.args;
355
- const child = (0, node_child_process_1.spawn)(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
356
- let stdout = "";
357
- let stderr = "";
358
- let pending = "";
359
- const consume = (chunk) => {
360
- pending += String(chunk);
361
- const lines = pending.split(/\r?\n/);
362
- pending = lines.pop() || "";
363
- for (const line of lines) {
364
- stdout += `${line}\n`;
365
- const progress = progressFromLine(line, config.provider);
366
- if (progress)
367
- onProgress?.(progress.message, progress.detail);
368
- }
369
- };
370
- child.stdout.on("data", consume);
371
- child.stderr.on("data", (chunk) => {
372
- stderr += String(chunk);
373
- });
374
- if (signal) {
375
- if (signal.aborted)
376
- child.kill("SIGTERM");
377
- signal.addEventListener("abort", () => child.kill("SIGTERM"), { once: true });
378
- }
379
- const exitCode = await new Promise((resolve, reject) => {
380
- child.once("error", reject);
381
- child.once("close", (code) => resolve(code ?? 1));
382
- });
383
- if (pending.trim()) {
384
- stdout += pending;
385
- const progress = progressFromLine(pending, config.provider);
386
- if (progress)
387
- onProgress?.(progress.message, progress.detail);
388
- }
389
- if (exitCode !== 0)
390
- throw new Error(localAgentError(command, stderr, exitCode));
391
- return extractLocalAgentProposal(stdout, config.provider);
456
+ const result = await runLocalCommand(cwd, command, args, config.provider, signal, onProgress);
457
+ if (result.exitCode !== 0)
458
+ throw new Error(localAgentError(command, result.stderr, result.exitCode));
459
+ return extractLocalAgentProposal(result.stdout, config.provider);
392
460
  }
393
461
  async function proposeChanges(cwd, input, config, signal, onProgress) {
462
+ onProgress?.("Reading source context…");
394
463
  const context = await contextFor(cwd, input.element);
464
+ onProgress?.("Starting the coding agent…");
395
465
  const visualContext = input.context ? { ...input.context, screenshots: undefined } : undefined;
396
466
  const history = input.messages?.map((message) => `${message.role}: ${message.content}`).join("\n") || input.instruction;
397
467
  const priorChanges = input.changesHistory?.length ? JSON.stringify(input.changesHistory, null, 2) : "None";
@@ -457,40 +527,10 @@ async function answerQuestion(cwd, input, config, signal, onProgress) {
457
527
  const prompt = `Answer the user's question conversationally and directly. Do not propose file changes and do not return JSON. If the question is about the selected UI, use the selection and source context below.\n\nUser question:\n${input.question}\n\nSelected element:\n${JSON.stringify(input.element, null, 2)}\n\nVisual context:\n${JSON.stringify({ ...input.context, screenshots: undefined }, null, 2)}\n\nConversation:\n${history}\n\nRelevant source context:\n${context || "No matching source context was found."}`;
458
528
  if (config.provider === "claude-code" || config.provider === "codex" || config.provider === "opencode") {
459
529
  const local = localCommand(config.provider, config.model, prompt);
460
- const command = local.command;
461
- const args = local.args;
462
- const child = (0, node_child_process_1.spawn)(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
463
- let output = "";
464
- let pending = "";
465
- let stderr = "";
466
- const consume = (chunk) => {
467
- pending += String(chunk);
468
- const lines = pending.split(/\r?\n/);
469
- pending = lines.pop() || "";
470
- for (const line of lines) {
471
- output += `${line}\n`;
472
- const progress = progressFromLine(line, config.provider);
473
- if (progress)
474
- onProgress?.(progress.message, progress.detail);
475
- }
476
- };
477
- child.stdout.on("data", consume);
478
- child.stderr.on("data", (chunk) => { stderr += String(chunk); });
479
- if (signal)
480
- signal.addEventListener("abort", () => child.kill("SIGTERM"), { once: true });
481
- const code = await new Promise((resolve, reject) => {
482
- child.once("error", reject);
483
- child.once("close", (status) => resolve(status ?? 1));
484
- });
485
- if (pending.trim()) {
486
- output += pending;
487
- const progress = progressFromLine(pending, config.provider);
488
- if (progress)
489
- onProgress?.(progress.message, progress.detail);
490
- }
491
- if (code !== 0)
492
- throw new Error(localAgentError(command, stderr, code));
493
- return extractLocalAgentText(output, config.provider).trim();
530
+ const result = await runLocalCommand(cwd, local.command, local.args, config.provider, signal, onProgress);
531
+ if (result.exitCode !== 0)
532
+ throw new Error(localAgentError(local.command, result.stderr, result.exitCode));
533
+ return extractLocalAgentText(result.stdout, config.provider).trim();
494
534
  }
495
535
  const model = config.model || (config.provider === "google" ? "gemini-2.5-flash" : config.provider === "openai" ? "gpt-4.1-mini" : config.provider === "ollama" ? "llama3.2" : "claude-sonnet-4-20250514");
496
536
  let response;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lasso-ai/cli",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "Select any part of your running app, describe a change, and let AI edit the real source code.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli/index.js",