@lasso-ai/cli 1.1.0 → 1.1.1

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
@@ -68,10 +68,10 @@ Three parties, all coordinating around one shared filesystem:
68
68
  ```
69
69
 
70
70
  Everything except the coding agent call runs locally. Edit proposals are never written to
71
- disk without an explicit accept from the user. The manual New Page workflow is a separate
72
- explicit write path: it creates only the filename/content the user confirms. The agent is
73
- the only party that ever leaves the machine, and only with the exact context the CLI
74
- assembled for it.
71
+ disk without an explicit accept from the user. The New Page workflow is a separate explicit
72
+ write path: it creates only the file at the folder the user selected in the project
73
+ explorer. The agent is the only party that ever leaves the machine, and only with the
74
+ exact context the CLI assembled for it.
75
75
 
76
76
  ## 4. Browser layer (capture only)
77
77
 
@@ -91,10 +91,13 @@ Responsibilities, and _only_ these:
91
91
  actions; acceptance is the transition that completes an edit task.
92
92
  - Keep a stable component conversation identity so repeated prompts reuse conversation
93
93
  and change history across DOM replacement during HMR.
94
- - Render the secondary toolbar, custom tooltips, and the manual New Page form with a
95
- project-folder explorer.
94
+ - Render the secondary toolbar, custom tooltips, and the New Page dialog with a lazy
95
+ project-folder tree. The tree is populated per-directory on expand; the CLI never
96
+ sends the whole project at once. Selecting a folder sets the write target, and a
97
+ new nested folder is created inline through its own bridge message.
96
98
  - Render task-scoped permission/input prompts and send explicit responses back over
97
- the bridge.
99
+ the bridge. The prompt must re-enable pointer events on its panel, because the
100
+ overlay host disables them to keep the toolbar click-through.
98
101
  - **Never mutate the DOM.** All visual changes the user sees post-edit come from the
99
102
  framework's own HMR reload, not from this layer.
100
103
 
@@ -121,10 +124,17 @@ A Node process that wraps the user's existing dev server. Entry: `src/cli/index.
121
124
  on receipt.
122
125
  6. On accept: snapshot the file's prior content (for undo), then apply the diff.
123
126
  7. On undo: restore the snapshot, full stop — no re-generation needed, so retries are free.
124
- 8. For New Page: list project-relative folders, validate the requested target against
125
- the project root, and create the file only after the user confirms the form.
127
+ 8. For New Page: answer `list_page_folders` one directory at a time so the overlay can
128
+ build its tree lazily, skipping dot-entries and build/vendor directories. Every
129
+ request resolves against the project root, so `..` and absolute paths are refused.
130
+ Create a file only after the user confirms, and refuse to overwrite. Nested folder
131
+ creation arrives separately as `create_page_folder` and never accepts a slash or a
132
+ dotted name.
126
133
  9. For local agents: keep child-process stdin attached to its task and forward explicit
127
- overlay responses when the agent requests permission or input.
134
+ overlay responses when the agent requests permission or input. Classify stderr
135
+ before prompting: a filesystem or transport failure is reported as an error with a
136
+ suggested fix, because Allow/Deny cannot resolve it. Only a genuine approval or
137
+ sign-in request becomes a prompt, and the text is cleaned of log prefixes first.
128
138
 
129
139
  ### 5.2 Source resolution strategies
130
140
 
package/CHANGELOG.md CHANGED
@@ -20,6 +20,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
20
20
  overlay prompt with Allow, Deny, or custom text.
21
21
  - **Secondary overlay toolbar**: added a New Page workflow with a project-folder
22
22
  explorer, filename/content form, path validation, and explicit file creation.
23
+ - **Redesigned New Page dialog**: replaced the flat folder dropdown with a
24
+ VS Code-style lazy tree of the project. You type only a page name and pick a
25
+ folder; nested folders are created inline with a `+` button, folders load on
26
+ demand, a filter box narrows deep trees, and clicking a file reuses its name.
27
+ Selecting a folder no longer collapses it, and the starter file is generated
28
+ from the extension (`.tsx`, `.ts`, `.css`, `.json`, `.md`).
29
+ - **Readable agent permission prompts**: the prompt no longer dumps raw local-agent
30
+ log lines. Filesystem failures such as `EACCES: permission denied` and
31
+ `Cause([Die([…])])` now surface as errors with a suggested fix, because
32
+ Allow/Deny can never resolve them. Only genuine approval and sign-in requests
33
+ open the Allow/Deny prompt.
34
+ - **Clickable agent prompt**: the prompt panel opts back into pointer events, so
35
+ Allow, Deny, and custom options respond to clicks again; Enter submits typed
36
+ input and Escape sends Deny, matching the close button.
23
37
  - **Custom toolbar tooltips**: toolbar labels now use an accessible Lasso-styled
24
38
  tooltip on hover and keyboard focus instead of browser-native title popovers.
25
39
  - **Filtered agent progress**: useful tool/progress events remain visible while
package/README.md CHANGED
@@ -90,10 +90,13 @@ Turn on Lasso Mode and interact with your application normally.
90
90
  ### Overlay toolbars
91
91
 
92
92
  The overlay includes a primary editing toolbar and a compact secondary toolbar.
93
- The secondary toolbar's **New Page** action opens a manual page-creation form where
94
- you can choose a project folder, enter a filename, edit starter content, and create
95
- the file explicitly. Lasso never chooses a source folder or overwrites an existing
96
- file without confirmation.
93
+ The secondary toolbar's **New Page** action opens a project explorer. You type a page
94
+ name and pick the folder it should live in — the tree loads lazily as you expand
95
+ it, a filter box narrows deep trees, and the `+` button next to **Folder** creates
96
+ a new nested folder inline. Clicking a file in the tree reuses its name so you only
97
+ type the part that is missing. Starter content is generated from the extension, and
98
+ Lasso never chooses a source folder for you or overwrites an existing file without
99
+ confirmation.
97
100
 
98
101
  ### Source-aware editing
99
102
 
@@ -360,9 +363,10 @@ That's it.
360
363
 
361
364
  ### Create a page manually
362
365
 
363
- Use **New Page** in the secondary toolbar to open the page form. Select the target
364
- folder in the project explorer, choose a filename, edit the starter content if
365
- needed, and select **Create page**. The bridge validates that the folder stays
366
+ Use **New Page** in the secondary toolbar to open the project explorer. Type a page
367
+ name, expand the tree to pick the target folder, and select **Create page**. Use the
368
+ `+` button beside **Folder** to create a nested folder without leaving the dialog,
369
+ or the filter box to narrow a large tree. The bridge validates that the folder stays
366
370
  inside the project and refuses to overwrite an existing file.
367
371
 
368
372
  ---
@@ -491,8 +495,8 @@ The CLI:
491
495
  8. Shows you the diff
492
496
  9. Writes only after you accept
493
497
 
494
- The separate New Page workflow is also explicit: it writes only after you select
495
- a folder, confirm the filename/content form, and choose **Create page**.
498
+ The separate New Page workflow is also explicit: it writes only after you pick
499
+ a folder in the project explorer, type a name, and choose **Create page**.
496
500
 
497
501
  The goal is simple:
498
502
 
package/dist/cli/agent.js CHANGED
@@ -303,6 +303,70 @@ function snippet(value, max = 80) {
303
303
  const s = String(value ?? "").trim().replace(/\s+/g, " ");
304
304
  return s.length > max ? `${s.slice(0, max)}…` : s;
305
305
  }
306
+ const ANSI_PATTERN = /\u001B\[[0-9;?]*[ -/]*[@-~]/g;
307
+ const LOG_FIELD_PATTERN = /([A-Za-z_][\w.-]*)=("(?:[^"\\]|\\.)*"|[^\s]*)/g;
308
+ function cleanAgentText(value) {
309
+ return value.replace(ANSI_PATTERN, "").replace(/\s+/g, " ").trim();
310
+ }
311
+ /**
312
+ * Local agents (notably OpenCode) print structured logs such as
313
+ * `timestamp=… level=ERROR run=… message="Failed to fetch models.dev" cause="…"`.
314
+ * Those lines are machine diagnostics, never user-facing copy, so they are parsed
315
+ * into their fields instead of being shown verbatim.
316
+ */
317
+ function parseAgentLogFields(line) {
318
+ if (!/^\s*(?:timestamp|time|ts)=\S/.test(line))
319
+ return null;
320
+ const fields = {};
321
+ for (const match of line.matchAll(LOG_FIELD_PATTERN)) {
322
+ const key = match[1];
323
+ const raw = match[2] ?? "";
324
+ fields[key] = raw.startsWith('"')
325
+ ? raw.slice(1, -1).replace(/\\(["\\])/g, "$1")
326
+ : raw;
327
+ }
328
+ return fields;
329
+ }
330
+ // Filesystem failures that a permission request can never resolve.
331
+ const AGENT_FAILURE_MARKERS = /\b(?:EACCES|EPERM|ENOENT|EEXIST|EISDIR|ENOTDIR|ENOSPC|EMFILE|ENOTEMPTY)\b|\bCause\(\[Die\(/i;
332
+ const AGENT_FAILURE_HINTS = [
333
+ {
334
+ pattern: /EACCES: permission denied[\s\S]*(?:locks?|state|storage|\.config|config\/)/i,
335
+ message: "The local agent cannot write to its own state folder. Fix that folder's ownership (for example `sudo chown -R $(whoami) ~/.local/state/opencode`) and retry.",
336
+ },
337
+ {
338
+ pattern: /EACCES: permission denied/i,
339
+ message: "The local agent was denied access to a file or folder it needs. Check the permissions on its state directory, then retry.",
340
+ },
341
+ {
342
+ pattern: /failed to fetch models\.dev/i,
343
+ message: "The local agent could not reach models.dev to refresh its model list. Check the network or proxy it uses, then retry.",
344
+ },
345
+ ];
346
+ function agentFailureHint(line) {
347
+ return AGENT_FAILURE_HINTS.find((hint) => hint.pattern.test(line))?.message;
348
+ }
349
+ const PERMISSION_PROMPT_PATTERN = /permission\s+(?:is\s+)?(?:required|requested|needed|denied)|needs?\s+(?:your\s+)?permission|permission\s+to\s+(?:run|edit|write|read|execute|create|delete|modify|access|apply)|approve\s+(?:this|the|tool|action|command)|authori[sz]e\s+(?:this|the|tool|action|command)|authentication\s+(?:is\s+)?required|(?:please\s+)?log\s?in|sign\s?in\s+(?:required|to\s+continue)|press\s+.{1,24}?to\s+(?:continue|approve|confirm)|waiting for (?:your )?(?:permission|approval|input|response)/i;
350
+ /**
351
+ * Returns human-readable copy for a real approval/authentication request, or
352
+ * `null` when the line is anything else. A structured error log never becomes a
353
+ * permission prompt: Allow/Deny cannot fix a filesystem failure, and hiding the
354
+ * error behind those buttons is what made the prompt unreadable.
355
+ */
356
+ function permissionPromptMessage(raw) {
357
+ const line = cleanAgentText(raw);
358
+ if (!line)
359
+ return null;
360
+ if (parseAgentLogFields(line) || AGENT_FAILURE_MARKERS.test(line))
361
+ return null;
362
+ if (!PERMISSION_PROMPT_PATTERN.test(line))
363
+ return null;
364
+ const detail = line
365
+ .replace(/^[A-Za-z0-9_.-]+:\s*/, "")
366
+ .replace(/^["'\s]+|["'\s]+$/g, "")
367
+ .trim();
368
+ return snippet(detail || line, 240);
369
+ }
306
370
  function progressEvent(message, detail) {
307
371
  const value = detail == null ? "" : String(detail).trim();
308
372
  return value ? { message, detail: value } : { message };
@@ -410,6 +474,9 @@ function progressEventsFromRaw(raw, provider) {
410
474
  }
411
475
  function localAgentError(command, stderr, exitCode) {
412
476
  const output = stderr.trim();
477
+ const hint = agentFailureHint(output);
478
+ if (hint)
479
+ return `${command === "opencode" ? "OpenCode" : command} could not finish: ${hint}`;
413
480
  if (command === "opencode" && /waiting for permission or authentication/i.test(output)) {
414
481
  return "OpenCode needs permission or authentication. Run OpenCode once in a terminal, approve the requested access or sign in, then retry in Lasso.";
415
482
  }
@@ -486,10 +553,14 @@ function runLocalCommand(cwd, command, args, provider, signal, onProgress, taskI
486
553
  for (const progress of progressEventsFromRaw(line, provider)) {
487
554
  onProgress?.(progress.message, progress.detail);
488
555
  }
489
- if (/permission\s+(required|denied)|approve\s+(this|the|tool)|authentication\s+required|login\s+required|press\s+.+to\s+continue/i.test(line)) {
490
- sendPrompt(line.replace(/\s+/g, " ").trim());
556
+ const promptMessage = permissionPromptMessage(line);
557
+ if (promptMessage) {
558
+ sendPrompt(promptMessage);
491
559
  }
492
- if (stream === "stderr" && line.trim()) {
560
+ else if (stream === "stderr" && line.trim()) {
561
+ const hint = agentFailureHint(line);
562
+ if (hint)
563
+ onProgress?.(`${label} · ${hint}`);
493
564
  if (provider === "opencode" && /quota exceeded|authentication failed|invalid api key|unauthorized|forbidden/i.test(line)) {
494
565
  onProgress?.("OpenCode · provider rejected the request; stopping this task…");
495
566
  child.kill("SIGTERM");
@@ -538,6 +609,9 @@ function runLocalCommand(cwd, command, args, provider, signal, onProgress, taskI
538
609
  for (const progress of progressEventsFromRaw(stderrTail, provider)) {
539
610
  onProgress?.(progress.message, progress.detail);
540
611
  }
612
+ const hint = agentFailureHint(stderrTail);
613
+ if (hint)
614
+ onProgress?.(`${label} · ${hint}`);
541
615
  if (provider === "opencode" && /quota exceeded|authentication failed|invalid api key|unauthorized|forbidden/i.test(stderrTail)) {
542
616
  onProgress?.("OpenCode · provider rejected the request; stopping this task…");
543
617
  }
@@ -97,11 +97,16 @@ export type BridgeMessage = {
97
97
  response: string;
98
98
  } | {
99
99
  type: "list_page_folders";
100
+ path?: string;
100
101
  } | {
101
102
  type: "create_page";
102
103
  folder: string;
103
104
  fileName: string;
104
105
  content: string;
106
+ } | {
107
+ type: "create_page_folder";
108
+ parent: string;
109
+ name: string;
105
110
  } | {
106
111
  type: "git_status";
107
112
  } | {
@@ -132,6 +137,11 @@ type ModelOption = {
132
137
  label: string;
133
138
  provider: "anthropic" | "openai" | "google" | "ollama" | "cli";
134
139
  };
140
+ type PageEntry = {
141
+ name: string;
142
+ path: string;
143
+ type: "directory" | "file";
144
+ };
135
145
  export type ServerBridgeMessage = {
136
146
  type: "config";
137
147
  apiKeyConfigured: boolean;
@@ -165,11 +175,18 @@ export type ServerBridgeMessage = {
165
175
  prompt: AgentPrompt;
166
176
  } | {
167
177
  type: "page_folders";
168
- folders: string[];
178
+ path: string;
179
+ entries: PageEntry[];
180
+ project?: string;
181
+ error?: string;
169
182
  } | {
170
183
  type: "page_created";
171
184
  path: string;
172
185
  error?: string;
186
+ } | {
187
+ type: "page_folder_created";
188
+ path: string;
189
+ error?: string;
173
190
  } | {
174
191
  type: "assistant_message";
175
192
  taskId?: string;
@@ -181,22 +181,33 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
181
181
  let activeAgentController = null;
182
182
  const taskControllers = new Map();
183
183
  let localAgents = new Set();
184
- const pageFolders = (directory, relative = "", depth = 0) => {
185
- if (depth > 5)
184
+ const pageIgnoredDirs = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo", "out", "coverage", ".vercel", ".cache"]);
185
+ const resolveInsideProject = (root, relative) => {
186
+ const target = node_path_1.default.resolve(root, relative && relative !== "." ? relative : "");
187
+ return target === root || target.startsWith(`${root}${node_path_1.default.sep}`) ? target : null;
188
+ };
189
+ // Directories are returned first so the overlay can render an explorer-style
190
+ // tree that mirrors how VS Code orders folders above files.
191
+ const pageEntries = (root, relative) => {
192
+ const directory = resolveInsideProject(root, relative);
193
+ if (!directory)
186
194
  return [];
187
- const folders = [relative || "."];
195
+ let entries = [];
188
196
  try {
189
197
  for (const entry of node_fs_1.default.readdirSync(directory, { withFileTypes: true })) {
190
- if (!entry.isDirectory() || ["node_modules", ".git", ".next", "dist", "build", ".turbo"].includes(entry.name) || entry.name.startsWith("."))
198
+ if (entry.name.startsWith(".") || (entry.isDirectory() && pageIgnoredDirs.has(entry.name)))
191
199
  continue;
192
- const nextRelative = relative ? node_path_1.default.join(relative, entry.name) : entry.name;
193
- folders.push(...pageFolders(node_path_1.default.join(directory, entry.name), nextRelative, depth + 1));
200
+ entries.push({
201
+ name: entry.name,
202
+ path: relative && relative !== "." ? node_path_1.default.join(relative, entry.name) : entry.name,
203
+ type: entry.isDirectory() ? "directory" : "file",
204
+ });
194
205
  }
195
206
  }
196
207
  catch {
197
- // Ignore folders that cannot be read.
208
+ // Folders that cannot be read are treated as empty.
198
209
  }
199
- return folders;
210
+ return entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1);
200
211
  };
201
212
  bridgeServer.on("request", (req, res) => {
202
213
  if (req.method !== "POST" || req.url?.split("?", 1)[0] !== "/__lasso/bridge/restart")
@@ -341,19 +352,60 @@ function startBridge(cwd = process.cwd(), collabConfig = null, bridgePort = expo
341
352
  console.log("[lasso] received from overlay:", msg);
342
353
  }
343
354
  if (msg.type === "list_page_folders") {
344
- socket.send(JSON.stringify({ type: "page_folders", folders: pageFolders(cwd).sort() }));
355
+ const requested = msg.path || ".";
356
+ const root = node_path_1.default.resolve(cwd);
357
+ const directory = resolveInsideProject(root, requested);
358
+ if (!directory) {
359
+ socket.send(JSON.stringify({ type: "page_folders", path: requested, entries: [], error: "That folder is outside the current project." }));
360
+ return;
361
+ }
362
+ socket.send(JSON.stringify({ type: "page_folders", path: requested, entries: pageEntries(root, requested), project: node_path_1.default.basename(root) }));
363
+ return;
364
+ }
365
+ if (msg.type === "create_page_folder") {
366
+ const root = node_path_1.default.resolve(cwd);
367
+ const name = node_path_1.default.basename(msg.name || "");
368
+ const parent = resolveInsideProject(root, msg.parent || ".");
369
+ if (!parent || !name || name !== msg.name || name === "." || name === "..") {
370
+ socket.send(JSON.stringify({ type: "page_folder_created", path: msg.name || "", error: "Enter a folder name without slashes or dots." }));
371
+ return;
372
+ }
373
+ const target = node_path_1.default.resolve(parent, name);
374
+ if (!target.startsWith(`${root}${node_path_1.default.sep}`)) {
375
+ socket.send(JSON.stringify({ type: "page_folder_created", path: name, error: "Choose a folder inside the current project." }));
376
+ return;
377
+ }
378
+ try {
379
+ node_fs_1.default.mkdirSync(target);
380
+ socket.send(JSON.stringify({ type: "page_folder_created", path: node_path_1.default.relative(root, target) }));
381
+ }
382
+ catch (error) {
383
+ socket.send(JSON.stringify({
384
+ type: "page_folder_created",
385
+ path: node_path_1.default.relative(root, target),
386
+ error: error instanceof Error && error.message.includes("EEXIST") ? "A folder with that name already exists." : "The folder could not be created.",
387
+ }));
388
+ }
345
389
  return;
346
390
  }
347
391
  if (msg.type === "create_page") {
348
392
  const root = node_path_1.default.resolve(cwd);
349
- const folder = msg.folder === "." ? root : node_path_1.default.resolve(root, msg.folder);
350
- const fileName = node_path_1.default.basename(msg.fileName);
351
- const target = node_path_1.default.resolve(folder, fileName);
352
- if (!target.startsWith(`${root}${node_path_1.default.sep}`) || target === root || !fileName || fileName !== msg.fileName || !node_fs_1.default.existsSync(folder)) {
393
+ const folder = resolveInsideProject(root, msg.folder || ".");
394
+ // A page name may contain "/" so the overlay can create nested routes in
395
+ // one step, but every segment is validated and resolved inside the project.
396
+ const segments = (msg.fileName || "").split("/").map((segment) => segment.trim()).filter(Boolean);
397
+ const fileName = segments.pop() || "";
398
+ if (!folder || !fileName || segments.some((segment) => segment === "." || segment === "..") || fileName === "." || fileName === "..") {
399
+ socket.send(JSON.stringify({ type: "page_created", path: msg.fileName || "", error: "Choose a valid project folder and file name." }));
400
+ return;
401
+ }
402
+ const target = node_path_1.default.resolve(folder, ...segments, fileName);
403
+ if (!target.startsWith(`${root}${node_path_1.default.sep}`)) {
353
404
  socket.send(JSON.stringify({ type: "page_created", path: msg.fileName, error: "Choose a valid project folder and file name." }));
354
405
  return;
355
406
  }
356
407
  try {
408
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(target), { recursive: true });
357
409
  node_fs_1.default.writeFileSync(target, msg.content.slice(0, 200000), { encoding: "utf8", flag: "wx" });
358
410
  socket.send(JSON.stringify({ type: "page_created", path: node_path_1.default.relative(root, target) }));
359
411
  }