@lasso-ai/cli 1.1.0 → 1.1.2

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
 
@@ -160,38 +170,46 @@ context (source + screenshot + instruction)
160
170
  │ Agent adapter │ interface: given context, return old/new string pairs
161
171
  └───────┬───────┘
162
172
  │
163
- ┌──────┼───────────────────┬─────────────────────┐
164
- ▼ ▼ ▼ ▼
165
- Built-in Claude Code User's own CLI coding Any future
166
- agent (headless/ agent (Cursor CLI, adapter
167
- (default) `claude -p`) Aider, etc.)
173
+ ┌──────┼──────────────┬─────────────────┐
174
+ ▼ ▼ ▼ ▼
175
+ Hosted Claude Code Codex OpenCode
176
+ model `claude -p` `codex exec` `opencode run`
168
177
  ```
169
178
 
170
- - **Built-in agent** (`agent: "builtin"`): default path, zero setup, calls a hosted model
171
- directly (`@anthropic-ai/sdk`) with the assembled context and the old/new-string contract
172
- from §5.3.
173
- - **Claude Code passthrough** (`agent: "claude-code"`): the CLI shells out to Claude Code
174
- in headless/print mode (`claude -p`), passing the assembled context as the prompt and
175
- parsing its file edits back into the same diff-preview pipeline. This lets a user keep
176
- using their existing Claude Code setup (subscription, project memory, MCP tools) while
177
- still getting the point-and-select UX.
178
- - **Bring-your-own-agent** (`agent: "custom"`): define the adapter interface once (context
179
- in, old/new-string pairs out) and let power users wire in whatever CLI coding tool they
180
- already trust. Lasso's value in this mode is entirely the capture + context-assembly
181
- pipeline (§4–5), not the model itself.
182
-
183
- 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`:
184
207
 
185
208
  ```json
186
- // lasso.config.json (before `lasso init`)
209
+ // lasso.config.json — the only project fields
187
210
  {
188
- "agent": "builtin" // | "claude-code" | "custom"
189
- }
190
-
191
- // lasso.config.json (after `lasso init` — id is the only project field)
192
- {
193
- "agent": "builtin",
194
- "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"
195
213
  }
196
214
  ```
197
215
 
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,60 +363,58 @@ 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
  ---
369
373
 
370
374
  ## Agent configuration
371
375
 
372
- 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.
373
379
 
374
- ```json
375
- {
376
- "agent": "builtin"
377
- }
378
- ```
380
+ ### Local CLI agents
379
381
 
380
- ### 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):
381
385
 
382
- ```json
383
- {
384
- "agent": "builtin"
385
- }
386
- ```
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` |
387
391
 
388
- 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.
389
395
 
390
- 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`:
391
400
 
392
401
  ```bash
402
+ export LASSO_AGENT_PROVIDER=anthropic # or openai, google, ollama
393
403
  export ANTHROPIC_API_KEY=your_key
404
+ export LASSO_AGENT_MODEL=... # optional
394
405
  ```
395
406
 
396
- ### Claude Code
397
-
398
- ```json
399
- {
400
- "agent": "claude-code"
401
- }
402
- ```
403
-
404
- 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`.
405
411
 
406
- ### Custom agent
407
-
408
- ```json
409
- {
410
- "agent": "custom"
411
- }
412
- ```
412
+ Hosted agents additionally require your Lasso key from `lasso auth login`, and the
413
+ credentials are never written to `lasso.config.json`.
413
414
 
414
- Build your own adapter for another coding agent.
415
+ ### The adapter contract
415
416
 
416
- The adapter contract is intentionally simple:
417
+ Whichever agent runs, the contract is the same:
417
418
 
418
419
  ```text
419
420
  Lasso context
@@ -491,8 +492,8 @@ The CLI:
491
492
  8. Shows you the diff
492
493
  9. Writes only after you accept
493
494
 
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**.
495
+ The separate New Page workflow is also explicit: it writes only after you pick
496
+ a folder in the project explorer, type a name, and choose **Create page**.
496
497
 
497
498
  The goal is simple:
498
499
 
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
  }