diff --git a/docs/ai/design/2026-05-31-feature-agent-rename.md b/docs/ai/design/2026-05-31-feature-agent-rename.md new file mode 100644 index 00000000..a763baf4 --- /dev/null +++ b/docs/ai/design/2026-05-31-feature-agent-rename.md @@ -0,0 +1,103 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture + +## Architecture Overview + +```mermaid +graph TD + CLI["CLI: agent rename "] + Validate["Validate new name (NAME_REGEX)"] + Registry["AgentRegistry.rename(current, new)"] + FS["~/.ai-devkit/agents.json (atomic write)"] + + CLI --> Validate + Validate -->|valid| Registry + Validate -->|invalid| ErrFormat["Error: invalid name format"] + Registry -->|current not found| ErrNotFound["Error: agent not found"] + Registry -->|new name in use (live)| ErrConflict["Error: name already in use"] + Registry -->|success| FS + FS --> Success["ui.success: renamed"] +``` + +## Data Models + +`RegistryEntry` (existing, unchanged): +```ts +interface RegistryEntry { + name: string; // ← this field is updated + type: AgentType; + pid: number; + tmuxSession: string; // ← NOT updated (registry-only rename) + cwd: string; + startedAt: string; + sessionId: string; + sessionFilePath: string; +} +``` + +## API Design + +### `AgentRegistry.rename(currentName: string, newName: string): void` + +Added to `packages/agent-manager/src/utils/AgentRegistry.ts`. + +Behaviour: +1. Read the registry file. +2. Find entry with `name === currentName`. If not found, throw `RenameNotFoundError`. +3. Prune stale entries before conflict check. +4. Check whether any remaining entry has `name === newName`. If found and alive, throw `RenameConflictError`. +5. Update the matched entry's `name` to `newName`. Write atomically. + +Two new error classes (in `AgentRegistry.ts`): +```ts +export class RenameNotFoundError extends Error { + constructor(public agentName: string) { ... } +} +export class RenameConflictError extends Error { + constructor(public agentName: string) { ... } +} +``` + +### CLI: `agent rename ` + +Added in `registerAgentCommand()` in `packages/cli/src/commands/agent.ts`. + +``` +ai-devkit agent rename +``` + +Validation order: +1. Validate `` against `NAME_REGEX` → exit 1 with format hint. +2. Short-circuit if ` === ` → success no-op message. +3. Call `AgentRegistry.default().rename(currentName, newName)`. +4. Catch `RenameNotFoundError` → `ui.error` + exit 1. +5. Catch `RenameConflictError` → `ui.error` + exit 1. +6. On success → `ui.success("Agent \"\" renamed to \"\".")`. + +## Component Breakdown + +| Component | File | Change | +|-----------|------|--------| +| `AgentRegistry` | `packages/agent-manager/src/utils/AgentRegistry.ts` | Add `rename()`, `RenameNotFoundError`, `RenameConflictError` | +| `agent.ts` CLI | `packages/cli/src/commands/agent.ts` | Add `agent rename` subcommand | +| `index.ts` (agent-manager) | `packages/agent-manager/src/index.ts` | Export new error classes | + +## Design Decisions + +**Registry-only rename (no tmux session rename):** Per user decision. Simpler implementation, no dependency on a live tmux session. The `tmuxSession` field retains the original session name, so `tmux attach -t ` still works. + +**Prune before conflict check:** Matches the pattern used in `startAgent`. Prevents a stale dead entry from blocking a valid rename. + +**Same-name short-circuit in CLI (not registry):** Keeps `AgentRegistry.rename()` a simple mutation primitive; the no-op guard is a CLI-layer concern. + +**Error classes on `AgentRegistry`:** Mirrors `AgentNameInUseError` / `AgentPidPollTimeoutError` pattern in `agent.service.ts`. CLI catches them for user-friendly messages. + +## Non-Functional Requirements + +- Atomic write: use existing `.tmp` + `renameSync` pattern. No data loss on crash mid-write. +- No new runtime dependencies. diff --git a/docs/ai/implementation/2026-05-31-feature-agent-rename.md b/docs/ai/implementation/2026-05-31-feature-agent-rename.md new file mode 100644 index 00000000..d1c0729c --- /dev/null +++ b/docs/ai/implementation/2026-05-31-feature-agent-rename.md @@ -0,0 +1,65 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Development Setup +**How do we get started?** + +- Prerequisites and dependencies +- Environment setup steps +- Configuration needed + +## Code Structure +**How is the code organized?** + +- Directory structure +- Module organization +- Naming conventions + +## Implementation Notes +**Key technical details to remember:** + +### Core Features +- Feature 1: Implementation approach +- Feature 2: Implementation approach +- Feature 3: Implementation approach + +### Patterns & Best Practices +- Design patterns being used +- Code style guidelines +- Common utilities/helpers + +## Integration Points +**How do pieces connect?** + +- API integration details +- Database connections +- Third-party service setup + +## Error Handling +**How do we handle failures?** + +- Error handling strategy +- Logging approach +- Retry/fallback mechanisms + +## Performance Considerations +**How do we keep it fast?** + +- Optimization strategies +- Caching approach +- Query optimization +- Resource management + +## Security Notes +**What security measures are in place?** + +- Authentication/authorization +- Input validation +- Data encryption +- Secrets management + diff --git a/docs/ai/planning/2026-05-31-feature-agent-rename.md b/docs/ai/planning/2026-05-31-feature-agent-rename.md new file mode 100644 index 00000000..1ff4aa1a --- /dev/null +++ b/docs/ai/planning/2026-05-31-feature-agent-rename.md @@ -0,0 +1,28 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown + +## Task Breakdown + +- [x] Task 1: Add `RenameNotFoundError` and `RenameConflictError` to `AgentRegistry`, implement `rename()` method, and export new error classes from `packages/agent-manager/src/index.ts` +- [x] Task 2: Add `agent rename ` subcommand to `packages/cli/src/commands/agent.ts` +- [x] Task 3: Write unit tests for `AgentRegistry.rename()` in `packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts` +- [x] Task 4: Write unit tests for the `agent rename` CLI command in `packages/cli/src/__tests__/commands/agent.test.ts` + +## Dependencies + +- Task 2 depends on Task 1 (needs `rename()` and error classes from agent-manager). +- Tasks 3 and 4 can be written after their respective implementation tasks. +- No external dependencies or infrastructure changes. + +## Risks & Mitigation + +| Risk | Mitigation | +|------|-----------| +| `AgentRegistry` exports not updated | Verify `packages/agent-manager/src/index.ts` re-exports new symbols | +| Stale entry blocks rename when it should not | Prune before conflict check, matching `startAgent` pattern | +| CLI test for `agent rename` doesn't exist yet | Add alongside the command, following existing `agent.test.ts` patterns | diff --git a/docs/ai/requirements/2026-05-31-feature-agent-rename.md b/docs/ai/requirements/2026-05-31-feature-agent-rename.md new file mode 100644 index 00000000..9e9d79a7 --- /dev/null +++ b/docs/ai/requirements/2026-05-31-feature-agent-rename.md @@ -0,0 +1,52 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement + +Running agents accumulate auto-generated names like `my-project-1748700000` that are hard to recall. There is currently no way to assign a more meaningful name to a running agent after it has been started. Users must stop and restart the agent with a new `--name` to rename it, losing context. + +Affected users: CLI users who manage multiple long-lived agents via `agent list`, `agent send`, `agent open`. + +## Goals & Objectives + +**Primary goal:** Allow a user to rename a registered agent by running `agent rename `. + +**Non-goals:** +- Renaming the underlying tmux session (registry-only change, per decision) +- Renaming an agent that is not in the registry (stale/unknown agents are out of scope) +- Bulk rename or pattern-based rename + +## User Stories & Use Cases + +- As a CLI user, I want to run `agent rename my-project-1abc2d my-api-agent` so the agent shows a readable name in `agent list` and I can reference it easily with `agent send --id my-api-agent`. +- As a CLI user, if I supply a `` that is already in use, I want a clear error telling me the conflict. +- As a CLI user, if I supply a `` not in the registry, I want a clear error. +- As a CLI user, if I supply an invalid `` format, I want a validation error with the format rules. + +**Edge cases:** +- Current name and new name are the same → no-op success or informational message. +- New name exists but the entry is stale (process dead) → prune first, then allow rename. + +## Success Criteria + +- `agent rename ` updates `name` in `~/.ai-devkit/agents.json` atomically. +- The renamed agent appears under the new name in `agent list`. +- `agent send --id ` and `agent open ` resolve correctly after rename. +- All error paths (not found, conflict, invalid format) produce actionable messages and exit code 1. +- Existing unit tests for `AgentRegistry` pass; new tests cover `rename()`. + +## Constraints & Assumptions + +- Name format: `/^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/` (existing `NAME_REGEX` in `agent.ts`). +- Registry file: `~/.ai-devkit/agents.json` (atomic write via `.tmp` + `rename`). +- The tmux session name is NOT updated — `tmuxSession` field in the entry retains its original value. +- No distributed locking; single-writer pattern is already established. + +## Questions & Open Items + +All material questions resolved. No open items. diff --git a/docs/ai/testing/2026-05-31-feature-agent-rename.md b/docs/ai/testing/2026-05-31-feature-agent-rename.md new file mode 100644 index 00000000..50958b29 --- /dev/null +++ b/docs/ai/testing/2026-05-31-feature-agent-rename.md @@ -0,0 +1,81 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy + +## Test Coverage Goals +**What level of testing do we aim for?** + +- Unit test coverage target (default: 100% of new/changed code) +- Integration test scope (critical paths + error handling) +- End-to-end test scenarios (key user journeys) +- Alignment with requirements/design acceptance criteria + +## Unit Tests +**What individual components need testing?** + +### Component/Module 1 +- [ ] Test case 1: [Description] (covers scenario / branch) +- [ ] Test case 2: [Description] (covers edge case / error handling) +- [ ] Additional coverage: [Description] + +### Component/Module 2 +- [ ] Test case 1: [Description] +- [ ] Test case 2: [Description] +- [ ] Additional coverage: [Description] + +## Integration Tests +**How do we test component interactions?** + +- [ ] Integration scenario 1 +- [ ] Integration scenario 2 +- [ ] API endpoint tests +- [ ] Integration scenario 3 (failure mode / rollback) + +## End-to-End Tests +**What user flows need validation?** + +- [ ] User flow 1: [Description] +- [ ] User flow 2: [Description] +- [ ] Critical path testing +- [ ] Regression of adjacent features + +## Test Data +**What data do we use for testing?** + +- Test fixtures and mocks +- Seed data requirements +- Test database setup + +## Test Reporting & Coverage +**How do we verify and communicate test results?** + +- Coverage commands and thresholds (`npm run test -- --coverage`) +- Coverage gaps (files/functions below 100% and rationale) +- Links to test reports or dashboards +- Manual testing outcomes and sign-off + +## Manual Testing +**What requires human validation?** + +- UI/UX testing checklist (include accessibility) +- Browser/device compatibility +- Smoke tests after deployment + +## Performance Testing +**How do we validate performance?** + +- Load testing scenarios +- Stress testing approach +- Performance benchmarks + +## Bug Tracking +**How do we manage issues?** + +- Issue tracking process +- Bug severity levels +- Regression testing strategy + diff --git a/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts b/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts index 9721d076..0aa7c5cb 100644 --- a/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts +++ b/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js'; +import { AgentRegistry, RenameNotFoundError, RenameConflictError, type RegistryEntry } from '../../utils/AgentRegistry.js'; function makeEntry(over: Partial = {}): RegistryEntry { return { @@ -179,4 +179,45 @@ describe('AgentRegistry', () => { expect(AgentRegistry.default()).toBe(AgentRegistry.default()); }); }); + + describe('rename', () => { + it('updates the name of an existing entry', () => { + registry.register(makeEntry({ name: 'old-name', pid: process.pid })); + registry.rename('old-name', 'new-name'); + expect(registry.lookup('new-name')?.name).toBe('new-name'); + expect(registry.lookup('old-name')).toBeNull(); + }); + + it('preserves all other fields on the renamed entry', () => { + registry.register(makeEntry({ name: 'old-name', pid: process.pid, tmuxSession: 'old-name', cwd: '/my/cwd' })); + registry.rename('old-name', 'new-name'); + const entry = registry.lookup('new-name'); + expect(entry?.tmuxSession).toBe('old-name'); + expect(entry?.cwd).toBe('/my/cwd'); + expect(entry?.pid).toBe(process.pid); + }); + + it('throws RenameNotFoundError when current name does not exist', () => { + expect(() => registry.rename('ghost', 'new-name')).toThrow(RenameNotFoundError); + }); + + it('throws RenameConflictError when new name is already in use by a live entry', () => { + registry.register(makeEntry({ name: 'agent-a', pid: process.pid })); + registry.register(makeEntry({ name: 'agent-b', pid: process.pid })); + expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError); + }); + + it('succeeds when new name exists only as a stale (dead) entry', () => { + registry.register(makeEntry({ name: 'agent-a', pid: process.pid })); + registry.register(makeEntry({ name: 'agent-b', pid: 999999 })); + expect(() => registry.rename('agent-a', 'agent-b')).not.toThrow(); + expect(registry.lookup('agent-b')?.pid).toBe(process.pid); + }); + + it('writes atomically (no leftover .tmp on success)', () => { + registry.register(makeEntry({ name: 'old-name', pid: process.pid })); + registry.rename('old-name', 'new-name'); + expect(fs.existsSync(`${regPath}.tmp`)).toBe(false); + }); + }); }); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 2f308b37..18473f08 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -23,7 +23,7 @@ export { getProcessTty } from './utils/process.js'; export type { AgentSortKey } from './utils/sortAgents.js'; export type { ListAgentsOptions } from './AgentManager.js'; -export { AgentRegistry } from './utils/AgentRegistry.js'; +export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js'; export type { RegistryEntry } from './utils/AgentRegistry.js'; export { TmuxManager } from './terminal/TmuxManager.js'; export { AGENTS } from './utils/agents.js'; diff --git a/packages/agent-manager/src/utils/AgentRegistry.ts b/packages/agent-manager/src/utils/AgentRegistry.ts index dcbb6334..8ed59800 100644 --- a/packages/agent-manager/src/utils/AgentRegistry.ts +++ b/packages/agent-manager/src/utils/AgentRegistry.ts @@ -3,6 +3,20 @@ import os from 'os'; import path from 'path'; import type { AgentType } from '../adapters/AgentAdapter.js'; +export class RenameNotFoundError extends Error { + constructor(public agentName: string) { + super(`Agent "${agentName}" not found in registry.`); + this.name = 'RenameNotFoundError'; + } +} + +export class RenameConflictError extends Error { + constructor(public agentName: string) { + super(`Agent "${agentName}" is already in use.`); + this.name = 'RenameConflictError'; + } +} + export interface RegistryEntry { name: string; type: AgentType; @@ -97,6 +111,23 @@ export class AgentRegistry { this.writeFile(data); } + rename(currentName: string, newName: string): void { + const data = this.readFile(); + const idx = data.entries.findIndex((e) => e.name === currentName); + if (idx < 0) { + throw new RenameNotFoundError(currentName); + } + const liveEntries = data.entries.filter((e) => this.isAlive(e)); + const conflict = liveEntries.find((e) => e.name === newName); + if (conflict) { + throw new RenameConflictError(newName); + } + const pruned = liveEntries.map((e) => + e.name === currentName ? { ...e, name: newName } : e, + ); + this.writeFile({ entries: pruned }); + } + lookup(name: string): RegistryEntry | null { const data = this.readFile(); return data.entries.find((e) => e.name === name) ?? null; diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 4787da76..1be0305f 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -40,8 +40,29 @@ const mockRegistry: any = { list: vi.fn().mockReturnValue([]), register: vi.fn(), isAlive: vi.fn().mockReturnValue(false), + rename: vi.fn(), }; +const { RenameNotFoundError, RenameConflictError } = vi.hoisted(() => { + class RenameNotFoundError extends Error { + agentName: string; + constructor(agentName: string) { + super(`Agent "${agentName}" not found in registry.`); + this.name = 'RenameNotFoundError'; + this.agentName = agentName; + } + } + class RenameConflictError extends Error { + agentName: string; + constructor(agentName: string) { + super(`Agent "${agentName}" is already in use.`); + this.name = 'RenameConflictError'; + this.agentName = agentName; + } + } + return { RenameNotFoundError, RenameConflictError }; +}); + vi.mock('@ai-devkit/agent-manager', () => ({ AgentManager: vi.fn(() => mockManager), ClaudeCodeAdapter: vi.fn(), @@ -73,6 +94,8 @@ vi.mock('@ai-devkit/agent-manager', () => ({ gemini_cli: { command: 'gemini', matches: () => true }, opencode: { command: 'opencode', matches: () => true }, }, + RenameNotFoundError: RenameNotFoundError, + RenameConflictError: RenameConflictError, }), { virtual: true }); vi.mock('inquirer', () => ({ @@ -1077,4 +1100,58 @@ Waiting on user input`, expect(mockManager.listSessions).toHaveBeenCalledWith({ cwd: undefined, type: 'opencode' }); }); }); + + describe('agent rename', () => { + it('calls registry.rename and prints success', async () => { + mockRegistry.rename.mockReturnValue(undefined); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'rename', 'old-name', 'new-name']); + + expect(mockRegistry.rename).toHaveBeenCalledWith('old-name', 'new-name'); + expect(ui.success).toHaveBeenCalledWith('Agent "old-name" renamed to "new-name".'); + }); + + it('exits with error when new name has invalid format', async () => { + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'rename', 'old-name', 'INVALID NAME']); + + expect(mockRegistry.rename).not.toHaveBeenCalled(); + expect(ui.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('prints info and exits 0 when current and new name are the same', async () => { + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'rename', 'same-name', 'same-name']); + + expect(mockRegistry.rename).not.toHaveBeenCalled(); + expect(ui.info).toHaveBeenCalled(); + }); + + it('shows error and exits 1 when agent is not found', async () => { + mockRegistry.rename.mockImplementation(() => { throw new RenameNotFoundError('old-name'); }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'rename', 'old-name', 'new-name']); + + expect(ui.error).toHaveBeenCalledWith('Agent "old-name" not found in registry.'); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('shows error and exits 1 when new name is already in use', async () => { + mockRegistry.rename.mockImplementation(() => { throw new RenameConflictError('new-name'); }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'rename', 'old-name', 'new-name']); + + expect(ui.error).toHaveBeenCalledWith('Agent "new-name" is already in use. Choose a different name.'); + expect(process.exit).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 64d7b925..4322953c 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -17,6 +17,8 @@ import { TerminalFocusManager, TtyWriter, AgentRegistry, + RenameNotFoundError, + RenameConflictError, TmuxManager, AGENTS, type StartableAgentType, @@ -756,6 +758,39 @@ export function registerAgentCommand(program: Command): void { renderConversationDetail(displayMessages, conversation.length, isTruncated); })); + agentCommand + .command('rename ') + .description('Rename an agent in the registry') + .action(withErrorHandler('rename agent', async (currentName: string, newName: string) => { + if (!NAME_REGEX.test(newName)) { + ui.error( + `Invalid name "${newName}". Use lowercase letters, digits, and hyphens only. ` + + 'Must start and end with a letter or digit, 2–64 characters.' + ); + process.exit(1); + return; + } + + if (currentName === newName) { + ui.info(`Agent "${currentName}" already has that name.`); + return; + } + + try { + AgentRegistry.default().rename(currentName, newName); + ui.success(`Agent "${currentName}" renamed to "${newName}".`); + } catch (err) { + if (err instanceof RenameNotFoundError) { + ui.error(err.message); + } else if (err instanceof RenameConflictError) { + ui.error(`Agent "${err.agentName}" is already in use. Choose a different name.`); + } else { + throw err; + } + process.exit(1); + } + })); + agentCommand .command('console') .description('Interactive multi-agent console (open, message, monitor)')