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.
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.
Detection and install
- One
Qoderrow 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:.currentonly 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.currentwithout duplicating entries in the root that was already done. 1c. A root whosesettings.jsoncould 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. - Each root honours only its own overrides —
QODER_CONFIG_DIR/QODER_CLI_HOMEfor 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. - Install writes all six events into
<root>/settings.jsonwith"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 onebubble <phase> qoderper event. - Install writes no
hooksConfig, nodisableAllHooks, and no key outsidehooks. - A malformed or unreadable
settings.jsonis refused byte-for-byte unchanged, matching the existinginstallJsonHooks refuses malformed configstest. - Disconnect removes only Petdex-owned entries and leaves a mixed hook group's foreign commands intact.
Runtime behaviour
stateForEvent("tool-failure", …)returns"failed"in bothhook_runner.zigandbubble-runner.ts.formatBubbleon atool-failurepayload withtool_name: "Bash"renders exactlyBash failedin both implementations; withtool_nameabsent, exactlyTool failed.- No bubble string introduced by this change contains an ASCII
"or\. - The
tool-failure/statebody carries"duration":1220; every other phase's/statebody is byte-identical to today's. busyistruefortool-failure.- 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
- The Qoder row renders its glyph from image slot 16, the registry's last free
one; the bubble avatar resolves
agent_source: "qoder"toqoder.pngviaagentArtBytesrather than falling back. page.tsx's works-with row renders five agents including Qoder, backed by a genuine vectorqoder.svg(see Decision 7 — the placeholder the first draft planned is no longer needed).- 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,:1054and theires/zhcounterparts. No README change: neitherREADME.mdnorpackages/petdex-cli/README.mdenumerates 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
zig test agent_hooks.zigandzig test hook_runner.zigpass, with new tests covering AC 1-8 and 10-12. Existing counts (18 and 9) only grow.- From
packages/petdex-cli:bun run build && bun run typecheckandbun testpass, with TS tests mirroring the Zig cases in AC 7, 8, 10. - From the root:
bun run check,bun run i18n:checkandbun run buildpass. - Work lands on
feat/qoder-agentin the main checkout.
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→~/.qoder→QODERCN_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 theCLAUDE_CONFIG_DIRwork in #601: separate installs with separate accounts deserve separate control. Rejected after seeing it on screen — two rows readingQoderandQoder CNis 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:Disconnectbeing all-or-nothing is what a single switch means, not a defect, and the tri-stateHookStatusgets 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
dwellForto fall back to each duration state's intrinsic animation length whenduration_ms == 0. The honest fix, and it repairswaving/jumping/reviewtoo. 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
durationon thetool-failure/statePOST only.hook_server.zig:313-314already parses and clampsduration, 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.
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
QoderandQoder CNstacked 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
HookStatusvariant for the mixed case - Decision: A) —
worseStatusfolds.noneover.nodeover.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 aHookStatusvariant, 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.jsonhooks 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:~/.qoderis 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_idsandagent_artare 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
AgentKindand the boolean was the only difference. Once one row had to iterate every root, the boolean turned intoif (cn) A else Brepeated in the two resolvers plus the status fold, install and uninstall. Aqoder_rootstable 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.
classifyConfigreports an unparseable config as.none, whileinstallJsonHooksrefuses 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.qoderActionablePathstherefore filters roots through the existingcanInstallJsonHookspredicate, 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.pushdeduplicates by path. Only an env misconfiguration can collapse them (*_CONFIG_DIRpointed at one directory), and writing twice is harmless, but counting twice is not.
4b. Which environment overrides does the resolver honour?
- Options: A)
*_CONFIG_DIRonly, mirroring the Claude precedent · B)*_CONFIG_DIRplus*_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_DIRfor Claude. That reasoning does not transfer: qoder'sgetConfigHomeDir()(paths.ts:92-115) falls back topath.join(homedir(), GLOBAL_CONFIG_DIR), and itshomedir()(paths.ts:73-79) honoursQODER_CLI_HOME/QODERCN_CLI_HOMEbeforeos.homedir(). A user with*_CLI_HOMEset 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 oneorelsein each resolver.*_CONFIG_DIR_NAMEstays out of scope: it renames the leaf directory only, is far rarer, and unlike*_CLI_HOMEits 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-878plus the truthiness check atpaths.ts:102), matchingagent_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.jsonStringstops at the first"or\and decodes neither, which is the truncation class PR #628 exists to fix.errorstrings routinely carry both.tool_nameis 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
isInterrupttofirePostToolUseFailureEvent(hookEventHandler.ts:471,:485), so the field is never emitted today, not merely rare. Cancellations are separately filtered upstream (isCancelledErroron the throw path,!ctx.signal.abortedon the error-result path). Handling it would mean adding a bool reader tohook_runner.zig, which has onlyjsonStringandjsonNumberPubtoday, 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/qodercliholds only raster.icoframes; its.svgfiles 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.svgis the published vector with coordinates rounded to one decimal (39257 → 11534 bytes, ~4.4 KB gzipped; the source carries full float precision like147.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.pngis 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:xlinkdropped, 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 withsips -s format png -Z 40. - The two
.icofiles are byte-identical (shasum -a 256→ac9546562920a4488cfa1b5dd9292658344549b640bda785f9213492a24ddbe1).
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. - Vector source: the published 180×180 Qoder mark
(
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.svgis a tile filled#fffandgemini.svga tile filled with a chromatic gradient, whileclaude-code.svg(#D97757) andopencode.svg(#CFCECD) are bare glyphs. Every existing tile is light or chromatic; none is near-black. Measured rather than eyeballed, a#111113tile breaks in both themes at once. Against the dark.surfacergb8(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.surfacergb8(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 andagent_art's identical{light, dark}pair stays truthful. Composited onto both.surfacecolours and inspected before adopting. The web keeps the official tile:page.tsx:169renders at 16px withopacity-80, and codex and gemini already put tiles there.
8. busy for tool-failure?
- Options: A)
true, likepost· B)false, likestop - 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 == .nodeis unreachable for these kinds in practice. Install is the consistent answer and needs no comment;continuewould 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.qoderCliPetentries - Decision: A) —
scripts/i18n-check.tscompares 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.
UserPromptSubmit→user-prompt→jumpingPreToolUse→pre→reviewfor Read/Grep/Glob, elserunningPostToolUse→post→idlePostToolUseFailure→tool-failure→failed(new)Notification→notification→waitingStop→stop→waving
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.
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.
- A matcher-less entry matches every invocation —
hookPlanner.ts:146-147,// No matcher means match all. Petdex writes entries with nomatcher(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. Stopcarrieslast_assistant_message(types.ts:1127-1133), whichhook_runner.zig:77already 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_namealways precedes the free-texterror. All three tool events build{...base, tool_use_id, tool_name, tool_input, error, …}(hookEventHandler.ts:221-224,:259-263,:474-478), andhook_server.jsonStringtakes the first match for a key. The new phase therefore does not widen the key-collision risk that the flat scan already carries.
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:23—displayNameagent_hooks.zig:32—hookAgentNameagent_hooks.zig:242—scan, config-directory armagent_hooks.zig:250—scan, settings-file armagent_hooks.zig:657—migrateLegacyHooksagent_hooks.zig:673—uninstallmain.zig:1557—install_agentdispatch
Four [agent_count]… array literals, which fail to compile when the initializer
count no longer matches:
agent_hooks.zig:234—scan'svar outmain.zig:248— theagentsModel initializermain.zig:996—agent_icon_idsmain.zig:1007—agent_art
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.
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 pathconst 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.
// 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.
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— theagentsModel 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.:996—agent_icon_idsbecomes{ 9, 10, 11, 15, 16 }, taking the registry's last free slot.:1007—agent_artgains one entry,@embedFile("assets/agents/qoder.png").:1557— theinstall_agentdispatch gains.qoder.:2633— snapshot all four environment variables frominit.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.
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:
BubblePhasegains"failed"(keeping the existingBubbleEventunion shape rather than inventing a fourthkind).eventFromArgsmaps thetool-failurephase to{ kind: "tool", phase: "failed", toolName }, passingtoolNamethrough asnullwhen absent. This is a trap: the existing code substitutes the literal lowercase"tool"whentool_nameis missing (bubble-runner.ts:361-371), so routing the new phase through the current path would rendertool failedin TS againstTool failedin Zig — a direct AC8 failure and a parity break. The"tool"substitution stays in place for therunning/donephases, which depend on it.formatBubblereturns${toolName} failedahead of the canonical-kind switch, and"Tool failed"whentoolNameis falsy;paststaysfalsefor"failed".stateForEventreturns"failed"; the/statebody is built by the extractedstateBodyhelper above.
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.
packages/petdex-desktop-native/src/agent_hooks.zig— oneAgentKindvariant, theqoder_rootstable and its resolvers,qoderActionablePaths/worseStatus/qoderStatus,qoder_events,installQoder/uninstallQoder, scan/migrate/uninstall branches, new testspackages/petdex-desktop-native/src/hook_runner.zig—tool-failurephase instateForEvent/formatBubble/busy, new exported purestateBodybuilder replacing the inline/statebufPrint, parity testspackages/petdex-desktop-native/src/main.zig— agents initializer, icon ids, agent art, install dispatch, env snapshotspackages/petdex-desktop-native/src/assets/agents/qoder.png— new, 40×40packages/petdex-cli/src/hooks/bubble-templates.ts—"failed"phase and templatepackages/petdex-cli/src/hooks/bubble-templates.test.ts— mirrored template testspackages/petdex-cli/src/hooks/bubble-runner.ts—stateForEvent,eventFromArgsnull-toolName path, exportedstateBodytwinpackages/petdex-cli/src/hooks/bubble-runner.test.ts— mirrored runner testspublic/brand/agents/qoder.svg— new, true vector at one-decimal precisionsrc/app/[locale]/page.tsx— works-with row gains Qodersrc/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).
- [AC1, AC2]
zig test agent_hooks.zig— fixture homes with~/.qoderonly,~/.qoder-cnonly, both, and neither; plusQODER_CONFIG_DIR/QODERCN_CONFIG_DIRredirect tests asserting cross-kind isolation. - [AC3, AC4] Fixture-home install test asserting six events,
"timeout": 2, surviving foreign keys,.pre-petdex-backuppresent, idempotence viastd.mem.count(… "bubble pre qoder") == 1, and absence ofhooksConfig. - [AC5] Malformed-config test mirroring
installJsonHooks refuses malformed configs. - [AC6] Mixed-hook-group test mirroring
installClaude removes only its command from a mixed hook group. - [AC7, AC8, AC11]
zig test hook_runner.zigandbun test—stateForEventandformatBubblecases in both implementations, asserting the exact strings. - [AC9] Grep the new template literals for
"and\; assert in the Zig round-trip test that the rendered body surviveshook_server's reader. - [AC10, AC12] Round-trip assertions on the
/statebody. The body builder is factored into a purestateBody(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-failuremust render{"state":"failed","duration":1220,"agent_source":"qoder"}, andpre/post/stop/notificationmust render{"state":"<s>","agent_source":"<a>"}with nodurationkey at all. The non-failure assertion is the AC12 regression guard — it fails if anyone ever reintroduces a second format string. - [AC13] Manual: launch the app with
~/.qoderpresent, confirm the row and glyph render in both themes; probepetdex-hook bubble tool-failure qoderagainst the live sidecar and confirmstate == "failed"and the avatar resolves. - [AC2] Manual, and the only backstop for the unenforced touchpoint: launch with
QODER_CONFIG_DIRpointed at a scratch directory, press Install on theQoderrow, and assert the hooks land in that directory and that~/.qoder/settings.jsonis untouched. Repeat withQODERCN_CONFIG_DIR, which moves the other root behind the same row. Nothing in the compiler or the Zig suite catches a missingmain.zig:2633snapshot — 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. - [AC14, AC15]
bun run build,bun run i18n:check, visual check of the works-with row. - [AC16-AC18]
zig test agent_hooks.zig,zig test hook_runner.zig,bun run build && bun run typecheckandbun testinpackages/petdex-cli,bun run check/bun run i18n:check/bun run buildat the root. - [AC19]
git log --oneline main..feat/qoder-agent.
dwellForintrinsic durations. Duration states currently fall back tomin_dwell_ms(250ms) when the POST carries no explicit duration, sowaving,jumpingandreviewall 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.qoderCliPetand friends) — maintainer's call.