diff --git a/docs/ai/design/2026-10-17-feature-devkitd-agent-start.md b/docs/ai/design/2026-10-17-feature-devkitd-agent-start.md
new file mode 100644
index 000000000..4c653ec24
--- /dev/null
+++ b/docs/ai/design/2026-10-17-feature-devkitd-agent-start.md
@@ -0,0 +1,289 @@
+---
+phase: design
+title: devkitd-agent-start design
+description: agent.start RPC on devkitd — Rust tmux/herdr runtimes, harness start profiles, merged registry columns in daemon.db agents table, dual-write bridge to legacy agents.db, CLI daemon-first routing with TS fallback
+---
+
+# Design — devkitd-agent-start
+
+## Architecture overview
+
+```mermaid
+graph TD
+ subgraph "TS (unchanged callers)"
+ CLI[agent start cmd] -->|daemon reachable| DC[daemon-client
DaemonClient.startAgent]
+ CLI -->|connect fail /
unknown method| TS[ManagedAgentRuntime.startAgent
legacy fallback]
+ TUI[console StartAgentPane] -->|spawns CLI| CLI
+ end
+ subgraph "rust/"
+ D[crates/devkitd
server.rs dispatch] --> AS[rpc/agent/start.rs
orchestration + mutex]
+ AS --> RT[runtime/mod.rs
trait AgentRuntime]
+ RT --> TM[runtime/tmux.rs]
+ RT --> HD[runtime/herdr.rs]
+ AS --> SP[devkit-harness
start_profiles.rs]
+ AS --> ST[devkit-core store.rs + store/registry.rs
agents table + registry cols]
+ AS --> EV[emit agent.started]
+ SW[sweep loop] --> ST
+ end
+ DC -.->|"unix socket ~/.ai-devkit/daemon.sock"| D
+ AS -->|dual-write| LDB[~/.ai-devkit/agents.db
legacy registry]
+ ST -->|boot delta import| LDB
+```
+
+`agent.start` runs on the blocking pool (not `INLINE_METHODS`) — it can take
+up to `timeoutMs` plus herdr retries.
+
+## Wire contract
+
+```json
+→ {"id":1,"method":"agent.start","params":{
+ "type":"claude", // required: StartableAgentType
+ "name":"my-agent", // optional: server generates {basename}-{ts36}
+ "cwd":"/repo", // required
+ "runtime":"tmux", // optional: "tmux"|"herdr", default "tmux"
+ "args":["--flag"], // optional: appended after "--" (herdr)
+ "timeoutMs":15000, // optional: pid-resolution deadline
+ "pollIntervalMs":500 // optional
+ }}
+
+← {"id":1,"result":{
+ "name":"my-agent","type":"claude","pid":12345,
+ "runtime":"tmux","runtimeRef":{"session":"my-agent"},
+ "cwd":"/repo","startedAt":"2026-10-17T07:44:00.000Z",
+ "warnings":["tmux session \"x\" already exists ... will be replaced."]}}
+
+← {"id":1,"error":"Agent \"x\" is already running (PID 42).","code":"name_in_use",
+ "details":{"pid":42}}
+```
+
+`Response` gains two optional fields (both skipped when absent — old clients
+unaffected):
+
+```rust
+pub code: Option,
+pub details: Option, // pid, command, timeoutMs, provider, reason...
+```
+
+New ts-rs-exported types in `proto.rs`: `AgentStartParams`,
+`AgentStartResult`. `runtimeRef` crosses as `serde_json::Value`/`unknown`.
+
+### Error codes
+
+| code | details | CLI rendering |
+|---|---|---|
+| `invalid_params` | `field`, `message` | same text as current validation |
+| `harness_not_startable` | `type` | unsupported-type error |
+| `name_in_use` | `pid` | `Agent "n" is already running (PID p)` |
+| `runtime_unavailable` | `provider`, `reason`, `detail` | tmux install instructions / herdr message |
+| `pid_poll_timeout` | `command`, `timeoutMs` | pid-poll timeout message |
+| `internal` | — | generic throw |
+
+## daemon.db schema — merged `agents` table
+
+Extend the existing snapshot table (no new table; `Store::open` migration
+pattern via `PRAGMA table_info`, matching the `start_time_ms` precedent):
+
+```sql
+ALTER TABLE agents ADD COLUMN name TEXT;
+ALTER TABLE agents ADD COLUMN agent_type TEXT;
+ALTER TABLE agents ADD COLUMN runtime TEXT;
+ALTER TABLE agents ADD COLUMN runtime_ref TEXT NOT NULL DEFAULT '';
+ALTER TABLE agents ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE agents ADD COLUMN registered_at INTEGER;
+CREATE UNIQUE INDEX idx_agents_name ON agents(name) WHERE name IS NOT NULL;
+```
+
+`session_file` (existing, sweep-filled) serves `sessionFilePath`. Declared
+`agent_type` (what the user passed) is stored separately from the
+adapter-derived type to keep them honest when they disagree.
+
+### Snapshot rules (the load-bearing change)
+
+`apply_agent_snapshot` currently keys identity on `(pid, start_time_ms)` and
+deletes every row whose identity vanishes. With registry columns:
+
+1. **Registered-before-swept**: register inserts a row with
+ `start_time_ms = NULL` before the 2s sweep sees the process. When the
+ sweep delivers `(pid, real_st)` for a stored `(pid, NULL)` row → the
+ ON CONFLICT upsert adopts `start_time_ms`; the row is *not* in `gone`
+ (see below) and `appeared` fires normally.
+2. **Pid reuse**: stored `start_time_ms` non-NULL and differs → DELETE the
+ row entirely (name, pin, runtime_ref) then re-insert. A recycled pid
+ never inherits a dead agent's identity.
+3. **True disappearance**: pid absent from the sweep —
+ `name IS NULL` → DELETE (unchanged);
+ `name IS NOT NULL` → tombstone: keep the row (stale `last_seen`, retained
+ `runtime_ref`) so `kill`/`send` on a dead agent still resolves and an
+ orphaned pane stays closable by name. Freed by prune at next
+ `agent.start` name check or future `agent.stop`.
+
+`gone`/`appeared` computation changes: `gone` yields a pid only when the
+stored `start_time_ms` is non-NULL (NULL-st rows were never live). Tombstone
+rows count as `known` only for `gone` purposes — a named row's pid
+reappearing with a *new* `start_time_ms` is identity-change (rule 2), not
+adoption.
+
+### Registry write path
+
+`register_start` (single transaction):
+
+```sql
+INSERT INTO agents (pid, name, agent_type, runtime, runtime_ref,
+ registered_at, first_seen, last_seen)
+VALUES (?,?,?,?,?,?,?,?)
+ON CONFLICT(pid) DO UPDATE SET
+ name=excluded.name, agent_type=excluded.agent_type,
+ runtime=excluded.runtime, runtime_ref=excluded.runtime_ref,
+ registered_at=excluded.registered_at, last_seen=excluded.last_seen
+-- start_time_ms untouched: NULL until first sweep adopts real value
+```
+
+Name uniqueness is enforced by `idx_agents_name` *and* pre-checked under a
+`start_mutex` on `Daemon` — the mutex also serializes concurrent starts so
+two racing `agent.start` calls can't both pass the name check.
+
+Prune semantics move from "row exists" to "live": `name_in_use` fires only
+when the named row's pid is present in the last sweep snapshot (live). Dead
+named rows are reclaimed during the check — matching TS `prune()` behavior
+but liveness-aware.
+
+## Rust components
+
+### `devkit-harness::start_profiles`
+
+```rust
+pub struct StartProfile {
+ pub command: &'static str, // e.g. "kiro-cli" for kiro
+ pub runtime_kind: &'static str, // passed to herdr --kind
+ pub matcher: MatchSpec,
+}
+
+pub enum MatchSpec {
+ Argv0(&'static str), // matchArgv0
+ Argv0Contains(&'static str), // matchArgv0Name
+ AnyBasename(&'static [&'static str]), // matchAnyBasename
+}
+```
+
+Eleven entries mirroring `HARNESS_RUNTIME_PROFILES`. `MatchSpec::matches`
+ports `tokensOf`/`basenameOf`/`mayContinueArgv0` **and `executablePath`'s
+filesystem probing** (`isFile` on progressive prefixes — handles absolute
+paths under space-containing dirs). TS unit-test cases for the matchers are
+ported verbatim.
+
+### `devkitd::runtime` module
+
+```rust
+pub trait AgentRuntime {
+ fn is_available(&self) -> Availability; // {ok, reason, detail}
+ fn start(&self, input: StartInput) -> Result;
+ // {pid, runtime_ref: Value}
+}
+```
+
+- **`TmuxRuntime`** — ports `TmuxManager`: `tmux -V` availability,
+ `has-session`, `new-session -d -s -c `,
+ `send-keys -t Enter`, `list-panes -t -F
+ #{pane_pid}`, `kill-session`; descendant scan via `pgrep -P` BFS +
+ `ps -o command= -p `; `poll_for_pid` with interval/timeout and the
+ **5-consecutive-identical-pid stability rule** (`REQUIRED_STABLE_PID_POLLS`).
+ On timeout: kill session, `pid_poll_timeout`.
+- **`HerdrRuntime`** — ports `HerdrCliClient` + `HerdrResponseParsers`:
+ `herdr --version` and `herdr api snapshot` availability (`binary-missing`
+ vs `backend-unreachable`); `workspace create --cwd --label ` →
+ `result.workspace.workspace_id` + `result.root_pane.pane_id`; `agent start
+ --kind --pane --timeout ` with
+ `agent_pane_busy` retry (5s budget, 100ms interval); `pane process-info
+ --pane ` poll (3s, 100ms) → first `foreground_processes[].pid` else
+ `shell_pid`; runtime_ref `{session:"default", workspaceId?, tabId?, paneId,
+ agentName}`. JSON `error` blocks map to `HerdrRuntimeError(code)` →
+ `runtime_unavailable`/`internal`.
+- All external commands via `std::process::Command` with injected argv-0 /
+ `PATH` for tests (same seams as TS's injectable `runner`/`tmux` deps).
+
+### `devkitd::agent_start` handler
+
+`validate → resolve profile → availability → [start_mutex] prune +
+name-check → orphan-session warn → runtime.start → register (+ dual-write)
+→ emit agent.started → apply_sweep() → return entry+warnings`.
+
+Running `apply_sweep()` right after register reconciles the snapshot,
+adopts `start_time_ms`, refreshes `enriched`, and emits `agent.appeared` —
+both events fire for one start (`agent.started` = declared, `.appeared` =
+observed). Subscribers dedupe by pid; documented behavior.
+
+### Enriched overlay
+
+After `enricher.enrich()`, overlay registry fields for pids with `name`
+non-NULL: `EnrichedAgent.name = row.name` (registered name wins — this is
+the #398 fix), `pinned = row.pinned`. No new `EnrichedAgent` fields;
+consumers needing `runtime_ref` use the legacy DB (dual-write) until
+`agent.lookup` lands in a later feature.
+
+## Legacy agents.db bridge
+
+- **Dual-write**: after registering in daemon.db, if
+ `~/.ai-devkit/agents.db` exists with an `agents` table at schema v5,
+ upsert the same row (`type,pid,name,tmux_session,cwd,started_at,
+ session_id,session_file_path,updated_at,pinned,runtime,runtime_ref`).
+ Absent or older-schema file → skip silently. Keeps `send`/`kill`/`rename`/
+ `open`/channel consumers working unchanged.
+- **Delta import on boot**: `Store::open` — for every legacy row whose
+ `(type,pid)` isn't registered and whose pid isn't in the live snapshot,
+ insert a tombstone (`start_time_ms NULL`, `registered_at` from
+ `started_at`). Every boot, not one-shot: heals entries written by the TS
+ fallback path while the daemon was down. Legacy file wins nothing on
+ conflict — daemon row is authoritative.
+
+## TS changes
+
+- `packages/daemon-client`: `DaemonClient.startAgent(params)` →
+ `AgentStartResult`; errors thrown as `DaemonRpcError {code, details,
+ message}`; `subscribe` unaffected. `requestTimeoutMs` for `agent.start`
+ raised to `timeoutMs + margin` per-call.
+- `packages/cli/src/commands/agent/start.command.ts`: after existing
+ client-side validation, `DaemonClient` via `tryConnect`/`ensureDaemon` →
+ `agent.start` → on `code:"unknown_method"` (old devkitd) or connect
+ failure → existing `startAgent` path unchanged. Result `warnings` →
+ `ui.warning`; error codes map to the exact `reportAgentStartError`
+ messages (tmux instructions resolved client-side).
+- Name generation stays client-side; server-side `{basename}-{ts36}`
+ generation exists only for socket-direct callers omitting `name`.
+- Regenerate `gen/*.ts` via `cargo test -p devkit-core` (ts-rs).
+
+## Alternatives considered
+
+- **Separate `registry` table** — rejected per user direction; merged row
+ gives free overlay but costs the three snapshot rules above.
+- **Daemon shells out to TS helper** — rejected: npm-version coupling +
+ node startup per start; worst of both worlds.
+- **No dual-write, migrate all consumers now** — rejected: sync
+ `AgentRegistry.lookup` call sites would all need async socket reads;
+ balloons scope.
+- **Suppress `agent.appeared` for daemon-started pids** — rejected: extra
+ dedupe state for cosmetic purity; kinds differ so subscribers can
+ distinguish.
+
+## Security / performance
+
+- Socket stays 0600 same-uid (SO_PEERCRED on Linux); `agent.start` spawns
+ processes — same trust level as the CLI, no new privilege boundary.
+- `args` are passed to `herdr` argv, never a shell; tmux `send-keys` sends
+ the harness `command` only (fixed per profile) — caller `args` go nowhere
+ near tmux.
+- Start is O(pane-pid descendants) polling + ≤3 blocking calls; blocking
+ pool absorbs it; `start_mutex` is held across spawn — serial starts are
+ intentional (name uniqueness is global).
+- `daemon.db` WAL + `synchronous=NORMAL` unchanged; registry columns add a
+ few bytes/row.
+
+## Resolved decisions
+
+- Merged `agents` table (not separate `registry`); tombstones for named
+ dead rows; pid-reuse deletes registration.
+- `Response.code` + `details` added; `error` string preserved for old
+ clients.
+- Runtime passed in `params.runtime` (CLI reads `ConfigManager`); daemon
+ defaults `tmux`.
+- Both `agent.started` and `agent.appeared` fire for one start.
+- Client disconnect mid-start → daemon completes (no cancel RPC in v1).
diff --git a/docs/ai/implementation/2026-10-17-feature-devkitd-agent-start.md b/docs/ai/implementation/2026-10-17-feature-devkitd-agent-start.md
new file mode 100644
index 000000000..1cde89cd0
--- /dev/null
+++ b/docs/ai/implementation/2026-10-17-feature-devkitd-agent-start.md
@@ -0,0 +1,108 @@
+---
+phase: implementation
+title: Implementation — devkitd-agent-start
+description: What shipped — agent.start RPC in devkitd, merged registry columns in daemon.db agents table, Rust tmux/herdr runtimes, harness start profiles, dual-write legacy bridge, daemon-first CLI routing
+---
+
+# Implementation — devkitd-agent-start
+
+## What changed
+
+### devkit-core (`rust/crates/devkit-core`)
+
+- `proto.rs` — `Response` gains `code`/`details` (both skipped when absent);
+ new `err_coded` constructor; `AgentStartParams`/`AgentStartResult` structs
+ exported via ts-rs to `packages/daemon-client/src/gen/`.
+- `store.rs` + `store/registry.rs` — `agents` table extended with nullable registry columns; store.rs holds connection/schema/event log/snapshot, store/registry.rs holds the registration lifecycle + legacy agents.db bridge
+ (`name`, `agent_type`, `runtime`, `runtime_ref`, `pinned`,
+ `registered_at`, `gone_at`) + partial unique index on `name`.
+ `apply_agent_snapshot` rewritten around the three rules: registered-
+ before-swept adoption (NULL `start_time_ms`), pid-reuse full delete,
+ named-tombstone retention (`gone_at` dedupes `agent.disappeared`).
+ Upsert uses `COALESCE(start_time_ms)` so flaky start-time reads never
+ clear identity. New API: `register_start`, `lookup_name`, `live_name`,
+ `unregister`, `prune_dead`, `registry_overlays`, `set_legacy_db`,
+ `import_legacy_agents`, `iso8601_utc`. Legacy bridge: delta import on
+ boot (attach-by-pid to live rows, dead-by-default tombstones otherwise)
+ + dual-write on `register_start`.
+
+### devkit-harness (`rust/crates/devkit-harness`)
+
+- `start_profiles.rs` — `MatchSpec` (`Argv0` | `Argv0Contains` |
+ `AnyBasename`) porting the TS matcher closures including
+ `executablePath`'s filesystem probing for space-containing argv0;
+ `StartProfile` table covering all 11 `StartableAgentType`s (kiro
+ launches `kiro-cli`).
+
+### devkitd (`rust/crates/devkitd`)
+
+- `runtime/mod.rs` — `AgentRuntime` trait, `Availability`, `StartInput`,
+ `StartOutput`, `StartError`.
+- `runtime/tmux.rs` — `TmuxRuntime` (tmux session mgmt, `pgrep -P` BFS,
+ `ps -o command=`, 5-stable-poll PID resolution, timeout → kill-session).
+- `runtime/herdr.rs` — `HerdrRuntime` (`--version`/`api snapshot`
+ availability, workspace create, agent start with `agent_pane_busy`
+ retry, process-info poll → foreground/shell pid).
+- `rpc/agent/start.rs` — orchestration: validate → availability →
+ `start_mutex` → prune/liveness name check → orphan-session warning →
+ spawn → register (+dual-write) → `agent.started` event → `apply_sweep`.
+- `server.rs` — `Daemon.start_mutex`, `emit` → `pub(crate)`, `"agent.start"`
+ dispatch arm (blocking pool, not INLINE_METHODS), registry overlay in
+ `apply_sweep` (registered `name`/`pinned` win over adapter labels),
+ `Daemon::new` wires legacy-db bridge.
+- `main.rs` — `mod runtime`, `mod rpc`.
+
+### TS (`packages/daemon-client`, `packages/cli`)
+
+- `client.ts` — `DaemonRpcError {code, details}`; `request()` per-call
+ timeout; `DaemonClient.startAgent()` (timeout = poll timeout + 15s).
+- `start.command.ts` — `tryDaemonStart`: `ensureDaemon()` →
+ `daemon.startAgent` → coded errors render identical messages/exit
+ codes; `unknown method` or unreachable → existing TS `startAgent`
+ unchanged. Transport failure mid-request reports rather than retries
+ (the daemon may have completed the start).
+- `agent.test.ts` — daemon-client module mock + 6 daemon-routed tests.
+- `integration.test.ts` — isolated-HOME real-socket test asserting coded
+ errors round-trip.
+
+## Deviations from design
+
+- `gone_at` column added beyond the doc'd set — needed to stop repeated
+ `agent.disappeared` for tombstones and to make `live_name` meaningful.
+- `start_mutex` held across spawn+register only (as designed), but the
+ name check uses `kill(pid,0)` liveness rather than snapshot membership —
+ pending rows (registered, never swept) are judged by real liveness.
+- `process.exit` in the CLI error path needs an explicit `return` after
+ it — `process.exit` is mocked in vitest so execution would fall through
+ into the fallback spawn.
+- `import_legacy_agents` attaches names to live rows by pid only
+ (documented narrow pid-reuse window) rather than a start-time match —
+ `started_at` ISO parsing was traded for the simpler attach.
+
+## Edge cases handled
+
+- Registered-before-swept rows (start beats the 2s sweep): adopted, not
+ deleted; `agent.started` + `agent.appeared` both fire (documented).
+- pid reuse can't inherit a dead agent's name (identity change → full
+ row delete).
+- Client disconnect mid-start: daemon completes; agent registered.
+- Stale devkitd on socket: `unknown method` → TS fallback.
+- Daemon env `PATH` may lack tmux/herdr → `runtime_unavailable` surfaces
+ the same install hint as today.
+
+## Test evidence
+
+- `cargo test --workspace`: 133 tests green (30 devkit-core, 80
+ devkit-harness, 23 devkitd).
+- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
+- `packages/cli` agent.test.ts: 106 tests (6 new daemon-routed).
+- `packages/daemon-client` integration: real-socket coded-error
+ round-trip.
+
+## Follow-ups (not this feature)
+
+- `agent.stop`/`rename`/`send`/`open`/`pin` RPCs → then remove
+ dual-write + import.
+- `agent.started` payload could carry the enriched row once consumers
+ need it.
+- tmux PATH refresh for systemd-installed daemons.
diff --git a/docs/ai/planning/2026-10-17-feature-devkitd-agent-start.md b/docs/ai/planning/2026-10-17-feature-devkitd-agent-start.md
new file mode 100644
index 000000000..eb2d0be5b
--- /dev/null
+++ b/docs/ai/planning/2026-10-17-feature-devkitd-agent-start.md
@@ -0,0 +1,123 @@
+---
+phase: planning
+title: Plan — devkitd-agent-start
+description: Task breakdown for agent.start RPC — proto, schema, Rust runtimes, orchestration, daemon-client, CLI routing
+---
+
+# Plan — devkitd-agent-start
+
+## Milestones
+
+- [x] M1: Wire contract + schema (proto code/details, agents-table registry
+ columns, snapshot rules)
+- [x] M2: Rust runtime layer (start profiles + matchers, tmux, herdr)
+- [x] M3: `agent.start` handler + registry overlay + events + bridge
+ (dual-write, boot import)
+- [x] M4: TS client + CLI routing/fallback
+- [ ] M5: Full verification + docs
+
+## Task Breakdown
+
+### M1 — wire contract + schema
+
+- [x] T1.1: `proto.rs` — add `code: Option` +
+ `details: Option` to `Response` (skip_serializing_if None); add
+ `AgentStartParams`/`AgentStartResult` structs with ts-rs exports;
+ `Response::err` variants keep `code=None` unless provided. Verify:
+ `cargo test -p devkit-core` regenerates `gen/*.ts`.
+- [x] T1.2: `store.rs` — extend `agents` migration block: add `name`,
+ `agent_type`, `runtime`, `runtime_ref`, `pinned`, `registered_at`
+ columns (PRAGMA table_info loop like `start_time_ms`); create partial
+ unique index `idx_agents_name`. Verify: fresh + existing-db open both
+ succeed (unit test).
+- [x] T1.3: `store.rs` — new snapshot rules in `apply_agent_snapshot`:
+ NULL-`start_time_ms` rows excluded from `gone` (adopted by upsert);
+ non-NULL identity change → full delete; absent pid with `name` →
+ tombstone keep; absent pid without name → delete. Plus
+ `register_start` upsert, `lookup_name`, `live_names` (names whose pids
+ are in last snapshot), `prune_dead`. Verify: unit tests per testing doc
+ (registered-before-swept, pid reuse, tombstone, unique name).
+- [x] T1.4: `store.rs` — legacy bridge: `import_legacy_agents(path)` run
+ on `Store::open` (delta merge, tombstone insert for rows whose
+ (type,pid) not registered and pid not live); `dual_write_legacy(row)`
+ used by register when `~/.ai-devkit/agents.db` exists at schema v5.
+ Verify: fixture-db unit tests.
+
+### M2 — Rust runtime layer
+
+- [x] T2.1: `devkit-harness/src/start_profiles.rs` — `MatchSpec` enum +
+ `matches()` porting `tokensOf`/`basenameOf`/`mayContinueArgv0`/
+ `executablePath` (incl. `isFile` probing); `StartProfile` table for all
+ 11 types; `start_profile(type) -> Option`. Verify: ported
+ matcher tests incl. spaced-dir tmpdir cases green.
+- [x] T2.2: `devkitd/src/runtime/mod.rs` — `AgentRuntime` trait,
+ `Availability`, `StartInput`, `StartOutput`, `StartError`; command-runner
+ seam (argv0/PATH injectable).
+- [x] T2.3: `runtime/tmux.rs` — `TmuxRuntime`: `-V` availability;
+ session_exists/create/send_keys/list_pane_pids/kill_session;
+ `pgrep -P` BFS + `ps -o command=`; `poll_for_pid` (interval, timeout,
+ 5-stable rule); timeout → kill_session + `pid_poll_timeout`. Verify:
+ fake-tmux script tests.
+- [x] T2.4: `runtime/herdr.rs` — `HerdrRuntime`: `--version` +
+ `api snapshot` availability; `workspace create` parse; `agent start`
+ with `agent_pane_busy` retry (5s/100ms); `pane process-info` poll
+ (3s/100ms, foreground-first then shell_pid); runtimeRef build. Verify:
+ fake-herdr JSON script tests incl. busy-retry.
+
+### M3 — handler + registry integration
+
+- [x] T3.1: `devkitd/src/agent_start.rs` — orchestration: validate
+ (name regex `/^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/` ported without regex
+ crate, cwd isdir, type→profile, runtime known), default-name
+ generation `{basename-sanitized}-{ts36}`, availability, `start_mutex`
+ → live-name check → orphan-session warning → `runtime.start` →
+ `register_start` + dual-write → `emit("agent.started")` →
+ `apply_sweep()` → result + warnings.
+- [x] T3.2: `server.rs` — `Daemon.start_mutex`; `"agent.start"` arm in
+ `dispatch` (NOT in `INLINE_METHODS`); error→code mapping
+ (`name_in_use`, `runtime_unavailable`, `pid_poll_timeout`,
+ `harness_not_startable`, `invalid_params`, `internal`).
+- [x] T3.3: Enriched overlay in `apply_sweep` — after `enrich`, rows with
+ `name` override `EnrichedAgent.name` and `pinned`.
+- [x] T3.4: `Store::open` boot import wiring + `agent.list` returns
+ overlayed names. Verify: e2e socket test — start → list shows name;
+ subscribe sees `agent.started` + `agent.appeared`.
+
+### M4 — TS client + CLI
+
+- [x] T4.1: `daemon-client` — `DaemonClient.startAgent(params)`;
+ `DaemonRpcError` exposing `code`/`details`; per-request timeout
+ override for long-running methods. Verify: vitest socket roundtrip
+ (build devkitd first — `DEVKITD_BIN`).
+- [x] T4.2: `cli start.command.ts` — daemon-first routing via
+ `tryConnect`/`ensureDaemon`; `params.runtime` from `ConfigManager`;
+ `warnings` → `ui.warning`; error codes → existing message texts; on
+ connect-fail/`unknown method` → existing `startAgent` unchanged;
+ `--mode durable` bypasses daemon path entirely.
+- [ ] T4.3: Verify `nx test` for `daemon-client` + `cli` packages;
+ `agent.test.ts` untouched and green.
+
+### M5 — verification + docs
+
+- [ ] T5.1: `cargo fmt --check`, `cargo clippy -- -D warnings`,
+ `cargo test --workspace` green.
+- [ ] T5.2: `nx run-many -t build,test,lint` green.
+- [ ] T5.3: Manual smoke on macOS: real `tmux`, shim binary, `agent
+ start` via daemon; attach hint; `agent list` name; `agent kill`
+ cleanup (proves dual-write worked).
+- [ ] T5.4: Implementation doc + changelog entry.
+
+## Sequencing notes & risks
+
+- T1.3 must land before T3.1 (handler depends on registry primitives).
+- T2.x are parallel-safe; T2.1 gates both.
+- Risk: `executablePath` filesystem probing — port semantics exactly;
+ spaced-dir tests exist to catch drift.
+- Risk: stale-binary trap in daemon-client tests — always
+ `cargo build -p devkitd` before vitest (memory note: tests auto-select
+ `rust/target/{release,debug}`).
+- Risk: `agent.appeared` + `agent.started` both firing — accepted;
+ subscribers dedupe by pid.
+- Follow-up (not this feature): `agent.stop`/`rename`/`send`/`open` RPCs
+ let the legacy dual-write be removed; `Response.details` reusable for
+ them.
diff --git a/docs/ai/requirements/2026-10-17-feature-devkitd-agent-start.md b/docs/ai/requirements/2026-10-17-feature-devkitd-agent-start.md
new file mode 100644
index 000000000..08d3c3a69
--- /dev/null
+++ b/docs/ai/requirements/2026-10-17-feature-devkitd-agent-start.md
@@ -0,0 +1,149 @@
+---
+phase: requirements
+title: Migrate agent start to devkitd — agent.start RPC, daemon-owned registry
+description: Move interactive agent spawning (tmux + herdr) from the TS agent-manager into devkitd; daemon.db becomes the registry source of truth; fixes the discovery-race name loss
+---
+
+# Requirements — devkitd-agent-start
+
+## Problem Statement
+
+`ai-devkit agent start` spawns agents entirely on the TypeScript side
+(`packages/agent-manager`: `startAgent` → `TmuxAgentRuntime` /
+`HerdrAgentRuntime` → `AgentRegistry` SQLite). Meanwhile `devkitd` owns live
+process discovery, the enriched agent list, and the event stream. Split
+authority produces a concrete, observed bug class:
+
+1. **Discovery race loses the requested name** (GitHub issue #398). The
+ console spawns `agent start` as a child CLI; the daemon's 2s sweep sees the
+ new process before `registry.register()` lands, so a parent refresh
+ registers the fallback `ai-devkit-` name first — and
+ `AgentRegistry.mergeEntry` preserves the existing name, so the fallback
+ wins permanently. The user's chosen name is lost and the session preview
+ stays unavailable until console restart.
+2. **Two sources of truth.** The daemon's `agents` snapshot table knows
+ processes; the TS registry DB knows names, runtime, runtimeRef, pinned.
+ Neither can answer "who owns this pid" alone.
+3. **No start event.** The daemon emits only `agent.appeared` on its sweep
+ cadence; subscribers get a late, unattributed notification instead of an
+ immediate `agent.started` carrying the requested identity.
+
+## Goals & Objectives
+
+**Primary:**
+
+- Add an `agent.start` JSON-RPC method to devkitd that performs the whole
+ start sequence: validate → availability → name-uniqueness → orphan-session
+ handling → spawn → PID resolution → register → emit `agent.started`.
+- Port both interactive runtimes to Rust: `tmux` (session create, send-keys,
+ pane-pid descendant scan, stable-pid polling) and `herdr` (`herdr` CLI:
+ workspace create, agent start with `agent_pane_busy` retry, pane
+ process-info poll).
+- Make `daemon.db` the registry source of truth by extending the existing
+ `agents` table with nullable registry columns (`name`, `agent_type`,
+ `runtime`, `runtime_ref`, `pinned`, `registered_at`) — one row per process
+ carries both observed and declared state.
+- CLI routes `agent start` through the daemon when reachable, falling back to
+ the current TS path on connect-failure or `unknown method` (old devkitd).
+- Structured error codes over the wire so the CLI reproduces today's exact
+ messages and exit codes.
+
+**Secondary:**
+
+- Emit `agent.started` immediately (don't wait for the sweep) and refresh the
+ enriched cache so `agent.list` shows the registered name at once.
+- Every-boot delta import of the legacy agents DB into `daemon.db` so
+ pre-migration rows heal forward.
+- Dual-write registrations into the legacy agents DB while it exists so
+ unmigrated consumers (`send`, `kill`, `rename`, `open`, channel bridge)
+ keep working.
+
+**Non-goals:**
+
+- Durable agents (`--mode durable` / `createDurableAgentService`) stay
+ entirely on the TS path.
+- Migrating `agent stop|send|rename|open|pin` to RPC — out of scope; they
+ keep working via the dual-write bridge.
+- Daemon-spawned processes for channels, bridge supervision, scheduled work.
+- Version-gated daemon restart on CLI upgrade (tracked separately, #376) —
+ this feature only needs the `unknown method` fallback.
+- Changes to the wire protocol framing (still newline-delimited JSON).
+
+## User Stories & Use Cases
+
+- As a CLI user, `ai-devkit agent start --type claude --name foo --cwd .`
+ prints the same success line, working directory, and attach hint whether
+ the daemon or the fallback path served it — and the requested name always
+ sticks.
+- As a console user, starting an agent from the start pane shows it in the
+ list under its requested name immediately (the console spawns the CLI
+ subprocess, which routes through the daemon).
+- As an agent-running-an-agent, `agent.start` is callable over the socket
+ without a nested CLI subprocess.
+- As a maintainer, harness start knowledge lives next to the harness adapter
+ in `devkit-harness`, not split across languages.
+
+**Edge cases:**
+
+- Same name started twice concurrently → `name_in_use`, not a tmux-session
+ clobber race.
+- Registered pid dies before the next sweep → tombstone row keeps
+ `runtime_ref` so the orphaned tmux/herdr pane is still cleanable by name.
+- Pid reused by an unrelated process → must not inherit the dead agent's
+ name (identity is `(pid, start_time_ms)`).
+- Daemon spawned under systemd/autostart has a narrower `PATH` than the
+ invoking shell — `tmux`/`herdr` binaries must resolve in the daemon's env.
+- Client disconnects mid-start → daemon completes the start; the agent is
+ registered and visible in `agent.list` (strictly better than today's
+ orphaned-session outcome).
+
+## Success Criteria
+
+- `ai-devkit agent start` succeeds through the daemon for every startable
+ harness type on both runtimes; output and exit codes match the current
+ CLI exactly.
+- Requested name is authoritative: no `ai-devkit-` fallback clobber
+ even when the sweep wins the race (regression for #398).
+- `agent.started` event is emitted and replayable via `subscribe
+ {"afterSeq"}`; `agent.list` returns the agent under its requested name
+ immediately after the RPC resolves.
+- Duplicate-name start returns `name_in_use`; dead-pid names are freed by
+ liveness-aware pruning.
+- With an old devkitd on the socket (`unknown method`) or no daemon at all,
+ the CLI falls back to the TS path and still succeeds.
+- `cargo test`, `cargo clippy -D warnings`, `cargo fmt --check` green;
+ `nx run-many -t build,test,lint` green including daemon-client typecheck
+ against regenerated proto types.
+- Legacy agents DB receives the dual-written row while it exists; on daemon
+ boot, missing rows are imported.
+
+## Constraints & Assumptions
+
+- The daemon is per-user, single-process; all registry mutation funnels
+ through it, so a `start_mutex` makes name-uniqueness atomic.
+- The wire `Response` gains an optional `code` field; `error` stays a
+ human-readable string for old clients.
+- Blocking RPC: start may take up to `timeoutMs` (default 15s) plus herdr
+ retries (~8s worst case). Runs on the blocking pool; client request
+ timeout must cover it.
+- Harness matchers port as data (`Argv0 | Argv0Contains | AnyBasename`),
+ not closures; semantics copied verbatim from `runtimeProfiles.ts`.
+- Assumption: `opencode`, `pi`, `muse` need start profiles too even though
+ devkit-harness lacks full adapters for them — a start profile is just
+ command + match spec + discovery aliases.
+- Assumption: user approved dual-write bridge and every-boot delta import
+ (decisions from pre-lifecycle brainstorm).
+
+## Questions & Open Items — resolved
+
+- **Registry storage**: merged into the existing `daemon.db.agents` table
+ (nullable registry columns) rather than a second table. Requires three
+ snapshot rules: NULL-`start_time_ms` rows are "registered, not yet swept"
+ (upsert, don't delete); identity-change on a swept row → full delete
+ (pid reuse drops names); true disappearance keeps named tombstones for
+ runtime_ref cleanup, freeing the name only via prune.
+- **Scope**: herdr runtime in; durable agents out.
+- **Transition**: dual-write to legacy DB + delta import on boot; remove
+ when remaining registry ops migrate to RPC.
+- **Cancellation**: daemon completes start even if the client disconnects —
+ no `agent.start.cancel` in v1.
diff --git a/docs/ai/testing/2026-10-17-feature-devkitd-agent-start.md b/docs/ai/testing/2026-10-17-feature-devkitd-agent-start.md
new file mode 100644
index 000000000..7ec7635e5
--- /dev/null
+++ b/docs/ai/testing/2026-10-17-feature-devkitd-agent-start.md
@@ -0,0 +1,135 @@
+---
+phase: testing
+title: devkitd-agent-start test coverage
+description: Rust unit + socket e2e for agent.start, tmux/herdr via fake-CLI fixtures, snapshot-rule tests, daemon-client and CLI fallback tests
+---
+
+# Testing — devkitd-agent-start
+
+## Test Coverage Goals
+
+- 100% coverage on new Rust modules (`start_profiles`, `runtime/*`,
+ `agent_start` handler) and new `store.rs` registry paths.
+- Socket-level e2e mirroring existing `server.rs` tests (temp dir, real
+ unix socket).
+- External tools (`tmux`, `herdr`, `pgrep`, `ps`) faked with PATH-injected
+ shell-script fixtures — no real tmux/herdr required in CI.
+- CLI behavior identical on daemon path and fallback path.
+
+## Unit Tests
+
+### `devkit-harness::start_profiles` + `MatchSpec`
+
+- [x] Port every case from `runtimeProfiles.test.ts`: argv0 match rejects
+ similar argument text; discovery aliases resolve; versioned launcher
+ (muse) profiles.
+- [x] Spaced-directory cases: argv0 under `My Apps/`, distribution-name
+ match, argument-containing-spaces not treated as executable, "agent word
+ inside executable directory" non-match. Uses a tmpdir fixture for
+ `isFile` probing.
+- [x] All 11 `StartableAgentType`s have profiles; unknown type → `None`
+ → `harness_not_startable`.
+- [x] kiro profile: `command == "kiro-cli"`, `runtime_kind == "kiro"`.
+
+### `devkit-core::store` — registry columns + snapshot rules
+
+- [x] `register_start` upsert: new row gets name/type/runtime/ref;
+ conflict on pid preserves sweep columns, updates registry columns.
+- [x] Registered-before-swept: row with `start_time_ms NULL` + name, then
+ sweep sees `(pid, real_st)` → row updated, not deleted; `appeared`
+ contains pid; `gone` does not.
+- [x] Pid reuse: named row with old `start_time_ms`, sweep sees same pid
+ new `start_time_ms` → row deleted+reinserted, name cleared;
+ disappeared+appeared both emitted.
+- [x] Tombstone: named row's pid absent from sweep → row survives, not in
+ `gone`… wait — it IS gone (disappeared event must fire) but row kept.
+ Assert: `agent.disappeared` fires once, row survives with name.
+- [x] Tombstone pid reuse: dead named row, new proc takes pid → name
+ freed, no inheritance.
+- [x] Name UNIQUE index: second `register_start` same name → constraint
+ error → `name_in_use`.
+- [x] `prune_dead`: named rows whose pids absent from live snapshot are
+ reclaimed by `name_is_live` check.
+- [x] Delta import: legacy `agents.db` rows appear as tombstones; existing
+ daemon rows win.
+
+### `devkitd::runtime::tmux`
+
+- [x] Fake `tmux` script: `-V` ok → available; missing → `binary-missing`.
+- [x] Start sequence argv exact: `new-session -d -s N -c C`,
+ `send-keys -t N Enter`.
+- [x] Existing session → `kill-session` before create; warning surfaced.
+- [x] Pid polling: fake `list-panes`/`pgrep`/`ps` returning matching
+ command → pid returned; stability rule needs 5 identical polls (fake
+ returns alternating pids → keeps polling).
+- [x] Timeout → `kill-session` issued → `pid_poll_timeout`.
+- [x] Descendant match at depth >1 (pane→shell→node→claude chain).
+
+### `devkitd::runtime::herdr`
+
+- [x] Availability: `--version` ok + `api snapshot` ok → available; ENOENT
+ → `binary-missing`; snapshot failure → `backend-unreachable`.
+- [x] `workspace create` JSON → `workspace_id` + `root_pane.pane_id`;
+ missing pane id → error.
+- [x] `agent start` argv: `--kind`, `--pane`, `--timeout`, `-- args`.
+- [x] `agent_pane_busy` JSON error → retried within 5s/100ms budget;
+ persistent busy → `runtime_unavailable`-class error.
+- [x] `pane process-info`: `foreground_processes[0].pid` preferred;
+ fallback `shell_pid`; poll till deadline.
+- [x] runtimeRef shape: `{session:"default", workspaceId, tabId, paneId,
+ agentName}`.
+
+### `devkitd::agent_start` handler + `server.rs` dispatch
+
+- [x] Validation: bad name regex, missing cwd, unknown type, unknown
+ runtime → `invalid_params`/`harness_not_startable`.
+- [x] `name_in_use` with live pid → error carries `pid` in `details`.
+- [x] Dead named row → pruned, start proceeds (liveness-aware name check).
+- [x] `agent.started` emitted with entry payload; replayable via
+ `subscribe afterSeq`.
+- [x] Concurrent same-name starts serialized by `start_mutex` → exactly
+ one succeeds.
+- [x] Post-start `agent.list` shows registered name (enriched overlay).
+- [x] `Response.code`/`details` serialize; old `error` string intact.
+- [x] Dual-write: legacy db fixture receives row; missing legacy file →
+ skip silently.
+- [x] Client disconnect mid-start → start completes, row registered.
+
+### `daemon-client` TS
+
+- [x] `startAgent` params/result wire roundtrip over real socket
+ (`DEVKITD_BIN` override; build `cargo build -p devkitd` first — see
+ stale-binary memory note).
+- [x] Error code → `DaemonRpcError` with `code`/`details`.
+- [x] `unknown method` from old daemon → distinguishable for fallback.
+
+### `cli` TS
+
+- [x] `agent start` with daemon up → result printed, warnings forwarded,
+ exit 0 (mock DaemonClient).
+- [x] Daemon down / `unknown method` → TS `startAgent` invoked (spy).
+- [x] Each error code maps to the same `ui.error` text as today's typed
+ errors; exit code 1 preserved.
+- [x] `--mode durable` never touches the daemon path.
+- [x] Existing `agent.test.ts` suite stays green (fallback path).
+
+## Integration / E2E
+
+- [x] Temp HOME: daemon serve → `agent.start` → `agent.list` shows name;
+ `subscribe` sees `agent.started` then `agent.appeared`.
+- [x] Real `tmux` smoke (manual/CI-optional, macOS+Linux): actual session,
+ actual `claude`-shim binary, attach hint correct.
+- [x] Kill spawned proc → `agent.disappeared` + tombstone; start same name
+ again → succeeds (name freed by prune).
+
+## Mocks / fixtures
+
+- `rust/crates/devkitd/tests/fixtures/`: fake `tmux`, `herdr`, `pgrep`,
+ `ps` shell scripts driven by env-provided state files.
+- Legacy `agents.db` fixture at schema v5 with representative rows.
+- Reuse existing `server.rs` tempdir-socket test harness.
+
+## Coverage target
+
+New Rust code ≥ 90% line coverage; all error codes exercised; both
+runtimes covered by fake-CLI tests; CLI fallback covered by vitest.
diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts
index df7702ff1..6b6e9f030 100644
--- a/packages/cli/src/__tests__/commands/agent.test.ts
+++ b/packages/cli/src/__tests__/commands/agent.test.ts
@@ -89,6 +89,8 @@ const {
mockHerdrReadOutput,
mockHerdrFocus,
mockHerdrStop,
+ mockEnsureDaemon,
+ mockDaemonStartAgent,
} = vi.hoisted(() => ({
mockEnableDebug: vi.fn(),
mockDebugLogger: vi.fn(),
@@ -108,6 +110,8 @@ const {
mockHerdrReadOutput: vi.fn().mockResolvedValue("done\n"),
mockHerdrFocus: vi.fn().mockResolvedValue(true),
mockHerdrStop: vi.fn().mockResolvedValue(undefined),
+ mockEnsureDaemon: vi.fn().mockResolvedValue(null),
+ mockDaemonStartAgent: vi.fn(),
}));
let restoreStdin: (() => void) | undefined;
@@ -337,6 +341,14 @@ vi.mock("../../lib/Config.js", () => ({
}),
}));
+vi.mock("@ai-devkit/daemon-client", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ ensureDaemon: mockEnsureDaemon,
+ };
+});
+
vi.mock("../../tui/console/ConsoleApp.js", () => ({
ConsoleApp: () => null,
}));
@@ -417,6 +429,8 @@ describe("agent command", () => {
mockDurableService.send.mockReset();
mockPiPrintService.create.mockReset();
mockPiPrintService.send.mockReset();
+ mockEnsureDaemon.mockReset().mockResolvedValue(null);
+ mockDaemonStartAgent.mockReset();
mockFocusManager.findTerminal.mockReset();
mockFocusManager.focusTerminal.mockReset();
mockTtyWriterSend.mockReset().mockResolvedValue(undefined);
@@ -761,6 +775,199 @@ describe("agent command", () => {
expect(process.exit).toHaveBeenCalledWith(1);
});
+ describe("daemon-routed start", () => {
+ function daemonUp(result: unknown) {
+ const client = {
+ startAgent: mockDaemonStartAgent,
+ close: vi.fn(),
+ };
+ mockEnsureDaemon.mockResolvedValue(client);
+ mockDaemonStartAgent.mockResolvedValue(result);
+ return client;
+ }
+
+ it("starts through the daemon and skips the local path", async () => {
+ const client = daemonUp({
+ name: "agent1",
+ type: "claude",
+ pid: 4242,
+ runtime: "tmux",
+ runtimeRef: { session: "agent1" },
+ cwd: process.cwd(),
+ startedAt: "2026-10-17T00:00:00.000Z",
+ warnings: [],
+ });
+ const program = new Command();
+ registerAgentCommand(program);
+
+ await program.parseAsync([
+ "node",
+ "test",
+ "agent",
+ "start",
+ "--type",
+ "claude",
+ "--name",
+ "agent1",
+ ]);
+
+ expect(mockDaemonStartAgent).toHaveBeenCalledWith({
+ type: "claude",
+ name: "agent1",
+ cwd: process.cwd(),
+ runtime: "tmux",
+ });
+ expect(mockStartAgent).not.toHaveBeenCalled();
+ expect(ui.success).toHaveBeenCalledWith('Agent "agent1" started (claude, PID 4242)');
+ expect(ui.text).toHaveBeenCalledWith("Attach: tmux attach -t agent1");
+ expect(client.close).toHaveBeenCalled();
+ });
+
+ it("forwards daemon warnings", async () => {
+ daemonUp({
+ name: "agent1",
+ type: "claude",
+ pid: 1,
+ runtime: "tmux",
+ runtimeRef: { session: "agent1" },
+ cwd: process.cwd(),
+ startedAt: "2026-10-17T00:00:00.000Z",
+ warnings: [
+ 'tmux session "agent1" already exists but has no live registry entry — it will be replaced.',
+ ],
+ });
+ const program = new Command();
+ registerAgentCommand(program);
+
+ await program.parseAsync([
+ "node",
+ "test",
+ "agent",
+ "start",
+ "--type",
+ "claude",
+ "--name",
+ "agent1",
+ ]);
+
+ expect(ui.warning).toHaveBeenCalledWith(
+ 'tmux session "agent1" already exists but has no live registry entry — it will be replaced.',
+ );
+ });
+
+ it("reports coded daemon errors without falling back", async () => {
+ const { DaemonRpcError } = await import("@ai-devkit/daemon-client");
+ const client = {
+ startAgent: mockDaemonStartAgent,
+ close: vi.fn(),
+ };
+ mockEnsureDaemon.mockResolvedValue(client);
+ mockDaemonStartAgent.mockRejectedValue(
+ new DaemonRpcError(
+ 'Agent "agent1" is already running (PID 77). Choose a different name.',
+ "name_in_use",
+ { pid: 77 },
+ ),
+ );
+ const program = new Command();
+ registerAgentCommand(program);
+
+ await program.parseAsync([
+ "node",
+ "test",
+ "agent",
+ "start",
+ "--type",
+ "claude",
+ "--name",
+ "agent1",
+ ]);
+
+ expect(ui.error).toHaveBeenCalledWith(
+ 'Agent "agent1" is already running (PID 77). Choose a different name.',
+ );
+ expect(mockStartAgent).not.toHaveBeenCalled();
+ expect(process.exit).toHaveBeenCalledWith(1);
+ });
+
+ it("maps pid_poll_timeout details to the existing message", async () => {
+ const { DaemonRpcError } = await import("@ai-devkit/daemon-client");
+ mockEnsureDaemon.mockResolvedValue({
+ startAgent: mockDaemonStartAgent,
+ close: vi.fn(),
+ });
+ mockDaemonStartAgent.mockRejectedValue(
+ new DaemonRpcError("timeout", "pid_poll_timeout", {
+ command: "claude",
+ timeoutMs: 15000,
+ }),
+ );
+ const program = new Command();
+ registerAgentCommand(program);
+
+ await program.parseAsync([
+ "node",
+ "test",
+ "agent",
+ "start",
+ "--type",
+ "claude",
+ "--name",
+ "agent1",
+ ]);
+
+ expect(ui.error).toHaveBeenCalledWith(
+ 'Agent process not found after 15s. Verify that "claude" is in PATH inside the tmux environment.',
+ );
+ expect(process.exit).toHaveBeenCalledWith(1);
+ });
+
+ it("falls back to the local path on unknown method (old devkitd)", async () => {
+ const { DaemonRpcError } = await import("@ai-devkit/daemon-client");
+ mockEnsureDaemon.mockResolvedValue({
+ startAgent: mockDaemonStartAgent,
+ close: vi.fn(),
+ });
+ mockDaemonStartAgent.mockRejectedValue(new DaemonRpcError("unknown method: agent.start"));
+ const program = new Command();
+ registerAgentCommand(program);
+
+ await program.parseAsync([
+ "node",
+ "test",
+ "agent",
+ "start",
+ "--type",
+ "claude",
+ "--name",
+ "agent1",
+ ]);
+
+ expect(mockStartAgent).toHaveBeenCalledTimes(1);
+ expect(ui.success).toHaveBeenCalledWith('Agent "agent1" started (claude, PID 12345)');
+ });
+
+ it("falls back to the local path when the daemon is unreachable", async () => {
+ mockEnsureDaemon.mockResolvedValue(null);
+ const program = new Command();
+ registerAgentCommand(program);
+
+ await program.parseAsync([
+ "node",
+ "test",
+ "agent",
+ "start",
+ "--type",
+ "claude",
+ "--name",
+ "agent1",
+ ]);
+
+ expect(mockStartAgent).toHaveBeenCalledTimes(1);
+ expect(mockDaemonStartAgent).not.toHaveBeenCalled();
+ });
+ });
+
it("shows info when no agents are running", async () => {
mockManager.listAgents.mockResolvedValue([]);
diff --git a/packages/cli/src/commands/agent/start.command.ts b/packages/cli/src/commands/agent/start.command.ts
index 69664f1fc..598e97531 100644
--- a/packages/cli/src/commands/agent/start.command.ts
+++ b/packages/cli/src/commands/agent/start.command.ts
@@ -15,13 +15,117 @@ import {
} from "@ai-devkit/agent-manager";
import { ui } from "../../util/terminal-ui.js";
import { withErrorHandler } from "../../util/errors.js";
-import { enableDebug } from "../../util/debug.js";
+import { createLogger, enableDebug } from "../../util/debug.js";
import { AGENT_NAME_REGEX, generateAgentName } from "../../util/agent.js";
import { resolveTmuxInstallInstructions } from "../../util/tmux.js";
import { createTmuxInspectionDeps } from "../../util/tmux-deps.js";
import { ConfigManager } from "../../lib/Config.js";
import { createDurableAgentService } from "./factory.js";
import { formatCwd, formatPrintProvider } from "./render.js";
+import { DaemonRpcError, ensureDaemon } from "@ai-devkit/daemon-client";
+
+interface StartedEntry {
+ name: string;
+ type: string;
+ pid: number;
+ runtime: string;
+ runtimeRef: unknown;
+ cwd: string;
+}
+
+function renderStartedAgent(entry: StartedEntry): void {
+ ui.success(`Agent "${entry.name}" started (${entry.type}, PID ${entry.pid})`);
+ ui.text(`Working directory: ${formatCwd(entry.cwd)}`);
+ if (entry.runtime === "herdr") {
+ ui.text("Runtime: herdr");
+ } else {
+ const tmuxRef = parseTmuxRuntimeRef(entry.runtimeRef);
+ if (tmuxRef) ui.text(`Attach: tmux attach -t ${tmuxRef.session}`);
+ }
+}
+
+/**
+ * Daemon-first start: devkitd owns spawning + registration when reachable.
+ * Falls back to the local TS path on connect-failure or `unknown method`
+ * (older devkitd on the socket). Coded daemon errors render the same
+ * messages as their typed-error equivalents — no retry, the daemon already
+ * reported the real failure.
+ */
+const debug = createLogger("agent:start");
+
+async function tryDaemonStart(input: {
+ agentType: StartableAgentType;
+ agentName: string;
+ cwd: string;
+ runtimeProvider: string;
+}): Promise<"done" | "fallback" | "failed"> {
+ const daemon = await ensureDaemon({ onDebug: debug });
+ if (!daemon) {
+ debug("no daemon available -> falling back to local startAgent path");
+ return "fallback";
+ }
+ debug("daemon path: calling agent.start over the daemon socket");
+ try {
+ const result = await daemon.startAgent({
+ type: input.agentType,
+ name: input.agentName,
+ cwd: input.cwd,
+ runtime: input.runtimeProvider,
+ });
+ for (const warning of result.warnings ?? []) ui.warning(warning);
+ renderStartedAgent({
+ name: result.name,
+ type: result.type,
+ pid: result.pid,
+ runtime: result.runtime,
+ runtimeRef: result.runtimeRef,
+ cwd: result.cwd,
+ });
+ return "done";
+ } catch (err) {
+ if (err instanceof DaemonRpcError && err.code) {
+ debug(`daemon returned coded error ${err.code}; not retrying locally`);
+ await reportDaemonStartError(err);
+ return "failed";
+ }
+ if (err instanceof Error && err.message.startsWith("unknown method")) {
+ debug("daemon predates agent.start (unknown method) -> local fallback");
+ return "fallback"; // pre-agent.start devkitd
+ }
+ // Transport failure mid-request — the daemon may have completed the
+ // start; retrying locally could double-spawn. Report, don't retry.
+ debug("transport failure mid-request; reporting without retry");
+ ui.error(err instanceof Error ? err.message : String(err));
+ return "failed";
+ } finally {
+ daemon.close();
+ }
+}
+
+async function reportDaemonStartError(err: DaemonRpcError): Promise {
+ const details = (err.details ?? {}) as Record;
+ switch (err.code) {
+ case "name_in_use":
+ ui.error(err.message);
+ break;
+ case "runtime_unavailable":
+ if (details.provider === "tmux") {
+ const instructions = await resolveTmuxInstallInstructions(createTmuxInspectionDeps());
+ ui.error(`tmux is not installed or not in PATH. ${instructions.message}`);
+ } else {
+ ui.error(`Herdr runtime is unavailable (${details.reason}): ${details.detail}`);
+ }
+ break;
+ case "pid_poll_timeout":
+ ui.error(
+ `Agent process not found after ${Number(details.timeoutMs) / 1000}s. ` +
+ `Verify that "${details.command}" is in PATH inside the tmux environment.`,
+ );
+ break;
+ default:
+ ui.error(err.message);
+ }
+}
async function reportStartError(err: unknown): Promise {
if (err instanceof TmuxUnavailableError) {
@@ -110,6 +214,18 @@ export function registerAgentStartCommand(agentCommand: Command): void {
return;
}
const runtimeProvider = await new ConfigManager().getAgentRuntimeProvider();
+ const routed = await tryDaemonStart({
+ agentType: agentType as StartableAgentType,
+ agentName,
+ cwd,
+ runtimeProvider,
+ });
+ if (routed === "done") return;
+ if (routed === "failed") {
+ process.exit(1);
+ return; // process.exit is mocked under test — don't fall through
+ }
+ debug("local startAgent path (no daemon or older devkitd)");
const entry = await startAgent(
{
type: agentType as StartableAgentType,
@@ -119,14 +235,7 @@ export function registerAgentStartCommand(agentCommand: Command): void {
},
{ onWarning: (msg: string) => ui.warning(msg) },
);
- ui.success(`Agent "${entry.name}" started (${entry.type}, PID ${entry.pid})`);
- ui.text(`Working directory: ${formatCwd(entry.cwd)}`);
- if (entry.runtime === "herdr") {
- ui.text("Runtime: herdr");
- } else {
- const tmuxRef = parseTmuxRuntimeRef(entry.runtimeRef);
- if (tmuxRef) ui.text(`Attach: tmux attach -t ${tmuxRef.session}`);
- }
+ renderStartedAgent(entry);
} catch (err) {
await reportStartError(err);
process.exit(1);
diff --git a/packages/daemon-client/src/__tests__/integration.test.ts b/packages/daemon-client/src/__tests__/integration.test.ts
index c80e3be4b..904b1a145 100644
--- a/packages/daemon-client/src/__tests__/integration.test.ts
+++ b/packages/daemon-client/src/__tests__/integration.test.ts
@@ -123,6 +123,31 @@ describe.runIf(bin != null)("ensureDaemon (real binary)", () => {
expect(Array.isArray(result)).toBe(true);
client!.close();
}, 30000);
+
+ it("startAgent surfaces coded daemon errors", async () => {
+ // Isolated HOME guarantees a fresh devkitd at the current build — a
+ // stale user daemon on the real socket would answer "unknown method".
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "aidk-home-"));
+ const prevHome = process.env.HOME;
+ process.env.HOME = tmpHome;
+ try {
+ const client = await ensureDaemon({ waitMs: 20000 });
+ expect(client).not.toBeNull();
+ // Unknown harness type → harness_not_startable (no spawn attempted).
+ await expect(client!.startAgent({ type: "nonsense", cwd: "/" })).rejects.toMatchObject({
+ code: "harness_not_startable",
+ });
+ // Bad name → invalid_params before anything spawns.
+ await expect(
+ client!.startAgent({ type: "claude", name: "BAD NAME", cwd: "/" }),
+ ).rejects.toMatchObject({ code: "invalid_params" });
+ await client!.request("shutdown").catch(() => {});
+ client!.close();
+ } finally {
+ if (prevHome === undefined) delete process.env.HOME;
+ else process.env.HOME = prevHome;
+ }
+ }, 30000);
});
describe.runIf(fs.existsSync(sock))("live daemon", () => {
diff --git a/packages/daemon-client/src/autostart.ts b/packages/daemon-client/src/autostart.ts
index 2bccd9e40..4b9bf03c5 100644
--- a/packages/daemon-client/src/autostart.ts
+++ b/packages/daemon-client/src/autostart.ts
@@ -10,18 +10,34 @@ import { resolveDaemonBinary } from "./binary.js";
* for readiness. Returns null when no binary exists or spawn fails — callers
* degrade to today's code paths.
*/
-export async function ensureDaemon(opts: { waitMs?: number } = {}): Promise {
+export async function ensureDaemon(
+ opts: { waitMs?: number; onDebug?: (msg: string) => void } = {},
+): Promise {
const waitMs = opts.waitMs ?? 3000;
+ const debug = opts.onDebug;
// Test runs must never spawn a real daemon. Callers that want to exercise
// autostart do so by pointing DEVKITD_BIN at a real binary in an explicit
// integration test — the env below is checked there too via VITEST_SKIP.
- if (process.env.AI_DEVKIT_NO_DAEMON) return null;
+ if (process.env.AI_DEVKIT_NO_DAEMON) {
+ debug?.("daemon path disabled (AI_DEVKIT_NO_DAEMON set)");
+ return null;
+ }
const existing = await DaemonClient.tryConnect();
- if (existing) return existing;
- if (process.env.VITEST && !process.env.DEVKITD_BIN && !process.env.AI_DEVKITD_BIN) return null;
+ if (existing) {
+ debug?.(`connected to live daemon at ${daemonSocketPath()}`);
+ return existing;
+ }
+ debug?.("no live daemon on socket; attempting autostart");
+ if (process.env.VITEST && !process.env.DEVKITD_BIN && !process.env.AI_DEVKITD_BIN) {
+ debug?.("autostart skipped in test env (VITEST set, no DEVKITD_BIN)");
+ return null;
+ }
- const bin = resolveDaemonBinary();
- if (!bin) return null;
+ const bin = resolveDaemonBinary(debug);
+ if (!bin) {
+ debug?.("no devkitd binary; daemon path unavailable");
+ return null;
+ }
const sockPath = daemonSocketPath();
const dir = path.dirname(sockPath);
@@ -35,7 +51,8 @@ export async function ensureDaemon(opts: { waitMs?: number } = {}): Promise {
+async function waitForSocket(
+ waitMs: number,
+ debug?: (msg: string) => void,
+): Promise {
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
const client = await DaemonClient.tryConnect();
- if (client) return client;
+ if (client) {
+ debug?.("devkitd socket ready");
+ return client;
+ }
await new Promise((r) => setTimeout(r, 50));
}
+ debug?.(`timed out after ${waitMs}ms waiting for devkitd socket`);
return null;
}
diff --git a/packages/daemon-client/src/binary.ts b/packages/daemon-client/src/binary.ts
index 45cc3705d..92f113bc8 100644
--- a/packages/daemon-client/src/binary.ts
+++ b/packages/daemon-client/src/binary.ts
@@ -10,11 +10,15 @@ import { fileURLToPath } from "node:url";
* 2. Platform package `@ai-devkit/devkitd--` (npm optional dep)
* 3. ~/.ai-devkit/bin/devkitd (manual install)
* 4. rust/target/{release,debug}/devkitd relative to the monorepo (dev)
- * Returns null when nothing is found — callers must degrade gracefully.
+ * Returns null when nothing is found — callers
+ * must degrade gracefully.
*/
-export function resolveDaemonBinary(): string | null {
+export function resolveDaemonBinary(onDebug?: (msg: string) => void): string | null {
const env = process.env.DEVKITD_BIN ?? process.env.AI_DEVKITD_BIN;
- if (env && fs.existsSync(env)) return env;
+ if (env && fs.existsSync(env)) {
+ onDebug?.(`devkitd binary from env override: ${env}`);
+ return env;
+ }
const plats: Record = {
linux: "linux",
@@ -32,14 +36,20 @@ export function resolveDaemonBinary(): string | null {
path.dirname(pkgJson),
process.platform === "win32" ? "devkitd.exe" : "devkitd",
);
- if (fs.existsSync(candidate)) return candidate;
+ if (fs.existsSync(candidate)) {
+ onDebug?.(`devkitd binary from platform package: ${candidate}`);
+ return candidate;
+ }
} catch {
/* package not installed */
}
}
const home = path.join(os.homedir(), ".ai-devkit", "bin", "devkitd");
- if (fs.existsSync(home)) return home;
+ if (fs.existsSync(home)) {
+ onDebug?.(`devkitd binary from ~/.ai-devkit/bin: ${home}`);
+ return home;
+ }
// Dev checkout: walk up to the nearest rust/target//devkitd and
// pick the freshest profile — a stale release build otherwise shadows a
@@ -57,10 +67,14 @@ export function resolveDaemonBinary(): string | null {
/* absent */
}
}
- if (best) return best.file;
+ if (best) {
+ onDebug?.(`devkitd binary from dev checkout: ${best.file}`);
+ return best.file;
+ }
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
+ onDebug?.("no devkitd binary found in any location");
return null;
}
diff --git a/packages/daemon-client/src/client.ts b/packages/daemon-client/src/client.ts
index 783c27282..d7cd44877 100644
--- a/packages/daemon-client/src/client.ts
+++ b/packages/daemon-client/src/client.ts
@@ -1,12 +1,29 @@
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
+import type { AgentStartParams } from "./gen/AgentStartParams.js";
+import type { AgentStartResult } from "./gen/AgentStartResult.js";
import type { EnrichedAgent } from "./gen/EnrichedAgent.js";
import type { Event } from "./gen/Event.js";
/** Wire event pushed by the daemon — generated from devkit-core via ts-rs. */
export type DaemonEvent = Event;
+/**
+ * RPC error with the daemon's structured `code`/`details` when present.
+ * `code` is undefined for old daemons and local transport failures.
+ */
+export class DaemonRpcError extends Error {
+ constructor(
+ message: string,
+ readonly code?: string,
+ readonly details?: unknown,
+ ) {
+ super(message);
+ this.name = "DaemonRpcError";
+ }
+}
+
export function daemonSocketPath(): string {
return path.join(os.homedir(), ".ai-devkit", "daemon.sock");
}
@@ -78,7 +95,7 @@ export class DaemonClient {
}
}
- request(method: string, params: unknown = {}): Promise {
+ request(method: string, params: unknown = {}, timeoutMs?: number): Promise {
if (!this.socket) return Promise.reject(new Error("not connected"));
const id = this.nextId++;
return new Promise((resolve, reject) => {
@@ -88,7 +105,7 @@ export class DaemonClient {
if (this.pending.delete(id)) {
reject(new Error(`daemon request "${method}" timed out`));
}
- }, this.requestTimeoutMs);
+ }, timeoutMs ?? this.requestTimeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.socket!.write(JSON.stringify({ id, method, params }) + "\n");
});
@@ -111,6 +128,16 @@ export class DaemonClient {
return (await this.request("agent.list")) as EnrichedAgent[];
}
+ /**
+ * Daemon-side agent start — the daemon validates, spawns via the selected
+ * runtime, registers the agent, and emits agent.started. The request
+ * timeout covers the pid-resolution deadline plus a margin.
+ */
+ async startAgent(params: AgentStartParams): Promise {
+ const timeoutMs = Number(params.timeoutMs ?? 15_000) + 15_000;
+ return (await this.request("agent.start", params, timeoutMs)) as AgentStartResult;
+ }
+
private onData(chunk: Buffer) {
this.buffer += chunk.toString("utf8");
let idx: number;
@@ -133,7 +160,10 @@ export class DaemonClient {
if (!p) continue;
this.pending.delete(id);
clearTimeout(p.timer);
- if (msg.error) p.reject(new Error(String(msg.error)));
+ if (msg.error)
+ p.reject(
+ new DaemonRpcError(String(msg.error), msg.code as string | undefined, msg.details),
+ );
else p.resolve(msg.result);
}
}
diff --git a/packages/daemon-client/src/gen/AgentStartParams.ts b/packages/daemon-client/src/gen/AgentStartParams.ts
new file mode 100644
index 000000000..aa00d4d35
--- /dev/null
+++ b/packages/daemon-client/src/gen/AgentStartParams.ts
@@ -0,0 +1,8 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Params for `agent.start`. Mirrors the TS `StartAgentOptions` surface that
+ * crosses the wire — `runtime` is the provider ("tmux" | "herdr"), resolved
+ * client-side from config.
+ */
+export type AgentStartParams = { type: string, name?: string, cwd: string, runtime?: string, args?: Array, timeoutMs?: number, pollIntervalMs?: number, };
diff --git a/packages/daemon-client/src/gen/AgentStartResult.ts b/packages/daemon-client/src/gen/AgentStartResult.ts
new file mode 100644
index 000000000..fdb910bc0
--- /dev/null
+++ b/packages/daemon-client/src/gen/AgentStartResult.ts
@@ -0,0 +1,7 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * `agent.start` result — the registered entry plus non-fatal warnings the
+ * CLI renders verbatim.
+ */
+export type AgentStartResult = { name: string, type: string, pid: number, runtime: string, runtimeRef: unknown, cwd: string, startedAt: string, warnings: Array, };
diff --git a/packages/daemon-client/src/gen/Response.ts b/packages/daemon-client/src/gen/Response.ts
index 04e4c0b4f..282340fcb 100644
--- a/packages/daemon-client/src/gen/Response.ts
+++ b/packages/daemon-client/src/gen/Response.ts
@@ -1,3 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
-export type Response = { id?: number, result?: unknown, error?: string, };
+export type Response = { id?: number, result?: unknown, error?: string,
+/**
+ * Machine-readable error code (e.g. "name_in_use"). Absent on legacy
+ * errors — clients must handle its absence.
+ */
+code?: string,
+/**
+ * Structured error context (pid, command, timeoutMs, ...).
+ */
+details?: unknown, };
diff --git a/packages/daemon-client/src/index.ts b/packages/daemon-client/src/index.ts
index 434741752..3d6fb1802 100644
--- a/packages/daemon-client/src/index.ts
+++ b/packages/daemon-client/src/index.ts
@@ -1,5 +1,7 @@
-export { DaemonClient, daemonSocketPath } from "./client.js";
+export { DaemonClient, daemonSocketPath, DaemonRpcError } from "./client.js";
export type { DaemonEvent } from "./client.js";
+export type { AgentStartParams } from "./gen/AgentStartParams.js";
+export type { AgentStartResult } from "./gen/AgentStartResult.js";
export type { EnrichedAgent, Event } from "./gen/index.js";
export { resolveDaemonBinary } from "./binary.js";
export { ensureDaemon } from "./autostart.js";
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 64e59c320..13745d900 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -118,6 +118,7 @@ dependencies = [
"devkit-core",
"devkit-harness",
"libc",
+ "serde",
"serde_json",
"tokio",
"tracing",
diff --git a/rust/crates/devkit-core/src/proto.rs b/rust/crates/devkit-core/src/proto.rs
index 50cefd072..281458884 100644
--- a/rust/crates/devkit-core/src/proto.rs
+++ b/rust/crates/devkit-core/src/proto.rs
@@ -24,6 +24,15 @@ pub struct Response {
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub error: Option,
+ /// Machine-readable error code (e.g. "name_in_use"). Absent on legacy
+ /// errors — clients must handle its absence.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub code: Option,
+ /// Structured error context (pid, command, timeoutMs, ...).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[ts(optional, type = "unknown")]
+ pub details: Option,
}
impl Response {
@@ -32,6 +41,8 @@ impl Response {
id,
result: Some(result),
error: None,
+ code: None,
+ details: None,
}
}
pub fn err(id: Option, msg: impl Into) -> Self {
@@ -39,10 +50,71 @@ impl Response {
id,
result: None,
error: Some(msg.into()),
+ code: None,
+ details: None,
+ }
+ }
+ pub fn err_coded(
+ id: Option,
+ code: impl Into,
+ msg: impl Into,
+ details: Option,
+ ) -> Self {
+ Self {
+ id,
+ result: None,
+ error: Some(msg.into()),
+ code: Some(code.into()),
+ details,
}
}
}
+/// Params for `agent.start`. Mirrors the TS `StartAgentOptions` surface that
+/// crosses the wire — `runtime` is the provider ("tmux" | "herdr"), resolved
+/// client-side from config.
+#[derive(Debug, Deserialize, TS)]
+pub struct AgentStartParams {
+ #[serde(rename = "type")]
+ pub agent_type: String,
+ #[serde(default)]
+ #[ts(optional)]
+ pub name: Option,
+ pub cwd: String,
+ #[serde(default)]
+ #[ts(optional)]
+ pub runtime: Option,
+ #[serde(default)]
+ #[ts(optional)]
+ pub args: Option>,
+ #[serde(default, rename = "timeoutMs")]
+ #[ts(optional, type = "number")]
+ pub timeout_ms: Option,
+ #[serde(default, rename = "pollIntervalMs")]
+ #[ts(optional, type = "number")]
+ pub poll_interval_ms: Option,
+}
+
+/// `agent.start` result — the registered entry plus non-fatal warnings the
+/// CLI renders verbatim.
+#[derive(Debug, Serialize, TS)]
+pub struct AgentStartResult {
+ pub name: String,
+ #[serde(rename = "type")]
+ pub agent_type: String,
+ #[ts(type = "number")]
+ pub pid: u64,
+ pub runtime: String,
+ #[serde(rename = "runtimeRef")]
+ #[ts(type = "unknown")]
+ pub runtime_ref: Value,
+ pub cwd: String,
+ #[serde(rename = "startedAt")]
+ pub started_at: String,
+ #[serde(default)]
+ pub warnings: Vec,
+}
+
/// Event pushed to subscribers. `seq` is the persisted log sequence number —
/// subscribers ack by seq to get at-least-once delivery.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
@@ -74,6 +146,8 @@ mod tests {
Request::export(&cfg).unwrap();
Response::export(&cfg).unwrap();
Event::export(&cfg).unwrap();
+ AgentStartParams::export(&cfg).unwrap();
+ AgentStartResult::export(&cfg).unwrap();
crate::agent::EnrichedAgent::export(&cfg).unwrap();
// ts-rs emits `import ... from "./X"` without the `.js` extension
@@ -123,6 +197,59 @@ mod tests {
.contains("\"id\""));
}
+ #[test]
+ fn response_carries_error_code_and_details() {
+ let err = serde_json::to_string(&Response::err_coded(
+ Some(3),
+ "name_in_use",
+ "Agent \"x\" is already running (PID 42).",
+ Some(json!({"pid": 42})),
+ ))
+ .unwrap();
+ let v: Value = serde_json::from_str(&err).unwrap();
+ assert_eq!(v["code"], "name_in_use");
+ assert_eq!(v["details"]["pid"], 42);
+ // Old clients still see a plain error string.
+ assert!(v["error"].as_str().unwrap().contains("already running"));
+ // code/details omitted when absent.
+ assert!(!serde_json::to_string(&Response::err(Some(2), "nope"))
+ .unwrap()
+ .contains("\"code\""));
+ }
+
+ #[test]
+ fn agent_start_params_deserialize() {
+ let p: AgentStartParams = serde_json::from_str(
+ r#"{"type":"claude","cwd":"/repo","runtime":"tmux",
+ "args":["--x"],"timeoutMs":15000,"pollIntervalMs":500}"#,
+ )
+ .unwrap();
+ assert_eq!(p.agent_type, "claude");
+ assert_eq!(p.cwd, "/repo");
+ assert_eq!(p.runtime.as_deref(), Some("tmux"));
+ assert_eq!(p.timeout_ms, Some(15000));
+ assert!(p.name.is_none());
+ }
+
+ #[test]
+ fn agent_start_result_serializes_registry_entry_shape() {
+ let r = AgentStartResult {
+ name: "foo".into(),
+ agent_type: "claude".into(),
+ pid: 4242,
+ runtime: "tmux".into(),
+ runtime_ref: json!({"session": "foo"}),
+ cwd: "/repo".into(),
+ started_at: "2026-10-17T00:00:00.000Z".into(),
+ warnings: vec!["orphan replaced".into()],
+ };
+ let v: Value = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
+ assert_eq!(v["type"], "claude");
+ assert_eq!(v["runtimeRef"]["session"], "foo");
+ assert_eq!(v["startedAt"], "2026-10-17T00:00:00.000Z");
+ assert_eq!(v["warnings"][0], "orphan replaced");
+ }
+
#[test]
fn event_frame_shape_is_stable() {
// Subscribers parse {"event": {...}} — field names are the contract.
diff --git a/rust/crates/devkit-core/src/store.rs b/rust/crates/devkit-core/src/store.rs
index a206ed27f..ac8e77ae1 100644
--- a/rust/crates/devkit-core/src/store.rs
+++ b/rust/crates/devkit-core/src/store.rs
@@ -2,7 +2,7 @@ use crate::proto::Event;
use anyhow::{Context, Result};
use rusqlite::{params, Connection};
use serde_json::Value;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// Newest events kept before retention pruning kicks in.
@@ -12,8 +12,14 @@ const PRUNE_EVERY: i64 = 512;
/// Daemon-owned store: the persisted agent snapshot (for diff/events across
/// restarts) and the durable event log. The daemon is the sole writer.
+mod registry;
+pub use registry::Registration;
+
pub struct Store {
conn: Mutex,
+ /// Legacy agent registry (`~/.ai-devkit/agents.db`) — dual-write target
+ /// while unmigrated TS consumers still read it.
+ legacy_db: Mutex