@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 +35 -27
- package/README.md +29 -32
- package/dist/cli/agent.d.ts +2 -1
- package/dist/cli/agent.js +8 -8
- package/dist/cli/bridge.js +32 -10
- package/dist/overlay.js +2513 -2488
- package/package.json +1 -1
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
|
-
|
|
176
|
-
|
|
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
|
-
- **
|
|
181
|
-
directly (`@anthropic-ai/sdk`)
|
|
182
|
-
from §5.3.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
209
|
+
// lasso.config.json — the only project fields
|
|
197
210
|
{
|
|
198
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
{
|
|
380
|
-
"agent": "builtin"
|
|
381
|
-
}
|
|
382
|
-
```
|
|
380
|
+
### Local CLI agents
|
|
383
381
|
|
|
384
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
+
### The adapter contract
|
|
419
416
|
|
|
420
|
-
|
|
417
|
+
Whichever agent runs, the contract is the same:
|
|
421
418
|
|
|
422
419
|
```text
|
|
423
420
|
Lasso context
|
package/dist/cli/agent.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
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() +
|
|
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,
|
|
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 <
|
|
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 });
|
package/dist/cli/bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
338
|
+
const MAX_REFRESH_ATTEMPTS = 3;
|
|
339
|
+
if (refreshAttempts < MAX_REFRESH_ATTEMPTS) {
|
|
326
340
|
reviewRefreshAttempts.set(request.taskId, refreshAttempts + 1);
|
|
327
|
-
|
|
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 (
|
|
338
|
-
|
|
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 }));
|