Skip to content

feat(ai): auto-open the built-in browser when the agent starts a web server#35

Merged
ndemianc merged 4 commits into
developfrom
feat/auto-preview
Jul 24, 2026
Merged

feat(ai): auto-open the built-in browser when the agent starts a web server#35
ndemianc merged 4 commits into
developfrom
feat/auto-preview

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

LevelCode has shipped VS Code's Simple Browser all along — but you had to know the command name to find it, so effectively nobody did. This makes it appear on its own: the moment a background command advertises a local address, the site opens beside the chat, and the agent keeps working while you watch the page update.

That compounds with how edits already land. The agent writes to disk immediately (apply-then-review — Keep/Undo happens after), so the dev server's watcher fires HMR and the preview updates before you ever click Keep. That's the "composing in real time" effect, with no extra machinery.

Security — why sniffPreviewUrl isn't just "find a URL"

Its input is a command's stdout: whatever a repo's dev script chose to print. If we opened any URL found there, a hostile repo could aim the editor's browser anywhere just by logging a line:

Local:  http://evil.example.com:3000/

So the host must be local — remote addresses are ignored, never opened. This is the load-bearing property, and it's mutation-checked: removing the host check opens http://evil.example.com:3000/ and fails the suite.

Also handled:

  • Suffix trickshttp://localhost.evil.com:3000 and http://127.0.0.1.evil.com:3000 are not local (the check is anchored).
  • Bind addresses0.0.0.0 and [::] mean "every interface", not a destination, so they're rewritten to localhost, which actually resolves.
  • Mixed output — Vite prints both Local: and Network:; the local one wins regardless of what the network line says.

Three UX choices worth stating

choice why
preserveFocus: true a server coming up mid-run must never steal the caret from whoever is typing
ViewColumn.Beside the site sits next to the work rather than replacing it
dedupe by URL, once per session closing the tab is final — otherwise the server's next log line reopens what you just dismissed, and a restart-on-save server stacks a tab per reload

Failure is swallowed: a preview must never be able to fail an agent run. Off via levelcode.ai.preview.autoOpen.

Two pre-existing bugs found on the way

  1. verify.js shipped a raw NUL byte in diagKey's separator, so file reported it as data and grep and diff skipped the entire module in silence. I hit this trying to read the file — the same defect class review caught in the MCP modules, but this one predates that work. Replaced with the escape (identical runtime value) and pinned by a byte-level test.
  2. verify.js had no tests at all, despite PLAN.md claiming it was "pure, unit-tested: 24 + 8 + 13 cases". It now has a suite, since auto-open depends on its sniffers.

Verification

  • 23 suites, 0 failures. test/verify.test.js is new (10 cases).
  • Mutation-checked in both directions: dropping the local-host check fails; not rewriting 0.0.0.0 fails.
  • Simple Browser's simpleBrowser.api.open(uri, {preserveFocus, viewColumn}) signature was confirmed against the shipped extension source, not assumed.

Not covered by CI: the actual "tab opens beside the chat" path — that needs a built app and a live agent run. The sniffing and the security bound are unit-tested; the host call is verified by reading against the real Simple Browser source.

Try it

Ask the agent to start a dev server (npm run dev). When it prints its address, the site should open beside the chat without taking focus. Close the tab and it stays closed.

🤖 Generated with Claude Code

…server

LevelCode has shipped VS Code's Simple Browser all along — but you had to know the
command name to find it, so nobody did. This makes it appear on its own: the
moment a background command advertises a local address, the site opens beside the
chat, and the agent keeps working while you watch the page update.

That pairs with how edits already land: the agent writes to disk immediately
(apply-then-review), so the dev server's watcher fires HMR and the preview updates
before you ever click Keep.

SECURITY — the reason sniffPreviewUrl is not just "find a URL". Its input is a
command's stdout, i.e. whatever a repo's dev script chose to print. Opening any
URL found there would let a hostile repo aim the editor's browser anywhere just by
logging a line ("Local: http://evil.example.com"). So the host must be LOCAL;
remote addresses are ignored, never opened. 0.0.0.0 and [::] are bind addresses
rather than destinations, so they're rewritten to localhost, which resolves.

Three UX choices worth stating:
- preserveFocus — a server coming up must never steal the caret from whoever is
  typing, and one usually comes up mid-run.
- ViewColumn.Beside — the site sits next to the work instead of replacing it.
- dedupe by URL, once per session — so CLOSING the tab is final. Without it, a
  chatty server's next log line would reopen what the user just dismissed, and a
  restart-on-save server would stack a tab per reload.
Failure is swallowed: a preview must never be able to fail an agent run.
Off via levelcode.ai.preview.autoOpen.

Also fixes two things found on the way:
- verify.js shipped a RAW NUL byte in diagKey's separator, so `file` called it
  "data" and grep/diff skipped the entire module in silence — the same defect
  review caught in the MCP modules. Replaced with the backslash-u0000 escape
  (identical runtime value) and pinned by a byte-level test.
- verify.js had NO tests at all, despite the plan claiming it was unit-tested.
  It now has a suite, since auto-open depends on its sniffers.

Verified: 23 suites, 0 failures (verify.test.js is new: 10 cases). Both new
behaviours mutation-checked — dropping the local-host check opens
"http://evil.example.com:3000/" and fails the suite; not rewriting 0.0.0.0 fails
too. Simple Browser's api.open signature confirmed against the shipped extension
source, not assumed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 24, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an auto-preview UX to extensions/levelcode-ai so that when the agent starts a background dev server and its stdout advertises a local address, LevelCode automatically opens that URL in the built-in Simple Browser beside the chat (without stealing focus), while continuing the agent run.

Changes:

  • Introduces sniffPreviewUrl() in verify.js and wires it into the agent’s background command streaming to detect previewable local URLs.
  • Adds a host-side openPreview() implementation (dedupe-by-URL per session, preserveFocus, ViewColumn.Beside) and passes it into the agent context.
  • Adds a new setting levelcode.ai.preview.autoOpen (default true) and a new verify.test.js suite covering both preview sniffing and the “no raw control bytes” invariant.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

File Description
extensions/levelcode-ai/test/verify.test.js Adds unit tests for verify.js sniffers and a byte-level control-character check.
extensions/levelcode-ai/package.json Adds levelcode.ai.preview.autoOpen setting (default enabled) and documents the local-only security bound + dedupe behavior.
extensions/levelcode-ai/extension.js Implements openPreview() using Simple Browser and session dedupe; clears preview state when reaping commands.
extensions/levelcode-ai/agent.js Detects a preview URL from background command output and calls the host openPreview() once per run.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread extensions/levelcode-ai/extension.js
openPreview added the URL to the "already previewed" set BEFORE attempting to
open it. If simpleBrowser.api.open threw — Simple Browser disabled, or briefly
unavailable — the URL stayed marked, so no later run in that session would retry
and the preview silently never appeared, with nothing for the user to point at.

The marking wasn't arbitrary, though: opening is async, so two runs advertising
the same address could otherwise both pass the check and stack two tabs. So the
fix keeps BOTH properties instead of just moving the line — three states rather
than one Set: never-tried, in-flight, shown.

  begin()     -> in-flight, blocks a concurrent duplicate
  succeeded() -> shown; never reopen, so closing the tab stays final
  failed()    -> forget the attempt entirely; a later run may retry

Extracted as createPreviewGate() in verify.js rather than left inline, because
extension.js requires vscode and cannot be unit-tested — this is precisely the
kind of state-machine bug that needs a test, and inline it would have had none.

Verified: 23 suites, 0 failures (verify.test.js now 15 cases, 5 new). The fix is
mutation-checked against the original bug: re-adding the URL to `shown` inside
begin() fails with "a failed open must not suppress later attempts".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 24, 2026 00:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Comment thread extensions/levelcode-ai/test/verify.test.js Outdated
Comment thread extensions/levelcode-ai/package.json Outdated
…l bound (PR #35 review)

1. The SECURITY test allowed sniffPreviewUrl to return a localhost URL for the
   remote-only fixtures, so it was weaker than the sentence above it claimed —
   and it was permitting a real leak, not a theoretical one. sniffPort's first
   pattern matches ANY http(s) URL, so after refusing a remote host the fallback
   re-extracted the port out of that same refused address:

     "Local: http://evil.example.com:3000/"  ->  "http://localhost:3000"

   A preview conjured entirely out of a line we had deliberately ignored. The
   fallback now blanks remote URLs first, so remote-only output yields null. The
   assertion is strict equality against null, matching its stated contract.

   A bare port with no host ("running on port 3000") still works — that is the
   Express-boilerplate case the fallback exists for — and mixed Local/Network
   output still picks the local one.

2. The setting description understated the bound: it said localhost / 127.0.0.1,
   while the implementation also accepts the IPv6 loopback [::1] and treats the
   bind addresses 0.0.0.0 and [::] as local (rewriting them to localhost, which a
   browser can actually resolve). Now says so.

Verified: 23 suites, 0 failures. Mutation-checked — restoring the raw-text
fallback reproduces the leak above and fails the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 24, 2026 01:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Comment thread extensions/levelcode-ai/agent.js
Comment thread extensions/levelcode-ai/test/verify.test.js Outdated
1. runCommand streams arbitrary stdout slices, so a dev server's address
   routinely arrives split across a boundary — "http://local" + "host:5173/" —
   and neither half matches. The preview would then silently never open, which is
   the worst failure mode for this feature because there is nothing to point at.
   Simulated against the real streaming path:

     per-chunk (old): null
     tail      (new): "http://localhost:5173/"

   The sniffs now read a bounded tail of entry.ring, which was already being
   maintained for read_command_output. 8 KB is far more than any single log line
   needs, and bounding it keeps the rescan cheap for a noisy watcher that never
   prints an address at all.

   Applied to ALL THREE sniffs, not just the preview: port and ready had the same
   latent gap, and fixing only the new one would have left the identical bug in
   place next to it.

2. ctx.openPreview is async and was called bare. Now wrapped so a rejection can
   never escape into a live stream handler — showing a browser tab must not be
   able to disturb a running command's output.

3. The junk-input test asserted only doesNotThrow. For a stdout handler that is
   necessary but not sufficient: a non-null return would still pop a browser tab.
   It now asserts the value is null, over a wider set of junk.

Verified: 23 suites, 0 failures; verify.test.js is 16 cases. The split-chunk case
is pinned with the real sniffer — each half must not match, the join must.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 24, 2026 01:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

extensions/levelcode-ai/agent.js:407

  • The sniffer now only scans entry.ring.slice(-SNIFF_TAIL). If a single stdout chunk is larger than SNIFF_TAIL and the server URL/port appears earlier in that chunk, it can be sliced away and never detected (regression vs scanning the whole chunk). Consider sniffing against previousTail + chunk so you still handle boundary-splits without missing URLs that occur near the start of a large chunk.
					const tail = entry.ring.slice(-SNIFF_TAIL);
					if (!entry.port) { const p = sniffPort(tail); if (p) { entry.port = p; ctx.post({ type: 'bgTask', id: runId, port: p }); } }
					if (!entry.ready && looksReady(tail)) { entry.ready = true; ctx.post({ type: 'bgTask', id: runId, ready: true }); }

Comment on lines +54 to +59
test('PREVIEW: prefers the full printed url, keeping scheme, port and base path', () => {
assert.strictEqual(sniffPreviewUrl(' ➜ Local: http://localhost:5173/'), 'http://localhost:5173/');
assert.strictEqual(sniffPreviewUrl('ready - started server on http://localhost:3000/app'), 'http://localhost:3000/app');
assert.strictEqual(sniffPreviewUrl('https://localhost:8443/'), 'https://localhost:8443/');
assert.strictEqual(sniffPreviewUrl('running at http://127.0.0.1:4200'), 'http://127.0.0.1:4200');
});
@ndemianc
ndemianc merged commit b736104 into develop Jul 24, 2026
2 checks passed
@ndemianc
ndemianc deleted the feat/auto-preview branch July 24, 2026 01:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants