diff --git a/.github/workflows/desktop-native-ci.yml b/.github/workflows/desktop-native-ci.yml index a074d53b..d45ae137 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 @@ -219,7 +265,14 @@ 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" \ -H "Content-Type: application/json" -d '{"state":"running"}' \ http://127.0.0.1:7777/state @@ -462,11 +515,45 @@ 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 + $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 + "=== 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" } } else { "NO TOKEN at $tokenPath" } 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..1e38f2c1 --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/README.md @@ -0,0 +1,65 @@ +# 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. + +## 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 +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. + +## 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. + +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. + +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..5186189a --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.test.ts @@ -0,0 +1,224 @@ +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, + type HerdrAgent, + type HerdrEvent, + herdrAgents, + parseCliAgents, + postState, + postUpdate, + reconcileEvent, + safePaneId, + safeText, + shouldBridge, + statusState, + updateFromEvent, + withBridgeLock, +} 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("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(""); + 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", + }); + }); + + 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 new file mode 100644 index 00000000..cd11f5d4 --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/bridge.ts @@ -0,0 +1,471 @@ +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"; + +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"; +}; + +export type PetdexResponse = { + body: string; + ok: boolean; + status: number; +}; + +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 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) { + 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: { + "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, +): Promise { + 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); +} + +export async function postState( + state: PetdexUpdate["state"], + agentSource: string, + token: string, + 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; +} + +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 { + 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 reconciled = reconcileEvent(event, agents); + const update = updateFromEvent( + reconciled.event, + reconciled.info, + reconciled.aggregate, + 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); + await postState(aggregate, "herdr", secret); + 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 withBridgeLock(() => snapshot(config)); + if (mode === "test") return testBridge(); + const eventName = process.env.HERDR_PLUGIN_EVENT; + if (eventName && eventName !== "pane.agent_status_changed") return; + return withBridgeLock(() => + 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..721447a8 --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/herdr-plugin.toml @@ -0,0 +1,18 @@ +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" +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/integrations/herdr/managed-smoke.ts b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts new file mode 100644 index 00000000..43ea08eb --- /dev/null +++ b/packages/petdex-desktop-native/integrations/herdr/managed-smoke.ts @@ -0,0 +1,175 @@ +const decoder = new TextDecoder(); +const herdr = process.env.HERDR_BIN_PATH || "herdr"; + +import { petdexRequest } from "./bridge"; + +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 (serverRunning()) return true; + await Bun.sleep(100); + } + return false; +} + +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()) { + server = Bun.spawn([herdr, "server"], { + stderr: "pipe", + stdout: "pipe", + }); + if (!(await waitForServer())) throw new Error("Herdr server did not start"); + } + try { + await waitForPluginIdle(); + 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 petdexRequest("/state"); + if (!response.ok) throw new Error("Petdex state endpoint is unavailable"); + 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 { + 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/assets/agents/herdr.png b/packages/petdex-desktop-native/src/assets/agents/herdr.png new file mode 100644 index 00000000..90800334 Binary files /dev/null and b/packages/petdex-desktop-native/src/assets/agents/herdr.png differ diff --git a/packages/petdex-desktop-native/src/herdr_status.zig b/packages/petdex-desktop-native/src/herdr_status.zig new file mode 100644 index 00000000..3b083a90 --- /dev/null +++ b/packages/petdex-desktop-native/src/herdr_status.zig @@ -0,0 +1,49 @@ +const std = @import("std"); +const plat = @import("plat.zig"); + +pub const Status = enum(u8) { + absent, + available, + connected, + + pub fn caption(self: Status) []const u8 { + return switch (self) { + .absent => "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; + const source = plat.herdrPluginListAlloc(allocator, home) 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 != .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; + 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, "{\"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/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..16cc101e 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 @@ -58,6 +59,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 +73,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 +177,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 +220,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; @@ -390,6 +399,14 @@ fn handleConnectionThread(server: *Server, stream: std.Io.net.Stream) void { const io = scope.io(); 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); } @@ -409,7 +426,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; @@ -427,7 +447,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; } @@ -444,8 +467,15 @@ 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); - 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 { @@ -524,13 +554,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); @@ -577,14 +608,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; } @@ -810,6 +837,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..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), @@ -1090,7 +1092,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 +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]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,8 +1132,11 @@ 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/herdr.png"), .dark = @embedFile("assets/agents/herdr.png") }, + .{ .light = @embedFile("assets/agents/fallback.png"), .dark = @embedFile("assets/agents/fallback.png") }, }; -const agent_fallback_art: []const u8 = @embedFile("assets/agents/fallback.png"); +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 @@ -1254,23 +1259,26 @@ 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 (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; } -/// 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 { +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; } @@ -1672,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. @@ -1884,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: @@ -2245,7 +2260,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 +2499,33 @@ 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; +} + +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 @@ -2565,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 @@ -2686,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 @@ -2702,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; } @@ -2723,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; } @@ -2939,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)) { @@ -2949,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 .{ @@ -3444,9 +3496,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 +3507,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 +3555,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, }), }; @@ -3540,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)) @@ -3828,7 +3878,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 +3965,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 +3973,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 +4011,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 + 2, 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(herdr_icon_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" { @@ -4329,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); @@ -4354,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" { @@ -4433,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" { @@ -4720,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); @@ -4746,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 }, @@ -4830,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)); @@ -4840,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. @@ -4986,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); } @@ -5013,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 @@ -5023,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. @@ -5037,19 +5112,18 @@ 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)); } } -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" { @@ -5116,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 d07db746..ae7ccb76 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,50 @@ 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; + 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; + 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; 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" {