Skip to content

v0.0.2: agent ergonomics - #2

Merged
hhff merged 20 commits into
mainfrom
v0.0.2-ergonomics
Aug 18, 2026
Merged

v0.0.2: agent ergonomics#2
hhff merged 20 commits into
mainfrom
v0.0.2-ergonomics

Conversation

@hhff

@hhff hhff commented Aug 18, 2026

Copy link
Copy Markdown
Member

Field feedback from a design agent building a replica surfaced six ergonomic gaps (the mirror's fidelity was never the problem). This PR fixes all six plus a friendlier README:

  1. Socket control plane — CLI reads become clients of a running serve over <root>/serve.sock; the store-locked error becomes nearly unreachable.
  2. figmog dump / figmog_subtree — full-fidelity subtree export with --depth and --fields projection (one call instead of 204).
  3. --under <id> subtree scoping on text/find/search/where.
  4. Sticky pull --geometry — vector path data, per-mirror stickiness via a new (append-only) MirrorConfig record.
  5. figmog images / figmog_images — node renders + image fills, version-cached in a new images sink; explicit-only budget spend.
  6. --resolve-vars — design-token names joined onto bound values at read time.
  7. README rewrite — leads with the positioning: a superset of the Figma MCP surface for high-performance design agents; depth moves to docs/SPEC.md.

Spec: docs/specs/2026-08-17-v0.0.2-ergonomics.md. Task commits land on this branch with per-task adversarial reviews; final whole-PR review before merge; v0.0.2 pre-release tagged from main.

🤖 Generated with Claude Code

hhff and others added 20 commits August 17, 2026 21:34
…eometry, images, resolve-vars, README overhaul)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 of the v0.0.2 ergonomics plan (docs/specs/2026-08-17-v0.0.2-ergonomics.md
§2, §3, §6):

- query::subtree (`figmog dump` / figmog_subtree): full raw JSON,
  children nested recursively in child-index order, depth-bounded,
  fields-projected (id/name/type/children always survive). Registry
  19 -> 20 tools.
- query::{text,find,search,where_} gain `under: Option<String>`: a
  cycle-safe BFS scope over the children index, composing with `page`
  by intersection. CLI --under flags + matching tool args.
- resolve_vars annotation (query::node/subtree/styles): joins
  boundVariables binding sites against the variables table, emitting
  {variable_id, variable_name, values_by_mode} or
  {variable_id, source: "unresolved"} under a resolved_variables key,
  never mutating raw data.

Folded in mid-task per coordinator scope addition (spec §2b, commit
6881cd7): every node-id-shaped argument (id/under/target, and the
matching CLI flags) now accepts a full Figma URL via
ident::parse_node_ref, not just a bare id. figmog serve's multi-file
session routing infers the target mirror from a URL's file key when
`file` is omitted (explicit `file` still wins on disagreement).

Updated every tool-count assertion the registry addition touches
(enumerated in the task report). 156 tests green; clippy/fmt/doc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, full table collapsed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alization

Task review fix round (3 Important, 2 bundled minors):

- I1: query::search truncated to `limit` before applying `--under`'s
  scope filter, so a tight limit under a scope containing a real but
  globally-outranked match returned []. Bm25Reader::search already
  scores every matching doc internally regardless of `limit` (only its
  final sort truncates), so a scoped call now requests an effectively
  unlimited `limit`, filters to scope, then truncates — unscoped calls
  keep the direct top-k path.
- I2: `--page` wasn't routed through the URL-aware normalize_node_ref
  (find/text/where_ used normalize_node_id, bare-id only). Fixed at all
  three call sites.
- I3: the §2b explicit-file/URL-file mismatch note (serve.rs) compared
  raw `file` argument text against an already-normalized URL-derived
  key, false-positiving when the explicit file arg was itself a URL
  naming the same file in different spelling. Now compares normalized
  keys (parse_file_ref), falling back to raw text only when it can't
  parse at all.
- dispatch.rs's module doc comment still said "16 read-only tools"
  (missed in the first pass); now 17.
- New children_cycle_does_not_hang_under_scoping test: a hand-upserted
  mutual-parent pair (same construction as the existing parent-cycle
  test) derives a children-multimap cycle too; proves scope_ids's BFS
  terminates.

165 tests green; clippy/fmt/doc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ther, not both)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uary.computer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
figmog serve now also listens on <figmog-root>/serve.sock (spec §1):
a listener thread accepts connections and forwards newline-delimited
JSON-RPC frames into the existing single-threaded request loop, tagged
with a connection id so responses route back to the right client.
Startup does a stale-socket probe (connect refused ⇒ unlink+rebind,
connect succeeds ⇒ clean "another figmog serve owns this root" error)
and the socket file is unlinked on clean exit. --no-socket disables
listening.

Every CLI read command, `tools`, and `call` now try that socket first
(when --db wasn't used) and become clients of the running serve —
always-fresh answers, no store-lock contention, and `figmog call
figmog_sync` now reaches the *owning* process instead of failing
against its own lock. Unreachable or --no-socket falls back to the
pre-existing direct store open, unchanged. pull's lock-error message
now points at that escape hatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on-blocking response writes

Review fix round (2 Critical + 1 Important + bundled minors):

- C1: routed reads/calls now inject .figmog/current's key as the `file`
  argument when the call omits one, so a socket-routed command targets
  the same mirror direct mode would instead of silently answering from
  serve's own default session (or hard-failing with no default in the
  zero-startup-file quick-start shape). Skipped for figmog_open, whose
  `file` is a required argument with its own meaning, not a routing hint.
- C2: strip null-valued keys from a routed read's argument object before
  sending (json! always emits a null "equals" for an omitted --equals,
  which dispatch_read_tool's presence-based check misread as "match
  null"); dispatch.rs treats a present-but-null `equals` as absent too,
  as defense in depth.
- I1: write_socket_response no longer holds the connection registry's
  mutex across a blocking write — it clones the connection handle out
  from behind the lock first — and every accepted connection now gets a
  bounded (5s) write timeout, so a client that never reads its responses
  can no longer stall the single-threaded loop (and therefore stdio)
  indefinitely. Socket frame reads are also now capped at 8MB.
- m1: a routed `figmog status` (not `call figmog_status`) no longer
  carries the server-spliced `upstream` field, matching direct mode.
- m2: a socket read timeout reports a specific, actionable message.
- m3: the socket file guard only unlinks if it's still the same inode
  it bound, protecting a live socket from a racer or a manual rm+touch.
- m4: figmog-root is chmod'd 0700 on every bind (socket file mode alone
  isn't the access boundary on macOS).
- m5: an explicit --upstream/--no-upstream on a socket-routed tools/call
  now gets a one-line stderr note that serve's own settings apply.
- m6: new e2e proves 8 read commands are byte-identical direct vs.
  socket-routed, including the exact where/status shapes C2 and m1 broke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds `Id::MirrorConfig`/`Rec::MirrorConfig(MirrorConfigRec { geometry })`
(appended after ProxyCache, per the append-only rule) with its own
`mirror_config` sink, so `pull --geometry` can request Figma's
`?geometry=paths` vector path data without breaking postcard decoding of
existing stores. The flag is sticky: every re-pull path (cli pull,
figmog_sync, serve's watch tick, sessions auto-open, figmog_open's own
`geometry` arg) reads the stored config and unions it with any override,
so geometry never churns off a flag drift; `pull --fresh` is the
documented way to turn it back off. mirror_config is structurally
sweep-exempt, same as meta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add Id::ImageBlob/Rec::ImageBlob (append-only), a new images Table sink,
and images::scan/resolve fetch orchestration for the two Figma image
endpoints (node renders, fill images), cache-first and version-gated
exactly like proxy_cache. Wire figmog_images into the MCP tool registry
(20 -> 21) and figmog serve's per-session dispatch, add the figmog images
CLI command with socket-or-direct routing, and join stale image rows into
the existing post-pull cache eviction at both call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- images::scan no longer trusts the image_key hash alone: a keyed hit is
  only served when the row's kind/subject match (and, for renders,
  format/scale_milli too), mirroring cache::lookup's own collision
  discipline. Two new regression tests.
- to_mcp_content now measures the base64-encoded payload against
  CONTENT_SIZE_CAP instead of raw bytes (base64 inflates ~4/3), encoding
  once and reusing the length for both the comparison and the oversized
  note.
- SVG renders ship as MCP text blocks with raw markup (tagged
  mimeType: image/svg+xml) instead of base64 image blocks, sidestepping
  inconsistent client rendering of image/svg+xml and giving agents
  directly usable markup; the same encoded-length cap still applies.
  cli::images::from_socket_result updated to decode either payload shape
  by the mimeType marker rather than assuming type == "image".
- figmog images now requires at least one id (clap), matching the tool
  path's existing rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README rewritten for a first-time reader per spec §7: positioning
sentence, measured headline numbers, a Claude-first quick start (including
removing the official Figma MCP server), the CLI path below it, a compact
why-table, short feature sections linking into docs/SPEC.md, the full
benchmark run collapsed, one install block, one limitations list, one
license note, and a note for Figma.

docs/SPEC.md updated for every v0.0.2 feature: the socket control plane
(new §6a and the CLI side in §10), node addressing (§12), subtree dump,
--under scoping and --resolve-vars (§13), sticky geometry and MirrorConfig
(§14), images and ImageBlob (§15), sinks 14 to 16, registry 19 to 21, and
version-keyed eviction covering both caches.

CLAUDE.md gains mirror_config and images in the frozen sink list, the
"new record kind, not a wider struct" rule, and the AGPL/CLA note.

Cargo.toml 0.0.1 -> 0.0.2. src/serve.rs's module doc said 20 local tools;
it is 21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The collapsed block now carries the corpus and cold-sync lines, every
per-tool column (calls, p50, p95, p99, max) from the 2026-08-17 run
against the real 5,339-node file, and the load-phase plus API-comparison
figures behind the headline numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
socket_guard_does_not_unlink_a_file_replaced_at_the_same_path assumed a
remove-then-recreate at the same path always yields a fresh inode, which
holds on macOS's filesystems but not on ext4 (the common Linux CI
filesystem), which recycles inode numbers immediately and can legitimately
hand the recreated file the exact same (dev, ino) the guard bound —
causing the test to fail on ubuntu-latest.

Restructured to build the replacement at a sibling path while the
original still exists (two simultaneously-live files are guaranteed
distinct inodes on every platform), then rename(2) it over the guard's
path — rename preserves the source inode across the move, so the file
left at the guard's path is provably a different inode regardless of
filesystem. No production behavior changed; added a doc-comment note on
SocketGuard acknowledging this is a best-effort check, not a hard
guarantee, given ext4's inode-reuse behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hhff
hhff merged commit 1dc989a into main Aug 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant