From 91cf7f4afc2c3bc4edd50a58c76e73c1cbd66c29 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Fri, 7 Aug 2026 20:27:54 +0200 Subject: [PATCH 01/11] feat: add typed WebView navigation command --- packages/core/compile-surface/core.ts | 5 ++ packages/core/sdk/core.d.ts | 5 ++ packages/core/sdk/core.ts | 13 +++++ src/runtime/effects.zig | 74 +++++++++++++++++++++++++++ src/runtime/ts_core_host.zig | 12 +++++ src/runtime/ts_core_host_tests.zig | 60 ++++++++++++++++++++++ src/runtime/ui_app.zig | 17 ++++++ tools/corewire/emit_facade.zig | 5 ++ 8 files changed, 191 insertions(+) diff --git a/packages/core/compile-surface/core.ts b/packages/core/compile-surface/core.ts index 03d9fcd3c..0dc71930b 100644 --- a/packages/core/compile-surface/core.ts +++ b/packages/core/compile-surface/core.ts @@ -237,6 +237,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: "quit_app" } | { readonly op: "image_load"; @@ -494,6 +495,10 @@ export const Cmd = { return { op: "window_show", label }; }, + navigateWebView(label: string, url: Uint8Array): CmdData { + return { op: "webview_navigate", label, url }; + }, + quitApp(): CmdData { return { op: "quit_app" }; }, diff --git a/packages/core/sdk/core.d.ts b/packages/core/sdk/core.d.ts index 9331db1b5..7ab365625 100644 --- a/packages/core/sdk/core.d.ts +++ b/packages/core/sdk/core.d.ts @@ -263,6 +263,10 @@ export type Cmd = { } | { readonly op: "window_show"; readonly label: string; +} | { + readonly op: "webview_navigate"; + readonly label: string; + readonly url: Uint8Array; } | { readonly op: "quit_app"; } | { @@ -343,6 +347,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; quitApp(): Cmd; imageLoad(id: number, source: ImageSource, route: ImageRoute): Cmd; imageCancel(id: number): Cmd; diff --git a/packages/core/sdk/core.ts b/packages/core/sdk/core.ts index 95c48a834..6b3444a5c 100644 --- a/packages/core/sdk/core.ts +++ b/packages/core/sdk/core.ts @@ -223,6 +223,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.quitApp() graceful terminate, the tray "Quit" // consequence: the host quits through the // SAME shutdown path a last-window close @@ -878,6 +881,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: "quit_app" } | { readonly op: "image_load"; @@ -1231,6 +1235,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 }; + }, + /// Quit the app for real — the graceful terminate, and the tray "Quit" /// consequence. The host quits through the SAME shutdown path a /// last-window close takes, so the stop hook runs exactly once and a diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index 719e0f1cc..463909a86 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -76,6 +76,8 @@ const validation = @import("validation.zig"); const runtime_clock = @import("clock.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; /// Maximum argv entries per spawn. @@ -273,6 +275,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, +}; + /// Type-erased handle to the embedding host's named-command services, /// bound onto the effects channel (`bindHostCalls`). This is the seam /// behind `hostRequest`/`hostSend` — the generic named host call a @@ -324,6 +337,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 @@ -4154,6 +4195,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, /// The embedding host's named-command services (`hostSend` / /// `hostRequest`), bound by whoever hosts a transpiled app core /// (loop-thread only). Null means no host services: sends drop, @@ -4163,6 +4208,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 @@ -5328,6 +5375,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; + } + /// Point named host commands at the embedding host's services /// (see `HostCallBinding`). Loop-thread only; the first bind /// sticks. @@ -7833,6 +7887,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. @@ -7840,6 +7909,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 07ef87076..d2bb50f45 100644 --- a/src/runtime/ts_core_host.zig +++ b/src/runtime/ts_core_host.zig @@ -285,6 +285,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. //! quit_app -> `fx.quitApp()` — the graceful terminate through the //! same shutdown path a last-window close takes. //! show_notification -> `fx.showNotification` fire-and-forget; invalid or @@ -1062,6 +1067,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] + 0x1E => { + 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 bf55b77b5..b7676caff 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 // @@ -289,6 +290,7 @@ 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 + navigate_webview, // 83: webview_navigate child URL }; pub const InitResult = struct { model: *const Model, cmd: []const u8 }; @@ -511,6 +513,7 @@ const mini_core = struct { .drop_paste => return .{ .model = model, .cmd = cmdCancel("paste") }, .open_win => return .{ .model = model, .cmd = cmdWindowShow("player") }, .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| { @@ -891,6 +894,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] = 0x1E; + 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; @@ -2154,6 +2166,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 3ecf3b93d..34fb6f7f9 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -1362,6 +1362,11 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe .show_fn = effectsShowWindowByLabel, .quit_fn = effectsQuitApp, }); + self.effects.bindWebViewActions(.{ + .context = runtime, + .window_id = self.canvas_window_id, + .navigate_fn = effectsNavigateWebView, + }); if (runtime.options.session_recorder) |recorder| { self.effects.bindJournal(recorder.effectJournal()); } @@ -5979,6 +5984,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; +} + /// The build storage pinned under a presented native context menu: /// which canvas's arena pair and which generation (index) of that pair /// built the presented tree. The canvas is named by STABLE window diff --git a/tools/corewire/emit_facade.zig b/tools/corewire/emit_facade.zig index 32b4e62fa..35c8120e3 100644 --- a/tools/corewire/emit_facade.zig +++ b/tools/corewire/emit_facade.zig @@ -2577,6 +2577,11 @@ const FacadeEmitter = struct { \\ nscfWU8(sink, 0x10); \\ nscfWShortText(sink, cmd.label); \\ return; + \\ case "webview_navigate": + \\ nscfWU8(sink, 0x1e); + \\ nscfWShortText(sink, cmd.label); + \\ nscfWBytes(sink, cmd.url); + \\ return; \\ case "quit_app": \\ nscfWU8(sink, 0x11); \\ return; From f7651e00f57887e14ba67458272f238b9cd24661 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Fri, 7 Aug 2026 21:01:12 +0200 Subject: [PATCH 02/11] ci: tolerate shared-runner GPU input latency --- .github/workflows/ci.yml | 1 + build.zig | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d006b5277..39e2ef590 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,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/build.zig b/build.zig index 0d2d6c306..2de6b8137 100644 --- a/build.zig +++ b/build.zig @@ -2644,7 +2644,14 @@ pub fn build(b: *std.Build) void { \\# loop. Assert an explicit input-to-glass bound (the perf harness \\# budgets the same channel at 100 ms) instead of the one-interval \\# budget flag the old completion-channel stamp happened to satisfy. - \\if [ "$input_latency" -le 0 ] || [ "$input_latency" -gt 100000000 ]; then echo "components GPU input-to-glass latency was implausible: $input_latency ns" >&2; exit 1; fi + \\# Shared runners can briefly exceed that local sanity bound while the + \\# input is still consumed and presented correctly, so allow the CI + \\# workflow to widen only this plausibility ceiling explicitly. + \\input_latency_budget_ms="${NATIVE_SDK_INPUT_LATENCY_BUDGET_MS:-100}" + \\case "$input_latency_budget_ms" in ''|*[!0-9]*) echo "NATIVE_SDK_INPUT_LATENCY_BUDGET_MS must be a positive integer of milliseconds: $input_latency_budget_ms" >&2; exit 1 ;; esac + \\if [ "$input_latency_budget_ms" -le 0 ]; then echo "NATIVE_SDK_INPUT_LATENCY_BUDGET_MS must be a positive integer of milliseconds: $input_latency_budget_ms" >&2; exit 1; fi + \\input_latency_budget_ns=$((input_latency_budget_ms * 1000000)) + \\if [ "$input_latency" -le 0 ] || [ "$input_latency" -gt "$input_latency_budget_ns" ]; then echo "components GPU input-to-glass latency exceeded ${input_latency_budget_ms} ms: $input_latency ns" >&2; exit 1; fi \\echo "gpu-components smoke ok" , "sh", From 2c75e57f8974e9a69bcfd59249f98dbe342ca026 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Fri, 7 Aug 2026 21:32:15 +0200 Subject: [PATCH 03/11] feat: wire Agent Wars previews through navigateWebView --- .gitignore | 1 + README.md | 1 + build.zig | 1 + examples/README.md | 3 +- examples/agent-wars/README.md | 84 +++ examples/agent-wars/app.zon | 40 ++ examples/agent-wars/package.json | 33 + examples/agent-wars/preview/index.html | 74 +++ .../agent-wars/sidecar/coordinator.test.ts | 87 +++ examples/agent-wars/sidecar/coordinator.ts | 405 +++++++++++++ examples/agent-wars/src/app.native | 78 +++ examples/agent-wars/src/core.ts | 566 ++++++++++++++++++ examples/agent-wars/tsconfig.json | 18 + examples/agent-wars/tsconfig.sidecar.json | 17 + 14 files changed, 1407 insertions(+), 1 deletion(-) create mode 100644 examples/agent-wars/README.md create mode 100644 examples/agent-wars/app.zon create mode 100644 examples/agent-wars/package.json create mode 100644 examples/agent-wars/preview/index.html create mode 100644 examples/agent-wars/sidecar/coordinator.test.ts create mode 100644 examples/agent-wars/sidecar/coordinator.ts create mode 100644 examples/agent-wars/src/app.native create mode 100644 examples/agent-wars/src/core.ts create mode 100644 examples/agent-wars/tsconfig.json create mode 100644 examples/agent-wars/tsconfig.sidecar.json diff --git a/.gitignore b/.gitignore index 61f54ea50..f0acc3c39 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,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 88dd40333..32e77d02c 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 | | --- | --- | | [`ai-chat-ts`](./examples/ai-chat-ts) | TypeScript + Native markup end to end: modules, a text editor, 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 2de6b8137..1ddd4fbab 100644 --- a/build.zig +++ b/build.zig @@ -1418,6 +1418,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 bfd5ce88f..ab44ee9e8 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 | | --- | --- | | `ai-chat-ts` | Multi-module TypeScript core, text editing, `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. | | `soundboard-ts` | Full music player: audio effects, timers, search, assets, native context menus, and adaptive markup. | | `system-monitor-ts` | Subprocess effects, timers, parsing, tables, charts, controlled scroll, and confirmation flows. | @@ -60,4 +61,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 `ai-chat-ts`, `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 the GPU trio for custom-rendered or retained-canvas panes. +Start with `native init` for a small TypeScript + Native markup app, then use `ai-chat-ts`, `agent-wars`, `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 the GPU trio 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..08063fad1 --- /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.8.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 @@ + + + +