Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fc5a47f
docs(workspace): plan stable voice-friendly agent names
Juliusolsson05 Sep 5, 2026
f8d07d0
docs(workspace): plan stable voice-friendly agent names implementation
Juliusolsson05 Sep 7, 2026
6570386
docs(workspace): correct the agent names plan for phone, lib and reco…
Juliusolsson05 Sep 7, 2026
6663983
docs(workspace): keep the agent names reconciler merge prototype-safe
Juliusolsson05 Sep 7, 2026
9576d84
feat(workspace): add the approved ranked agent name vocabulary
Juliusolsson05 Sep 7, 2026
62675d9
feat(workspace): add the durable agent name registry
Juliusolsson05 Sep 7, 2026
f1b5dd2
feat(workspace): expose agent name allocation over application IPC
Juliusolsson05 Sep 7, 2026
596a154
feat(settings): add the default-off Agent names toggle
Juliusolsson05 Sep 7, 2026
6438b6b
test(workspace): lock the agent name ordering and clarify registry gu…
Juliusolsson05 Sep 7, 2026
40198d2
feat(workspace): carry a durable naming identity across agent replace…
Juliusolsson05 Sep 7, 2026
f9ea341
feat(workspace): add the shared agent name selector and its store slice
Juliusolsson05 Sep 7, 2026
e4b4cd5
feat(workspace): reconcile agent names from workspace membership
Juliusolsson05 Sep 7, 2026
2b52930
feat(workspace): show agent names in headers and the Dispatch index
Juliusolsson05 Sep 8, 2026
d2fcc91
feat(control): expose agent names in observation and exact operator s…
Juliusolsson05 Sep 8, 2026
543ef01
docs(control): teach spoken agent names to the operator
Juliusolsson05 Sep 8, 2026
21ca259
fix(workspace): carry the agent name identity through every Undo Clos…
Juliusolsson05 Sep 8, 2026
a068cd3
Merge remote-tracking branch 'origin/main' into feat/agent-names
Juliusolsson05 Sep 8, 2026
aeb50c9
fix(control): tell operators what an empty name search means and tigh…
Juliusolsson05 Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/decomposition/agent-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Agent names

A: settings registry/persistence, SessionMeta and existing replacement/rehydration
owners, shared AgentTitleHeader, DispatchAgentList, workspace observations and
cross-window agents.search are the trusted current surfaces. Titles describe tasks;
window-local coordinates currently provide spoken addresses that are awkward to say.

D: an opt-in Agent names toggle reveals stable, legible names from the approved
100-name list in headers/lists and exposes the same names for exact MCP discovery.
Names never silently target a different agent after close, replacement or restart.
No prompt, title, focus or layout is changed by enabling this feature.

## Stages

1. Durable name owner and setting.
- Produces: shared ranked vocabulary/contracts; isolated main name registry
with atomic persistence and serialized allocation; application IPC; default-off
Agent names setting with normal coercion/registry support.
- Verified by: concurrent-window allocation and reopen/corrupt-store tests;
settings defaults/coercion. Allocation is committed before names are shown.
- Why separate: renderer-local counters race across windows and reassign spoken
addresses after restart. MCP must never own application identity.
- Reality check: existing window-owned workspace persistence keeps its payload
opaque, so registry storage is separate; SessionMeta carries only its durable
naming identity, not a second copy of global allocation state.
2. Workspace identity and presentation.
- Produces: one membership-driven reconciliation hook, lifecycle continuity,
name selector/badge, shared header and Dispatch list integration.
- Verified by: real-store toggle/assignment race checks, actual replacement
hooks and header/list rendering. Names stay out of token-stream updates.
- Why separate: presentation must consume one assignment, never mint on render.
- Reality check: AgentTitleHeader is shared by structured/native providers;
replacement changes local session IDs and explicitly reconstructs metadata.
3. External operator integration and verification.
- Produces: enabled agentName fields in observations, exact name search and
crash-course/skill instructions, reviewable feature PR with verification.
- Verified by: real cross-window observation/search cases, disabled behavior,
renderer/type/contract checks and an isolated visual preview where practical.
- Why separate: spoken labels must resolve to current IDs/owners before effects;
name lookup must not bypass existing ambiguity or ownership handling.
- Reality check: existing agents.search returns all candidates and unavailable
windows; mutations already require stable session identity.

## Isolation and decisions

The main name registry allocates unique names under a durable opaque identity;
only its application IPC adapter consumes it. Feature/UI/MCP code may not import
its persistence internals. A renderer reconciler claims missing identities after
workspace restoration; a shared selector reads names for all presentation/control
consumers. Membership/identity changes trigger reconciliation, not runtime tokens.
Assignments are retained when off and never automatically recycled. Pool overflow
uses explicit numeric suffixes. Duplicate/new agents receive new identities;
replacement/recovery/restore retain identity. Existing settings propagation remains
the app's setting contract; name reservations are process-wide across windows.

## Unknowns and evidence

Check metadata reconstruction during bulk reload and rehydration, related children
and buried records, windows moving sessions, assignment arriving after close or
replacement, disk failures, and narrow headers/rows. IPC failure must not fabricate
names or overwrite an unreadable registry. Main never parses workspace blobs for
naming. Corrupt/foreign metadata must not bypass provider/owner resolution.

Tests use real owner/store/registry paths and captured transition scenarios from
existing lifecycle tests, with fault injection only at IPC/disk/clock boundaries.
No live user workspace or provider is used for visual/testing probes.
3,048 changes: 3,048 additions & 0 deletions docs/superpowers/plans/2026-09-07-agent-names.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions operator-skills/agent-code-computer-execution/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ project, title, provider and current conversation; do not assume the focused
window or the first search result is the target. Pass the returned `sessionId`
to agent tools. These normally resolve their owning window automatically.

If the user speaks a name — "send this to Apollo" — search `{name: "Apollo"}`.
The match is exact and case-insensitive, never a substring, and "Apollo 2" is a
different agent from "Apollo". A name is application-wide and stable across
restart, reload and provider replacement, unlike a window-local label, and it is
never reused after an agent closes. Read the empty page together with
`unavailableWindows`: an empty result with `unavailableWindows` empty means that
agent is gone, while a window listed there has not been searched yet, so inspect
or retry it before concluding anything. Several windows can observe the same
agent, so a name search can still return more than one candidate; resolve to the
returned `sessionId` before sending. `agentName: null` on every agent usually
means the "Agent names" setting is off; it can also mean the workspace is still
restoring or the name store is unreadable, so ask the user rather than guessing.

For a window-specific operation, pass `_control.windowId`. Include
`_control.generation` when the action depends on the exact workspace you just
observed. A reload makes the old generation stale: refresh the observation before
Expand Down
4 changes: 3 additions & 1 deletion src/control-sdk/catalog/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export const workspaceObservationSchema = z.object({
tabs: z.array(z.object({ id: z.string(), title: z.string(), focusedSessionId: z.string(), sessionIds: z.array(z.string()) })),
sessions: z.array(z.object({
sessionId: z.string(), title: z.string(), displayLabel: z.string().nullable().default(null).describe('Current window-local visible coordinate; can change with layout. Never use as a stable ID.'),
displayedTitle: z.string().default('').describe('The current UI title, including prompt fallback where shown.'), cwd: z.string(), provider: z.string(),
displayedTitle: z.string().default('').describe('The current UI title, including prompt fallback where shown.'),
agentName: z.string().nullable().default(null).describe('Stable spoken name for voice operation, e.g. "Apollo". Null when the Agent names setting is off, when the agent is a terminal, or before a name has been allocated. Unlike displayLabel this does not change with layout, and it is never reused after an agent closes.'),
cwd: z.string(), provider: z.string(),
providerRuntime: z.string().nullable(), providerSessionId: z.string().nullable(),
pinned: z.boolean(), placements: z.array(placementSchema),
})),
Expand Down
101 changes: 101 additions & 0 deletions src/main/agentNames/ipc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest'

import type { AgentNameRegistry } from '@main/agentNames/registry'

// vi.hoisted, because vi.mock factories are hoisted above ordinary consts and
// would otherwise hit the temporal dead zone the first time a mocked module is
// imported.
const { handlers, windowIdFor, getBrowserWindow } = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
windowIdFor: vi.fn<(sender: unknown) => string | null>(),
getBrowserWindow: vi.fn<(id: string) => object | null>(),
}))

vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
},
},
}))
vi.mock('@main/storage/paths.js', () => ({ STATE_DIR: '/recorded/state' }))
vi.mock('@main/window/windowRegistry.js', () => ({
windowIdFor: (sender: unknown) => windowIdFor(sender),
getBrowserWindow: (id: string) => getBrowserWindow(id),
}))

const { registerAgentNamesIpc } = await import('@main/agentNames/ipc.js')

function fakeRegistry(): { registry: AgentNameRegistry; resolve: ReturnType<typeof vi.fn> } {
const resolve = vi.fn(async (identities: readonly string[]) =>
Object.fromEntries(identities.map((identity, index) => [identity, `Name${index}`])))
return { registry: { resolve } as unknown as AgentNameRegistry, resolve }
}

function event(options: { registered: boolean; mainFrame: boolean; live?: boolean }) {
const sender = { mainFrame: {} }
windowIdFor.mockReturnValue(options.registered ? 'window-one' : null)
// Default live: the existing cases are about the other two clauses, and a
// registered window is normally still open.
getBrowserWindow.mockReturnValue(options.live === false ? null : { id: 1 })
return { sender, senderFrame: options.mainFrame ? sender.mainFrame : {} }
}

describe('agent names IPC', () => {
it('refuses a sender that is not a registered application window', async () => {
const { registry, resolve } = fakeRegistry()
registerAgentNamesIpc(registry)
const handler = handlers.get('agent-names:resolve')!

await expect(handler(event({ registered: false, mainFrame: true }), ['a']))
.rejects.toThrow(/registered application window/)
expect(resolve).not.toHaveBeenCalled()
})

it('refuses a sub-frame of a registered window', async () => {
// WHY the frame check is separate from the window check: an embedded
// iframe (rendered content, a preview surface) shares the window's
// WebContents. Allocating names from there would let untrusted page
// content burn spoken addresses.
const { registry, resolve } = fakeRegistry()
registerAgentNamesIpc(registry)
const handler = handlers.get('agent-names:resolve')!

await expect(handler(event({ registered: true, mainFrame: false }), ['a']))
.rejects.toThrow(/registered application window/)
expect(resolve).not.toHaveBeenCalled()
})

it('refuses a still-registered window whose BrowserWindow is already gone', async () => {
// The liveness clause the control host's senderWindow applies. A retired
// window's queued invoke would otherwise spend a spoken address that
// allocation can never hand back.
const { registry, resolve } = fakeRegistry()
registerAgentNamesIpc(registry)
const handler = handlers.get('agent-names:resolve')!

await expect(handler(event({ registered: true, mainFrame: true, live: false }), ['a']))
.rejects.toThrow(/registered application window/)
expect(resolve).not.toHaveBeenCalled()
})

it('de-duplicates identities before allocating', async () => {
const { registry, resolve } = fakeRegistry()
registerAgentNamesIpc(registry)
const handler = handlers.get('agent-names:resolve')!

await handler(event({ registered: true, mainFrame: true }), ['a', 'b', 'a'])

expect(resolve).toHaveBeenCalledWith(['a', 'b'])
})

it('rejects a malformed request before it reaches the registry', async () => {
const { registry, resolve } = fakeRegistry()
registerAgentNamesIpc(registry)
const handler = handlers.get('agent-names:resolve')!

await expect(handler(event({ registered: true, mainFrame: true }), ['', 'b'])).rejects.toThrow()
await expect(handler(event({ registered: true, mainFrame: true }), 'not-an-array')).rejects.toThrow()
expect(resolve).not.toHaveBeenCalled()
})
})
56 changes: 56 additions & 0 deletions src/main/agentNames/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ipcMain } from 'electron'
import { join } from 'node:path'
import { z } from 'zod'

import { AgentNameRegistry } from '@main/agentNames/registry.js'
import { STATE_DIR } from '@main/storage/paths.js'
import { getBrowserWindow, windowIdFor } from '@main/window/windowRegistry.js'

// WHY its own file next to workspace.json rather than a key inside it: the
// workspace file is a per-window opaque payload that main only moves bytes for,
// and it is rewritten wholesale on every autosave. Name allocation is
// application-wide, has a different writer and a different failure mode — a
// corrupt workspace costs a layout, a corrupt registry would cost every spoken
// address — so the two must not be able to take each other down.
export const AGENT_NAMES_FILE = join(STATE_DIR, 'agent-names.json')

// Bounded so a malformed or hostile renderer cannot make the allocator walk a
// huge list under the serialization tail. 10k is far past any real workspace.
const requestSchema = z.array(z.string().min(1).max(200)).max(10_000)

/**
* The ONLY consumer of AgentNameRegistry.
*
* WHY the registry is a constructor default rather than a module singleton:
* `registerAllIpc` runs exactly once per application process, so one instance
* per call is already process-wide, and taking it as an argument is what makes
* the sender guard testable without touching the real state directory.
*/
export function registerAgentNamesIpc(
registry: AgentNameRegistry = new AgentNameRegistry(AGENT_NAMES_FILE),
): void {
ipcMain.handle('agent-names:resolve', async (event, raw: unknown) => {
// Three separate conditions on purpose, and deliberately the SAME three the
// control host's `senderWindow` applies (createControlHost.ts:66-67), so the
// two main-side entry points a renderer can reach cannot drift apart:
// 1. the WebContents maps to one of our registered window IDs,
// 2. that ID still has a live BrowserWindow, and
// 3. the request came from the app's own top-level document rather than an
// embedded frame it happens to host.
//
// WHY (2) is not redundant with (1): `windowIdFor` deliberately keeps
// answering for a closed window — it falls back to `retiredWebContentsIds`
// (windowRegistry.ts:429-435) so late messages from a departing renderer can
// still be attributed — while `getBrowserWindow` returns null once the entry
// is gone or the window is destroyed. Without (2), a queued invoke from a
// window the user already closed would still allocate, and allocation is
// monotonic: it would permanently spend a spoken address on nothing.
const windowId = windowIdFor(event.sender)
if (!windowId || !getBrowserWindow(windowId) || event.senderFrame !== event.sender.mainFrame) {
throw new Error('Agent names require a registered application window')
}
// De-duplicate before the registry so a repeated identity in one batch
// cannot advance the counter twice.
return registry.resolve([...new Set(requestSchema.parse(raw))])
})
}
Loading