Commit Graph
282 Commits
Author SHA1 Message Date
Ramiro_quaiandClaude Sonnet 5 8a57a492a8 refactor(input): share pointer/mouse click dispatch across click paths
Follows up on the maintainer's review note on PR #3431: WebDriver.click
(testdriver), Frame.user_input.triggerMousePress/Release (CDP's
Input.dispatchMouseEvent, i.e. Puppeteer/Playwright), and actions.click
(MCP) each dispatched their own near-identical pointerdown/mousedown/
pointerup/mouseup/click sequence. Adds three shared functions to
frame/user_input.zig -- dispatchPointerPress, dispatchPointerRelease,
dispatchClickAsPointer -- and routes all three call sites through them.

This gives the CDP path pointerdown/pointerup for the first time (it
previously only fired bare mousedown/mouseup/click), and a PointerEvent
click (previously a plain MouseEvent there). It also gives WebDriver.click
suppress/focus handling it never had: that function used to dispatch its
fixed five-event sequence unconditionally, ignoring preventDefault() and
never moving focus.

Because CDP's mousePressed and mouseReleased arrive as two independent
Input.dispatchMouseEvent messages with no shared call stack, a new
Page.input_mousedown_suppressed field carries whether the press half's
cancelled pointerdown should suppress this gesture's mouseup on the
release half. It's read-and-reset unconditionally at the top of
triggerMouseRelease (and reset on a press that finds no element), so an
unmatched or missed message can't leak stale state into the next gesture.

dispatchPointerPress returns PressResult{suppress_mouse, suppress_focus}
rather than running the focus default action itself: focusForMouseDown
can fail, and the three callers don't agree on what that should mean for
the click (actions.click: warn and continue; WebDriver.click and CDP:
propagate), so each runs it against the result with its own handling.

WebDriver.click also now reads frame._page.input_modifiers so a held
modifier key still reaches its dispatched events, matching its own
pre-existing local helpers' behavior (only compiled under
-Dwpt_extensions; actions.click and CDP don't track modifier state, so
they pass an empty Modifiers{}).

WebDriver.actionSequence's performPointerSource (a fourth, more complex
copy -- click counts, drag chords, touch) is deliberately left alone, as
is its own pre-existing gap (no pointerdown-suppresses-mousedown there).

Two new CDP tests reuse the existing mcp_actions.html fixture (#btn
records the full event sequence; #btnPreventDefault's pointerdown
listener calls preventDefault()) to pin the new pointer events and the
cross-message suppression. Both were confirmed to fail against the
pre-refactor triggerMousePress/Release bodies. Extended #btn's recorder
with event.detail after an independent review caught mousedown/mouseup's
click count silently dropping to 0 at all three call sites in an earlier
version of this change; the MCP click test's assertion was updated to
match.

zig build test and zig build test -Dwpt_extensions: 1516/1516 both ways.
zig fmt --check clean on all seven changed files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLXnBHBxQNskg2MAke3Lhv
2026-09-17 07:55:59 +08:00
Adrià Arrufat 2cc537fc8c Merge branch 'main' into stylesheet-scroll-container 2026-09-16 14:53:52 +02:00
Karl Seguin afbc8378b1 Merge pull request #3522 from lightpanda-io/regex-shared
`Regex`: share the PCRE2 wrapper; `findElement` matches names by `/regex/`
2026-09-16 16:55:40 +08:00
Adrià Arrufat 90487270ba findElement: the name filter is one union; a literal is one by grammar
A name is a substring or a regex, never both, so the filter says so
instead of carrying two optionals the caller has to null against each
other. Whether `/.../x` is a literal is now decided by JavaScript's flag
alphabet rather than "looks like letters", which stops `/usr/bin` from
being read as a pattern with flags. Comments that narrated callers or
the type name are gone; the literal parser gets its own test in place
of two MCP round-trips.
2026-09-16 09:38:42 +02:00
Adrià Arrufat 5710f1d684 findElement: accept the flags of a JavaScript regex literal
A model that writes `/spice/i` should not be told the name has a
stray `i` in it. `i` and `u` are already how names match, `s` and `m`
map to their PCRE2 options, and any other letter is a tool error that
names it. Text after the closing slash that is not a letter makes the
whole thing a plain substring again.
2026-09-16 09:29:18 +02:00
Adrià Arrufat b3758045a8 Resolve scroll containers through the style cascade
Element.scrollContainer read the inline style= attribute only, so a
scroller declared in a stylesheet was invisible to the scroll tool and to
wheel scrolling, which then fell through to the viewport.

StyleManager now tracks overflow-x and overflow-y alongside display,
visibility, opacity and pointer-events, and exposes scrolls(el, axes) as
an own-element probe. The overflow shorthand is expanded into its
longhands in declaration order, in both the attribute scan and the
materialized style object, so a shorthand and its longhands keep the
precedence of the source text. overlay counts as auto, as in Chrome.

Element.scrollContainer asks the style manager, and the two unused Props
bits hold the new flags, so the per-element memo does not grow.
2026-09-15 09:46:49 +02:00
Adrià Arrufat 158de60d64 findElement: match accessible names by regex
`name` is a substring, which cannot say "starts with", "exactly this"
or "either of these". A name written as `/.../`, the spelling adblock
lists already use, is a JavaScript-syntax pattern compiled through the
App's PCRE2 context; an invalid one comes back as a tool error carrying
PCRE2's message and offset so the model can fix it rather than retry
blind.
2026-09-15 09:33:39 +02:00
Adrià Arrufat 1e67c6fc91 Scroll the nearest scroll container from the scroll tool
The scroll tool (MCP, agent, LP.scrollNode) wrote scrollTop on the exact
node it was given, so a leaf inside an overflow:auto panel stored an
offset on a non-scroller, the panel's own scroll listener never ran, and
the tool reported the requested coordinates as if it had worked. It also
fired a synchronous bubbling scroll on top of the async non-bubbling
scroll/scrollend the setters already schedule.

actions.scroll now resolves the nearest ancestor-or-self scroll
container, falls back to the node itself, and returns the node that
moved plus the read-back position. The tool and LP.scrollNode report
that instead of the request.

The container query moves from user_input.zig onto Element as
scrollContainer(axes), so the wheel path, the tool and WebDriver share
one resolver. WebDriver's wheel scrolled the hit-test element directly
and fired its own bubbling scroll; it now goes through
user_input.wheelScroll like CDP and BiDi wheel.

Window and Element share one ScrollToOpts. Its offsets() helper
normalizes the positional and dictionary forms once, and an omitted axis
in the dictionary form leaves that axis untouched for the window too,
matching browsers, so scrolling the window on one axis no longer resets
the other.
2026-09-14 17:48:25 +02:00
Adrià Arrufat 0e04a2fe5d Merge remote-tracking branch 'origin/main' into fix/click-dispatch-full-mouse-sequence
# Conflicts:
#	src/browser/frame/user_input.zig
#	src/browser/webapi/Element.zig
2026-09-11 10:39:53 +02:00
Adrià Arrufat 032502db21 refactor(input): share dispatch-cancelable and native-focusable helpers
Extract EventManager.dispatchCancelable so the acquire-ref/dispatch/read-
defaultPrevented pattern lives in one place instead of four copies across
actions.zig, user_input.zig and WebDriver.zig. Share a single
isNativelyFocusable predicate between the mouse-focus rules and moveFocus,
which had the same tag switch verbatim, and fold the SVG-link check into
isSvgLink. Return early from Element.focus when the element is already
active, before the visibility walk, so a click no longer pays a CSS
ancestor walk to re-focus a native control. Collapse the repeated MCP
click-test blocks into a selector loop and give the runtime focus test
active()/expectActive() helpers.
2026-09-11 10:35:37 +02:00
Ramiro_quaiandClaude Opus 5 5e43683a6e fix(input): honor mousedown preventDefault and correct mouse-focus rules
Addresses review on the mousedown default action.

Blur when a mousedown lands outside any focusable element: focusForMouseDown
now blurs the document's active element when the ancestor walk finds nothing
mouse-focusable, so a plain click moves focus to the body. The MCP fixture had
this backwards and asserted the old behavior; it now records
document.activeElement === document.body.

Treat an unparsable tabindex as if the attribute were absent (HTML 6.6.3)
rather than as a terminal "not focusable" answer, so <button tabindex="abc">
keeps its native focusability. This mirrors HtmlElement.getTabIndex's
parse-failure fallthrough for the same attribute instead of introducing a
second parser with different semantics.

Check the tabindex attribute before the HTML-only guard. An explicit,
parseable tabindex is focusable on any element, and SVG links are focusable by
the same href they activate on. This was only a gap until the blur above made
it a regression: clicking such an element used to leave focus alone and would
now have dropped it to the body, where Chrome focuses the element.

Honor preventDefault() on mousedown. The default action ran unconditionally,
so the toolbar idiom -- preventDefault() on mousedown to keep focus in a
focused input -- did not work, and the new blur made that load-bearing. The
mouse dispatch helpers in actions.zig, user_input.zig and WebDriver.zig now
report whether the event was cancelled, and each call site gates
focusForMouseDown on it. Non-mousedown call sites discard the result.

Editing-host focus behavior is deliberately unchanged.

Fold mouseFocusTabIndex into isMouseFocusable; its ?i32 return was never read.
Drop the duplicate Runtime.zig test that drove the same actions.click path as
the tools.zig fixture, and expand the remaining one, renamed to reflect what it
now covers. Two of its assertions sample document.activeElement in a mouseup
listener rather than after the click, because click activation behavior focuses
those elements unconditionally and would mask what mousedown decided. Harden
the disabled-control test to record the full five-event sequence instead of
mousedown alone. Every assertion was checked to fail against the pre-fix code.

zig build test 1340/1340, zig build test -Dwpt_extensions 1340/1340,
zig fmt --check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 01:10:04 -06:00
Karl Seguin 5bc4f7482c render: add "clutter" option to --strip-mode
Adds a `clutter` option to --strip-mode. This is based on readability.js. It
isn't a direct port (e.g. it doesn't strop bylines). It fallsback to `shell` if
it strips too much (and shell itself can fallback to not stripping anything).
But clutter rarely fallback to shell, only when a page is very small or when
it strips out _a lot_.

Also expanded shell to look at class names and ids.
2026-09-11 06:36:48 +08:00
Karl Seguin 2e6999f20b chore: make declarations private if they don't need to be public
This change is 99%  s/pub//   + a handful of dead code removal.
2026-09-10 14:42:09 +08:00
Ramiro_quai e2d878eee8 fix(input): mousedown focuses tabindex targets, not only contenteditable
focusEditingHostForMouseDown only walked contenteditable hosts, so a
div[tabindex=0] stayed unfocused after click. Replace it with
focusForMouseDown: still prefer the outermost editing host, then focus
the nearest mouse-focusable element (including tabindex=-1).

Call sites: actions.click, CDP triggerMousePress, WebDriver pointerDown.

Tests cover the MCP selector path, agent Page.click on the HTML fixture,
and a runtime-created child click (ancestor walk, non-focusable must not
steal focus, tabindex=-1 is mouse-focusable).
2026-09-07 23:31:09 -06:00
Ramiro_quaiandClaude Sonnet 5 46ac5094c5 fix(actions): address review on click's mouse/pointer sequence
Round-1 review feedback on the pointerdown/mousedown/pointerup/mouseup/
click sequence added in 3e8ff142e:

- Guard disabled elements up front (el.isDisabled()), matching the checks
  WebDriver.click and HtmlElement.click already have — otherwise a
  disabled checkbox/button still activates.
- A cancelled pointerdown now suppresses both mousedown and mouseup for
  the gesture (one check, not two independent ones — see below).
- mousedown now calls Frame.user_input.focusEditingHostForMouseDown,
  matching the CDP path in frame/user_input.zig, so clicking a
  contenteditable host or tabindex target actually focuses it.
- updateHoverTarget(..., .{ .with_pointer = true }) fires before the
  first pointerdown, matching the hover step both other click paths take.
- click is now dispatched as a PointerEvent (pointerType "mouse",
  pointerId 1), matching HTMLElement.click(), with pressure 0.5 while a
  button is down.
- Dropped the always-0 `button` param from both dispatch helpers, merged
  the duplicated dispatch/log code into one dispatch() helper, dropped
  the restated clientX/clientY = 0 defaults, trimmed the doc comment.

Test coverage: the click assertion in tools.zig now checks the exact
five-event sequence (type:button:buttons:pointerType:isTrusted) instead
of a single boolean. Two new fixtures in mcp_actions.html close the gaps
the weaker test couldn't see: a button whose pointerdown listener calls
preventDefault() (asserts the sequence collapses to exactly pointerdown,
pointerup, click — no mousedown/mouseup), and a disabled button (asserts
mousedown never fires).

The first draft of the suppression fix wrongly gated mouseup on
pointerup's own preventDefault() instead of pointerdown's; the new
preventDefault fixture catches that regression too, confirmed by
reintroducing it and watching the assertion fail before restoring the
correct version.

Full suite (1339 tests) and zig fmt --check pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:42:58 -06:00
Karl Seguin 7c51dfff05 bidi: add skeleton for bidi
1. Abstract "Driver". Non-CDP things that referenced *CDP now reference a Driver
2. Move the NodeRegistry out of CDP. This created an artificial link between
   agent / mcp and CDP
3. Add BiDi driver with enough to navigate to a page
2026-08-31 22:44:59 +08:00
Karl Seguin 23891004dc Merge pull request #3327 from lightpanda-io/anchor-target-blank-popup
browser: open target=_blank links as popups and let tools follow them
2026-08-31 19:31:25 +08:00
Adrià Arrufat 95e8dfa400 browser: make resolveTargetFrame's _blank explicit; submit target=_blank forms into a popup
resolveTargetFrame returned null for _blank, so every caller carried the
meaning of that null. It now returns a tagged union and callsites switch
on it. <form target=_blank> takes the same path as links: the popup is
opened only once the submission is going ahead, so validation or
preventDefault can't leave a stray window. The opener rule (withheld
unless rel=opener) lives in one Frame.openBlankTarget helper.

awaitQueuedNavigation took a frame id, but Runner waits are keyed by
Page root, so a followed popup's id resolved to FrameNotFound and every
navigation from inside a popup failed. It now takes the Frame and waits
on its Page root, read before processing since a synthetic root
navigation frees the Page in place.
2026-08-31 09:32:59 +02:00
Ramiro_quai 3e8ff142ef fix(actions): click dispatches full pointer/mouse sequence, not a bare click event
actions.click() previously dispatched a single untrusted-shaped "click"
MouseEvent directly on the target node, skipping pointerdown, mousedown,
pointerup, and mouseup entirely. Many real-world widgets — custom
autocomplete/combobox components in particular — open or otherwise react
on mousedown, not click alone, so this made them unreachable through the
click tool even though the element was correctly focused and targeted.

WebDriver.zig's own click() already implements the correct sequence
(pointerdown, mousedown, pointerup, mouseup, click) for testdriver's
click, with a comment explicitly contrasting it against a lone untrusted
click event. This change ports that same sequence into actions.click()
so the MCP/CDP "click" action produces the same event sequence a real
user click would.

Adds a mousedown assertion to the existing MCP Actions test
(mcp_actions.html + tools.zig) to catch a regression here; confirmed the
new assertion fails against the old implementation and passes against
this one. Full test suite (1339 tests) and zig fmt --check both pass.

Reproduced against a real, previously-untested production site
(a Wix-built autocomplete branch-selector widget) where click could
focus the input but never open its option list; a minimal local
reproduction (a mousedown-only widget) confirms the fix.
2026-08-30 04:32:17 -06:00
Adrià Arrufat f1bdd0019f Merge branch 'main' into tool-session 2026-08-28 15:14:56 +02:00
Adrià Arrufat 96e9ef969a browser: open target=_blank links as popups and let tools follow them
Clicking a target=_blank link was logged and dropped. It now opens a
popup Frame, the same top-level context window.open creates; the opener
is withheld unless rel=opener, per spec.

A popup is invisible to the tool layer, which acts on the root frame, so
finalizeAction snapshots the popup count before the action and, when one
appears, waits for it to load and makes it the session's current frame.
The action result says so. Once the popup is gone, tools fall back to
the root.
2026-08-28 11:23:05 +02:00
Adrià Arrufat e6165d23bb mcp: release a large HTTP response buffer after the reply
The transport already drops its staging buffer past 256 KB, but the
connection buffer that receives the copy lives for the whole keep-alive
connection and was only ever cleared, so one screenshot pinned its
capacity per open connection. Apply the same threshold there.

Claude-Session: https://claude.ai/code/session_01S9t1TX3vTKunaXdnatbcjB
2026-08-27 21:28:32 +02:00
Adrià Arrufat d8893f95d7 mcp: bracket every isolate use, drop park_isolates
stdio kept the isolate permanently entered while HTTP parked it between
requests, so a handler touching page JS without enterIsolate passed the
stdio-mode tests and only failed under --port. Bracket unconditionally:
stdio is the one-isolate case of the same discipline.

The flag survives only as `multi_session`, which is what it always gated:
the session tools need a transport that routes by id.

Claude-Session: https://claude.ai/code/session_01M6WGk8wZSE28efFQkYT9SK
2026-08-27 16:30:49 +02:00
Adrià Arrufat 071eca1f3f mcp: fold Server.Session onto lp.ToolSession
Extract the Browser+Session+Notification+node Registry quartet, with its
ordering-sensitive init/teardown and isolate enter/exit, into
src/ToolSession.zig. mcp/Server.zig's sessions map now holds
*lp.ToolSession directly; the session id lives only as the map key.

Claude-Session: https://claude.ai/code/session_01M6WGk8wZSE28efFQkYT9SK
2026-08-27 16:27:27 +02:00
Adrià Arrufat 45744407af screenshot: fit() owns the inline sizing; prune() owns image expiry
The renderer knows that layout reflows to the width and that height 0
means the whole content, so the bounding rule moves next to measure()
and only measures when the strip isn't already fixed: the default
viewport shot no longer pays a second layout pass. The limits are
constants spliced into the tool description, not a caller-tunable that
nothing tuned. expireImages runs inside prune so there is one end-of-
turn hook, and re-homes a stripped result instead of casting away const.
2026-08-27 15:03:19 +02:00
Adrià Arrufat c693979468 agent: bound what a screenshot costs the model
An inline screenshot is re-sent on every request for as long as it sits
in history, and a full-page render could reach 1920x16384. Inline images
are now rendered at most 1280 wide and 4096 tall (measured after the
reflow, so short pages aren't padded), files keep full size; the
conversation keeps only the newest two images, older tool results keep
their text with a note; the MCP transport releases its buffer after a
large response.
2026-08-27 15:03:19 +02:00
Adrià Arrufat cff32f6c04 agent: let the model see a REPL /screenshot; fold the tool-result adapters
The slash path's result has two consumers: the terminal, which can't
show an image, and the conversation, which can. Opt in when a model is
attached and forward the image through the same adapter the model-driven
path uses, and stop mapping a failed adapter to a text-only success.
MCP's ImageContent and CallToolResult take their payload type like
TextContent does; resolveScope reuses resolveTarget; needsLocator folds
into replayRequires.
2026-08-27 15:03:19 +02:00
Adrià Arrufat 193f15558c tools: declare image support at the call site, not in each consumer
Callers pass CallOpts.inline_image; execScreenshot rejects a path-less
call before navigating or rendering, which removes the per-consumer
guards and covers the model-driven tool path that had none. MCP image
content is a protocol type, the screenshot recording rule joins the
recorder's replayRequires predicate, and the viewport-to-Opts mapping,
node-scope ladder and save-path helpers are shared instead of copied.
2026-08-27 15:03:19 +02:00
Adrià Arrufat 56c6a0a7ab tools: add a screenshot tool
The PNG renderer was reachable from CDP and fetch --dump png only. The
tool renders the page or one node; with `path` it writes the file and
returns its location (agent, PandaScript, MCP), without it MCP returns
the image inline as base64 content. ToolResult carries the prepared
image so the transport streams it; the agent and script runtime reject
the inline form since their tool results are text. An inline screenshot
is not recorded, as it has no replayable form.
2026-08-27 15:03:19 +02:00
Adrià Arrufat 6dca2c8917 links: dedup by href, skip hidden, fall back to alt/aria-label/title
The links tool returned every a[href] verbatim: hidden nav entries,
one row per duplicate href, and null text for image and icon links even
though markdown already knows to use alt. Nav-heavy pages produced
thousands of rows for an agent looking for one link.

One entry per resolved href (the first, upgraded with text from a later
duplicate), hidden anchors skipped with the same check tree uses, text
falling back to aria-label, title, then a descendant img alt. The tool
gains an optional limit.
2026-08-26 13:13:08 +02:00
Adrià Arrufat 5f8eb43867 Merge pull request #3276 from lightpanda-io/mcp-tool-annotations
mcp: add title and annotations to tools/list
2026-08-26 09:46:48 +02:00
Pierre Tachoire f2169836e5 Merge pull request #3256 from lightpanda-io/mcp-origin
mcp: enforce HTTP conditions
2026-08-26 09:42:29 +02:00
Adrià Arrufat 48551d241a mcp: navigation and waits are not read-only; consoleLogs is not idempotent
Review feedback: navigation writes cookies and storage and a wait lets page
scripts mutate state, so neither can be auto-approved as read-only.
consoleLogs drains its buffer, so repeated calls differ.
2026-08-26 08:44:57 +02:00
Karl Seguin 51279b80c2 Merge pull request #3274 from lightpanda-io/mcp-protocol-version
mcp: negotiate the protocol version on initialize
2026-08-26 13:41:40 +08:00
Karl Seguin ff9adc4202 Merge pull request #3277 from lightpanda-io/html-tool-maxbytes
tools: add maxBytes and strip to the html tool
2026-08-26 13:38:13 +08:00
Pierre Tachoire 78fda6779f allow localhost host to connect to CDP and MCP 2026-08-26 09:24:01 +08:00
Pierre TachoireandAdrià Arrufat a0e3da6a6b Update src/mcp/HttpServer.zig
Co-authored-by: Adrià Arrufat <1671644+arrufat@users.noreply.github.com>
2026-08-26 09:24:01 +08:00
Pierre Tachoire 9db3c1bb94 mcp: enforce HTTP conditions
Apply same conditions than CDP for MCP HTTP conns:
* refuse requests including Origin header
* accept only ip as host
* ensure content-type is json for POST
2026-08-26 09:24:00 +08:00
Adrià Arrufat f05789353b tools: add maxBytes and strip to the html tool
An unscoped html call returned the whole document, inline scripts and
stylesheets included, with no way to cap it; markdown already had
maxBytes. The byte-capping writer moves out of markdown.zig into a shared
LimitedWriter so both tools use it, and strip reuses dump.Opts.Strip.
2026-08-25 15:53:11 +02:00
Adrià Arrufat ade731cb0b mcp: add title and annotations to tools/list
Every tool already had a short summary and a natural read-only /
destructive classification, but tools/list exposed neither, so clients
had to prompt for every call, including pure reads like getUrl.
2026-08-25 15:46:30 +02:00
Adrià Arrufat 3e8b836680 mcp: negotiate the protocol version on initialize
initialize always answered 2024-11-05 regardless of what the client asked
for. Per the spec, echo the requested version when we support it and
otherwise reply with the newest we do (2025-11-25).
2026-08-25 15:30:26 +02:00
Adrià Arrufat ec396f5dc1 mcp: close the connection after a keep_alive=false response
std.http.Server's respond() only drains the request body when the server
keeps the connection alive; with keep_alive=false it leaves the body
unread, sets reader.state = .closing, and expects the caller to sever the
connection ("the connection will be severed after the response is sent").

handleConn looped on the client's keep_alive flag instead, which is true
for any HTTP/1.1 request, so after a 405/413/415/417/403 it called
receiveHead() again and fed the unread POST body to the head parser. The
server advertised `connection: close` but held the socket open until the
peer gave up.

Honour the reader's closing state so the socket is closed right after the
rejection response.
2026-08-25 10:33:39 +02:00
Karl Seguin 2d32e78838 chore: Remove Server component from Network
Network is less cohesive than https://github.com/lightpanda-io/browser/pull/3242
would indicate. It has two distinct and _completely_ separate responsibilities.

1 - It acts as the base for each HttpClient, providing a shared connection pool
    (for http and ws) and access to the process wide Cache, RobotStore,
    WebBothAuth, certificates, ...

2 - It accepts, polls and reads from CDP connection

There is zero relationship between these, and it's a particularly bad place for
this duality to exist because both parts are, in their own way, the main
multi-threaded junction in the system.

This commit is purely mechanical it:

1 - Keeps network as the base for each HttpClient.
2 - Extract the CDP interaction into the existing Server.zig
3 - Gives mcp's HttpServer its own accept loop
2026-08-22 10:11:30 +08:00
Adrià Arrufat e69aedea14 tools: treat backendNodeId 0 as omitted, add recovery hints to errors
Some models (gpt-5.x via codex) zero-fill optional tool params instead
of omitting them, so every tree/markdown/html call carried
backendNodeId: 0, failed with NodeNotFound, and got retried blind --
one 'go to hacker news' burned 19 tool calls and 18 full page loads.

Registry ids start at 1, so 0 can never name a real node. Make
0-means-omitted part of the tool contract: state it in the schema
descriptions and normalize it in parseValue. Required ids
(nodeDetails) are untouched.

In-band guidance alone was not enough -- the model re-emitted the
identical call even when the error spelled out the fix -- but keep it
for genuinely stale ids: NodeNotFound and FrameNotLoaded now carry an
actionable message in the agent, slash-command, and MCP paths instead
of a bare error name.
2026-08-06 14:20:34 +02:00
Karl Seguin c726b022e8 tests: improve test output and test log handling
1 - TEST_VERBOSE is now off by default
2 - There's a afterEach callback that is automatically run after each tests, it:
     a - clears the log filters
     b - resets the test arena
3 - LogFilter replace with
     a - testing.silenceLog(&.{...scopes...}); to silence all logs for the given
         scopes.
     b - testing.expectLog(&.{...scopes}); to set log expectations, 1 per log.
         The goal here isn't so much to expect logs (though, you can do that),
         but rather to silence an expected # of logs, without silencing more.
2026-07-31 07:30:25 +08:00
Karl Seguin 8e42d63c1c zig: Zig 0.16
Built against https://github.com/lightpanda-io/zig-v8-fork/tree/zig-0.16 but
it doesn't require a new v8 build.

Built against https://github.com/lightpanda-io/boringssl-zig/tree/zig-0.16
since the current fork we point to isn't updated.

A global std.Io instance, lp.io. Way easier this way and requires 0 changes to
our libcurl integration / event loop.

Network code uses a new layer that does what Zig 0.15's posix package used to
do. Again, quicker migration that way. But, as long as we have the global IO,
and given the half-baked nature of networking in std.Io 0.16, this just makes
sense. Things can be migrated as needed.

The std.time.* -> std.Io.Timestamp/Clock/Duration resulted in _a lot_ of
changes. ArrayList = .{} -> ArrayList -> .empty also resulted in a lot of
changes, but that's obviously superficial. As is the trimLeft/trimRight ->
trimStart/trimEnd rename.

Locking adopt the `Uncancelable` variants, e.g. mutex.lockUncancelable() to
preserve the error-free signature (and, because cancellation would be something
we'd have to put more thought into).

std.json.ObjectMap is now unmanaged, so the allocator had to be passed along.
However, there's still a deprecated managed variant of MemoryPool, so I switched
to it (we can do a small follow up PR to move to the unmanaged after).

I tried use_llvm = false, but it locks my computer, consuming RAM until MacOS
gives me a popup I've never seen before, begging me to start killing processes.

Agent and the networking stuff saw the most significant changes.
2026-07-22 13:26:03 +08:00
Adrià Arrufat 8c0ec3d73a Merge branch 'main' into pandascript-skill 2026-07-14 12:06:05 +02:00
Adrià Arrufat 3e801fb83b script: generate the PandaScript skill from the tool schemas
The /save script documentation lived as a hand-maintained string in
Agent.zig whose primitives table drifted whenever a tool changed. Move
it to src/script/skill.zig and render the reference (signatures, option
lists, enums, defaults, per-parameter descriptions) from Schema.all()
at first use, keeping the curated per-tool notes behind an exhaustive
switch on Tool so a new or renamed tool is a compile error until its
doc entry exists.

The rendered skill is shared by three consumers:
- the /save and revision system prompts (built lazily in Agent.zig)
- a new mcp://skill/pandascript resource
- `zig build skills`, which writes zig-out/skills/<name>/SKILL.md with
  Claude Code frontmatter via a registry-based generator exe, so
  future Lightpanda skills are one registry entry each

Schema.FieldEntry now retains per-parameter schema descriptions, which
previously existed only in the raw JSON.
2026-07-13 10:54:13 +02:00
Adrià Arrufat 2c4803b1cf mcp: ensure clean http server shutdown
Track active connections and shut down sockets during deinit to unblock
pending reads. Drain the job queue properly before stopping the worker.
Also fix `--http-port` references to `--port` in docs and errors.
2026-07-13 10:07:37 +02:00
Adrià Arrufat f66f0c191d mcp: add HTTP transport and multi-session support
Introduces an HTTP transport option to serve multipleagents from a
single process. Each connection is routed to its own isolated
browsing session using the `Mcp-Session-Id` header.

Also adds new session management tools (`session_new`, `session_list`,
`session_close`) and refactors the MCP server to support multiple V8
isolates with parking.
2026-07-12 15:27:20 +02:00