feat(workspace): give agents stable voice-friendly names - #836
Merged
Conversation
The plan precedes the code because that is this repository's convention for a feature of this size, and because the Stage 1 sketch left on this branch has no tests at all: judging it required naming the properties it has to satisfy first. Doing that surfaced two real defects in the sketch that a later reviewer would have had to find by hand — spawn() seeds a naming identity even though replaceSession spawns through it, and the replacement commit places agentNameId where the successor's own metadata overwrites it, so a provider switch or reload silently renamed a pane. Eleven tasks map onto the three decomposition stages: the shared vocabulary, the isolated main registry with serialized allocation and atomic persistence, its application IPC and the default-off setting; then the durable naming identity, the membership-driven reconciler, the shared selector and the header and Dispatch badges; then the operator observation, exact name search and the crash-course and skill text. The sketch files stay uncommitted: they are the implementation step's input, not its output. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…nciler contracts Fix round 1. Four corrections would have shipped a defect or blocked the gate. The shared selector read state.workspaceState.sessions eagerly, and the header calls it before its early return, so every PaneHeader render touched keys the phone's store stub does not have. The phone renders the real PaneHeader and installs that stub through a Vite-only alias, while tsconfig.web type-checks src/remote-client against the real hooks module — so the crash was invisible to tsc and would have reached a device. The reads are now optional, the selector test covers two keyless shapes, and the gate lives in the phone-coupling spec that already states this rule for workspaceRuntimes. Object.hasOwn does not type-check under the web project's ES2020 lib. Both own-property checks now use Object.prototype.hasOwnProperty.call, as mouseBinding.ts and sessionOwnership.ts already do and explain. The reconciler cancelled in-flight replies from a cleanup that fires on every state change, while leaving those identities marked as requested — so the mount-time claim discarded the first reply and stranded it permanently. Its own test could not have passed. Cancellation is now unmount-scoped, identities are derived through the same pure claim the write effect commits so the first request is already complete, requestedRef clears on settle, and the merge is identity-preserving so an empty reply cannot loop. apply: 'live' is not a member of SettingMetadata['apply']. It came from the sketch, so the tree was already type-broken and the first typecheck step would have failed on someone else's line; a new first step repairs it, and the row now takes DEFAULT_SETTING_METADATA rather than restating it. Also: a test for a reply arriving after close or replacement; replacement and rehydration now carry identity instead of minting it, so the reconciler really is the sole minter the plan claims; a hard stop asking the user to ratify names 4-100, which nothing in the repository attests; the shared refs harness instead of a hand-rolled 17-field literal; and reasons recorded for the ipc directory deviation, the DispatchMiniList exclusion and the absent visual preview. While verifying that round, one more defect surfaced: z.record() drops a "__proto__" key, so reading the assignment map out of the schema's output would have forgotten that assignment on reopen and allocated the agent a second name. load() now validates with zod but takes its data from the raw parsed JSON, the duplicate-name check moved out of the refine that inspected the pruned map, and a reopen round-trip test pins it. A stale claim that hotkeyBinding.test.ts is a known failure was removed: no such file exists on this base, and the plan now says to measure the baseline instead of recalling it. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
Fix round 2. Both defects were introduced by round 1's own fixes and both were
reproduced against the installed runtime before being accepted.
N1. Round 1 replaced `{ ...previous, ...resolved }` with an explicit merge
loop, and that is not an equivalent rewrite: object spread creates own
properties, `merged[identity] = name` does not. For the identity "__proto__"
the write hits the prototype setter and vanishes while the read returns
Object.prototype, so the loop reports a change it did not make, `names` gets a
new reference, the effect re-runs, the identity is still not an own property,
and it is requested again — an unbounded IPC, store-write and re-render spin,
on the one input shape the rest of the feature had already been hardened
against. The merge now collects validated entries and returns
`{ ...previous, ...Object.fromEntries(additions) }`, and compares the previous
value only behind Object.prototype.hasOwnProperty.call. Verified that the
corrected form returns the same reference from the second pass onward, which
is what makes the effect settle. Task 7 gains a "__proto__" identity test whose
real assertion is the call count after settle, not the stored value.
N2. load() reads the assignment map from the raw JSON to survive zod's pruning,
but zod never validates a "__proto__" key either, so the schema's
`z.string().trim().min(1).max(100)` silently does not apply to exactly the key
the raw read exists to recover. Measured: a number, an empty string and a
140-character string all parse clean. The empty string is the harmful one — it
throws nothing, is adopted, counts as already assigned, and leaves that
identity permanently unnameable while rendering nothing. load() now restates
the value check over the raw entries and refuses the store on any violation,
which also replaces an incidental TypeError from normalizeAgentName with a
decision. Task 2 covers all three values, written as raw JSON text because an
object literal `{ __proto__: '' }` creates no own property and would test
nothing.
Also records the own-property discipline as a cross-module invariant: the
registry uses a null-prototype map so plain access is safe, while the renderer
store map is written only through spread and read only behind hasOwnProperty.
Mixing those two conventions is what produced N1. The optional identities memo
was considered and skipped, with the reasoning left in place.
Refs #816
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
The list is the source of NEW allocations only; the registry stores assigned strings, so this file is append-only and its order is a contract. Overflow uses an explicit " 2" suffix because a wrapped pool makes a spoken address ambiguous. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
One process-wide allocator over its own file, separate from workspace.json. Every allocation is serialized on a single promise tail and renamed into place before it is returned, so two windows cannot both be told "Apollo" and a crash cannot publish an unreserved name. Names are never recycled and an unreadable store is refused rather than overwritten. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
The handler admits only a registered window's main frame, de-duplicates and bounds its input, and is the sole consumer of the registry. The preload method gets its own domain rather than riding on workspaceApi, because name allocation is deliberately isolated from workspace.json persistence. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
The flag gates allocation as well as display, so an installation that never enables it never writes agent-names.json. Coercion accepts only a real boolean true, matching the other opt-in flags. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…arantees names.test.ts pinned only the length, the first three names, normalized uniqueness and a per-name regex, so swapping two entries, substituting a name or deleting one and appending a replacement all passed green — while names.ts declares the list ordered and append-only precisely because those edits silently re-point every future allocation. The vocabulary now asserts the full ranking as a literal; verified by swapping two mid-list entries, which the other three tests still passed and this one caught. Comment and message corrections found alongside it, no behavior change: both "unreadable" refusals now name the file the user has to inspect; the schema gate records that `.strict()` + `version: z.literal(1)` is a one-way door a v2 must pass with an additive reader rather than a bumped literal; the never-overwrite guarantee is scoped to the first load, since a later hand edit is discarded by the single writer's next commit; the class doc says the one-instance invariant is inherited from the application's single instance lock rather than enforced here; the vocabulary no longer claims to be phonetically distinct (the short tail clusters on purpose) and states that `as const` narrows the array, so membership goes through the normalized view. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…ment SessionMeta gains agentNameId, the opaque key the main registry allocates against. spawn no longer seeds it — replaceSession spawns through the same function, so a seed there renamed a pane on every provider switch — and the replacement commit now sets it last so the successor's own metadata cannot overwrite the carried identity. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
One pure rule turns (setting, session metadata, allocated map) into a name for the header, the Dispatch index and workspace.observe, so no consumer can invent its own definition. The map is store state but deliberately not persisted: the main registry is the source of truth. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
One hook claims a durable identity for every unnamed agent after restoration and asks main to allocate the names it does not already hold. It is driven by membership and identity changes only, never by token-stream updates, and a failed allocation leaves the map untouched rather than fabricating a name. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
The shared agent header now renders a name badge beside the title and survives an untitled agent, and every Dispatch row leads with the same name chip, which covers classic, grid and tiled Dispatch through the one shared index. Shells render nothing, and the whole surface disappears when the setting is off. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…earch Every observed session carries agentName, null while the setting is off, and agents.search gains an exact, whitespace-normalized name filter that reuses the registry's own equivalence rule. Ambiguity and unavailable-window reporting are unchanged: a name still returns every candidate window, and sending still targets a stable session ID. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
The crash course and the operator skill now explain the exact-match name lookup, how a name differs from a window-local label, that a closed name never resolves to a different agent, and what an all-null agentName means. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…e path Undo Close had three restore paths that disagreed about durable metadata. restorePaneEntry committed only `tabs`, so the successor's meta was whatever spawn built — identity gone, and the user's title with it. restoreTabEntry looked safe because it filled a `freshSessions` map, but nothing ever read that map, so it lost the same fields. restoreDetachedEntry did patch `sessions`, but through a hand-written allowlist that predates naming and omitted `agentNameId`. Every one of these paths respawns with `resumeSessionId`, so the agent that returns is the same conversation: dropping the identity made the reconciler claim a new one and the registry allocate a second name, so Cmd-Shift-T renamed a live agent and leaked the old name forever — allocation never recycles. All three now share one `carryDurableMeta` helper, so the tab path's detached children are covered too and the three cannot drift again. Also, in the same blast radius: - `claimMissingIdentities` skipped on truthiness, so a non-string `agentNameId` from a hand-edited workspace.json read as "already identified" while `resolveAgentName` resolved it to null — an agent permanently unnamed with no way to heal. Both it and `agentNameIdentities` now go through one `identityOf` shape guard; the second half matters because buried records never pass through the claim, and a non-string there made the IPC allocator's `z.array(z.string().min(1))` reject the whole batch for every agent. - The reconciler spec gains the enable-while-running case its hook's header promises (flip the setting on a mounted harness; one claim, one request), and the misplaced "WHY a real component with real useState" block now sits above `mount`/`Harness` instead of above `hostileWorkspace`. - The plan's late-reply test snippet gains the `(_identities: string[])` parameter the committed test needs, so the text matches what shipped. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…ten the name surfaces The whole-branch review found one wrong instruction in the operator skill and eight smaller places where a comment, a schema or a tooltip did not match the code beside it. All nine land together because they are one blast radius: what a name search means, and what every surface says a name is. 1. operator-skills/agent-code-computer-execution/SKILL.md — the paragraph told an operator that an empty result means the agent is gone. That is only true when unavailableWindows is empty, which the tool returns precisely so an unsearched window is not read as a closed agent, and the skill never mentioned the field. The "all agentName: null means the setting is off" sentence was a converse: all-null also happens while the workspace is still restoring, or when the name store is unreadable and allocation is refused. Both now say what to check and to ask rather than guess. 2. src/shared/agentNames/names.ts + names.test.ts — dropped the claim that deleting a vocabulary entry strands a stored assignment. It does not: name lookup never reads this list, as the file's own closing paragraph says. The true cost (a deletion re-points every allocation behind it) is kept. 3. src/main/control/globalCapabilities.ts — agents.search's `name` had no upper bound, and `query` beside it had the same gap. Both are compared against every session of every window on every call, so an unbounded string is the cheap way to make one request walk megabytes per agent. name .max(120), query .max(200), with a WHY for the numbers, and both bounds covered next to the existing search tests. 4. src/main/agentNames/ipc.ts — the sender guard's comment claimed it proves the WebContents is one of our windows, but it omitted the liveness clause its own precedent applies (createControlHost.ts:66-67). windowIdFor deliberately still answers for a retired WebContents, so a queued invoke from a closed window could spend a spoken address that allocation never recycles. Added the getBrowserWindow clause from the same already-imported module, plus a test. 5. src/renderer/src/workspace/control/agents.ts — agents.list's free-text haystack omitted agentName while agents.search included it, so a partially heard name was recoverable globally and not in the window holding the agent. Added the field, the query/tool descriptions now match the haystack, one test. 6. src/preload/api/index.ts — the note that the main/ipc/ registry mirrors this split one-to-one stopped being true for agentNames, whose registrar lives at src/main/agentNames/ipc.ts by design. Amended to say so. 7. src/main/agentNames/registry.ts — the single-ownership paragraph cited src/main/index.ts:280 as the instance lock; that line is `packagingSmoke || app.requestSingleInstanceLock()`. Added the clause that the packaging smoke run skips the lock and never reaches allocation. 8. src/renderer/src/workspace/hook/actions/undoClose.ts — the list of fields spawn rebuilds omitted providerRuntime (session.ts:339-345). 9. src/renderer/src/workspace/dispatch/DispatchAgentList.tsx — the row tooltip showed only the title while the pane header joins name and title. The title is the part a narrow index truncates, so the tooltip was missing the name exactly when it was needed. Made consistent with AgentTitleHeader.tsx:26. Refs #816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
An external voice operator has no stable way to address one agent. Titles describe the task and the user edits them freely;
displayLabel(C18) is window-local and changes with layout, and both are awkward to say. Session IDs are UUIDs. Nothing in the app could be spoken and still resolve to exactly one agent after a close, a provider switch or a restart.What this adds
An opt-in Agent names setting (default off, under Settings → Workspace). When enabled, every provider agent gets one stable name from a user-ranked 100-name list (Apollo, Jasper, Beatrix, …; explicit
"Apollo 2"suffixes past the pool). The name appears:agentNameon every session inworkspace.observe/app.observe, and as an exact, case-insensitive, whitespace-normalizednamefilter onagents.search.Terminals are never named. Disabling hides names everywhere (header, Dispatch, observation, search) while keeping every assignment, so re-enabling shows the same addresses without re-allocating. The operator server itself is unaffected by the toggle. A closed agent's name is never handed to another agent, so a stale spoken request resolves to nothing rather than to the wrong target.
Design decisions and tradeoffs
Identity model.
SessionMeta.agentNameIdis an opaque key, not the name. It is minted in exactly one place — the renderer reconciler (claimMissingIdentities), which runs only after workspace restoration and only while the setting is on — and its value is the session's own local ID at claim time (arandomUUID()from main, so it cannot collide across windows). Every path that gives a live conversation a new local ID carries the identity forward:replaceSession(provider switch, rewind, resume), bulk reload (...restoredMeta), rehydration (stable IDs plus key-preserving reconciliation), closed-window adoption, and all three Undo Close paths through onecarryDurableMetahelper.spawndeliberately does not seed an identity, because replacement spawns through it and a seed there renamed a pane on every provider switch. Duplicate, linked children, orchestration workers and control-created agents therefore start with no identity and get a fresh name — they are new conversations.Registry isolation. Names live in
~/.config/agent-code/agent-names.json, a separate file fromworkspace.json, with a separate writer and failure mode: a corrupt workspace costs a layout, a corrupt registry would cost every spoken address, so neither can take the other down. The registry is process-wide (one per main), serializes every allocation on a single promise tail so two windows spawning in the same frame cannot both be told "Apollo", writes via UUID temp file +rename, and stores a monotonicnextIndexso a removed assignment never recycles a name. An unreadable or internally inconsistent store (two identities under one spoken name, a non-string value) is refused, not repaired or overwritten. It is consumed only by its IPC adapter; feature, UI and MCP code cannot import it, and MCP has no allocation path at all. Single-process ownership is inherited from the app's single-instance lock (no file lock added; documented in the class comment). No fsync, consistent with the other stores in this repo.Prototype safety. Identities originate in a user-editable file, so
"__proto__","constructor"and friends are reachable. The three identity-keyed maps each handle this in the form that fits them: the registry uses a null-prototype map and reads the assignment map from raw JSON becausez.record()silently drops a__proto__key (verified against the pinned zod; tested); the renderer store map is written only through spread /Object.fromEntriesand read only behindObject.prototype.hasOwnProperty.call; the selector never returns an inherited value. The termination test for the reconciler is the__proto__identity — a naive merge spins on it forever.Object.hasOwnis avoided because the renderer targets lib ES2020.Undo Close finding. Auditing the carry paths found that Undo Close disagreed with itself: the pane path committed only
tabs, the tab path filled afreshSessionsmap that nothing read, and the detached path used a hand-written allowlist. All three respawn with--resume, so the agent that comes back is the same conversation; dropping the identity would have made the reconciler claim a new one and the registry burn a second name — Cmd-Shift-T as a silent rename. The three paths now sharecarryDurableMeta, which also fixes the pre-existing loss of the user's title and linked/orchestration parentage on pane undo.Dotted IDs vs
ac_tool names. Capability IDs are dotted (agents.search); the MCP tool name is derived (ac_agents_search). The crash course keeps the dotted form in its per-topic sections, as its neighbours already do, and spells theac_forms in the "Operating with MCP and computer use" section; the operator skill namesac_agents_searchin the paragraph directly above the new one.Not done, on purpose. No name is ever minted on render, derived from an index, or fabricated on IPC failure — a failed allocation is an agent with no visible name until the next membership change. The store map is not persisted (the registry is the source of truth; one IPC round trip re-resolves it on launch). Reconciliation is driven by workspace membership and the setting, never by runtime/token updates; the per-pane subscription selects a primitive so token streams do not re-render headers.
Fixes #816
Tests and verification
New specs (45 cases across 8 files):
src/shared/agentNames/names.test.ts(ordering lock as a full literal, overflow rule, normalization),src/main/agentNames/registry.test.ts(commit-before-return, concurrent windows, reopen without recycling, 101-identity overflow, corrupt/duplicate/__proto__stores refused, hostile identity cannot polluteObject.prototype),src/main/agentNames/ipc.test.ts(sender guards, dedup, schema),selectors.test.ts,reconciler.renderer.test.tsx(real store + realuseState, enable-while-running, malformed identities re-claimed, failure leaves the map untouched, late reply after replacement/close,__proto__asks exactly once),presentation.renderer.test.tsx,agentNameContinuity.renderer.test.tsx(realuseSessionActions.replaceSession, fresh spawn mints nothing, all three Undo Close paths),control/agentNames.renderer.test.ts(recordeddispatch-global-d23fixture: cross-window exact search keeps both candidates and the unavailable window; disabled hides names and lookup). Existing specs extended: settings coercion/registry, documentation contract, phone keyless-store gate.Verification gate on the merged tree (
a068cd36, main3256e06amerged in), Node 24:npm run typecheckexit 0;npm test2910 of 2912 tests passed across 426 of 428 files, the two failures being the known pre-existing ones (lazy-prose/index.renderer.test.tsxtimeout, issue #700, andimageAttachment.test.ts, which cites a deleted local transcript);npm run check:keybindingsexit 0 (no new binding);npm run test:packageexit 0. Isolation greps: the registry is imported only undersrc/main/agentNames/; no control or MCP code can allocate. The final fix commit (aeb50c98) re-ran the affected renderer (79 tests) and unit (36 tests) suites and the type gate.Isolation greps (registry imported only by
src/main/agentNames/ipc.ts;agent-names.json/AGENT_NAMES_FILEreferenced from no feature, UI or MCP module) pass on this branch. Mergedorigin/main(quota-independent provider switch, #834) with no overlapping files; the bulk switch reachesreplaceSessionand therefore carries identity. No app launch and no visual preview — layout claims for narrow tiled lanes are reasoned fromflex-shrink-0on the badge, not measured.Known limitations and follow-ups
__proto__key across Electron IPC is unverified without launching the app; both ends are hardened independently and the worst case is one exotic identity showing no name and being re-requested, not a spin.state.sessions; a buried record'ssessionMetasnapshot keeps whatever it had at bury time. This converges (identity equals the stable session ID, so a revive re-claims the same key) but the snapshot is stale until then.agents.list(window-scoped) does not includeagentNamein its free-text haystack;agents.searchdoes. Cheap to align.agent-names:resolveguard accepts a retired (closed-window)WebContentsID thatwindowIdForstill maps; the control host additionally checksgetBrowserWindow(id). Harmless for allocation; worth the one-line liveness clause for consistency.workspace.jsongives two sessions the sameagentNameId; no in-app path produces that state.spawned !== undefinedbranch ofcarryDurableMeta(productionspawnregisters the meta; the harness spawn does not).titletooltip omits the name; the badge has noaria-label(matches the existing chip convention).agentNameskey a dev-profile sketch left in settings is not inRETIRED_SETTINGS_KEYS.Plan:
docs/superpowers/plans/2026-09-07-agent-names.mdDecomposition:
docs/decomposition/agent-names.md🤖 Generated with Claude Code
https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd