From 8e332d63a96124e783977916a500dd9a27d515e5 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 14:48:17 -0300 Subject: [PATCH 01/14] feat(desktop): add Herdr bridge MVP --- packages/petdex-desktop-native/README.md | 7 + .../integrations/herdr/README.md | 54 +++ .../integrations/herdr/bridge.test.ts | 158 +++++++++ .../integrations/herdr/bridge.ts | 332 ++++++++++++++++++ .../integrations/herdr/config.example.json | 11 + .../integrations/herdr/herdr-plugin.toml | 19 + .../petdex-desktop-native/src/hook_runner.zig | 23 +- .../petdex-desktop-native/src/hook_server.zig | 24 +- packages/petdex-desktop-native/src/main.zig | 105 ++++-- packages/petdex-desktop-native/src/plat.zig | 27 ++ 10 files changed, 711 insertions(+), 49 deletions(-) create mode 100644 packages/petdex-desktop-native/integrations/herdr/README.md create mode 100644 packages/petdex-desktop-native/integrations/herdr/bridge.test.ts create mode 100644 packages/petdex-desktop-native/integrations/herdr/bridge.ts create mode 100644 packages/petdex-desktop-native/integrations/herdr/config.example.json create mode 100644 packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml diff --git a/packages/petdex-desktop-native/README.md b/packages/petdex-desktop-native/README.md index 1c175df7..ba3dce7b 100644 --- a/packages/petdex-desktop-native/README.md +++ b/packages/petdex-desktop-native/README.md @@ -31,6 +31,13 @@ CLI and SDK checkout used by the matching release workflow. The build scripts apply the Petdex-owned macOS Mach-O headerpad patch before compiling; they fail if the SDK source no longer matches the pinned patch. +## Herdr + +The local Herdr plugin mirrors agent attention from Herdr into Petdex and +preserves the exact pane ID so clicking the pet can focus that pane. Direct +Petdex hooks remain preferred for supported agents. See +[`integrations/herdr`](integrations/herdr/README.md) for setup and filtering. + ## Remote agents (SSH) Agents running on other machines can drive the same pet. Declare remotes in diff --git a/packages/petdex-desktop-native/integrations/herdr/README.md b/packages/petdex-desktop-native/integrations/herdr/README.md new file mode 100644 index 00000000..bde8cac1 --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/README.md @@ -0,0 +1,54 @@ +# Petdex for Herdr + +This Herdr plugin mirrors normalized agent attention into the local Petdex desktop app. + +It requires `bun` and `herdr` on the Herdr process PATH. Petdex must be running with hooks enabled. + +Direct Petdex hooks remain the richer source for Claude, Codex, Gemini, OpenCode, Qoder, Kimi, CodeBuddy, OMP, and Hermes. The bridge defaults to agents outside that set so it does not replace tool names, approval events, failures, or assistant previews with coarse Herdr states. + +## Local MVP + +```bash +herdr plugin link packages/petdex-desktop-native/integrations/herdr +herdr plugin list +``` + +Start Petdex and test the bridge: + +```bash +herdr plugin action invoke test +``` + +To opt a directly supported agent into the bridge, create `config.json` in the plugin config directory: + +```bash +herdr plugin config-dir dev.petdex.bridge +``` + +```json +{ + "includeAgents": ["claude", "codex", "opencode"] +} +``` + +When `includeAgents` is present, only those normalized names are bridged. `"*"` enables every detected agent. Without it, the bridge covers agents that do not have direct Petdex hooks. `excludeAgents` can add names to the default exclusion set. + +## State mapping + +| Herdr | Petdex | +| --- | --- | +| `working` | `running`, busy card | +| `blocked` | `waiting`, attention card | +| `idle` | `idle`, expiring card | +| `done` | `idle`, expiring card | +| `unknown` | ignored | + +Herdr `done` means idle and not yet seen. It is not treated as verified task completion. + +## MVP limits + +Herdr starts one bridge process per status event. Near-simultaneous events use last-write-wins aggregation, so a newer state can briefly be replaced by an older snapshot. + +The aggregate includes every agent visible to Herdr. A directly hooked agent running outside Herdr is not visible to that aggregate and can briefly have its global state replaced by a bridged agent. Its next direct hook restores the richer state. + +Click-to-focus targets active Herdr agent panes. If the agent was released or Herdr cannot resolve the pane, Petdex falls back to the originating application. diff --git a/packages/petdex-desktop-native/integrations/herdr/bridge.test.ts b/packages/petdex-desktop-native/integrations/herdr/bridge.test.ts new file mode 100644 index 00000000..130dbdb3 --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; + +import { + aggregateState, + type HerdrAgent, + type HerdrEvent, + herdrAgents, + parseCliAgents, + postUpdate, + safePaneId, + safeText, + shouldBridge, + statusState, + updateFromEvent, +} from "./bridge"; + +const agent: HerdrAgent = { + agent: "claude", + agent_session: { value: "session-1" }, + agent_status: "blocked", + foreground_cwd: "/repo", + pane_id: "w1:p5", + terminal_title_stripped: "Fix auth", +}; + +const event: HerdrEvent = { + data: { + agent: "claude", + agent_status: "blocked", + pane_id: "w1:p5", + state_labels: { blocked: "Needs approval" }, + }, +}; + +describe("Herdr Petdex bridge", () => { + test("defaults to agents without direct Petdex hooks", () => { + expect(shouldBridge("claude", {})).toBeFalse(); + expect(shouldBridge("cursor", {})).toBeTrue(); + expect(shouldBridge("claude", { includeAgents: ["claude"] })).toBeTrue(); + expect(shouldBridge("cursor", { excludeAgents: ["cursor"] })).toBeFalse(); + expect(shouldBridge("qodercli", {})).toBeFalse(); + expect(shouldBridge("open_code", {})).toBeFalse(); + }); + + test("maps semantic states without treating done as completion", () => { + expect(statusState("working")).toBe("running"); + expect(statusState("blocked")).toBe("waiting"); + expect(statusState("done")).toBe("idle"); + expect(statusState("unknown")).toBeNull(); + }); + + test("builds a token-gated Petdex update with exact pane metadata", () => { + const update = updateFromEvent(event, agent, "waiting", { + includeAgents: ["claude"], + }); + expect(update).toEqual({ + bubble: { + agent_source: "claude", + busy: false, + herdr_pane_id: "w1:p5", + session_id: "session-1", + source_cwd: "/repo", + text: "Needs approval", + title: "Fix auth", + }, + state: "waiting", + }); + }); + + test("aggregates blocked before working and idle", () => { + expect( + aggregateState([ + { agent: "cursor", agent_status: "working" }, + { agent: "kilo", agent_status: "blocked" }, + ]), + ).toBe("waiting"); + expect(aggregateState([{ agent: "cursor", agent_status: "working" }])).toBe( + "running", + ); + expect(aggregateState([{ agent: "cursor", agent_status: "done" }])).toBe( + "idle", + ); + }); + + test("keeps direct agent activity in the global state and falls back to the event", () => { + expect( + aggregateState( + [{ agent: "claude", agent_status: "working", pane_id: "w1:p1" }], + { agent_status: "idle", pane_id: "w1:p2" }, + ), + ).toBe("running"); + expect( + aggregateState([], { agent_status: "blocked", pane_id: "w1:p2" }), + ).toBe("waiting"); + expect( + aggregateState( + [{ agent: "cursor", agent_status: "blocked", pane_id: "w1:p2" }], + { agent_status: "idle", pane_id: "w1:p2" }, + ), + ).toBe("idle"); + }); + + test("rejects malformed pane ids and sanitizes Petdex flat JSON text", () => { + expect(safePaneId("w1:p5")).toBe("w1:p5"); + expect(safePaneId("w1:p5;open")).toBe(""); + expect(safeText('a\\b"c\n')).toBe("a b c"); + }); + + test("parses Herdr CLI envelopes", () => { + expect( + parseCliAgents('{"result":{"agents":[{"agent":"cursor"}]}}'), + ).toEqual([{ agent: "cursor" }]); + expect(parseCliAgents("bad")).toEqual([]); + }); + + test("falls back cleanly when the Herdr CLI cannot spawn", () => { + const previous = process.env.HERDR_BIN_PATH; + try { + process.env.HERDR_BIN_PATH = "/does/not/exist"; + expect(herdrAgents()).toEqual([]); + } finally { + if (previous) process.env.HERDR_BIN_PATH = previous; + else delete process.env.HERDR_BIN_PATH; + } + }); + + test("posts the token-gated bubble and aggregate state", async () => { + const update = updateFromEvent(event, agent, "waiting", { + includeAgents: ["claude"], + }); + expect(update).not.toBeNull(); + if (!update) throw new Error("expected bridge update"); + const requests: Array<{ body: string; token: string; url: string }> = []; + const fetcher = async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const headers = new Headers(init?.headers); + requests.push({ + body: String(init?.body), + token: headers.get("x-petdex-update-token") ?? "", + url: String(input), + }); + return new Response("{}", { status: 200 }); + }; + await postUpdate(update, "secret", fetcher as typeof fetch); + expect(requests).toHaveLength(2); + expect(requests.map((request) => request.url)).toEqual([ + "http://127.0.0.1:7777/bubble", + "http://127.0.0.1:7777/state", + ]); + expect(requests.every((request) => request.token === "secret")).toBeTrue(); + expect(JSON.parse(requests[1].body)).toEqual({ + agent_source: "claude", + state: "waiting", + }); + }); +}); diff --git a/packages/petdex-desktop-native/integrations/herdr/bridge.ts b/packages/petdex-desktop-native/integrations/herdr/bridge.ts new file mode 100644 index 00000000..b090a42c --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.ts @@ -0,0 +1,332 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export type AgentStatus = "idle" | "working" | "blocked" | "done" | "unknown"; + +export type HerdrAgent = { + agent?: string | null; + agent_session?: { value?: string | null } | null; + agent_status?: AgentStatus | null; + cwd?: string | null; + foreground_cwd?: string | null; + pane_id?: string | null; + state_labels?: Record | null; + terminal_title_stripped?: string | null; +}; + +export type HerdrEvent = { + data?: { + agent?: string | null; + agent_status?: AgentStatus | null; + display_agent?: string | null; + pane_id?: string | null; + state_labels?: Record | null; + title?: string | null; + workspace_id?: string | null; + }; + event?: string; +}; + +export type BridgeConfig = { + excludeAgents?: string[]; + includeAgents?: string[]; +}; + +export type PetdexUpdate = { + bubble: { + agent_source: string; + busy: boolean; + herdr_pane_id: string; + session_id: string; + source_cwd?: string; + text: string; + title: string; + }; + state: "idle" | "jumping" | "running" | "waiting"; +}; + +const directPetdexAgents = new Set([ + "claude", + "claude-code", + "codebuddy", + "codex", + "gemini", + "hermes", + "kimi", + "kimi-code", + "omp", + "open-code", + "opencode", + "qoder", + "qodercli", +]); + +const decoder = new TextDecoder(); + +export function normalizeAgent(value: unknown): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replaceAll("_", "-"); +} + +export function shouldBridge(agent: string, config: BridgeConfig): boolean { + const normalized = normalizeAgent(agent); + if (!normalized) return false; + const include = (config.includeAgents ?? []) + .map(normalizeAgent) + .filter(Boolean); + if (include.length > 0) + return include.includes("*") || include.includes(normalized); + const excluded = new Set([ + ...directPetdexAgents, + ...(config.excludeAgents ?? []).map(normalizeAgent).filter(Boolean), + ]); + return !excluded.has(normalized); +} + +export function safeText(value: unknown, max = 96): string { + return Array.from(String(value ?? ""), (character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127 || character === '"' || character === "\\" + ? " " + : character; + }) + .join("") + .replace(/\s+/g, " ") + .trim() + .slice(0, max); +} + +export function safePaneId(value: unknown): string { + const pane = String(value ?? "").trim(); + return /^[A-Za-z0-9:_-]{1,64}$/.test(pane) ? pane : ""; +} + +export function statusState(status: AgentStatus): PetdexUpdate["state"] | null { + if (status === "blocked") return "waiting"; + if (status === "working") return "running"; + if (status === "idle" || status === "done") return "idle"; + return null; +} + +export function aggregateState( + agents: HerdrAgent[], + current?: Pick, +): PetdexUpdate["state"] { + const currentPane = safePaneId(current?.pane_id); + let reconciled = false; + const statuses = agents.map((agent) => { + if (currentPane && safePaneId(agent.pane_id) === currentPane) { + reconciled = true; + return current?.agent_status; + } + return agent.agent_status; + }); + if (!reconciled && current?.agent_status) statuses.push(current.agent_status); + if (statuses.includes("blocked")) return "waiting"; + if (statuses.includes("working")) return "running"; + return "idle"; +} + +export function updateFromEvent( + event: HerdrEvent, + agentInfo: HerdrAgent | undefined, + aggregate: PetdexUpdate["state"], + config: BridgeConfig, +): PetdexUpdate | null { + const data = event.data ?? {}; + const paneId = safePaneId(data.pane_id ?? agentInfo?.pane_id); + const agent = normalizeAgent(data.agent ?? agentInfo?.agent); + const status = data.agent_status ?? agentInfo?.agent_status ?? "unknown"; + if (!paneId || !shouldBridge(agent, config) || !statusState(status)) + return null; + const label = + safeText(data.display_agent ?? agentInfo?.agent ?? agent, 32) || "Agent"; + const stateLabels = data.state_labels ?? agentInfo?.state_labels ?? {}; + const fallback = + status === "blocked" + ? `${label} needs you` + : status === "working" + ? `${label} is working` + : `${label} is ready`; + const text = safeText(stateLabels[status] ?? fallback, 110) || fallback; + const title = + safeText(data.title ?? agentInfo?.terminal_title_stripped ?? label, 60) || + label; + const nativeSession = safeText(agentInfo?.agent_session?.value, 64); + const sourceCwd = safeText(agentInfo?.foreground_cwd ?? agentInfo?.cwd, 511); + return { + bubble: { + agent_source: agent, + busy: status === "working", + herdr_pane_id: paneId, + session_id: nativeSession || `herdr:${paneId}`, + ...(sourceCwd.startsWith("/") ? { source_cwd: sourceCwd } : {}), + text, + title, + }, + state: aggregate, + }; +} + +export function parseCliAgents(raw: string): HerdrAgent[] { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed?.result?.agents) ? parsed.result.agents : []; + } catch { + return []; + } +} + +export function parseEvent(raw: string | undefined): HerdrEvent { + try { + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +export async function postUpdate( + update: PetdexUpdate, + token: string, + fetcher: typeof fetch = fetch, +): Promise { + const headers = { + "content-type": "application/json", + "x-petdex-update-token": token, + }; + const requests = [ + fetcher("http://127.0.0.1:7777/bubble", { + method: "POST", + headers, + body: JSON.stringify(update.bubble), + signal: AbortSignal.timeout(500), + }), + fetcher("http://127.0.0.1:7777/state", { + method: "POST", + headers, + body: JSON.stringify({ + state: update.state, + agent_source: update.bubble.agent_source, + }), + signal: AbortSignal.timeout(500), + }), + ]; + const responses = await Promise.all(requests); + if (responses.some((response) => !response.ok)) + throw new Error("Petdex rejected Herdr update"); +} + +async function loadConfig(): Promise { + const root = process.env.HERDR_PLUGIN_CONFIG_DIR; + if (!root) return {}; + try { + return JSON.parse(await readFile(join(root, "config.json"), "utf8")); + } catch { + return {}; + } +} + +export function herdrAgents(): HerdrAgent[] { + const herdr = process.env.HERDR_BIN_PATH || "herdr"; + try { + const child = Bun.spawnSync([herdr, "agent", "list"], { + stderr: "pipe", + stdout: "pipe", + }); + if (child.exitCode !== 0) return []; + return parseCliAgents(decoder.decode(child.stdout)); + } catch { + return []; + } +} + +async function token(): Promise { + const home = process.env.HOME || process.env.USERPROFILE; + if (!home || existsSync(join(home, ".petdex", "runtime", "hooks-disabled"))) + return ""; + try { + return ( + await readFile(join(home, ".petdex", "runtime", "update-token"), "utf8") + ).trim(); + } catch { + return ""; + } +} + +async function deliver(event: HerdrEvent, config: BridgeConfig): Promise { + if (event.data?.agent && !shouldBridge(event.data.agent, config)) return; + const agents = herdrAgents(); + const pane = safePaneId(event.data?.pane_id); + const info = agents.find((agent) => safePaneId(agent.pane_id) === pane); + const update = updateFromEvent( + event, + info, + aggregateState(agents, event.data), + config, + ); + const secret = update ? await token() : ""; + if (update && secret) await postUpdate(update, secret); +} + +async function snapshot(config: BridgeConfig): Promise { + const agents = herdrAgents(); + const secret = await token(); + if (!secret) return; + const aggregate = aggregateState(agents); + for (const agent of agents) { + if (agent.agent_status !== "working" && agent.agent_status !== "blocked") + continue; + const event: HerdrEvent = { + event: "pane.agent_status_changed", + data: { + agent: agent.agent, + agent_status: agent.agent_status, + pane_id: agent.pane_id, + state_labels: agent.state_labels, + title: agent.terminal_title_stripped, + }, + }; + const update = updateFromEvent(event, agent, aggregate, config); + if (update) await postUpdate(update, secret); + } +} + +async function testBridge(): Promise { + const secret = await token(); + if (!secret) throw new Error("Petdex is not running"); + const pane = safePaneId(process.env.HERDR_PANE_ID) || "herdr:test"; + await postUpdate( + { + bubble: { + agent_source: "herdr", + busy: false, + herdr_pane_id: pane, + session_id: `herdr:${pane}`, + text: "Herdr bridge connected", + title: "Petdex", + }, + state: "jumping", + }, + secret, + ); +} + +async function main(): Promise { + const mode = process.argv[2] ?? "event"; + const config = await loadConfig(); + if (mode === "snapshot") return snapshot(config); + if (mode === "test") return testBridge(); + const eventName = process.env.HERDR_PLUGIN_EVENT; + if (eventName && eventName !== "pane.agent_status_changed") return; + return deliver(parseEvent(process.env.HERDR_PLUGIN_EVENT_JSON), config); +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/packages/petdex-desktop-native/integrations/herdr/config.example.json b/packages/petdex-desktop-native/integrations/herdr/config.example.json new file mode 100644 index 00000000..6f95fc83 --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/config.example.json @@ -0,0 +1,11 @@ +{ + "includeAgents": [ + "cursor", + "copilot", + "devin", + "droid", + "kilo", + "mastracode", + "grok" + ] +} diff --git a/packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml b/packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml new file mode 100644 index 00000000..eab86fcc --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml @@ -0,0 +1,19 @@ +id = "dev.petdex.bridge" +name = "Petdex" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Mirror Herdr agent attention into Petdex." +platforms = ["linux", "macos", "windows"] + +[[startup]] +command = ["bun", "run", "bridge.ts", "snapshot"] + +[[actions]] +id = "test" +title = "Test Petdex bridge" +contexts = ["workspace"] +command = ["bun", "run", "bridge.ts", "test"] + +[[events]] +on = "pane.agent_status_changed" +command = ["bun", "run", "bridge.ts", "event"] diff --git a/packages/petdex-desktop-native/src/hook_runner.zig b/packages/petdex-desktop-native/src/hook_runner.zig index 2008a8c3..e2c43729 100644 --- a/packages/petdex-desktop-native/src/hook_runner.zig +++ b/packages/petdex-desktop-native/src/hook_runner.zig @@ -29,7 +29,7 @@ const post_poll_ms: u64 = 5; /// argv tail after "bubble": [phase, agent?]. Reads stdin, formats, /// POSTs bubble + state to the in-process hook server. Never fails outward. -pub fn run(phase: []const u8, arg_agent: ?[]const u8, origin_app: plat.OriginApplication, source_cwd_raw: ?[]const u8, home: []const u8) void { +pub fn run(phase: []const u8, arg_agent: ?[]const u8, origin_app: plat.OriginApplication, source_cwd_raw: ?[]const u8, herdr_pane_raw: ?[]const u8, home: []const u8) void { // Always finish consuming the host's payload before any early return. // The host may still be writing after the useful 64 KiB prefix, and // closing the read end early propagates EPIPE/Broken pipe to the agent. @@ -57,6 +57,7 @@ pub fn run(phase: []const u8, arg_agent: ?[]const u8, origin_app: plat.OriginApp var tty_buf: [64]u8 = undefined; const source_tty = if (origin_app == .terminal) (plat.controllingTty(&tty_buf) orelse "") else ""; const source_cwd = plat.safeSourceCwd(source_cwd_raw) orelse ""; + const herdr_pane = plat.safeHerdrPaneId(herdr_pane_raw) orelse ""; var session_hash_buf: [64]u8 = undefined; const session_id = payloadSessionId(payload, &session_hash_buf); @@ -106,9 +107,9 @@ pub fn run(phase: []const u8, arg_agent: ?[]const u8, origin_app: plat.OriginApp var posts: [2]PostJob = undefined; var post_count: usize = 0; - var body_buf: [1024]u8 = undefined; + var body_buf: [1536]u8 = undefined; if (text.len > 0) { - const body = bubbleBodyWithMetadata(&body_buf, text, title, busy, agent, session_id, source_app, source_tty, source_cwd); + const body = bubbleBodyWithMetadata(&body_buf, text, title, busy, agent, session_id, source_app, source_tty, source_cwd, herdr_pane); if (body) |b| { if (startPost("/bubble", b, token)) |post| { posts[post_count] = post; @@ -163,10 +164,10 @@ fn isToolFailurePhase(phase: []const u8) bool { /// precisely how session_id got parsed, used for titles, and then left out of /// the POST that needed it. pub fn bubbleBody(out: []u8, text: []const u8, title: []const u8, busy: bool, agent: []const u8, session_id: ?[]const u8) ?[]const u8 { - return bubbleBodyWithMetadata(out, text, title, busy, agent, session_id, "", "", ""); + return bubbleBodyWithMetadata(out, text, title, busy, agent, session_id, "", "", "", ""); } -fn bubbleBodyWithMetadata(out: []u8, text: []const u8, title: []const u8, busy: bool, agent: []const u8, session_id: ?[]const u8, source_app: []const u8, source_tty: []const u8, source_cwd: []const u8) ?[]const u8 { +fn bubbleBodyWithMetadata(out: []u8, text: []const u8, title: []const u8, busy: bool, agent: []const u8, session_id: ?[]const u8, source_app: []const u8, source_tty: []const u8, source_cwd: []const u8, herdr_pane: []const u8) ?[]const u8 { var title_buf: [256]u8 = undefined; const title_part: []const u8 = if (title.len > 0) (std.fmt.bufPrint(&title_buf, ",\"title\":\"{s}\"", .{title}) catch return null) @@ -177,9 +178,9 @@ fn bubbleBodyWithMetadata(out: []u8, text: []const u8, title: []const u8, busy: (std.fmt.bufPrint(&session_buf, ",\"session_id\":\"{s}\"", .{sid}) catch return null) else ""; - var metadata_buf: [704]u8 = undefined; - const metadata = if (source_app.len > 0 or source_tty.len > 0 or source_cwd.len > 0) - (std.fmt.bufPrint(&metadata_buf, ",\"source_app\":\"{s}\",\"source_tty\":\"{s}\",\"source_cwd\":\"{s}\"", .{ source_app, source_tty, source_cwd }) catch return null) + var metadata_buf: [800]u8 = undefined; + const metadata = if (source_app.len > 0 or source_tty.len > 0 or source_cwd.len > 0 or herdr_pane.len > 0) + (std.fmt.bufPrint(&metadata_buf, ",\"source_app\":\"{s}\",\"source_tty\":\"{s}\",\"source_cwd\":\"{s}\",\"herdr_pane_id\":\"{s}\"", .{ source_app, source_tty, source_cwd, herdr_pane }) catch return null) else ""; return std.fmt.bufPrint(out, "{{\"text\":\"{s}\"{s},\"busy\":{},\"agent_source\":\"{s}\"{s}{s}}}", .{ text, title_part, busy, agent, session_part, metadata }) catch null; @@ -818,6 +819,12 @@ test "bubbleBody carries session_id, and omits it when there is none" { ); } +test "bubble metadata carries the exact Herdr pane id" { + var buf: [1024]u8 = undefined; + const body = bubbleBodyWithMetadata(&buf, "Needs approval", "Fix auth", false, "cursor", "session-1", "ghostty", "", "/repo", "w1:p5").?; + try t.expectEqualStrings("w1:p5", hook_server.jsonStringPub(body, "herdr_pane_id").?); +} + test "two runner sessions reach the mailbox as two bubbles" { // End to end over the real parser and the real mailbox: this is the // acceptance criterion of #657 on the path the default install uses, diff --git a/packages/petdex-desktop-native/src/hook_server.zig b/packages/petdex-desktop-native/src/hook_server.zig index 13f2d1c4..4355f437 100644 --- a/packages/petdex-desktop-native/src/hook_server.zig +++ b/packages/petdex-desktop-native/src/hook_server.zig @@ -58,6 +58,8 @@ pub const Bubble = struct { source_tty_len: usize = 0, source_cwd: [512]u8 = @splat(0), source_cwd_len: usize = 0, + herdr_pane: [64]u8 = @splat(0), + herdr_pane_len: usize = 0, busy: bool = false, counter: u64 = 0, @@ -70,6 +72,9 @@ pub const Bubble = struct { pub fn cwdSlice(self: *const Bubble) []const u8 { return self.source_cwd[0..self.source_cwd_len]; } + pub fn herdrPaneSlice(self: *const Bubble) []const u8 { + return self.herdr_pane[0..self.herdr_pane_len]; + } }; /// How many conversations can narrate at once. Fixed because the @@ -171,10 +176,10 @@ pub const Mailbox = struct { /// full the least recently updated entry is evicted: an abandoned /// session must not hold a slot against a live one. pub fn setBubble(self: *Mailbox, session: []const u8, text: []const u8, agent: []const u8, title: []const u8, busy: bool) u64 { - return self.setBubbleWithMetadata(session, text, agent, title, .none, "", "", busy); + return self.setBubbleWithMetadata(session, text, agent, title, .none, "", "", "", busy); } - pub fn setBubbleWithMetadata(self: *Mailbox, session: []const u8, text: []const u8, agent: []const u8, title: []const u8, origin_app: plat.OriginApplication, source_tty: []const u8, source_cwd: []const u8, busy: bool) u64 { + pub fn setBubbleWithMetadata(self: *Mailbox, session: []const u8, text: []const u8, agent: []const u8, title: []const u8, origin_app: plat.OriginApplication, source_tty: []const u8, source_cwd: []const u8, herdr_pane: []const u8, busy: bool) u64 { self.mutex.lock(); defer self.mutex.unlock(); @@ -214,6 +219,9 @@ pub const Mailbox = struct { const cwd_n = @min(source_cwd.len, slot.source_cwd.len); @memcpy(slot.source_cwd[0..cwd_n], source_cwd[0..cwd_n]); slot.source_cwd_len = cwd_n; + const pane_n = @min(herdr_pane.len, slot.herdr_pane.len); + @memcpy(slot.herdr_pane[0..pane_n], herdr_pane[0..pane_n]); + slot.herdr_pane_len = pane_n; slot.busy = busy; self.bubble_counter += 1; @@ -524,13 +532,14 @@ fn route(server: *Server, conn: *Conn, method: []const u8, path: []const u8, hea const origin_app = plat.OriginApplication.fromTermProgram(jsonString(body, "source_app")); const source_tty = plat.safeSourceTty(jsonString(body, "source_tty")) orelse ""; const source_cwd = plat.safeSourceCwd(jsonString(body, "source_cwd")) orelse ""; + const herdr_pane = plat.safeHerdrPaneId(jsonString(body, "herdr_pane_id")) orelse ""; const busy = std.mem.indexOf(u8, body, "\"busy\":true") != null; // Canonical conversation metadata wins over raw continuation/session // ids. Arbitrary provider keys are normalized to the mailbox's fixed // 64-byte key instead of being truncated into possible collisions. var session_hash: [64]u8 = undefined; const session = bubbleSessionKey(body, &session_hash); - const counter = mailbox.setBubbleWithMetadata(session, capped, agent[0..@min(agent.len, 24)], title[0..@min(title.len, 96)], origin_app, source_tty, source_cwd, busy); + const counter = mailbox.setBubbleWithMetadata(session, capped, agent[0..@min(agent.len, 24)], title[0..@min(title.len, 96)], origin_app, source_tty, source_cwd, herdr_pane, busy); mirrorBubble(server, capped, counter, title[0..@min(title.len, 96)], agent[0..@min(agent.len, 24)], busy) catch {}; const out = std.fmt.bufPrint(&scratch, "{{\"ok\":true,\"counter\":{d}}}", .{counter}) catch return; return respond(conn, 200, out); @@ -810,6 +819,15 @@ test "two sessions hold two bubbles and neither overwrites the other" { try std.testing.expectEqual(beta_counter, out[1].counter); } +test "bubble metadata preserves the Herdr pane id" { + var mb: Mailbox = .{}; + _ = mb.setBubbleWithMetadata("herdr:w1:p5", "Needs approval", "cursor", "Fix auth", .terminal, "", "/repo", "w1:p5", false); + + var out: [max_bubbles]Bubble = @splat(.{}); + try std.testing.expectEqual(@as(?usize, 1), mb.takeBubbles(&out)); + try std.testing.expectEqualStrings("w1:p5", out[0].herdrPaneSlice()); +} + test "a sessionless agent keeps the single shared slot" { var mb: Mailbox = .{}; _ = mb.setBubble("", "first", "codex", "", true); diff --git a/packages/petdex-desktop-native/src/main.zig b/packages/petdex-desktop-native/src/main.zig index d5581031..e3774825 100644 --- a/packages/petdex-desktop-native/src/main.zig +++ b/packages/petdex-desktop-native/src/main.zig @@ -1090,7 +1090,7 @@ var initial_pet_y: ?f64 = null; // assets/agents/, re-registered only when the agent changes. const avatar_image_id: u64 = 13; const tail_image_id: u64 = 14; -// One slot for every agent logo, packed side by side and read back with +// One slot for every agent logo plus fallback, packed side by side and read back with // `image_src` (the thumbnail atlas above does the same). Previously each // agent held its own registry id, which ran the app into the SDK's // 16-slot ceiling (canvas_limits.max_registered_canvas_images): ids @@ -1120,7 +1120,7 @@ fn agentIconRect(index: usize) geometry.RectF { /// cannot go missing from a bundle or resolve against the wrong cwd. /// opencode ships light and dark glyphs; the rest read on both. const AgentArt = struct { light: []const u8, dark: []const u8 }; -const agent_art = [agent_hooks.agent_count]AgentArt{ +const agent_art = [agent_hooks.agent_count + 1]AgentArt{ .{ .light = @embedFile("assets/agents/claude-code.png"), .dark = @embedFile("assets/agents/claude-code.png") }, .{ .light = @embedFile("assets/agents/codex.png"), .dark = @embedFile("assets/agents/codex.png") }, .{ .light = @embedFile("assets/agents/gemini.png"), .dark = @embedFile("assets/agents/gemini.png") }, @@ -1130,8 +1130,9 @@ const agent_art = [agent_hooks.agent_count]AgentArt{ .{ .light = @embedFile("assets/agents/codebuddy.png"), .dark = @embedFile("assets/agents/codebuddy.png") }, .{ .light = @embedFile("assets/agents/omp.png"), .dark = @embedFile("assets/agents/omp.png") }, .{ .light = @embedFile("assets/agents/hermes.png"), .dark = @embedFile("assets/agents/hermes.png") }, + .{ .light = @embedFile("assets/agents/fallback.png"), .dark = @embedFile("assets/agents/fallback.png") }, }; -const agent_fallback_art: []const u8 = @embedFile("assets/agents/fallback.png"); +const agent_fallback_index = agent_hooks.agent_count; /// Pack every settings agent logo into one registry slot, themed like the /// bubble avatar and rebuilt on appearance flips. Each logo decodes @@ -1254,23 +1255,25 @@ var avatar_theme_dark: bool = false; /// is looked up by name rather than by enum. An unknown name is the /// normal case for an agent we do not ship a glyph for, not an error. fn agentArtBytes(agent: []const u8, dark: bool) []const u8 { - for (std.enums.values(agent_hooks.AgentKind)) |kind| { - if (std.mem.eql(u8, kind.hookAgentName(), agent)) { - const art = agent_art[@intFromEnum(kind)]; - return if (dark) art.dark else art.light; - } - } - return agent_fallback_art; + const index = if (agentKindForName(agent)) |kind| @intFromEnum(kind) else agent_fallback_index; + const art = agent_art[index]; + return if (dark) art.dark else art.light; } -/// Which cell of the packed logo strip belongs to this agent, or null -/// for a name we ship no glyph for. The strip has exactly one cell per -/// AgentKind and none for the fallback art, so an unknown agent has no -/// tile to point at and the caller has to draw nothing. -fn agentIconIndex(agent: []const u8) ?usize { +/// Which cell of the packed logo strip belongs to this agent. +fn agentIconIndex(agent: []const u8) usize { + if (agentKindForName(agent)) |kind| return @intFromEnum(kind); + return agent_fallback_index; +} + +fn agentKindForName(agent: []const u8) ?agent_hooks.AgentKind { for (std.enums.values(agent_hooks.AgentKind)) |kind| { - if (std.mem.eql(u8, kind.hookAgentName(), agent)) return @intFromEnum(kind); + if (std.mem.eql(u8, kind.hookAgentName(), agent)) return kind; } + if (std.mem.eql(u8, agent, "claude")) return .claude_code; + if (std.mem.eql(u8, agent, "open-code")) return .opencode; + if (std.mem.eql(u8, agent, "qodercli")) return .qoder; + if (std.mem.eql(u8, agent, "kimi")) return .kimi_code; return null; } @@ -2245,7 +2248,8 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void { if (isTap(now - model.press_ms, read.x - model.press_x, read.y - model.press_y)) { model.sample_len = 0; if (newestBubble(model)) |bubble| { - _ = plat.activateOriginApplication(bubble.origin_app, bubble.ttySlice(), bubble.cwdSlice()); + const focused = if (env_home) |home| plat.activateHerdrPane(home, bubble.herdrPaneSlice()) else false; + if (!focused) _ = plat.activateOriginApplication(bubble.origin_app, bubble.ttySlice(), bubble.cwdSlice()); } model.pat_flip = !model.pat_flip; applyState(model, if (model.pat_flip) .jumping else .waving, pat_react_ms, fx); @@ -2483,9 +2487,14 @@ fn bubbleMaxCardWidth(model: *const Model) f32 { } fn bubbleMaxCardHeight(model: *const Model) f32 { - const rows = @as(f32, @floatFromInt(@as(u16, model.bubble_answer_lines) + 1)); - const line_height = bubbleFontSize(model) * 1.35; - return @ceil(rows * line_height + (rows - 1) * bubble_line_gap + bubble_card_padding * 2); + const rows = @as(usize, model.bubble_answer_lines) + 1; + return @ceil(bubbleContentHeight(model, rows) + bubble_card_padding * 2); +} + +fn bubbleContentHeight(model: *const Model, row_count: usize) f32 { + if (row_count == 0) return 0; + const rows = @as(f32, @floatFromInt(row_count)); + return rows * bubbleFontSize(model) * 1.35 + @as(f32, @floatFromInt(row_count - 1)) * bubble_line_gap; } /// Painted width of the widest line a card holds. The strings are the @@ -3444,9 +3453,7 @@ fn bubbleCard(ui: *AppUi, model: *const Model, slot: usize) AppUi.Node { // included), which is strictly better than a strip cell, so the card // Hunter looks at most never degrades. Older cards read their logo // out of the shared strip via image_src, the same addressing - // settings_view uses for its rows. An agent with no cell in the - // strip draws an empty box of the same width, so the column still - // lines up. + // settings_view uses for its rows. const agent_name = bubble.agent[0..bubble.agent_len]; const avatar = if (newest) blk: { var img = ui.image(.{ @@ -3457,14 +3464,14 @@ fn bubbleCard(ui: *AppUi, model: *const Model, slot: usize) AppUi.Node { }); img.widget.image_fit = .contain; break :blk img; - } else if (agents_icons_ready and agentIconIndex(agent_name) != null) blk: { + } else if (agents_icons_ready) blk: { var img = ui.image(.{ .width = bubble_avatar_width, .height = bubble_avatar_width, .image = agent_icon_atlas_id, .semantics = .{ .label = "Agent avatar" }, }); - img.widget.image_src = agentIconRect(agentIconIndex(agent_name).?); + img.widget.image_src = agentIconRect(agentIconIndex(agent_name)); img.widget.image_fit = .contain; break :blk img; } else ui.el(.stack, .{ .width = bubble_avatar_width, .height = bubble_avatar_width }, .{}); @@ -3505,7 +3512,7 @@ fn bubbleCard(ui: *AppUi, model: *const Model, slot: usize) AppUi.Node { const content = [_]AppUi.Node{ ui.row(.{ .gap = bubble_content_gap, .cross = .center }, .{ avatar, - ui.column(.{ .grow = 1, .gap = bubble_line_gap, .cross = .start }, @as([]const AppUi.Node, rows[0..row_count])), + ui.column(.{ .grow = 1, .height = bubbleContentHeight(model, row_count), .gap = bubble_line_gap, .main = .start, .cross = .start }, @as([]const AppUi.Node, rows[0..row_count])), spinner_slot, }), }; @@ -3828,7 +3835,7 @@ pub fn main(init: std.process.Init) !void { const phase = args_it.next() orelse return; const agent: ?[]const u8 = args_it.next(); const origin_app = plat.OriginApplication.fromTermProgram(init.environ_map.get("TERM_PROGRAM")); - hook_runner.run(phase, agent, origin_app, init.environ_map.get("PWD"), env_home orelse return); + hook_runner.run(phase, agent, origin_app, init.environ_map.get("PWD"), init.environ_map.get("HERDR_PANE_ID"), env_home orelse return); return; } } @@ -3915,7 +3922,7 @@ pub fn main(init: std.process.Init) !void { test "every agent gets its own cell in the icon strip" { // One slot holds them all, so a wrong offset silently draws the // neighbouring agent's logo rather than failing to register. - for (0..agent_hooks.agent_count) |i| { + for (0..agent_art.len) |i| { const rect = agentIconRect(i); try std.testing.expectEqual(@as(f32, @floatFromInt(i * agent_icon_px)), rect.x); try std.testing.expectEqual(@as(f32, 0), rect.y); @@ -3923,14 +3930,14 @@ test "every agent gets its own cell in the icon strip" { try std.testing.expectEqual(@as(f32, agent_icon_px), rect.height); } // Cells abut with no overlap: agent N ends exactly where N+1 begins. - if (agent_hooks.agent_count >= 2) { + if (agent_art.len >= 2) { const first = agentIconRect(0); const second = agentIconRect(1); try std.testing.expectEqual(first.x + first.width, second.x); } // The packed strip stays inside the SDK's per-image bounds, which is // the ceiling this atlas exists to avoid running into again. - const atlas_w = agent_hooks.agent_count * agent_icon_px; + const atlas_w = agent_art.len * agent_icon_px; try std.testing.expect(atlas_w * agent_icon_px * 4 <= 1024 * 1024); try std.testing.expect(atlas_w <= 512 * 512); } @@ -3961,7 +3968,31 @@ test "transparent surfaces clear independently from settings" { test "one image slot covers every agent" { // agent_art is what loadAgentsAtlas walks, so a new AgentKind without // artwork would pack short and leave the last agent blank. - try std.testing.expectEqual(agent_hooks.agent_count, agent_art.len); + try std.testing.expectEqual(agent_hooks.agent_count + 1, agent_art.len); +} + +test "Herdr agent aliases resolve to their Petdex artwork" { + try std.testing.expectEqual(agent_hooks.AgentKind.claude_code, agentKindForName("claude").?); + try std.testing.expectEqual(agent_hooks.AgentKind.opencode, agentKindForName("open-code").?); + try std.testing.expectEqual(agent_hooks.AgentKind.qoder, agentKindForName("qodercli").?); + try std.testing.expectEqual(agent_hooks.AgentKind.kimi_code, agentKindForName("kimi").?); + try std.testing.expectEqual(agent_fallback_index, agentIconIndex("herdr")); +} + +test "bubble title and status stay in one compact text block" { + var model: Model = .{}; + testPushBubble(&model, "herdr", "Thinking…", true, -1); + const title = "Execute sleep 30 in the terminal"; + @memcpy(model.bubbles[0].title[0..title.len], title); + model.bubbles[0].title_len = title.len; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var ui = AppUi.init(arena.allocator()); + const card = bubbleCard(&ui, &model, 0); + const text_column = card.nodes[0].nodes[1].widget; + try std.testing.expectEqual(bubbleContentHeight(&model, 2), text_column.layout.min_size.height); + try std.testing.expectEqual(text_column.layout.min_size.height, text_column.layout.max_size.height); } test "activating index 0 works before any sheet is loaded" { @@ -5043,13 +5074,11 @@ test "a flipped stack stays inside its container at both ends" { } } -test "an agent with no strip cell falls back to no tile" { - // Every shipped AgentKind has a cell, the fallback art does not: the - // strip is packed from agent_art alone. - try std.testing.expect(agentIconIndex("claude-code") != null); - try std.testing.expect(agentIconIndex("codex") != null); - try std.testing.expectEqual(@as(?usize, null), agentIconIndex("some-unknown-agent")); - try std.testing.expectEqual(@as(?usize, null), agentIconIndex("")); +test "an agent with no dedicated art uses the fallback tile" { + try std.testing.expectEqual(@as(usize, @intFromEnum(agent_hooks.AgentKind.claude_code)), agentIconIndex("claude-code")); + try std.testing.expectEqual(@as(usize, @intFromEnum(agent_hooks.AgentKind.codex)), agentIconIndex("codex")); + try std.testing.expectEqual(agent_fallback_index, agentIconIndex("some-unknown-agent")); + try std.testing.expectEqual(agent_fallback_index, agentIconIndex("")); } test "clearing a bubble also cancels its lifetime" { diff --git a/packages/petdex-desktop-native/src/plat.zig b/packages/petdex-desktop-native/src/plat.zig index d07db746..6deddd4f 100644 --- a/packages/petdex-desktop-native/src/plat.zig +++ b/packages/petdex-desktop-native/src/plat.zig @@ -464,6 +464,23 @@ pub fn safeSourceCwd(value: ?[]const u8) ?[]const u8 { return cwd; } +pub fn safeHerdrPaneId(value: ?[]const u8) ?[]const u8 { + const pane = value orelse return null; + if (pane.len == 0 or pane.len > 64) return null; + for (pane) |ch| { + if (!std.ascii.isAlphanumeric(ch) and ch != ':' and ch != '_' and ch != '-') return null; + } + return pane; +} + +test "Herdr pane ids accept only bounded CLI-safe identifiers" { + try std.testing.expectEqualStrings("w1:p5", safeHerdrPaneId("w1:p5").?); + try std.testing.expect(safeHerdrPaneId("w1:p5;open") == null); + try std.testing.expect(safeHerdrPaneId("") == null); + var too_long: [65]u8 = @splat('a'); + try std.testing.expect(safeHerdrPaneId(&too_long) == null); +} + fn spawnAndWait(argv: []const []const u8) bool { var scope = Scope.init(); defer scope.deinit(); @@ -475,6 +492,16 @@ fn spawnAndWait(argv: []const []const u8) bool { }; } +pub fn activateHerdrPane(home: []const u8, pane_raw: []const u8) bool { + const pane = safeHerdrPaneId(pane_raw) orelse return false; + if (spawnAndWait(&.{ "herdr", "agent", "focus", pane })) return true; + var local_buf: [768]u8 = undefined; + const local = std.fmt.bufPrint(&local_buf, "{s}/.local/bin/herdr", .{home}) catch return false; + if (spawnAndWait(&.{ local, "agent", "focus", pane })) return true; + if (spawnAndWait(&.{ "/opt/homebrew/bin/herdr", "agent", "focus", pane })) return true; + return spawnAndWait(&.{ "/usr/local/bin/herdr", "agent", "focus", pane }); +} + pub fn activateOriginApplication(origin: OriginApplication, source_tty: []const u8, source_cwd: []const u8) bool { if (builtin.os.tag != .macos) return false; _ = source_cwd; From f68621fded247a59c2a32d39c4048371f4389c6f Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 17:09:44 -0300 Subject: [PATCH 02/14] feat(desktop): surface Herdr status and artwork --- .../src/assets/agents/herdr.png | Bin 0 -> 4069 bytes .../src/herdr_status.zig | 46 ++++++ packages/petdex-desktop-native/src/main.zig | 133 ++++++++++++------ packages/petdex-desktop-native/src/plat.zig | 9 ++ .../src/settings_view.zig | 35 ++++- 5 files changed, 176 insertions(+), 47 deletions(-) create mode 100644 packages/petdex-desktop-native/src/assets/agents/herdr.png create mode 100644 packages/petdex-desktop-native/src/herdr_status.zig diff --git a/packages/petdex-desktop-native/src/assets/agents/herdr.png b/packages/petdex-desktop-native/src/assets/agents/herdr.png new file mode 100644 index 0000000000000000000000000000000000000000..908003349952b7fbe29208858749f55a3efbec0e GIT binary patch literal 4069 zcmc&%^;gtiwEYaBL$?wlAu&TsOM}SJjkI)wAT2o{N(xFSAsvGvJ%r$Z3@IhjG1AP? z-TCKd(K|>hqLZFXPsDGZ5SCbBQXE~WNNC)`nL@K58ykuXS!2l@Q1t9p(atp>=004d-2*AH((0{Q!y#LL@^YH&q z|7TQ9BnSWi3LQ0NMZ-YQmgU_*y3TuYM4a>;Q5>QXs%rV*7uzObt*2ks1nK#||W{Al60#0w=5b z|JRTtpiVN_YJPmMzrT8UMw0qDAQp{ohZMM*#>IYBUFl8p3ATb*A~^s7%O+)Wk%p03 z)CH7V;}Sr_IN9Bu^5pMeZ(7d+e;wDiUHnMpYl|U}2j4rKWL(#jDbk{gpn08EJFrqMI|EgISF) zLukWluvGBzmv+C4C=sVW-X8$SN*`<(wY6PxcVF2+ zOy>SQLPC*>ghbx?Q8MJolh{SKaj$9Gr%+9;;OxQd1I)yQ z!Ryk?g{KBkaq+HP+sH6aRs8vEszQjARGe9Z`T|{2k;o%4&GUUuEm zKq^6r5jaKP4{MGYub5Fau6*HV9xZqh60t_iKPKZvi;@rG^O*i=q-CJZC|(L{5J$ZY zzW8QXq3O_@$_2-+R@-6RE$V`@C)DjvoN^2NPcX$YXwmKu1i!BF@46g+sdVle_h=$C zEzx99XYL6BefOs0;p5{-|9H@1+QT0!z2p3hnI#DRO=M+&*|H@h11o6J5lT^|aHzXA zT?bhj@Rcv_3wu@q^l&J$B;8i zZTkErBqX#jj){rME9xL1CLvjJP|QFYL;gNf$iQU&c<(Pw zYRC-<3Iewfb+Vm|NTDX|MlAd;d}^Na{iMO8ZmFmkAD@? zfXJDrE9I(58F3mM!W;itX>vxhadI>IViD&hiaK2M>F+a9Ly2RTh%82oX)L~A!mXr( zuxs3>M)33Bh+N!te8km-B%lk$dz6LH$z_8}B-GKWm9ElAO7_du&j^V z5GlC>&?1w%5}lUzUeE5A^2(dR%`-)@b_Dc0b13Po4LZtt>Bs=VrGbU->^!^fruo)k*`6S_zOmWpz4icO{plS${E}F!XbSQCiOdQI=WX~uD-E#l6{{li{kHRCpq(+D1}cp>;N}e zt-I3S%n{;#XWfq;^s7I-SlXwPulYN?Mt&X#eTsJM|DttUQ)1VZ8IqovIr(d3TzGkJ z=#k09d~8KcrMZSK9^HA!_~%2hS``_imSn47Ov4R;x*AkvJZhsyoU|W230t&}?u9XQ zdgk$0M-BcJL>p5WHWxJSxV9rtxhe#hK9To<1{#zeOT7!vS9XQoeB)FP=WZ73vp*U&a6*jsL|NM4;C5|%kNa^I|rSSPib*^R+4Yzhv?@Q+(MaBGXoC`nL zp8VZQpT%V;WAe4t!<1cFgTr|?KUj$iXlSmvEJTQk$v_4f$93|r!ODr)kT>A+wI7+l z%rON9Msc|%r-aFsM(_HF-LosDJjBy6ELL~O;&XZ<%JKfC{p5z{>+c~y6QE}Bw{rp| zXCxL97jdJ{%S|=P%^M8sUPQe<3apkEDNbG+fhH0sdgtamN^P9D)2cpU8Q`&6-)EP- zhSFo%k9g#C(r#B;H@85$uWaVQoR(xgmRv&Z37U6~KqI2_o>6Fpr`!{|X&skp#BJA$ zhM4dqkqv!%1F@D2S+CUf2aC<**!UeTT>Q17U4?ggTN?;f9Pmsa{x?h)`S#( zB(%qRUEUcy;m>jv%)P!p`A9py{XmGaC~#LdUub2MC?eyNmoYS^v)`egT;WaDSo)toyD#m!0o*X5nZwVlJ#&Z3+j#=5?+{uQzW# zK7wWVAOeTFuR&hGKTK5wF|cJEwr)tiW#8W6F+&@jWGBfGJ_yFn3|cm4Th^#G-^>P& z&J7JtROP#g=*EJq?=cPB4#M91O7diGz+Z7QCCZ+?rPhO@PK{6AIO4@)MarPCFwn~V zWdo2jdZy%qe*qX%l)|NBx8ommJ$CdsE9}MZq(?ITV3oJ+y)h-dupm>n)h126Zqbm{3=D&Akad{W9DDMHc>H-yb2OAyF~>rD5sbdDS%d{U zQi-|@$J{pVj*-7g_AloYh!tYl>F!3q+NVxVHrC7u~!EH4h~-px+wYdp#q88E6bBk2kR0Aad*fz z-R#V4g;bK-d&=&fWlRk%%>OvR9_R{$&oZ<<&|S5DMi56?^l@+8t4=cAnPDe!*>2ah z;68ViP#ywOPW;lc{_^hj=aT~Zr!|aNaO72s{cqvc3^c1ID=&*+6obmNP;J+H#(k5p zPTta!;)RTmOL@y5Fg^3Lp>z4{{ft>#(g7!v`#|#*=Ir2|Fm9loOT<{1kF>K1UhAaX z#S|5bD0^PHQl410uRFDsE8vmHDx$HxUAj=EXFTRSeBbWO{^hX>E~&wsXdS4kaib=M9UE^BGI6p`|;g^DnA>i zgaA@{nbAtPwqW2sFE8z{mde8-DL>aU3v#7OF6~TL9-lvSBJV)+Q@G&2Z%OLe^eSF_ zD;lBtMs`;h5zVp;N)MWI`ZF6El18;k<9NMZcS&D@A9W*7V1D6Mn^V}TLVs@S=r}u# zBocGM#JG;d;jnB!-#KqTHULv~bel8=K0kgq-H&HdN2TPrGH{I|#aFN>HZkChIF4-d z4CJTEj+ZY>wsExfwk$P8Wrg~fgMCHb@>f5vm(Qj4sV0h~$O-(4lYv zAm6j?Y1q5p=Pr9QeooeT$joWoNH>2~f_^w& zzd&xhY9I4E05LdcB+4eK+CVPKL1UO3)K>>s zn@dj_bTtLwKPoIAb}A+_5t%Ro=?iYH zA5xi2!LNT00}AiuZY(6^;*EQz$J&r9Ah@OUW5e(L7ZYqW`86gFfzpBW^f*yb59^-I zRSyolw*Wv8CmWVLWlNsy&dEv&P?}!$aeY7GRpSNO098v9mg_tPW}uib0aBpKJ)Q$b z1jYx#jcNR7v&HnIK!E@6)Md!6Q`e-_GsOu;Wh(uQ9*C;VR^)u5u0Rhs8#4sZzG;M_ z0f0n7-8&U71gL6$-{?VUR{P7q>%*$?8ZB~Fc|W@0&`7`p^Q5?Vi_0K!ky7yXl{{Bz znbDjohc898L|{UopUHg&n`pbek|8Mt*PqalyVl9lDoyj%OH!%>O6@m^60U$Er9iTM z^zA3)<`~Tn3k_1E6fy-9-7dm2?Zwq5ZKb7A)gs?PPLdq0=T7^9SOZVO+)6^UOc};xi&Q*;7YHsIwL>UX|%yX zQbrK;O=ECcsedVF8INdsUdVHs97B z!shC3YNgwAO-y`NU-j&n "Not detected", + .available => "Installed; Petdex plugin not connected", + .connected => "Petdex plugin connected", + }; + } +}; + +pub fn detect(allocator: std.mem.Allocator, home: []const u8) Status { + if (!plat.herdrAvailable(home)) return .absent; + var path_buf: [768]u8 = undefined; + const path = std.fmt.bufPrint(&path_buf, "{s}/.config/herdr/plugins.json", .{home}) catch return .available; + const source = plat.readFileAlloc(allocator, path, 1024 * 1024) orelse return .available; + defer allocator.free(source); + return if (petdexPluginEnabled(allocator, source)) .connected else .available; +} + +fn petdexPluginEnabled(allocator: std.mem.Allocator, source: []const u8) bool { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, source, .{}) catch return false; + defer parsed.deinit(); + if (parsed.value != .array) return false; + for (parsed.value.array.items) |entry| { + if (entry != .object) continue; + const id = entry.object.get("plugin_id") orelse continue; + if (id != .string or !std.mem.eql(u8, id.string, "dev.petdex.bridge")) continue; + const enabled = entry.object.get("enabled") orelse return false; + return enabled == .bool and enabled.bool; + } + return false; +} + +test "Petdex Herdr plugin status follows its enabled field" { + const allocator = std.testing.allocator; + try std.testing.expect(petdexPluginEnabled(allocator, "[{\"plugin_id\":\"dev.petdex.bridge\",\"enabled\":true}]")); + try std.testing.expect(!petdexPluginEnabled(allocator, "[{\"plugin_id\":\"dev.petdex.bridge\",\"enabled\":false}]")); + try std.testing.expect(!petdexPluginEnabled(allocator, "[{\"plugin_id\":\"other\",\"enabled\":true}]")); +} diff --git a/packages/petdex-desktop-native/src/main.zig b/packages/petdex-desktop-native/src/main.zig index e3774825..69768d4e 100644 --- a/packages/petdex-desktop-native/src/main.zig +++ b/packages/petdex-desktop-native/src/main.zig @@ -26,6 +26,7 @@ const remote_agents = @import("remote_agents.zig"); const remote_ssh = @import("remote_ssh.zig"); const remote_writeback = @import("remote_writeback.zig"); const remote_runtime = @import("remote_runtime.zig"); +const herdr_status = @import("herdr_status.zig"); pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic); @@ -280,6 +281,7 @@ pub const Model = struct { .{ .kind = .omp }, .{ .kind = .hermes }, }, + herdr_status: herdr_status.Status = .absent, agents_prompted: bool = false, codex_trust_note: bool = false, pet_filter: [48]u8 = @splat(0), @@ -1120,7 +1122,7 @@ fn agentIconRect(index: usize) geometry.RectF { /// cannot go missing from a bundle or resolve against the wrong cwd. /// opencode ships light and dark glyphs; the rest read on both. const AgentArt = struct { light: []const u8, dark: []const u8 }; -const agent_art = [agent_hooks.agent_count + 1]AgentArt{ +const agent_art = [agent_hooks.agent_count + 2]AgentArt{ .{ .light = @embedFile("assets/agents/claude-code.png"), .dark = @embedFile("assets/agents/claude-code.png") }, .{ .light = @embedFile("assets/agents/codex.png"), .dark = @embedFile("assets/agents/codex.png") }, .{ .light = @embedFile("assets/agents/gemini.png"), .dark = @embedFile("assets/agents/gemini.png") }, @@ -1130,9 +1132,11 @@ const agent_art = [agent_hooks.agent_count + 1]AgentArt{ .{ .light = @embedFile("assets/agents/codebuddy.png"), .dark = @embedFile("assets/agents/codebuddy.png") }, .{ .light = @embedFile("assets/agents/omp.png"), .dark = @embedFile("assets/agents/omp.png") }, .{ .light = @embedFile("assets/agents/hermes.png"), .dark = @embedFile("assets/agents/hermes.png") }, + .{ .light = @embedFile("assets/agents/herdr.png"), .dark = @embedFile("assets/agents/herdr.png") }, .{ .light = @embedFile("assets/agents/fallback.png"), .dark = @embedFile("assets/agents/fallback.png") }, }; -const agent_fallback_index = agent_hooks.agent_count; +pub const herdr_icon_index = agent_hooks.agent_count; +const agent_fallback_index = agent_hooks.agent_count + 1; /// Pack every settings agent logo into one registry slot, themed like the /// bubble avatar and rebuilt on appearance flips. Each logo decodes @@ -1255,13 +1259,14 @@ var avatar_theme_dark: bool = false; /// is looked up by name rather than by enum. An unknown name is the /// normal case for an agent we do not ship a glyph for, not an error. fn agentArtBytes(agent: []const u8, dark: bool) []const u8 { - const index = if (agentKindForName(agent)) |kind| @intFromEnum(kind) else agent_fallback_index; + const index = if (std.mem.eql(u8, agent, "herdr")) herdr_icon_index else if (agentKindForName(agent)) |kind| @intFromEnum(kind) else agent_fallback_index; const art = agent_art[index]; return if (dark) art.dark else art.light; } /// Which cell of the packed logo strip belongs to this agent. fn agentIconIndex(agent: []const u8) usize { + if (std.mem.eql(u8, agent, "herdr")) return herdr_icon_index; if (agentKindForName(agent)) |kind| return @intFromEnum(kind); return agent_fallback_index; } @@ -1675,7 +1680,11 @@ pub fn boot(model: *Model, fx: *Effects) void { // for the first frames of a hidden-dock boot, which beats holding // the setting hostage to an SDK boot hook that does not exist yet. plat.setDockIconHidden(model.hide_dock); - if (env_home) |home| model.agents = agent_hooks.scan(boot_allocator, home); + if (env_home) |home| { + model.agents = agent_hooks.scan(boot_allocator, home); + model.herdr_status = herdr_status.detect(boot_allocator, home); + } + loadAgentsAtlas(model.dark, fx); // First point where the platform codec is reachable: `init_fx` runs // on the loop thread right after the runtime binds services onto fx. @@ -1887,7 +1896,10 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void { model.agents = agent_hooks.scan(boot_allocator, home); }, .open_settings => { - if (env_home) |home| model.agents = agent_hooks.scan(boot_allocator, home); + if (env_home) |home| { + model.agents = agent_hooks.scan(boot_allocator, home); + model.herdr_status = herdr_status.detect(boot_allocator, home); + } loadAgentsAtlas(model.dark, fx); if (model.settings_open) { // Already open, likely buried behind other windows: @@ -2497,6 +2509,25 @@ fn bubbleContentHeight(model: *const Model, row_count: usize) f32 { return rows * bubbleFontSize(model) * 1.35 + @as(f32, @floatFromInt(row_count - 1)) * bubble_line_gap; } +fn bubbleRowCount(model: *const Model, slot: usize) usize { + const bubble = &model.bubbles[slot]; + const chars_per_line: usize = model.bubble_columns; + const answer_lines: usize = model.bubble_answer_lines; + const title = clipDisplay(bubble.title[0..bubble.title_len], chars_per_line, &bubble_title_scratch[slot], false); + const text = clipDisplay(bubble.text[0..bubble.text_len], chars_per_line * answer_lines, &bubble_text_scratch[slot], true); + var count: usize = if (title.len > 0) 1 else 0; + for (splitLines(text, chars_per_line, answer_lines)) |line| { + if (line.len > 0) count += 1; + } + return count; +} + +fn bubbleCardHeight(model: *const Model, slot: usize) f32 { + const content = bubbleContentHeight(model, bubbleRowCount(model, slot)); + const inner = @max(content, @max(bubble_avatar_width, bubble_busy_width)); + return @ceil(inner + bubble_card_padding * 2); +} + /// Painted width of the widest line a card holds. The strings are the /// ones bubbleCard hands to the view, and the font ids come from /// textSpanFontId over the same tokens, which is the SDK's measurement @@ -2574,8 +2605,7 @@ fn bubbleRenderedCardWidth(model: *const Model, slot: usize) f32 { /// rounded rect. Outside a stack there is nothing to inherit from and /// intrinsic sizing is what the single bubble has always wanted. fn bubbleRenderedCardHeight(model: *const Model, slot: usize) f32 { - _ = slot; - return if (bubbleStackable(model)) bubbleMaxCardHeight(model) else 0; + return if (bubbleStackable(model)) bubbleCardHeight(model, slot) else 0; } /// The vertical axis the cards center on, in stack-container local @@ -2695,15 +2725,28 @@ fn bubbleCardAlpha(model: *const Model, slot: usize) f32 { fn bubbleCardOffset(model: *const Model, slot: usize) f32 { if (!bubbleStackable(model)) return 0; const from_front: f32 = @floatFromInt(model.bubbles_len - 1 - slot); - const collapsed = bubble_peek_offset * @min(from_front, bubble_peek_max_depth); - const expanded = (bubbleMaxCardHeight(model) + bubble_stack_gap) * from_front; - const magnitude = collapsed + (expanded - collapsed) * bubbleExpansionEased(model); - if (model.bubble_flipped) return magnitude; - // Unflipped, the container reserves the whole fan but the front card - // belongs at its BOTTOM edge (nearest the pet), so every card starts - // from there and the others stack upward from it. - const slack = bubbleStackHeightAt(model, 1) - bubbleMaxCardHeight(model); - return slack - magnitude; + const peek = bubble_peek_offset * @min(from_front, bubble_peek_max_depth); + const front = model.bubbles_len - 1; + const collapsed = if (model.bubble_flipped) + peek + else + bubbleExpandedStackHeight(model) - bubbleCardHeight(model, front) - peek; + var expanded: f32 = 0; + if (model.bubble_flipped) { + var i = front; + while (i > slot) : (i -= 1) expanded += bubbleCardHeight(model, i) + bubble_stack_gap; + } else { + for (0..slot) |i| expanded += bubbleCardHeight(model, i) + bubble_stack_gap; + } + const t = bubbleExpansionEased(model); + return collapsed + (expanded - collapsed) * t; +} + +fn bubbleExpandedStackHeight(model: *const Model) f32 { + if (model.bubbles_len == 0) return bubbleMaxCardHeight(model); + var height: f32 = 0; + for (0..model.bubbles_len) |slot| height += bubbleCardHeight(model, slot); + return height + bubble_stack_gap * @as(f32, @floatFromInt(model.bubbles_len - 1)); } /// Height the window needs at a given expansion. Collapsed only has to @@ -2711,11 +2754,12 @@ fn bubbleCardOffset(model: *const Model, slot: usize) f32 { /// column. The window is sized to the max of both (see syncBubbleWindow) /// so a resize never races the animation. fn bubbleStackHeightAt(model: *const Model, expansion: f32) f32 { - const card = bubbleMaxCardHeight(model); - if (!bubbleStackable(model)) return card; + if (!bubbleStackable(model)) return if (model.bubbles_len == 0) bubbleMaxCardHeight(model) else bubbleCardHeight(model, 0); + var tallest: f32 = 0; + for (0..model.bubbles_len) |slot| tallest = @max(tallest, bubbleCardHeight(model, slot)); const behind: f32 = @floatFromInt(model.bubbles_len - 1); - const collapsed = card + bubble_peek_offset * @min(behind, bubble_peek_max_depth); - const expanded = card * @as(f32, @floatFromInt(model.bubbles_len)) + bubble_stack_gap * behind; + const collapsed = tallest + bubble_peek_offset * @min(behind, bubble_peek_max_depth); + const expanded = bubbleExpandedStackHeight(model); return collapsed + (expanded - collapsed) * expansion; } @@ -2732,8 +2776,7 @@ fn bubbleStackHeightAt(model: *const Model, expansion: f32) f32 { /// the front card, which costs nothing: the window is click-through and /// fully transparent already. fn bubbleWindowHeight(model: *const Model) f32 { - const count: f32 = @floatFromInt(@max(model.bubbles_len, 1)); - const cards = bubbleMaxCardHeight(model) * count + bubble_stack_gap * (count - 1); + const cards = if (model.bubbles_len == 0) bubbleMaxCardHeight(model) else bubbleExpandedStackHeight(model); return cards + @as(f32, @floatFromInt(tail_h)) + bubble_head_gap + bubble_canvas_margin * 2; } @@ -2948,7 +2991,7 @@ fn bubbleCardsRect(model: *const Model) BubbleRect { var min_x = bubbleCardCenterDx(model, front); var max_x = min_x + bubbleRenderedCardWidth(model, front); var min_y = bubbleCardOffset(model, front); - var max_y = min_y + bubbleMaxCardHeight(model); + var max_y = min_y + bubbleCardHeight(model, front); // The peeks behind the front card stick out; they are visible and so // they are hoverable. if (bubbleStackable(model)) { @@ -2958,7 +3001,7 @@ fn bubbleCardsRect(model: *const Model) BubbleRect { min_x = @min(min_x, x0); max_x = @max(max_x, x0 + bubbleRenderedCardWidth(model, slot)); min_y = @min(min_y, y0); - max_y = @max(max_y, y0 + bubbleMaxCardHeight(model)); + max_y = @max(max_y, y0 + bubbleCardHeight(model, slot)); } } return .{ @@ -3547,7 +3590,7 @@ fn bubbleCard(ui: *AppUi, model: *const Model, slot: usize) AppUi.Node { if (bubbleStackable(model)) { const scale = bubbleCardScale(model, slot); const w = bubbleRenderedCardWidth(model, slot); - const h = bubbleMaxCardHeight(model); + const h = bubbleCardHeight(model, slot); const cx = w / 2; const cy = h / 2; card.widget.transform = canvas.Affine.translate(bubbleCardCenterDx(model, slot), bubbleCardOffset(model, slot)) @@ -3968,7 +4011,7 @@ test "transparent surfaces clear independently from settings" { test "one image slot covers every agent" { // agent_art is what loadAgentsAtlas walks, so a new AgentKind without // artwork would pack short and leave the last agent blank. - try std.testing.expectEqual(agent_hooks.agent_count + 1, agent_art.len); + try std.testing.expectEqual(agent_hooks.agent_count + 2, agent_art.len); } test "Herdr agent aliases resolve to their Petdex artwork" { @@ -3976,7 +4019,7 @@ test "Herdr agent aliases resolve to their Petdex artwork" { try std.testing.expectEqual(agent_hooks.AgentKind.opencode, agentKindForName("open-code").?); try std.testing.expectEqual(agent_hooks.AgentKind.qoder, agentKindForName("qodercli").?); try std.testing.expectEqual(agent_hooks.AgentKind.kimi_code, agentKindForName("kimi").?); - try std.testing.expectEqual(agent_fallback_index, agentIconIndex("herdr")); + try std.testing.expectEqual(herdr_icon_index, agentIconIndex("herdr")); } test "bubble title and status stay in one compact text block" { @@ -4360,7 +4403,7 @@ test "collapsed cards recede behind the front one" { try std.testing.expectEqual(@as(f32, 1), bubbleCardAlpha(&model, 1)); // The container reserves the whole fan, and unflipped the front card // sits at its bottom edge, nearest the pet. - try std.testing.expectEqual(bubbleStackHeightAt(&model, 1) - bubbleMaxCardHeight(&model), bubbleCardOffset(&model, 1)); + try std.testing.expectEqual(bubbleStackHeightAt(&model, 1) - bubbleCardHeight(&model, 1), bubbleCardOffset(&model, 1)); // The one behind is smaller, dimmer and pushed up by the peek offset. try std.testing.expect(bubbleCardScale(&model, 0) < 1); @@ -4385,8 +4428,8 @@ test "expanded restores the slice 1 column" { try std.testing.expectEqual(@as(f32, 1), bubbleCardScale(&model, i)); try std.testing.expectEqual(@as(f32, 1), bubbleCardAlpha(&model, i)); } - try std.testing.expectEqual(bubbleStackHeightAt(&model, 1) - bubbleMaxCardHeight(&model), bubbleCardOffset(&model, 1)); - try std.testing.expectEqual(bubbleMaxCardHeight(&model) + bubble_stack_gap, bubbleCardOffset(&model, 1) - bubbleCardOffset(&model, 0)); + try std.testing.expectEqual(bubbleStackHeightAt(&model, 1) - bubbleCardHeight(&model, 1), bubbleCardOffset(&model, 1)); + try std.testing.expectEqual(bubbleCardHeight(&model, 0) + bubble_stack_gap, bubbleCardOffset(&model, 1) - bubbleCardOffset(&model, 0)); } test "hover waits out the delay, and leaving collapses at once" { @@ -4464,8 +4507,10 @@ test "hover hit tests the drawn cards, not the tall transparent window" { // Expanded, the live band reaches much higher up the window. model.bubble_expansion = 1; - const high = bottom - @as(f64, @floatCast(bubbleMaxCardHeight(&model))) - 10; - try std.testing.expect(bubbleHoverHit(&model, win_x, win_y, win_height, win_x + 20, high)); + const expanded_rect = bubbleCardsRect(&model); + const high_x = win_x + @as(f64, @floatCast(expanded_rect.x + expanded_rect.w / 2)); + const high_y = win_y + @as(f64, @floatCast(expanded_rect.y + 2)); + try std.testing.expect(bubbleHoverHit(&model, win_x, win_y, win_height, high_x, high_y)); } test "collapsed peeks are clamped to the front card and centered" { @@ -4751,7 +4796,7 @@ test "the hover rect covers the whole visible card, not just its text" { const cx = bubble_canvas_margin + bubbleCardCenterDx(&model, slot); const cy = bubbleStackOriginY(&model) + bubbleCardOffset(&model, slot); const cw = bubbleRenderedCardWidth(&model, slot); - const chh = bubbleMaxCardHeight(&model); + const chh = bubbleCardHeight(&model, slot); try std.testing.expect(r.x <= cx); try std.testing.expect(r.y <= cy); try std.testing.expect(r.x + r.w >= cx + cw); @@ -4777,7 +4822,7 @@ test "the hover rect covers the whole visible card, not just its text" { const fx0 = bubble_canvas_margin + bubbleCardCenterDx(&model, model.bubbles_len - 1); const fy0 = bubbleStackOriginY(&model) + bubbleCardOffset(&model, model.bubbles_len - 1); const fw = bubbleRenderedCardWidth(&model, model.bubbles_len - 1); - const fh = bubbleMaxCardHeight(&model); + const fh = bubbleCardHeight(&model, model.bubbles_len - 1); const wh: f64 = @floatCast(bubbleWindowHeight(&model)); for ([_][2]f32{ .{ fx0 + 1, fy0 + 1 }, @@ -4861,7 +4906,7 @@ test "the hover rect starts at the stack container, not at the canvas margin" { const front = model.bubbles_len - 1; const fy0 = origin_y + bubbleCardOffset(&model, front); const fx = bubble_canvas_margin + bubbleCardCenterDx(&model, front) + 4; - const bottom = fy0 + bubbleMaxCardHeight(&model); + const bottom = fy0 + bubbleCardHeight(&model, front); const wh: f64 = @floatCast(bubbleWindowHeight(&model)); // One point inside the bottom edge: live. try std.testing.expect(bubbleHoverHit(&model, 0, 0, wh, fx, bottom - 1)); @@ -4871,11 +4916,11 @@ test "the hover rect starts at the stack container, not at the canvas margin" { // card unflipped and the deepest peek once flipped: the peeks are // drawn, so they are hoverable, and the rect is their union. var drawn_top = origin_y + bubbleCardOffset(&model, 0); - var drawn_bottom = drawn_top + bubbleMaxCardHeight(&model); + var drawn_bottom = drawn_top + bubbleCardHeight(&model, 0); for (0..model.bubbles_len) |slot| { const top = origin_y + bubbleCardOffset(&model, slot); drawn_top = @min(drawn_top, top); - drawn_bottom = @max(drawn_bottom, top + bubbleMaxCardHeight(&model)); + drawn_bottom = @max(drawn_bottom, top + bubbleCardHeight(&model, slot)); } // Well past the slop below everything drawn: dead, so the fix // widened the rect onto the cards rather than onto the window. @@ -5017,11 +5062,11 @@ test "a stacked card keeps its own height, never the container's" { testPushBubble(&model, "alpha", "older", false, -1); testPushBubble(&model, "beta", "newer", true, -1); - const card_h = bubbleMaxCardHeight(&model); const container = bubbleStackHeightAt(&model, 1); - try std.testing.expect(container > card_h); for (0..model.bubbles_len) |i| { + const card_h = bubbleCardHeight(&model, i); try std.testing.expectEqual(card_h, bubbleRenderedCardHeight(&model, i)); + try std.testing.expect(card_h < bubbleMaxCardHeight(&model)); try std.testing.expect(bubbleRenderedCardHeight(&model, i) < container); } @@ -5044,7 +5089,6 @@ test "a flipped stack stays inside its container at both ends" { model.bubble_flipped = true; const container = bubbleStackHeightAt(&model, 1); - const card_h = bubbleMaxCardHeight(&model); // Every card, at every point of the animation, must sit fully // inside the container: top edge at or below 0, bottom edge at or @@ -5054,7 +5098,7 @@ test "a flipped stack stays inside its container at both ends" { for (0..model.bubbles_len) |i| { const top = bubbleCardOffset(&model, i); try std.testing.expect(top >= -0.01); - try std.testing.expect(top + card_h <= container + 0.01); + try std.testing.expect(top + bubbleCardHeight(&model, i) <= container + 0.01); } // Flipped, the FRONT card leads at the top, hard against the // head gap, and the rest hang below it. @@ -5068,9 +5112,10 @@ test "a flipped stack stays inside its container at both ends" { for (0..model.bubbles_len) |i| { const top = bubbleCardOffset(&model, i); try std.testing.expect(top >= -0.01); - try std.testing.expect(top + card_h <= container + 0.01); + try std.testing.expect(top + bubbleCardHeight(&model, i) <= container + 0.01); } - try std.testing.expectEqual(container - card_h, bubbleCardOffset(&model, model.bubbles_len - 1)); + const front = model.bubbles_len - 1; + try std.testing.expectEqual(container - bubbleCardHeight(&model, front), bubbleCardOffset(&model, front)); } } @@ -5145,7 +5190,7 @@ test "an empty stack still reserves one card of window height" { var model: Model = .{}; const empty = bubbleWindowHeight(&model); testPushBubble(&model, "alpha", "reading", true, -1); - try std.testing.expectEqual(empty, bubbleWindowHeight(&model)); + try std.testing.expect(bubbleWindowHeight(&model) < empty); } test "expiry drops only the bubbles past their deadline" { diff --git a/packages/petdex-desktop-native/src/plat.zig b/packages/petdex-desktop-native/src/plat.zig index 6deddd4f..02f3fb4a 100644 --- a/packages/petdex-desktop-native/src/plat.zig +++ b/packages/petdex-desktop-native/src/plat.zig @@ -492,6 +492,15 @@ fn spawnAndWait(argv: []const []const u8) bool { }; } +pub fn herdrAvailable(home: []const u8) bool { + if (spawnAndWait(&.{ "herdr", "--version" })) return true; + var local_buf: [768]u8 = undefined; + const local = std.fmt.bufPrint(&local_buf, "{s}/.local/bin/herdr", .{home}) catch return false; + if (spawnAndWait(&.{ local, "--version" })) return true; + if (spawnAndWait(&.{ "/opt/homebrew/bin/herdr", "--version" })) return true; + return spawnAndWait(&.{ "/usr/local/bin/herdr", "--version" }); +} + pub fn activateHerdrPane(home: []const u8, pane_raw: []const u8) bool { const pane = safeHerdrPaneId(pane_raw) orelse return false; if (spawnAndWait(&.{ "herdr", "agent", "focus", pane })) return true; diff --git a/packages/petdex-desktop-native/src/settings_view.zig b/packages/petdex-desktop-native/src/settings_view.zig index 1c890498..55fe5dc2 100644 --- a/packages/petdex-desktop-native/src/settings_view.zig +++ b/packages/petdex-desktop-native/src/settings_view.zig @@ -192,6 +192,33 @@ fn agentsSection(ui: *AppUi, model: *const Model, icons: IconAtlas) AppUi.Node { return ui.column(.{ .gap = 12 }, @as([]const AppUi.Node, rows[0..count])); } +fn herdrSection(ui: *AppUi, model: *const Model, icons: IconAtlas) AppUi.Node { + if (model.herdr_status == .absent) return ui.el(.stack, .{}, .{}); + var logo = ui.image(.{ + .width = 24, + .height = 24, + .image = if (icons.ready) icons.image else 0, + .semantics = .{ .label = "Herdr" }, + }); + logo.widget.image_src = icons.rect(app.herdr_icon_index); + logo.widget.image_fit = .contain; + return ui.el(.panel, .{ + .padding = 12, + .gap = 12, + .cross = .center, + .style_tokens = .{ .background = .surface, .radius = .md }, + .semantics = .{ .label = "Herdr" }, + }, .{ + ui.row(.{ .gap = 12, .cross = .center }, .{ + logo, + ui.column(.{ .grow = 1, .main = .center }, .{ + ui.text(.{}, "Herdr"), + mutedParagraph(ui, model.herdr_status.caption()), + }), + }), + }); +} + /// SSH remotes running agents whose hooks ride the reverse tunnel. /// Read-only by design: remotes are declared in /// ~/.petdex/remote-agents.json and the section only reports what the @@ -280,7 +307,7 @@ pub fn settingsView(ui: *AppUi, model: *const Model, icons: IconAtlas, thumbs: T // One scrollable page: the root scroll takes the window frame and // everything - full pet catalog included - flows inside it. No // more per-section band budgets. - var page = ui.scroll(.{ .grow = 1 }, .{ui.column(.{ .padding = 16, .gap = 12 }, .{ + const page = ui.scroll(.{ .grow = 1 }, .{ui.column(.{ .padding = 16, .gap = 12 }, .{ ui.text(.{ .size = .lg }, "Pets"), installBanner(ui, model), ui.el(.search_field, .{ @@ -305,6 +332,7 @@ pub fn settingsView(ui: *AppUi, model: *const Model, icons: IconAtlas, thumbs: T ui.el(.stack, .{ .height = 10 }, .{}), ui.text(.{ .size = .lg }, "Agents"), agentsSection(ui, model, icons), + herdrSection(ui, model, icons), remoteSection(ui, model), ui.el(.stack, .{ .height = 10 }, .{}), ui.text(.{ .size = .lg }, "Appearance"), @@ -492,8 +520,9 @@ pub fn settingsView(ui: *AppUi, model: *const Model, icons: IconAtlas, thumbs: T // of the scroll extent, so the last card needs explicit air. ui.el(.stack, .{ .height = 8 }, .{}), })}); - page.widget.style.background = settingsBackground(model); - return page; + var root = ui.el(.panel, .{ .grow = 1 }, .{page}); + root.widget.style.background = settingsBackground(model); + return root; } test "settings descriptions use wrapped paragraphs" { From 04617c2e2da5cd77dd0388893906d6767cd0df16 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 17:47:50 -0300 Subject: [PATCH 03/14] feat(desktop): harden Herdr plugin integration --- .github/workflows/desktop-native-ci.yml | 48 ++++++ .../integrations/herdr/README.md | 17 ++- .../integrations/herdr/bridge.test.ts | 66 ++++++++ .../integrations/herdr/bridge.ts | 113 +++++++++++--- .../integrations/herdr/herdr-plugin.toml | 1 - .../integrations/herdr/managed-smoke.ts | 143 ++++++++++++++++++ .../src/herdr_status.zig | 19 ++- packages/petdex-desktop-native/src/plat.zig | 25 +++ 8 files changed, 401 insertions(+), 31 deletions(-) create mode 100644 packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts diff --git a/.github/workflows/desktop-native-ci.yml b/.github/workflows/desktop-native-ci.yml index a074d53b..84e1001a 100644 --- a/.github/workflows/desktop-native-ci.yml +++ b/.github/workflows/desktop-native-ci.yml @@ -68,11 +68,39 @@ jobs: - platform: windows runner: windows-2022 expected_to_fail: false + env: + HERDR_PLUGIN_SOURCE: ${{ (github.event.pull_request.head.repo.full_name || github.repository) }}/packages/petdex-desktop-native/integrations/herdr + HERDR_PLUGIN_REF: ${{ github.event.pull_request.head.sha || github.sha }} steps: - name: Checkout petdex uses: actions/checkout@v7 + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Install Herdr (Unix) + if: matrix.platform != 'windows' + shell: bash + run: | + curl -fsSL https://herdr.dev/install.sh -o "$RUNNER_TEMP/install-herdr.sh" + HERDR_INSTALL_DIR="$RUNNER_TEMP/herdr" sh "$RUNNER_TEMP/install-herdr.sh" + echo "$RUNNER_TEMP/herdr" >> "$GITHUB_PATH" + + - name: Install Herdr (Windows) + if: matrix.platform == 'windows' + shell: pwsh + run: | + $installer = "$env:RUNNER_TEMP\install-herdr.ps1" + Invoke-WebRequest -Uri "https://herdr.dev/install.ps1" -OutFile $installer + & $installer -InstallDir "$env:RUNNER_TEMP\herdr" + "$env:RUNNER_TEMP\herdr" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install managed Herdr plugin + shell: bash + working-directory: packages/petdex-desktop-native/integrations/herdr + run: bun run managed-smoke.ts install + - name: Install Linux GUI libraries # The SDK's Linux host links gtk4. Cross-compiling this from # macOS fails on a missing gtk4 that says nothing about the code, @@ -202,6 +230,24 @@ jobs: Set-Content -Path "$dir\pet.json" -NoNewline Get-ChildItem $dir | Select-Object Name, Length | Format-Table -AutoSize + - name: Exercise managed Herdr plugin (macOS) + if: matrix.platform == 'macos' + shell: bash + working-directory: packages/petdex-desktop-native + run: | + set -euo pipefail + PETDEX_PET=ci-pet ./zig-out/bin/petdex-desktop-native > herdr-smoke.log 2>&1 & + APP_PID=$! + for _ in $(seq 1 60); do + test -s "$HOME/.petdex/runtime/update-token" && break + kill -0 "$APP_PID" || { cat herdr-smoke.log; exit 1; } + sleep 1 + done + test -s "$HOME/.petdex/runtime/update-token" + bun run integrations/herdr/managed-smoke.ts action + kill "$APP_PID" || true + wait "$APP_PID" 2>/dev/null || true + - name: Run and exercise the hook server (Linux) if: matrix.platform == 'linux' shell: bash @@ -220,6 +266,7 @@ jobs: sleep 10 kill -0 "$APP_PID" || { echo "app died"; cat run.log; exit 1; } TOKEN=$(cat ~/.petdex/runtime/update-token) + bun run integrations/herdr/managed-smoke.ts action curl -fsS -X POST -H "x-petdex-update-token: $TOKEN" \ -H "Content-Type: application/json" -d '{"state":"running"}' \ http://127.0.0.1:7777/state @@ -462,6 +509,7 @@ jobs: $tokenPath = "$env:USERPROFILE\.petdex\runtime\update-token" if (Test-Path $tokenPath) { $token = Get-Content $tokenPath -Raw + bun run integrations/herdr/managed-smoke.ts action "=== hook round trip ===" Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:7777/state" ` -Headers @{ "x-petdex-update-token" = $token } ` diff --git a/packages/petdex-desktop-native/integrations/herdr/README.md b/packages/petdex-desktop-native/integrations/herdr/README.md index bde8cac1..1e38f2c1 100644 --- a/packages/petdex-desktop-native/integrations/herdr/README.md +++ b/packages/petdex-desktop-native/integrations/herdr/README.md @@ -6,7 +6,16 @@ It requires `bun` and `herdr` on the Herdr process PATH. Petdex must be running Direct Petdex hooks remain the richer source for Claude, Codex, Gemini, OpenCode, Qoder, Kimi, CodeBuddy, OMP, and Hermes. The bridge defaults to agents outside that set so it does not replace tool names, approval events, failures, or assistant previews with coarse Herdr states. -## Local MVP +## Install from GitHub + +```bash +herdr plugin install crafter-station/petdex/packages/petdex-desktop-native/integrations/herdr +herdr plugin list --plugin dev.petdex.bridge --json +``` + +Review Herdr's trust preview before confirming the install. Use `--ref desktop-vX.Y.Z` to pin a released Petdex desktop version. + +## Local development ```bash herdr plugin link packages/petdex-desktop-native/integrations/herdr @@ -45,9 +54,11 @@ When `includeAgents` is present, only those normalized names are bridged. `"*"` Herdr `done` means idle and not yet seen. It is not treated as verified task completion. -## MVP limits +## Runtime behavior + +Herdr starts one bridge process per status event. The plugin serializes those processes through `HERDR_PLUGIN_STATE_DIR` and queries Herdr again after taking the lock, so an older event cannot overwrite a newer live state. -Herdr starts one bridge process per status event. Near-simultaneous events use last-write-wins aggregation, so a newer state can briefly be replaced by an older snapshot. +The startup snapshot always publishes the aggregate state, including `idle` when Herdr has no active agents, before it restores active cards. The aggregate includes every agent visible to Herdr. A directly hooked agent running outside Herdr is not visible to that aggregate and can briefly have its global state replaced by a bridged agent. Its next direct hook restores the richer state. diff --git a/packages/petdex-desktop-native/integrations/herdr/bridge.test.ts b/packages/petdex-desktop-native/integrations/herdr/bridge.test.ts index 130dbdb3..5186189a 100644 --- a/packages/petdex-desktop-native/integrations/herdr/bridge.test.ts +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { aggregateState, @@ -6,12 +9,15 @@ import { type HerdrEvent, herdrAgents, parseCliAgents, + postState, postUpdate, + reconcileEvent, safePaneId, safeText, shouldBridge, statusState, updateFromEvent, + withBridgeLock, } from "./bridge"; const agent: HerdrAgent = { @@ -100,6 +106,27 @@ describe("Herdr Petdex bridge", () => { ).toBe("idle"); }); + test("reconciles a stale event against Herdr's current pane state", () => { + const stale: HerdrEvent = { + data: { + agent: "cursor", + agent_status: "working", + pane_id: "w1:p5", + title: "Old work", + }, + }; + const current: HerdrAgent = { + agent: "cursor", + agent_status: "blocked", + pane_id: "w1:p5", + terminal_title_stripped: "Needs approval", + }; + const reconciled = reconcileEvent(stale, [current]); + expect(reconciled.aggregate).toBe("waiting"); + expect(reconciled.event.data?.agent_status).toBe("blocked"); + expect(reconciled.event.data?.title).toBe("Needs approval"); + }); + test("rejects malformed pane ids and sanitizes Petdex flat JSON text", () => { expect(safePaneId("w1:p5")).toBe("w1:p5"); expect(safePaneId("w1:p5;open")).toBe(""); @@ -155,4 +182,43 @@ describe("Herdr Petdex bridge", () => { state: "waiting", }); }); + + test("posts idle independently when no bubble exists", async () => { + const requests: Array<{ body: string; url: string }> = []; + const fetcher = async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + requests.push({ body: String(init?.body), url: String(input) }); + return new Response("{}", { status: 200 }); + }; + await postState("idle", "herdr", "secret", fetcher as typeof fetch); + expect(requests).toEqual([ + { + body: '{"state":"idle","agent_source":"herdr"}', + url: "http://127.0.0.1:7777/state", + }, + ]); + }); + + test("serializes concurrent bridge reconciliations", async () => { + const stateRoot = await mkdtemp(join(tmpdir(), "petdex-herdr-")); + let active = 0; + let maximum = 0; + try { + await Promise.all( + Array.from({ length: 6 }, () => + withBridgeLock(async () => { + active += 1; + maximum = Math.max(maximum, active); + await Bun.sleep(10); + active -= 1; + }, stateRoot), + ), + ); + expect(maximum).toBe(1); + } finally { + await rm(stateRoot, { recursive: true, force: true }); + } + }); }); diff --git a/packages/petdex-desktop-native/integrations/herdr/bridge.ts b/packages/petdex-desktop-native/integrations/herdr/bridge.ts index b090a42c..e6b19bd1 100644 --- a/packages/petdex-desktop-native/integrations/herdr/bridge.ts +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { mkdir, open, readFile, rm, stat } from "node:fs/promises"; import { join } from "node:path"; export type AgentStatus = "idle" | "working" | "blocked" | "done" | "unknown"; @@ -204,19 +204,92 @@ export async function postUpdate( body: JSON.stringify(update.bubble), signal: AbortSignal.timeout(500), }), - fetcher("http://127.0.0.1:7777/state", { - method: "POST", - headers, - body: JSON.stringify({ - state: update.state, - agent_source: update.bubble.agent_source, - }), - signal: AbortSignal.timeout(500), - }), + postState(update.state, update.bubble.agent_source, token, fetcher), ]; const responses = await Promise.all(requests); - if (responses.some((response) => !response.ok)) - throw new Error("Petdex rejected Herdr update"); + if (!responses[0].ok) throw new Error("Petdex rejected Herdr update"); +} + +export async function postState( + state: PetdexUpdate["state"], + agentSource: string, + token: string, + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher("http://127.0.0.1:7777/state", { + method: "POST", + headers: { + "content-type": "application/json", + "x-petdex-update-token": token, + }, + body: JSON.stringify({ state, agent_source: agentSource }), + signal: AbortSignal.timeout(500), + }); + if (!response.ok) throw new Error("Petdex rejected Herdr state"); + return response; +} + +export async function withBridgeLock( + run: () => Promise, + stateRoot = process.env.HERDR_PLUGIN_STATE_DIR, +): Promise { + if (!stateRoot) return run(); + await mkdir(stateRoot, { recursive: true }); + const lockPath = join(stateRoot, "petdex-bridge.lock"); + let lock: Awaited> | undefined; + for (let attempt = 0; attempt < 250; attempt += 1) { + try { + lock = await open(lockPath, "wx"); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const lockStat = await stat(lockPath).catch(() => undefined); + if (lockStat && Date.now() - lockStat.mtimeMs > 30_000) { + await rm(lockPath, { force: true }); + continue; + } + if (attempt === 249) + throw new Error("Timed out waiting for Petdex bridge"); + await Bun.sleep(20); + } + } + if (!lock) throw new Error("Could not lock Petdex bridge"); + try { + return await run(); + } finally { + await lock.close(); + await rm(lockPath, { force: true }); + } +} + +export function reconcileEvent( + event: HerdrEvent, + agents: HerdrAgent[], +): { + aggregate: PetdexUpdate["state"]; + event: HerdrEvent; + info: HerdrAgent | undefined; +} { + const pane = safePaneId(event.data?.pane_id); + const info = agents.find((agent) => safePaneId(agent.pane_id) === pane); + const reconciled: HerdrEvent = info + ? { + ...event, + data: { + ...event.data, + agent: info.agent ?? event.data?.agent, + agent_status: info.agent_status ?? event.data?.agent_status, + pane_id: info.pane_id ?? event.data?.pane_id, + state_labels: info.state_labels ?? event.data?.state_labels, + title: info.terminal_title_stripped ?? event.data?.title, + }, + } + : event; + return { + aggregate: aggregateState(agents, info ? undefined : reconciled.data), + event: reconciled, + info, + }; } async function loadConfig(): Promise { @@ -259,12 +332,11 @@ async function token(): Promise { async function deliver(event: HerdrEvent, config: BridgeConfig): Promise { if (event.data?.agent && !shouldBridge(event.data.agent, config)) return; const agents = herdrAgents(); - const pane = safePaneId(event.data?.pane_id); - const info = agents.find((agent) => safePaneId(agent.pane_id) === pane); + const reconciled = reconcileEvent(event, agents); const update = updateFromEvent( - event, - info, - aggregateState(agents, event.data), + reconciled.event, + reconciled.info, + reconciled.aggregate, config, ); const secret = update ? await token() : ""; @@ -276,6 +348,7 @@ async function snapshot(config: BridgeConfig): Promise { const secret = await token(); if (!secret) return; const aggregate = aggregateState(agents); + await postState(aggregate, "herdr", secret); for (const agent of agents) { if (agent.agent_status !== "working" && agent.agent_status !== "blocked") continue; @@ -317,11 +390,13 @@ async function testBridge(): Promise { async function main(): Promise { const mode = process.argv[2] ?? "event"; const config = await loadConfig(); - if (mode === "snapshot") return snapshot(config); + if (mode === "snapshot") return withBridgeLock(() => snapshot(config)); if (mode === "test") return testBridge(); const eventName = process.env.HERDR_PLUGIN_EVENT; if (eventName && eventName !== "pane.agent_status_changed") return; - return deliver(parseEvent(process.env.HERDR_PLUGIN_EVENT_JSON), config); + return withBridgeLock(() => + deliver(parseEvent(process.env.HERDR_PLUGIN_EVENT_JSON), config), + ); } if (import.meta.main) { diff --git a/packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml b/packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml index eab86fcc..721447a8 100644 --- a/packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml +++ b/packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml @@ -11,7 +11,6 @@ command = ["bun", "run", "bridge.ts", "snapshot"] [[actions]] id = "test" title = "Test Petdex bridge" -contexts = ["workspace"] command = ["bun", "run", "bridge.ts", "test"] [[events]] diff --git a/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts new file mode 100644 index 00000000..51f68aed --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts @@ -0,0 +1,143 @@ +const decoder = new TextDecoder(); +const herdr = process.env.HERDR_BIN_PATH || "herdr"; + +type PluginList = { + result?: { + plugins?: Array<{ + enabled?: boolean; + source?: { + kind?: string; + requested_ref?: string; + resolved_commit?: string; + }; + warnings?: string[]; + }>; + }; +}; + +type ActionInvocation = { + result?: { log?: { log_id?: string } }; +}; + +type PluginLog = { + log_id?: string; + status?: string; + stderr?: string; +}; + +type PluginLogs = { + result?: { logs?: PluginLog[] }; +}; + +function run(args: string[], allowFailure = false): string { + const child = Bun.spawnSync([herdr, ...args], { + stderr: "pipe", + stdout: "pipe", + }); + const stdout = decoder.decode(child.stdout).trim(); + const stderr = decoder.decode(child.stderr).trim(); + if (child.exitCode !== 0) { + if (allowFailure) return ""; + throw new Error(stderr || stdout || `${herdr} ${args.join(" ")} failed`); + } + return stdout; +} + +function parse(raw: string): T { + try { + return JSON.parse(raw) as T; + } catch { + throw new Error(`Invalid Herdr JSON: ${raw}`); + } +} + +async function install(): Promise { + const source = process.env.HERDR_PLUGIN_SOURCE; + const ref = process.env.HERDR_PLUGIN_REF; + if (!source || !ref) throw new Error("Missing managed plugin source or ref"); + run(["plugin", "install", source, "--ref", ref, "--yes"]); + const listed = parse( + run(["plugin", "list", "--plugin", "dev.petdex.bridge", "--json"]), + ); + const plugin = listed.result?.plugins?.[0]; + if (!plugin?.enabled) throw new Error("Managed Petdex plugin is not enabled"); + if (plugin.source?.kind !== "github") + throw new Error("Petdex plugin is not GitHub-managed"); + if (plugin.source?.requested_ref !== ref) + throw new Error("Managed Petdex plugin requested the wrong ref"); + if (plugin.source?.resolved_commit !== ref) + throw new Error("Managed Petdex plugin resolved the wrong commit"); + if (plugin.warnings?.length) + throw new Error( + `Managed Petdex plugin warnings: ${plugin.warnings.join(", ")}`, + ); +} + +async function waitForServer(): Promise { + for (let attempt = 0; attempt < 80; attempt += 1) { + if (run(["status", "server"], true)) return true; + await Bun.sleep(100); + } + return false; +} + +async function action(): Promise { + let server: ReturnType | undefined; + if (!run(["status", "server"], true)) { + server = Bun.spawn([herdr, "server"], { + stderr: "pipe", + stdout: "pipe", + }); + if (!(await waitForServer())) throw new Error("Herdr server did not start"); + } + try { + const invoked = parse( + run([ + "plugin", + "action", + "invoke", + "test", + "--plugin", + "dev.petdex.bridge", + ]), + ); + const logId = invoked.result?.log?.log_id; + if (!logId) throw new Error("Herdr action returned no log id"); + let finished: PluginLog | undefined; + for (let attempt = 0; attempt < 100; attempt += 1) { + const logs = parse( + run([ + "plugin", + "log", + "list", + "--plugin", + "dev.petdex.bridge", + "--limit", + "20", + ]), + ); + finished = logs.result?.logs?.find((entry) => entry.log_id === logId); + if (finished?.status !== "running") break; + await Bun.sleep(100); + } + if (finished?.status !== "succeeded") + throw new Error( + finished?.stderr || `Herdr action did not succeed: ${finished?.status}`, + ); + const response = await fetch("http://127.0.0.1:7777/state"); + if (!response.ok) throw new Error("Petdex state endpoint is unavailable"); + const state = (await response.json()) as { state?: string }; + if (state.state !== "jumping") + throw new Error(`Petdex did not receive Herdr action: ${state.state}`); + } finally { + if (server) { + run(["server", "stop"], true); + await server.exited; + } + } +} + +const mode = process.argv[2]; +if (mode === "install") await install(); +else if (mode === "action") await action(); +else throw new Error("Expected install or action"); diff --git a/packages/petdex-desktop-native/src/herdr_status.zig b/packages/petdex-desktop-native/src/herdr_status.zig index e3b00eb8..3b083a90 100644 --- a/packages/petdex-desktop-native/src/herdr_status.zig +++ b/packages/petdex-desktop-native/src/herdr_status.zig @@ -17,9 +17,7 @@ pub const Status = enum(u8) { pub fn detect(allocator: std.mem.Allocator, home: []const u8) Status { if (!plat.herdrAvailable(home)) return .absent; - var path_buf: [768]u8 = undefined; - const path = std.fmt.bufPrint(&path_buf, "{s}/.config/herdr/plugins.json", .{home}) catch return .available; - const source = plat.readFileAlloc(allocator, path, 1024 * 1024) orelse return .available; + const source = plat.herdrPluginListAlloc(allocator, home) orelse return .available; defer allocator.free(source); return if (petdexPluginEnabled(allocator, source)) .connected else .available; } @@ -27,8 +25,12 @@ pub fn detect(allocator: std.mem.Allocator, home: []const u8) Status { fn petdexPluginEnabled(allocator: std.mem.Allocator, source: []const u8) bool { const parsed = std.json.parseFromSlice(std.json.Value, allocator, source, .{}) catch return false; defer parsed.deinit(); - if (parsed.value != .array) return false; - for (parsed.value.array.items) |entry| { + if (parsed.value != .object) return false; + const result = parsed.value.object.get("result") orelse return false; + if (result != .object) return false; + const plugins = result.object.get("plugins") orelse return false; + if (plugins != .array) return false; + for (plugins.array.items) |entry| { if (entry != .object) continue; const id = entry.object.get("plugin_id") orelse continue; if (id != .string or !std.mem.eql(u8, id.string, "dev.petdex.bridge")) continue; @@ -40,7 +42,8 @@ fn petdexPluginEnabled(allocator: std.mem.Allocator, source: []const u8) bool { test "Petdex Herdr plugin status follows its enabled field" { const allocator = std.testing.allocator; - try std.testing.expect(petdexPluginEnabled(allocator, "[{\"plugin_id\":\"dev.petdex.bridge\",\"enabled\":true}]")); - try std.testing.expect(!petdexPluginEnabled(allocator, "[{\"plugin_id\":\"dev.petdex.bridge\",\"enabled\":false}]")); - try std.testing.expect(!petdexPluginEnabled(allocator, "[{\"plugin_id\":\"other\",\"enabled\":true}]")); + try std.testing.expect(petdexPluginEnabled(allocator, "{\"result\":{\"plugins\":[{\"plugin_id\":\"dev.petdex.bridge\",\"enabled\":true,\"source\":{\"kind\":\"github\"}}]}}")); + try std.testing.expect(!petdexPluginEnabled(allocator, "{\"result\":{\"plugins\":[{\"plugin_id\":\"dev.petdex.bridge\",\"enabled\":false}]}}")); + try std.testing.expect(!petdexPluginEnabled(allocator, "{\"result\":{\"plugins\":[{\"plugin_id\":\"other\",\"enabled\":true}]}}")); + try std.testing.expect(!petdexPluginEnabled(allocator, "{\"result\":{\"plugins\":[]}}")); } diff --git a/packages/petdex-desktop-native/src/plat.zig b/packages/petdex-desktop-native/src/plat.zig index 02f3fb4a..ae7ccb76 100644 --- a/packages/petdex-desktop-native/src/plat.zig +++ b/packages/petdex-desktop-native/src/plat.zig @@ -492,6 +492,31 @@ fn spawnAndWait(argv: []const []const u8) bool { }; } +fn runHerdrPluginList(allocator: std.mem.Allocator, binary: []const u8) ?[]u8 { + var scope = Scope.init(); + defer scope.deinit(); + const result = std.process.run(allocator, scope.io(), .{ + .argv = &.{ binary, "plugin", "list", "--plugin", "dev.petdex.bridge", "--json" }, + .stdout_limit = .limited(1024 * 1024), + .stderr_limit = .limited(64 * 1024), + }) catch return null; + allocator.free(result.stderr); + if (result.term != .exited or result.term.exited != 0) { + allocator.free(result.stdout); + return null; + } + return result.stdout; +} + +pub fn herdrPluginListAlloc(allocator: std.mem.Allocator, home: []const u8) ?[]u8 { + if (runHerdrPluginList(allocator, "herdr")) |source| return source; + var local_buf: [768]u8 = undefined; + const local = std.fmt.bufPrint(&local_buf, "{s}/.local/bin/herdr", .{home}) catch return null; + if (runHerdrPluginList(allocator, local)) |source| return source; + if (runHerdrPluginList(allocator, "/opt/homebrew/bin/herdr")) |source| return source; + return runHerdrPluginList(allocator, "/usr/local/bin/herdr"); +} + pub fn herdrAvailable(home: []const u8) bool { if (spawnAndWait(&.{ "herdr", "--version" })) return true; var local_buf: [768]u8 = undefined; From 4cc4ba1e9aea9b754f1961cb6545ff1902c0111e Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 17:51:03 -0300 Subject: [PATCH 04/14] fix(desktop): wait for Herdr server readiness --- .../integrations/herdr/managed-smoke.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts index 51f68aed..d50a1313 100644 --- a/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts +++ b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts @@ -75,15 +75,19 @@ async function install(): Promise { async function waitForServer(): Promise { for (let attempt = 0; attempt < 80; attempt += 1) { - if (run(["status", "server"], true)) return true; + if (serverRunning()) return true; await Bun.sleep(100); } return false; } +function serverRunning(): boolean { + return /^status:\s+running$/m.test(run(["status", "server"], true)); +} + async function action(): Promise { let server: ReturnType | undefined; - if (!run(["status", "server"], true)) { + if (!serverRunning()) { server = Bun.spawn([herdr, "server"], { stderr: "pipe", stdout: "pipe", From eaf7a33bdef41e348d49ac7eb46caafd6e96235d Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 17:53:02 -0300 Subject: [PATCH 05/14] test(desktop): settle Herdr startup before smoke --- .../integrations/herdr/managed-smoke.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts index d50a1313..3f2873e1 100644 --- a/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts +++ b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts @@ -85,6 +85,31 @@ function serverRunning(): boolean { return /^status:\s+running$/m.test(run(["status", "server"], true)); } +async function waitForPluginIdle(): Promise { + let stable = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + const logs = parse( + run([ + "plugin", + "log", + "list", + "--plugin", + "dev.petdex.bridge", + "--limit", + "20", + ]), + ); + if (logs.result?.logs?.some((entry) => entry.status === "running")) { + stable = 0; + } else { + stable += 1; + if (stable === 2) return; + } + await Bun.sleep(100); + } + throw new Error("Herdr plugin startup did not settle"); +} + async function action(): Promise { let server: ReturnType | undefined; if (!serverRunning()) { @@ -95,6 +120,7 @@ async function action(): Promise { if (!(await waitForServer())) throw new Error("Herdr server did not start"); } try { + await waitForPluginIdle(); const invoked = parse( run([ "plugin", From 4b303efa759b0021dbddce4969cc8159c2042470 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 17:59:51 -0300 Subject: [PATCH 06/14] fix(desktop): serialize Herdr hook delivery --- .../integrations/herdr/bridge.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/petdex-desktop-native/integrations/herdr/bridge.ts b/packages/petdex-desktop-native/integrations/herdr/bridge.ts index e6b19bd1..4509197d 100644 --- a/packages/petdex-desktop-native/integrations/herdr/bridge.ts +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.ts @@ -197,17 +197,14 @@ export async function postUpdate( "content-type": "application/json", "x-petdex-update-token": token, }; - const requests = [ - fetcher("http://127.0.0.1:7777/bubble", { - method: "POST", - headers, - body: JSON.stringify(update.bubble), - signal: AbortSignal.timeout(500), - }), - postState(update.state, update.bubble.agent_source, token, fetcher), - ]; - const responses = await Promise.all(requests); - if (!responses[0].ok) throw new Error("Petdex rejected Herdr update"); + const bubble = await fetcher("http://127.0.0.1:7777/bubble", { + method: "POST", + headers, + body: JSON.stringify(update.bubble), + signal: AbortSignal.timeout(500), + }); + if (!bubble.ok) throw new Error("Petdex rejected Herdr update"); + await postState(update.state, update.bubble.agent_source, token, fetcher); } export async function postState( From e52771488107bbaa43a01381e6a1b35215e60102 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 18:02:50 -0300 Subject: [PATCH 07/14] fix(desktop): use portable Herdr loopback transport --- .../integrations/herdr/bridge.ts | 104 ++++++++++++++---- .../integrations/herdr/managed-smoke.ts | 6 +- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/packages/petdex-desktop-native/integrations/herdr/bridge.ts b/packages/petdex-desktop-native/integrations/herdr/bridge.ts index 4509197d..c928aaca 100644 --- a/packages/petdex-desktop-native/integrations/herdr/bridge.ts +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { mkdir, open, readFile, rm, stat } from "node:fs/promises"; +import { request } from "node:http"; import { join } from "node:path"; export type AgentStatus = "idle" | "working" | "blocked" | "done" | "unknown"; @@ -46,6 +47,12 @@ export type PetdexUpdate = { state: "idle" | "jumping" | "running" | "waiting"; }; +export type PetdexResponse = { + body: string; + ok: boolean; + status: number; +}; + const directPetdexAgents = new Set([ "claude", "claude-code", @@ -188,21 +195,77 @@ export function parseEvent(raw: string | undefined): HerdrEvent { } } +export function petdexRequest( + path: string, + options: { body?: string; method?: "GET" | "POST"; token?: string } = {}, +): Promise { + return new Promise((resolve, reject) => { + const body = options.body ?? ""; + const headers: Record = { + connection: "close", + }; + if (body) { + headers["content-length"] = Buffer.byteLength(body); + headers["content-type"] = "application/json"; + } + if (options.token) headers["x-petdex-update-token"] = options.token; + const req = request( + { + hostname: "127.0.0.1", + port: 7777, + path, + method: options.method ?? "GET", + headers, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.on("end", () => { + const status = response.statusCode ?? 0; + resolve({ + body: Buffer.concat(chunks).toString("utf8"), + ok: status >= 200 && status < 300, + status, + }); + }); + }, + ); + req.setTimeout(5_000, () => req.destroy(new Error("Petdex timed out"))); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +async function postJson( + path: string, + body: string, + token: string, + fetcher?: typeof fetch, +): Promise<{ ok: boolean }> { + if (!fetcher) return petdexRequest(path, { body, method: "POST", token }); + return fetcher(`http://127.0.0.1:7777${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-petdex-update-token": token, + }, + body, + signal: AbortSignal.timeout(5_000), + }); +} + export async function postUpdate( update: PetdexUpdate, token: string, - fetcher: typeof fetch = fetch, + fetcher?: typeof fetch, ): Promise { - const headers = { - "content-type": "application/json", - "x-petdex-update-token": token, - }; - const bubble = await fetcher("http://127.0.0.1:7777/bubble", { - method: "POST", - headers, - body: JSON.stringify(update.bubble), - signal: AbortSignal.timeout(500), - }); + const bubble = await postJson( + "/bubble", + JSON.stringify(update.bubble), + token, + fetcher, + ); if (!bubble.ok) throw new Error("Petdex rejected Herdr update"); await postState(update.state, update.bubble.agent_source, token, fetcher); } @@ -211,17 +274,14 @@ export async function postState( state: PetdexUpdate["state"], agentSource: string, token: string, - fetcher: typeof fetch = fetch, -): Promise { - const response = await fetcher("http://127.0.0.1:7777/state", { - method: "POST", - headers: { - "content-type": "application/json", - "x-petdex-update-token": token, - }, - body: JSON.stringify({ state, agent_source: agentSource }), - signal: AbortSignal.timeout(500), - }); + fetcher?: typeof fetch, +): Promise<{ ok: boolean }> { + const response = await postJson( + "/state", + JSON.stringify({ state, agent_source: agentSource }), + token, + fetcher, + ); if (!response.ok) throw new Error("Petdex rejected Herdr state"); return response; } diff --git a/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts index 3f2873e1..43ea08eb 100644 --- a/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts +++ b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts @@ -1,6 +1,8 @@ const decoder = new TextDecoder(); const herdr = process.env.HERDR_BIN_PATH || "herdr"; +import { petdexRequest } from "./bridge"; + type PluginList = { result?: { plugins?: Array<{ @@ -154,9 +156,9 @@ async function action(): Promise { throw new Error( finished?.stderr || `Herdr action did not succeed: ${finished?.status}`, ); - const response = await fetch("http://127.0.0.1:7777/state"); + const response = await petdexRequest("/state"); if (!response.ok) throw new Error("Petdex state endpoint is unavailable"); - const state = (await response.json()) as { state?: string }; + const state = JSON.parse(response.body) as { state?: string }; if (state.state !== "jumping") throw new Error(`Petdex did not receive Herdr action: ${state.state}`); } finally { From 603ae989d6c689037c351a7c2ec492617a81914a Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 18:06:35 -0300 Subject: [PATCH 08/14] test(desktop): isolate Herdr server on Windows --- .github/workflows/desktop-native-ci.yml | 22 +++++++++++++++++++ .../integrations/herdr/bridge.ts | 9 +++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-native-ci.yml b/.github/workflows/desktop-native-ci.yml index 84e1001a..32b48328 100644 --- a/.github/workflows/desktop-native-ci.yml +++ b/.github/workflows/desktop-native-ci.yml @@ -509,12 +509,34 @@ jobs: $tokenPath = "$env:USERPROFILE\.petdex\runtime\update-token" if (Test-Path $tokenPath) { $token = Get-Content $tokenPath -Raw + $herdrServer = Start-Process -FilePath (Get-Command herdr).Source ` + -ArgumentList "server" -RedirectStandardOutput herdr.log ` + -RedirectStandardError herdr.err -PassThru + $herdrReady = $false + for ($attempt = 0; $attempt -lt 80; $attempt++) { + if ((herdr status server) -match "status:\s+running") { + $herdrReady = $true + break + } + Start-Sleep -Milliseconds 100 + } + if (-not $herdrReady) { + Get-Content herdr.log -ErrorAction SilentlyContinue + Get-Content herdr.err -ErrorAction SilentlyContinue + throw "Herdr server did not start" + } bun run integrations/herdr/managed-smoke.ts action + $bridgeExit = $LASTEXITCODE + "=== Herdr plugin logs ===" + herdr plugin log list --plugin dev.petdex.bridge --limit 20 "=== hook round trip ===" Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:7777/state" ` -Headers @{ "x-petdex-update-token" = $token } ` -ContentType "application/json" -Body '{"state":"running"}' | ConvertTo-Json -Compress Invoke-RestMethod -Uri "http://127.0.0.1:7777/state" | ConvertTo-Json -Compress + herdr server stop + $herdrServer.WaitForExit(10000) | Out-Null + if ($bridgeExit -ne 0) { throw "managed Herdr action failed" } } else { "NO TOKEN at $tokenPath" } diff --git a/packages/petdex-desktop-native/integrations/herdr/bridge.ts b/packages/petdex-desktop-native/integrations/herdr/bridge.ts index c928aaca..cd11f5d4 100644 --- a/packages/petdex-desktop-native/integrations/herdr/bridge.ts +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.ts @@ -243,7 +243,14 @@ async function postJson( token: string, fetcher?: typeof fetch, ): Promise<{ ok: boolean }> { - if (!fetcher) return petdexRequest(path, { body, method: "POST", token }); + if (!fetcher) { + try { + return await petdexRequest(path, { body, method: "POST", token }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Petdex ${path} request failed: ${message}`); + } + } return fetcher(`http://127.0.0.1:7777${path}`, { method: "POST", headers: { From bd535a641ef0c2a8b9c7810f69df1f965ab040a3 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 18:13:09 -0300 Subject: [PATCH 09/14] fix(desktop): keep hook sockets on listener IO --- .../petdex-desktop-native/src/hook_server.zig | 30 ++----------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/packages/petdex-desktop-native/src/hook_server.zig b/packages/petdex-desktop-native/src/hook_server.zig index 4355f437..3b057a14 100644 --- a/packages/petdex-desktop-native/src/hook_server.zig +++ b/packages/petdex-desktop-native/src/hook_server.zig @@ -27,7 +27,6 @@ const Conn = struct { }; pub const max_pending = 50; -const max_active_connections: u32 = 64; const max_request_bytes: usize = 8192; const connection_timeout_ms: i64 = 5_000; @@ -297,7 +296,6 @@ const Server = struct { mirror_lock: BlockingMutex = .{}, last_state_mirror: u64 = 0, last_bubble_mirror: u64 = 0, - active_connections: std.atomic.Value(u32) = .init(0), // Token-bucket limiter, sidecar budget: 30/s shared by state+bubble. bucket: f64 = 30, bucket_stamp_ms: i64 = 0, @@ -373,34 +371,12 @@ fn run(server: *Server) void { while (true) { const stream = listener.accept(io) catch continue; - const active = server.active_connections.fetchAdd(1, .acq_rel); - if (active >= max_active_connections) { - _ = server.active_connections.fetchSub(1, .release); - stream.close(io); - continue; - } - // A client can disappear after sending only part of a request. Keep - // that blocking read off the accept loop so later hooks still reach - // the server while the abandoned connection drains or closes. - const thread = std.Thread.spawn(.{}, handleConnectionThread, .{ server, stream }) catch { - _ = server.active_connections.fetchSub(1, .release); - stream.close(io); - continue; - }; - thread.detach(); + var conn: Conn = .{ .stream = stream, .io = io }; + handleConnection(server, &conn); + stream.close(io); } } -fn handleConnectionThread(server: *Server, stream: std.Io.net.Stream) void { - defer _ = server.active_connections.fetchSub(1, .release); - var scope = plat.Scope.init(); - defer scope.deinit(); - const io = scope.io(); - var conn: Conn = .{ .stream = stream, .io = io }; - handleConnection(server, &conn); - stream.close(io); -} - fn handleConnection(server: *Server, conn: *Conn) void { var buf: [max_request_bytes]u8 = undefined; var total: usize = 0; From 46c0daba22fabb021071c48639b1588a5372afae Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 18:18:51 -0300 Subject: [PATCH 10/14] fix(desktop): gracefully close hook responses --- .../petdex-desktop-native/src/hook_server.zig | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/petdex-desktop-native/src/hook_server.zig b/packages/petdex-desktop-native/src/hook_server.zig index 3b057a14..c63651fb 100644 --- a/packages/petdex-desktop-native/src/hook_server.zig +++ b/packages/petdex-desktop-native/src/hook_server.zig @@ -27,6 +27,7 @@ const Conn = struct { }; pub const max_pending = 50; +const max_active_connections: u32 = 64; const max_request_bytes: usize = 8192; const connection_timeout_ms: i64 = 5_000; @@ -296,6 +297,7 @@ const Server = struct { mirror_lock: BlockingMutex = .{}, last_state_mirror: u64 = 0, last_bubble_mirror: u64 = 0, + active_connections: std.atomic.Value(u32) = .init(0), // Token-bucket limiter, sidecar budget: 30/s shared by state+bubble. bucket: f64 = 30, bucket_stamp_ms: i64 = 0, @@ -371,12 +373,35 @@ fn run(server: *Server) void { while (true) { const stream = listener.accept(io) catch continue; - var conn: Conn = .{ .stream = stream, .io = io }; - handleConnection(server, &conn); - stream.close(io); + const active = server.active_connections.fetchAdd(1, .acq_rel); + if (active >= max_active_connections) { + _ = server.active_connections.fetchSub(1, .release); + stream.close(io); + continue; + } + // A client can disappear after sending only part of a request. Keep + // that blocking read off the accept loop so later hooks still reach + // the server while the abandoned connection drains or closes. + const thread = std.Thread.spawn(.{}, handleConnectionThread, .{ server, stream }) catch { + _ = server.active_connections.fetchSub(1, .release); + stream.close(io); + continue; + }; + thread.detach(); } } +fn handleConnectionThread(server: *Server, stream: std.Io.net.Stream) void { + defer _ = server.active_connections.fetchSub(1, .release); + var scope = plat.Scope.init(); + defer scope.deinit(); + const io = scope.io(); + var conn: Conn = .{ .stream = stream, .io = io }; + handleConnection(server, &conn); + stream.shutdown(io, .send) catch {}; + stream.close(io); +} + fn handleConnection(server: *Server, conn: *Conn) void { var buf: [max_request_bytes]u8 = undefined; var total: usize = 0; From b87d0131e2b34863d3064c381f70c51757a682fd Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 18:25:13 -0300 Subject: [PATCH 11/14] fix(desktop): read Windows hook sockets portably --- .../petdex-desktop-native/src/hook_server.zig | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/petdex-desktop-native/src/hook_server.zig b/packages/petdex-desktop-native/src/hook_server.zig index c63651fb..5fe8ac10 100644 --- a/packages/petdex-desktop-native/src/hook_server.zig +++ b/packages/petdex-desktop-native/src/hook_server.zig @@ -16,6 +16,7 @@ //! once per event, nothing keeps sockets open. const std = @import("std"); +const builtin = @import("builtin"); const plat = @import("plat.zig"); /// One connection, plus the Io that owns it. Everything downstream of @@ -418,7 +419,10 @@ fn handleConnection(server: *Server, conn: *Conn) void { respond(conn, 413, "{\"ok\":false,\"error\":\"headers_too_large\"}"); return; } - const got = receiveWithTimeout(conn, buf[total..], timeout) catch return; + const got = receiveWithTimeout(conn, buf[total..], timeout) catch |err| { + std.debug.print("petdex: hook receive failed ({s})\n", .{@errorName(err)}); + return; + }; if (got == 0) return; total += got; header_end = if (std.mem.indexOf(u8, buf[0..total], "\r\n\r\n")) |at| at + 4 else null; @@ -436,7 +440,10 @@ fn handleConnection(server: *Server, conn: *Conn) void { } const request_len = head_len + content_length; while (total < request_len) { - const got = receiveWithTimeout(conn, buf[total..request_len], timeout) catch return; + const got = receiveWithTimeout(conn, buf[total..request_len], timeout) catch |err| { + std.debug.print("petdex: hook body receive failed ({s})\n", .{@errorName(err)}); + return; + }; if (got == 0) return; total += got; } @@ -453,7 +460,10 @@ fn handleConnection(server: *Server, conn: *Conn) void { } fn receiveWithTimeout(conn: *Conn, buffer: []u8, timeout: std.Io.Timeout) !usize { - const message = try conn.stream.socket.receiveTimeout(conn.io, buffer, timeout); + const message = if (builtin.os.tag == .windows) + try conn.stream.socket.receive(conn.io, buffer) + else + try conn.stream.socket.receiveTimeout(conn.io, buffer, timeout); return message.data.len; } From 7f0dcc5e5cb6360c891d78b248db63832caf30e0 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 18:31:07 -0300 Subject: [PATCH 12/14] fix(desktop): flush Windows hook responses --- .github/workflows/desktop-native-ci.yml | 6 ++++++ .../petdex-desktop-native/src/hook_server.zig | 15 +++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-native-ci.yml b/.github/workflows/desktop-native-ci.yml index 32b48328..b97a7bb7 100644 --- a/.github/workflows/desktop-native-ci.yml +++ b/.github/workflows/desktop-native-ci.yml @@ -265,6 +265,12 @@ jobs: APP_PID=$! sleep 10 kill -0 "$APP_PID" || { echo "app died"; cat run.log; exit 1; } + for _ in $(seq 1 60); do + test -s ~/.petdex/runtime/update-token && break + kill -0 "$APP_PID" || { echo "app died"; cat run.log; exit 1; } + sleep 1 + done + test -s ~/.petdex/runtime/update-token || { cat run.log; exit 1; } TOKEN=$(cat ~/.petdex/runtime/update-token) bun run integrations/herdr/managed-smoke.ts action curl -fsS -X POST -H "x-petdex-update-token: $TOKEN" \ diff --git a/packages/petdex-desktop-native/src/hook_server.zig b/packages/petdex-desktop-native/src/hook_server.zig index 5fe8ac10..3b903f1f 100644 --- a/packages/petdex-desktop-native/src/hook_server.zig +++ b/packages/petdex-desktop-native/src/hook_server.zig @@ -400,6 +400,13 @@ fn handleConnectionThread(server: *Server, stream: std.Io.net.Stream) void { var conn: Conn = .{ .stream = stream, .io = io }; handleConnection(server, &conn); stream.shutdown(io, .send) catch {}; + if (builtin.os.tag == .windows) { + var drain: [1]u8 = undefined; + while (true) { + const message = stream.socket.receive(io, &drain) catch break; + if (message.data.len == 0) break; + } + } stream.close(io); } @@ -597,14 +604,10 @@ fn respond(conn: *Conn, status: u16, body: []const u8) void { else => "OK", }; const head = std.fmt.bufPrint(&buf, "HTTP/1.1 {d} {s}\r\ncontent-type: application/json\r\ncontent-length: {d}\r\nconnection: close\r\n\r\n", .{ status, reason, body.len }) catch return; - writeAll(conn, head); - writeAll(conn, body); -} - -fn writeAll(conn: *Conn, bytes: []const u8) void { var write_buf: [64]u8 = undefined; var writer = conn.stream.writer(conn.io, &write_buf); - writer.interface.writeAll(bytes) catch return; + writer.interface.writeAll(head) catch return; + writer.interface.writeAll(body) catch return; writer.interface.flush() catch return; } From bbe801868776d98af71ad2f3c1d3b7f020215ad7 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 18:37:26 -0300 Subject: [PATCH 13/14] test(desktop): expose Windows hook diagnostics --- .github/workflows/desktop-native-ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/desktop-native-ci.yml b/.github/workflows/desktop-native-ci.yml index b97a7bb7..d45ae137 100644 --- a/.github/workflows/desktop-native-ci.yml +++ b/.github/workflows/desktop-native-ci.yml @@ -515,6 +515,10 @@ jobs: $tokenPath = "$env:USERPROFILE\.petdex\runtime\update-token" if (Test-Path $tokenPath) { $token = Get-Content $tokenPath -Raw + "=== pre-Herdr health probe ===" + Invoke-RestMethod -Uri "http://127.0.0.1:7777/health" | ConvertTo-Json -Compress + "=== pre-Herdr stderr ===" + Get-Content run.err -ErrorAction SilentlyContinue $herdrServer = Start-Process -FilePath (Get-Command herdr).Source ` -ArgumentList "server" -RedirectStandardOutput herdr.log ` -RedirectStandardError herdr.err -PassThru @@ -540,6 +544,13 @@ jobs: -Headers @{ "x-petdex-update-token" = $token } ` -ContentType "application/json" -Body '{"state":"running"}' | ConvertTo-Json -Compress Invoke-RestMethod -Uri "http://127.0.0.1:7777/state" | ConvertTo-Json -Compress + "=== runtime mirrors after requests ===" + Get-Content "$env:USERPROFILE\.petdex\runtime\state.json" -ErrorAction SilentlyContinue + Get-Content "$env:USERPROFILE\.petdex\runtime\bubble.json" -ErrorAction SilentlyContinue + "=== stderr after requests ===" + Get-Content run.err -ErrorAction SilentlyContinue + "=== process after requests ===" + if ($app.HasExited) { "EXITED, code $($app.ExitCode)" } else { "alive, pid $($app.Id)" } herdr server stop $herdrServer.WaitForExit(10000) | Out-Null if ($bridgeExit -ne 0) { throw "managed Herdr action failed" } From 9c334fa3fb851f925ad309c1b649c3abfbda1573 Mon Sep 17 00:00:00 2001 From: Railly Date: Thu, 13 Aug 2026 18:39:36 -0300 Subject: [PATCH 14/14] fix(desktop): read Windows hook streams correctly --- packages/petdex-desktop-native/src/hook_server.zig | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/petdex-desktop-native/src/hook_server.zig b/packages/petdex-desktop-native/src/hook_server.zig index 3b903f1f..16cc101e 100644 --- a/packages/petdex-desktop-native/src/hook_server.zig +++ b/packages/petdex-desktop-native/src/hook_server.zig @@ -467,11 +467,15 @@ fn handleConnection(server: *Server, conn: *Conn) void { } fn receiveWithTimeout(conn: *Conn, buffer: []u8, timeout: std.Io.Timeout) !usize { - const message = if (builtin.os.tag == .windows) - try conn.stream.socket.receive(conn.io, buffer) - else - try conn.stream.socket.receiveTimeout(conn.io, buffer, timeout); - return message.data.len; + if (builtin.os.tag == .windows) { + var reader = conn.stream.reader(conn.io, &.{}); + var data = [_][]u8{buffer}; + return reader.interface.readVec(&data) catch |err| switch (err) { + error.EndOfStream => 0, + error.ReadFailed => return reader.err orelse error.Unexpected, + }; + } + return (try conn.stream.socket.receiveTimeout(conn.io, buffer, timeout)).data.len; } fn route(server: *Server, conn: *Conn, method: []const u8, path: []const u8, head: []const u8, body: []const u8) void {