The recorder holds each goto one step: if the next recorded call is
waitForSelector/waitForState, the emitted line carries
waitUntil=domcontentloaded (the follow-up wait covers readiness); anything
else, including extract and comments, flushes the plain load-wait form. An
explicit waitUntil is never rewritten. bytes() flushes a trailing held goto
and became fallible; isEmpty() replaces the len check in synthesizeSave (#3138).
Optional param on the goto tool (load | domcontentloaded | networkalmostidle |
networkidle), default unchanged (load). Threaded through performGoto, startGoto
and the script runtime's pending-goto driver, which previously hard-coded
.load. Closes half of #3138.
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.
Since the Zig 0.16 merge, console.log and completion echoes from the
agent runtime tests reach the real stdout (0.15 silently swallowed
them), polluting the bench.json that CI captures from 'zig build test'
and breaking the perf-fmt job's JSON decoding.
Add Runtime.console_sink: when set, console lines write there instead
of stdout/stderr. The three printing tests now capture their output
and assert it, covering console argument joining and the top-level
return echo.
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.
- Add highlighting for JS functions, methods, types, and template
interpolations.
- Introduce teal color and adjust cyan to bright cyan.
- Import `ansi.zig` directly instead of through `Terminal`.
- Add PandaScript semantics note to the agent's system prompt.
- Refactor markdown table rendering and streaming.
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.
actions.setChecked set the input state and then dispatched a trusted
click, but the click's activation behavior (EventManager's
ActivationState) toggles a checkbox on every click dispatch — undoing
the state just set — and its commit path fires input/change, which
setChecked then fired a second time. Checkboxes always landed in the
opposite of the requested state with doubled events; radios only
appeared to work because radio activation always checks. It survived
because existing tests asserted the tool's report string, never the
resulting DOM state.
setChecked is now a no-op when the state already matches, otherwise it
performs the click (reusing actions.click) and verifies the state
landed — erroring before the click for radio+false (a click can never
uncheck a radio, and the click has observable page side effects) and
after it when a listener preventDefault()ed the toggle.
Caught by the checked.js golden in the demo agent regression suite.
The Runtime test now asserts DOM state and idempotency instead of
trusting the report string.
The main addition in this commit is that we hook into the Isolate's
AddNearHeapLimitCallback callback and try to force the isolate to shutdown
rather than letting v8 hit an OOM which would take down the entire process.
In support of this, we now support a `--v8-max-heap-mb` command line option to
set an explicit heap limit. As a simple way to test this feature, load a
relatively heavy JS page with `--v8-max-heap-mb 1`.
There's also a `--v8-flags-unsafe` which is a mechanism to pass arbitrary
flags to v8 via its `SetFlagsFromString`. The parameter is called `unsafe`
because some [of the many] configurable flags could conflict with how the
snapshot is built and result in crashes. The snapshot creator also gains a
`--v8-flags-unsafe` flag, so advance users COULD create their snapshot and
run lightpanda with the same set of flags.
startGoto runs under the browser's isolate; its error path rejected the
script-isolate resolver via a `return` inside that block, i.e. before
the deferred isolate exit — v8::Exception::Error on the wrong isolate,
SIGSEGV. Reached by any goto that fails to start: page.goto(null) (an
extract miss flowing into goto), malformed arguments.
Yield null from the isolate block and settle the resolver after it.
The script runtime's async goto path calls startGoto directly,
bypassing the substituteStringArgs pass that call() applies to every
other tool, so $LP_* placeholders in goto URLs (a base URL, a username
in the path) stayed literal and the navigation failed on replay.
A press/click that submits a form or follows a link commits a
replacement Page inside the tool call itself (finalizeAction ->
awaitQueuedNavigation), freeing the Frame that
Session._tool_frame_override still pointed at; finalizeAction then
dereferenced it via requireFrame -> currentFrame. Any script whose
press/click navigated crashed on replay.
Store a frame id instead of a pointer and resolve it at every
currentFrame call. The replacement page keeps the frame id (see
commitPendingPage), so the same handle stays routable across the swap.
Changes `page.goto` to return a pending Promise instead of blocking
synchronously. Introduces a driver loop in `Runtime` to tick the
browser and settle pending navigations. This allows parallel gotos
and routes tool calls to their respective frames.
Replaces the 1 (+1 inflight) page design of session for an unlimited number of
pages. The main goal is to support a more efficient async goto (1). Without this
commit, async goto has two implementation: multiple browser, which is resource
intensive (thread + isolate per page), bolting it onto sub-frames (like iframe
or popups). The issue with the 2nd version (which I originally suggested) is
that most resources are tied to the Page. So even if an async goto "page" (which
would map to a Frame) is released, most things stay in memory, including the
DOM (page.factory) and the V8::Context.
This new approach adds multiple page support to sessions. The advantage is
pretty clear: the existing memory model (page-tied resources) becomes a strength
of the design, rather than a weakness.
This change is not tirivla, but the diff is inflated by 2 large mechanial
changes, so it isn't _that_ big either. That said, I'd divide this into four
parts.
1 - The old concept of _active/_pending is now baked into the Page itself. A
Page has a `replaces: ?*Page = null` and `replacement: ?*Page = null` field.
2 - Because of #1 above, the Session now just has an `pages: ArrayList(*Page)`.
As much as possible, single-page APIs, like `removePage` no longer exists. We're
trying to present a consistent multi-page API. See #3.
3 - References to *Page and *Frame have always been dangerous. For example,
`lightpanda.fetch` has a block to scope `frame`:
```zig
{
const frame = try session.createPage();
// frame isn't safe to use after navigate, it can be swapped out
_ = try frame.navigate(...)
}
```
While we still hand out *Page and *Frame (user-beware), some of the more
important APIs now take a frame_id which Session can resolve. Furthermore,
createPage now returns a PageHandle which is a safe wrapper around a page/frame.
4 - Two large mechanical changes were made:
a - Many tests were superficially changed to account for new naming or use
a new test-helper to preserve the single-page illusion (because 99% of
tests _are_ single-page)
b - Many CDP changes where `bc.session.currentFrame()` -> `bc.mainFrame()`).
This isn't to say CDP changes are meaningless, but it's mostly 1 change
about how the "main" target/frame_id is tracked (by CDP itself, rather
than the Session) that required a number of superficial changes to
accomodate
`Runner` remains largely single-page focused. Runner and some MCP/Agent tools
continue to be tied to the "currentFrame" or "primaryPage". The Runner is
something I want to address in a follow-up PR, but we need to figure out what
it means to "wait" for multiple pages.
As much as possible, Session becomes multi-page native and has no concept of
a special/first/primary/main page. Users of session become responsible for
tracking pages of interest.
This change is not trivial, but the diff is inflated by 2 large mechanical
changes:
1 - Many tests
(1) https://github.com/lightpanda-io/browser/pull/2759
Allows tools like `/getEnv` with a single optional field to accept
positional arguments. Also adds autocompletion and ghost hints for
live `LP_*` environment variables.
Additionally, fixes a memory leak in `settings.zig` when parsing
invalid ZON configurations.
Replaces global blocking functions with a `Page` class. `page.goto` is
now asynchronous and must be awaited, while other methods remain
synchronous. Scripts are wrapped in an async IIFE to support top-level
await, and output is returned via `return <value>`.
* Revert "agent: enhance /save progress and verification feedback"
This reverts commit 54f467d1fa.
* Revert "agent: verify synthesized scripts during /save"
This reverts commit b141da30ca.
* agent: extract save helpers to save.zig
Moves pure helper functions related to the `/save` command (parsing,
validation, file writing, prompt shaping) from `Agent.zig` to a
dedicated `save.zig` module.
Introduces a multi-step synthesis process for `/save` that derives a
logical JSON output schema and uses a dry-run runtime to verify
candidate scripts. The LLM can now run and self-correct its scripts
using a new `run_script` tool before finalizing the save.
Removes the `waitUntil` option from `goto` and other navigation tools,
making them default to the fast `load` event. Introduces a dedicated
`waitForState` tool to wait for specific load states on demand.
Replaces manual tool-by-tool argument parsing in the runtime with a
schema-driven `marshalArgs` function. This supports merging trailing
options objects with leading positional arguments (e.g., `goto(url,
options)`). Also unifies positional definitions in `Schema` for both
the runtime and the recorder.
Only unwrap the `__root` sentinel injected for array schemas, ensuring
single-field object schemas retain their shape. Also update synthesis
prompt instructions for modern JS and tool fidelity.
Removes the `-i`/`--interactive` CLI flag and live file-based
recording. Instead, the REPL now supports a `/load <path>` command
to run scripts from disk, and `/save` to export the in-memory
session recording.
The `Recorder` is simplified to be purely in-memory, and the script
runtime is moved to `src/script/Runtime.zig`.
BREAKING CHANGE: The `-i`/`--interactive` flag has been removed. Use
the `/save` and `/load` commands within the REPL instead.
- Add `recordRaw` to record raw JS lines in the REPL.
- Only record commands if they succeed without error.
- Fix kitty terminal cursor keys by forcing legacy arrow encoding.
Removes the `--self-heal` CLI option, the `scriptStep` and `scriptHeal`
MCP tools, and associated verification/iterator machinery. Replaces
"PandaScript" terminology with "slash commands" and moves shared
helpers to `tools.zig`.
BREAKING CHANGE: The `--self-heal` CLI flag and the `scriptStep` and
`scriptHeal` MCP tools have been removed.
- Use `std.enums.values` and `@tagName` in `ScriptRuntime` to eliminate
the manual `primitive_specs` and `console_specs` arrays.
- Simplify `Primitive.tool()` using `std.meta.stringToEnum`.
- Clean up `writeConsoleLine` and remove `writeJsPositional`.
Replace recorded agent replay with a standalone JavaScript script runtime.
Install synchronous agent primitives in an isolated V8 context, add console
output, return structured extract values as JS objects/arrays, and route script
execution through the new runtime.
Update recording to emit .js function calls, default /save filenames to .js,
drop script-level extract save support, and refresh agent docs/tutorials for
the new format.
Self-healing is disabled for now.
Replaces the fragile `firstSentence` parser with an explicit `summary`
field on tool definitions. Also standardizes user-facing REPL
terminology to "command" instead of "slash command" or "tool".
* tools: add session-scoped bridge store
Exposes `globalThis.lp` to `/eval` calls, allowing state to persist
across evaluations and page navigations. Adds a `save` parameter to
both `/eval` and `/extract` to store results in the bridge.
* browser: await promises in eval and support inline args
- Await JS Promises in `eval` tool with a 30s timeout
- Support inline arguments in multi-line slash commands
- Silence output on successful `save=`
- Add `limit` option to extract schema walker
* eval: return empty text for undefined async IIFE
* extract: support limit on simple string arrays
Treats `["<sel>"]` as sugar for `[{"selector": "<sel>"}]` in the schema
walker. This enables the `"limit"` option on simple string arrays.
Also updates agent documentation to cover cross-call state with `lp.*`.
* refactor: optimize bridge store and schema lookup
- Introduce `bridgeStorePut` to skip redundant JSON validation for
trusted stringified values in `bridgeSync`.
- Store the schema pointer in `BlockOpener` to avoid re-parsing and
looking up the schema in `Iterator.next`.
- Clean up error handling and optional unwrapping in `execEval`.
Add a REPL-only `/save [filename.lp]` command that persists the current
interactive session as PandaScript without requiring the user to start the
agent with `-i <script>`. The command records the same replayable actions as
the existing script recorder, but keeps them in memory until the user chooses
to save.
Functional behavior:
- During a REPL session, record replayable browser actions into an in-memory
script buffer.
- Manual slash commands are recorded through the same PandaScript formatting
and filtering rules used by file recording.
- Natural-language turns record their prompt as a `# ...` comment only when
the LLM turn produces at least one successful replayable tool call.
- Failed LLM tool calls are skipped, and repeated successful `/extract` calls
keep only the last successful extract, matching the existing recorder logic.
- `/save filename.lp` writes the current in-memory recording to that file.
- Bare `/save` creates a random `session-<hex>.lp` file on first save.
- If the first save targets an existing file, prompt with the existing numbered
TTY picker and ask whether to replace or append.
- After the first successful save, the REPL session is locked to that filename:
later `/save` or `/save same-file.lp` appends to the same file without
prompting, while `/save other-file.lp` is rejected.
- After each successful save, reset the in-memory recorder so future saves only
append actions entered since the previous save.
- On any `/save` error or cancellation, keep the in-memory recorder intact so
the user can retry without losing captured actions.
- Restrict `/save` filenames to local file names, not paths, to keep behavior
scoped to the current directory.
Code changes:
- Add `Recorder.Memory`, an in-memory recorder that shares the existing
`Command.isRecorded`, `Command.format`, comment formatting, and `LP_*`
reverse-substitution behavior with file recording.
- Add `Recorder.Memory.reset()` so `/save` can clear only successfully saved
deltas.
- Add `/save` to the REPL meta command table and help/completion surface.
- Add `save_buffer` and `save_path` state to `Agent`.
- Feed manual REPL tool calls into `save_buffer` alongside the existing optional
file recorder.
- Extend LLM turn recording with a `capture_for_save` flag so natural-language
REPL turns can be captured without affecting non-REPL script/self-heal paths.
- Implement `/save` handling in `Agent`:
- parse and validate the optional filename,
- choose replace/append for existing first-save targets,
- remember the first successful save path,
- enforce the single-destination rule for the rest of the session,
- append later deltas by default,
- commit remembered path state only after a successful file write.
- Add focused coverage for the memory recorder’s filtering and reset behavior.
fix: address various stability and reliability issues
- Cache stderr TTY check in Config to optimize log hot path.
- Cap signal handler attempts during no-hard-exit.
- Always duplicate URL segments to prevent invalid frees.
- Write live file before backup in atomic writes.
- Settle microtasks before verifying DOM elements.
- Add diagnostics to command parsing to report invalid field values.
- Fix UTF-8 truncation in spinner to avoid splitting codepoints.
- Fix swapped arguments in log error formatting.
- Distinguish "null" values from missing elements in verifier.
- Handle MCP tool cancellation and timeouts with specific error codes.