Skip to content

Instantly share code, notes, and snippets.

@sorrycc
Created August 1, 2026 02:35
Show Gist options
  • Select an option

  • Save sorrycc/837b51198f135d7a0438feb25e4712ff to your computer and use it in GitHub Desktop.

Select an option

Save sorrycc/837b51198f135d7a0438feb25e4712ff to your computer and use it in GitHub Desktop.
Petdex: Qoder agent support — design doc (crafter-station/petdex PR #629)

Qoder agent support

1. Background

Petdex connects to four coding agents today: Claude Code, Codex, Gemini CLI, and opencode. Each one gets its hooks written from the desktop app's Settings → Agents panel, and every hook invocation drives the floating pet's animation state and speech bubble through the in-binary runner at packages/petdex-desktop-native/src/hook_runner.zig.

Qoder (~/Projects/qodercli, npm @qodercn-ai/qoderclicn) ships a hook system that is structurally identical to Claude Code's — same settings.json shape, same event names for the five events Petdex already rides, and the same tool names (Read / Grep / Glob / Bash / Write / Edit / WebSearch / WebFetch). Its subagent tool is Agent, which formatBubble already matches alongside task. That makes it the cheapest agent Petdex has ever added: the bubble templates need zero changes for existing phases.

It also carries one event Claude Code lacks — PostToolUseFailure. Petdex's sprite sheet has had a failed row since v1 (row 5, 8 frames, 1220ms) that no agent has ever lit, because no wired agent reports tool failure. Wiring Qoder's failure event is the first time that artwork does anything.

2. Requirements Summary

Add Qoder as a first-class agent: detection, one-click install/disconnect, hook-driven pet reactions, and a new tool-failure phase that lights the failed row.

The same settings.json hooks drive both the Qoder IDE and the Qoder CLI, so this is one agent named Qoder, not Qoder CLI.

It ships as two independent builds that can coexist on one machine — a global build rooted at ~/.qoder and a CN build rooted at ~/.qoder-cn. isCN is baked in at build time (brand.ts:99), and each build reads its own brand-prefixed environment override (brand.ts:133, :152): the global build honours QODER_CONFIG_DIR, the CN build honours QODERCN_CONFIG_DIR. Both values are complete root paths, semantically equal to CLAUDE_CONFIG_DIR. Both roots sit behind one Settings row: Install covers every root present, Disconnect clears every root present, and a root that is partly connected reads as not installed until one press completes it.

Install writes nothing outside the hooks object. Qoder's hooksConfig.enabled already defaults to true (packages/cli/src/config/settingsSchema.ts), so writing it — as Petdex does for Gemini via enable_hooks_config = true — could only ever clobber a deliberate user opt-out.

3. Acceptance Criteria

Detection and install

  1. One Qoder row appears when any root is present, and none when no root is. One Install writes every present root; one Disconnect clears every present root. 1b. The row folds the roots worst-first: .current only when every actionable root is connected, otherwise the state of whichever root still needs a press. From a partly connected machine, a single Install reaches .current without duplicating entries in the root that was already done. 1c. A root whose settings.json could never be merged into — unreadable, not JSON, wrong shape — is excluded from the row rather than counted as unhooked, so the button can never become a no-op. That config stays byte-for-byte untouched. 1d. Two roots that resolve to the same path count and are written once.
  2. Each root honours only its own overrides — QODER_CONFIG_DIR / QODER_CLI_HOME for the global root, QODERCN_* for the CN one — including when the corresponding default dir does not exist. Unset or empty falls back to the default. No variable can move another root.
  3. Install writes all six events into <root>/settings.json with "timeout": 2, preserves foreign top-level keys and foreign hook entries, writes a one-time .pre-petdex-backup, and is idempotent — a second run leaves exactly one bubble <phase> qoder per event.
  4. Install writes no hooksConfig, no disableAllHooks, and no key outside hooks.
  5. A malformed or unreadable settings.json is refused byte-for-byte unchanged, matching the existing installJsonHooks refuses malformed configs test.
  6. Disconnect removes only Petdex-owned entries and leaves a mixed hook group's foreign commands intact.

Runtime behaviour

  1. stateForEvent("tool-failure", …) returns "failed" in both hook_runner.zig and bubble-runner.ts.
  2. formatBubble on a tool-failure payload with tool_name: "Bash" renders exactly Bash failed in both implementations; with tool_name absent, exactly Tool failed.
  3. No bubble string introduced by this change contains an ASCII " or \.
  4. The tool-failure /state body carries "duration":1220; every other phase's /state body is byte-identical to today's.
  5. busy is true for tool-failure.
  6. Existing phases produce byte-identical bubble text, state and POST bodies for Claude Code, Codex, Gemini and opencode — no regression outside the new phase.

Presentation

  1. The Qoder row renders its glyph from image slot 16, the registry's last free one; the bubble avatar resolves agent_source: "qoder" to qoder.png via agentArtBytes rather than falling back.
  2. page.tsx's works-with row renders five agents including Qoder, backed by a genuine vector qoder.svg (see Decision 7 — the placeholder the first draft planned is no longer needed).
  3. The agent-enumerating i18n prose names Qoder alongside the existing agents, with no new i18n keys. The enumerating strings are en.json:14, :128, :636, :1042, :1054 and their es / zh counterparts. No README change: neither README.md nor packages/petdex-cli/README.md enumerates agents at all — a case-insensitive grep for claude, gemini and opencode returns zero hits in both. There is no list to append to, and inventing one is out of scope for this change.

Verification

  1. zig test agent_hooks.zig and zig test hook_runner.zig pass, with new tests covering AC 1-8 and 10-12. Existing counts (18 and 9) only grow.
  2. From packages/petdex-cli: bun run build && bun run typecheck and bun test pass, with TS tests mirroring the Zig cases in AC 7, 8, 10.
  3. From the root: bun run check, bun run i18n:check and bun run build pass.
  4. Work lands on feat/qoder-agent in the main checkout.

4. Problem Analysis

The interesting problem is not "how do we add an agent" — installJsonHooks was built for exactly this shape and takes the event list, agent token, timeout and feature-flag toggle as parameters. It is the two questions the existing four agents never raised.

Two config roots for one product.

  • Approach A — one AgentKind, resolve by precedence (QODER_CONFIG_DIR~/.qoderQODERCN_CONFIG_DIR~/.qoder-cn). Smallest diff, one Settings row. Rejected: on a machine with both builds installed it silently serves one and leaves the other dead, with no UI affordance explaining why.
  • Approach B — two AgentKinds, each owning its root and its own environment override. Tried first, on the argument that produced the CLAUDE_CONFIG_DIR work in #601: separate installs with separate accounts deserve separate control. Rejected after seeing it on screen — two rows reading Qoder and Qoder CN is noise for one product, and the independence they buy is independence nobody asked for.
  • Chosen — one AgentKind, install into every root present. One row, one switch, no silent gap. The two objections that first steered this to B do not survive contact: Disconnect being all-or-nothing is what a single switch means, not a defect, and the tri-state HookStatus gets an honest value for the mixed case by folding worst-first (Decision 1b).

Lighting a duration state for its full length.

failed is a transient state — main.zig:1233 isDurationState — so it reverts to idle once its dwell expires. dwellFor(.failed, 0) returns min_dwell_ms (250ms) against a 1220ms animation, so roughly two of eight frames would play.

  • Approach A — fix dwellFor to fall back to each duration state's intrinsic animation length when duration_ms == 0. The honest fix, and it repairs waving / jumping / review too. Rejected for this branch: it silently stretches Claude Code's and Codex's Stop wave from 250ms to 1120ms — a visible behaviour change for every shipped user, in a PR nominally about adding an agent. Recorded as a follow-up.
  • Chosen — carry an explicit duration on the tool-failure /state POST only. hook_server.zig:313-314 already parses and clamps duration, so this is purely additive and every other phase's request body stays byte-identical.

Second-order effect of the chosen approach. dwellFor(.failed, 1220) returns 1220 (main.zig:1266-1270), and the poll loop drains hook_server.mailbox only once dwell_over (:1864-1876). So for those 1220ms any following events queue into the 50-slot mailbox (hook_server.zig:29) instead of displaying. This is the intended trade — a failure the user cannot see is worse than a brief lag — but it is the first time Petdex holds a state for ~1.2s on a mid-turn event; waving only ever fires at Stop, where nothing follows. It is a second argument for the dwellFor follow-up, which would make every duration state honest at once rather than special-casing this one.

5. Decision Log

1. One AgentKind or two?

  • Options: A) one with precedence resolution · B) two kinds · C) one writing to every present root
  • Decision: C)revised after the two-row build was run. The first implementation shipped B, on the #601 argument that separate installs deserve separate control. Seeing Qoder and Qoder CN stacked in Settings settled it the other way: two rows for one product is noise, and nobody wants to manage the builds separately. A stays rejected for the original reason — it silently serves one root and leaves the other dead, which is exactly the machine this work was reported from.

1b. What does the single row show when one root is connected and another is not?

  • Options: A) worst-first, so any unconnected root reads as not installed · B) best-first, connected if any root is · C) a new HookStatus variant for the mixed case
  • Decision: A)worseStatus folds .none over .node over .current, so whichever root still needs a press decides the button, and one press completes every root. B would report Connected while a build sits dead with no affordance to fix it. C is the precise answer but costs a HookStatus variant, a caption branch, and a new case in every agent's status handling, for a state that one click resolves. The cost of A is real and accepted: a user connected in one root only sees "Hooks not installed" until they press Install once.

2. Naming: Qoder or Qoder CLI?

  • Options: A) Qoder CLI · B) Qoder
  • Decision: B) — the same settings.json hooks drive both the Qoder IDE and the Qoder CLI, so naming the row after the CLI undersells what connecting it does. This is also why the row covers roots rather than executables: ~/.qoder is the product's config home, not any one binary's.

3. Where does the new enum variant go?

  • Options: A) appended after opencode · B) inserted alphabetically
  • Decision: A)agent_icon_ids and agent_art are arrays indexed by @intFromEnum. Appending keeps indices 0-3 (and thus icon ids 9/10/11/15) untouched; inserting would silently re-map every existing agent's glyph.

4. How are the qoder roots resolved in Zig?

  • Options: A) duplicated function pairs per root · B) one pair taking a cn: bool · C) a table of root descriptors
  • Decision: C)revised when the rows collapsed. B was right while each root had its own AgentKind and the boolean was the only difference. Once one row had to iterate every root, the boolean turned into if (cn) A else B repeated in the two resolvers plus the status fold, install and uninstall. A qoder_roots table of {leaf, config_dir, cli_home} makes all five the same loop and makes a third Qoder brand one more entry instead of another arm in four places. Claude's helpers stay untouched either way.

4c. What happens to a root whose settings.json cannot be merged into?

  • Options: A) count it as .none, like any unhooked root · B) exclude it from the row entirely
  • Decision: B) — collapsing to one row introduced a failure mode two rows did not have. classifyConfig reports an unparseable config as .none, while installJsonHooks refuses to touch it and returns false. Folded worst-first, a single broken root would peg the row at "Hooks not installed" behind a button that can never succeed, with nothing on screen explaining why — with two rows the user could at least see which install was broken. qoderActionablePaths therefore filters roots through the existing canInstallJsonHooks predicate, and a root we would refuse to write is dropped rather than counted. The row then tracks the healthy roots and the button always works. The accepted cost is that a broken config produces no signal at all; surfacing it would need the caption work deferred with Decision 1b's option C.

4d. Two roots resolving to the same path

  • QoderPaths.push deduplicates by path. Only an env misconfiguration can collapse them (*_CONFIG_DIR pointed at one directory), and writing twice is harmless, but counting twice is not.

4b. Which environment overrides does the resolver honour?

  • Options: A) *_CONFIG_DIR only, mirroring the Claude precedent · B) *_CONFIG_DIR plus *_CLI_HOME · C) all three, including *_CONFIG_DIR_NAME
  • Decision: B)revised in Phase 4 review. The original draft scoped this to A on the grounds that Petdex handles only CLAUDE_CONFIG_DIR for Claude. That reasoning does not transfer: qoder's getConfigHomeDir() (paths.ts:92-115) falls back to path.join(homedir(), GLOBAL_CONFIG_DIR), and its homedir() (paths.ts:73-79) honours QODER_CLI_HOME / QODERCN_CLI_HOME before os.homedir(). A user with *_CLI_HOME set would get "Not detected", or an install written where their qoder never reads — the exact silent-no-op failure that got Approach A rejected in Decision 1. Cost is two more snapshot globals and one orelse in each resolver. *_CONFIG_DIR_NAME stays out of scope: it renames the leaf directory only, is far rarer, and unlike *_CLI_HOME its absence degrades to a visible "Not detected" rather than a wrong-root write. Confirmed compatible with the existing empty-string convention: qoder treats an empty env value as unset (brand.ts:871-878 plus the truthiness check at paths.ts:102), matching agent_hooks.zig:70-73.

5. What does the tool-failure bubble say?

  • Options: A) <Tool> failed · B) <Tool> failed: <clipped error> · C) a fixed string
  • Decision: A)hook_server.jsonString stops at the first " or \ and decodes neither, which is the truncation class PR #628 exists to fix. error strings routinely carry both. tool_name is a controlled identifier from qodercli's own registry, so A satisfies AC9 structurally rather than by careful wording. C throws away the one useful fact we have for free.

6. Is is_interrupt handled?

  • Options: A) ignore it · B) suppress bubble and state when true
  • Decision: A) — stronger than YAGNI: no caller anywhere passes isInterrupt to firePostToolUseFailureEvent (hookEventHandler.ts:471, :485), so the field is never emitted today, not merely rare. Cancellations are separately filtered upstream (isCancelledError on the throw path, !ctx.signal.aborted on the error-result path). Handling it would mean adding a bool reader to hook_runner.zig, which has only jsonString and jsonNumberPub today, to read a key that never arrives.

7. Where does the artwork come from?

  • Options: A) the .ico's raster frames · B) a base64 PNG wrapped in an SVG · C) the published vector

  • Decision: C)revised after the original draft. The first draft chose B and labelled it a placeholder, on the stated premise that no vector source existed (~/Projects/qodercli holds only raster .ico frames; its .svg files are Ink terminal-render test snapshots). That premise was wrong — the published Qoder mark is a true vector, 180×180 viewBox, real paths. There is no placeholder in this design any more. The two surfaces are deliberately decoupled — an earlier revision of this entry proposed one source of truth for both, and that was wrong; see Decision 7b. public/brand/agents/qoder.svg is the published vector with coordinates rounded to one decimal (39257 → 11534 bytes, ~4.4 KB gzipped; the source carries full float precision like 147.59971253967285, and 0.1 units of a 180 viewBox is 0.02 px even at the 40 px render, far below anything visible). assets/agents/qoder.png is instead the bare glyph, 40×40 RGBA at 1754 B, in family with claude-code's 478 B and codex's 1213 B.

    Provenance, so the byte counts and renders are reproducible rather than asserted:

    • Vector source: the published 180×180 Qoder mark (https://img.alicdn.com/imgextra/i3/O1CN01KliT1u1jEq947NlKH_!!6000000004517-55-tps-180-180.svg), image/svg+xml, 39257 B, genuine paths. Rounded with a regex over -?\d+\.\d+ at one decimal, xmlns:xlink dropped, inter-tag whitespace collapsed, and the root element given the siblings' height="1em" / width="1em" / <title> shape.
    • Raster source: ~/Projects/qodercli/assets/qodercli.ico, whose largest frame is a 256×256 PNG, downscaled with sips -s format png -Z 40.
    • The two .ico files are byte-identical (shasum -a 256ac9546562920a4488cfa1b5dd9292658344549b640bda785f9213492a24ddbe1).

    Both files carry a third party's trademark, exactly as the four existing agent glyphs do; they identify the agent for interoperability and Petdex claims no rights in them. The SVG is served publicly at petdex.dev/brand/agents/qoder.svg, so this is worth a maintainer's eye even though it matches existing practice.

7b. Does the desktop PNG inherit the web SVG's treatment?

  • Options: A) yes, one artifact for both surfaces · B) no, official tile on the web and bare glyph on the desktop · C) tile everywhere, with a light/dark variant pair
  • Decision: B)added in Phase 4 review, correcting Decision 7's original "one source of truth serves both surfaces". The published mark is a dark tile (#111113, rx=40). The first draft accepted it for both surfaces on the grounds that "the existing set is already mixed" between tiles and bare glyphs. That is true but lands on the wrong axis: reading the four existing fills, codex.svg is a tile filled #fff and gemini.svg a tile filled with a chromatic gradient, while claude-code.svg (#D97757) and opencode.svg (#CFCECD) are bare glyphs. Every existing tile is light or chromatic; none is near-black. Measured rather than eyeballed, a #111113 tile breaks in both themes at once. Against the dark .surface rgb8(25,25,28) (main.zig:287) it computes to 1.08:1 — the WCAG floor for a non-text UI boundary is 3:1, so the silhouette is effectively invisible and the tile treatment simply does not render. Against the light .surface rgb8(255,255,255) (:296) it is 18.86:1, a near-black block far heavier than claude-code's glyph at 3.12:1 sitting in the same list. Invisible in one theme, dominant in the other, in a row of icons meant to read as peers. AgentArt { light, dark } (main.zig:1006) exists for exactly this, and opencode is the standing precedent — it ships two PNGs because one did not work in both themes. Option C would follow that path honestly but costs a second render and contradicts Decision 2's single-glyph framing. B is cheaper and better: the .ico's bare glyph is two greens with a fully transparent lens (sampled: RGBA (0,0,0,0) at the lens centre, (39,189,81,255) and (42,219,92,255) on the two lobes), so one asset genuinely works on both surfaces and agent_art's identical {light, dark} pair stays truthful. Composited onto both .surface colours and inspected before adopting. The web keeps the official tile: page.tsx:169 renders at 16px with opacity-80, and codex and gemini already put tiles there.

8. busy for tool-failure?

  • Options: A) true, like post · B) false, like stop
  • Decision: A) — a failed tool does not end the turn; the agent reacts to the error and keeps working, so the bubble should keep its spinner.

9. migrateLegacyHooks behaviour for the new kinds?

  • Options: A) call install · B) continue, like opencode
  • Decision: A) — the legacy CLI runner never wrote qoder hooks, so status == .node is unreachable for these kinds in practice. Install is the consistent answer and needs no comment; continue would need one explaining a special case that does not exist.

10. Scope of the i18n change?

  • Options: A) values only, no new keys · B) values plus new keywords.qoderCliPet entries
  • Decision: A)scripts/i18n-check.ts compares keys only, never values, so editing prose is CI-safe in all three locales. New SEO keyword entries are a maintainer's call, not a side effect of an agent integration.

6. Design

Event mapping

  • UserPromptSubmituser-promptjumping
  • PreToolUseprereview for Read/Grep/Glob, else running
  • PostToolUsepostidle
  • PostToolUseFailuretool-failurefailed (new)
  • Notificationnotificationwaiting
  • Stopstopwaving

Installed with timeout = 2 (seconds, as Claude — Gemini's 2000 is milliseconds) and enable_hooks_config = false.

Deliberately not wired: SubagentStart / SubagentStop (concurrent subagents fight over one global pet state), PermissionRequest (duplicates Notification), StopFailure, SessionStart.

Why failed survives long enough to be seen

coreToolHookTriggers.ts:288-290 fires PostToolUse only under if (hookSystem && !toolResult.error), under the comment "Only fire PostToolUse for successful tool calls; failed calls already triggered PostToolUseFailure above." The two events are mutually exclusive per tool call, so no trailing idle stomps the failed state. The whole feature rests on this.

Three qoder facts this design depends on

  • A matcher-less entry matches every invocationhookPlanner.ts:146-147, // No matcher means match all. Petdex writes entries with no matcher (agent_hooks.zig:378-382), so this is load-bearing: it is what makes the install fire at all, and it is worth stating because nothing in the written config hints at it.
  • Stop carries last_assistant_message (types.ts:1127-1133), which hook_runner.zig:77 already prefers over a transcript-tail read. Qoder gets Codex-quality close-of-turn previews for zero work — recorded here so nobody later "adds" the transcript fallback Claude Code needs.
  • tool_name always precedes the free-text error. All three tool events build {...base, tool_use_id, tool_name, tool_input, error, …} (hookEventHandler.ts:221-224, :259-263, :474-478), and hook_server.jsonString takes the first match for a key. The new phase therefore does not widen the key-collision risk that the flat scan already carries.

Touchpoint inventory

Ten sites break the build until they are updated, which is the reason Decision 1 is affordable at all. One further site has no compiler backstop — see "The one unenforced touchpoint" below. Nothing in this ten-item inventory relies on a human remembering it.

Seven exhaustive switch sites (no else arm anywhere):

  • agent_hooks.zig:23displayName
  • agent_hooks.zig:32hookAgentName
  • agent_hooks.zig:242scan, config-directory arm
  • agent_hooks.zig:250scan, settings-file arm
  • agent_hooks.zig:657migrateLegacyHooks
  • agent_hooks.zig:673uninstall
  • main.zig:1557install_agent dispatch

Four [agent_count]… array literals, which fail to compile when the initializer count no longer matches:

  • agent_hooks.zig:234scan's var out
  • main.zig:248 — the agents Model initializer
  • main.zig:996agent_icon_ids
  • main.zig:1007agent_art

The one unenforced touchpoint

main.zig:2633 is a plain assignment:

agent_hooks.env_claude_config_dir = init.environ_map.get("CLAUDE_CONFIG_DIR");

Forgetting to snapshot QODER_CONFIG_DIR / QODERCN_CONFIG_DIR compiles clean and keeps every Zig test green, because the tests set the globals directly — exactly as the existing CLAUDE_CONFIG_DIR test does at agent_hooks.zig:797. The new globals would simply stay null, qoderConfigDir would silently fall back to ~/.qoder, and the only symptom is an install writing to a root the user's qoder never reads.

This is simultaneously the sole main.zig touchpoint with no compiler backstop and the sole thing AC2 depends on end-to-end. It gets two mitigations: the env snapshot is added in the same edit as the two globals are declared, and §8 step 8 promotes it to an explicit manual probe (launch with QODER_CONFIG_DIR pointed at a scratch dir and assert the install lands there, not in ~/.qoder) rather than leaving it to the general "confirm the row renders" check.

agent_hooks.zig

AgentKind gains one variant, qoder, appended after opencode; agent_count 4→5. Display name Qoder, hook token "qoder".

Four snapshot globals mirroring env_claude_config_dir, wired into a table so every root-walking operation is the same loop (Decision 4):

pub var env_qoder_config_dir: ?[]const u8 = null;     // QODER_CONFIG_DIR
pub var env_qoder_cn_config_dir: ?[]const u8 = null;  // QODERCN_CONFIG_DIR
pub var env_qoder_cli_home: ?[]const u8 = null;       // QODER_CLI_HOME
pub var env_qoder_cn_cli_home: ?[]const u8 = null;    // QODERCN_CLI_HOME

const QoderRoot = struct { leaf: []const u8, config_dir: *const ?[]const u8, cli_home: *const ?[]const u8 };
const qoder_roots = [_]QoderRoot{
    .{ .leaf = ".qoder",    .config_dir = &env_qoder_config_dir,    .cli_home = &env_qoder_cli_home },
    .{ .leaf = ".qoder-cn", .config_dir = &env_qoder_cn_config_dir, .cli_home = &env_qoder_cn_cli_home },
};

Each entry reads only its own globals, so no variable can move another root (AC2). Empty-string handling matches Claude's: set-but-blank falls back to the default dir.

The single seam every operation goes through:

/// Roots whose build is installed AND whose config we could actually merge into.
fn qoderActionablePaths(allocator, home) QoderPaths   // dirExists + canInstallJsonHooks + dedup
fn worseStatus(a, b) HookStatus                        // .none over .node over .current
fn qoderStatus(allocator, home) HookStatus             // fold, or .absent when no root is actionable
pub fn installQoder(allocator, home) bool              // every actionable path, all must succeed
fn uninstallQoder(allocator, home) bool                // every actionable path
const qoder_events = [_]HookEvent{
    .{ .event = "UserPromptSubmit",   .phase = "user-prompt" },
    .{ .event = "PreToolUse",         .phase = "pre" },
    .{ .event = "PostToolUse",        .phase = "post" },
    .{ .event = "PostToolUseFailure", .phase = "tool-failure" },
    .{ .event = "Notification",       .phase = "notification" },
    .{ .event = "Stop",               .phase = "stop" },
};

scan cannot express a multi-root agent in its single-dir/single-config loop, so qoder is resolved before it — out[@intFromEnum(.qoder)].status = qoderStatus(…) — and both switch arms continue rather than unreachable, so a refactor that drops that line degrades to "not detected" instead of panicking in release. migrateLegacyHooks and uninstall each gain one arm. scan's own var out: [agent_count]AgentInfo literal grows to five entries alongside the Model initializer in main.zig.

hook_runner.zig

// stateForEvent
if (std.mem.eql(u8, phase, "tool-failure")) return "failed";

// formatBubble — before the running/done split
if (std.mem.eql(u8, phase, "tool-failure")) {
    const tool = jsonString(payload, "tool_name") orelse return "Tool failed";
    return fmt2(out, clipRaw(tool, 28), " failed");
}

busy gains tool-failure. The /state body becomes duration-aware while leaving every other phase's bytes untouched.

The obvious shape — two bufPrint calls behind an if/else, one with duration and one without — is rejected. It makes AC12 depend on a human keeping two format strings in sync forever, and a later edit to only one arm would break every existing agent's request body with nothing to catch it.

Worse, the body is currently built inline inside run() (hook_runner.zig:96-117), which drains stdin through plat.readStdin, reads ~/.petdex/runtime/update-token, and spawns threads POSTing to 127.0.0.1:7777. zig test hook_runner.zig can only reach pure functions — which is exactly why all nine existing tests are pure. The TS side has the same problem: bubble-runner.ts:486-490 sits inside runBubble, and the only test that touches it (bubble-runner.test.ts:175-216) writes the killswitch file first, so it returns before any POST. There is no test seam, so AC12 as originally specified was unverifiable.

Both problems are solved by extracting one pure, exported builder with a single format string and an optional fragment:

pub fn stateBody(out: []u8, state: []const u8, duration_ms: u32, agent: []const u8) ?[]const u8 {
    var dur_buf: [24]u8 = undefined;
    const dur: []const u8 = if (duration_ms > 0)
        (std.fmt.bufPrint(&dur_buf, ",\"duration\":{d}", .{duration_ms}) catch return null)
    else
        "";
    return std.fmt.bufPrint(out, "{{\"state\":\"{s}\"{s},\"agent_source\":\"{s}\"}}", .{ state, dur, agent }) catch null;
}

duration_ms == 0 provably renders today's bytes, so AC12 stops being a review obligation and becomes a one-line expectEqualStrings. It takes duration_ms rather than phase so the builder stays free of phase vocabulary and the caller owns the mapping: failed_duration_ms = 1220, a named constant carrying failed's durationMs from src/lib/pet-states.ts:67.

The TS twin is likewise extracted and exported, using single-path construction so key order matches the Zig port exactly — state, then optional duration, then agent_source. The natural edit (const body = { state, agent_source }; if (…) body.duration = …) would emit duration last, and since JSON.stringify preserves insertion order the two "line-for-line ports" would serialize different bytes for the same event:

export function stateBody(state: string, durationMs: number, agentSource: string | null) {
  const body: Record<string, unknown> = { state };
  if (durationMs > 0) body.duration = durationMs;
  body.agent_source = agentSource;
  return body;
}

Key order is irrelevant to the sidecar — jsonString and jsonNumber scan by key, not position — but a byte-identity AC that constrains only one side is half an AC, and the mirrored tests could not see the divergence.

main.zig

Five touchpoints, four of them compiler-enforced: the switch (kind) site is exhaustive with no else, and every agent-indexed array is declared [agent_hooks.agent_count]… with a literal initializer, so growing agent_count turns any un-updated literal into a compile error rather than a silent gap. The only two places that index by enum position — main.zig:1074 (agent_art[@intFromEnum(kind)]) and :2261 (agent_icon_ids[@intFromEnum(info.kind)]) — are therefore safe as long as the new variant is appended (Decision 3). The fifth, :2633, is the unenforced one above — and the only one, now that the single row needs no duplicate-slot guard.

  • :248 — the agents Model initializer, currently four explicit entries, grows to five. Worth calling out as the easiest line to miss when reading the diff, even though the compiler will not let it through.
  • :996agent_icon_ids becomes { 9, 10, 11, 15, 16 }, taking the registry's last free slot.
  • :1007agent_art gains one entry, @embedFile("assets/agents/qoder.png").
  • :1557 — the install_agent dispatch gains .qoder.
  • :2633 — snapshot all four environment variables from init.environ_map.

agentStatusCaption needs no qoder branch: the default arm ("Hooks not installed" / "Hooks outdated (CLI runner)" / "Connected") is already correct for a JSON-hook agent.

CLI parity (packages/petdex-cli)

hook_runner.zig is a line-for-line port of bubble-runner.ts + bubble-templates.ts, held in parity by mirrored tests. The CLI never installs qoder hooks, but the parity contract is structural, so the TS side changes too:

  • BubblePhase gains "failed" (keeping the existing BubbleEvent union shape rather than inventing a fourth kind).
  • eventFromArgs maps the tool-failure phase to { kind: "tool", phase: "failed", toolName }, passing toolName through as null when absent. This is a trap: the existing code substitutes the literal lowercase "tool" when tool_name is missing (bubble-runner.ts:361-371), so routing the new phase through the current path would render tool failed in TS against Tool failed in Zig — a direct AC8 failure and a parity break. The "tool" substitution stays in place for the running / done phases, which depend on it.
  • formatBubble returns ${toolName} failed ahead of the canonical-kind switch, and "Tool failed" when toolName is falsy; past stays false for "failed".
  • stateForEvent returns "failed"; the /state body is built by the extracted stateBody helper above.

Assets

  • packages/petdex-desktop-native/src/assets/agents/qoder.png — 40×40 RGBA bare glyph on transparent, downscaled from the .ico's 256px frame. Not a render of the SVG below: see Decision 7b for why the two surfaces are decoupled.
  • public/brand/agents/qoder.svg — the published 180×180 vector, coordinates rounded to one decimal. A genuine vector, not a placeholder.

7. Files Changed

  • packages/petdex-desktop-native/src/agent_hooks.zig — one AgentKind variant, the qoder_roots table and its resolvers, qoderActionablePaths / worseStatus / qoderStatus, qoder_events, installQoder / uninstallQoder, scan/migrate/uninstall branches, new tests
  • packages/petdex-desktop-native/src/hook_runner.zigtool-failure phase in stateForEvent / formatBubble / busy, new exported pure stateBody builder replacing the inline /state bufPrint, parity tests
  • packages/petdex-desktop-native/src/main.zig — agents initializer, icon ids, agent art, install dispatch, env snapshots
  • packages/petdex-desktop-native/src/assets/agents/qoder.png — new, 40×40
  • packages/petdex-cli/src/hooks/bubble-templates.ts"failed" phase and template
  • packages/petdex-cli/src/hooks/bubble-templates.test.ts — mirrored template tests
  • packages/petdex-cli/src/hooks/bubble-runner.tsstateForEvent, eventFromArgs null-toolName path, exported stateBody twin
  • packages/petdex-cli/src/hooks/bubble-runner.test.ts — mirrored runner tests
  • public/brand/agents/qoder.svg — new, true vector at one-decimal precision
  • src/app/[locale]/page.tsx — works-with row gains Qoder
  • src/i18n/messages/{en,es,zh}.json — agent-enumerating prose, values only

Not changed, contrary to the first draft: README.md and packages/petdex-cli/README.md — neither enumerates agents (see AC15).

8. Verification

  1. [AC1, AC2] zig test agent_hooks.zig — fixture homes with ~/.qoder only, ~/.qoder-cn only, both, and neither; plus QODER_CONFIG_DIR / QODERCN_CONFIG_DIR redirect tests asserting cross-kind isolation.
  2. [AC3, AC4] Fixture-home install test asserting six events, "timeout": 2, surviving foreign keys, .pre-petdex-backup present, idempotence via std.mem.count(… "bubble pre qoder") == 1, and absence of hooksConfig.
  3. [AC5] Malformed-config test mirroring installJsonHooks refuses malformed configs.
  4. [AC6] Mixed-hook-group test mirroring installClaude removes only its command from a mixed hook group.
  5. [AC7, AC8, AC11] zig test hook_runner.zig and bun teststateForEvent and formatBubble cases in both implementations, asserting the exact strings.
  6. [AC9] Grep the new template literals for " and \; assert in the Zig round-trip test that the rendered body survives hook_server's reader.
  7. [AC10, AC12] Round-trip assertions on the /state body. The body builder is factored into a pure stateBody(out: []u8, state: []const u8, duration_ms: u32, agent: []const u8) ?[]const u8 (and its TS twin) so the tests assert rendered bytes directly instead of going through a socket: tool-failure must render {"state":"failed","duration":1220,"agent_source":"qoder"}, and pre / post / stop / notification must render {"state":"<s>","agent_source":"<a>"} with no duration key at all. The non-failure assertion is the AC12 regression guard — it fails if anyone ever reintroduces a second format string.
  8. [AC13] Manual: launch the app with ~/.qoder present, confirm the row and glyph render in both themes; probe petdex-hook bubble tool-failure qoder against the live sidecar and confirm state == "failed" and the avatar resolves.
  9. [AC2] Manual, and the only backstop for the unenforced touchpoint: launch with QODER_CONFIG_DIR pointed at a scratch directory, press Install on the Qoder row, and assert the hooks land in that directory and that ~/.qoder/settings.json is untouched. Repeat with QODERCN_CONFIG_DIR, which moves the other root behind the same row. Nothing in the compiler or the Zig suite catches a missing main.zig:2633 snapshot — the tests set the globals directly — so this probe is the only thing standing between a dropped line and an install written where qoder never reads.
  10. [AC14, AC15] bun run build, bun run i18n:check, visual check of the works-with row.
  11. [AC16-AC18] zig test agent_hooks.zig, zig test hook_runner.zig, bun run build && bun run typecheck and bun test in packages/petdex-cli, bun run check / bun run i18n:check / bun run build at the root.
  12. [AC19] git log --oneline main..feat/qoder-agent.

9. Follow-ups (not in this branch)

  • dwellFor intrinsic durations. Duration states currently fall back to min_dwell_ms (250ms) when the POST carries no explicit duration, so waving, jumping and review all play a fraction of their animation for every agent. Fixing it properly means changing shipped behaviour for Claude Code and Codex and belongs in its own PR.
  • New i18n SEO keyword entries (keywords.qoderCliPet and friends) — maintainer's call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment