Skip to content

feat(workspace): give agents stable voice-friendly names - #836

Merged
Juliusolsson05 merged 18 commits into
mainfrom
feat/agent-names
Sep 8, 2026
Merged

feat(workspace): give agents stable voice-friendly names#836
Juliusolsson05 merged 18 commits into
mainfrom
feat/agent-names

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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:

  • beside the title in the shared agent header (structured and native-terminal surfaces), including for untitled agents;
  • as a leading chip on every Dispatch index row (classic, grid and tiled share the one list);
  • as agentName on every session in workspace.observe / app.observe, and as an exact, case-insensitive, whitespace-normalized name filter on agents.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.agentNameId is 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 (a randomUUID() 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 one carryDurableMeta helper. spawn deliberately 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 from workspace.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 monotonic nextIndex so 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 because z.record() silently drops a __proto__ key (verified against the pinned zod; tested); the renderer store map is written only through spread / Object.fromEntries and read only behind Object.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.hasOwn is 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 a freshSessions map 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 share carryDurableMeta, 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 the ac_ forms in the "Operating with MCP and computer use" section; the operator skill names ac_agents_search in 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 pollute Object.prototype), src/main/agentNames/ipc.test.ts (sender guards, dedup, schema), selectors.test.ts, reconciler.renderer.test.tsx (real store + real useState, 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 (real useSessionActions.replaceSession, fresh spawn mints nothing, all three Undo Close paths), control/agentNames.renderer.test.ts (recorded dispatch-global-d23 fixture: 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, main 3256e06a merged in), Node 24: npm run typecheck exit 0; npm test 2910 of 2912 tests passed across 426 of 428 files, the two failures being the known pre-existing ones (lazy-prose/index.renderer.test.tsx timeout, issue #700, and imageAttachment.test.ts, which cites a deleted local transcript); npm run check:keybindings exit 0 (no new binding); npm run test:package exit 0. Isolation greps: the registry is imported only under src/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_FILE referenced from no feature, UI or MCP module) pass on this branch. Merged origin/main (quota-independent provider switch, #834) with no overlapping files; the bulk switch reaches replaceSession and therefore carries identity. No app launch and no visual preview — layout claims for narrow tiled lanes are reasoned from flex-shrink-0 on the badge, not measured.

Known limitations and follow-ups

  • Structured-clone preservation of an own __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.
  • The reconciler's claim writes only state.sessions; a buried record's sessionMeta snapshot 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 include agentName in its free-text haystack; agents.search does. Cheap to align.
  • The agent-names:resolve guard accepts a retired (closed-window) WebContents ID that windowIdFor still maps; the control host additionally checks getBrowserWindow(id). Harmless for allocation; worth the one-line liveness clause for consistency.
  • No de-duplication if a hand-edited workspace.json gives two sessions the same agentNameId; no in-app path produces that state.
  • Untested directly: the 200-char / 10k request caps, the handler's return value, and the spawned !== undefined branch of carryDurableMeta (production spawn registers the meta; the harness spawn does not).
  • Dispatch row title tooltip omits the name; the badge has no aria-label (matches the existing chip convention).
  • The stray agentNames key a dev-profile sketch left in settings is not in RETIRED_SETTINGS_KEYS.

Plan: docs/superpowers/plans/2026-09-07-agent-names.md
Decomposition: docs/decomposition/agent-names.md

🤖 Generated with Claude Code
https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd

Juliusolsson05 and others added 18 commits September 7, 2026 12:46
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
@Juliusolsson05
Juliusolsson05 merged commit 9c41feb into main Sep 8, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the feat/agent-names branch September 8, 2026 02:43
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.

feat(workspace): add voice-friendly agent names to headers, lists and operator tools

1 participant