@lasso-ai/cli 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -170,38 +170,46 @@ context (source + screenshot + instruction)
170
170
  │ Agent adapter │ interface: given context, return old/new string pairs
171
171
  └───────┬───────┘
172
172
  │
173
- ┌──────┼───────────────────┬─────────────────────┐
174
- ▼ ▼ ▼ ▼
175
- Built-in Claude Code User's own CLI coding Any future
176
- agent (headless/ agent (Cursor CLI, adapter
177
- (default) `claude -p`) Aider, etc.)
173
+ ┌──────┼──────────────┬─────────────────┐
174
+ ▼ ▼ ▼ ▼
175
+ Hosted Claude Code Codex OpenCode
176
+ model `claude -p` `codex exec` `opencode run`
178
177
  ```
179
178
 
180
- - **Built-in agent** (`agent: "builtin"`): default path, zero setup, calls a hosted model
181
- directly (`@anthropic-ai/sdk`) with the assembled context and the old/new-string contract
182
- from §5.3.
183
- - **Claude Code passthrough** (`agent: "claude-code"`): the CLI shells out to Claude Code
184
- in headless/print mode (`claude -p`), passing the assembled context as the prompt and
185
- parsing its file edits back into the same diff-preview pipeline. This lets a user keep
186
- using their existing Claude Code setup (subscription, project memory, MCP tools) while
187
- still getting the point-and-select UX.
188
- - **Bring-your-own-agent** (`agent: "custom"`): define the adapter interface once (context
189
- in, old/new-string pairs out) and let power users wire in whatever CLI coding tool they
190
- already trust. Lasso's value in this mode is entirely the capture + context-assembly
191
- pipeline (§4–5), not the model itself.
192
-
193
- Selection is a config choice, not an either/or product decision:
179
+ - **Hosted model** (`anthropic` | `openai` | `google` | `ollama`): the default path when
180
+ no local CLI is selected. Lasso calls the provider SDK directly (`@anthropic-ai/sdk`)
181
+ with the assembled context and the old/new-string contract from §5.3. The provider and
182
+ key come from `LASSO_AGENT_PROVIDER` plus the matching `*_API_KEY`, read from the
183
+ process environment or the project `.env`.
184
+ - **Claude Code passthrough** (`claude-code`): the CLI shells out to Claude Code in
185
+ headless/print mode (`claude -p … --permission-mode plan --output-format
186
+ stream-json --max-turns 3`), passing the assembled context as the prompt and parsing
187
+ its output back into the same diff-preview pipeline. This lets a user keep using their
188
+ existing Claude Code setup (subscription, project memory, MCP tools) while still
189
+ getting the point-and-select UX.
190
+ - **Codex passthrough** (`codex`): `codex exec --json --sandbox read-only
191
+ --skip-git-repo-check`. Lasso passes the repository trust check itself, so Codex's own
192
+ check is skipped, and the read-only sandbox keeps it from writing.
193
+ - **OpenCode passthrough** (`opencode`): `opencode run --format json --print-logs
194
+ --log-level INFO --agent plan`. The `plan` agent is read-only; `--print-logs` is why
195
+ diagnostics reach the bridge, which classifies them rather than surfacing raw output.
196
+
197
+ Every local agent is invoked read-only, so it can only propose a patch. Lasso writes
198
+ nothing until the user accepts the diff. If an agent asks for approval or input, the
199
+ child's stdin stays attached and the request is surfaced as a task-scoped overlay prompt.
200
+
201
+ Availability is detected rather than configured: at bridge start Lasso runs `which` for
202
+ `claude`, `codex`, and `opencode`, and only detected agents appear in the model picker.
203
+ OpenCode's model list is discovered with `opencode models`.
204
+
205
+ Selection happens in the overlay model picker, not in `lasso.config.json`. That file
206
+ carries only the project `id` and local `domain`:
194
207
 
195
208
  ```json
196
- // lasso.config.json (before `lasso init`)
209
+ // lasso.config.json — the only project fields
197
210
  {
198
- "agent": "builtin" // | "claude-code" | "custom"
199
- }
200
-
201
- // lasso.config.json (after `lasso init` — id is the only project field)
202
- {
203
- "agent": "builtin",
204
- "id": "proj_3f2a9c…" // workspace-scoped; commit it so teammates share the session
211
+ "id": "proj_3f2a9c…", // workspace-scoped; commit it so teammates share the session
212
+ "domain": "my-project.lasso"
205
213
  }
206
214
  ```
207
215
 
package/README.md CHANGED
@@ -373,51 +373,48 @@ inside the project and refuses to overwrite an existing file.
373
373
 
374
374
  ## Agent configuration
375
375
 
376
- Create `lasso.config.json` in your project root:
376
+ You choose the coding agent in the overlay's model picker, not in a config file.
377
+ `lasso.config.json` only records your project `id` and local `domain` — it has no
378
+ agent field.
377
379
 
378
- ```json
379
- {
380
- "agent": "builtin"
381
- }
382
- ```
380
+ ### Local CLI agents
383
381
 
384
- ### Built-in agent
382
+ Lasso shells out to a coding CLI installed on your machine. Each one appears as its own
383
+ group in the model picker **only when its binary is on the `PATH` that `lasso dev`
384
+ sees**, so install it before starting the dev server (or restart afterwards):
385
385
 
386
- ```json
387
- {
388
- "agent": "builtin"
389
- }
390
- ```
386
+ | Agent | Binary | Lasso runs |
387
+ | ------------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------- |
388
+ | [Claude Code](https://lasso.byorello.space/docs/claude-code) | `claude` | `claude -p … --permission-mode plan --output-format stream-json --max-turns 3` |
389
+ | [Codex](https://lasso.byorello.space/docs/codex) | `codex` | `codex exec --json --sandbox read-only --skip-git-repo-check` |
390
+ | [OpenCode](https://lasso.byorello.space/docs/opencode) | `opencode` | `opencode run --format json --print-logs --agent plan` |
391
391
 
392
- Uses Lasso's built-in agent through the Anthropic SDK.
392
+ All three run read-only, so the agent proposes a patch and never writes to your project.
393
+ Sign each CLI in once in a real terminal; Lasso inherits the credentials it stores for
394
+ your user.
393
395
 
394
- Requires:
396
+ ### Hosted agents
397
+
398
+ To use a hosted model instead, set the provider and key in your shell or in the project
399
+ `.env` before running `lasso dev`:
395
400
 
396
401
  ```bash
402
+ export LASSO_AGENT_PROVIDER=anthropic # or openai, google, ollama
397
403
  export ANTHROPIC_API_KEY=your_key
404
+ export LASSO_AGENT_MODEL=... # optional
398
405
  ```
399
406
 
400
- ### Claude Code
401
-
402
- ```json
403
- {
404
- "agent": "claude-code"
405
- }
406
- ```
407
-
408
- Lasso can delegate the coding task to Claude Code in headless mode while keeping the same selection → context → diff → accept workflow.
407
+ Supported keys are `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
408
+ `GOOGLE_GENERATIVE_AI_API_KEY` (or `GEMINI_API_KEY`), and `OLLAMA_BASE_URL` with
409
+ `OLLAMA_MODEL`. If `LASSO_AGENT_PROVIDER` is unset, Lasso picks a provider from whichever
410
+ key it finds, defaulting to `anthropic`.
409
411
 
410
- ### Custom agent
411
-
412
- ```json
413
- {
414
- "agent": "custom"
415
- }
416
- ```
412
+ Hosted agents additionally require your Lasso key from `lasso auth login`, and the
413
+ credentials are never written to `lasso.config.json`.
417
414
 
418
- Build your own adapter for another coding agent.
415
+ ### The adapter contract
419
416
 
420
- The adapter contract is intentionally simple:
417
+ Whichever agent runs, the contract is the same:
421
418
 
422
419
  ```text
423
420
  Lasso context
@@ -45,7 +45,8 @@ export type AgentConfig = {
45
45
  baseUrl?: string;
46
46
  };
47
47
  export type LocalAgent = "claude-code" | "codex" | "opencode";
48
- export type AgentProgress = (message: string, detail?: string) => void;
48
+ export type AgentProgressLevel = "working" | "error";
49
+ export type AgentProgress = (message: string, detail?: string, level?: AgentProgressLevel) => void;
49
50
  export type AgentPrompt = {
50
51
  message: string;
51
52
  kind: "permission" | "input";
package/dist/cli/agent.js CHANGED
@@ -37,7 +37,7 @@ async function detectLocalAgents() {
37
37
  const ignored = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo"]);
38
38
  const sourceExtensions = /\.(tsx?|jsx?|vue|svelte|css|scss|html)$/i;
39
39
  const sourceFileCache = new Map();
40
- async function sourceFiles(directory, budget = { remaining: 40 }) {
40
+ async function sourceFiles(directory, budget = { remaining: 25 }) {
41
41
  if (budget.remaining <= 0)
42
42
  return [];
43
43
  const cached = sourceFileCache.get(directory);
@@ -63,7 +63,7 @@ async function sourceFiles(directory, budget = { remaining: 40 }) {
63
63
  budget.remaining -= 1;
64
64
  }
65
65
  }
66
- sourceFileCache.set(directory, { expiresAt: Date.now() + 5000, files });
66
+ sourceFileCache.set(directory, { expiresAt: Date.now() + 30000, files });
67
67
  return files;
68
68
  }
69
69
  async function contextFor(cwd, element) {
@@ -95,7 +95,7 @@ async function contextFor(cwd, element) {
95
95
  try {
96
96
  const content = await promises_1.default.readFile(file, "utf8");
97
97
  if (!needle || (sourceFile && file.endsWith(sourceFile)) || content.includes(needle) || content.includes(element.label)) {
98
- return `FILE: ${node_path_1.default.relative(cwd, file)}\n${content.slice(0, 8000)}`;
98
+ return `FILE: ${node_path_1.default.relative(cwd, file)}\n${content.slice(0, 6000)}`;
99
99
  }
100
100
  }
101
101
  catch {
@@ -104,7 +104,7 @@ async function contextFor(cwd, element) {
104
104
  return null;
105
105
  }));
106
106
  for (const result of results) {
107
- if (result && snippets.length < 6)
107
+ if (result && snippets.length < 4)
108
108
  snippets.push(result);
109
109
  }
110
110
  return snippets.join("\n\n---\n\n");
@@ -560,9 +560,9 @@ function runLocalCommand(cwd, command, args, provider, signal, onProgress, taskI
560
560
  else if (stream === "stderr" && line.trim()) {
561
561
  const hint = agentFailureHint(line);
562
562
  if (hint)
563
- onProgress?.(`${label} · ${hint}`);
563
+ onProgress?.(`${label} · ${hint}`, undefined, "error");
564
564
  if (provider === "opencode" && /quota exceeded|authentication failed|invalid api key|unauthorized|forbidden/i.test(line)) {
565
- onProgress?.("OpenCode · provider rejected the request; stopping this task…");
565
+ onProgress?.("OpenCode · provider rejected the request; stopping this task…", undefined, "error");
566
566
  child.kill("SIGTERM");
567
567
  }
568
568
  }
@@ -611,9 +611,9 @@ function runLocalCommand(cwd, command, args, provider, signal, onProgress, taskI
611
611
  }
612
612
  const hint = agentFailureHint(stderrTail);
613
613
  if (hint)
614
- onProgress?.(`${label} · ${hint}`);
614
+ onProgress?.(`${label} · ${hint}`, undefined, "error");
615
615
  if (provider === "opencode" && /quota exceeded|authentication failed|invalid api key|unauthorized|forbidden/i.test(stderrTail)) {
616
- onProgress?.("OpenCode · provider rejected the request; stopping this task…");
616
+ onProgress?.("OpenCode · provider rejected the request; stopping this task…", undefined, "error");
617
617
  }
618
618
  }
619
619
  resolve({ stdout, stderr, exitCode: code ?? 1 });
@@ -118,7 +118,20 @@ function proposalMatchesCurrentSource(cwd, changes) {
118
118
  try {
119
119
  for (const change of changes) {
120
120
  const filePath = resolveProposedFile(cwd, change.filePath);
121
- prepareChange(node_fs_1.default.readFileSync(filePath, "utf8"), change);
121
+ const content = node_fs_1.default.readFileSync(filePath, "utf8");
122
+ // Try exact match first
123
+ try {
124
+ prepareChange(content, change);
125
+ }
126
+ catch {
127
+ // If exact match fails, try with normalized whitespace
128
+ const normalizedContent = content.replace(/\s+/g, " ").trim();
129
+ const normalizedOldString = change.oldString.replace(/\s+/g, " ").trim();
130
+ const normalizedNewString = change.newString.replace(/\s+/g, " ").trim();
131
+ if (!normalizedContent.includes(normalizedOldString)) {
132
+ throw new Error("Source content does not match proposed change (even with normalized whitespace)");
133
+ }
134
+ }
122
135
  }
123
136
  return true;
124
137
  }
@@ -308,9 +321,9 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
308
321
  if (socket.readyState === socket.OPEN) {
309
322
  socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "working", message: statusMessage || "Working…" }));
310
323
  }
311
- void (0, agent_1.proposeChanges)(cwd, request, config, controller.signal, (message, detail) => {
324
+ void (0, agent_1.proposeChanges)(cwd, request, config, controller.signal, (message, detail, level) => {
312
325
  if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
313
- socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "working", message, detail }));
326
+ socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: level === "error" ? "error" : "working", message, detail }));
314
327
  }
315
328
  }, (prompt) => {
316
329
  if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
@@ -322,21 +335,30 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
322
335
  return;
323
336
  if (!proposalMatchesCurrentSource(cwd, proposal.changes)) {
324
337
  const refreshAttempts = reviewRefreshAttempts.get(request.taskId) || 0;
325
- if (refreshAttempts < 1) {
338
+ const MAX_REFRESH_ATTEMPTS = 3;
339
+ if (refreshAttempts < MAX_REFRESH_ATTEMPTS) {
326
340
  reviewRefreshAttempts.set(request.taskId, refreshAttempts + 1);
327
- runEditReview(request, config, "The source changed while the proposal was being prepared. Refreshing the review…");
341
+ setTimeout(() => {
342
+ if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
343
+ runEditReview(request, config, `The source changed while the proposal was being prepared. Refreshing the review… (${refreshAttempts + 1}/${MAX_REFRESH_ATTEMPTS})`);
344
+ }
345
+ }, 500 * (refreshAttempts + 1));
328
346
  }
329
347
  else {
330
- 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." }));
348
+ 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." }));
331
349
  }
332
350
  return;
333
351
  }
334
352
  socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "review", message: proposal.summary, changes: proposal.changes }));
335
353
  })
336
354
  .catch((error) => {
337
- if (!controller.signal.aborted && socket.readyState === socket.OPEN) {
338
- 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." }));
355
+ if (socket.readyState !== socket.OPEN)
356
+ return;
357
+ if (controller.signal.aborted) {
358
+ socket.send(JSON.stringify({ type: "agent_status", taskId: request.taskId, status: "stopped", message: "Agent stopped." }));
359
+ return;
339
360
  }
361
+ 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." }));
340
362
  })
341
363
  .finally(() => {
342
364
  if (taskControllers.get(request.taskId) === controller)
@@ -432,9 +454,9 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
432
454
  ? { provider: cliProvider, model: msg.model }
433
455
  : { ...agentConfig, provider: (msg.provider || agentConfig.provider), model: msg.model };
434
456
  socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "working", message: "Working…" }));
435
- void (0, agent_1.answerQuestion)(cwd, { taskId: msg.taskId, question: msg.question, context: msg.context, element: msg.element, messages: msg.messages }, selectedConfig, controller.signal, (message, detail) => {
457
+ void (0, agent_1.answerQuestion)(cwd, { taskId: msg.taskId, question: msg.question, context: msg.context, element: msg.element, messages: msg.messages }, selectedConfig, controller.signal, (message, detail, level) => {
436
458
  if (!controller.signal.aborted && socket.readyState === socket.OPEN)
437
- socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: "working", message, detail }));
459
+ socket.send(JSON.stringify({ type: "agent_status", taskId: msg.taskId, status: level === "error" ? "error" : "working", message, detail }));
438
460
  }, (prompt) => {
439
461
  if (!controller.signal.aborted && socket.readyState === socket.OPEN)
440
462
  socket.send(JSON.stringify({ type: "agent_prompt", taskId: msg.taskId, prompt }));