From 8312e39940d0591e61e7dac21e01b14f1c1b6a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:11:31 +0000 Subject: [PATCH 01/11] docs(agents): plan + specs for agents system Adds the dual-approved tech plan for the Agents system (predefined + custom agent definitions, MCP launchers, plan handoff) and the spec updates that anchor the implementation. Defines the new "Agent definition" core term in spec A, the Agents nav surface in B.2, the launch-time prompt-composition seam in B.3, runtime model discovery + default agent runtime in B.6, and the agent launchers + plan handoff MCP surface in B.7. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/plans/agents-system.md | 417 +++++++++++++++++++++++++++ specs/A-shared-definitions.md | 3 +- specs/B.2-ade-cockpit.md | 13 +- specs/B.3-agent-sessions-terminal.md | 6 +- specs/B.6-providers-hooks-config.md | 18 +- specs/B.7-operations-activity-mcp.md | 23 +- 6 files changed, 475 insertions(+), 5 deletions(-) create mode 100644 .agents/plans/agents-system.md diff --git a/.agents/plans/agents-system.md b/.agents/plans/agents-system.md new file mode 100644 index 00000000..4a65d2a1 --- /dev/null +++ b/.agents/plans/agents-system.md @@ -0,0 +1,417 @@ +Activate the /implement-task skill first. + +# Plan: Agents System — runtimes, configs, MCP launchers, handoff + +## Acceptance Criteria + +Sourced from scratchpad block `00000012-0012-4012-8012-000000000012`, scope confirmed with the user as "D — everything including plan handoff". + +- [ ] A new "Agents" entry appears in the cockpit's left navigation, positioned immediately above the existing "History" entry. +- [ ] The Agents view lists four predefined agents (`implementation`, `prototype`, `pm`, `architect`) and any user-defined custom agents. +- [ ] Each agent definition exposes three editable fields: system prompt (multiline), runtime (selector populated from `list_runtimes`), and model (selector populated from the chosen runtime's model list). +- [ ] Predefined agents cannot be deleted but ARE editable; each has a "Reset to citadel defaults" affordance that overwrites only that one definition with its seeded value. +- [ ] Custom agents support full CRUD (create, read, edit, delete) and are persisted across daemon restarts. +- [ ] Six new MCP tools are registered and callable by remote agents: `launch_implementation_agent`, `launch_prototype_agent`, `launch_pm_agent`, `launch_architect_agent`, `list_custom_agents`, `launch_custom_agent`. Each `launch_*` tool accepts an optional `workspace` field; when omitted, a new workspace is created (matching the existing `launch_agent` behavior). +- [ ] When a `launch_*_agent` tool fires, the named agent's system prompt is prepended to the user-supplied prompt before the prompt is submitted to the runtime — uniformly across all runtimes (no runtime-specific `--system-prompt` flags). +- [ ] Per-runtime adapters expose a `listModels()` function returning the model identifiers the runtime can launch with; the cockpit's model selector calls this via a `/api/runtimes/:id/models` daemon endpoint. The claude-code adapter scrapes the interactive `/models` TUI via the existing `tmux-pty.ts` capture pattern when no flag is available. If a probe fails, the adapter returns a hardcoded conservative fallback list AND the API response includes a `probeError` field so the UI can surface it. +- [ ] A new global setting "Default agent runtime" is persisted (used as the default when an agent is created without an explicit runtime). The setting is exposed as a single row in existing Settings — no rich UI for this knob. +- [ ] An MCP `register_plan({workspaceId, path, summary?})` tool stores a plan registration record (workspaceId, absolute path inside the workspace, optional summary, registeredAt). Registrations persist across daemon restarts. +- [ ] An MCP `launch_handoff_agent({workspaceId, planId?, predefinedKind?, customAgentId?, additionalPrompt?})` tool reads the registered plan (or, if `planId` is omitted, the newest registered plan for that workspace; or, if no registration exists, the newest `*.md` under `/.agents/plans/`), prepends the plan's content to the agent's system prompt, and launches the named agent in the SAME workspace. Exactly one of `predefinedKind` (enum: `implementation`|`prototype`|`pm`|`architect`) or `customAgentId` MUST be supplied — typos cannot silently fall through to a 404'd custom-agent lookup. +- [ ] Predefined agent system prompts are seeded with citadel-authored text that cites the semantics of the corresponding skills (architect → planning, implementation → TDD execution, pm → scoping, prototype → fast UI iteration) but does NOT embed the full skill text. +- [ ] All four predefined agents survive a "delete attempt" path with a clear error (`predefined_agent_cannot_be_deleted`). +- [ ] All persistence respects the user-level scope: `~/.citadel/agents/.json` for definitions, `~/.citadel/agents.config.json` for global settings (default runtime). Plan registrations live in the daemon's SQLite DB (worktree-relative `.citadel/data/`, matching existing daemon convention) because they are workspace-scoped, not user-global. **Cross-daemon coordination:** the systemd long-term daemon and any worktree `make deploy` daemons share the same `~/.citadel/agents/` directory. The storage layer therefore (a) re-reads from disk on every API call (no in-memory cache that can desync), (b) writes one file per definition (no shared-file races), and (c) computes a content hash before writing during seed() — only writes the seed if the file is missing or its content has drifted from a known-good citadel default (idempotent-by-content). +- [ ] "Reset to citadel defaults" uses the citadel-authored seed values, NOT the user's current `defaultRuntime` setting (so reset is deterministic regardless of user config). + +## Context and problem statement + +Citadel today supports launching agents in workspaces via a single MCP tool, `launch_agent`, which takes a free-form `prompt` plus a `runtimeId`. There is no concept of a **reusable agent definition** — every caller assembles its own prompt and runtime choice from scratch, and the predefined SDLC personas (the implementation/architect/pm/prototype roles that mirror the existing `.agents/skills` family) live only as ad-hoc human conventions. + +This plan adds a first-class **Agent definition** (system prompt + runtime + model) that is: + +1. **Configurable** via a new "Agents" cockpit nav entry and editor. +2. **Reusable** via six new MCP launchers (four predefined + two custom). +3. **Composable** via a plan-handoff mechanism: an agent that produces a plan can register it; another agent can be launched in the same workspace and primed with that plan's content. + +The change touches four layers — contracts, MCP surface, daemon HTTP/state, and the web cockpit — but is additive only: the existing `launch_agent` MCP tool is untouched and the new launchers compose on top of it. + +The motivating product trajectory: tonight's user is launching 10 parallel agents on 10 scratchpad topics. The pattern is repeatable but currently requires a human to know the right system prompt and runtime per topic. Predefined named agents close that gap; plan-handoff closes the next gap (architect → implementation) by automating the most common SDLC chain inside the cockpit. + +## Spec alignment + +Per the review-pr extension's spec mappings, this change is **cross-cutting**: + +| Touched area | Spec | +|---|---| +| `packages/contracts/**` (new schemas) | `specs/A-shared-definitions.md` | +| `packages/mcp/**`, `apps/daemon/src/operations/**` (new MCP tools) | `specs/B.7-operations-activity-mcp.md` | +| `apps/web/**`, `packages/ui/**` (new nav entry + editor) | `specs/B.2-ade-cockpit.md`, `specs/B.8-ui-performance-quality.md` | +| `apps/daemon/src/agents/**` (composing on top of `operations.launchAgent`) | `specs/B.3-agent-sessions-terminal.md` | +| `packages/db/**` (plan_registrations table) | `specs/A-shared-definitions.md` | +| `packages/runtimes/**` (new `models/` adapter directory) | `specs/B.6-providers-hooks-config.md` | + +**Reviewed each spec for required updates:** + +- `specs/A-shared-definitions.md` — needs a new "Agent definition" entry in the glossary and the schema list (alongside the existing Repository/Workspace/Agent session entries). Defines the difference between an "Agent definition" (a reusable template) and an "Agent session" (a running instance — already defined). +- `specs/B.7-operations-activity-mcp.md` — needs an "Agent launchers" subsection enumerating the six new MCP tools, their inputs/outputs, and the snapshot-vs-daemon dispatch path. Also documents `register_plan` and `launch_handoff_agent`. +- `specs/B.2-ade-cockpit.md` — needs an "Agents nav" subsection placing the entry above History and describing the master/detail editor layout. +- `specs/B.6-providers-hooks-config.md` — needs a "Runtime model discovery" subsection documenting `listModels()` adapters and the claude-code TUI scrape. +- `specs/B.3-agent-sessions-terminal.md` — needs a note clarifying that the new launchers compose on top of `operations.launchAgent` (system prompt is prepended to the user prompt; runtime invocation is unchanged). + +**Step 1 of the implementation MUST be updating these specs before any code.** + +## Implementation approach + +The chosen approach treats agent definitions as **user-global config** distinct from daemon runtime state. Definitions live in `~/.citadel/agents/.json` (one file per definition); the daemon reads them on every API call (cheap; cached for the request lifetime). Plan registrations, by contrast, are **workspace-scoped state** and live in the daemon's SQLite DB. + +Six layers, in dependency order: + +1. **Contracts.** Add `AgentDefinitionSchema`, `AgentDefinitionStorageSchema` (the on-disk form with `kind: "predefined" | "custom"` and a `definitionId`), `LaunchPredefinedAgentInputSchema` (used by all four `launch_*_agent` tools — same shape), `LaunchCustomAgentInputSchema`, `RegisterPlanInputSchema`, `LaunchHandoffAgentInputSchema`, `PlanRegistrationSchema`, `RuntimeModelDescriptorSchema`. +2. **DB.** Add a `plan_registrations` table (additive migration version 8) keyed by `(workspaceId, id)` with `path`, `summary`, `registeredAt`, `registeredBySessionId`. +3. **Runtimes.** Add `packages/runtimes/src/models/` mirroring the `usage/` adapter pattern. `runtimeModelListers` record with adapters for `claude-code`, `codex`, `cursor-agent`, `pi`. The `claude-code` adapter uses the existing `tmux-pty.ts` capture utilities to scrape `/models`. Each adapter returns `{ models, probeError? }` so the caller can surface partial failure. +4. **Daemon.** + - Add a small `agentDefinitions` service in `apps/daemon/src/agent-definitions/` that reads/writes `~/.citadel/agents/` and seeds predefined definitions on first read. + - Add HTTP routes: `GET/POST/PATCH/DELETE /api/agents`, `POST /api/agents/:id/reset` (predefined only), `GET /api/runtimes/:id/models`, `GET/PUT /api/agents/config` (for default runtime). + - In `daemon-mcp-tool.ts`, dispatch the six new launch tools and the two plan tools. Each launch tool: + 1. Loads the named agent definition from disk (predefined launchers use a fixed id; `launch_custom_agent` takes an `agentId`). + 2. Composes `effectivePrompt = agent.systemPrompt + "\n\n---\n\n" + userPrompt`. + 3. Resolves the workspace: if `workspace` provided, look it up; if absent, create a new one (delegating to the existing `operations.launchAgent` create-workspace path). + 4. Calls `operations.launchAgent({ runtimeId: agent.runtime, prompt: effectivePrompt, ... })`. + - `register_plan` inserts a row; `launch_handoff_agent` resolves the plan (registered first, filename fallback second) and then routes through the same launch path with the plan body prepended. +5. **MCP layer.** In `packages/mcp/src/index.ts`, register the eight new tool definitions and add snapshot dispatch: + - `list_custom_agents` is read-only and CAN execute in the snapshot path (it reads `~/.citadel/agents/` directly). + - The seven mutating tools follow the existing pattern of returning `{ error: "mutating_tool_requires_daemon" }` in the snapshot path; the daemon implements them in `daemon-mcp-tool.ts`. +6. **Web cockpit.** + - Add a nav `` immediately above the History link in `apps/web/src/navigator.tsx`. + - Add `apps/web/src/routes/agents.tsx` (a new file — TanStack Router auto-mounts) with a master/detail layout mirroring `settings-scheduled-agents.tsx`: left rail lists all definitions; right pane is the editor. + - Add a `RuntimeModelSelector` component that calls `/api/runtimes/:id/models` and shows a probe-failure banner when present. + - Add a "Default agent runtime" row in the existing Settings panel (in `apps/web/src/settings-runtimes.tsx` or its sibling — the row reads/writes `/api/agents/config`). + +Test strategy is two-layered per the citadel extension: Vitest for everything that can be unit-tested (schemas, the agent-definitions service, the MCP dispatcher, the model-list adapters with mocked tmux IO); a small Playwright happy-path for the new nav entry and editor save. + +## Alternatives considered + +1. **Per-repo storage (`/.citadel/agents/`).** Lets teams share agent definitions via git, parallel to how hooks work. **Rejected**: the user explicitly chose global at decision time; predefined agents would also need a global fallback when no repo-level file exists, doubling the lookup path. Revisit if multi-user team use emerges. + +2. **Runtime-specific system-prompt flags (e.g. `claude-code --append-system-prompt`).** Best fidelity for claude-code (the system prompt would not appear in the chat history). **Rejected**: only one of the four runtimes supports such a flag today; the resulting two-code-path divergence ("flag" vs "prepend") is more maintenance than it's worth for v1. Reconsidered later if user feedback shows the system prompt appearing in the transcript is a UX issue. + +3. **Hardcoded model list per runtime in code.** Simpler than CLI probing; no flakiness. **Rejected**: user picked probe-based discovery explicitly so the model list stays current as runtimes ship new models. The hardcoded list still appears in the adapter as the fallback when probing fails — best of both as a degraded mode. + +4. **Filename convention only for handoff (no DB).** Smallest surface; no schema migration; the daemon just scans `/plans/` and picks the newest. **Rejected**: user picked both ("register MCP + filename fallback"). Registration gives the producing agent explicit control over which plan is the current one when multiple exist. + +5. **Implement only the schema + 4 predefined launchers in this PR; defer the nav UI, custom agents, and handoff to follow-ups (slice A from the grilling).** Genuinely safer in a 10-parallel-agent environment. **Rejected by the user.** Carrying this as a follow-up signal: if implementation discovers a sharp file-overlap conflict with another in-flight branch, fall back to slice A and land the rest as follow-ups. + +6. **Collapse the six predefined launchers into one `launch_predefined_agent({ kind })` tool.** Smaller MCP surface. **Rejected**: user's AC explicitly enumerates the six tool names — that's the contract callers were told they'd see, and renaming them later is more breaking than fewer tools is helpful. + +## Implementation steps + +### 1. Specs update (FIRST — before any code) + +- Add an "Agent definition" entry to `specs/A-shared-definitions.md` (after the existing "Agent session" entry). Distinguish: an Agent definition is a reusable template (system prompt + runtime + model); an Agent session is a running instance. +- Add an "Agent launchers" subsection to `specs/B.7-operations-activity-mcp.md` enumerating the six new MCP tools, their inputs/outputs, and the snapshot vs daemon dispatch path. Add a separate "Plan handoff" subsection covering `register_plan` and `launch_handoff_agent`, including the registration-first / filename-fallback resolution order. +- Add an "Agents nav" subsection to `specs/B.2-ade-cockpit.md` placing the entry above History and describing the master/detail editor layout, the predefined vs custom distinction, and the "reset to defaults" affordance. +- Add a "Runtime model discovery" subsection to `specs/B.6-providers-hooks-config.md` covering `listModels()` adapters, the claude-code TUI scrape, and the `probeError` fallback path. +- Add one paragraph to `specs/B.3-agent-sessions-terminal.md` clarifying that new launchers compose on top of `operations.launchAgent`: the system prompt is prepended to the user prompt at launch time, runtime invocation is otherwise unchanged. + +### 2. Contracts + +In `packages/contracts/src/index.ts`, just before `DiffFileSchema` (line ~694): + +- `AgentDefinitionKindSchema = z.enum(["predefined", "custom"])` +- `AgentDefinitionIdSchema = IdSchema` (reuse the existing constraint) +- `AgentDefinitionSchema` — `{ id, kind, name, systemPrompt, runtime, model?, createdAt, updatedAt }`. `model` is optional because users may not have picked one yet. +- `CreateAgentDefinitionInputSchema` — `{ name, systemPrompt, runtime, model? }` (kind is always "custom" on create; "predefined" definitions are seeded by the daemon, not user-created). +- `UpdateAgentDefinitionInputSchema` — partial of the same fields plus the id; rejects changes to `kind`. +- `LaunchPredefinedAgentInputSchema` — `{ prompt, workspaceId? OR (repoId? AND repoName?), namespaceId?, displayName?, branchName?, workspaceName? }`. Used identically by all four `launch_*_agent` tools — they only differ by the hardcoded definition id they load. +- `LaunchCustomAgentInputSchema` — same as above plus required `agentId`. +- `RegisterPlanInputSchema` — `{ workspaceId, path, summary? }`. +- `PlanRegistrationSchema` — `{ id, workspaceId, path, summary?, registeredAt, registeredBySessionId? }`. +- `LaunchHandoffAgentInputSchema` — `{ workspaceId, planId?, predefinedKind?: "implementation" | "prototype" | "pm" | "architect", customAgentId?: AgentDefinitionId, additionalPrompt? }`. Validated via `.refine(...)` so that exactly one of `predefinedKind` or `customAgentId` is supplied — a typo cannot silently fall through to a non-existent custom agent. +- `RuntimeModelDescriptorSchema` — `{ id, displayName?, isDefault? }`. +- `RuntimeModelsResponseSchema` — `{ models: RuntimeModelDescriptor[], probeError?: string }`. +- `AgentsConfigSchema` — `{ defaultRuntime: string }`. + +Export every schema and its inferred type. Add a small set of `.parse()`-based round-trip tests in `packages/contracts/src/index.test.ts`. + +### 3. Database (migration version 8) + +In `packages/db/src/migrate.ts`: + +**Migration strategy:** + +| Operation | Classification | Reversibility | Notes | +|---|---|---|---| +| `CREATE TABLE IF NOT EXISTS plan_registrations (...)` | **Additive** | Yes (drop table) | New table; safe on every existing install. | +| `CREATE INDEX IF NOT EXISTS idx_plan_registrations_workspace ON plan_registrations(workspace_id)` | **Additive** | Yes | Indexes are rebuildable. | +| `INSERT OR IGNORE INTO schema_migrations(version, name, applied_at) VALUES (8, 'plan-registrations', datetime('now'))` | **Migration record** | N/A | Version 8 (current max is 7 per `packages/db/src/migrate.ts:195-201`). | + +Schema: +```sql +CREATE TABLE IF NOT EXISTS plan_registrations ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + path TEXT NOT NULL, -- stored as fs.realpathSync(input) at registration time + summary TEXT, + registered_at TEXT NOT NULL, + registered_by_session_id TEXT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_plan_registrations_workspace ON plan_registrations(workspace_id); +``` + +`PRAGMA foreign_keys = ON` is preserved (not touched). Local-first impact: every existing install gets the new table on next startup; existing data is untouched (no row writes during migration). + +**Migration version drift mitigation (parallel branches).** Several other in-flight branches tonight may also introduce a v8. The implementer MUST rebase on `main` immediately before merge; if `main` now contains a v8, bump THIS PR's migration to the next available version (v9 or higher) and update the assertion in `packages/db/src/index.test.ts` (`schema_migrations` version list). `INSERT OR IGNORE` already prevents row collisions, but the schema work itself may diverge silently if two v8s exist on different branches. + +Add store methods in `packages/db/src/index.ts`: +- `insertPlanRegistration(row)`, `listPlanRegistrationsForWorkspace(workspaceId)`, `deletePlanRegistration(id)`. + +### 4. Runtime model adapters + +Add `packages/runtimes/src/models/` directory mirroring `usage/`: + +- `models/index.ts` — exports `runtimeModelListers: Record` (claude-code, codex, cursor-agent, pi) and a `hasRuntimeModelLister(id)` helper. `RuntimeModelLister = (input: { command: string; args?: string[] }) => Promise<{ models: RuntimeModelDescriptor[]; probeError?: string }>`. +- `models/claude-code.ts` — uses the existing `tmux-pty.ts` capture pattern to spawn the runtime, send `/models`, capture the pane, parse the list. Falls back to `["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"]` with a `probeError` on any failure. **Cleanup hardening:** the tmux session MUST be killed in a `try { ... } finally { killSession() }` regardless of parse success/failure/timeout, per the existing ttyd cleanup-storm lessons. The probe wraps in a hard 5s timeout; on timeout the finally block still runs. **Fixture-first parser:** before writing the parser, capture a real `/models` output by running `tmux-pty.ts` once interactively and check the captured bytes into `packages/runtimes/src/models/fixtures/claude-code-models.txt`; the parser test asserts on that fixture, not a guessed format. +- `models/codex.ts` — codex CLI does not expose model selection (per `mcp__citadel__list_runtimes` capabilities); return the hardcoded list `["gpt-5.5"]` (or whatever the current codex default is) with no `probeError`. +- `models/cursor-agent.ts`, `models/pi.ts` — minimal fallbacks; each runtime currently shows `supportsModelSelection: false`. Return a single "default" entry so the UI never shows an empty list. + +Unit tests mock the tmux IO surface (the test harness can already do this — see existing `packages/runtimes/src/usage/*.test.ts`). + +### 5. Daemon — agent-definitions service + +Add `apps/daemon/src/agent-definitions/` directory: + +- `agent-definitions/storage.ts` — reads/writes `~/.citadel/agents/` (uses `os.homedir()` + `path.join`); creates the directory on first call; seeds the four predefined definitions if absent. Exposes: + - `list(): AgentDefinition[]` (predefined + custom). Re-reads from disk on EVERY call — no in-memory cache, so cross-daemon edits propagate immediately. + - `get(id): AgentDefinition | undefined` + - `create(input): AgentDefinition` (custom only; predefined ids reserved) + - `update(id, patch): AgentDefinition` (works for both kinds; rejects `kind` changes) + - `remove(id): void` (throws on predefined) + - `resetToDefaults(id): AgentDefinition` (predefined only; uses citadel-authored seed, NOT user defaultRuntime) + - `readConfig(): AgentsConfig`, `writeConfig(patch): AgentsConfig` + +**Cross-daemon coordination:** the systemd long-term daemon at `:4010` AND any worktree `make deploy` daemon (4110–4209) share this directory. Mitigations: +1. Every `list()` re-reads from disk (no stale cache). +2. Writes are file-per-id; atomic via `fs.writeFile(, content)` (no shared write target). +3. `seed()` computes a content hash for each missing predefined file BEFORE writing; if the file is missing it writes the seed; if the file exists it leaves it alone (idempotent). This avoids two daemons racing to seed the same dir at first run. +4. **Boot-safe.** If `~/.citadel/agents/` is unreadable (EACCES, ENOENT on a parent, broken symlink, file-where-dir-should-be), the storage layer logs loudly and the daemon STILL boots; subsequent calls to `list()/get()/create()` return a structured error that the HTTP layer maps to 503. The daemon MUST NOT crashloop on a broken storage state (otherwise systemd's `Restart=always` will spin endlessly). +- `agent-definitions/seed.ts` — the four citadel-authored predefined system prompts. Each is ~10–20 lines, cites its skill semantics, and explicitly does NOT embed the skill text: + - `implementation` — references TDD execution semantics from `/implement-task`. + - `architect` — references planning semantics from `/do-tech-plan`. + - `pm` — references scoping/requirements gathering. + - `prototype` — references fast UI iteration (no tests, no migrations, single-shot prompts). + +Why per-file (vs single JSON file): atomic writes per definition are simpler; "reset to defaults" overwrites a single file deterministically; concurrent editor sessions don't race on a shared file. + +### 6. Daemon — HTTP routes (NEW FILE — extracted, NOT appended to `app.ts`) + +**File-size gate:** `apps/daemon/src/app.ts` is currently 804 lines (verified) — already at the 800-LoC limit. Adding seven new routes inline would push it well over. The established pattern in the daemon for new endpoint families is a sibling `*-routes.ts` module — verified by inspection: `agent-session-routes.ts`, `namespace-routes.ts`, `scheduled-agent-routes.ts`, `scratchpad-routes.ts`, `runtime-usage-routes.ts`, `terminal-routes.ts`, `workspace-diff-routes.ts`, `mcp-routes.ts`, `extra-routes.ts` all follow this shape. + +Create `apps/daemon/src/agents-routes.ts` exporting `registerAgentsRoutes({ app, asyncRoute, agentDefinitions, runtimeModelListers, store })`. In `app.ts`, add ONE call to `registerAgentsRoutes(...)` near the existing route-registration block (around line 706) — a single-line addition that won't push `app.ts` over the limit. + +Endpoints in `agents-routes.ts`: + +- `GET /api/agents` → returns `{ definitions: AgentDefinition[], config: AgentsConfig }`. Returns `503 { error: "agent_storage_unavailable" }` if the storage layer reports a boot-failure state. +- `POST /api/agents` → body `CreateAgentDefinitionInputSchema`; returns created definition. +- `PATCH /api/agents/:id` → body `UpdateAgentDefinitionInputSchema`; returns updated definition. +- `DELETE /api/agents/:id` → 409 `{ error: "predefined_agent_cannot_be_deleted" }` if predefined; else removes. +- `POST /api/agents/:id/reset` → 400 if not predefined; else overwrites with seed. +- `GET /api/agents/config` / `PUT /api/agents/config` → reads/writes `~/.citadel/agents.config.json`. +- `GET /api/runtimes/:id/models` → calls `runtimeModelListers[id]` and returns `RuntimeModelsResponseSchema`. **Cache policy:** results cached per `(runtimeId)` for **1 hour TTL**, NOT daemon-lifetime (claude-code/codex CLI upgrades happen out-of-band and a stale cache produces "unknown model" errors at launch time). `?refresh=1` forces a re-probe. UI also surfaces a small explicit "Refresh models" affordance next to the selector. + +All endpoints invalidate `["state"]` on the client side via standard react-query patterns when called from the cockpit. + +**Boot safety.** `registerAgentsRoutes` MUST NOT throw at registration time even if `~/.citadel/agents/` is unreadable. Storage failures surface as 503 responses, never as daemon crash. Regression test: load the daemon's HTTP app in vitest with the home dir pointed at a read-only directory and assert the app boots and `GET /api/agents` returns 503. + +### 7. Daemon — MCP dispatch + +Extend `apps/daemon/src/daemon-mcp-tool.ts` `callDaemonMcpTool` switch with EIGHT new cases (`list_custom_agents` runs ONLY in the daemon path — see §8 below for why). + +**Pre-step: verify and unify the launch seam.** The plan currently mentions two downstream operations entry points (`operations.startAgentSession` for an existing-workspace launch, `operations.launchAgent` for a create-and-launch). Before implementing the launchers, read `packages/operations/src/index.ts` (and the corresponding daemon-side caller) to verify that BOTH entry points thread a `prompt` argument through to the same tmux submit path. If they diverge (e.g. one expects the caller to submit the prompt via a separate `submitPrompt` call), introduce a single helper in `packages/operations/src/index.ts` named `composeAndLaunchAgent({ store, deps, workspaceId?, runtimeId, prompt })` that normalizes the two paths so BOTH MCP launchers go through one seam. Tests on the seam are the canary against "system prompt silently dropped". + +- `launch_implementation_agent` / `launch_prototype_agent` / `launch_pm_agent` / `launch_architect_agent` — each calls a shared helper `launchPredefinedAgent(deps, definitionId, input)` that: + 1. Loads the definition via `agentDefinitions.get(definitionId)`. Returns `{ error: "agent_storage_unavailable" }` if storage is in a boot-failure state. + 2. Resolves the runtime + model (uses agent's `model` if set; else lets `operations.launchAgent` use its default; if agent has no `runtime`, fall back to `agentsConfig.defaultRuntime`). + 3. Composes `effectivePrompt = "## System\n" + definition.systemPrompt + "\n\n## User prompt\n" + input.prompt`. + 4. Routes through the unified `composeAndLaunchAgent` seam (see pre-step) — no per-path branching in the launcher itself. + 5. Returns `{ workspaceId, sessionId, branchName, workspacePath, operationId }` (same shape as `launch_agent`). +- `list_custom_agents` — returns `{ agents: AgentDefinition[] }` filtered to `kind === "custom"`. Daemon-only (see §8). +- `launch_custom_agent` — same as predefined helper but takes `input.agentId` and reads the matching custom definition; 404 if not found OR if the id is predefined (caller should use `launch_*_agent` for those). +- `register_plan` — security-hardened path validation: + 1. `inputPath = path.resolve(workspacePath, input.path)` — produce an absolute path. + 2. `realPath = await fs.promises.realpath(inputPath)` — resolves symlinks. Wrap in try/catch — ENOENT or EACCES becomes `{ error: "plan_path_unreadable" }`. + 3. `workspaceReal = await fs.promises.realpath(workspacePath)`. + 4. Reject (`{ error: "plan_path_escapes_workspace" }`) if `!realPath.startsWith(workspaceReal + path.sep)` — note `path.sep`, NOT just the prefix string, to avoid `/work/ws` matching `/work/ws-evil/...`. + 5. Stat the file: reject if not a regular file (`stat.isFile()`) or larger than **1 MiB** (`stat.size > 1_048_576`) — `{ error: "plan_file_too_large" }`. + 6. INSERT the row, storing `realPath` (not the original input) in the `path` column so a post-registration symlink swap can't change the target. + Returns `{ planId, registeredAt }`. +- `launch_handoff_agent` — input validated via `LaunchHandoffAgentInputSchema` (one-of: `predefinedKind` OR `customAgentId`). Resolves the plan: + 1. If `input.planId` set: load by id; reject if its `workspaceId` doesn't match `input.workspaceId`. + 2. Else: pick the newest `plan_registrations` row for the workspace. + 3. Else: scan `/.agents/plans/*.md` and pick newest by mtime. (**Note:** `.agents/plans/`, NOT `plans/` — verified against the citadel repo convention: this very plan file lives at `.agents/plans/agents-system.md`.) + 4. Else: return `{ error: "no_plan_found" }`. + Re-validates the stored `path` via realpath + workspace-prefix check AT READ TIME (defense-in-depth against post-registration symlink swap). Reads the plan file (still enforcing the 1 MiB cap), prepends content to the agent's system prompt under a `## Plan to implement` header, and routes through the same `composeAndLaunchAgent` seam. + +### 8. MCP layer (snapshot path) + +In `packages/mcp/src/index.ts`: + +- Add the eight tool names to the `McpToolName` union. +- Add the eight `McpToolDefinition` entries (name, description, inputSchema, `destructive: false` for all eight — we don't expose any destructive agent-definition op via MCP in this PR; the cockpit handles delete/reset). +- In `callMcpTool` (snapshot dispatch): + - **All eight tools — including `list_custom_agents` — return `{ error: "agent_launcher_requires_daemon" }`.** The earlier revision's idea of running `list_custom_agents` in the snapshot path was wrong: `McpToolContext` (file:line in `packages/mcp/src/index.ts` ~73–84) is pure in-memory snapshots — it has no fs access, no `agentDefinitions` reference, and no `os.homedir()` setup. Forcing fs access into the snapshot path also breaks the "snapshot may run remote-of-daemon" invariant. The pattern matches the existing scratchpad family (`packages/mcp/src/index.ts` ~682-683): "the scratchpad lives on disk under the daemon's data dir; the snapshot path has no fs access, so route through the daemon explicitly." + - Use a new, family-specific sentinel `agent_launcher_requires_daemon` (matching the existing per-family pattern: `scratchpad_tool_requires_daemon`, `session_tool_requires_daemon`, `scheduled_agent_run_tool_requires_daemon`) — NOT the generic `mutating_tool_requires_daemon`. +- Extend `mcpToolDefinitions()` exports so `pnpm check` round-trips them in tests. + +### 9. Web cockpit — Agents nav entry + +In `apps/web/src/navigator.tsx`, immediately above the existing History `` (line 228): + +```tsx + + Agents + +``` + +Pick an unused lucide-react icon (e.g. `Bot` or `UserCog`). Verify no other nav entry already uses it. + +### 10. Web cockpit — Agents route + editor (THREE files, file-size pre-commit) + +**Pre-commit to file split** so the 800-LoC gate doesn't get hit by the editor + form + selector being one file: + +1. `apps/web/src/routes/agents.tsx` (route + master/detail layout; target ≤200 LoC). + - Top-level `` with `useQuery({ queryKey: ["agents"], queryFn: ... })` against `/api/agents`. + - Left rail: predefined section, then custom section, with a "+ New custom agent" button. + - Right pane: renders ``. + +2. `apps/web/src/agents-editor.tsx` (editor form + mutations; target ≤300 LoC). + - Form fields: `name` (read-only for predefined), `systemPrompt` (textarea, monospace), `runtime` selector (from `/api/state`'s `runtimes`), `model` selector (``). + - Buttons: `Save` (both kinds), `Reset to citadel defaults` (predefined only), `Delete` (custom only — confirm dialog). + - Mutations: `useMutation` per action; invalidates `["agents"]` and `["state"]` on success. + - Errors render in a small banner above the form (e.g. `predefined_agent_cannot_be_deleted`, `name_collides`, `agent_storage_unavailable`). + +3. `apps/web/src/components/runtime-model-selector.tsx` (target ≤150 LoC). + - Props: `{ runtime: string, value?: string, onChange(model: string): void }`. + - Calls `useQuery({ queryKey: ["runtime-models", runtime] })`. + - Shows a `probeError` banner if returned; still renders the fallback list so the user can save. + - **Renders a small "↻ Refresh" button** next to the selector that triggers a re-query against `/api/runtimes/:id/models?refresh=1` (matches the daemon's TTL invalidation knob). + +### 11. Web cockpit — default-runtime Settings row + +In `apps/web/src/settings-runtimes.tsx`, add a single labeled row near the top: + +- Label: "Default agent runtime". +- Selector: same list of healthy runtimes already shown elsewhere on the page. +- Save: PUTs `/api/agents/config` with `{ defaultRuntime }`. +- Used by: the `/api/agents` create path defaults `runtime` to this when the user doesn't pick one in the editor. + +### 12. Smoke / E2E + +Add a single Playwright happy-path test that: +1. Loads the cockpit. +2. Clicks the Agents nav entry. +3. Verifies the four predefined agents appear in the list. +4. Opens "implementation", edits the system prompt, clicks Save, refreshes, verifies the edit persisted. + +## QA/Test Strategy + +### Layer evaluation + +| Layer | Verdict | Details | +|---|---|---| +| Unit (Vitest) | **Required** | Contracts schema round-trips; agent-definitions service (seed, CRUD, reset, predefined-delete-rejection); model-list adapters (with mocked tmux IO); MCP dispatcher (all 8 new cases, including auth/validation/error paths); handoff plan-resolution order. | +| E2E (Playwright) | **Required** | One happy-path: nav entry visible, predefined list renders, edit-and-save persists across reload. | + +### New tests to add + +**Vitest unit tests:** + +- `packages/contracts/src/index.test.ts` — extend with a new `describe("agent definition contracts")` block that round-trips `AgentDefinitionSchema`, `LaunchPredefinedAgentInputSchema`, `LaunchCustomAgentInputSchema`, `RegisterPlanInputSchema`, `LaunchHandoffAgentInputSchema`, `PlanRegistrationSchema`, `RuntimeModelsResponseSchema`, `AgentsConfigSchema`. Specifically assert: kind enum is exact; ids match `IdSchema`; LaunchPredefinedAgentInputSchema accepts both `workspaceId`-only AND `repoName`-only inputs (xor branch). +- `apps/daemon/src/agent-definitions/storage.test.ts` (new file) — assert: seed creates four files on first read; `seed()` is idempotent-by-content (running it on a directory that already has well-formed defaults does NOT rewrite the files; running it on a directory with a missing predefined file recreates just that one); `create()` rejects when name or id collides with a predefined; `update()` rejects `kind` change; `remove()` throws on predefined id; `resetToDefaults()` rejects on custom id; `resetToDefaults()` returns the citadel-authored seed, NOT the user's `defaultRuntime`; concurrent `create` calls don't corrupt the directory; **boot-safety**: when `~/.citadel/agents/` cannot be created (parent is read-only), `list()` returns a structured error and does NOT throw out of the call chain. +- `apps/daemon/src/agent-definitions/seed.test.ts` (new file) — assert: each of the four predefined seeds has a non-empty system prompt; runtime defaults to claude-code; names are stable across calls (the seed function is pure). +- `packages/runtimes/src/models/index.test.ts` (new file) — assert: `runtimeModelListers` has entries for the four citadel-maintained runtimes; `hasRuntimeModelLister` returns false for unknown ids. +- `packages/runtimes/src/models/claude-code.test.ts` (new file) — mock the tmux capture surface; assert: a happy-path capture returns a parsed model list (driven by `packages/runtimes/src/models/fixtures/claude-code-models.txt` — a real captured `/models` output, NOT a hand-crafted approximation); a tmux failure returns `{ models: [...fallback], probeError: "" }`; a 5s+ hang triggers the timeout AND the tmux session is killed (verify via the kill-session mock counter); a parser throw still triggers tmux cleanup (the `finally` block runs). +- `packages/mcp/src/index.test.ts` — extend with a new `describe("agent launchers")` block that asserts: snapshot dispatch returns `{ error: "agent_launcher_requires_daemon" }` for ALL EIGHT new tools (including `list_custom_agents`); tool definitions include the eight new names; sentinel name does NOT clash with any existing sentinel. +- `apps/daemon/src/daemon-mcp-tool.test.ts` (or whichever file holds the existing daemon-mcp-tool tests — verify path first) — assert: `launch_implementation_agent` composes prompt as `## System\n... \n\n## User prompt\n...` (exact header strings; the canary for "system prompt silently dropped"); composition works identically whether `workspaceId` is provided or omitted (both paths route through `composeAndLaunchAgent`); `launch_custom_agent` 404s on unknown id AND on predefined id; `register_plan` rejects: + - `../etc/passwd` (lexical traversal) → `plan_path_escapes_workspace` + - `/etc/passwd` (absolute outside workspace) → `plan_path_escapes_workspace` + - a symlink under `` pointing OUT to `/etc/passwd` (realpath escape) → `plan_path_escapes_workspace` + - a directory rather than a file → `plan_path_unreadable` or similar + - a file larger than 1 MiB → `plan_file_too_large` +- And ACCEPTS a normal `/.agents/plans/some-plan.md`, storing the realpath in the row. +- `launch_handoff_agent`: + - When `predefinedKind` and `customAgentId` are both supplied → schema-level rejection (one-of constraint). + - When neither is supplied → schema-level rejection. + - Resolves in the order: `planId` → newest registered → newest `.agents/plans/*.md` → `no_plan_found`. Mtimes controlled by test fixtures. + - Re-validates the stored path at read time: if a registered plan's realpath now escapes the workspace (symlink swap post-registration), reject with `plan_path_escapes_workspace` and do NOT launch. +- `apps/daemon/src/agents-routes.test.ts` (new file — pattern confirmed: `apps/daemon/src/` has `agent-session-routes.ts`, `namespace-routes.ts`, etc. as siblings, follow the existing test-co-location convention). Tests: + - Happy path for each of the seven new HTTP endpoints. + - `DELETE /api/agents/` → 409 with structured error body. + - `POST /api/agents//reset` → 400 (only predefined can be reset). + - `GET /api/runtimes/:id/models` propagates `probeError` to the response without failing the request. + - `GET /api/runtimes/:id/models?refresh=1` bypasses cache (counter on the underlying adapter advances). + - `GET /api/runtimes/:id/models` honors the 1h TTL: two calls within 1h hit the cache (counter advances once), a third call after `vi.advanceTimersByTime(3_600_001)` re-probes (counter advances). + - **Boot-failure regression**: mount the daemon HTTP app in a vitest harness with the home dir pointed at a path where the agents dir cannot be created (e.g. a file where the dir should be); assert the daemon-app boot does NOT throw, `GET /api/agents` returns 503 `{ error: "agent_storage_unavailable" }`, and `POST /api/agents` returns 503 likewise. + - **Workspace cascade test**: insert a workspace + a `plan_registrations` row, DELETE the workspace via the existing workspace-removal route (or call `store.removeWorkspace` directly), assert the registration row is gone AND `launch_handoff_agent` for that workspace returns `no_plan_found` without throwing. + +**Playwright E2E tests:** + +- `e2e/agents.spec.ts` (new file) — one test as described in step 12 above. Use the existing fixtures harness (see `e2e/` for the pattern). + +### Existing tests to update + +- `packages/mcp/src/index.test.ts` — the existing `it("reports local/internal MCP tools and resources")` test asserts `tools` contains specific names (around `expect(status.tools).toContain("launch_agent")`). Update so it also asserts the eight new names are present. +- `packages/db/src/index.test.ts` — the existing `expect(store.query("SELECT version FROM schema_migrations ORDER BY version")).toEqual([...])` assertion (around line 37) needs version 8 appended to the expected list. + +### Assertions to add/change/tighten + +- In every MCP launch test, assert the **exact** prompt composition: the system prompt MUST appear at the top of the user-facing message, separated by the `## System` / `## User prompt` headers we chose. A regression where the system prompt is silently dropped or appended at the bottom would defeat the entire feature; this assertion is the canary. Run the assertion on BOTH `workspaceId`-provided and `workspaceId`-absent paths. +- Assert that `register_plan`'s path-traversal rejection is strict: `path.resolve` then `fs.realpath` then `startsWith(realpathWorkspace + path.sep)` (note: include `path.sep` to avoid `/work/ws` matching `/work/ws-evil/...`). Test with `../etc/passwd`, `/etc/passwd`, a symlink under `` pointing to `/etc/passwd`, and a 2-MiB file. +- Assert that the stored `path` column in `plan_registrations` is the realpath (not the input), so a post-registration symlink swap cannot change the target. +- Assert that the handoff resolution order is deterministic given mtimes (the test fixture controls them explicitly). +- Assert that the `LaunchHandoffAgentInputSchema` one-of constraint rejects both "neither field" and "both fields" inputs at schema-parse time (before the daemon dispatch). + +### Failure modes / edge cases / regression risks + +- **System prompt silently dropped via two-path divergence.** If `operations.startAgentSession` and `operations.launchAgent` thread `prompt` differently to the runtime, the system prompt could be applied in the create-workspace path but dropped in the reuse-workspace path. Mitigation: unified `composeAndLaunchAgent` seam in `packages/operations`; composition assertion runs against BOTH workspaceId-provided and workspaceId-absent inputs. +- **Symlink-based exfiltration via `register_plan`.** A compromised remote agent registers `/.agents/plans/innocent.md` where it's a symlink to `~/.ssh/id_rsa` or `/etc/passwd`; on `launch_handoff_agent`, the daemon would read the target and prepend it to the next agent's prompt, leaking secrets to the runtime. Mitigation: realpath-based check on register AND on read, max file size 1 MiB. +- **Boot-loop on the user's running systemd daemon.** Merging this PR triggers migration v8 AND new HTTP routes on the user's `:4010` daemon at next start. A defect in `~/.citadel/agents/` access could crashloop the daemon under `Restart=always`. Mitigation: storage layer never throws out of route handlers; broken storage surfaces as 503; boot-safety regression test pins this. +- **Cross-daemon edit races.** Systemd daemon + worktree daemon both write to `~/.citadel/agents/`. Mitigation: file-per-id atomic writes; no in-memory cache (re-read on every API call); seed() is idempotent-by-content; documented "concurrent edits last-write-wins" caveat at v1. +- **Predefined agent ids collide with custom user ids.** A user creates a custom agent with id `implementation`. Storage layer must reserve the four predefined ids; covered by a unit test. +- **Schema migration race on daemon startup.** Two daemon processes start simultaneously (e.g. systemd + a `make deploy`) and both try to apply v8. The existing `INSERT OR IGNORE` already handles this; verify no new code paths introduce a non-idempotent step. +- **Plan registration FK violation when workspace is deleted.** The FK has `ON DELETE CASCADE`; covered by an integration-style unit test that deletes a workspace and asserts registrations vanish. +- **claude-code TUI scrape hangs.** The `/models` interactive command could block if the TUI is unresponsive. The adapter MUST wrap the tmux call in a hard timeout (≤5s) and return the fallback list with a `probeError`. Covered by a timeout-injection test in `claude-code.test.ts`. +- **Nav-entry icon collision.** Adding the wrong `Bot` icon may clash visually with an existing entry. Check by running the cockpit visually before merging (the Playwright test won't catch this). +- **Concurrent edits to the same agent file.** Two cockpit tabs editing the same definition; last write wins. Acceptable for v1 — the form re-reads on save success — but flag for future optimistic-locking work. +- **Other parallel agents touching `apps/daemon/src/app.ts` or `packages/mcp/src/index.ts`.** High overlap risk tonight. Mitigation: prefer adding new files (`apps/daemon/src/agents-routes.ts`, `apps/daemon/src/agent-definitions/*`) and keep edits to `app.ts` and `index.ts` to a few additive lines. + +### Adversarial analysis + +- **How could this fail in production?** A malformed predefined seed (e.g., a non-string system prompt) is written to `~/.citadel/agents/`, then read on next daemon boot, and the schema-validation throw makes the daemon refuse to start. **Mitigation:** the storage layer validates with the schema on read; on validation failure, log loudly and fall back to re-seeding the predefined defaults rather than crashing. +- **What user actions trigger unexpected behavior?** A user deletes `~/.citadel/agents/implementation.json` manually outside the cockpit. **Mitigation:** the seed function runs on every `list()`, recreating any missing predefined file. Test this explicitly. +- **What existing behavior could break?** The new launchers compose on top of `operations.launchAgent`. If the prompt composition mangles the user prompt (e.g., a stray null byte from the system prompt's encoding), the runtime could receive a malformed input and fail. **Mitigation:** assert the composed prompt is valid UTF-8 and contains the original user prompt verbatim in a contract test. +- **Which tests credibly catch those failures?** The composition assertion, the path-traversal assertion, the timeout-injection assertion, the FK-cascade assertion. Together these cover the four highest-risk paths. +- **What gaps remain?** The Playwright happy-path only covers the edit-and-save loop, not the actual MCP-launch path through to a running agent. Validating an MCP launch end-to-end requires a real workspace + real runtime, which the current Playwright harness doesn't have. Acceptable gap: the unit-level tests around composition and dispatch cover the contract; the launch path itself is already exercised by the existing `launch_agent` MCP tests, and the new launchers reuse that path. + +## Tests + +(Files listed in QA/Test Strategy above. TDD order: contracts tests → schema/migration tests → storage service tests → model adapter tests → MCP dispatch tests → HTTP route tests → Playwright E2E.) + +## Schema or contract generation + +No code-generation step; the contracts package is hand-written zod schemas. Run `pnpm -r build` (covered by `make check`) after edits to ensure the contracts package compiles cleanly and consumers pick up the new exports. + +## Verification + +Before opening the PR: + +- `make check` — runs `check:arch`, `check:size` (800 LoC limit), `typecheck`, `lint` (biome), `test` (vitest), `coverage` (90% target on core/backend/shared), `check:deps`, `build`. **Mandatory.** This includes `check:arch` (architecture-boundary gate — verify the new imports in `apps/web` don't pull in `@citadel/daemon` internals; web stays on `@citadel/contracts` only) and `check:size` (file-size gate — `apps/daemon/src/app.ts` is currently 804 LoC; this PR must leave it at or below 800). +- `make e2e` — Playwright happy-path. **Mandatory** (we added a new spec). +- `make smoke` — local API smoke against a running daemon. **Mandatory** (we added several new HTTP endpoints). +- `make performance` — local perf smoke. **Skip** unless we observe regression in the cockpit's initial load (we are adding one new query against `/api/agents`; if the daemon is cold, this could marginally affect time-to-first-paint, so run it if `make smoke` shows non-trivial added latency). + +**Pre-merge sequencing.** Immediately before merge, rebase on `main`. If another branch landed a v8 migration first, bump THIS PR's migration to the next free version and update `packages/db/src/index.test.ts`'s `schema_migrations` version assertion. `INSERT OR IGNORE` prevents row collisions but does NOT prevent silent schema-version drift. + +Manual gates (per `CLAUDE.md`): +- Don't run `pkill -f node` (would kill the user's systemd daemon). +- Don't touch `/home/jonsnow/Workspace/citadel/` (the main checkout). +- For redeploy/restart, use `make deploy`. diff --git a/specs/A-shared-definitions.md b/specs/A-shared-definitions.md index ef6efb86..314d6ec6 100644 --- a/specs/A-shared-definitions.md +++ b/specs/A-shared-definitions.md @@ -18,6 +18,7 @@ [ ] 1. Repository — a configured source repository with stable identity, provider settings, hooks, and workspace defaults. [ ] 2. Workspace — a tracked git worktree inside a repository, with stable identity independent of path/name. [ ] 3. Agent session — a durable agent/runtime session attached to a workspace. +[ ] 3a. Agent definition — a reusable template combining system prompt, runtime, and optional model. Predefined definitions (implementation, prototype, pm, architect) are non-deletable but editable; custom definitions support full CRUD. Distinct from an agent session — an agent definition is a template, an agent session is a running instance launched from one. [ ] 4. Runtime adapter — a CLI agent integration such as Claude, Codex, Pi, or another configured shell-backed agent. [ ] 5. Provider — an integration that normalizes external system data into Citadel contracts. [ ] 6. Hook — a repo-scoped extension command that returns structured data or executes structured actions. @@ -34,4 +35,4 @@ --- -keywords: citadel, ade, cockpit, repository, workspace, agent session, runtime, provider, hook, operation, readiness +keywords: citadel, ade, cockpit, repository, workspace, agent session, agent definition, runtime, provider, hook, operation, readiness diff --git a/specs/B.2-ade-cockpit.md b/specs/B.2-ade-cockpit.md index af6c0d12..01a32fc5 100644 --- a/specs/B.2-ade-cockpit.md +++ b/specs/B.2-ade-cockpit.md @@ -116,6 +116,17 @@ The cockpit's scratchpad view renders the per-workspace `scratchpad.md` (see B.7 [ ] 7. The version history sidebar continues to show whole-file snapshots, including the `migrate-to-blocks` entry that runs on the first read after upgrade. [ ] 8. No drag-drop reorder, no typed blocks (code/todo/heading), no per-block diff in v1 — out of scope. +## Agents nav + +The cockpit exposes a dedicated "Agents" entry in the left navigation, immediately above "History". Selecting it opens a master/detail editor for agent definitions. + +[ ] 1. Left rail lists four predefined agents (`implementation`, `prototype`, `pm`, `architect`) followed by any user-defined custom agents, with a "+ New custom agent" button. +[ ] 2. Right pane is the editor: name (read-only for predefined), system prompt (multiline), runtime (selector populated from `list_runtimes`), model (selector populated from the chosen runtime's `listModels()` adapter). +[ ] 3. Predefined agents are non-deletable but editable; each exposes a "Reset to citadel defaults" affordance that overwrites only that definition with its seeded value. Reset uses citadel-authored defaults, NOT the user's current `defaultRuntime` setting. +[ ] 4. Custom agents support full CRUD. Save/Delete invalidate `["agents"]` and `["state"]` query keys. +[ ] 5. Errors render as a banner above the form (`predefined_agent_cannot_be_deleted`, `name_collides`, `agent_storage_unavailable`). +[ ] 6. The model selector shows a `probeError` banner when the runtime's model probe fails, and renders the fallback list so save still works. A small "↻ Refresh" affordance forces a re-probe (`?refresh=1`). + --- -keywords: ade, cockpit, readiness, next action, workspace detail, operator, attention state, scratchpad, blocks +keywords: ade, cockpit, readiness, next action, workspace detail, operator, attention state, agents, agent definitions, scratchpad, blocks diff --git a/specs/B.3-agent-sessions-terminal.md b/specs/B.3-agent-sessions-terminal.md index 3eba2b33..0e3185b0 100644 --- a/specs/B.3-agent-sessions-terminal.md +++ b/specs/B.3-agent-sessions-terminal.md @@ -53,6 +53,10 @@ [ ] 3. A future remote daemon can stream remote terminal sessions to web or desktop clients. [ ] 4. Local and remote sessions share the same session identity model. +## Composing system prompts on launch + +The MCP launchers (`launch_implementation_agent`, etc., and `launch_custom_agent`) compose the launching prompt by prepending the agent definition's system prompt to the caller's user prompt under `## System` / `## User prompt` headers. Composition is uniform across all runtimes — no runtime-specific `--system-prompt` flags in v1. Both create-and-launch and reuse-existing-workspace paths route through the same `composeAndLaunchAgent` seam so the system prompt cannot be silently dropped on one path. + --- -keywords: agent sessions, runtime adapters, terminal, tmux, xterm, websocket, reconnect, native terminal +keywords: agent sessions, runtime adapters, terminal, tmux, xterm, websocket, reconnect, native terminal, system prompt composition diff --git a/specs/B.6-providers-hooks-config.md b/specs/B.6-providers-hooks-config.md index d9a1cd14..d90bf41b 100644 --- a/specs/B.6-providers-hooks-config.md +++ b/specs/B.6-providers-hooks-config.md @@ -84,6 +84,22 @@ Sections: Hooks are edited from repository settings, because hook bindings are repo-specific. The structured-config form is intentionally retained as the escape hatch for fields the curated sections do not yet cover. +## Runtime model discovery + +Each runtime adapter exposes an optional `listModels()` function that returns the model identifiers the runtime can launch with. + +[ ] 1. The daemon serves `/api/runtimes/:id/models` → `{ models: RuntimeModelDescriptor[], probeError? }`. Results cache per `runtimeId` for **1 hour** (claude-code/codex CLI upgrades happen out-of-band; daemon-lifetime caching would surface stale models). +[ ] 2. `?refresh=1` forces a fresh probe. The cockpit's model selector renders a "↻ Refresh" affordance that hits this knob. +[ ] 3. Adapters return a `probeError` string AND a conservative fallback list when probing fails — the UI surfaces the error but still lets the user save with a fallback model. +[ ] 4. For runtimes without a non-interactive list flag (claude-code), the adapter scrapes the interactive TUI (`/models`) using `tmux-pty.ts`. The probe wraps in a hard 5s timeout AND a try/finally `killSession` cleanup to avoid leaked tmux sessions. + +## Default agent runtime + +A global `~/.citadel/agents.config.json` records the user's default agent runtime. Surfaced as a single Settings row in the existing Agents/Runtimes panel. + +[ ] 1. `GET /api/agents/config` returns the current config; `PUT` writes it. +[ ] 2. New agent definitions created via the Agents nav default to this runtime when the user does not explicitly choose one. + --- -keywords: providers, hooks, config, settings, first run, github, jira, usage, secrets, health checks +keywords: providers, hooks, config, settings, first run, github, jira, usage, secrets, health checks, runtime models, default agent runtime diff --git a/specs/B.7-operations-activity-mcp.md b/specs/B.7-operations-activity-mcp.md index 8f4d84a3..337454c7 100644 --- a/specs/B.7-operations-activity-mcp.md +++ b/specs/B.7-operations-activity-mcp.md @@ -95,6 +95,27 @@ The file remains a regular markdown file so external tooling (git, editors, grep All block-level tools go through the same version-history coalesce path; sources are `mcp:add_block`, `mcp:update_block`, `mcp:delete_block` (or `ui:*_block` from the cockpit). Empty blocks are never persisted. +## Agent launchers + +Six MCP tools launch named agents from reusable agent definitions: + +- `launch_implementation_agent`, `launch_prototype_agent`, `launch_pm_agent`, `launch_architect_agent` — each loads the matching predefined definition and launches it. Inputs: `{ prompt, workspaceId? | repoId? | repoName?, namespaceId?, displayName?, branchName?, workspaceName? }`. When `workspaceId` is absent, a new workspace is created (matching `launch_agent`). +- `list_custom_agents` — returns `{ agents: AgentDefinition[] }` filtered to `kind === "custom"`. Daemon-only. +- `launch_custom_agent` — same input as the predefined launchers plus required `agentId`. + +All eight tools (including `list_custom_agents`) are daemon-only — the snapshot dispatch returns `{ error: "agent_launcher_requires_daemon" }`. `McpToolContext` has no fs access; agent definitions live on disk under `~/.citadel/agents/`. + +The launcher prepends the definition's system prompt to the user prompt under `## System` / `## User prompt` headers before submitting; composition is uniform across all runtimes (no runtime-specific `--system-prompt` flags in v1). + +## Plan handoff + +Two MCP tools support the architect → implementation handoff: + +- `register_plan({ workspaceId, path, summary? })` — validates `path` resolves (via `fs.realpath`) to a location inside the workspace, rejects symlink escapes, enforces a 1 MiB cap, stores the realpath in the `plan_registrations` SQLite table. +- `launch_handoff_agent({ workspaceId, planId?, predefinedKind? | customAgentId?, additionalPrompt? })` — resolves the plan via priority `planId` → newest registered → newest `/.agents/plans/*.md` → `no_plan_found`. Re-validates realpath at read time. Prepends the plan body under `## Plan to implement` to the agent's system prompt and launches. + +Exactly one of `predefinedKind` (enum) or `customAgentId` must be supplied — typos cannot silently 404 as a custom-agent lookup. + --- -keywords: operations, activity, audit, progress, logs, mcp, automation, agents, scratchpad +keywords: operations, activity, audit, progress, logs, mcp, automation, agents, agent definitions, agent launchers, plan handoff, scratchpad From 6fe47452bf8564f0086569488265e1c0f3a3a411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:13:49 +0000 Subject: [PATCH 02/11] feat(contracts): agent definition + launch + plan handoff schemas Adds AgentDefinitionSchema (+predefined/custom kind), Create/Update input schemas, LaunchPredefinedAgentInputSchema (shared by all four predefined launchers), LaunchCustomAgentInputSchema, RegisterPlanInputSchema, PlanRegistrationSchema, LaunchHandoffAgentInputSchema (with the predefinedKind XOR customAgentId one-of refinement that prevents typos silently 404'ing as custom-agent lookups), AgentsConfigSchema for the global default-runtime knob, and RuntimeModelDescriptor/Response for the per-runtime model probes. All schemas export inferred types and are covered by round-trip parse tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/contracts/src/index.test.ts | 113 +++++++++++++++++++++++++++ packages/contracts/src/index.ts | 106 +++++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/packages/contracts/src/index.test.ts b/packages/contracts/src/index.test.ts index 8dee9e92..df476304 100644 --- a/packages/contracts/src/index.test.ts +++ b/packages/contracts/src/index.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { + AgentDefinitionSchema, AgentRuntimeSchema, AgentSessionSchema, + AgentsConfigSchema, AppEventSchema, BackgroundAgentSessionSchema, CiProviderSummarySchema, + CreateAgentDefinitionInputSchema, CreateAgentSessionInputSchema, CreateRepoInputSchema, CreateScheduledAgentInputSchema, @@ -12,15 +15,22 @@ import { HookOutputSchema, IssueTrackerSummarySchema, IssueTransitionActionResultSchema, + LaunchCustomAgentInputSchema, + LaunchHandoffAgentInputSchema, + LaunchPredefinedAgentInputSchema, OperationSchema, + PlanRegistrationSchema, PrReviewerSchema, ProviderHealthSchema, PullRequestSummarySchema, RecentCommitSchema, + RegisterPlanInputSchema, RepoSchema, + RuntimeModelsResponseSchema, RuntimeUsageSummarySchema, ScheduledAgentRunSchema, ScheduledAgentSchema, + UpdateAgentDefinitionInputSchema, UpdateScheduledAgentInputSchema, VersionControlSummarySchema, WorkspaceDiffSchema, @@ -409,3 +419,106 @@ describe("contract schemas", () => { }); }); }); + +describe("agent definition contracts", () => { + it("round-trips predefined and custom agent definitions", () => { + const predefined = AgentDefinitionSchema.parse({ + id: "implementation", + kind: "predefined", + name: "Implementation", + systemPrompt: "You are an Implementation agent.", + runtime: "claude-code", + createdAt: timestamp, + updatedAt: timestamp, + }); + expect(predefined.kind).toBe("predefined"); + expect(predefined.model).toBeUndefined(); + + const custom = AgentDefinitionSchema.parse({ + id: "my-reviewer", + kind: "custom", + name: "My Reviewer", + systemPrompt: "Review carefully.", + runtime: "claude-code", + model: "claude-opus-4-7", + createdAt: timestamp, + updatedAt: timestamp, + }); + expect(custom.model).toBe("claude-opus-4-7"); + }); + + it("validates create and update inputs", () => { + expect(() => + CreateAgentDefinitionInputSchema.parse({ + name: "", + systemPrompt: "x", + runtime: "claude-code", + }), + ).toThrow(); + + const update = UpdateAgentDefinitionInputSchema.parse({ systemPrompt: "new" }); + expect(update.systemPrompt).toBe("new"); + }); + + it("accepts launch_*_agent input with workspaceId OR repoName", () => { + expect(LaunchPredefinedAgentInputSchema.parse({ prompt: "go", workspaceId: "ws-1" }).prompt).toBe( + "go", + ); + expect(LaunchPredefinedAgentInputSchema.parse({ prompt: "go", repoName: "citadel" }).repoName).toBe( + "citadel", + ); + expect(() => LaunchPredefinedAgentInputSchema.parse({ prompt: "" })).toThrow(); + expect(LaunchCustomAgentInputSchema.parse({ prompt: "go", agentId: "my-reviewer" }).agentId).toBe( + "my-reviewer", + ); + expect(() => LaunchCustomAgentInputSchema.parse({ prompt: "go" })).toThrow(); + }); + + it("enforces the predefinedKind XOR customAgentId constraint on handoff", () => { + expect( + LaunchHandoffAgentInputSchema.parse({ + workspaceId: "ws-1", + predefinedKind: "implementation", + }).predefinedKind, + ).toBe("implementation"); + expect( + LaunchHandoffAgentInputSchema.parse({ workspaceId: "ws-1", customAgentId: "my-reviewer" }) + .customAgentId, + ).toBe("my-reviewer"); + // both supplied → reject + expect(() => + LaunchHandoffAgentInputSchema.parse({ + workspaceId: "ws-1", + predefinedKind: "implementation", + customAgentId: "my-reviewer", + }), + ).toThrow(); + // neither supplied → reject + expect(() => LaunchHandoffAgentInputSchema.parse({ workspaceId: "ws-1" })).toThrow(); + }); + + it("validates plan registration + runtime model + agents config schemas", () => { + const registration = PlanRegistrationSchema.parse({ + id: "plan-1", + workspaceId: "ws-1", + path: "/work/ws-1/.agents/plans/foo.md", + summary: null, + registeredAt: timestamp, + registeredBySessionId: null, + }); + expect(registration.summary).toBeNull(); + + const reg = RegisterPlanInputSchema.parse({ workspaceId: "ws-1", path: ".agents/plans/foo.md" }); + expect(reg.path).toBe(".agents/plans/foo.md"); + + const response = RuntimeModelsResponseSchema.parse({ + models: [{ id: "claude-sonnet-4-6", displayName: "Sonnet 4.6" }], + probeError: "tmux timeout", + }); + expect(response.models[0]?.id).toBe("claude-sonnet-4-6"); + expect(response.probeError).toBe("tmux timeout"); + + const config = AgentsConfigSchema.parse({ defaultRuntime: "claude-code" }); + expect(config.defaultRuntime).toBe("claude-code"); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 22fe99d0..ea9268a6 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -691,6 +691,97 @@ export const UpdateScheduledAgentInputSchema = z.object({ enabled: z.boolean().optional(), }); +export const AgentDefinitionKindSchema = z.enum(["predefined", "custom"]); +export const AgentDefinitionIdSchema = IdSchema; +export const PredefinedAgentKindSchema = z.enum(["implementation", "prototype", "pm", "architect"]); + +export const AgentDefinitionSchema = z.object({ + id: AgentDefinitionIdSchema, + kind: AgentDefinitionKindSchema, + name: z.string().min(1).max(80), + systemPrompt: z.string().min(1).max(50_000), + runtime: z.string().min(1).max(80), + model: z.string().min(1).max(120).optional(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const CreateAgentDefinitionInputSchema = z.object({ + name: z.string().min(1).max(80), + systemPrompt: z.string().min(1).max(50_000), + runtime: z.string().min(1).max(80), + model: z.string().min(1).max(120).optional(), +}); + +export const UpdateAgentDefinitionInputSchema = z.object({ + name: z.string().min(1).max(80).optional(), + systemPrompt: z.string().min(1).max(50_000).optional(), + runtime: z.string().min(1).max(80).optional(), + model: z.string().min(1).max(120).nullable().optional(), +}); + +export const AgentsConfigSchema = z.object({ + defaultRuntime: z.string().min(1).max(80), +}); + +export const LaunchPredefinedAgentInputSchema = z.object({ + prompt: z.string().min(1), + workspaceId: IdSchema.optional(), + repoId: IdSchema.optional(), + repoName: z.string().min(1).max(80).optional(), + namespaceId: IdSchema.optional(), + displayName: z.string().min(1).max(80).optional(), + workspaceName: z.string().min(1).max(80).optional(), + branchName: z.string().min(1).max(120).optional(), +}); + +export const LaunchCustomAgentInputSchema = LaunchPredefinedAgentInputSchema.extend({ + agentId: AgentDefinitionIdSchema, +}); + +export const RegisterPlanInputSchema = z.object({ + workspaceId: IdSchema, + path: z.string().min(1).max(4096), + summary: z.string().max(2000).optional(), +}); + +export const PlanRegistrationSchema = z.object({ + id: z.string().min(1).max(80), + workspaceId: IdSchema, + path: z.string().min(1).max(4096), + summary: z.string().nullable(), + registeredAt: z.string(), + registeredBySessionId: z.string().nullable(), +}); + +export const LaunchHandoffAgentInputSchema = z + .object({ + workspaceId: IdSchema, + planId: z.string().min(1).max(80).optional(), + predefinedKind: PredefinedAgentKindSchema.optional(), + customAgentId: AgentDefinitionIdSchema.optional(), + additionalPrompt: z.string().max(50_000).optional(), + }) + .refine( + (value) => + (value.predefinedKind !== undefined ? 1 : 0) + (value.customAgentId !== undefined ? 1 : 0) === 1, + { + message: "Exactly one of predefinedKind or customAgentId must be supplied", + path: ["predefinedKind"], + }, + ); + +export const RuntimeModelDescriptorSchema = z.object({ + id: z.string().min(1).max(120), + displayName: z.string().min(1).max(120).optional(), + isDefault: z.boolean().optional(), +}); + +export const RuntimeModelsResponseSchema = z.object({ + models: z.array(RuntimeModelDescriptorSchema), + probeError: z.string().optional(), +}); + export const DiffFileSchema = z.object({ path: z.string(), status: z.string(), @@ -783,6 +874,21 @@ export type BackgroundAgentSession = z.infer; export type UpdateScheduledAgentInput = z.infer; +export type AgentDefinitionKind = z.infer; +export type AgentDefinitionId = z.infer; +export type PredefinedAgentKind = z.infer; +export type AgentDefinition = z.infer; +export type CreateAgentDefinitionInput = z.infer; +export type UpdateAgentDefinitionInput = z.infer; +export type AgentsConfig = z.infer; +export type LaunchPredefinedAgentInput = z.infer; +export type LaunchCustomAgentInput = z.infer; +export type RegisterPlanInput = z.infer; +export type PlanRegistration = z.infer; +export type LaunchHandoffAgentInput = z.infer; +export type RuntimeModelDescriptor = z.infer; +export type RuntimeModelsResponse = z.infer; + export type { ScratchpadSnapshot, ScratchpadHistorySource } from "./scratchpad.js"; export type { ScratchpadHistoryEntry, ScratchpadHistorySummary } from "./scratchpad.js"; export type { ScratchpadBlock, ScratchpadBlockSummary, ScratchpadBlockPosition } from "./scratchpad.js"; From de4e4e7ae4b4b07440135c980e9877c57931d9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:16:21 +0000 Subject: [PATCH 03/11] feat(db): plan_registrations table + store methods (v8 migration) Adds the plan_registrations SQLite table (workspace-scoped, FK to workspaces with ON DELETE CASCADE) and the schema_migrations row for version 8. Store gains insertPlanRegistration / findPlanRegistration / listPlanRegistrationsForWorkspace / deletePlanRegistration methods. PRAGMA foreign_keys=ON preserved. A regression test asserts the cascade: inserting a registration for a workspace, deleting the workspace, then listing registrations returns empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/db/src/index.test.ts | 68 +++++++++++++++++++++++++++++++++++ packages/db/src/index.ts | 53 +++++++++++++++++++++++++++ packages/db/src/migrate.ts | 14 +++++++- 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/db/src/index.test.ts b/packages/db/src/index.test.ts index 56bba801..6212091f 100644 --- a/packages/db/src/index.test.ts +++ b/packages/db/src/index.test.ts @@ -42,6 +42,7 @@ describe("SqliteStore", () => { { version: 5 }, { version: 6 }, { version: 7 }, + { version: 8 }, ]); }); @@ -659,4 +660,71 @@ describe("SqliteStore", () => { // Unknown id returns null without throwing. expect(store.deleteScheduledAgentCascade("missing")).toBeNull(); }); + + it("plan_registrations rows persist and cascade with their workspace", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-db-")); + dirs.push(dir); + const store = new SqliteStore(path.join(dir, "citadel.sqlite")); + store.migrate(); + + store.insertRepo({ + id: "repo_plan", + name: "Plan", + rootPath: path.join(dir, "repo"), + defaultBranch: "main", + defaultRemote: "origin", + worktreeParent: path.join(dir, "worktrees"), + setupHookIds: [], + teardownHookIds: [], + providerIds: [], + deployHookCommand: null, + createdAt: "2026-05-17T00:00:00.000Z", + updatedAt: "2026-05-17T00:00:00.000Z", + archivedAt: null, + }); + store.insertWorkspace({ + id: "ws_plan", + repoId: "repo_plan", + name: "plan-ws", + branch: "fb-plan-ws", + baseBranch: "main", + path: path.join(dir, "worktrees", "plan-ws"), + kind: "worktree", + lifecycle: "ready", + dirty: false, + source: "scratch", + prUrl: null, + issueKey: null, + issueTitle: null, + issueUrl: null, + slackThreadUrl: null, + section: "backlog", + pinned: false, + namespaceId: null, + createdAt: "2026-05-17T00:00:00.000Z", + updatedAt: "2026-05-17T00:00:00.000Z", + archivedAt: null, + }); + store.insertPlanRegistration({ + id: "plan-1", + workspaceId: "ws_plan", + path: "/tmp/plan/ws/.agents/plans/foo.md", + summary: "Foo plan", + registeredAt: "2026-05-17T00:00:00.000Z", + registeredBySessionId: null, + }); + + const list = store.listPlanRegistrationsForWorkspace("ws_plan"); + expect(list).toHaveLength(1); + expect(list[0]?.summary).toBe("Foo plan"); + expect(store.findPlanRegistration("plan-1")?.path).toBe("/tmp/plan/ws/.agents/plans/foo.md"); + + // CASCADE on workspace delete: registrations vanish. + store.deleteWorkspace("ws_plan"); + expect(store.listPlanRegistrationsForWorkspace("ws_plan")).toEqual([]); + expect(store.findPlanRegistration("plan-1")).toBeNull(); + + // deletePlanRegistration is a no-op for unknown ids. + expect(store.deletePlanRegistration("missing")).toBe(false); + }); }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ff700f5e..09a52337 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -8,6 +8,7 @@ import type { Namespace, Operation, OperationLogEntry, + PlanRegistration, Repo, ScheduledAgent, Workspace, @@ -662,6 +663,44 @@ export class SqliteStore { return next; } + insertPlanRegistration(row: PlanRegistration) { + this.database + .prepare( + `INSERT INTO plan_registrations (id, workspace_id, path, summary, registered_at, registered_by_session_id) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + row.id, + row.workspaceId, + row.path, + row.summary, + row.registeredAt, + row.registeredBySessionId, + ); + } + + findPlanRegistration(id: string): PlanRegistration | null { + const row = this.database + .prepare("SELECT * FROM plan_registrations WHERE id = ?") + .get(id) as Record | undefined; + if (!row) return null; + return planRegistrationFromRow(row); + } + + listPlanRegistrationsForWorkspace(workspaceId: string): PlanRegistration[] { + const rows = this.database + .prepare( + "SELECT * FROM plan_registrations WHERE workspace_id = ? ORDER BY registered_at DESC", + ) + .all(workspaceId) as Array>; + return rows.map(planRegistrationFromRow); + } + + deletePlanRegistration(id: string): boolean { + const result = this.database.prepare("DELETE FROM plan_registrations WHERE id = ?").run(id); + return (result.changes ?? 0) > 0; + } + private ensureColumn(table: string, column: string, definition: string) { const cols = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; if (!cols.some((entry) => entry.name === column)) { @@ -670,6 +709,20 @@ export class SqliteStore { } } +function planRegistrationFromRow(row: Record): PlanRegistration { + return { + id: String(row.id), + workspaceId: String(row.workspace_id), + path: String(row.path), + summary: row.summary === null || row.summary === undefined ? null : String(row.summary), + registeredAt: String(row.registered_at), + registeredBySessionId: + row.registered_by_session_id === null || row.registered_by_session_id === undefined + ? null + : String(row.registered_by_session_id), + }; +} + // Attach the scheduled_agent_runs and background_sessions methods to // SqliteStore.prototype. The implementations live in scheduled-run-store.ts // (kept separate to stay under the per-file line budget); the type diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 4bf836c4..92442774 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -192,13 +192,25 @@ export function runMigrations( ); CREATE INDEX IF NOT EXISTS idx_background_sessions_scheduled_agent ON background_sessions(scheduled_agent_id); + CREATE TABLE IF NOT EXISTS plan_registrations ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + path TEXT NOT NULL, + summary TEXT, + registered_at TEXT NOT NULL, + registered_by_session_id TEXT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_plan_registrations_workspace + ON plan_registrations(workspace_id); INSERT OR IGNORE INTO schema_migrations(version, name, applied_at) VALUES (2, 'activity-hook-output', datetime('now')), (3, 'operation-logs-retry', datetime('now')), (4, 'workspace-linked-urls', datetime('now')), (5, 'scheduled-agents', datetime('now')), (6, 'namespaces', datetime('now')), - (7, 'background-sessions-and-runs', datetime('now')); + (7, 'background-sessions-and-runs', datetime('now')), + (8, 'plan-registrations', datetime('now')); `); ensureColumn("scheduled_agents", "schedule_type", "TEXT NOT NULL DEFAULT 'recurring'"); ensureColumn("scheduled_agents", "run_at", "TEXT"); From cf159c0fb8ff861607906d112c84660708f82f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:19:06 +0000 Subject: [PATCH 04/11] feat(runtimes): listModels() adapters per runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds packages/runtimes/src/models/, mirroring the usage/ adapter pattern. runtimeModelListers registry exposes per-runtime probes; claude-code attempts a TUI scrape via tmux-pty.ts (5s timeout, try/finally killSession cleanup) and degrades to a hardcoded fallback list with a probeError on any failure. codex/cursor-agent/pi return small hardcoded defaults — their CLIs don't expose model selection today. Parser is fixture-driven (fixtures/claude-code-models.txt) so future TUI-format drift is caught at test time. Real-fixture verification against a live claude-code session is a follow-up gate before relying on the parser in production. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/runtimes/src/index.ts | 12 +++ packages/runtimes/src/models/claude-code.ts | 101 ++++++++++++++++++ packages/runtimes/src/models/codex.ts | 13 +++ packages/runtimes/src/models/cursor-agent.ts | 9 ++ .../models/fixtures/claude-code-models.txt | 12 +++ packages/runtimes/src/models/index.test.ts | 70 ++++++++++++ packages/runtimes/src/models/index.ts | 37 +++++++ packages/runtimes/src/models/pi.ts | 9 ++ 8 files changed, 263 insertions(+) create mode 100644 packages/runtimes/src/models/claude-code.ts create mode 100644 packages/runtimes/src/models/codex.ts create mode 100644 packages/runtimes/src/models/cursor-agent.ts create mode 100644 packages/runtimes/src/models/fixtures/claude-code-models.txt create mode 100644 packages/runtimes/src/models/index.test.ts create mode 100644 packages/runtimes/src/models/index.ts create mode 100644 packages/runtimes/src/models/pi.ts diff --git a/packages/runtimes/src/index.ts b/packages/runtimes/src/index.ts index 910ca475..73e78367 100644 --- a/packages/runtimes/src/index.ts +++ b/packages/runtimes/src/index.ts @@ -30,6 +30,18 @@ export { } from "./usage/index.js"; export type { RuntimeUsageFetcher } from "./usage/index.js"; +export { + runtimeModelListers, + hasRuntimeModelLister, + fetchClaudeCodeModels, + parseClaudeCodeModelsList, + CLAUDE_CODE_MODELS_FALLBACK, + fetchCodexModels, + fetchCursorAgentModels, + fetchPiModels, +} from "./models/index.js"; +export type { RuntimeModelLister, RuntimeModelListerResult } from "./models/index.js"; + export { getStatusAdapter, claudeCodeStatusAdapter, codexStatusAdapter, lastNonEmptyLine } from "./status/index.js"; export type { RuntimeStatusAdapter, diff --git a/packages/runtimes/src/models/claude-code.ts b/packages/runtimes/src/models/claude-code.ts new file mode 100644 index 00000000..ee6bffe8 --- /dev/null +++ b/packages/runtimes/src/models/claude-code.ts @@ -0,0 +1,101 @@ +import type { RuntimeModelDescriptor } from "@citadel/contracts"; +import { sleep, spawnEphemeralTmux, stripAnsi } from "../usage/tmux-pty.js"; +import type { RuntimeModelListerResult } from "./index.js"; + +const READY_MARKER = "Claude Code v"; +// Heuristic detection of the /models picker. Claude Code renders the picker +// as a menu where each row carries the model identifier; the exact format is +// version-dependent. Probe is best-effort — failure surfaces as probeError. +const MODEL_LINE_RE = /\b(claude-(?:opus|sonnet|haiku)-[\d-]+[a-z0-9-]*)\b/gi; +const PROBE_TIMEOUT_MS = 5_000; + +export const CLAUDE_CODE_MODELS_FALLBACK: RuntimeModelDescriptor[] = [ + { id: "claude-opus-4-7", displayName: "Opus 4.7", isDefault: false }, + { id: "claude-sonnet-4-6", displayName: "Sonnet 4.6", isDefault: true }, + { id: "claude-haiku-4-5", displayName: "Haiku 4.5", isDefault: false }, +]; + +// Parse model identifiers from a captured /models pane. Picks up any +// `claude-(opus|sonnet|haiku)-N-M[-suffix]` token, dedupes, preserves order. +export function parseClaudeCodeModelsList(rawPaneText: string): string[] { + const cleaned = stripAnsi(rawPaneText); + const seen = new Set(); + const out: string[] = []; + let match: RegExpExecArray | null; + MODEL_LINE_RE.lastIndex = 0; + match = MODEL_LINE_RE.exec(cleaned); + while (match !== null) { + const id = match[1]?.toLowerCase(); + if (id && !seen.has(id)) { + seen.add(id); + out.push(id); + } + match = MODEL_LINE_RE.exec(cleaned); + } + return out; +} + +// Drive `claude` via tmux to render the /models picker, capture the pane, +// extract identifiers. Wrapped in a hard 5s timeout; failure returns the +// fallback list with a probeError so the caller can still render the UI. +// +// Cleanup invariant: tmux session MUST be killed in finally even on parser +// throw or timeout, per the ttyd cleanup-storm lessons documented in the +// project memory. +export async function fetchClaudeCodeModels(input: { + command: string; + args?: string[]; +}): Promise { + try { + const probe = withTimeout(probeClaudeCodeModels(input), PROBE_TIMEOUT_MS); + return await probe; + } catch (err) { + return { + models: CLAUDE_CODE_MODELS_FALLBACK, + probeError: err instanceof Error ? err.message : String(err), + }; + } +} + +async function probeClaudeCodeModels(input: { + command: string; + args?: string[]; +}): Promise { + const tmux = await spawnEphemeralTmux({ + command: input.command, + args: input.args ?? [], + width: 200, + height: 60, + scrollback: 300, + sessionPrefix: "citadel_models_claude", + }); + try { + await tmux.waitFor((text) => text.includes(READY_MARKER), { timeoutMs: 4_000 }); + tmux.sendText("/models"); + tmux.sendKey("Enter"); + await sleep(400); + const ids = parseClaudeCodeModelsList(tmux.capture()); + if (ids.length === 0) { + return { models: CLAUDE_CODE_MODELS_FALLBACK, probeError: "no_models_parsed" }; + } + return { models: ids.map((id) => ({ id })) }; + } finally { + tmux.kill(); + } +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`probe_timeout_${timeoutMs}ms`)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + }, + ); + }); +} diff --git a/packages/runtimes/src/models/codex.ts b/packages/runtimes/src/models/codex.ts new file mode 100644 index 00000000..a00a54b0 --- /dev/null +++ b/packages/runtimes/src/models/codex.ts @@ -0,0 +1,13 @@ +import type { RuntimeModelListerResult } from "./index.js"; + +// codex CLI does not expose model selection (supportsModelSelection: false in +// the runtimes registry). Return a single default entry so the UI never shows +// an empty list. Update this manually when codex gains a model picker. +export async function fetchCodexModels(_input: { + command: string; + args?: string[]; +}): Promise { + return { + models: [{ id: "gpt-5.5", displayName: "GPT-5.5", isDefault: true }], + }; +} diff --git a/packages/runtimes/src/models/cursor-agent.ts b/packages/runtimes/src/models/cursor-agent.ts new file mode 100644 index 00000000..025ca524 --- /dev/null +++ b/packages/runtimes/src/models/cursor-agent.ts @@ -0,0 +1,9 @@ +import type { RuntimeModelListerResult } from "./index.js"; + +// cursor-agent doesn't advertise model selection; ship a single default. +export async function fetchCursorAgentModels(_input: { + command: string; + args?: string[]; +}): Promise { + return { models: [{ id: "default", isDefault: true }] }; +} diff --git a/packages/runtimes/src/models/fixtures/claude-code-models.txt b/packages/runtimes/src/models/fixtures/claude-code-models.txt new file mode 100644 index 00000000..2811c0e3 --- /dev/null +++ b/packages/runtimes/src/models/fixtures/claude-code-models.txt @@ -0,0 +1,12 @@ +Welcome to Claude Code v1.0.0 + +╭──────────────────────────────────────────╮ +│ Select model │ +├──────────────────────────────────────────┤ +│ > claude-opus-4-7 (Opus 4.7) │ +│ claude-sonnet-4-6 (Sonnet 4.6) │ +│ claude-haiku-4-5 (Haiku 4.5) │ +│ claude-sonnet-4-5 (Sonnet 4.5) │ +╰──────────────────────────────────────────╯ + + ↑/↓ to navigate · Enter to select · Esc to cancel diff --git a/packages/runtimes/src/models/index.test.ts b/packages/runtimes/src/models/index.test.ts new file mode 100644 index 00000000..6b7a6d95 --- /dev/null +++ b/packages/runtimes/src/models/index.test.ts @@ -0,0 +1,70 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + CLAUDE_CODE_MODELS_FALLBACK, + fetchCodexModels, + fetchCursorAgentModels, + fetchPiModels, + hasRuntimeModelLister, + parseClaudeCodeModelsList, + runtimeModelListers, +} from "./index.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const claudeFixture = fs.readFileSync(path.join(fixturesDir, "claude-code-models.txt"), "utf8"); + +describe("runtimeModelListers", () => { + it("exposes listers for the four citadel-maintained runtimes", () => { + expect(Object.keys(runtimeModelListers).sort()).toEqual( + ["claude-code", "codex", "cursor-agent", "pi"].sort(), + ); + }); + + it("hasRuntimeModelLister gates by runtime id", () => { + expect(hasRuntimeModelLister("claude-code")).toBe(true); + expect(hasRuntimeModelLister("shell")).toBe(false); + expect(hasRuntimeModelLister("totally-unknown")).toBe(false); + }); + + it("non-claude runtimes return a non-empty default list with no probeError", async () => { + const codex = await fetchCodexModels({ command: "codex" }); + expect(codex.models.length).toBeGreaterThan(0); + expect(codex.probeError).toBeUndefined(); + + const cursor = await fetchCursorAgentModels({ command: "cursor-agent" }); + expect(cursor.models.length).toBeGreaterThan(0); + expect(cursor.probeError).toBeUndefined(); + + const pi = await fetchPiModels({ command: "pi" }); + expect(pi.models.length).toBeGreaterThan(0); + expect(pi.probeError).toBeUndefined(); + }); +}); + +describe("parseClaudeCodeModelsList", () => { + it("extracts model ids from the captured /models picker fixture", () => { + const ids = parseClaudeCodeModelsList(claudeFixture); + expect(ids).toEqual([ + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-sonnet-4-5", + ]); + }); + + it("dedupes repeated mentions and preserves first-seen order", () => { + const text = "claude-sonnet-4-6 ... claude-opus-4-7 ... claude-sonnet-4-6 again"; + expect(parseClaudeCodeModelsList(text)).toEqual(["claude-sonnet-4-6", "claude-opus-4-7"]); + }); + + it("returns an empty list when no model identifiers are present", () => { + expect(parseClaudeCodeModelsList("nothing to see here")).toEqual([]); + }); + + it("fallback list has the canonical models", () => { + expect(CLAUDE_CODE_MODELS_FALLBACK.map((entry) => entry.id)).toContain("claude-sonnet-4-6"); + expect(CLAUDE_CODE_MODELS_FALLBACK.map((entry) => entry.id)).toContain("claude-opus-4-7"); + }); +}); diff --git a/packages/runtimes/src/models/index.ts b/packages/runtimes/src/models/index.ts new file mode 100644 index 00000000..373216b9 --- /dev/null +++ b/packages/runtimes/src/models/index.ts @@ -0,0 +1,37 @@ +import type { RuntimeModelDescriptor } from "@citadel/contracts"; +import { fetchClaudeCodeModels } from "./claude-code.js"; +import { fetchCodexModels } from "./codex.js"; +import { fetchCursorAgentModels } from "./cursor-agent.js"; +import { fetchPiModels } from "./pi.js"; + +export type RuntimeModelListerResult = { + models: RuntimeModelDescriptor[]; + probeError?: string; +}; + +export type RuntimeModelLister = (input: { + command: string; + args?: string[]; +}) => Promise; + +// Lookup of built-in, runtime-owned model listers keyed by runtime id. +// Mirrors the `runtimeUsageFetchers` registry next door. +export const runtimeModelListers: Record = { + "claude-code": fetchClaudeCodeModels, + codex: fetchCodexModels, + "cursor-agent": fetchCursorAgentModels, + pi: fetchPiModels, +}; + +export function hasRuntimeModelLister(runtimeId: string): boolean { + return Object.hasOwn(runtimeModelListers, runtimeId); +} + +export { + fetchClaudeCodeModels, + parseClaudeCodeModelsList, + CLAUDE_CODE_MODELS_FALLBACK, +} from "./claude-code.js"; +export { fetchCodexModels } from "./codex.js"; +export { fetchCursorAgentModels } from "./cursor-agent.js"; +export { fetchPiModels } from "./pi.js"; diff --git a/packages/runtimes/src/models/pi.ts b/packages/runtimes/src/models/pi.ts new file mode 100644 index 00000000..f478c4e8 --- /dev/null +++ b/packages/runtimes/src/models/pi.ts @@ -0,0 +1,9 @@ +import type { RuntimeModelListerResult } from "./index.js"; + +// pi runtime doesn't advertise model selection; ship a single default. +export async function fetchPiModels(_input: { + command: string; + args?: string[]; +}): Promise { + return { models: [{ id: "default", isDefault: true }] }; +} From e62b246c275230419257d0b5c444afa83ca110df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:21:10 +0000 Subject: [PATCH 05/11] feat(daemon): agent-definitions storage service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ~/.citadel/agents/ storage backing the four predefined agent definitions (architect, implementation, pm, prototype) plus user-defined custom agents. Each definition lives in its own JSON file for atomic writes and to keep concurrent edits from racing on a shared file. Highlights: - Idempotent seeding: list() re-reads disk on every call (no in-memory cache that could desync across daemons sharing the directory), and the seed only writes when a predefined file is missing OR present-but- unparseable; well-formed user edits are preserved. - Boot-safe: when the directory cannot be created, list() returns [] and state() reports "unavailable" so the daemon does not crashloop under systemd Restart=always. - Predefined IDs are reserved — custom create rejects collisions; remove rejects predefined; resetToDefaults rejects custom and restores the citadel-authored seed (NOT the user's defaultRuntime). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/daemon/src/agent-definitions/seed.ts | 88 ++++++ .../src/agent-definitions/storage.test.ts | 156 ++++++++++ apps/daemon/src/agent-definitions/storage.ts | 274 ++++++++++++++++++ 3 files changed, 518 insertions(+) create mode 100644 apps/daemon/src/agent-definitions/seed.ts create mode 100644 apps/daemon/src/agent-definitions/storage.test.ts create mode 100644 apps/daemon/src/agent-definitions/storage.ts diff --git a/apps/daemon/src/agent-definitions/seed.ts b/apps/daemon/src/agent-definitions/seed.ts new file mode 100644 index 00000000..ba8f0599 --- /dev/null +++ b/apps/daemon/src/agent-definitions/seed.ts @@ -0,0 +1,88 @@ +import type { AgentDefinition, PredefinedAgentKind } from "@citadel/contracts"; + +// Stable seed timestamp — predefined definitions report the same createdAt +// across all installs so tests and audits can pin them. +const SEED_TIMESTAMP = "2026-01-01T00:00:00.000Z"; +const DEFAULT_RUNTIME = "claude-code"; + +// Predefined system prompts. Each cites the corresponding skill's semantics +// without embedding the full skill text; the actual /implement-task, +// /do-tech-plan, etc. skills remain the canonical source. +export const PREDEFINED_AGENT_SYSTEM_PROMPTS: Record = { + architect: `You are an Architect agent in Citadel. + +Your job is to produce a rigorous technical plan before any implementation. +Gather requirements, cross-check specs, surface alternatives, identify risks, +and define a QA/Test Strategy. Do not write production code in this role. + +Mirror the semantics of the /do-tech-plan skill: structured 9-point plan with +context, spec alignment, approach, alternatives, implementation steps, +QA/Test Strategy, tests, verification. Default to small, reviewable plans. +Push back on scope you believe will not fit in one PR.`, + implementation: `You are an Implementation agent in Citadel. + +Your job is to execute a reviewed plan via the TDD cycle: tests first, +then production code, then targeted checks. Mirror the semantics of +the /implement-task skill: plan intake, mandatory task list, TDD loop, +targeted checks, self-review, push. + +Do not redesign the plan. If a plan is wrong, surface it; do not silently +rewrite. Commit incrementally — each implementation unit ends with a commit +describing the why.`, + pm: `You are a PM agent in Citadel. + +Your job is to clarify scope, write acceptance criteria, and shape work into +PR-sized units before architecture or implementation begins. Ask sharp +clarifying questions when requirements are ambiguous; refuse to invent +business logic. + +Output: a tight requirements summary with explicit acceptance criteria, the +smallest coherent first slice, and what is out of scope. Avoid prescribing +implementation details — that is the architect's role.`, + prototype: `You are a Prototype agent in Citadel. + +Your job is fast UI iteration: small, single-shot prompts to land a usable +interactive prototype quickly. Skip tests, skip migrations, skip refactors. +Optimize for "user can click on it in a browser in under 20 minutes." + +This role is intentionally NOT production-quality; mark the resulting files +with a clear "prototype" header so subsequent agents know to harden them +before merging.`, +}; + +const PREDEFINED_NAMES: Record = { + architect: "Architect", + implementation: "Implementation", + pm: "PM", + prototype: "Prototype", +}; + +const PREDEFINED_KINDS: PredefinedAgentKind[] = ["architect", "implementation", "pm", "prototype"]; + +export function predefinedAgentIds(): PredefinedAgentKind[] { + return [...PREDEFINED_KINDS]; +} + +export function isPredefinedAgentId(id: string): id is PredefinedAgentKind { + return (PREDEFINED_KINDS as string[]).includes(id); +} + +// Seed value for a single predefined definition. Pure — same input always +// produces the same output, so callers can use it for content-hash dedupe. +export function predefinedAgentSeed(kind: PredefinedAgentKind): AgentDefinition { + return { + id: kind, + kind: "predefined", + name: PREDEFINED_NAMES[kind], + systemPrompt: PREDEFINED_AGENT_SYSTEM_PROMPTS[kind], + runtime: DEFAULT_RUNTIME, + createdAt: SEED_TIMESTAMP, + updatedAt: SEED_TIMESTAMP, + }; +} + +export function predefinedAgentSeeds(): AgentDefinition[] { + return PREDEFINED_KINDS.map(predefinedAgentSeed); +} + +export const DEFAULT_AGENTS_CONFIG = { defaultRuntime: DEFAULT_RUNTIME } as const; diff --git a/apps/daemon/src/agent-definitions/storage.test.ts b/apps/daemon/src/agent-definitions/storage.test.ts new file mode 100644 index 00000000..d2b1515a --- /dev/null +++ b/apps/daemon/src/agent-definitions/storage.test.ts @@ -0,0 +1,156 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + AgentDefinitionsError, + createAgentDefinitionsStorage, +} from "./storage.js"; +import { predefinedAgentIds, predefinedAgentSeed } from "./seed.js"; + +const dirs: string[] = []; + +function makeStorage() { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-agents-")); + const configPath = path.join(baseDir, "..", `${path.basename(baseDir)}.config.json`); + dirs.push(baseDir); + return { + baseDir, + configPath, + storage: createAgentDefinitionsStorage({ baseDir, configPath }), + }; +} + +beforeEach(() => { + dirs.length = 0; +}); + +afterEach(() => { + for (const dir of dirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +describe("agent definitions storage", () => { + it("seeds four predefined definitions on first read", () => { + const { storage } = makeStorage(); + const list = storage.list(); + expect(list.map((d) => d.id).sort()).toEqual(predefinedAgentIds().sort()); + for (const def of list) { + expect(def.kind).toBe("predefined"); + expect(def.systemPrompt.length).toBeGreaterThan(0); + expect(def.runtime).toBe("claude-code"); + } + }); + + it("seed() is idempotent — does not rewrite well-formed files on repeat list()", () => { + const { storage, baseDir } = makeStorage(); + storage.list(); + const filePath = path.join(baseDir, "implementation.json"); + const beforeMtime = fs.statSync(filePath).mtimeMs; + // Sleep a tick to let mtime advance if a rewrite happened. + const wait = Date.now() + 30; + while (Date.now() < wait) { + /* spin */ + } + storage.list(); + const afterMtime = fs.statSync(filePath).mtimeMs; + expect(afterMtime).toBe(beforeMtime); + }); + + it("recreates a manually-deleted predefined file on next list()", () => { + const { storage, baseDir } = makeStorage(); + storage.list(); + fs.unlinkSync(path.join(baseDir, "implementation.json")); + const list2 = storage.list(); + expect(list2.find((d) => d.id === "implementation")).toBeTruthy(); + }); + + it("rejects predefined ids for delete and reject custom ids for reset", () => { + const { storage } = makeStorage(); + storage.list(); // seed + expect(() => storage.remove("implementation")).toThrowError(AgentDefinitionsError); + try { + storage.remove("implementation"); + } catch (err) { + expect((err as AgentDefinitionsError).code).toBe("predefined_agent_cannot_be_deleted"); + } + const custom = storage.create({ name: "Custom A", systemPrompt: "hi", runtime: "claude-code" }); + try { + storage.resetToDefaults(custom.id); + } catch (err) { + expect((err as AgentDefinitionsError).code).toBe("predefined_agent_cannot_be_reset_by_custom_id"); + } + }); + + it("create rejects on name collision and update rejects on rename collision", () => { + const { storage } = makeStorage(); + storage.list(); + storage.create({ name: "Reviewer", systemPrompt: "x", runtime: "claude-code" }); + try { + storage.create({ name: "reviewer", systemPrompt: "x", runtime: "claude-code" }); + } catch (err) { + expect((err as AgentDefinitionsError).code).toBe("name_collides"); + } + const second = storage.create({ name: "Other", systemPrompt: "x", runtime: "claude-code" }); + try { + storage.update(second.id, { name: "Reviewer" }); + } catch (err) { + expect((err as AgentDefinitionsError).code).toBe("name_collides"); + } + }); + + it("resetToDefaults restores the citadel-authored seed verbatim, NOT user defaultRuntime", () => { + const { storage } = makeStorage(); + storage.list(); + storage.writeConfig({ defaultRuntime: "codex" }); + storage.update("implementation", { runtime: "codex", systemPrompt: "rewritten" }); + const reset = storage.resetToDefaults("implementation"); + expect(reset.runtime).toBe("claude-code"); + expect(reset.systemPrompt).toBe(predefinedAgentSeed("implementation").systemPrompt); + }); + + it("custom agent CRUD round-trip", () => { + const { storage } = makeStorage(); + storage.list(); + const created = storage.create({ + name: "Reviewer", + systemPrompt: "Review carefully.", + runtime: "claude-code", + }); + expect(created.kind).toBe("custom"); + expect(storage.get(created.id)?.systemPrompt).toBe("Review carefully."); + const updated = storage.update(created.id, { systemPrompt: "Review skeptically." }); + expect(updated.systemPrompt).toBe("Review skeptically."); + storage.remove(created.id); + expect(storage.get(created.id)).toBeUndefined(); + }); + + it("readConfig defaults when no config file exists; writeConfig persists", () => { + const { storage } = makeStorage(); + expect(storage.readConfig().defaultRuntime).toBe("claude-code"); + const updated = storage.writeConfig({ defaultRuntime: "codex" }); + expect(updated.defaultRuntime).toBe("codex"); + expect(storage.readConfig().defaultRuntime).toBe("codex"); + }); + + it("boot-safety: when baseDir cannot be created, list() returns empty and state() reports unavailable", () => { + // Point baseDir at a path WHERE a parent is a file — mkdirSync will fail. + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-agents-broken-")); + dirs.push(parent); + const blockerFile = path.join(parent, "block"); + fs.writeFileSync(blockerFile, ""); + const baseDir = path.join(blockerFile, "agents"); + const configPath = path.join(blockerFile, "agents.config.json"); + const storage = createAgentDefinitionsStorage({ baseDir, configPath }); + expect(storage.list()).toEqual([]); + expect(storage.state()).toBe("unavailable"); + expect(() => storage.create({ name: "X", systemPrompt: "y", runtime: "claude-code" })).toThrowError( + AgentDefinitionsError, + ); + }); +}); diff --git a/apps/daemon/src/agent-definitions/storage.ts b/apps/daemon/src/agent-definitions/storage.ts new file mode 100644 index 00000000..2e8b839c --- /dev/null +++ b/apps/daemon/src/agent-definitions/storage.ts @@ -0,0 +1,274 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + AgentDefinitionSchema, + AgentsConfigSchema, + type AgentDefinition, + type AgentsConfig, + type CreateAgentDefinitionInput, + type UpdateAgentDefinitionInput, +} from "@citadel/contracts"; +import { + DEFAULT_AGENTS_CONFIG, + isPredefinedAgentId, + predefinedAgentSeed, + predefinedAgentSeeds, +} from "./seed.js"; + +export type AgentDefinitionsStorageState = "ready" | "unavailable"; + +export type AgentDefinitionsStorageError = + | "agent_storage_unavailable" + | "predefined_agent_cannot_be_deleted" + | "predefined_agent_cannot_be_reset_by_custom_id" + | "custom_agent_cannot_reuse_predefined_id" + | "agent_not_found" + | "name_collides"; + +export class AgentDefinitionsError extends Error { + constructor(public readonly code: AgentDefinitionsStorageError, message?: string) { + super(message ?? code); + } +} + +export type AgentDefinitionsStorage = { + state(): AgentDefinitionsStorageState; + list(): AgentDefinition[]; + get(id: string): AgentDefinition | undefined; + create(input: CreateAgentDefinitionInput): AgentDefinition; + update(id: string, patch: UpdateAgentDefinitionInput): AgentDefinition; + remove(id: string): void; + resetToDefaults(id: string): AgentDefinition; + readConfig(): AgentsConfig; + writeConfig(patch: Partial): AgentsConfig; +}; + +export type AgentDefinitionsStorageOptions = { + // Directory containing the per-definition JSON files. Defaults to + // ~/.citadel/agents. Override only for tests. + baseDir?: string; + // Path to the config JSON. Defaults to ~/.citadel/agents.config.json. + configPath?: string; +}; + +const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/; + +export function createAgentDefinitionsStorage( + options: AgentDefinitionsStorageOptions = {}, +): AgentDefinitionsStorage { + const home = os.homedir(); + const baseDir = options.baseDir ?? path.join(home, ".citadel", "agents"); + const configPath = options.configPath ?? path.join(home, ".citadel", "agents.config.json"); + let bootError: string | null = null; + + const ensureDir = (): boolean => { + try { + fs.mkdirSync(baseDir, { recursive: true }); + // After mkdir, prove we can write a sentinel. + const sentinel = path.join(baseDir, ".citadel-write-test"); + fs.writeFileSync(sentinel, ""); + fs.unlinkSync(sentinel); + bootError = null; + return true; + } catch (err) { + bootError = err instanceof Error ? err.message : String(err); + return false; + } + }; + + const writeDefinition = (def: AgentDefinition) => { + const filePath = path.join(baseDir, `${def.id}.json`); + const data = JSON.stringify(def, null, 2); + fs.writeFileSync(filePath, data); + }; + + const writeIfDriftedOrMissing = (def: AgentDefinition) => { + const filePath = path.join(baseDir, `${def.id}.json`); + const desired = JSON.stringify(def, null, 2); + let current: string | null = null; + try { + current = fs.readFileSync(filePath, "utf8"); + } catch { + current = null; + } + if (current === null) { + fs.writeFileSync(filePath, desired); + return; + } + const equal = createHash("sha256").update(current).digest("hex") === + createHash("sha256").update(desired).digest("hex"); + if (!equal) { + // Only overwrite if the on-disk file is unparseable. For valid but + // edited predefined definitions, the user's edits stand. + try { + const parsed = JSON.parse(current); + AgentDefinitionSchema.parse(parsed); + return; + } catch { + fs.writeFileSync(filePath, desired); + } + } + }; + + const seedIfNeeded = () => { + for (const seed of predefinedAgentSeeds()) { + writeIfDriftedOrMissing(seed); + } + }; + + const readAllDefinitions = (): AgentDefinition[] => { + if (!ensureDir()) return []; + seedIfNeeded(); + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(baseDir, { withFileTypes: true }); + } catch (err) { + bootError = err instanceof Error ? err.message : String(err); + return []; + } + const out: AgentDefinition[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith(".json")) continue; + const filePath = path.join(baseDir, entry.name); + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw); + const def = AgentDefinitionSchema.parse(parsed); + out.push(def); + } catch { + // Skip malformed files; do not throw. Predefined ids will be re-seeded + // on the next call if we deleted the bad file, but for safety we just + // skip. A future operator-facing log surface could expose this. + } + } + // Stable order: predefined first (alpha), then custom (alpha by name). + const predefined = out.filter((d) => d.kind === "predefined").sort((a, b) => a.id.localeCompare(b.id)); + const custom = out.filter((d) => d.kind === "custom").sort((a, b) => a.name.localeCompare(b.name)); + return [...predefined, ...custom]; + }; + + const idForName = (name: string): string => { + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (slug.length >= 2 && SLUG_RE.test(slug)) return slug; + // Fall back to a stable timestamp-derived id if the name is exotic. + return `agent-${Date.now().toString(36)}`; + }; + + const uniqueCustomId = (name: string, existing: AgentDefinition[]): string => { + const base = idForName(name); + if (isPredefinedAgentId(base)) { + return uniqueCustomId(`${name}-custom`, existing); + } + const taken = new Set(existing.map((d) => d.id)); + if (!taken.has(base)) return base; + for (let i = 2; i < 1000; i += 1) { + const candidate = `${base}-${i}`; + if (!taken.has(candidate)) return candidate; + } + return `${base}-${Date.now().toString(36)}`; + }; + + return { + state() { + ensureDir(); + return bootError ? "unavailable" : "ready"; + }, + list() { + return readAllDefinitions(); + }, + get(id: string) { + return readAllDefinitions().find((d) => d.id === id); + }, + create(input) { + if (!ensureDir()) throw new AgentDefinitionsError("agent_storage_unavailable", bootError ?? ""); + const all = readAllDefinitions(); + if (all.some((d) => d.name.toLowerCase() === input.name.toLowerCase())) { + throw new AgentDefinitionsError("name_collides"); + } + const id = uniqueCustomId(input.name, all); + if (isPredefinedAgentId(id)) { + throw new AgentDefinitionsError("custom_agent_cannot_reuse_predefined_id"); + } + const now = new Date().toISOString(); + const def: AgentDefinition = { + id, + kind: "custom", + name: input.name, + systemPrompt: input.systemPrompt, + runtime: input.runtime, + model: input.model, + createdAt: now, + updatedAt: now, + }; + writeDefinition(def); + return def; + }, + update(id, patch) { + if (!ensureDir()) throw new AgentDefinitionsError("agent_storage_unavailable", bootError ?? ""); + const all = readAllDefinitions(); + const current = all.find((d) => d.id === id); + if (!current) throw new AgentDefinitionsError("agent_not_found"); + if (patch.name !== undefined && patch.name !== current.name) { + const collision = all.some( + (d) => d.id !== id && d.name.toLowerCase() === patch.name?.toLowerCase(), + ); + if (collision) throw new AgentDefinitionsError("name_collides"); + } + const next: AgentDefinition = { + ...current, + name: patch.name ?? current.name, + systemPrompt: patch.systemPrompt ?? current.systemPrompt, + runtime: patch.runtime ?? current.runtime, + model: patch.model === undefined ? current.model : patch.model ?? undefined, + updatedAt: new Date().toISOString(), + }; + writeDefinition(next); + return next; + }, + remove(id) { + if (!ensureDir()) throw new AgentDefinitionsError("agent_storage_unavailable", bootError ?? ""); + if (isPredefinedAgentId(id)) { + throw new AgentDefinitionsError("predefined_agent_cannot_be_deleted"); + } + const filePath = path.join(baseDir, `${id}.json`); + try { + fs.unlinkSync(filePath); + } catch { + throw new AgentDefinitionsError("agent_not_found"); + } + }, + resetToDefaults(id) { + if (!ensureDir()) throw new AgentDefinitionsError("agent_storage_unavailable", bootError ?? ""); + if (!isPredefinedAgentId(id)) { + throw new AgentDefinitionsError("predefined_agent_cannot_be_reset_by_custom_id"); + } + const seed = predefinedAgentSeed(id); + writeDefinition(seed); + return seed; + }, + readConfig() { + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw); + return AgentsConfigSchema.parse(parsed); + } catch { + return { ...DEFAULT_AGENTS_CONFIG }; + } + }, + writeConfig(patch) { + const current = this.readConfig(); + const next = AgentsConfigSchema.parse({ ...current, ...patch }); + try { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(next, null, 2)); + } catch (err) { + bootError = err instanceof Error ? err.message : String(err); + throw new AgentDefinitionsError("agent_storage_unavailable", bootError); + } + return next; + }, + }; +} From f3bb0493d2b694dbb9d2424582f68819c4c1180f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:28:47 +0000 Subject: [PATCH 06/11] feat(daemon): agents-routes.ts + per-runtime model probe endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds apps/daemon/src/agents-routes.ts with the new HTTP surface for agent definitions and per-runtime model discovery: - GET/POST/PATCH/DELETE /api/agents (predefined-delete → 409, predefined-reset-by-custom-id → 400, etc.) - POST /api/agents/:id/reset - GET/PUT /api/agents/config - GET /api/runtimes/:id/models with a 1-hour TTL cache and ?refresh=1 bypass (claude-code CLI upgrades happen out-of-band, so daemon-lifetime caching would surface stale models) app.ts gets a single registrar call so it remains thin. To stay under the 800-LoC file-size gate, also compresses the /api/repos/:repoId/ branches handler (behavior unchanged) and extracts agent contracts to packages/contracts/src/agents.ts (and IdSchema to id.ts) since the new schemas pushed index.ts over its budget. Route tests drive the live express app via node:http (no new dev deps; supertest avoided to keep the lockfile clean). The 1h-TTL behavior is exercised with a fake clock that exercises hit/miss/refresh transitions in order. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/daemon/src/agents-routes.test.ts | 253 ++++++++++++++++++++++++++ apps/daemon/src/agents-routes.ts | 199 ++++++++++++++++++++ apps/daemon/src/app.ts | 41 ++--- packages/contracts/src/agents.ts | 108 +++++++++++ packages/contracts/src/id.ts | 7 + packages/contracts/src/index.ts | 115 +----------- 6 files changed, 584 insertions(+), 139 deletions(-) create mode 100644 apps/daemon/src/agents-routes.test.ts create mode 100644 apps/daemon/src/agents-routes.ts create mode 100644 packages/contracts/src/agents.ts create mode 100644 packages/contracts/src/id.ts diff --git a/apps/daemon/src/agents-routes.test.ts b/apps/daemon/src/agents-routes.test.ts new file mode 100644 index 00000000..570f0845 --- /dev/null +++ b/apps/daemon/src/agents-routes.test.ts @@ -0,0 +1,253 @@ +import express from "express"; +import http from "node:http"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { RuntimeModelLister } from "@citadel/runtimes"; +import { registerAgentsRoutes } from "./agents-routes.js"; +import { + createAgentDefinitionsStorage, + type AgentDefinitionsStorage, +} from "./agent-definitions/storage.js"; + +type HttpResult = { status: number; body: Record }; + +async function request(server: http.Server, method: string, p: string, body?: unknown): Promise { + const address = server.address(); + if (typeof address !== "object" || address === null) throw new Error("server not listening"); + const port = (address as { port: number }).port; + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port, + method, + path: p, + headers: { "content-type": "application/json" }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + const parsed = raw.length === 0 ? {} : JSON.parse(raw); + resolve({ status: res.statusCode ?? 0, body: parsed }); + }); + res.on("error", reject); + }, + ); + req.on("error", reject); + if (body !== undefined) req.write(JSON.stringify(body)); + req.end(); + }); +} + +function mountApp(opts: { + storage: AgentDefinitionsStorage; + modelListers?: Record; + runtimes?: Array<{ id: string; command: string; args: string[] }>; + now?: () => number; +}): { app: express.Express; close: () => Promise; server: http.Server } { + const app = express(); + app.use(express.json()); + const asyncRoute = + (handler: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise): express.RequestHandler => + (req, res, next) => { + Promise.resolve(handler(req, res, next)).catch(next); + }; + registerAgentsRoutes({ + app, + asyncRoute, + agentDefinitions: opts.storage, + runtimes: () => opts.runtimes ?? [{ id: "claude-code", command: "claude", args: [] }], + modelListers: opts.modelListers, + now: opts.now, + }); + app.use( + (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + }, + ); + const server = http.createServer(app); + return new Promise<{ app: express.Express; close: () => Promise; server: http.Server }>((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve({ + app, + server, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }) as never; +} + +async function mountAppAsync(opts: Parameters[0]) { + return (await mountApp(opts)) as unknown as { app: express.Express; close: () => Promise; server: http.Server }; +} + +const dirs: string[] = []; +const closers: Array<() => Promise> = []; + +beforeEach(() => { + dirs.length = 0; + closers.length = 0; +}); + +afterEach(async () => { + for (const close of closers) { + try { + await close(); + } catch { + // ignore + } + } + for (const dir of dirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +function makeStorage() { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-routes-")); + const configPath = path.join(baseDir, "..", `${path.basename(baseDir)}.config.json`); + dirs.push(baseDir); + return createAgentDefinitionsStorage({ baseDir, configPath }); +} + +function makeBrokenStorage() { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-routes-broken-")); + dirs.push(parent); + const blocker = path.join(parent, "block"); + fs.writeFileSync(blocker, ""); + return createAgentDefinitionsStorage({ + baseDir: path.join(blocker, "agents"), + configPath: path.join(blocker, "agents.config.json"), + }); +} + +async function mount(opts: Parameters[0]) { + const ctx = await mountAppAsync(opts); + closers.push(ctx.close); + return ctx; +} + +describe("agents routes", () => { + it("GET /api/agents returns predefined definitions + config", async () => { + const { server } = await mount({ storage: makeStorage() }); + const res = await request(server, "GET", "/api/agents"); + expect(res.status).toBe(200); + const definitions = (res.body.definitions as Array<{ id: string }>).map((d) => d.id); + expect(definitions).toContain("implementation"); + expect(definitions).toContain("architect"); + expect((res.body.config as { defaultRuntime: string }).defaultRuntime).toBe("claude-code"); + }); + + it("DELETE on a predefined agent returns 409 predefined_agent_cannot_be_deleted", async () => { + const { server } = await mount({ storage: makeStorage() }); + await request(server, "GET", "/api/agents"); + const res = await request(server, "DELETE", "/api/agents/implementation"); + expect(res.status).toBe(409); + expect(res.body.error).toBe("predefined_agent_cannot_be_deleted"); + }); + + it("POST creates custom, PATCH updates, DELETE removes", async () => { + const { server } = await mount({ storage: makeStorage() }); + await request(server, "GET", "/api/agents"); + const create = await request(server, "POST", "/api/agents", { + name: "Reviewer", + systemPrompt: "Review.", + runtime: "claude-code", + }); + expect(create.status).toBe(201); + const id = (create.body.definition as { id: string }).id; + const update = await request(server, "PATCH", `/api/agents/${id}`, { systemPrompt: "Review hard." }); + expect((update.body.definition as { systemPrompt: string }).systemPrompt).toBe("Review hard."); + const remove = await request(server, "DELETE", `/api/agents/${id}`); + expect(remove.status).toBe(204); + }); + + it("POST /api/agents/:id/reset on a custom id returns 400", async () => { + const { server } = await mount({ storage: makeStorage() }); + await request(server, "GET", "/api/agents"); + const create = await request(server, "POST", "/api/agents", { + name: "Reviewer", + systemPrompt: "x", + runtime: "claude-code", + }); + const id = (create.body.definition as { id: string }).id; + const reset = await request(server, "POST", `/api/agents/${id}/reset`); + expect(reset.status).toBe(400); + }); + + it("GET /api/runtimes/:id/models honors the 1h TTL and ?refresh=1 bypasses it", async () => { + let counter = 0; + const lister: RuntimeModelLister = async () => { + counter += 1; + return { models: [{ id: "claude-sonnet-4-6", isDefault: true }] }; + }; + let nowMs = 1_000_000; + const { server } = await mount({ + storage: makeStorage(), + modelListers: { "claude-code": lister }, + now: () => nowMs, + }); + const a = await request(server, "GET", "/api/runtimes/claude-code/models"); + expect(a.status).toBe(200); + expect(counter).toBe(1); + await request(server, "GET", "/api/runtimes/claude-code/models"); + expect(counter).toBe(1); + nowMs += 30 * 60 * 1_000; + await request(server, "GET", "/api/runtimes/claude-code/models"); + expect(counter).toBe(1); + nowMs += 31 * 60 * 1_000; + await request(server, "GET", "/api/runtimes/claude-code/models"); + expect(counter).toBe(2); + await request(server, "GET", "/api/runtimes/claude-code/models?refresh=1"); + expect(counter).toBe(3); + }); + + it("model probe error propagates as probeError on the response", async () => { + const lister: RuntimeModelLister = async () => { + throw new Error("tmux exploded"); + }; + const { server } = await mount({ + storage: makeStorage(), + modelListers: { "claude-code": lister }, + }); + const res = await request(server, "GET", "/api/runtimes/claude-code/models"); + expect(res.status).toBe(200); + expect(res.body.probeError).toBe("tmux exploded"); + }); + + it("model probe with probeError in the result propagates without throwing", async () => { + const lister: RuntimeModelLister = async () => ({ + models: [{ id: "fallback", isDefault: true }], + probeError: "no_models_parsed", + }); + const { server } = await mount({ + storage: makeStorage(), + modelListers: { "claude-code": lister }, + }); + const res = await request(server, "GET", "/api/runtimes/claude-code/models"); + expect(res.body.probeError).toBe("no_models_parsed"); + expect((res.body.models as Array<{ id: string }>).map((m) => m.id)).toEqual(["fallback"]); + }); + + it("GET /api/agents returns 503 when storage is unavailable", async () => { + const { server } = await mount({ storage: makeBrokenStorage() }); + const res = await request(server, "GET", "/api/agents"); + expect(res.status).toBe(503); + expect(res.body.error).toBe("agent_storage_unavailable"); + }); + + it("PUT /api/agents/config persists defaultRuntime", async () => { + const storage = makeStorage(); + const { server } = await mount({ storage }); + const res = await request(server, "PUT", "/api/agents/config", { defaultRuntime: "codex" }); + expect((res.body.config as { defaultRuntime: string }).defaultRuntime).toBe("codex"); + expect(storage.readConfig().defaultRuntime).toBe("codex"); + }); +}); diff --git a/apps/daemon/src/agents-routes.ts b/apps/daemon/src/agents-routes.ts new file mode 100644 index 00000000..238b5709 --- /dev/null +++ b/apps/daemon/src/agents-routes.ts @@ -0,0 +1,199 @@ +import { + AgentsConfigSchema, + CreateAgentDefinitionInputSchema, + UpdateAgentDefinitionInputSchema, + type RuntimeModelsResponse, +} from "@citadel/contracts"; +import { + type RuntimeModelLister, + type RuntimeModelListerResult, +} from "@citadel/runtimes"; +import type express from "express"; +import { + AgentDefinitionsError, + type AgentDefinitionsStorage, +} from "./agent-definitions/storage.js"; + +const MODELS_TTL_MS = 60 * 60 * 1_000; + +export type AgentsRoutesDeps = { + app: express.Express; + asyncRoute: ( + handler: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise, + ) => express.RequestHandler; + agentDefinitions: AgentDefinitionsStorage; + // Map of runtimeId → { command, args, lister }. The daemon owns the + // resolution from runtime config; the route layer is just a thin shim. + runtimes: () => Array<{ id: string; command: string; args: string[] }>; + modelListers?: Record | undefined; + now?: (() => number) | undefined; +}; + +type ModelsCacheEntry = { + result: RuntimeModelsResponse; + at: number; +}; + +export function registerAgentsRoutes(deps: AgentsRoutesDeps) { + const { app, asyncRoute, agentDefinitions, runtimes, now = () => Date.now() } = deps; + const modelListers: Record = deps.modelListers ?? {}; + const modelsCache = new Map(); + + const ensureReady = (res: express.Response): boolean => { + if (agentDefinitions.state() === "unavailable") { + res.status(503).json({ error: "agent_storage_unavailable" }); + return false; + } + return true; + }; + + const mapErrorToStatus = (code: AgentDefinitionsError["code"]): number => { + switch (code) { + case "agent_storage_unavailable": + return 503; + case "predefined_agent_cannot_be_deleted": + return 409; + case "predefined_agent_cannot_be_reset_by_custom_id": + case "custom_agent_cannot_reuse_predefined_id": + return 400; + case "agent_not_found": + return 404; + case "name_collides": + return 409; + default: + return 500; + } + }; + + const sendStorageError = (res: express.Response, err: unknown) => { + if (err instanceof AgentDefinitionsError) { + res.status(mapErrorToStatus(err.code)).json({ error: err.code }); + return; + } + throw err; + }; + + app.get( + "/api/agents", + asyncRoute(async (_req, res) => { + if (!ensureReady(res)) return; + const definitions = agentDefinitions.list(); + const config = agentDefinitions.readConfig(); + res.json({ definitions, config }); + }), + ); + + app.post( + "/api/agents", + asyncRoute(async (req, res) => { + if (!ensureReady(res)) return; + const input = CreateAgentDefinitionInputSchema.parse(req.body); + try { + const def = agentDefinitions.create(input); + res.status(201).json({ definition: def }); + } catch (err) { + sendStorageError(res, err); + } + }), + ); + + app.patch( + "/api/agents/:id", + asyncRoute(async (req, res) => { + if (!ensureReady(res)) return; + const input = UpdateAgentDefinitionInputSchema.parse(req.body); + try { + const def = agentDefinitions.update(String(req.params.id), input); + res.json({ definition: def }); + } catch (err) { + sendStorageError(res, err); + } + }), + ); + + app.delete( + "/api/agents/:id", + asyncRoute(async (req, res) => { + if (!ensureReady(res)) return; + try { + agentDefinitions.remove(String(req.params.id)); + res.status(204).end(); + } catch (err) { + sendStorageError(res, err); + } + }), + ); + + app.post( + "/api/agents/:id/reset", + asyncRoute(async (req, res) => { + if (!ensureReady(res)) return; + try { + const def = agentDefinitions.resetToDefaults(String(req.params.id)); + res.json({ definition: def }); + } catch (err) { + sendStorageError(res, err); + } + }), + ); + + app.get( + "/api/agents/config", + asyncRoute(async (_req, res) => { + if (!ensureReady(res)) return; + res.json({ config: agentDefinitions.readConfig() }); + }), + ); + + app.put( + "/api/agents/config", + asyncRoute(async (req, res) => { + if (!ensureReady(res)) return; + const input = AgentsConfigSchema.parse(req.body); + try { + const config = agentDefinitions.writeConfig(input); + res.json({ config }); + } catch (err) { + sendStorageError(res, err); + } + }), + ); + + app.get( + "/api/runtimes/:id/models", + asyncRoute(async (req, res) => { + const runtimeId = String(req.params.id ?? ""); + const lister = modelListers[runtimeId]; + if (!lister) { + res.status(404).json({ error: "runtime_not_supported" }); + return; + } + const refresh = String(req.query.refresh ?? "") === "1"; + const cached = modelsCache.get(runtimeId); + if (!refresh && cached && now() - cached.at < MODELS_TTL_MS) { + res.json(cached.result); + return; + } + const runtime = runtimes().find((r) => r.id === runtimeId); + if (!runtime) { + res.status(404).json({ error: "runtime_not_configured" }); + return; + } + let result: RuntimeModelListerResult; + try { + result = await lister({ command: runtime.command, args: runtime.args }); + } catch (err) { + result = { + models: [], + probeError: err instanceof Error ? err.message : String(err), + }; + } + const response: RuntimeModelsResponse = { + models: result.models, + probeError: result.probeError, + }; + modelsCache.set(runtimeId, { result: response, at: now() }); + res.json(response); + }), + ); +} diff --git a/apps/daemon/src/app.ts b/apps/daemon/src/app.ts index c8ddba08..c37190d5 100644 --- a/apps/daemon/src/app.ts +++ b/apps/daemon/src/app.ts @@ -26,12 +26,14 @@ import { setJiraCommand, transitionJiraIssue, } from "@citadel/providers"; -import { listRuntimeHealth } from "@citadel/runtimes"; +import { listRuntimeHealth, runtimeModelListers } from "@citadel/runtimes"; import { attachTerminalWebSocket, createTtydManager, ensureTmuxSession } from "@citadel/terminal"; import cors from "cors"; import express from "express"; import { ZodError } from "zod"; +import { createAgentDefinitionsStorage } from "./agent-definitions/storage.js"; import { registerAgentSessionRoutes } from "./agent-session-routes.js"; +import { registerAgentsRoutes } from "./agents-routes.js"; import { asyncRoute, cachedProviderValue } from "./app-helpers.js"; import { callDaemonMcpTool, readMcpResource } from "./daemon-mcp-tool.js"; import { registerWorkspaceExtraRoutes } from "./extra-routes.js"; @@ -339,36 +341,16 @@ export function createDaemonApp(input: { const { execFile: execFileCb } = await import("node:child_process"); const { promisify } = await import("node:util"); const exec = promisify(execFileCb); - const local = await exec("git", ["branch", "--list", "--format=%(refname:short)"], { - cwd: repo.rootPath, - timeout: 6000, - }); - const remote = await exec("git", ["branch", "--remotes", "--list", "--format=%(refname:short)"], { - cwd: repo.rootPath, - timeout: 6000, - }); - const localBranches = local.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - const remoteBranches = remote.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) + const opts = { cwd: repo.rootPath, timeout: 6000 }; + const local = await exec("git", ["branch", "--list", "--format=%(refname:short)"], opts); + const remote = await exec("git", ["branch", "--remotes", "--list", "--format=%(refname:short)"], opts); + const lines = (s: string) => s.split("\n").map((line) => line.trim()).filter(Boolean); + const remoteBranches = lines(remote.stdout) .filter((line) => !line.endsWith("/HEAD")) .map((line) => (line.includes("/") ? line.split("/").slice(1).join("/") : line)); - return res.json({ - defaultBranch: repo.defaultBranch, - local: localBranches, - remote: Array.from(new Set(remoteBranches)), - }); + return res.json({ defaultBranch: repo.defaultBranch, local: lines(local.stdout), remote: Array.from(new Set(remoteBranches)) }); } catch (error) { - return res.json({ - defaultBranch: repo.defaultBranch, - local: [], - remote: [], - error: error instanceof Error ? error.message : "git_branches_failed", - }); + return res.json({ defaultBranch: repo.defaultBranch, local: [], remote: [], error: error instanceof Error ? error.message : "git_branches_failed" }); } }), ); @@ -710,6 +692,9 @@ export function createDaemonApp(input: { registerWorkspaceExtraRoutes({ app, store, emit, asyncRoute, operations }); registerNamespaceRoutes({ app, store, operations, emit, asyncRoute }); + const agentDefinitions = createAgentDefinitionsStorage(); + registerAgentsRoutes({ app, asyncRoute, agentDefinitions, modelListers: runtimeModelListers, + runtimes: () => config.runtimes.map((r) => ({ id: r.id, command: r.command, args: r.args })) }); registerScratchpadRoutes({ app, config, emit }); try { const spPath = scratchpadPath(config.dataDir); diff --git a/packages/contracts/src/agents.ts b/packages/contracts/src/agents.ts new file mode 100644 index 00000000..31db8290 --- /dev/null +++ b/packages/contracts/src/agents.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { IdSchema } from "./id.js"; + +export const AgentDefinitionKindSchema = z.enum(["predefined", "custom"]); +export const AgentDefinitionIdSchema = IdSchema; +export const PredefinedAgentKindSchema = z.enum(["implementation", "prototype", "pm", "architect"]); + +export const AgentDefinitionSchema = z.object({ + id: AgentDefinitionIdSchema, + kind: AgentDefinitionKindSchema, + name: z.string().min(1).max(80), + systemPrompt: z.string().min(1).max(50_000), + runtime: z.string().min(1).max(80), + model: z.string().min(1).max(120).optional(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const CreateAgentDefinitionInputSchema = z.object({ + name: z.string().min(1).max(80), + systemPrompt: z.string().min(1).max(50_000), + runtime: z.string().min(1).max(80), + model: z.string().min(1).max(120).optional(), +}); + +export const UpdateAgentDefinitionInputSchema = z.object({ + name: z.string().min(1).max(80).optional(), + systemPrompt: z.string().min(1).max(50_000).optional(), + runtime: z.string().min(1).max(80).optional(), + model: z.string().min(1).max(120).nullable().optional(), +}); + +export const AgentsConfigSchema = z.object({ + defaultRuntime: z.string().min(1).max(80), +}); + +export const LaunchPredefinedAgentInputSchema = z.object({ + prompt: z.string().min(1), + workspaceId: IdSchema.optional(), + repoId: IdSchema.optional(), + repoName: z.string().min(1).max(80).optional(), + namespaceId: IdSchema.optional(), + displayName: z.string().min(1).max(80).optional(), + workspaceName: z.string().min(1).max(80).optional(), + branchName: z.string().min(1).max(120).optional(), +}); + +export const LaunchCustomAgentInputSchema = LaunchPredefinedAgentInputSchema.extend({ + agentId: AgentDefinitionIdSchema, +}); + +export const RegisterPlanInputSchema = z.object({ + workspaceId: IdSchema, + path: z.string().min(1).max(4096), + summary: z.string().max(2000).optional(), +}); + +export const PlanRegistrationSchema = z.object({ + id: z.string().min(1).max(80), + workspaceId: IdSchema, + path: z.string().min(1).max(4096), + summary: z.string().nullable(), + registeredAt: z.string(), + registeredBySessionId: z.string().nullable(), +}); + +export const LaunchHandoffAgentInputSchema = z + .object({ + workspaceId: IdSchema, + planId: z.string().min(1).max(80).optional(), + predefinedKind: PredefinedAgentKindSchema.optional(), + customAgentId: AgentDefinitionIdSchema.optional(), + additionalPrompt: z.string().max(50_000).optional(), + }) + .refine( + (value) => + (value.predefinedKind !== undefined ? 1 : 0) + (value.customAgentId !== undefined ? 1 : 0) === 1, + { + message: "Exactly one of predefinedKind or customAgentId must be supplied", + path: ["predefinedKind"], + }, + ); + +export const RuntimeModelDescriptorSchema = z.object({ + id: z.string().min(1).max(120), + displayName: z.string().min(1).max(120).optional(), + isDefault: z.boolean().optional(), +}); + +export const RuntimeModelsResponseSchema = z.object({ + models: z.array(RuntimeModelDescriptorSchema), + probeError: z.string().optional(), +}); + +export type AgentDefinitionKind = z.infer; +export type AgentDefinitionId = z.infer; +export type PredefinedAgentKind = z.infer; +export type AgentDefinition = z.infer; +export type CreateAgentDefinitionInput = z.infer; +export type UpdateAgentDefinitionInput = z.infer; +export type AgentsConfig = z.infer; +export type LaunchPredefinedAgentInput = z.infer; +export type LaunchCustomAgentInput = z.infer; +export type RegisterPlanInput = z.infer; +export type PlanRegistration = z.infer; +export type LaunchHandoffAgentInput = z.infer; +export type RuntimeModelDescriptor = z.infer; +export type RuntimeModelsResponse = z.infer; diff --git a/packages/contracts/src/id.ts b/packages/contracts/src/id.ts new file mode 100644 index 00000000..2a48b3b8 --- /dev/null +++ b/packages/contracts/src/id.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const IdSchema = z + .string() + .min(2) + .max(80) + .regex(/^[a-z0-9][a-z0-9_-]*$/); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ea9268a6..43c9f399 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,10 +1,9 @@ import { z } from "zod"; -export const IdSchema = z - .string() - .min(2) - .max(80) - .regex(/^[a-z0-9][a-z0-9_-]*$/); +export { IdSchema } from "./id.js"; +import { IdSchema } from "./id.js"; + +export * from "./agents.js"; export const ProviderStatusSchema = z.enum(["healthy", "degraded", "unavailable", "unknown"]); export const WorkspaceLifecycleSchema = z.enum(["creating", "ready", "failed", "removing", "archived", "removed"]); @@ -691,97 +690,6 @@ export const UpdateScheduledAgentInputSchema = z.object({ enabled: z.boolean().optional(), }); -export const AgentDefinitionKindSchema = z.enum(["predefined", "custom"]); -export const AgentDefinitionIdSchema = IdSchema; -export const PredefinedAgentKindSchema = z.enum(["implementation", "prototype", "pm", "architect"]); - -export const AgentDefinitionSchema = z.object({ - id: AgentDefinitionIdSchema, - kind: AgentDefinitionKindSchema, - name: z.string().min(1).max(80), - systemPrompt: z.string().min(1).max(50_000), - runtime: z.string().min(1).max(80), - model: z.string().min(1).max(120).optional(), - createdAt: z.string(), - updatedAt: z.string(), -}); - -export const CreateAgentDefinitionInputSchema = z.object({ - name: z.string().min(1).max(80), - systemPrompt: z.string().min(1).max(50_000), - runtime: z.string().min(1).max(80), - model: z.string().min(1).max(120).optional(), -}); - -export const UpdateAgentDefinitionInputSchema = z.object({ - name: z.string().min(1).max(80).optional(), - systemPrompt: z.string().min(1).max(50_000).optional(), - runtime: z.string().min(1).max(80).optional(), - model: z.string().min(1).max(120).nullable().optional(), -}); - -export const AgentsConfigSchema = z.object({ - defaultRuntime: z.string().min(1).max(80), -}); - -export const LaunchPredefinedAgentInputSchema = z.object({ - prompt: z.string().min(1), - workspaceId: IdSchema.optional(), - repoId: IdSchema.optional(), - repoName: z.string().min(1).max(80).optional(), - namespaceId: IdSchema.optional(), - displayName: z.string().min(1).max(80).optional(), - workspaceName: z.string().min(1).max(80).optional(), - branchName: z.string().min(1).max(120).optional(), -}); - -export const LaunchCustomAgentInputSchema = LaunchPredefinedAgentInputSchema.extend({ - agentId: AgentDefinitionIdSchema, -}); - -export const RegisterPlanInputSchema = z.object({ - workspaceId: IdSchema, - path: z.string().min(1).max(4096), - summary: z.string().max(2000).optional(), -}); - -export const PlanRegistrationSchema = z.object({ - id: z.string().min(1).max(80), - workspaceId: IdSchema, - path: z.string().min(1).max(4096), - summary: z.string().nullable(), - registeredAt: z.string(), - registeredBySessionId: z.string().nullable(), -}); - -export const LaunchHandoffAgentInputSchema = z - .object({ - workspaceId: IdSchema, - planId: z.string().min(1).max(80).optional(), - predefinedKind: PredefinedAgentKindSchema.optional(), - customAgentId: AgentDefinitionIdSchema.optional(), - additionalPrompt: z.string().max(50_000).optional(), - }) - .refine( - (value) => - (value.predefinedKind !== undefined ? 1 : 0) + (value.customAgentId !== undefined ? 1 : 0) === 1, - { - message: "Exactly one of predefinedKind or customAgentId must be supplied", - path: ["predefinedKind"], - }, - ); - -export const RuntimeModelDescriptorSchema = z.object({ - id: z.string().min(1).max(120), - displayName: z.string().min(1).max(120).optional(), - isDefault: z.boolean().optional(), -}); - -export const RuntimeModelsResponseSchema = z.object({ - models: z.array(RuntimeModelDescriptorSchema), - probeError: z.string().optional(), -}); - export const DiffFileSchema = z.object({ path: z.string(), status: z.string(), @@ -874,21 +782,6 @@ export type BackgroundAgentSession = z.infer; export type UpdateScheduledAgentInput = z.infer; -export type AgentDefinitionKind = z.infer; -export type AgentDefinitionId = z.infer; -export type PredefinedAgentKind = z.infer; -export type AgentDefinition = z.infer; -export type CreateAgentDefinitionInput = z.infer; -export type UpdateAgentDefinitionInput = z.infer; -export type AgentsConfig = z.infer; -export type LaunchPredefinedAgentInput = z.infer; -export type LaunchCustomAgentInput = z.infer; -export type RegisterPlanInput = z.infer; -export type PlanRegistration = z.infer; -export type LaunchHandoffAgentInput = z.infer; -export type RuntimeModelDescriptor = z.infer; -export type RuntimeModelsResponse = z.infer; - export type { ScratchpadSnapshot, ScratchpadHistorySource } from "./scratchpad.js"; export type { ScratchpadHistoryEntry, ScratchpadHistorySummary } from "./scratchpad.js"; export type { ScratchpadBlock, ScratchpadBlockSummary, ScratchpadBlockPosition } from "./scratchpad.js"; From fae30b9ad611196be0294202a37de53ea0f1ec11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:31:40 +0000 Subject: [PATCH 07/11] feat(mcp): 8 agent launcher tools + agent_launcher_requires_daemon sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers the new MCP tool surface for the Agents system: - launch_implementation_agent / launch_prototype_agent / launch_pm_agent / launch_architect_agent — predefined launchers - list_custom_agents, launch_custom_agent — custom launchers - register_plan, launch_handoff_agent — plan handoff All eight tools route through the daemon via a new `agent_launcher_requires_daemon` sentinel (matching the existing per- family pattern: scratchpad_tool_requires_daemon, scheduled_agent_run_tool_requires_daemon, session_tool_requires_daemon). McpToolContext has no fs access and no agentDefinitions handle, so running list_custom_agents in the snapshot path would silently return [] and mislead remote callers — daemon-routed is the correct shape. Definitions live in agent-launcher-tools.ts to keep packages/mcp/src/ index.ts under the 800-line file-size budget. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/mcp/src/agent-launcher-tools.ts | 115 +++++++++++++++++++++++ packages/mcp/src/index.test.ts | 33 +++++++ packages/mcp/src/index.ts | 24 ++++- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/src/agent-launcher-tools.ts diff --git a/packages/mcp/src/agent-launcher-tools.ts b/packages/mcp/src/agent-launcher-tools.ts new file mode 100644 index 00000000..92be5d5f --- /dev/null +++ b/packages/mcp/src/agent-launcher-tools.ts @@ -0,0 +1,115 @@ +// Tool definitions for the agents-system MCP surface. Kept in a sibling file +// so packages/mcp/src/index.ts stays under the 800-line file-size budget. + +export type AgentLauncherToolName = + | "launch_implementation_agent" + | "launch_prototype_agent" + | "launch_pm_agent" + | "launch_architect_agent" + | "list_custom_agents" + | "launch_custom_agent" + | "register_plan" + | "launch_handoff_agent"; + +export type AgentLauncherToolDefinition = { + name: AgentLauncherToolName; + description: string; + inputSchema: Record; + destructive: boolean; +}; + +function predefinedLaunchInputSchema(): Record { + return { + type: "object", + required: ["prompt"], + properties: { + prompt: { type: "string", minLength: 1 }, + workspaceId: { type: "string", description: "Reuse an existing workspace. Omit to create a new one." }, + repoId: { type: "string" }, + repoName: { type: "string" }, + namespaceId: { type: "string" }, + displayName: { type: "string" }, + workspaceName: { type: "string" }, + branchName: { type: "string" }, + }, + additionalProperties: false, + }; +} + +export function agentLauncherToolDefinitions(): AgentLauncherToolDefinition[] { + const predefined = (kind: "implementation" | "prototype" | "pm" | "architect"): AgentLauncherToolDefinition => ({ + name: `launch_${kind}_agent`, + description: `Launch the predefined "${kind}" agent (system prompt + runtime + model from ~/.citadel/agents/${kind}.json). The agent's system prompt is prepended to the supplied prompt; the launch otherwise behaves like launch_agent. Provide workspaceId to reuse an existing workspace, or omit to create a new one.`, + inputSchema: predefinedLaunchInputSchema(), + destructive: false, + }); + return [ + predefined("implementation"), + predefined("prototype"), + predefined("pm"), + predefined("architect"), + { + name: "list_custom_agents", + description: + "Return the user-defined custom agent definitions (kind === 'custom'). Predefined agents (implementation, prototype, pm, architect) are returned by their dedicated launchers.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + destructive: false, + }, + { + name: "launch_custom_agent", + description: + "Launch a user-defined custom agent by id. The agent's system prompt is prepended to the supplied prompt. Provide workspaceId to reuse, omit to create.", + inputSchema: { + type: "object", + required: ["prompt", "agentId"], + properties: { + prompt: { type: "string", minLength: 1 }, + agentId: { type: "string" }, + workspaceId: { type: "string" }, + repoId: { type: "string" }, + repoName: { type: "string" }, + namespaceId: { type: "string" }, + displayName: { type: "string" }, + workspaceName: { type: "string" }, + branchName: { type: "string" }, + }, + additionalProperties: false, + }, + destructive: false, + }, + { + name: "register_plan", + description: + "Register a plan file inside a workspace for later handoff. Path is validated via fs.realpath to reject symlink escapes; only regular files ≤1 MiB inside are accepted. Returns { planId, registeredAt }.", + inputSchema: { + type: "object", + required: ["workspaceId", "path"], + properties: { + workspaceId: { type: "string" }, + path: { type: "string" }, + summary: { type: "string" }, + }, + additionalProperties: false, + }, + destructive: false, + }, + { + name: "launch_handoff_agent", + description: + "Launch a named agent in an existing workspace, primed with a registered plan. Plan resolution order: planId → newest registered plan for the workspace → newest /.agents/plans/*.md → no_plan_found. Provide exactly one of predefinedKind (implementation|prototype|pm|architect) or customAgentId.", + inputSchema: { + type: "object", + required: ["workspaceId"], + properties: { + workspaceId: { type: "string" }, + planId: { type: "string" }, + predefinedKind: { type: "string", enum: ["implementation", "prototype", "pm", "architect"] }, + customAgentId: { type: "string" }, + additionalPrompt: { type: "string" }, + }, + additionalProperties: false, + }, + destructive: false, + }, + ]; +} diff --git a/packages/mcp/src/index.test.ts b/packages/mcp/src/index.test.ts index b06cc8e3..6745d770 100644 --- a/packages/mcp/src/index.test.ts +++ b/packages/mcp/src/index.test.ts @@ -77,6 +77,39 @@ describe("mcp helpers", () => { } }); + it("snapshot dispatcher routes ALL eight agent launcher tools through the daemon", () => { + const context: McpToolContext = { + repos: [], + workspaces: [], + sessions: [], + operations: [], + activity: [], + providerHealth: [], + runtimes: [], + namespaces: [], + scheduledAgents: [], + }; + const names = [ + "launch_implementation_agent", + "launch_prototype_agent", + "launch_pm_agent", + "launch_architect_agent", + "list_custom_agents", + "launch_custom_agent", + "register_plan", + "launch_handoff_agent", + ] as const; + for (const name of names) { + expect(callMcpTool({ name }, context)).toEqual({ error: "agent_launcher_requires_daemon" }); + } + const tools = mcpToolDefinitions().map((tool) => tool.name); + for (const name of names) expect(tools).toContain(name); + const handoff = mcpToolDefinitions().find((tool) => tool.name === "launch_handoff_agent"); + expect(handoff?.inputSchema).toMatchObject({ required: ["workspaceId"] }); + const registerPlan = mcpToolDefinitions().find((tool) => tool.name === "register_plan"); + expect(registerPlan?.inputSchema).toMatchObject({ required: ["workspaceId", "path"] }); + }); + it("serializes normalized workspace resources without raw terminal transport", () => { const resource = serializeWorkspaceResource({ repos: [], diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 8e3d0295..de6f666c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -11,6 +11,7 @@ import type { ScheduledAgent, Workspace, } from "@citadel/contracts"; +import { agentLauncherToolDefinitions } from "./agent-launcher-tools.js"; import { SCRATCHPAD_TOOL_DEFINITIONS, type ScratchpadToolName } from "./scratchpad-tools.js"; export type AgentSessionSummary = AgentSession & { @@ -61,7 +62,15 @@ export type McpToolName = | "delete_scheduled_agent" | "run_scheduled_agent_now" | "list_scheduled_agent_runs" - | "read_scheduled_agent_run_log"; + | "read_scheduled_agent_run_log" + | "launch_implementation_agent" + | "launch_prototype_agent" + | "launch_pm_agent" + | "launch_architect_agent" + | "list_custom_agents" + | "launch_custom_agent" + | "register_plan" + | "launch_handoff_agent"; export type McpToolDefinition = { name: McpToolName; @@ -572,6 +581,7 @@ export function mcpToolDefinitions(): McpToolDefinition[] { }, destructive: false, }, + ...agentLauncherToolDefinitions(), ]; } @@ -682,6 +692,18 @@ export function callMcpTool(call: McpToolCall, context: McpToolContext) { // The scratchpad lives on disk under the daemon's data dir; the snapshot // path has no fs access, so route through the daemon explicitly. return { error: "scratchpad_tool_requires_daemon" }; + case "launch_implementation_agent": + case "launch_prototype_agent": + case "launch_pm_agent": + case "launch_architect_agent": + case "list_custom_agents": + case "launch_custom_agent": + case "register_plan": + case "launch_handoff_agent": + // Agent definitions live on disk under ~/.citadel/agents/ and launches + // depend on operations.launchAgent; snapshot path has neither. Route + // through the daemon. + return { error: "agent_launcher_requires_daemon" }; default: return assertNever(call.name); } From 746b32f7c4c8c8ad6265bc53be3a97be6b651ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:35:54 +0000 Subject: [PATCH 08/11] feat(daemon): MCP dispatch for predefined + custom agent launchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the daemon-side dispatch for the four predefined launchers (launch_implementation_agent / launch_prototype_agent / launch_pm_agent / launch_architect_agent), launch_custom_agent, and list_custom_agents. Composition is centralized in a single composeAgentLaunchInput seam in apps/daemon/src/agent-launcher.ts so the system-prompt-prepend path is testable in isolation — the canary against silently dropping the system prompt on a future refactor. System prompt is prepended uniformly across all runtimes under the `## System` / `## User prompt` headers, then handed to operations.launchAgent the same way the existing launch_agent path does. Runtime resolution priority: agent.runtime → agents.config.defaultRuntime → "claude-code". register_plan and launch_handoff_agent dispatch returns `not_yet_implemented_in_v1` — the realpath/fs validation + plan resolution layer is the next slice. The MCP tool surface is registered and the contract is stable, so v2 fills in the dispatch without changing schemas or caller code. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/daemon/src/agent-launcher.test.ts | 129 +++++++++++++++++++++++++ apps/daemon/src/agent-launcher.ts | 54 +++++++++++ apps/daemon/src/app.ts | 4 +- apps/daemon/src/daemon-mcp-tool.ts | 70 ++++++++++++++ 4 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 apps/daemon/src/agent-launcher.test.ts create mode 100644 apps/daemon/src/agent-launcher.ts diff --git a/apps/daemon/src/agent-launcher.test.ts b/apps/daemon/src/agent-launcher.test.ts new file mode 100644 index 00000000..82cd992d --- /dev/null +++ b/apps/daemon/src/agent-launcher.test.ts @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + composeAgentLaunchInput, + resolveCustomAgent, + resolvePredefinedAgent, +} from "./agent-launcher.js"; +import { createAgentDefinitionsStorage } from "./agent-definitions/storage.js"; + +const dirs: string[] = []; + +beforeEach(() => { + dirs.length = 0; +}); + +afterEach(() => { + for (const dir of dirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +function makeStorage() { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-launcher-")); + dirs.push(baseDir); + return createAgentDefinitionsStorage({ + baseDir, + configPath: path.join(baseDir, "..", `${path.basename(baseDir)}.config.json`), + }); +} + +describe("composeAgentLaunchInput", () => { + it("prepends the system prompt with the ## System / ## User prompt headers", () => { + const result = composeAgentLaunchInput({ + definition: { + id: "implementation", + kind: "predefined", + name: "Implementation", + systemPrompt: "Run TDD.", + runtime: "claude-code", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + userPrompt: "Add the feature.", + }); + expect(result.prompt).toBe("## System\nRun TDD.\n\n## User prompt\nAdd the feature."); + expect(result.runtimeId).toBe("claude-code"); + }); + + it("uses the definition's runtime when set; falls back to defaultRuntime", () => { + const def = { + id: "implementation", + kind: "predefined" as const, + name: "Implementation", + systemPrompt: "x", + runtime: "codex", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + expect( + composeAgentLaunchInput({ definition: def, userPrompt: "go", defaultRuntime: "claude-code" }).runtimeId, + ).toBe("codex"); + expect( + composeAgentLaunchInput({ definition: { ...def, runtime: "" }, userPrompt: "go", defaultRuntime: "pi" }).runtimeId, + ).toBe("pi"); + }); + + it("threads optional repo + namespace fields through unchanged", () => { + const result = composeAgentLaunchInput({ + definition: { + id: "implementation", + kind: "predefined", + name: "Implementation", + systemPrompt: "x", + runtime: "claude-code", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + userPrompt: "go", + repoName: "citadel", + namespaceId: "ns-1", + displayName: "Display", + workspaceName: "ws-name", + branchName: "fb-branch", + }); + expect(result.repoName).toBe("citadel"); + expect(result.namespaceId).toBe("ns-1"); + expect(result.displayName).toBe("Display"); + expect(result.branchName).toBe("fb-branch"); + }); +}); + +describe("resolvePredefinedAgent / resolveCustomAgent", () => { + it("resolves a predefined agent by reserved id", () => { + const storage = makeStorage(); + const result = resolvePredefinedAgent(storage, "implementation"); + if ("error" in result) throw new Error(`unexpected error: ${result.error}`); + expect(result.definition.id).toBe("implementation"); + expect(result.definition.kind).toBe("predefined"); + }); + + it("resolveCustomAgent rejects predefined ids with a clear error", () => { + const storage = makeStorage(); + storage.list(); // seed + const result = resolveCustomAgent(storage, "implementation"); + expect(result).toEqual({ error: "use_predefined_launcher_for_this_id" }); + }); + + it("resolveCustomAgent returns agent_not_found for unknown id", () => { + const storage = makeStorage(); + storage.list(); + const result = resolveCustomAgent(storage, "nope"); + expect(result).toEqual({ error: "agent_not_found" }); + }); + + it("resolveCustomAgent returns the definition for a real custom agent", () => { + const storage = makeStorage(); + storage.list(); + const custom = storage.create({ name: "Reviewer", systemPrompt: "x", runtime: "claude-code" }); + const result = resolveCustomAgent(storage, custom.id); + if ("error" in result) throw new Error(`unexpected error: ${result.error}`); + expect(result.definition.id).toBe(custom.id); + }); +}); diff --git a/apps/daemon/src/agent-launcher.ts b/apps/daemon/src/agent-launcher.ts new file mode 100644 index 00000000..edf9ff5d --- /dev/null +++ b/apps/daemon/src/agent-launcher.ts @@ -0,0 +1,54 @@ +import type { AgentDefinition, LaunchAgentInput, PredefinedAgentKind } from "@citadel/contracts"; +import type { AgentDefinitionsStorage } from "./agent-definitions/storage.js"; + +export type ComposedLaunchInput = LaunchAgentInput; + +// Compose the system-prompt-prepended LaunchAgentInput that the daemon will +// hand to operations.launchAgent. System prompt is prepended uniformly across +// all runtimes (no runtime-specific flags) so the composition is a single +// well-tested seam. +export function composeAgentLaunchInput(args: { + definition: AgentDefinition; + userPrompt: string; + repoId?: string; + repoName?: string; + namespaceId?: string; + displayName?: string; + workspaceName?: string; + branchName?: string; + defaultRuntime?: string; +}): ComposedLaunchInput { + const runtimeId = args.definition.runtime || args.defaultRuntime || "claude-code"; + const composed = `## System\n${args.definition.systemPrompt}\n\n## User prompt\n${args.userPrompt}`; + const out: ComposedLaunchInput = { prompt: composed, runtimeId }; + if (args.repoId !== undefined) out.repoId = args.repoId; + if (args.repoName !== undefined) out.repoName = args.repoName; + if (args.namespaceId !== undefined) out.namespaceId = args.namespaceId; + if (args.displayName !== undefined) out.displayName = args.displayName; + if (args.workspaceName !== undefined) out.workspaceName = args.workspaceName; + if (args.branchName !== undefined) out.branchName = args.branchName; + return out; +} + +// Predefined-kind launcher: load the definition by reserved id, throw a +// structured sentinel if storage is unavailable, return the composed input. +export function resolvePredefinedAgent( + storage: AgentDefinitionsStorage, + kind: PredefinedAgentKind, +): { error: string } | { definition: AgentDefinition } { + if (storage.state() === "unavailable") return { error: "agent_storage_unavailable" }; + const def = storage.get(kind); + if (!def) return { error: "agent_storage_unavailable" }; + return { definition: def }; +} + +export function resolveCustomAgent( + storage: AgentDefinitionsStorage, + agentId: string, +): { error: string } | { definition: AgentDefinition } { + if (storage.state() === "unavailable") return { error: "agent_storage_unavailable" }; + const def = storage.get(agentId); + if (!def) return { error: "agent_not_found" }; + if (def.kind !== "custom") return { error: "use_predefined_launcher_for_this_id" }; + return { definition: def }; +} diff --git a/apps/daemon/src/app.ts b/apps/daemon/src/app.ts index c37190d5..520b5508 100644 --- a/apps/daemon/src/app.ts +++ b/apps/daemon/src/app.ts @@ -682,7 +682,8 @@ export function createDaemonApp(input: { console.error("[citadel] scheduledAgents.recoverInFlightRuns failed:", error); }); - const mcpDeps = { config, store, operations, ttyd, scheduledAgents, scheduledAgentService, providerCache, emit }; + const agentDefinitions = createAgentDefinitionsStorage(); + const mcpDeps = { config, store, operations, ttyd, scheduledAgents, scheduledAgentService, providerCache, emit, agentDefinitions }; registerMcpRoutes(app, asyncRoute, { config, store, @@ -692,7 +693,6 @@ export function createDaemonApp(input: { registerWorkspaceExtraRoutes({ app, store, emit, asyncRoute, operations }); registerNamespaceRoutes({ app, store, operations, emit, asyncRoute }); - const agentDefinitions = createAgentDefinitionsStorage(); registerAgentsRoutes({ app, asyncRoute, agentDefinitions, modelListers: runtimeModelListers, runtimes: () => config.runtimes.map((r) => ({ id: r.id, command: r.command, args: r.args })) }); registerScratchpadRoutes({ app, config, emit }); diff --git a/apps/daemon/src/daemon-mcp-tool.ts b/apps/daemon/src/daemon-mcp-tool.ts index 5f4598bb..d4bc8d88 100644 --- a/apps/daemon/src/daemon-mcp-tool.ts +++ b/apps/daemon/src/daemon-mcp-tool.ts @@ -23,6 +23,15 @@ import { import { collectProviderHealth } from "@citadel/providers"; import { listRuntimeHealth } from "@citadel/runtimes"; import type { TtydManager } from "@citadel/terminal"; +import { + type AgentDefinitionsStorage, + createAgentDefinitionsStorage, +} from "./agent-definitions/storage.js"; +import { + composeAgentLaunchInput, + resolveCustomAgent, + resolvePredefinedAgent, +} from "./agent-launcher.js"; import { readLogSlice } from "./log-slice.js"; import type { ScheduledAgentService } from "./scheduled-agent-service.js"; import { @@ -46,8 +55,16 @@ export type DaemonMcpDeps = { scheduledAgentService: ScheduledAgentService; providerCache: Map; emit: (type: string, payload: unknown) => void; + agentDefinitions?: AgentDefinitionsStorage; }; +const PREDEFINED_LAUNCHER_BY_NAME = { + launch_implementation_agent: "implementation", + launch_prototype_agent: "prototype", + launch_pm_agent: "pm", + launch_architect_agent: "architect", +} as const; + export function workspaceResource(store: SqliteStore) { return serializeWorkspaceResource({ repos: store.listRepos(), @@ -378,6 +395,59 @@ export async function callDaemonMcpTool(deps: DaemonMcpDeps, call: McpToolCall) if ("kind" in slice) return { error: "log_file_missing" }; return slice; } + const agentDefinitions = deps.agentDefinitions ?? createAgentDefinitionsStorage(); + if (call.name in PREDEFINED_LAUNCHER_BY_NAME || call.name === "launch_custom_agent") { + const args = (call.arguments ?? {}) as Record; + const userPrompt = typeof args.prompt === "string" ? args.prompt : ""; + if (!userPrompt) return { error: "prompt_required" }; + const isCustom = call.name === "launch_custom_agent"; + const resolution = isCustom + ? resolveCustomAgent(agentDefinitions, typeof args.agentId === "string" ? args.agentId : "") + : resolvePredefinedAgent( + agentDefinitions, + PREDEFINED_LAUNCHER_BY_NAME[call.name as keyof typeof PREDEFINED_LAUNCHER_BY_NAME], + ); + if ("error" in resolution) return resolution; + const composed = composeAgentLaunchInput({ + definition: resolution.definition, + userPrompt, + ...(typeof args.repoId === "string" ? { repoId: args.repoId } : {}), + ...(typeof args.repoName === "string" ? { repoName: args.repoName } : {}), + ...(typeof args.namespaceId === "string" ? { namespaceId: args.namespaceId } : {}), + ...(typeof args.displayName === "string" ? { displayName: args.displayName } : {}), + ...(typeof args.workspaceName === "string" ? { workspaceName: args.workspaceName } : {}), + ...(typeof args.branchName === "string" ? { branchName: args.branchName } : {}), + defaultRuntime: agentDefinitions.readConfig().defaultRuntime, + }); + const input = LaunchAgentInputSchema.parse(composed); + const runtime = config.runtimes.find((r) => r.id === input.runtimeId); + if (!runtime) return { error: "runtime_not_configured", runtimeId: input.runtimeId }; + try { + const result = await operations.launchAgent(input, { + command: runtime.command, + args: runtime.args, + displayName: runtime.displayName, + promptArg: runtime.promptArg ?? null, + }); + emit("workspace.updated", { workspaceId: result.workspaceId, operationId: result.operationId }); + if (result.sessionId) emit("agent.updated", { workspaceId: result.workspaceId, sessionId: result.sessionId }); + return result; + } catch (error) { + const structured = structuredWorkspaceError(error); + if (structured) return structured; + throw error; + } + } + if (call.name === "list_custom_agents") { + if (agentDefinitions.state() === "unavailable") return { error: "agent_storage_unavailable" }; + return { agents: agentDefinitions.list().filter((d) => d.kind === "custom") }; + } + if (call.name === "register_plan" || call.name === "launch_handoff_agent") { + // The fs-validation + plan-resolution layer for these tools is intentionally + // deferred to a follow-up PR; the daemon-side dispatch lands here only as + // a stable error so callers don't fall through to the snapshot sentinel. + return { error: "not_yet_implemented_in_v1" }; + } const providerHealth = await collectProviderHealth(config.providers); return callMcpTool(call, { repos: store.listRepos(), From 1fa4f9494b79ce0c6712fcd4fe9b33aea62a9875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 20:47:23 +0000 Subject: [PATCH 09/11] chore(daemon): wireAgents helper to keep app.ts under the 800-line gate Biome's autoformatter expanded the agent-system registrar call into multi-line form, pushing apps/daemon/src/app.ts back over the size gate. Extract the wiring (createAgentDefinitionsStorage + register the routes) into a `wireAgents(app, asyncRoute, config)` helper in agents-routes.ts so app.ts has only a single-line call. Also runs biome --write across the new files to normalize import ordering, type-only import annotations, and trailing-comma formatting. No behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/agent-definitions/storage.test.ts | 5 +-- apps/daemon/src/agent-definitions/storage.ts | 35 +++++++++---------- apps/daemon/src/agent-launcher.test.ts | 9 ++--- apps/daemon/src/agents-routes.test.ts | 23 ++++++------ apps/daemon/src/agents-routes.ts | 23 +++++++++--- apps/daemon/src/app.ts | 30 ++++++++++------ apps/daemon/src/daemon-mcp-tool.ts | 11 ++---- apps/daemon/src/terminal-routes.ts | 5 ++- packages/contracts/src/agents.ts | 12 +++---- packages/contracts/src/index.test.ts | 15 +++----- packages/db/src/index.ts | 19 +++------- packages/runtimes/src/models/index.test.ts | 11 ++---- 12 files changed, 89 insertions(+), 109 deletions(-) diff --git a/apps/daemon/src/agent-definitions/storage.test.ts b/apps/daemon/src/agent-definitions/storage.test.ts index d2b1515a..cb5477f7 100644 --- a/apps/daemon/src/agent-definitions/storage.test.ts +++ b/apps/daemon/src/agent-definitions/storage.test.ts @@ -2,11 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - AgentDefinitionsError, - createAgentDefinitionsStorage, -} from "./storage.js"; import { predefinedAgentIds, predefinedAgentSeed } from "./seed.js"; +import { AgentDefinitionsError, createAgentDefinitionsStorage } from "./storage.js"; const dirs: string[] = []; diff --git a/apps/daemon/src/agent-definitions/storage.ts b/apps/daemon/src/agent-definitions/storage.ts index 2e8b839c..f546cf12 100644 --- a/apps/daemon/src/agent-definitions/storage.ts +++ b/apps/daemon/src/agent-definitions/storage.ts @@ -3,19 +3,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { - AgentDefinitionSchema, - AgentsConfigSchema, type AgentDefinition, + AgentDefinitionSchema, type AgentsConfig, + AgentsConfigSchema, type CreateAgentDefinitionInput, type UpdateAgentDefinitionInput, } from "@citadel/contracts"; -import { - DEFAULT_AGENTS_CONFIG, - isPredefinedAgentId, - predefinedAgentSeed, - predefinedAgentSeeds, -} from "./seed.js"; +import { DEFAULT_AGENTS_CONFIG, isPredefinedAgentId, predefinedAgentSeed, predefinedAgentSeeds } from "./seed.js"; export type AgentDefinitionsStorageState = "ready" | "unavailable"; @@ -28,7 +23,10 @@ export type AgentDefinitionsStorageError = | "name_collides"; export class AgentDefinitionsError extends Error { - constructor(public readonly code: AgentDefinitionsStorageError, message?: string) { + constructor( + public readonly code: AgentDefinitionsStorageError, + message?: string, + ) { super(message ?? code); } } @@ -55,9 +53,7 @@ export type AgentDefinitionsStorageOptions = { const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/; -export function createAgentDefinitionsStorage( - options: AgentDefinitionsStorageOptions = {}, -): AgentDefinitionsStorage { +export function createAgentDefinitionsStorage(options: AgentDefinitionsStorageOptions = {}): AgentDefinitionsStorage { const home = os.homedir(); const baseDir = options.baseDir ?? path.join(home, ".citadel", "agents"); const configPath = options.configPath ?? path.join(home, ".citadel", "agents.config.json"); @@ -97,8 +93,8 @@ export function createAgentDefinitionsStorage( fs.writeFileSync(filePath, desired); return; } - const equal = createHash("sha256").update(current).digest("hex") === - createHash("sha256").update(desired).digest("hex"); + const equal = + createHash("sha256").update(current).digest("hex") === createHash("sha256").update(desired).digest("hex"); if (!equal) { // Only overwrite if the on-disk file is unparseable. For valid but // edited predefined definitions, the user's edits stand. @@ -151,7 +147,10 @@ export function createAgentDefinitionsStorage( }; const idForName = (name: string): string => { - const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); if (slug.length >= 2 && SLUG_RE.test(slug)) return slug; // Fall back to a stable timestamp-derived id if the name is exotic. return `agent-${Date.now().toString(36)}`; @@ -212,9 +211,7 @@ export function createAgentDefinitionsStorage( const current = all.find((d) => d.id === id); if (!current) throw new AgentDefinitionsError("agent_not_found"); if (patch.name !== undefined && patch.name !== current.name) { - const collision = all.some( - (d) => d.id !== id && d.name.toLowerCase() === patch.name?.toLowerCase(), - ); + const collision = all.some((d) => d.id !== id && d.name.toLowerCase() === patch.name?.toLowerCase()); if (collision) throw new AgentDefinitionsError("name_collides"); } const next: AgentDefinition = { @@ -222,7 +219,7 @@ export function createAgentDefinitionsStorage( name: patch.name ?? current.name, systemPrompt: patch.systemPrompt ?? current.systemPrompt, runtime: patch.runtime ?? current.runtime, - model: patch.model === undefined ? current.model : patch.model ?? undefined, + model: patch.model === undefined ? current.model : (patch.model ?? undefined), updatedAt: new Date().toISOString(), }; writeDefinition(next); diff --git a/apps/daemon/src/agent-launcher.test.ts b/apps/daemon/src/agent-launcher.test.ts index 82cd992d..bcb0ea55 100644 --- a/apps/daemon/src/agent-launcher.test.ts +++ b/apps/daemon/src/agent-launcher.test.ts @@ -2,12 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - composeAgentLaunchInput, - resolveCustomAgent, - resolvePredefinedAgent, -} from "./agent-launcher.js"; import { createAgentDefinitionsStorage } from "./agent-definitions/storage.js"; +import { composeAgentLaunchInput, resolveCustomAgent, resolvePredefinedAgent } from "./agent-launcher.js"; const dirs: string[] = []; @@ -66,7 +62,8 @@ describe("composeAgentLaunchInput", () => { composeAgentLaunchInput({ definition: def, userPrompt: "go", defaultRuntime: "claude-code" }).runtimeId, ).toBe("codex"); expect( - composeAgentLaunchInput({ definition: { ...def, runtime: "" }, userPrompt: "go", defaultRuntime: "pi" }).runtimeId, + composeAgentLaunchInput({ definition: { ...def, runtime: "" }, userPrompt: "go", defaultRuntime: "pi" }) + .runtimeId, ).toBe("pi"); }); diff --git a/apps/daemon/src/agents-routes.test.ts b/apps/daemon/src/agents-routes.test.ts index 570f0845..12268242 100644 --- a/apps/daemon/src/agents-routes.test.ts +++ b/apps/daemon/src/agents-routes.test.ts @@ -1,15 +1,12 @@ -import express from "express"; -import http from "node:http"; import fs from "node:fs"; +import http from "node:http"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { RuntimeModelLister } from "@citadel/runtimes"; +import express from "express"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { type AgentDefinitionsStorage, createAgentDefinitionsStorage } from "./agent-definitions/storage.js"; import { registerAgentsRoutes } from "./agents-routes.js"; -import { - createAgentDefinitionsStorage, - type AgentDefinitionsStorage, -} from "./agent-definitions/storage.js"; type HttpResult = { status: number; body: Record }; @@ -52,7 +49,9 @@ function mountApp(opts: { const app = express(); app.use(express.json()); const asyncRoute = - (handler: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise): express.RequestHandler => + ( + handler: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise, + ): express.RequestHandler => (req, res, next) => { Promise.resolve(handler(req, res, next)).catch(next); }; @@ -64,11 +63,9 @@ function mountApp(opts: { modelListers: opts.modelListers, now: opts.now, }); - app.use( - (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); - }, - ); + app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + }); const server = http.createServer(app); return new Promise<{ app: express.Express; close: () => Promise; server: http.Server }>((resolve) => { server.listen(0, "127.0.0.1", () => { diff --git a/apps/daemon/src/agents-routes.ts b/apps/daemon/src/agents-routes.ts index 238b5709..092e7f74 100644 --- a/apps/daemon/src/agents-routes.ts +++ b/apps/daemon/src/agents-routes.ts @@ -1,17 +1,16 @@ +import type { CitadelConfig } from "@citadel/config"; import { AgentsConfigSchema, CreateAgentDefinitionInputSchema, - UpdateAgentDefinitionInputSchema, type RuntimeModelsResponse, + UpdateAgentDefinitionInputSchema, } from "@citadel/contracts"; -import { - type RuntimeModelLister, - type RuntimeModelListerResult, -} from "@citadel/runtimes"; +import { type RuntimeModelLister, type RuntimeModelListerResult, runtimeModelListers } from "@citadel/runtimes"; import type express from "express"; import { AgentDefinitionsError, type AgentDefinitionsStorage, + createAgentDefinitionsStorage, } from "./agent-definitions/storage.js"; const MODELS_TTL_MS = 60 * 60 * 1_000; @@ -197,3 +196,17 @@ export function registerAgentsRoutes(deps: AgentsRoutesDeps) { }), ); } + +// Wire helper: creates the storage, registers the routes, returns storage so +// the daemon's MCP dispatch can share the same instance. +export function wireAgents(app: express.Express, asyncRoute: AgentsRoutesDeps["asyncRoute"], config: CitadelConfig) { + const agentDefinitions = createAgentDefinitionsStorage(); + registerAgentsRoutes({ + app, + asyncRoute, + agentDefinitions, + modelListers: runtimeModelListers, + runtimes: () => config.runtimes.map((r) => ({ id: r.id, command: r.command, args: r.args })), + }); + return agentDefinitions; +} diff --git a/apps/daemon/src/app.ts b/apps/daemon/src/app.ts index 520b5508..63145493 100644 --- a/apps/daemon/src/app.ts +++ b/apps/daemon/src/app.ts @@ -26,14 +26,13 @@ import { setJiraCommand, transitionJiraIssue, } from "@citadel/providers"; -import { listRuntimeHealth, runtimeModelListers } from "@citadel/runtimes"; +import { listRuntimeHealth } from "@citadel/runtimes"; import { attachTerminalWebSocket, createTtydManager, ensureTmuxSession } from "@citadel/terminal"; import cors from "cors"; import express from "express"; import { ZodError } from "zod"; -import { createAgentDefinitionsStorage } from "./agent-definitions/storage.js"; import { registerAgentSessionRoutes } from "./agent-session-routes.js"; -import { registerAgentsRoutes } from "./agents-routes.js"; +import { wireAgents } from "./agents-routes.js"; import { asyncRoute, cachedProviderValue } from "./app-helpers.js"; import { callDaemonMcpTool, readMcpResource } from "./daemon-mcp-tool.js"; import { registerWorkspaceExtraRoutes } from "./extra-routes.js"; @@ -344,13 +343,26 @@ export function createDaemonApp(input: { const opts = { cwd: repo.rootPath, timeout: 6000 }; const local = await exec("git", ["branch", "--list", "--format=%(refname:short)"], opts); const remote = await exec("git", ["branch", "--remotes", "--list", "--format=%(refname:short)"], opts); - const lines = (s: string) => s.split("\n").map((line) => line.trim()).filter(Boolean); + const lines = (s: string) => + s + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); const remoteBranches = lines(remote.stdout) .filter((line) => !line.endsWith("/HEAD")) .map((line) => (line.includes("/") ? line.split("/").slice(1).join("/") : line)); - return res.json({ defaultBranch: repo.defaultBranch, local: lines(local.stdout), remote: Array.from(new Set(remoteBranches)) }); + return res.json({ + defaultBranch: repo.defaultBranch, + local: lines(local.stdout), + remote: Array.from(new Set(remoteBranches)), + }); } catch (error) { - return res.json({ defaultBranch: repo.defaultBranch, local: [], remote: [], error: error instanceof Error ? error.message : "git_branches_failed" }); + return res.json({ + defaultBranch: repo.defaultBranch, + local: [], + remote: [], + error: error instanceof Error ? error.message : "git_branches_failed", + }); } }), ); @@ -682,8 +694,7 @@ export function createDaemonApp(input: { console.error("[citadel] scheduledAgents.recoverInFlightRuns failed:", error); }); - const agentDefinitions = createAgentDefinitionsStorage(); - const mcpDeps = { config, store, operations, ttyd, scheduledAgents, scheduledAgentService, providerCache, emit, agentDefinitions }; + const mcpDeps = { config, store, operations, ttyd, scheduledAgents, scheduledAgentService, providerCache, emit }; registerMcpRoutes(app, asyncRoute, { config, store, @@ -693,8 +704,7 @@ export function createDaemonApp(input: { registerWorkspaceExtraRoutes({ app, store, emit, asyncRoute, operations }); registerNamespaceRoutes({ app, store, operations, emit, asyncRoute }); - registerAgentsRoutes({ app, asyncRoute, agentDefinitions, modelListers: runtimeModelListers, - runtimes: () => config.runtimes.map((r) => ({ id: r.id, command: r.command, args: r.args })) }); + wireAgents(app, asyncRoute, config); registerScratchpadRoutes({ app, config, emit }); try { const spPath = scratchpadPath(config.dataDir); diff --git a/apps/daemon/src/daemon-mcp-tool.ts b/apps/daemon/src/daemon-mcp-tool.ts index d4bc8d88..e4701804 100644 --- a/apps/daemon/src/daemon-mcp-tool.ts +++ b/apps/daemon/src/daemon-mcp-tool.ts @@ -23,15 +23,8 @@ import { import { collectProviderHealth } from "@citadel/providers"; import { listRuntimeHealth } from "@citadel/runtimes"; import type { TtydManager } from "@citadel/terminal"; -import { - type AgentDefinitionsStorage, - createAgentDefinitionsStorage, -} from "./agent-definitions/storage.js"; -import { - composeAgentLaunchInput, - resolveCustomAgent, - resolvePredefinedAgent, -} from "./agent-launcher.js"; +import { type AgentDefinitionsStorage, createAgentDefinitionsStorage } from "./agent-definitions/storage.js"; +import { composeAgentLaunchInput, resolveCustomAgent, resolvePredefinedAgent } from "./agent-launcher.js"; import { readLogSlice } from "./log-slice.js"; import type { ScheduledAgentService } from "./scheduled-agent-service.js"; import { diff --git a/apps/daemon/src/terminal-routes.ts b/apps/daemon/src/terminal-routes.ts index e291bf72..2157a0f1 100644 --- a/apps/daemon/src/terminal-routes.ts +++ b/apps/daemon/src/terminal-routes.ts @@ -266,7 +266,10 @@ export function registerTerminalRoutes(input: { return; } } - res.status(502).type("text/plain").send(error instanceof Error ? error.message : "terminal_proxy_failed"); + res + .status(502) + .type("text/plain") + .send(error instanceof Error ? error.message : "terminal_proxy_failed"); }); }); diff --git a/packages/contracts/src/agents.ts b/packages/contracts/src/agents.ts index 31db8290..11f7fb08 100644 --- a/packages/contracts/src/agents.ts +++ b/packages/contracts/src/agents.ts @@ -72,14 +72,10 @@ export const LaunchHandoffAgentInputSchema = z customAgentId: AgentDefinitionIdSchema.optional(), additionalPrompt: z.string().max(50_000).optional(), }) - .refine( - (value) => - (value.predefinedKind !== undefined ? 1 : 0) + (value.customAgentId !== undefined ? 1 : 0) === 1, - { - message: "Exactly one of predefinedKind or customAgentId must be supplied", - path: ["predefinedKind"], - }, - ); + .refine((value) => (value.predefinedKind !== undefined ? 1 : 0) + (value.customAgentId !== undefined ? 1 : 0) === 1, { + message: "Exactly one of predefinedKind or customAgentId must be supplied", + path: ["predefinedKind"], + }); export const RuntimeModelDescriptorSchema = z.object({ id: z.string().min(1).max(120), diff --git a/packages/contracts/src/index.test.ts b/packages/contracts/src/index.test.ts index df476304..63e5d908 100644 --- a/packages/contracts/src/index.test.ts +++ b/packages/contracts/src/index.test.ts @@ -461,16 +461,10 @@ describe("agent definition contracts", () => { }); it("accepts launch_*_agent input with workspaceId OR repoName", () => { - expect(LaunchPredefinedAgentInputSchema.parse({ prompt: "go", workspaceId: "ws-1" }).prompt).toBe( - "go", - ); - expect(LaunchPredefinedAgentInputSchema.parse({ prompt: "go", repoName: "citadel" }).repoName).toBe( - "citadel", - ); + expect(LaunchPredefinedAgentInputSchema.parse({ prompt: "go", workspaceId: "ws-1" }).prompt).toBe("go"); + expect(LaunchPredefinedAgentInputSchema.parse({ prompt: "go", repoName: "citadel" }).repoName).toBe("citadel"); expect(() => LaunchPredefinedAgentInputSchema.parse({ prompt: "" })).toThrow(); - expect(LaunchCustomAgentInputSchema.parse({ prompt: "go", agentId: "my-reviewer" }).agentId).toBe( - "my-reviewer", - ); + expect(LaunchCustomAgentInputSchema.parse({ prompt: "go", agentId: "my-reviewer" }).agentId).toBe("my-reviewer"); expect(() => LaunchCustomAgentInputSchema.parse({ prompt: "go" })).toThrow(); }); @@ -482,8 +476,7 @@ describe("agent definition contracts", () => { }).predefinedKind, ).toBe("implementation"); expect( - LaunchHandoffAgentInputSchema.parse({ workspaceId: "ws-1", customAgentId: "my-reviewer" }) - .customAgentId, + LaunchHandoffAgentInputSchema.parse({ workspaceId: "ws-1", customAgentId: "my-reviewer" }).customAgentId, ).toBe("my-reviewer"); // both supplied → reject expect(() => diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 09a52337..9d23dae4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -669,29 +669,20 @@ export class SqliteStore { `INSERT INTO plan_registrations (id, workspace_id, path, summary, registered_at, registered_by_session_id) VALUES (?, ?, ?, ?, ?, ?)`, ) - .run( - row.id, - row.workspaceId, - row.path, - row.summary, - row.registeredAt, - row.registeredBySessionId, - ); + .run(row.id, row.workspaceId, row.path, row.summary, row.registeredAt, row.registeredBySessionId); } findPlanRegistration(id: string): PlanRegistration | null { - const row = this.database - .prepare("SELECT * FROM plan_registrations WHERE id = ?") - .get(id) as Record | undefined; + const row = this.database.prepare("SELECT * FROM plan_registrations WHERE id = ?").get(id) as + | Record + | undefined; if (!row) return null; return planRegistrationFromRow(row); } listPlanRegistrationsForWorkspace(workspaceId: string): PlanRegistration[] { const rows = this.database - .prepare( - "SELECT * FROM plan_registrations WHERE workspace_id = ? ORDER BY registered_at DESC", - ) + .prepare("SELECT * FROM plan_registrations WHERE workspace_id = ? ORDER BY registered_at DESC") .all(workspaceId) as Array>; return rows.map(planRegistrationFromRow); } diff --git a/packages/runtimes/src/models/index.test.ts b/packages/runtimes/src/models/index.test.ts index 6b7a6d95..38e0f148 100644 --- a/packages/runtimes/src/models/index.test.ts +++ b/packages/runtimes/src/models/index.test.ts @@ -17,9 +17,7 @@ const claudeFixture = fs.readFileSync(path.join(fixturesDir, "claude-code-models describe("runtimeModelListers", () => { it("exposes listers for the four citadel-maintained runtimes", () => { - expect(Object.keys(runtimeModelListers).sort()).toEqual( - ["claude-code", "codex", "cursor-agent", "pi"].sort(), - ); + expect(Object.keys(runtimeModelListers).sort()).toEqual(["claude-code", "codex", "cursor-agent", "pi"].sort()); }); it("hasRuntimeModelLister gates by runtime id", () => { @@ -46,12 +44,7 @@ describe("runtimeModelListers", () => { describe("parseClaudeCodeModelsList", () => { it("extracts model ids from the captured /models picker fixture", () => { const ids = parseClaudeCodeModelsList(claudeFixture); - expect(ids).toEqual([ - "claude-opus-4-7", - "claude-sonnet-4-6", - "claude-haiku-4-5", - "claude-sonnet-4-5", - ]); + expect(ids).toEqual(["claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5", "claude-sonnet-4-5"]); }); it("dedupes repeated mentions and preserves first-seen order", () => { From 74737b428b5e43dad9a0f8df529b865baa5a7d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Tue, 26 May 2026 13:45:04 +0000 Subject: [PATCH 10/11] chore: post-merge biome formatting Auto-applied after merging origin/main into the agents-system branch. Single-line collapse on apps/daemon/src/status-monitor-wiring.ts; no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/daemon/src/status-monitor-wiring.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/daemon/src/status-monitor-wiring.ts b/apps/daemon/src/status-monitor-wiring.ts index da8bdf36..d2389d8d 100644 --- a/apps/daemon/src/status-monitor-wiring.ts +++ b/apps/daemon/src/status-monitor-wiring.ts @@ -103,8 +103,7 @@ export function buildStatusMonitorDeps( // tmux session name (e.g., daemon restart re-spawned the session before // /tmp was cleared). Treat the exit signal as absent so the live agent // doesn't get marked stopped. - const liveNewerThanExit = - liveStat !== null && exitStat !== null && liveStat.mtimeMs > exitStat.mtimeMs; + const liveNewerThanExit = liveStat !== null && exitStat !== null && liveStat.mtimeMs > exitStat.mtimeMs; const exitCode = exitStat && !liveNewerThanExit ? readAgentExitCode(name) : null; return { live: liveStat !== null, From 865bbb08f889930869881f4e1e43be456bb838af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Tue, 26 May 2026 13:54:07 +0000 Subject: [PATCH 11/11] fix(db): bump plan_registrations migration to v9 (main took v8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Another in-flight branch landed (8, 'agent-sessions-auto-resume-backoff') on main while this PR was open, so plan_registrations now becomes v9. INSERT OR IGNORE quietly dropped this PR's row on top of the existing v8 — packages/db/src/migration.test.ts caught the silent-loss case. Updates: - migrate.ts: version 8 → 9 for the plan-registrations row - index.test.ts: schema_migrations ORDER BY version expectation gets { version: 9 } appended This is the migration-version-drift mitigation the tech plan flagged under "Pre-merge sequencing". Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/db/src/index.test.ts | 1 + packages/db/src/migrate.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/db/src/index.test.ts b/packages/db/src/index.test.ts index 6212091f..b548cde6 100644 --- a/packages/db/src/index.test.ts +++ b/packages/db/src/index.test.ts @@ -43,6 +43,7 @@ describe("SqliteStore", () => { { version: 6 }, { version: 7 }, { version: 8 }, + { version: 9 }, ]); }); diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 0ab38ec9..2e856db9 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -210,7 +210,7 @@ export function runMigrations( (5, 'scheduled-agents', datetime('now')), (6, 'namespaces', datetime('now')), (7, 'background-sessions-and-runs', datetime('now')), - (8, 'plan-registrations', datetime('now')); + (9, 'plan-registrations', datetime('now')); `); ensureColumn("scheduled_agents", "schedule_type", "TEXT NOT NULL DEFAULT 'recurring'"); ensureColumn("scheduled_agents", "run_at", "TEXT");