diff --git a/.github/scripts/linux-canvas-smoke.sh b/.github/scripts/linux-canvas-smoke.sh
index ade751534..29912eba6 100755
--- a/.github/scripts/linux-canvas-smoke.sh
+++ b/.github/scripts/linux-canvas-smoke.sh
@@ -42,6 +42,14 @@ set -u
# Accessibility is not what this smoke tests.
export GTK_A11Y="${GTK_A11Y:-none}"
+# Allocate the display with xvfb-run when the runner does not provide one.
+# Re-exec the whole smoke script (rather than only the app) so the app,
+# xdotool, and xwininfo all use the same DISPLAY. This also avoids depending
+# on Xvfb's -displayfd support, which is not consistent across runner images.
+if [ -z "${DISPLAY:-}" ] && command -v xvfb-run >/dev/null 2>&1; then
+ exec xvfb-run -a --server-args="-screen 0 1280x800x24" "$0" "$@"
+fi
+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
app_dir="$repo_root/examples/ui-inbox"
snap="$app_dir/.zig-cache/native-sdk-automation/snapshot.txt"
@@ -128,18 +136,25 @@ assert_no_webkit() {
echo "== native-only ELF audit ok"
# ---- launch ---------------------------------------------------------------
-# The script owns its Xvfb (instead of wrapping the app in xvfb-run) so
-# the xdotool step below shares the app's display. -displayfd picks a
-# free display number, the modern equivalent of xvfb-run -a's probing.
-display_file="$(mktemp)"
-Xvfb -displayfd 4 -screen 0 1280x800x24 4>"$display_file" &
-xvfb_pid=$!
-for _ in $(seq 1 100); do
- [ -s "$display_file" ] && break
- sleep 0.1
-done
-[ -s "$display_file" ] || fail "Xvfb never reported a display number"
-export DISPLAY=":$(cat "$display_file")"
+# The normal CI path reaches this point with DISPLAY set by xvfb-run. Keep a
+# direct-Xvfb fallback for local environments that have Xvfb but not the
+# wrapper, while retaining the same display-sharing behavior.
+if [ -z "${DISPLAY:-}" ]; then
+ display_file="$(mktemp)"
+ xvfb_log="$(mktemp)"
+ Xvfb -displayfd 4 -screen 0 1280x800x24 4>"$display_file" 2>"$xvfb_log" &
+ xvfb_pid=$!
+ for _ in $(seq 1 100); do
+ [ -s "$display_file" ] && break
+ sleep 0.1
+ done
+ if [ ! -s "$display_file" ]; then
+ echo "-- Xvfb stderr:"
+ sed 's/^/ /' "$xvfb_log" 2>/dev/null
+ fail "Xvfb never reported a display number"
+ fi
+ export DISPLAY=":$(cat "$display_file")"
+fi
echo "== Xvfb on $DISPLAY"
cd "$app_dir" || fail "missing $app_dir"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4556d6901..aa43ebd7b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -90,6 +90,7 @@ jobs:
- run: zig build test-gpu-components-smoke
env:
NATIVE_SDK_SMOKE_BUDGET_MS: "1500"
+ NATIVE_SDK_INPUT_LATENCY_BUDGET_MS: "500"
macos-gpu-perf:
name: macOS GPU Perf
diff --git a/.gitignore b/.gitignore
index 85a837aa1..52a2b6daf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,4 @@ packages/core/node_modules/
# TS scaffold writes into a new app's .gitignore)
examples/soundboard-ts/node_modules/
examples/system-monitor-ts/node_modules/
+examples/agent-wars/node_modules/
diff --git a/README.md b/README.md
index 258d3038c..801df9a56 100644
--- a/README.md
+++ b/README.md
@@ -98,6 +98,7 @@ The apps pictured above live in [examples/](./examples), most as zero-config pro
| Example | What it shows |
| --- | --- |
| [`chatbot`](./examples/chatbot) | TypeScript + Native markup end to end: modules, a text editor, streaming fetch effects, and replay-safe configuration. |
+| [`agent-wars`](./examples/agent-wars) | A two-model Pi harness comparison app: native controls and progress around side-by-side WebView results. |
| [`soundboard-ts`](./examples/soundboard-ts) | The full music-player showcase in TypeScript + Native markup: audio, search, assets, timers, and context menus. |
| [`system-monitor-ts`](./examples/system-monitor-ts) | A live process monitor in TypeScript + Native markup: subprocess effects, tables, charts, and timers. |
| [`calculator`](./examples/calculator) | A complete small app: markup keypad, keyboard input, chrome shortcuts, theming. |
diff --git a/build.zig b/build.zig
index 49785b4d8..5161fc257 100644
--- a/build.zig
+++ b/build.zig
@@ -1647,6 +1647,7 @@ pub fn build(b: *std.Build) void {
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-terminal", "Run terminal example tests", "examples/terminal", .owned),
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-workbench", "Run workbench example tests", "examples/workbench", .owned),
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-system-monitor-ts", "Run system-monitor-ts example tests", "examples/system-monitor-ts", .managed),
+ addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-agent-wars", "Run agent-wars example tests", "examples/agent-wars", .managed),
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-effects-probe", "Run effects probe example tests", "examples/effects-probe", .managed),
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-channel-monitor", "Run channel monitor example tests", "examples/channel-monitor", .managed),
addExampleTestStep(b, host_cli_exe, native_examples_step, "test-example-menu-bar", "Run menu-bar lifecycle example tests", "examples/menu-bar", .managed),
diff --git a/examples/README.md b/examples/README.md
index 4f4b3cc9d..7e07c635c 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -17,6 +17,7 @@ TypeScript is the primary app-authoring language. A new `native init my_app` pro
| Example | Shows |
| --- | --- |
| `chatbot` | Multi-module TypeScript core, text editing, streaming `Cmd.fetch`, environment messages, and deterministic replay. |
+| `agent-wars` | Two editable Pi harness models, a shared task, spawn-streamed status, and side-by-side WebView previews. |
| `service-feed-reader` | The complete services loop: `Cmd.fetch`, a parsing service reached through the generated `@native-sdk/services` client, shared record shapes, and recorded replay without the service. |
| `relational-notes` | Append-only SQLite migrations, build-time checked SQL, generated typed transactions and page decoders, FTS5, and live queries. |
| `gpu-components` | Isolated interactive Native UI specimens, disclosure trees, anchored menus, and controlled component state. |
@@ -62,4 +63,4 @@ The `-ts` suffix is historical: `soundboard-ts` and `system-monitor-ts` distingu
`mobile-shell`, `ios`, and `android` are mobile host projects (Xcode/Gradle shells plus shared `app.zon` metadata) rather than desktop app directories.
-Start with `native init` for a small TypeScript + Native markup app, then use `chatbot`, `gpu-components`, `soundboard-ts`, or `system-monitor-ts` according to the feature you need. Use `habits` when you specifically want the smallest Zig-core equivalent, `hello` for the lower-level WebView path, `webview` for native commands or WebView policy, `capabilities` for guarded OS services, and `gpu-surface` or `gpu-dashboard` for custom-rendered or retained-canvas panes.
+Start with `native init` for a small TypeScript + Native markup app, then use `chatbot`, `agent-wars`, `gpu-components`, `soundboard-ts`, or `system-monitor-ts` according to the feature you need. Use `habits` when you specifically want the smallest Zig-core equivalent, `hello` for the lower-level WebView path, `webview` for native commands or WebView policy, `capabilities` for guarded OS services, and `gpu-surface` or `gpu-dashboard` for custom-rendered or retained-canvas panes.
diff --git a/examples/agent-wars/README.md b/examples/agent-wars/README.md
new file mode 100644
index 000000000..c757a4c65
--- /dev/null
+++ b/examples/agent-wars/README.md
@@ -0,0 +1,84 @@
+# Native SDK Agent Wars example
+
+A deliberately small, native-rendered comparison bench for exactly two coding
+models. The app shell is TypeScript + Native markup; a single long-lived Node
+sidecar uses the [AI SDK Pi harness](https://ai-sdk.dev/providers/ai-sdk-harnesses/pi)
+with `@ai-sdk/sandbox-just-bash` to run both agents concurrently.
+
+The example keeps the architecture visible:
+
+- `src/core.ts` owns the Native state machine, editable comboboxes, shared task,
+ compare/stop HTTP effects, and the coarse line-streamed status protocol.
+- `src/app.native` is the complete native UI. Progress appears only once per
+ model, immediately above its preview.
+- `sidecar/coordinator.ts` owns one local server, two isolated Pi sessions,
+ the versioned preview results, and the viewer bootstrap served to both child
+ WebViews.
+- `Cmd.navigateWebView` navigates those two declared child WebViews after the
+ coordinator announces that it is ready; the Native core never navigates the
+ reserved `main` WebView.
+
+## Requirements
+
+- macOS
+- Node.js 22 or newer
+- an `AI_GATEWAY_API_KEY` available to the app process
+
+Every comparison is routed through Vercel AI Gateway; provider-specific keys
+are neither read nor classified. The eight built-in choices are current model
+ids from Pi's [Vercel AI Gateway model catalog](https://pi.dev/models?provider=vercel-ai-gateway)
+that appear among recent [Terminal-Bench v2.1](https://artificialanalysis.ai/evaluations/terminalbench-v2-1)
+results: DeepSeek V4 Flash, GPT-5.6 Luna, GPT-5.6 Sol, Claude Opus 5,
+Claude Fable 5, Claude Opus 4.8, Kimi K3, and Grok 4.5. DeepSeek V4 Flash
+and GPT-5.6 Luna remain the defaults.
+
+The compact menus arrange those choices in two horizontal rows below each
+combobox. The native layout reserves that 64px surface before the platform
+WebViews begin, so every option receives real pointer clicks.
+Add another built-in choice to one of the two `MODEL_OPTIONS_*` arrays in
+`src/core.ts`; keep each row compact. The comboboxes remain editable, so any
+other model id from Pi's Vercel AI Gateway section can be entered directly.
+
+## Run
+
+```sh
+cd examples/agent-wars
+npm install
+AI_GATEWAY_API_KEY=... npm run dev
+```
+
+The key must be exported into the app process. The example never reads dotenv
+files. If the key is stored in one, export it in the shell before running the
+app (for example, `set -a; source ~/.env; set +a`).
+
+The Native core starts exactly one sidecar with `Cmd.spawn`. The sidecar listens
+on `127.0.0.1:43110` for `POST /compare`, `POST /stop`, and each slot's
+viewer/version/result routes. A compare sends the task as its plain-text body and the
+small run/model metadata as query parameters, so the Native core needs no JSON
+encoder and the sidecar needs no JSON request parser.
+Sidecar stdout is reserved for bounded tab-separated status records, which the
+core receives through the spawn's line message. Each slot reports only
+Starting, Working, and a detailed Ready/Failed terminal state. There is no SSE
+endpoint and no browser-to-native coordinator channel.
+
+Each Pi agent receives a separate in-memory just-bash filesystem. It must write
+one `index.html`; CSS, application code, and visual assets stay inline or
+procedural. A task may use a requested browser library such as Three.js through
+a version-pinned jsDelivr or unpkg ESM URL. To keep this example focused on the
+Native shell and sidecar boundary, the coordinator publishes that file verbatim:
+it does not validate or rewrite the document and it does not attach a content
+security policy. A missing `index.html` still fails the slot explicitly.
+Both declared child WebViews start with `zero://inline`, then the Native core
+uses `Cmd.navigateWebView` to load `http://127.0.0.1:43110/preview/A/viewer`
+and `/preview/B/viewer` once the coordinator is ready. The viewer page polls
+its slot's version route once per second, then places the completed page in an
+opaque iframe sandbox. It keeps the previous completed result visible while the
+next comparison runs. The poll only swaps preview documents; agent status and
+progress remain on the Native sidecar channel.
+
+## Check
+
+```sh
+npm run check
+npm test
+```
diff --git a/examples/agent-wars/app.zon b/examples/agent-wars/app.zon
new file mode 100644
index 000000000..778261d1f
--- /dev/null
+++ b/examples/agent-wars/app.zon
@@ -0,0 +1,40 @@
+.{
+ .id = "dev.native_sdk.agent_wars",
+ .name = "agent-wars",
+ .display_name = "Agent Wars",
+ .description = "Compare two Pi harness coding agents in a native-rendered visual evaluation bench.",
+ .version = "0.1.0",
+ .platforms = .{"macos"},
+ .permissions = .{ "view", "command", "network" },
+ .capabilities = .{ "native_views", "gpu_surfaces", "webview" },
+ .shell = .{
+ .windows = .{
+ .{
+ .label = "main",
+ .title = "Agent Wars",
+ .width = 1380,
+ .height = 820,
+ .resizable = false,
+ .restore_state = false,
+ .restore_policy = "center_on_primary",
+ .views = .{
+ .{ .label = "agent-wars-canvas", .kind = "gpu_surface", .fill = true, .role = "Agent Wars controls", .accessibility_label = "Agent Wars", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
+ // app.native's fixed geometry: 16px outer padding, a
+ // 668px column, and a 1px inset that leaves its border.
+ // Both panes are child WebViews. They start with the
+ // inline blank page and the TypeScript core navigates
+ // them to the sidecar viewer after it is ready.
+ .{ .label = "preview-a", .kind = "webview", .parent = "agent-wars-canvas", .url = "zero://inline", .x = 17, .y = 205, .width = 666, .height = 598, .layer = 20 },
+ .{ .label = "preview-b", .kind = "webview", .parent = "agent-wars-canvas", .url = "zero://inline", .x = 697, .y = 205, .width = 666, .height = 598, .layer = 20 },
+ },
+ },
+ },
+ },
+ .security = .{
+ .navigation = .{
+ .allowed_origins = .{ "zero://inline", "http://127.0.0.1:43110" },
+ .external_links = .{ .action = "deny" },
+ },
+ },
+ .web_engine = "system",
+}
diff --git a/examples/agent-wars/package.json b/examples/agent-wars/package.json
new file mode 100644
index 000000000..863887fd9
--- /dev/null
+++ b/examples/agent-wars/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "agent-wars",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "A two-model Pi harness comparison example for Native SDK.",
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "dev": "native dev",
+ "build": "native build",
+ "check": "native check && npm run typecheck:sidecar",
+ "typecheck:sidecar": "tsc -p tsconfig.sidecar.json",
+ "test": "native test -Dplatform=null && npm run test:sidecar",
+ "test:sidecar": "node --import tsx --test sidecar/coordinator.test.ts",
+ "sidecar": "node --import tsx sidecar/coordinator.ts"
+ },
+ "dependencies": {
+ "@ai-sdk/harness": "1.0.62",
+ "@ai-sdk/harness-pi": "1.0.62",
+ "@ai-sdk/sandbox-just-bash": "1.0.62",
+ "@native-sdk/core": "0.9.1",
+ "ai": "7.0.56",
+ "ws": "8.21.2",
+ "zod": "4.4.3"
+ },
+ "devDependencies": {
+ "@types/node": "20.19.43",
+ "tsx": "4.23.8",
+ "typescript": "7.0.2"
+ }
+}
diff --git a/examples/agent-wars/preview/index.html b/examples/agent-wars/preview/index.html
new file mode 100644
index 000000000..5e8c7d0b6
--- /dev/null
+++ b/examples/agent-wars/preview/index.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+ Agent preview
+
+
+
+
The completed preview will appear here.
+
+
+
diff --git a/examples/agent-wars/sidecar/coordinator.test.ts b/examples/agent-wars/sidecar/coordinator.test.ts
new file mode 100644
index 000000000..83982ce5a
--- /dev/null
+++ b/examples/agent-wars/sidecar/coordinator.test.ts
@@ -0,0 +1,87 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import { HarnessAgent } from "@ai-sdk/harness/agent";
+import { createPi } from "@ai-sdk/harness-pi";
+import { createJustBashSandbox } from "@ai-sdk/sandbox-just-bash";
+import {
+ agentInstructions,
+ ensureSessionWorkDir,
+ parseCompareRequest,
+ readViewerHtml,
+ requireGatewayApiKey,
+ sanitizeProtocolField,
+ validateModelId,
+} from "./coordinator.ts";
+
+test("agent contract honors requested browser libraries through pinned CDNs", () => {
+ const instructions = agentInstructions();
+ assert.match(instructions, /Three\.js task must use Three\.js/);
+ assert.match(instructions, /version-pinned https:\/\/cdn\.jsdelivr\.net or https:\/\/unpkg\.com/);
+ assert.match(instructions, /Keep visual assets procedural or inline/);
+ assert.doesNotMatch(instructions, /Do not use packages, external assets, network requests/);
+});
+
+test("protocol fields stay on one bounded line", () => {
+ assert.equal(sanitizeProtocolField(" one\ttwo\nthree "), "one two three");
+ assert.equal(sanitizeProtocolField("abcdef", 4), "abcd");
+});
+
+test("the coordinator requires one Vercel AI Gateway key", () => {
+ assert.equal(requireGatewayApiKey({ AI_GATEWAY_API_KEY: " gateway-key " }), "gateway-key");
+ assert.throws(() => requireGatewayApiKey({}), /AI_GATEWAY_API_KEY is required/);
+ assert.throws(() => requireGatewayApiKey({ AI_GATEWAY_API_KEY: " " }), /AI_GATEWAY_API_KEY is required/);
+});
+
+test("model ids accept Pi provider/model ids", () => {
+ assert.equal(validateModelId(" anthropic/claude-opus-5 "), "anthropic/claude-opus-5");
+ assert.equal(validateModelId("openai/gpt-5.6-sol"), "openai/gpt-5.6-sol");
+ assert.equal(validateModelId("xai/grok-4.5"), "xai/grok-4.5");
+ assert.throws(() => validateModelId("bad model"), /unsupported/);
+});
+
+test("compare control uses query metadata and a raw task body", () => {
+ const request = parseCompareRequest(
+ new URL(
+ "http://127.0.0.1:43110/compare?runId=7&modelA=deepseek/deepseek-v4-flash-0731&modelB=openai/gpt-5.6-luna",
+ ),
+ " Build a hamburger ",
+ );
+ assert.deepEqual(request, {
+ runId: 7,
+ task: "Build a hamburger",
+ modelA: "deepseek/deepseek-v4-flash-0731",
+ modelB: "openai/gpt-5.6-luna",
+ });
+ assert.throws(
+ () => parseCompareRequest(new URL("http://127.0.0.1:43110/compare?runId=0"), "task"),
+ /runId must be a positive integer/,
+ );
+});
+
+test("the bundled viewer retries and loads either completed slot", () => {
+ const viewer = readViewerHtml();
+ assert.equal(viewer, readFileSync(new URL("../preview/index.html", import.meta.url), "utf8"));
+ assert.match(viewer, /get\("slot"\) === "B" \? "B" : "A"/);
+ assert.match(viewer, /fetch\(`\$\{baseUrl\}\/version`/);
+ assert.match(viewer, /frame\.src = `\$\{baseUrl\}\/site\?run=\$\{version\}`/);
+ assert.match(viewer, /root\.append\(frame\)/);
+ assert.doesNotMatch(viewer, /Content-Security-Policy/i);
+ assert.match(viewer, /setInterval\(checkVersion, 1000\)/);
+});
+
+test("Pi can start inside the just-bash session directory", async () => {
+ const agent = new HarnessAgent({
+ id: "agent-wars-workdir-test",
+ harness: createPi({ model: "openai/gpt-5.6-sol" }),
+ sandbox: createJustBashSandbox({ cwd: "/work" }),
+ sandboxConfig: {
+ onSession: async ({ session, sessionWorkDir, abortSignal }) => {
+ await ensureSessionWorkDir(session, sessionWorkDir, abortSignal);
+ },
+ },
+ });
+
+ const session = await agent.createSession();
+ await session.destroy();
+});
diff --git a/examples/agent-wars/sidecar/coordinator.ts b/examples/agent-wars/sidecar/coordinator.ts
new file mode 100644
index 000000000..bbd522352
--- /dev/null
+++ b/examples/agent-wars/sidecar/coordinator.ts
@@ -0,0 +1,405 @@
+import { HarnessAgent } from "@ai-sdk/harness/agent";
+import { createPi } from "@ai-sdk/harness-pi";
+import { createJustBashSandbox } from "@ai-sdk/sandbox-just-bash";
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+export const CONTROL_PORT = 43110;
+const HOST = "127.0.0.1";
+const MAX_BODY_BYTES = 4096;
+const RUN_TIMEOUT_MS = 5 * 60 * 1000;
+const VIEWER_PATH = new URL("../preview/index.html", import.meta.url);
+
+export type Slot = "A" | "B";
+type ProgressPhase = "starting" | "working" | "ready" | "failed" | "stopped";
+
+export interface CompareRequest {
+ readonly runId: number;
+ readonly task: string;
+ readonly modelA: string;
+ readonly modelB: string;
+}
+
+interface PublishedPreview {
+ version: number;
+ html: string | null;
+}
+
+interface ActiveRun {
+ readonly runId: number;
+ readonly controller: AbortController;
+ timedOut: boolean;
+ readonly timeout: NodeJS.Timeout;
+}
+
+interface SandboxReader {
+ run(options: {
+ command: string;
+ abortSignal?: AbortSignal;
+ }): PromiseLike<{ exitCode: number; stdout: string; stderr: string }>;
+ readTextFile(options: {
+ path: string;
+ encoding?: string;
+ abortSignal?: AbortSignal;
+ }): PromiseLike;
+}
+
+export async function ensureSessionWorkDir(
+ session: SandboxReader,
+ sessionWorkDir: string,
+ abortSignal?: AbortSignal,
+): Promise {
+ // The adapter's bootstrap currently expands its session directory through
+ // a just-bash environment variable that is not preserved. Create the
+ // already-validated path literally before Pi mirrors the workspace.
+ const result = await session.run({
+ command: `mkdir -p ${JSON.stringify(sessionWorkDir)}`,
+ ...(abortSignal ? { abortSignal } : {}),
+ });
+ if (result.exitCode !== 0) {
+ throw new Error(
+ `Failed to create Pi session directory ${sessionWorkDir}: ${result.stderr || result.stdout}`,
+ );
+ }
+}
+
+interface CapturedSandbox {
+ readonly session: SandboxReader;
+ readonly workDir: string;
+}
+
+const previews: Record = {
+ A: { version: 0, html: null },
+ B: { version: 0, html: null },
+};
+
+let activeRun: ActiveRun | null = null;
+
+export function sanitizeProtocolField(value: unknown, maxLength = 512): string {
+ const text = value instanceof Error ? value.message : String(value ?? "");
+ return text.replace(/[\t\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim().slice(0, maxLength);
+}
+
+function emitStatus(runId: number, slot: Slot | "server", phase: ProgressPhase, message: unknown): void {
+ const safeMessage = sanitizeProtocolField(message) || "Unknown status";
+ process.stdout.write(`status\t${runId}\t${slot}\t${phase}\t${safeMessage}\n`);
+}
+
+export function validateModelId(value: unknown): string {
+ if (typeof value !== "string") throw new Error("Model id must be a string");
+ const model = value.trim();
+ if (model.length === 0 || model.length > 128) throw new Error("Model id must be 1–128 characters");
+ if (!/^[A-Za-z0-9._:/-]+$/.test(model)) throw new Error("Model id contains unsupported characters");
+ return model;
+}
+
+function parseRunId(value: string | null): number {
+ if (value === null || !/^\d+$/.test(value)) throw new Error("runId must be a positive integer");
+ const runId = Number(value);
+ if (!Number.isSafeInteger(runId) || runId <= 0) throw new Error("runId must be a positive integer");
+ return runId;
+}
+
+export function parseCompareRequest(url: URL, taskValue: string): CompareRequest {
+ const runId = parseRunId(url.searchParams.get("runId"));
+ const task = taskValue.trim();
+ if (task.length === 0) throw new Error("Shared task is required");
+ const modelA = validateModelId(url.searchParams.get("modelA"));
+ const modelB = validateModelId(url.searchParams.get("modelB"));
+ if (modelA === modelB) throw new Error("Choose two different models");
+ return { runId, task, modelA, modelB };
+}
+
+function asRecord(value: unknown): Record | null {
+ return value !== null && typeof value === "object" ? (value as Record) : null;
+}
+
+function errorMessage(error: unknown): string {
+ if (error instanceof Error && error.message) return error.message;
+ if (typeof error === "string") return error;
+ const record = asRecord(error);
+ if (typeof record?.message === "string") return record.message;
+ if (record?.error !== undefined) return errorMessage(record.error);
+ if (record?.cause !== undefined) return errorMessage(record.cause);
+ return "";
+}
+
+const GATEWAY_FAILURE_MESSAGE =
+ "AI Gateway request failed. Check the selected model, account access, credits, and rate limits.";
+
+export function requireGatewayApiKey(env: NodeJS.ProcessEnv = process.env): string {
+ const apiKey = env.AI_GATEWAY_API_KEY?.trim();
+ if (!apiKey) {
+ throw new Error(
+ "AI_GATEWAY_API_KEY is required. Create a Vercel AI Gateway key and restart Agent Wars.",
+ );
+ }
+ return apiKey;
+}
+
+export function agentInstructions(): string {
+ return [
+ "Build a single, polished, interactive web page for the requested task.",
+ "Honor technologies explicitly requested by the task; for example, a Three.js task must use Three.js rather than a 2D canvas substitute.",
+ "Work only in the provided sandbox and create one index.html in the current working directory.",
+ "Put all CSS and application JavaScript inline.",
+ "When a requested browser library is too large to inline, load it from a version-pinned https://cdn.jsdelivr.net or https://unpkg.com URL; prefer an ESM import and use no other network hosts.",
+ "Keep visual assets procedural or inline as data/blob URLs. Do not use remote images, fonts, APIs, fetch, XHR, WebSockets, package installation, build tools, or a development server.",
+ "Use the write/edit tools to produce the file; do not merely describe code in your answer.",
+ "The page must work at desktop preview size and remain usable when narrower.",
+ "Finish only after index.html contains the complete result.",
+ ].join(" ");
+}
+
+export function readViewerHtml(): string {
+ return readFileSync(VIEWER_PATH, "utf8");
+}
+
+async function runSlot(
+ run: ActiveRun,
+ slot: Slot,
+ model: string,
+ task: string,
+ gatewayApiKey: string,
+): Promise {
+ let captured: CapturedSandbox | null = null;
+ let streamFailed = false;
+ let streamFailure: unknown;
+
+ try {
+ const agent = new HarnessAgent({
+ id: `agent-wars-${slot.toLowerCase()}-${run.runId}`,
+ harness: createPi({
+ model,
+ auth: { gateway: { apiKey: gatewayApiKey } },
+ }),
+ instructions: agentInstructions(),
+ sandbox: createJustBashSandbox({ cwd: "/work" }),
+ sandboxConfig: {
+ onSession: async ({ session: sandboxSession, sessionWorkDir, abortSignal }) => {
+ await ensureSessionWorkDir(sandboxSession, sessionWorkDir, abortSignal);
+ captured = { session: sandboxSession, workDir: sessionWorkDir };
+ },
+ },
+ });
+
+ const session = await agent.createSession({ abortSignal: run.controller.signal });
+ try {
+ emitStatus(run.runId, slot, "working", "Working…");
+ const streamResult = await agent.stream({
+ session,
+ prompt: `Create the visual result for this shared task:\n\n${task}`,
+ abortSignal: run.controller.signal,
+ });
+
+ for await (const part of streamResult.stream as AsyncIterable) {
+ const event = asRecord(part);
+ if (event?.type === "error") {
+ if (!streamFailed) streamFailure = event.error;
+ streamFailed = true;
+ continue;
+ }
+ }
+
+ if (run.controller.signal.aborted) throw run.controller.signal.reason;
+ if (streamFailed) {
+ const detail = sanitizeProtocolField(errorMessage(streamFailure), 384);
+ console.error(
+ detail
+ ? `AI Gateway request failed for model ${model}: ${detail}`
+ : `AI Gateway request failed for model ${model}`,
+ );
+ throw new Error(GATEWAY_FAILURE_MESSAGE);
+ }
+ const sandbox = captured as CapturedSandbox | null;
+ if (sandbox === null) throw new Error("Sandbox session was not initialized");
+ const source = await sandbox.session.readTextFile({
+ path: `${sandbox.workDir}/index.html`,
+ abortSignal: run.controller.signal,
+ });
+ if (source === null) throw new Error("The agent did not create index.html");
+ previews[slot] = { version: run.runId, html: source };
+ emitStatus(run.runId, slot, "ready", "Ready");
+ } finally {
+ await session.destroy().catch(() => {});
+ }
+ } catch (error) {
+ if (run.controller.signal.aborted) {
+ emitStatus(
+ run.runId,
+ slot,
+ run.timedOut ? "failed" : "stopped",
+ run.timedOut ? "Timed out after 5 minutes" : "Stopped",
+ );
+ } else {
+ emitStatus(run.runId, slot, "failed", sanitizeProtocolField(error));
+ }
+ }
+}
+
+function startRun(request: CompareRequest, gatewayApiKey: string): void {
+ const controller = new AbortController();
+ let run: ActiveRun;
+ const timeout = setTimeout(() => {
+ run.timedOut = true;
+ controller.abort(new Error("Agent Wars run timed out"));
+ }, RUN_TIMEOUT_MS);
+ run = {
+ runId: request.runId,
+ controller,
+ timedOut: false,
+ timeout,
+ };
+ activeRun = run;
+
+ emitStatus(run.runId, "A", "starting", "Starting…");
+ emitStatus(run.runId, "B", "starting", "Starting…");
+
+ void Promise.allSettled([
+ runSlot(run, "A", request.modelA, request.task, gatewayApiKey),
+ runSlot(run, "B", request.modelB, request.task, gatewayApiKey),
+ ]).finally(() => {
+ clearTimeout(run.timeout);
+ if (activeRun === run) activeRun = null;
+ });
+}
+
+async function readText(request: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ let total = 0;
+ for await (const chunk of request) {
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ total += buffer.length;
+ if (total > MAX_BODY_BYTES) throw new Error("Request body is too large");
+ chunks.push(buffer);
+ }
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+function sendJson(response: ServerResponse, status: number, body: Record): void {
+ response.writeHead(status, {
+ "content-type": "application/json; charset=utf-8",
+ "cache-control": "no-store",
+ "access-control-allow-origin": "*",
+ });
+ response.end(JSON.stringify(body));
+}
+
+function sendHtml(response: ServerResponse, html: string, headOnly: boolean): void {
+ response.writeHead(200, {
+ "content-type": "text/html; charset=utf-8",
+ "cache-control": "no-store",
+ "x-content-type-options": "nosniff",
+ "referrer-policy": "no-referrer",
+ });
+ response.end(headOnly ? undefined : html);
+}
+
+async function handleRequest(
+ request: IncomingMessage,
+ response: ServerResponse,
+ gatewayApiKey: string,
+): Promise {
+ const url = new URL(request.url ?? "/", `http://${HOST}:${CONTROL_PORT}`);
+
+ if (request.method === "POST" && url.pathname === "/compare") {
+ const compare = parseCompareRequest(url, await readText(request));
+ if (activeRun !== null) {
+ sendJson(response, 409, { error: "A comparison is already running" });
+ return;
+ }
+ startRun(compare, gatewayApiKey);
+ sendJson(response, 202, { accepted: true, runId: compare.runId });
+ return;
+ }
+
+ if (request.method === "POST" && url.pathname === "/stop") {
+ const runId = parseRunId(url.searchParams.get("runId"));
+ if (activeRun === null) {
+ sendJson(response, 200, { stopped: false, reason: "No active comparison" });
+ return;
+ }
+ if (runId !== activeRun.runId) {
+ sendJson(response, 409, { error: "runId does not match the active comparison" });
+ return;
+ }
+ activeRun.controller.abort(new Error("Stopped by user"));
+ sendJson(response, 202, { stopped: true, runId });
+ return;
+ }
+
+ const previewMatch = url.pathname.match(/^\/preview\/([AB])\/(viewer|version|site)$/);
+ if ((request.method === "GET" || request.method === "HEAD") && previewMatch !== null) {
+ const slot = previewMatch[1] as Slot;
+ const resource = previewMatch[2];
+ if (resource === "viewer") {
+ sendHtml(response, readViewerHtml(), request.method === "HEAD");
+ return;
+ }
+ if (resource === "version") {
+ sendJson(response, 200, { version: previews[slot].version });
+ return;
+ }
+
+ const preview = previews[slot];
+ const requested = Number(url.searchParams.get("run"));
+ if (preview.html === null || !Number.isSafeInteger(requested) || requested !== preview.version) {
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
+ response.end("Preview not found");
+ return;
+ }
+ sendHtml(response, preview.html, request.method === "HEAD");
+ return;
+ }
+
+ sendJson(response, 404, { error: "Not found" });
+}
+
+function listen(server: Server, port: number): Promise {
+ return new Promise((resolveListen, reject) => {
+ server.once("error", reject);
+ server.listen(port, HOST, () => {
+ server.off("error", reject);
+ resolveListen();
+ });
+ });
+}
+
+async function closeServer(server: Server): Promise {
+ server.closeAllConnections?.();
+ await new Promise((resolveClose) => server.close(() => resolveClose()));
+}
+
+export async function main(): Promise {
+ const gatewayApiKey = requireGatewayApiKey();
+
+ // Keep stdout exclusively line-framed for the Native Cmd.spawn channel.
+ console.log = (...args: unknown[]) => console.error(...args);
+ console.debug = (...args: unknown[]) => console.error(...args);
+
+ const coordinator = createServer((request, response) => {
+ void handleRequest(request, response, gatewayApiKey).catch((error: unknown) => {
+ sendJson(response, 400, { error: sanitizeProtocolField(error) });
+ });
+ });
+ await listen(coordinator, CONTROL_PORT);
+ emitStatus(0, "server", "ready", "Coordinator ready");
+
+ const shutdown = (): void => {
+ activeRun?.controller.abort(new Error("Coordinator shutting down"));
+ void closeServer(coordinator).finally(() => { process.exitCode = 0; });
+ };
+ process.once("SIGTERM", shutdown);
+ process.once("SIGINT", shutdown);
+}
+
+const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
+if (invokedPath === import.meta.url) {
+ void main().catch((error: unknown) => {
+ emitStatus(0, "server", "failed", sanitizeProtocolField(error));
+ console.error(error);
+ process.exitCode = 1;
+ });
+}
diff --git a/examples/agent-wars/src/app.native b/examples/agent-wars/src/app.native
new file mode 100644
index 000000000..0b2a77ea8
--- /dev/null
+++ b/examples/agent-wars/src/app.native
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {option.label}
+
+
+
+
+ {option.label}
+
+
+
+
+
+
+
+
+
+
+ {aStatus}
+
+
+
+
+
+
+
+
+
+
+
+
+ {option.label}
+
+
+
+
+ {option.label}
+
+
+
+
+
+
+
+
+
+
+ {bStatus}
+
+
+
+
+
+
diff --git a/examples/agent-wars/src/core.ts b/examples/agent-wars/src/core.ts
new file mode 100644
index 000000000..87b10e669
--- /dev/null
+++ b/examples/agent-wars/src/core.ts
@@ -0,0 +1,566 @@
+import { Cmd, asciiBytes, utf8Bytes } from "@native-sdk/core";
+import {
+ applyTextInputEvent,
+ clampedInsertEvent,
+ type TextEditState,
+ type TextInputEvent,
+} from "@native-sdk/core/text";
+
+const MAX_TASK_BYTES = 4096;
+const MAX_MODEL_BYTES = 128;
+
+function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
+ let total = 0;
+ for (const part of parts) total += part.length;
+ const out = new Uint8Array(total);
+ let at = 0;
+ for (const part of parts) {
+ out.set(part, at);
+ at += part.length;
+ }
+ return out;
+}
+
+function compareUrl(runId: number, modelA: Uint8Array, modelB: Uint8Array): Uint8Array {
+ return concatBytes([
+ asciiBytes(`http://127.0.0.1:43110/compare?runId=${runId}&modelA=`),
+ modelA,
+ asciiBytes("&modelB="),
+ modelB,
+ ]);
+}
+
+function stopUrl(runId: number): Uint8Array {
+ return asciiBytes(`http://127.0.0.1:43110/stop?runId=${runId}`);
+}
+
+function previewViewerUrl(slot: "A" | "B"): Uint8Array {
+ return asciiBytes(`http://127.0.0.1:43110/preview/${slot}/viewer?slot=${slot}`);
+}
+
+export interface Editor {
+ readonly bytes: Uint8Array;
+ readonly anchor: number;
+ readonly focus: number;
+ readonly compStart: number;
+ readonly compEnd: number;
+}
+
+function editorWithText(bytes: Uint8Array): Editor {
+ return {
+ bytes,
+ anchor: 0,
+ focus: 0,
+ compStart: -1,
+ compEnd: -1,
+ };
+}
+
+function editorState(editor: Editor): TextEditState {
+ return {
+ text: editor.bytes,
+ selection: { anchor: editor.anchor, focus: editor.focus },
+ composition:
+ editor.compStart >= 0
+ ? { start: editor.compStart, end: editor.compEnd }
+ : null,
+ };
+}
+
+function editorFromState(state: TextEditState): Editor {
+ const start = state.composition !== null ? state.composition.start : -1;
+ const end = state.composition !== null ? state.composition.end : -1;
+ return {
+ bytes: state.text,
+ anchor: state.selection.anchor,
+ focus: state.selection.focus,
+ compStart:
+ start >= -1 && start <= 9007199254740991 ? Math.trunc(start) : -1,
+ compEnd: end >= -1 && end <= 9007199254740991 ? Math.trunc(end) : -1,
+ };
+}
+
+function editorApply(editor: Editor, event: TextInputEvent, capacity: number): Editor {
+ const state = editorState(editor);
+ const next = applyTextInputEvent(state, event, capacity);
+ if (next !== null) return editorFromState(next);
+ const clamped = clampedInsertEvent(state, event, capacity);
+ if (clamped === null) return editor;
+ const nextClamped = applyTextInputEvent(state, clamped, capacity);
+ return nextClamped === null ? editor : editorFromState(nextClamped);
+}
+
+export type SlotPhase = "idle" | "running" | "ready" | "failed";
+export type CoordinatorPhase = "starting" | "ready" | "failed";
+
+export interface SlotStatus {
+ readonly phase: SlotPhase;
+ readonly message: Uint8Array;
+}
+
+export interface ModelOption {
+ readonly id: Uint8Array;
+ readonly label: Uint8Array;
+}
+
+// Two compact rows keep the canvas-owned picker clear of the platform WebViews.
+// Add another option by placing it in either row; keep the labels short enough
+// for four equal columns. The combobox text remains editable for every other
+// Vercel AI Gateway model id.
+const MODEL_OPTIONS_TOP: readonly ModelOption[] = [
+ { id: asciiBytes("deepseek/deepseek-v4-flash-0731"), label: asciiBytes("DeepSeek V4 Flash") },
+ { id: asciiBytes("openai/gpt-5.6-luna"), label: asciiBytes("GPT-5.6 Luna") },
+ { id: asciiBytes("openai/gpt-5.6-sol"), label: asciiBytes("GPT-5.6 Sol") },
+ { id: asciiBytes("anthropic/claude-opus-5"), label: asciiBytes("Claude Opus 5") },
+];
+
+const MODEL_OPTIONS_BOTTOM: readonly ModelOption[] = [
+ { id: asciiBytes("anthropic/claude-fable-5"), label: asciiBytes("Claude Fable 5") },
+ { id: asciiBytes("anthropic/claude-opus-4.8"), label: asciiBytes("Claude Opus 4.8") },
+ { id: asciiBytes("moonshotai/kimi-k3"), label: asciiBytes("Kimi K3") },
+ { id: asciiBytes("xai/grok-4.5"), label: asciiBytes("Grok 4.5") },
+];
+
+export interface Model {
+ readonly task: Editor;
+ readonly modelA: Editor;
+ readonly modelB: Editor;
+ readonly aPickerOpen: boolean;
+ readonly bPickerOpen: boolean;
+ readonly coordinatorPhase: CoordinatorPhase;
+ readonly runId: number;
+ readonly runActive: boolean;
+ readonly stopRequested: boolean;
+ readonly slotA: SlotStatus;
+ readonly slotB: SlotStatus;
+}
+
+export type Msg =
+ | { readonly kind: "task_edit"; readonly edit: TextInputEvent }
+ | { readonly kind: "model_a_edit"; readonly edit: TextInputEvent }
+ | { readonly kind: "model_b_edit"; readonly edit: TextInputEvent }
+ | { readonly kind: "open_a_picker" }
+ | { readonly kind: "open_b_picker" }
+ | { readonly kind: "close_a_picker" }
+ | { readonly kind: "close_b_picker" }
+ | { readonly kind: "pick_a"; readonly modelId: Uint8Array }
+ | { readonly kind: "pick_b"; readonly modelId: Uint8Array }
+ | { readonly kind: "compare_or_stop" }
+ | { readonly kind: "sidecar_line"; readonly line: Uint8Array }
+ | { readonly kind: "sidecar_exit"; readonly code: number }
+ | { readonly kind: "sidecar_err"; readonly reason: Uint8Array }
+ | { readonly kind: "compare_response"; readonly status: number; readonly body: Uint8Array }
+ | { readonly kind: "compare_failed"; readonly reason: Uint8Array }
+ | { readonly kind: "stop_response"; readonly status: number; readonly body: Uint8Array }
+ | { readonly kind: "stop_failed"; readonly reason: Uint8Array };
+
+export const viewUnbound = [
+ "sidecar_line",
+ "sidecar_exit",
+ "sidecar_err",
+ "compare_response",
+ "compare_failed",
+ "stop_response",
+ "stop_failed",
+ "task",
+ "modelA",
+ "modelB",
+ "coordinatorPhase",
+ "runId",
+ "stopRequested",
+ "slotA",
+ "slotB",
+] as const;
+
+export function initialModel(): [Model, Cmd] {
+ return [
+ {
+ task: editorWithText(
+ asciiBytes(
+ "Create a photorealistic, interactive Three.js hamburger with procedurally modeled buns, sesame seeds, lettuce, tomato, cheese, beef patty, onion, pickles, and sauce.",
+ ),
+ ),
+ modelA: editorWithText(asciiBytes("deepseek/deepseek-v4-flash-0731")),
+ modelB: editorWithText(asciiBytes("openai/gpt-5.6-luna")),
+ aPickerOpen: false,
+ bPickerOpen: false,
+ coordinatorPhase: "starting",
+ runId: 0,
+ runActive: false,
+ stopRequested: false,
+ slotA: { phase: "running", message: utf8Bytes("Starting coordinator…") },
+ slotB: { phase: "running", message: utf8Bytes("Starting coordinator…") },
+ },
+ Cmd.spawn(
+ [
+ asciiBytes("/usr/bin/env"),
+ asciiBytes("node"),
+ asciiBytes("--import"),
+ asciiBytes("tsx"),
+ asciiBytes("sidecar/coordinator.ts"),
+ ],
+ {
+ key: "coordinator",
+ line: "sidecar_line",
+ exit: "sidecar_exit",
+ err: "sidecar_err",
+ },
+ ),
+ ];
+}
+
+export function taskText(model: Model): Uint8Array {
+ return model.task.bytes;
+}
+
+export function modelAText(model: Model): Uint8Array {
+ return model.modelA.bytes;
+}
+
+export function modelBText(model: Model): Uint8Array {
+ return model.modelB.bytes;
+}
+
+export function modelOptionsTop(_model: Model): readonly ModelOption[] {
+ return MODEL_OPTIONS_TOP;
+}
+
+export function modelOptionsBottom(_model: Model): readonly ModelOption[] {
+ return MODEL_OPTIONS_BOTTOM;
+}
+
+function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+}
+
+function isInputWhitespace(byte: number): boolean {
+ return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d;
+}
+
+function trimInput(bytes: Uint8Array): Uint8Array {
+ let start = 0;
+ let end = bytes.length;
+ while (start < end && isInputWhitespace(bytes[start])) start++;
+ while (end > start && isInputWhitespace(bytes[end - 1])) end--;
+ return bytes.subarray(start, end);
+}
+
+function validModelId(model: Uint8Array): boolean {
+ if (model.length === 0 || model.length > MAX_MODEL_BYTES) return false;
+ for (const byte of model) {
+ const alphaNumeric =
+ (byte >= 0x30 && byte <= 0x39) ||
+ (byte >= 0x41 && byte <= 0x5a) ||
+ (byte >= 0x61 && byte <= 0x7a);
+ if (
+ !alphaNumeric &&
+ byte !== 0x2e &&
+ byte !== 0x5f &&
+ byte !== 0x3a &&
+ byte !== 0x2f &&
+ byte !== 0x2d
+ ) return false;
+ }
+ return true;
+}
+
+export function actionDisabled(model: Model): boolean {
+ if (model.runActive) return model.stopRequested;
+ const task = trimInput(model.task.bytes);
+ const modelA = trimInput(model.modelA.bytes);
+ const modelB = trimInput(model.modelB.bytes);
+ return (
+ model.coordinatorPhase !== "ready" ||
+ task.length === 0 ||
+ !validModelId(modelA) ||
+ !validModelId(modelB) ||
+ bytesEqual(modelA, modelB)
+ );
+}
+
+export function aRunning(model: Model): boolean {
+ return model.slotA.phase === "running";
+}
+
+export function aReady(model: Model): boolean {
+ return model.slotA.phase === "ready";
+}
+
+export function aFailed(model: Model): boolean {
+ return model.slotA.phase === "failed";
+}
+
+export function aIdle(model: Model): boolean {
+ return model.slotA.phase === "idle";
+}
+
+export function aStatus(model: Model): Uint8Array {
+ return model.slotA.message;
+}
+
+export function bRunning(model: Model): boolean {
+ return model.slotB.phase === "running";
+}
+
+export function bReady(model: Model): boolean {
+ return model.slotB.phase === "ready";
+}
+
+export function bFailed(model: Model): boolean {
+ return model.slotB.phase === "failed";
+}
+
+export function bIdle(model: Model): boolean {
+ return model.slotB.phase === "idle";
+}
+
+export function bStatus(model: Model): Uint8Array {
+ return model.slotB.message;
+}
+
+function terminal(status: SlotStatus): boolean {
+ return status.phase === "ready" || status.phase === "failed";
+}
+
+function parseRunId(bytes: Uint8Array): number {
+ if (bytes.length === 0 || bytes.length > 12) return -1;
+ let value = 0;
+ for (let i = 0; i < bytes.length; i++) {
+ const digit = bytes[i];
+ if (digit < 0x30 || digit > 0x39) return -1;
+ value = value * 10 + digit - 0x30;
+ if (value > 9007199254740991) return -1;
+ }
+ return value;
+}
+
+function slotStatus(phase: Uint8Array, message: Uint8Array): SlotStatus | null {
+ if (bytesEqual(phase, asciiBytes("ready"))) return { phase: "ready", message };
+ if (bytesEqual(phase, asciiBytes("failed")) || bytesEqual(phase, asciiBytes("stopped"))) {
+ return { phase: "failed", message };
+ }
+ if (
+ bytesEqual(phase, asciiBytes("starting")) ||
+ bytesEqual(phase, asciiBytes("working"))
+ ) {
+ return { phase: "running", message };
+ }
+ return null;
+}
+
+function finishIfTerminal(model: Model, slotA: SlotStatus, slotB: SlotStatus): Model {
+ const done = terminal(slotA) && terminal(slotB);
+ return {
+ ...model,
+ slotA,
+ slotB,
+ runActive: done ? false : model.runActive,
+ stopRequested: done ? false : model.stopRequested,
+ };
+}
+
+interface StatusParts {
+ readonly kind: Uint8Array;
+ readonly runId: Uint8Array;
+ readonly slot: Uint8Array;
+ readonly phase: Uint8Array;
+ readonly message: Uint8Array;
+}
+
+function indexOfByte(bytes: Uint8Array, byte: number, start: number): number {
+ for (let i = start; i < bytes.length; i++) {
+ if (bytes[i] === byte) return i;
+ }
+ return -1;
+}
+
+function parseStatusParts(line: Uint8Array): StatusParts | null {
+ const first = indexOfByte(line, 0x09, 0);
+ if (first < 0) return null;
+ const second = indexOfByte(line, 0x09, first + 1);
+ if (second < 0) return null;
+ const third = indexOfByte(line, 0x09, second + 1);
+ if (third < 0) return null;
+ const fourth = indexOfByte(line, 0x09, third + 1);
+ if (fourth < 0 || indexOfByte(line, 0x09, fourth + 1) >= 0) return null;
+ return {
+ kind: line.subarray(0, first),
+ runId: line.subarray(first + 1, second),
+ slot: line.subarray(second + 1, third),
+ phase: line.subarray(third + 1, fourth),
+ message: line.subarray(fourth + 1),
+ };
+}
+
+function applySidecarLine(model: Model, line: Uint8Array): Model {
+ const parts = parseStatusParts(line);
+ if (parts === null || !bytesEqual(parts.kind, asciiBytes("status"))) return model;
+ const eventRunId = parseRunId(parts.runId);
+ const slot = parts.slot;
+ const phase = parts.phase;
+ const message = parts.message.slice(0, 512);
+
+ if (bytesEqual(slot, asciiBytes("server"))) {
+ if (bytesEqual(phase, asciiBytes("ready"))) {
+ if (model.runId !== 0) return { ...model, coordinatorPhase: "ready" };
+ return {
+ ...model,
+ coordinatorPhase: "ready",
+ slotA: { phase: "idle", message: asciiBytes("Ready to compare") },
+ slotB: { phase: "idle", message: asciiBytes("Ready to compare") },
+ };
+ }
+ return {
+ ...model,
+ coordinatorPhase: "failed",
+ runActive: false,
+ stopRequested: false,
+ slotA: { phase: "failed", message },
+ slotB: { phase: "failed", message },
+ };
+ }
+
+ if (eventRunId !== model.runId) return model;
+ const next = slotStatus(phase, message);
+ if (next === null) return model;
+ if (bytesEqual(slot, asciiBytes("A"))) {
+ return finishIfTerminal(model, next, model.slotB);
+ }
+ if (bytesEqual(slot, asciiBytes("B"))) {
+ return finishIfTerminal(model, model.slotA, next);
+ }
+ return model;
+}
+
+function failRun(model: Model, message: Uint8Array): Model {
+ const clipped = message.slice(0, 512);
+ return {
+ ...model,
+ runActive: false,
+ stopRequested: false,
+ slotA: terminal(model.slotA) ? model.slotA : { phase: "failed", message: clipped },
+ slotB: terminal(model.slotB) ? model.slotB : { phase: "failed", message: clipped },
+ };
+}
+
+export function update(model: Model, msg: Msg): [Model, Cmd] {
+ switch (msg.kind) {
+ case "task_edit":
+ if (model.runActive) return [model, Cmd.none];
+ return [{ ...model, task: editorApply(model.task, msg.edit, MAX_TASK_BYTES) }, Cmd.none];
+ case "model_a_edit":
+ if (model.runActive) return [model, Cmd.none];
+ return [{ ...model, modelA: editorApply(model.modelA, msg.edit, MAX_MODEL_BYTES), aPickerOpen: true }, Cmd.none];
+ case "model_b_edit":
+ if (model.runActive) return [model, Cmd.none];
+ return [{ ...model, modelB: editorApply(model.modelB, msg.edit, MAX_MODEL_BYTES), bPickerOpen: true }, Cmd.none];
+ case "open_a_picker":
+ if (model.runActive) return [model, Cmd.none];
+ return [{ ...model, aPickerOpen: true, bPickerOpen: false }, Cmd.none];
+ case "open_b_picker":
+ if (model.runActive) return [model, Cmd.none];
+ return [{ ...model, bPickerOpen: true, aPickerOpen: false }, Cmd.none];
+ case "close_a_picker":
+ return [{ ...model, aPickerOpen: false }, Cmd.none];
+ case "close_b_picker":
+ return [{ ...model, bPickerOpen: false }, Cmd.none];
+ case "pick_a":
+ if (model.runActive) return [model, Cmd.none];
+ return [{ ...model, modelA: editorWithText(msg.modelId), aPickerOpen: false }, Cmd.none];
+ case "pick_b":
+ if (model.runActive) return [model, Cmd.none];
+ return [{ ...model, modelB: editorWithText(msg.modelId), bPickerOpen: false }, Cmd.none];
+ case "compare_or_stop": {
+ if (model.runActive) {
+ if (model.stopRequested) return [model, Cmd.none];
+ return [
+ { ...model, stopRequested: true },
+ Cmd.fetch(
+ {
+ url: stopUrl(model.runId),
+ method: "POST",
+ timeoutMs: 5000,
+ },
+ { key: "stop-control", ok: "stop_response", err: "stop_failed" },
+ ),
+ ];
+ }
+ if (actionDisabled(model)) return [model, Cmd.none];
+ const nextRunId =
+ model.runId >= 0 && model.runId < 9007199254740991
+ ? Math.trunc(model.runId + 1)
+ : 1;
+ const task = trimInput(model.task.bytes);
+ const modelA = trimInput(model.modelA.bytes);
+ const modelB = trimInput(model.modelB.bytes);
+ return [
+ {
+ ...model,
+ task: editorWithText(task),
+ modelA: editorWithText(modelA),
+ modelB: editorWithText(modelB),
+ aPickerOpen: false,
+ bPickerOpen: false,
+ runId: nextRunId,
+ runActive: true,
+ stopRequested: false,
+ slotA: { phase: "running", message: utf8Bytes("Starting…") },
+ slotB: { phase: "running", message: utf8Bytes("Starting…") },
+ },
+ Cmd.fetch(
+ {
+ url: compareUrl(nextRunId, modelA, modelB),
+ method: "POST",
+ headers: { "content-type": "text/plain; charset=utf-8" },
+ body: task,
+ timeoutMs: 5000,
+ },
+ { key: "compare-control", ok: "compare_response", err: "compare_failed" },
+ ),
+ ];
+ }
+ case "sidecar_line": {
+ const next = applySidecarLine(model, msg.line);
+ if (model.coordinatorPhase !== "ready" && next.coordinatorPhase === "ready") {
+ return [
+ next,
+ Cmd.batch([
+ Cmd.navigateWebView("preview-a", previewViewerUrl("A")),
+ Cmd.navigateWebView("preview-b", previewViewerUrl("B")),
+ ]),
+ ];
+ }
+ return [next, Cmd.none];
+ }
+ case "sidecar_exit":
+ return [
+ failRun(
+ { ...model, coordinatorPhase: "failed" },
+ asciiBytes(`Coordinator exited (code ${msg.code})`),
+ ),
+ Cmd.none,
+ ];
+ case "sidecar_err":
+ return [
+ failRun(
+ { ...model, coordinatorPhase: "failed" },
+ msg.reason.length > 0 ? msg.reason : asciiBytes("Coordinator failed"),
+ ),
+ Cmd.none,
+ ];
+ case "compare_response":
+ if (msg.status >= 200 && msg.status < 300) return [model, Cmd.none];
+ return [failRun(model, asciiBytes(`Compare request failed (HTTP ${msg.status})`)), Cmd.none];
+ case "compare_failed":
+ return [failRun(model, msg.reason), Cmd.none];
+ case "stop_response":
+ if (msg.status >= 200 && msg.status < 300) return [model, Cmd.none];
+ return [failRun(model, asciiBytes(`Stop request failed (HTTP ${msg.status})`)), Cmd.none];
+ case "stop_failed":
+ return [failRun(model, msg.reason), Cmd.none];
+ }
+}
diff --git a/examples/agent-wars/tsconfig.json b/examples/agent-wars/tsconfig.json
new file mode 100644
index 000000000..fb51666a0
--- /dev/null
+++ b/examples/agent-wars/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "target": "esnext",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "lib": ["esnext"],
+ "types": [],
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "exactOptionalPropertyTypes": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/examples/agent-wars/tsconfig.sidecar.json b/examples/agent-wars/tsconfig.sidecar.json
new file mode 100644
index 000000000..833e5f61b
--- /dev/null
+++ b/examples/agent-wars/tsconfig.sidecar.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "target": "ES2023",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2023", "DOM"],
+ "types": ["node"],
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "exactOptionalPropertyTypes": true,
+ "noFallthroughCasesInSwitch": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["sidecar/**/*.ts"]
+}
diff --git a/packages/core/compile-surface/core.ts b/packages/core/compile-surface/core.ts
index 838272fe6..a3c0f940f 100644
--- a/packages/core/compile-surface/core.ts
+++ b/packages/core/compile-surface/core.ts
@@ -518,6 +518,7 @@ export type CmdData =
readonly value: number;
}
| { readonly op: "window_show"; readonly label: string }
+ | { readonly op: "webview_navigate"; readonly label: string; readonly url: Uint8Array }
| { readonly op: "window_hide"; readonly label: string }
| { readonly op: "dock_presence"; readonly visible: boolean }
| { readonly op: "quit_app" }
@@ -981,6 +982,10 @@ export const Cmd = {
return { op: "window_show", label };
},
+ navigateWebView(label: string, url: Uint8Array): CmdData {
+ return { op: "webview_navigate", label, url };
+ },
+
hideWindow(label: string): CmdData {
return { op: "window_hide", label };
},
diff --git a/packages/core/sdk/core.d.ts b/packages/core/sdk/core.d.ts
index 336ca90be..b1d6b8310 100644
--- a/packages/core/sdk/core.d.ts
+++ b/packages/core/sdk/core.d.ts
@@ -479,6 +479,10 @@ export type Cmd = {
} | {
readonly op: "window_show";
readonly label: string;
+} | {
+ readonly op: "webview_navigate";
+ readonly label: string;
+ readonly url: Uint8Array;
} | {
readonly op: "window_hide";
readonly label: string;
@@ -615,6 +619,7 @@ export declare const Cmd: {
videoSetMuted(key: string, muted: boolean): Cmd;
videoSetLoop(key: string, loop: boolean): Cmd;
showWindow(label: string): Cmd;
+ navigateWebView(label: string, url: Uint8Array): Cmd;
hideWindow(label: string): Cmd;
setDockPresence(visible: boolean): Cmd;
launchAtLoginStatus(route: RequestRoute): Cmd;
diff --git a/packages/core/sdk/core.ts b/packages/core/sdk/core.ts
index 29e08e92c..a51b73add 100644
--- a/packages/core/sdk/core.ts
+++ b/packages/core/sdk/core.ts
@@ -249,6 +249,9 @@
// "Open" consequence; also restores a
// minimized window. An unknown label is a
// no-op.
+// Cmd.navigateWebView(label, url)
+// navigate a declared child WebView in the
+// main window (fire-and-forget).
// Cmd.hideWindow(label) order a live window out while retaining
// its views; showWindow is the inverse.
// Cmd.setDockPresence(visible) show or remove the app from the macOS
@@ -1344,6 +1347,7 @@ export type Cmd =
readonly value: number;
}
| { readonly op: "window_show"; readonly label: string }
+ | { readonly op: "webview_navigate"; readonly label: string; readonly url: Uint8Array }
| { readonly op: "window_hide"; readonly label: string }
| { readonly op: "dock_presence"; readonly visible: boolean }
| { readonly op: "quit_app" }
@@ -1999,6 +2003,15 @@ export const Cmd = {
return { op: "window_show", label };
},
+ /// Navigate a declared child WebView in the main window. The runtime
+ /// validates the label, rejects the main WebView, and applies the same
+ /// navigation origin policy used by declarative WebView updates. The
+ /// command is fire-and-forget; invalid or denied requests do not crash
+ /// the update loop. Passing the current URL again forces a reload.
+ navigateWebView(label: string, url: Uint8Array): Cmd {
+ return { op: "webview_navigate", label, url };
+ },
+
/// Hide a live window without closing it. Its views and native identity
/// remain intact; `showWindow` brings it back. Unknown labels no-op.
hideWindow(label: string): Cmd {
diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig
index 5e689f7cd..5e2595efe 100644
--- a/src/runtime/effects.zig
+++ b/src/runtime/effects.zig
@@ -81,6 +81,8 @@ const credentials_store = @import("credentials_store.zig");
const file_access = @import("file_access.zig");
const pty_transport = @import("pty.zig");
+const effects_log = std.log.scoped(.zero_effects);
+
/// Maximum in-flight effects (spawn slots / worker threads).
pub const max_effects: usize = 16;
/// Record-store effects have their own capacity: a large batch or a busy
@@ -378,6 +380,17 @@ pub const WindowActionBinding = struct {
quit_fn: *const fn (context: *anyopaque) bool,
};
+/// Type-erased handle for navigating a declared child WebView from a
+/// TypeScript core command. The target window is supplied by `UiApp` and is
+/// refreshed whenever the canvas window identity changes; the callback uses
+/// the runtime's existing `updateView` path so label, target, URL-policy, and
+/// platform validation stay centralized.
+pub const WebViewActionBinding = struct {
+ context: *anyopaque,
+ window_id: platform.WindowId,
+ navigate_fn: *const fn (context: *anyopaque, window_id: platform.WindowId, label: []const u8, url: []const u8) bool,
+};
+
/// Runtime-owned platform-service entry points used by the intrinsic
/// `native-sdk.*` named commands. Binding the Runtime methods—not the raw
/// PlatformServices table—keeps validation and external-link policy in the
@@ -470,6 +483,34 @@ pub const WindowActionState = struct {
}
};
+/// The WebView-navigation mirror records the last fire-and-forget request so
+/// fake execution and session replay remain observable without a platform
+/// WebView. The live callback is still invoked in real mode.
+pub const WebViewActionState = struct {
+ navigate_count: u32 = 0,
+ label_buffer: [platform.max_webview_label_bytes]u8 = @splat(0),
+ label_len: usize = 0,
+ url_buffer: [platform.max_webview_url_bytes]u8 = @splat(0),
+ url_len: usize = 0,
+
+ pub fn label(self: *const WebViewActionState) []const u8 {
+ return self.label_buffer[0..self.label_len];
+ }
+
+ pub fn url(self: *const WebViewActionState) []const u8 {
+ return self.url_buffer[0..self.url_len];
+ }
+
+ fn record(self: *WebViewActionState, requested_label: []const u8, requested_url: []const u8) void {
+ const label_len = @min(requested_label.len, self.label_buffer.len);
+ @memcpy(self.label_buffer[0..label_len], requested_label[0..label_len]);
+ self.label_len = label_len;
+ const url_len = @min(requested_url.len, self.url_buffer.len);
+ @memcpy(self.url_buffer[0..url_len], requested_url[0..url_len]);
+ self.url_len = url_len;
+ }
+};
+
/// How a spawn's stdout comes back. `.lines` streams each line as an
/// `on_line` Msg as it arrives (the default; long-running streams).
/// `.collect` accumulates whole stdout — single-line JSON far beyond the
@@ -4854,6 +4895,10 @@ pub fn Effects(comptime Msg: type) type {
/// by `UiApp` alongside the services — the seam behind
/// app-drawn window controls (loop-thread only).
window_actions: ?WindowActionBinding = null,
+ /// The runtime's declared child-WebView navigation seam. Unlike
+ /// window actions, the target window id is refreshed by UiApp when
+ /// the canvas window identity becomes known.
+ webview_actions: ?WebViewActionBinding = null,
/// Runtime system services with their validation/policy layer intact.
/// Intrinsic platform commands use this binding; generic host calls
/// continue to use `host_calls` below.
@@ -4881,6 +4926,8 @@ pub fn Effects(comptime Msg: type) type {
/// Window-action mirror: counts and the last requested label,
/// observable in tests (`windowActionState`).
window_action_state: WindowActionState = .{},
+ /// WebView-navigation mirror, observable in tests.
+ webview_action_state: WebViewActionState = .{},
/// The environment spawned children inherit and fetch honors
/// (PATH for `spawnPath`-style lookups, proxy variables).
/// Bound once from the loop thread before the first real
@@ -6234,6 +6281,13 @@ pub fn Effects(comptime Msg: type) type {
if (self.window_actions == null) self.window_actions = binding;
}
+ /// Bind the runtime-owned WebView navigation seam. The runtime
+ /// context and callback are stable, while UiApp may refresh the
+ /// target canvas window id after the first frame event.
+ pub fn bindWebViewActions(self: *Self, binding: WebViewActionBinding) void {
+ self.webview_actions = binding;
+ }
+
/// Bind runtime-validated platform services for the SDK-reserved
/// named command family. Loop-thread only; the first bind sticks.
pub fn bindSystemServices(self: *Self, binding: SystemServiceBinding) void {
@@ -10401,6 +10455,21 @@ pub fn Effects(comptime Msg: type) type {
_ = binding.quit_fn(binding.context);
}
+ /// Navigate a declared child WebView in the bound canvas window.
+ /// Fire-and-forget: fake/replay records the request, while real mode
+ /// invokes the runtime callback. Invalid labels, the main WebView,
+ /// missing views, and denied origins fail closed and are logged by
+ /// the callback owner without aborting the update loop.
+ pub fn navigateWebView(self: *Self, label: []const u8, url: []const u8) void {
+ self.webview_action_state.navigate_count += 1;
+ self.webview_action_state.record(label, url);
+ if (self.executor == .fake) return;
+ const binding = self.webview_actions orelse return;
+ if (!binding.navigate_fn(binding.context, binding.window_id, label, url)) {
+ effects_log.warn("WebView navigation rejected for label '{s}'", .{label});
+ }
+ }
+
/// The window-action mirror, for tests: how many close/minimize/
/// show/quit requests rode the channel and the last label
/// requested.
@@ -10408,6 +10477,11 @@ pub fn Effects(comptime Msg: type) type {
return self.window_action_state;
}
+ /// The WebView-navigation mirror, for tests and replay diagnostics.
+ pub fn webViewActionState(self: *const Self) WebViewActionState {
+ return self.webview_action_state;
+ }
+
/// Set playback volume, clamped to 0.0—1.0. Remembered across
/// tracks: the next `playAudio` re-applies it.
pub fn setAudioVolume(self: *Self, volume: f32) void {
diff --git a/src/runtime/ts_core_host.zig b/src/runtime/ts_core_host.zig
index becd14fd0..9b0a174d3 100644
--- a/src/runtime/ts_core_host.zig
+++ b/src/runtime/ts_core_host.zig
@@ -297,6 +297,11 @@
//! activate (the tray "Open" consequence of the
//! menu-bar-app loop). No result Msg; the window's own
//! frame event carries the state.
+//! webview_navigate -> `fx.navigateWebView(label, url)` — fire-and-
+//! forget navigation of a declared child WebView in the
+//! main window. The runtime applies the normal WebView
+//! label, target, and origin-policy checks; invalid or
+//! denied requests do not abort dispatch.
//! window_hide -> `fx.hideWindow(label)` — retain the live window and
//! its views while ordering it out; window_show is the
//! inverse.
@@ -1243,6 +1248,13 @@ pub fn TsCoreHost(comptime core: type) type {
const label = takeShortBytes(cmd, &at);
fx.showWindow(label);
},
+ // webview_navigate [op][label_len][label]
+ // [url_len u32 LE][url]
+ 0x33 => {
+ const label = takeShortBytes(cmd, &at);
+ const url = takeLongBytes(cmd, &at);
+ fx.navigateWebView(label, url);
+ },
// quit_app [op]
0x11 => fx.quitApp(),
// image_load [op][id f64 LE][event_tag]
diff --git a/src/runtime/ts_core_host_tests.zig b/src/runtime/ts_core_host_tests.zig
index d2af0316c..26d8e3e33 100644
--- a/src/runtime/ts_core_host_tests.zig
+++ b/src/runtime/ts_core_host_tests.zig
@@ -12,6 +12,7 @@ const std = @import("std");
const effects_mod = @import("effects.zig");
const runtime_clock = @import("clock.zig");
const ts_core_host = @import("ts_core_host.zig");
+const platform = @import("../platform/root.zig");
// ------------------------------------------------------ the mini core
//
@@ -305,9 +306,10 @@ const mini_core = struct {
uget, // 81: fetch "uget" -> ufetched/failed
ufetched: struct { status: u64, body: []const u8 }, // 82: fetch ok
// record with a u64-classed number field
- start_capture, // 83: microphone capture key 91 -> capture_evt
- stop_capture, // 84: stop capture key 91
- capture_evt: struct { // 85: ten-field capture event arm
+ navigate_webview, // 83: webview_navigate child URL
+ start_capture, // 84: microphone capture key 91 -> capture_evt
+ stop_capture, // 85: stop capture key 91
+ capture_evt: struct { // 86: ten-field capture event arm
key: f64,
state: CaptureState,
source: CaptureSource,
@@ -687,6 +689,7 @@ const mini_core = struct {
.hide_win => return .{ .model = model, .cmd = cmdWindowHide("player") },
.dock_off => return .{ .model = model, .cmd = cmdDockPresence(false) },
.quit_app => return .{ .model = model, .cmd = cmdQuitApp() },
+ .navigate_webview => return .{ .model = model, .cmd = cmdWebViewNavigate("preview", "https://status.test/page") },
.open_chan => return .{ .model = model, .cmd = cmdChannelOpen(41, 47) },
.close_chan => return .{ .model = model, .cmd = cmdChannelClose(41) },
.chan_evt => |event| {
@@ -1186,6 +1189,15 @@ const mini_core = struct {
return out;
}
+ fn cmdWebViewNavigate(label: []const u8, url: []const u8) []const u8 {
+ const out = rt.frameAlloc(u8, 2 + label.len + 4 + url.len);
+ out[0] = 0x33;
+ out[1] = @intCast(label.len);
+ @memcpy(out[2..][0..label.len], label);
+ _ = writeLongBytes(out, 2 + label.len, url);
+ return out;
+ }
+
fn cmdImageLoad(id: f64, event_tag: u8, image_path: []const u8, url: []const u8, cache_path: []const u8, expected: f64) []const u8 {
const out = rt.frameAlloc(u8, 1 + 8 + 1 + 4 + image_path.len + 4 + url.len + 4 + cache_path.len + 8);
out[0] = 0x12;
@@ -2842,6 +2854,54 @@ test "window verbs bridge to the effects channel's label-addressed verbs" {
try std.testing.expectEqual(boot_pending, fx.pendingHostCount());
}
+test "webview navigation decodes onto the effects mirror" {
+ const fx = freshChannel();
+ defer fx.deinit();
+ Host.init(fx);
+
+ Host.dispatch(fx, .navigate_webview);
+ const state = fx.webViewActionState();
+ try std.testing.expectEqual(@as(u32, 1), state.navigate_count);
+ try std.testing.expectEqualStrings("preview", state.label());
+ try std.testing.expectEqualStrings("https://status.test/page", state.url());
+}
+
+test "webview navigation invokes its bound runtime seam in real mode" {
+ const fx = freshChannel();
+ defer fx.deinit();
+ fx.executor = .real;
+
+ const Stub = struct {
+ var calls: u32 = 0;
+ var window_id: platform.WindowId = 0;
+ var label: []const u8 = "";
+ var url: []const u8 = "";
+
+ fn navigate(context: *anyopaque, target: platform.WindowId, requested_label: []const u8, requested_url: []const u8) bool {
+ _ = context;
+ calls += 1;
+ window_id = target;
+ label = requested_label;
+ url = requested_url;
+ return true;
+ }
+ };
+ Stub.calls = 0;
+ var context: u8 = 0;
+ fx.bindWebViewActions(.{
+ .context = &context,
+ .window_id = 7,
+ .navigate_fn = Stub.navigate,
+ });
+
+ Host.init(fx);
+ Host.dispatch(fx, .navigate_webview);
+ try std.testing.expectEqual(@as(u32, 1), Stub.calls);
+ try std.testing.expectEqual(@as(platform.WindowId, 7), Stub.window_id);
+ try std.testing.expectEqualStrings("preview", Stub.label);
+ try std.testing.expectEqualStrings("https://status.test/page", Stub.url);
+}
+
test "a channel opens, posts route the five-field arm by name, and close retires the key" {
const fx = freshChannel();
defer fx.deinit();
diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig
index 70b2e9fbf..80cd76f8a 100644
--- a/src/runtime/ui_app.zig
+++ b/src/runtime/ui_app.zig
@@ -1502,6 +1502,11 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
.dock_presence_fn = effectsSetDockPresence,
.quit_fn = effectsQuitApp,
});
+ self.effects.bindWebViewActions(.{
+ .context = runtime,
+ .window_id = self.canvas_window_id,
+ .navigate_fn = effectsNavigateWebView,
+ });
self.effects.bindSystemServices(.{
.context = runtime,
.open_external_url_fn = effectsOpenExternalUrl,
@@ -6429,6 +6434,18 @@ fn effectsQuitApp(context: *anyopaque) bool {
return true;
}
+fn effectsNavigateWebView(context: *anyopaque, window_id: platform.WindowId, label: []const u8, url: []const u8) bool {
+ const runtime: *Runtime = @ptrCast(@alignCast(context));
+ _ = runtime.updateView(window_id, label, .{ .url = url }) catch |err| {
+ ui_app_log.warn(
+ "WebView navigation for '{s}' rejected: {s} - the view must be a declared child WebView and the URL's origin must be in security.navigation.allowed_origins",
+ .{ label, @errorName(err) },
+ );
+ return false;
+ };
+ return true;
+}
+
fn effectsOpenExternalUrl(context: *anyopaque, url: []const u8) anyerror!void {
const runtime: *Runtime = @ptrCast(@alignCast(context));
return runtime.openExternalUrl(url);
diff --git a/tools/corewire/emit_facade.zig b/tools/corewire/emit_facade.zig
index c7942645b..4b2ce98f9 100644
--- a/tools/corewire/emit_facade.zig
+++ b/tools/corewire/emit_facade.zig
@@ -2895,6 +2895,11 @@ const FacadeEmitter = struct {
\\ nscfWU8(sink, 0x10);
\\ nscfWShortText(sink, cmd.label);
\\ return;
+ \\ case "webview_navigate":
+ \\ nscfWU8(sink, 0x33);
+ \\ nscfWShortText(sink, cmd.label);
+ \\ nscfWBytes(sink, cmd.url);
+ \\ return;
\\ case "window_hide":
\\ nscfWU8(sink, 0x21);
\\ nscfWShortText(sink, cmd.label);