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/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..cb5477f7 --- /dev/null +++ b/apps/daemon/src/agent-definitions/storage.test.ts @@ -0,0 +1,153 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { predefinedAgentIds, predefinedAgentSeed } from "./seed.js"; +import { AgentDefinitionsError, createAgentDefinitionsStorage } from "./storage.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..f546cf12 --- /dev/null +++ b/apps/daemon/src/agent-definitions/storage.ts @@ -0,0 +1,271 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + type AgentDefinition, + AgentDefinitionSchema, + type AgentsConfig, + AgentsConfigSchema, + 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; + }, + }; +} diff --git a/apps/daemon/src/agent-launcher.test.ts b/apps/daemon/src/agent-launcher.test.ts new file mode 100644 index 00000000..bcb0ea55 --- /dev/null +++ b/apps/daemon/src/agent-launcher.test.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createAgentDefinitionsStorage } from "./agent-definitions/storage.js"; +import { composeAgentLaunchInput, resolveCustomAgent, resolvePredefinedAgent } from "./agent-launcher.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/agents-routes.test.ts b/apps/daemon/src/agents-routes.test.ts new file mode 100644 index 00000000..12268242 --- /dev/null +++ b/apps/daemon/src/agents-routes.test.ts @@ -0,0 +1,250 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +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"; + +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..092e7f74 --- /dev/null +++ b/apps/daemon/src/agents-routes.ts @@ -0,0 +1,212 @@ +import type { CitadelConfig } from "@citadel/config"; +import { + AgentsConfigSchema, + CreateAgentDefinitionInputSchema, + type RuntimeModelsResponse, + UpdateAgentDefinitionInputSchema, +} from "@citadel/contracts"; +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; + +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); + }), + ); +} + +// 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-github-quota.test.ts b/apps/daemon/src/app-github-quota.test.ts index c213246f..9e182713 100644 --- a/apps/daemon/src/app-github-quota.test.ts +++ b/apps/daemon/src/app-github-quota.test.ts @@ -188,5 +188,5 @@ exit 1 clearGhCooldown(); await closeServer(server); } - }); + }, 15_000); }); diff --git a/apps/daemon/src/app.ts b/apps/daemon/src/app.ts index ef80972c..1dfa4743 100644 --- a/apps/daemon/src/app.ts +++ b/apps/daemon/src/app.ts @@ -1,13 +1,11 @@ import fs from "node:fs"; import http from "node:http"; -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { CitadelConfig } from "@citadel/config"; import { mergeConfigPatch, saveConfig } from "@citadel/config"; import { type AppEvent, - CreateRepoInputSchema, CreateWorkspaceInputSchema, HookActionSchema, TransitionIssueInputSchema, @@ -15,8 +13,7 @@ import { } from "@citadel/contracts"; import type { SqliteStore } from "@citadel/db"; import { mcpStatus, mcpToolDefinitions } from "@citadel/mcp"; -import { OperationService, createDiagnosticsLogger, type DiagnosticsLogger } from "@citadel/operations"; -import { buildDiagnosticsSnapshot, streamDiagnosticsBundle } from "./diagnostics-bundle.js"; +import { type DiagnosticsLogger, OperationService, createDiagnosticsLogger } from "@citadel/operations"; import { type CollectGitHubVersionControlSummaryDeps, collectGitHubCiRunLog, @@ -39,12 +36,14 @@ import cors from "cors"; import express from "express"; import { ZodError } from "zod"; import { registerAgentSessionRoutes } from "./agent-session-routes.js"; +import { wireAgents } from "./agents-routes.js"; import { asyncRoute, cachedProviderValue } from "./app-helpers.js"; import { startDaemonAutoRecoveryMonitor } from "./auto-recovery-wiring.js"; import { startDaemonAutoResumeLoop } from "./auto-resume-wiring.js"; import { getBootRestoreSummary } from "./boot-restore.js"; import { registerCitadelActionRoutes } from "./citadel-actions-routes.js"; import { callDaemonMcpTool, readMcpResource } from "./daemon-mcp-tool.js"; +import { buildDiagnosticsSnapshot, streamDiagnosticsBundle } from "./diagnostics-bundle.js"; import { registerWorkspaceExtraRoutes } from "./extra-routes.js"; import { AUTOMATED_GH_DISABLED_REASON, @@ -65,6 +64,7 @@ import { registerNamespaceRoutes } from "./namespace-routes.js"; import { registerPrDiffRoute } from "./pr-diff-route.js"; import { registerPrRoutes } from "./pr-routes.js"; import { deriveReadiness, workspaceAppHookSample } from "./readiness.js"; +import { registerRepoRoutes } from "./repo-routes.js"; import { registerRestoreRoutes } from "./restore-routes.js"; import { registerRuntimeUsageRoutes } from "./runtime-usage-routes.js"; import { registerScheduledAgentRoutes } from "./scheduled-agent-routes.js"; @@ -95,12 +95,6 @@ type ProviderCollectors = { transitionJiraIssue: typeof transitionJiraIssue; }; -function expandTilde(input: string): string { - if (input === "~") return os.homedir(); - if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2)); - return input; -} - export function createDaemonApp(input: { config: CitadelConfig; configPath: string; @@ -316,162 +310,12 @@ export function createDaemonApp(input: { res.json({ config, configPath }); }); - app.post("/api/repos", (req, res) => { - const input = CreateRepoInputSchema.parse(req.body); - const repo = operations.registerRepo(input); - emit("repo.updated", { repoId: repo.id, repo }); - res.status(201).json({ repo }); - }); - - app.post( - "/api/repos/inspect", - asyncRoute(async (req, res) => { - const inputPath = typeof req.body?.rootPath === "string" ? req.body.rootPath : ""; - if (!inputPath) return res.status(400).json({ error: "root_path_required" }); - const resolved = path.resolve(expandTilde(inputPath)); - const exists = fs.existsSync(resolved); - const isGit = exists && fs.existsSync(path.join(resolved, ".git")); - let defaultBranch: string | null = null; - let remotes: string[] = []; - if (isGit) { - try { - const { execFile: execFileCb } = await import("node:child_process"); - const { promisify } = await import("node:util"); - const exec = promisify(execFileCb); - const headRef = await exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { - cwd: resolved, - timeout: 6000, - }).catch(() => ({ stdout: "" })); - defaultBranch = (headRef.stdout || "").trim().replace("refs/remotes/origin/", "").trim() || "main"; - const remoteList = await exec("git", ["remote"], { cwd: resolved, timeout: 6000 }).catch(() => ({ - stdout: "", - })); - remotes = remoteList.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - } catch { - defaultBranch = "main"; - } - } - res.json({ - rootPath: resolved, - exists, - isGit, - defaultBranch, - remotes, - suggestedWorktreeParent: path.join(path.dirname(resolved), `${path.basename(resolved)}-worktrees`), - providerCandidates: [ - { id: "github-gh", displayName: "GitHub CLI", enabled: config.providers.github.enabled }, - { id: "jira-jtk", displayName: "Jira CLI", enabled: config.providers.jira.enabled }, - ], - }); - }), - ); - - app.get("/api/fs/complete", (req, res) => { - const raw = typeof req.query.prefix === "string" ? req.query.prefix : ""; - const seed = raw || "~/"; - const trailingSlash = seed.endsWith("/"); - const expanded = expandTilde(seed); - const baseDir = trailingSlash ? path.resolve(expanded || os.homedir()) : path.resolve(path.dirname(expanded)); - const filter = trailingSlash ? "" : path.basename(expanded); - let entries: Array<{ name: string; path: string; isGit: boolean }> = []; - try { - const filterLower = filter.toLowerCase(); - const showHidden = filter.startsWith("."); - const dirents = fs.readdirSync(baseDir, { withFileTypes: true }); - for (const dirent of dirents) { - if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue; - if (!showHidden && dirent.name.startsWith(".")) continue; - if (filterLower && !dirent.name.toLowerCase().startsWith(filterLower)) continue; - const full = path.join(baseDir, dirent.name); - if (dirent.isSymbolicLink()) { - try { - if (!fs.statSync(full).isDirectory()) continue; - } catch { - continue; - } - } - const isGit = fs.existsSync(path.join(full, ".git")); - entries.push({ name: dirent.name, path: full, isGit }); - if (entries.length >= 100) break; - } - entries.sort((a, b) => a.name.localeCompare(b.name)); - entries = entries.slice(0, 50); - } catch { - entries = []; - } - res.json({ baseDir, filter, entries }); - }); - - app.get("/api/repos", (_req, res) => { - res.json({ repos: store.listRepos() }); - }); - - app.delete( - "/api/repos/:repoId", - asyncRoute(async (req, res) => { - const repoId = req.params.repoId; - if (typeof repoId !== "string") return res.status(400).json({ error: "repo_id_required" }); - const result = await operations.removeRepo({ - repoId, - force: req.query.force === "true", - cleanupWorktrees: req.query.cleanupWorktrees === "true", - }); - providerCache.clear(); - emit("repo.updated", result); - res.status(result.removed ? 202 : 409).json(result); - }), - ); + registerRepoRoutes({ app, config, store, operations, providerCache, emit, asyncRoute }); app.get("/api/workspaces", (_req, res) => { res.json({ workspaces: store.listWorkspaces() }); }); - app.get( - "/api/repos/:repoId/branches", - asyncRoute(async (req, res) => { - const repo = store.listRepos().find((candidate) => candidate.id === req.params.repoId); - if (!repo) return res.status(404).json({ error: "repo_not_found" }); - try { - 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) - .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)), - }); - } catch (error) { - return res.json({ - defaultBranch: repo.defaultBranch, - local: [], - remote: [], - error: error instanceof Error ? error.message : "git_branches_failed", - }); - } - }), - ); - app.get( "/api/workspaces/:workspaceId/issue-summary", asyncRoute(async (req, res) => { @@ -671,31 +515,6 @@ export function createDaemonApp(input: { res.json({ operation }); }); - app.patch( - "/api/repos/:repoId", - asyncRoute(async (req, res) => { - const repoId = String(req.params.repoId); - const patch = req.body ?? {}; - const allowed: Record = {}; - if (typeof patch.name === "string" && patch.name.length) allowed.name = patch.name; - if (typeof patch.worktreeParent === "string" && patch.worktreeParent.length) - allowed.worktreeParent = patch.worktreeParent; - if (Array.isArray(patch.setupHookIds)) - allowed.setupHookIds = patch.setupHookIds.filter((id: unknown) => typeof id === "string"); - if (Array.isArray(patch.teardownHookIds)) - allowed.teardownHookIds = patch.teardownHookIds.filter((id: unknown) => typeof id === "string"); - if (Array.isArray(patch.providerIds)) - allowed.providerIds = patch.providerIds.filter((id: unknown) => typeof id === "string"); - if (typeof patch.deployHookCommand === "string") - allowed.deployHookCommand = patch.deployHookCommand.trim() || null; - else if (patch.deployHookCommand === null) allowed.deployHookCommand = null; - const next = store.updateRepo(repoId, allowed); - if (!next) return res.status(404).json({ error: "repo_not_found" }); - emit("repo.updated", { repoId: next.id, repo: next }); - res.json({ repo: next }); - }), - ); - registerPrDiffRoute({ app, store, providerCache, asyncRoute }); app.post( @@ -717,18 +536,6 @@ export function createDaemonApp(input: { }), ); - app.post( - "/api/repos/:repoId/refresh", - asyncRoute(async (req, res) => { - const repo = store.listRepos().find((candidate) => candidate.id === req.params.repoId); - if (!repo) return res.status(404).json({ error: "repo_not_found" }); - const prefixes = [`vc:${repo.id}`, `ci:${repo.id}`]; - bustCacheByPrefixes(providerCache, prefixes); - emit("repo.refreshed", { repoId: repo.id }); - res.json({ refreshed: prefixes }); - }), - ); - app.get("/api/activity", (req, res) => { const workspaceId = typeof req.query.workspaceId === "string" ? req.query.workspaceId : undefined; res.json({ activity: store.listActivity(workspaceId) }); @@ -786,6 +593,7 @@ export function createDaemonApp(input: { registerWorkspaceExtraRoutes({ app, store, emit, asyncRoute, operations, config }); registerNamespaceRoutes({ app, store, operations, emit, asyncRoute }); + wireAgents(app, asyncRoute, config); registerScratchpadRoutes({ app, config, emit, store, operations, providerHealth: cachedProviderHealth }); registerCitadelActionRoutes({ app, config, emit }); backfillScratchpadOnStartup(config); diff --git a/apps/daemon/src/daemon-mcp-tool.ts b/apps/daemon/src/daemon-mcp-tool.ts index a7e08838..2909127d 100644 --- a/apps/daemon/src/daemon-mcp-tool.ts +++ b/apps/daemon/src/daemon-mcp-tool.ts @@ -24,6 +24,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 { readLogSlice } from "./log-slice.js"; import type { ScheduledAgentService } from "./scheduled-agent-service.js"; import { refineScratchpad } from "./scratchpad-refine.js"; @@ -48,8 +50,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(), @@ -411,6 +421,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(), diff --git a/apps/daemon/src/diagnostics-bundle.ts b/apps/daemon/src/diagnostics-bundle.ts index 56a18fb1..5d6c1cd2 100644 --- a/apps/daemon/src/diagnostics-bundle.ts +++ b/apps/daemon/src/diagnostics-bundle.ts @@ -20,7 +20,7 @@ import { promisify } from "node:util"; import type { CitadelConfig } from "@citadel/config"; import type { SqliteStore } from "@citadel/db"; import type { DiagnosticEvent, DiagnosticsLogger } from "@citadel/operations"; -import { listAllTmuxSessions, type TtydManager } from "@citadel/terminal"; +import { type TtydManager, listAllTmuxSessions } from "@citadel/terminal"; const execFileAsync = promisify(execFile); diff --git a/apps/daemon/src/repo-routes.ts b/apps/daemon/src/repo-routes.ts new file mode 100644 index 00000000..acf19cef --- /dev/null +++ b/apps/daemon/src/repo-routes.ts @@ -0,0 +1,215 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { CitadelConfig } from "@citadel/config"; +import { CreateRepoInputSchema } from "@citadel/contracts"; +import type { SqliteStore } from "@citadel/db"; +import type { OperationService } from "@citadel/operations"; +import type express from "express"; +import type { ProviderCache } from "./app-helpers.js"; +import { bustCacheByPrefixes } from "./workspace-fs-watcher.js"; + +type Emit = (type: string, payload: unknown) => void; +type AsyncHandler = (req: express.Request, res: express.Response, next: express.NextFunction) => Promise; +type AsyncRoute = ( + handler: AsyncHandler, +) => (req: express.Request, res: express.Response, next: express.NextFunction) => void; + +function expandTilde(input: string): string { + if (input === "~") return os.homedir(); + if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2)); + return input; +} + +export function registerRepoRoutes(input: { + app: express.Express; + config: CitadelConfig; + store: SqliteStore; + operations: OperationService; + providerCache: ProviderCache; + emit: Emit; + asyncRoute: AsyncRoute; +}) { + const { app, config, store, operations, providerCache, emit, asyncRoute } = input; + + app.post("/api/repos", (req, res) => { + const repo = operations.registerRepo(CreateRepoInputSchema.parse(req.body)); + emit("repo.updated", { repoId: repo.id, repo }); + res.status(201).json({ repo }); + }); + + app.post( + "/api/repos/inspect", + asyncRoute(async (req, res) => { + const inputPath = typeof req.body?.rootPath === "string" ? req.body.rootPath : ""; + if (!inputPath) return res.status(400).json({ error: "root_path_required" }); + const resolved = path.resolve(expandTilde(inputPath)); + const exists = fs.existsSync(resolved); + const isGit = exists && fs.existsSync(path.join(resolved, ".git")); + let defaultBranch: string | null = null; + let remotes: string[] = []; + if (isGit) { + try { + const { execFile: execFileCb } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const exec = promisify(execFileCb); + const headRef = await exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { + cwd: resolved, + timeout: 6000, + }).catch(() => ({ stdout: "" })); + defaultBranch = (headRef.stdout || "").trim().replace("refs/remotes/origin/", "").trim() || "main"; + const remoteList = await exec("git", ["remote"], { cwd: resolved, timeout: 6000 }).catch(() => ({ + stdout: "", + })); + remotes = remoteList.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + } catch { + defaultBranch = "main"; + } + } + res.json({ + rootPath: resolved, + exists, + isGit, + defaultBranch, + remotes, + suggestedWorktreeParent: path.join(path.dirname(resolved), `${path.basename(resolved)}-worktrees`), + providerCandidates: [ + { id: "github-gh", displayName: "GitHub CLI", enabled: config.providers.github.enabled }, + { id: "jira-jtk", displayName: "Jira CLI", enabled: config.providers.jira.enabled }, + ], + }); + }), + ); + + app.get("/api/fs/complete", (req, res) => { + const raw = typeof req.query.prefix === "string" ? req.query.prefix : ""; + const seed = raw || "~/"; + const trailingSlash = seed.endsWith("/"); + const expanded = expandTilde(seed); + const baseDir = trailingSlash ? path.resolve(expanded || os.homedir()) : path.resolve(path.dirname(expanded)); + const filter = trailingSlash ? "" : path.basename(expanded); + let entries: Array<{ name: string; path: string; isGit: boolean }> = []; + try { + const filterLower = filter.toLowerCase(); + const showHidden = filter.startsWith("."); + const dirents = fs.readdirSync(baseDir, { withFileTypes: true }); + for (const dirent of dirents) { + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue; + if (!showHidden && dirent.name.startsWith(".")) continue; + if (filterLower && !dirent.name.toLowerCase().startsWith(filterLower)) continue; + const full = path.join(baseDir, dirent.name); + if (dirent.isSymbolicLink()) { + try { + if (!fs.statSync(full).isDirectory()) continue; + } catch { + continue; + } + } + const isGit = fs.existsSync(path.join(full, ".git")); + entries.push({ name: dirent.name, path: full, isGit }); + if (entries.length >= 100) break; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + entries = entries.slice(0, 50); + } catch { + entries = []; + } + res.json({ baseDir, filter, entries }); + }); + + app.get("/api/repos", (_req, res) => { + res.json({ repos: store.listRepos() }); + }); + + app.delete( + "/api/repos/:repoId", + asyncRoute(async (req, res) => { + const repoId = req.params.repoId; + if (typeof repoId !== "string") return res.status(400).json({ error: "repo_id_required" }); + const result = await operations.removeRepo({ + repoId, + force: req.query.force === "true", + cleanupWorktrees: req.query.cleanupWorktrees === "true", + }); + providerCache.clear(); + emit("repo.updated", result); + res.status(result.removed ? 202 : 409).json(result); + }), + ); + + app.get( + "/api/repos/:repoId/branches", + asyncRoute(async (req, res) => { + const repo = store.listRepos().find((candidate) => candidate.id === req.params.repoId); + if (!repo) return res.status(404).json({ error: "repo_not_found" }); + try { + const { execFile: execFileCb } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const exec = promisify(execFileCb); + 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: 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", + }); + } + }), + ); + + app.patch( + "/api/repos/:repoId", + asyncRoute(async (req, res) => { + const repoId = String(req.params.repoId); + const patch = req.body ?? {}; + const allowed: Record = {}; + if (typeof patch.name === "string" && patch.name.length) allowed.name = patch.name; + if (typeof patch.worktreeParent === "string" && patch.worktreeParent.length) + allowed.worktreeParent = patch.worktreeParent; + if (Array.isArray(patch.setupHookIds)) + allowed.setupHookIds = patch.setupHookIds.filter((id: unknown) => typeof id === "string"); + if (Array.isArray(patch.teardownHookIds)) + allowed.teardownHookIds = patch.teardownHookIds.filter((id: unknown) => typeof id === "string"); + if (Array.isArray(patch.providerIds)) + allowed.providerIds = patch.providerIds.filter((id: unknown) => typeof id === "string"); + if (typeof patch.deployHookCommand === "string") + allowed.deployHookCommand = patch.deployHookCommand.trim() || null; + else if (patch.deployHookCommand === null) allowed.deployHookCommand = null; + const next = store.updateRepo(repoId, allowed); + if (!next) return res.status(404).json({ error: "repo_not_found" }); + emit("repo.updated", { repoId: next.id, repo: next }); + res.json({ repo: next }); + }), + ); + + app.post( + "/api/repos/:repoId/refresh", + asyncRoute(async (req, res) => { + const repo = store.listRepos().find((candidate) => candidate.id === req.params.repoId); + if (!repo) return res.status(404).json({ error: "repo_not_found" }); + const prefixes = [`vc:${repo.id}`, `ci:${repo.id}`]; + bustCacheByPrefixes(providerCache, prefixes); + emit("repo.refreshed", { repoId: repo.id }); + res.json({ refreshed: prefixes }); + }), + ); +} diff --git a/apps/daemon/src/scratchpad-routes-blocks.test.ts b/apps/daemon/src/scratchpad-routes-blocks.test.ts index dd05a728..fd9165ec 100644 --- a/apps/daemon/src/scratchpad-routes-blocks.test.ts +++ b/apps/daemon/src/scratchpad-routes-blocks.test.ts @@ -418,5 +418,5 @@ describe("scratchpad block routes + MCP block tools", () => { } finally { await closeServer(server); } - }); + }, 15_000); }); diff --git a/apps/web/src/settings-debug.tsx b/apps/web/src/settings-debug.tsx index f0f7e647..a1f23d1c 100644 --- a/apps/web/src/settings-debug.tsx +++ b/apps/web/src/settings-debug.tsx @@ -62,10 +62,10 @@ export function DebugPanel() { return (

- Citadel writes a structured event log to{" "} - {snapshot?.logFile.path ?? ".citadel/diagnostics.jsonl"} covering tmux/ttyd lifecycle, status-monitor - decisions, and boot-restore. Download the bundle below and share it when reporting "my sessions died" — it - includes the JSONL trail, a state snapshot, and the last 30 minutes of citadel.service journal. + Citadel writes a structured event log to {snapshot?.logFile.path ?? ".citadel/diagnostics.jsonl"}{" "} + covering tmux/ttyd lifecycle, status-monitor decisions, and boot-restore. Download the bundle below and share it + when reporting "my sessions died" — it includes the JSONL trail, a state snapshot, and the last 30 minutes of{" "} + citadel.service journal.

diff --git a/packages/contracts/src/agents.ts b/packages/contracts/src/agents.ts new file mode 100644 index 00000000..895782c6 --- /dev/null +++ b/packages/contracts/src/agents.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; +import { IdSchema } from "./primitives.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/index.test.ts b/packages/contracts/src/index.test.ts index 30183199..970f4455 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,16 +15,23 @@ import { HookOutputSchema, IssueTrackerSummarySchema, IssueTransitionActionResultSchema, + LaunchCustomAgentInputSchema, + LaunchHandoffAgentInputSchema, + LaunchPredefinedAgentInputSchema, OperationSchema, + PlanRegistrationSchema, PrMergeStateStatusSchema, PrReviewerSchema, ProviderHealthSchema, PullRequestSummarySchema, RecentCommitSchema, + RegisterPlanInputSchema, RepoSchema, + RuntimeModelsResponseSchema, RuntimeUsageSummarySchema, ScheduledAgentRunSchema, ScheduledAgentSchema, + UpdateAgentDefinitionInputSchema, UpdateScheduledAgentInputSchema, VersionControlSummarySchema, WorkspaceDiffSchema, @@ -583,3 +593,99 @@ describe("contract schemas", () => { ).toBe("pr-conflicts"); }); }); + +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 504f57bf..45c3877f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,6 +3,8 @@ import { ParentPrSchema, PrCommitSchema, PrMergeStrategySchema } from "./pr-rout import { IdSchema } from "./primitives.js"; export { IdSchema } from "./primitives.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"]); export const WorkspaceSourceSchema = z.enum(["scratch", "pr", "issue", "imported"]); diff --git a/packages/db/src/index.test.ts b/packages/db/src/index.test.ts index 13c7f1ed..ed821a36 100644 --- a/packages/db/src/index.test.ts +++ b/packages/db/src/index.test.ts @@ -47,6 +47,7 @@ describe("SqliteStore", () => { { version: 10 }, { version: 11 }, { version: 12 }, + { version: 13 }, ]); }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 390a228f..c1538c6d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -793,3 +793,5 @@ export class SqliteStore { // hoisting would run it before this class declaration completes. import { scheduledRunStoreMethods } from "./scheduled-run-store.js"; Object.assign(SqliteStore.prototype, scheduledRunStoreMethods); +import { planRegistrationStoreMethods } from "./plan-registration-store.js"; +Object.assign(SqliteStore.prototype, planRegistrationStoreMethods); diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 99499451..91389014 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')), + (13, 'plan-registrations', datetime('now')); `); ensureColumn("scheduled_agents", "schedule_type", "TEXT NOT NULL DEFAULT 'recurring'"); ensureColumn("scheduled_agents", "run_at", "TEXT"); diff --git a/packages/db/src/plan-registration-store.test.ts b/packages/db/src/plan-registration-store.test.ts new file mode 100644 index 00000000..81c2cfef --- /dev/null +++ b/packages/db/src/plan-registration-store.test.ts @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteStore } from "./index.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("plan registration store", () => { + it("persists plan_registrations rows and cascades 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"); + + store.deleteWorkspace("ws_plan"); + expect(store.listPlanRegistrationsForWorkspace("ws_plan")).toEqual([]); + expect(store.findPlanRegistration("plan-1")).toBeNull(); + expect(store.deletePlanRegistration("missing")).toBe(false); + }); +}); diff --git a/packages/db/src/plan-registration-store.ts b/packages/db/src/plan-registration-store.ts new file mode 100644 index 00000000..bdafddaf --- /dev/null +++ b/packages/db/src/plan-registration-store.ts @@ -0,0 +1,56 @@ +import type { PlanRegistration } from "@citadel/contracts"; +import type { SqliteStore } from "./index.js"; + +declare module "./index.js" { + interface SqliteStore { + insertPlanRegistration(row: PlanRegistration): void; + findPlanRegistration(id: string): PlanRegistration | null; + listPlanRegistrationsForWorkspace(workspaceId: string): PlanRegistration[]; + deletePlanRegistration(id: string): boolean; + } +} + +export const planRegistrationStoreMethods = { + insertPlanRegistration(this: SqliteStore, 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(this: SqliteStore, 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(this: SqliteStore, 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(this: SqliteStore, id: string): boolean { + const result = this.database.prepare("DELETE FROM plan_registrations WHERE id = ?").run(id); + return (result.changes ?? 0) > 0; + }, +}; + +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), + }; +} 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 a43b62b6..04ae450f 100644 --- a/packages/mcp/src/index.test.ts +++ b/packages/mcp/src/index.test.ts @@ -105,6 +105,40 @@ 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: [], + scratchpadPath: "/tmp/test-scratchpad.md", + }; + 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 b8a0aac1..b41becf8 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; @@ -580,6 +589,7 @@ export function mcpToolDefinitions(): McpToolDefinition[] { }, destructive: false, }, + ...agentLauncherToolDefinitions(), ]; } @@ -693,6 +703,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); } diff --git a/packages/operations/src/index.ts b/packages/operations/src/index.ts index 09c12046..81d36023 100644 --- a/packages/operations/src/index.ts +++ b/packages/operations/src/index.ts @@ -385,14 +385,6 @@ export class OperationService { return { retried: false, reason: "unknown_kind" as const }; } - /** - * Reconcile local state with reality: - * - mark sessions as `orphaned` when their tmux session is gone - * - mark workspaces whose worktree directory no longer exists as failed - * - archive repos whose rootPath no longer exists. - * - * Returns counts of the cleanup performed. - */ reconcile(): { sessions: number; workspaces: number; repos: number; deletedSessions: number } { return reconcileStore(this.store, (message, repoId) => this.activity("repo.removed", "system", message, repoId, null, null), @@ -453,11 +445,6 @@ export class OperationService { const ownedSessions = this.store.listSessions(workspace.id); for (const session of ownedSessions) { if (session.tmuxSessionName && !input.archiveOnly) killTmuxSession(session.tmuxSessionName); - // Always release the ttyd alongside — applies to both archive and full - // remove. Otherwise the ttyd process keeps running detached, holding a - // port + a tmux client slot, and a future iframe attempt for the - // (now-archived) session can't re-attach cleanly. The hook is a no-op - // when no manager is wired (tests). this.terminalHooks.onSessionStopped?.(session.id); } if (ownedSessions.length && !input.archiveOnly) { @@ -664,7 +651,6 @@ export class OperationService { listDeployedApps = (input: { workspaceId: string }) => listDeployedAppsImpl(this.deployOpsDeps(), this.resolveRepoWorkspace(input.workspaceId)); - // Per-workspace inflight guard prevents concurrent redeploys (double-click, human+MCP overlap). private redeployInflight = new Map>(); redeployApp = (input: { workspaceId: string; appName?: string | undefined }) => { const existing = this.redeployInflight.get(input.workspaceId); diff --git a/packages/runtimes/src/index.ts b/packages/runtimes/src/index.ts index bd1a5091..17644d12 100644 --- a/packages/runtimes/src/index.ts +++ b/packages/runtimes/src/index.ts @@ -31,6 +31,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..38e0f148 --- /dev/null +++ b/packages/runtimes/src/models/index.test.ts @@ -0,0 +1,63 @@ +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 }] }; +} diff --git a/packages/terminal/src/ttyd-theme.ts b/packages/terminal/src/ttyd-theme.ts new file mode 100644 index 00000000..4495a585 --- /dev/null +++ b/packages/terminal/src/ttyd-theme.ts @@ -0,0 +1,66 @@ +import type { TtydTheme } from "./ttyd.js"; + +export function ttydThemeArgs(theme: TtydTheme): string[] { + const palette = theme === "light" ? LIGHT_XTERM_THEME : DARK_XTERM_THEME; + return [ + "-t", + `theme=${JSON.stringify(palette)}`, + "-t", + "fontFamily=ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + "-t", + "reconnect=3", + ]; +} + +export function detectTtydThemeFromThemeJson(themeJson: string | null): TtydTheme { + if (themeJson?.includes(`"${LIGHT_XTERM_THEME.background}"`)) return "light"; + return "dark"; +} + +const LIGHT_XTERM_THEME = { + background: "#f5f1e8", + foreground: "#1a1814", + cursor: "#14171f", + cursorAccent: "#f5f1e8", + selectionBackground: "rgba(20, 23, 31, 0.18)", + black: "#1a1814", + red: "#9a1d12", + green: "#36680c", + yellow: "#825507", + blue: "#194d8e", + magenta: "#5f2a7a", + cyan: "#0a5d6e", + white: "#1a1814", + brightBlack: "#4a463e", + brightRed: "#b8281c", + brightGreen: "#4a8a14", + brightYellow: "#a06b0a", + brightBlue: "#2864ad", + brightMagenta: "#7d3a98", + brightCyan: "#0f7d92", + brightWhite: "#0c0a06", +}; + +const DARK_XTERM_THEME = { + background: "#1a1814", + foreground: "#e8e3d3", + cursor: "#f0ebdd", + cursorAccent: "#1a1814", + selectionBackground: "rgba(240, 235, 221, 0.18)", + black: "#1a1814", + red: "#ec7468", + green: "#a3d364", + yellow: "#e8b552", + blue: "#7eb5e4", + magenta: "#c896d4", + cyan: "#7dbedc", + white: "#e8e3d3", + brightBlack: "#948d7b", + brightRed: "#ff8d80", + brightGreen: "#bbe683", + brightYellow: "#f5c66a", + brightBlue: "#a2cef0", + brightMagenta: "#dcb1e4", + brightCyan: "#9ad0e8", + brightWhite: "#fffaef", +}; diff --git a/packages/terminal/src/ttyd.ts b/packages/terminal/src/ttyd.ts index 24961427..ab5cc11e 100644 --- a/packages/terminal/src/ttyd.ts +++ b/packages/terminal/src/ttyd.ts @@ -2,6 +2,7 @@ import { type ChildProcess, execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; import { tmuxPrefix } from "./index.js"; +import { detectTtydThemeFromThemeJson, ttydThemeArgs } from "./ttyd-theme.js"; export type TtydTheme = "light" | "dark"; @@ -583,87 +584,6 @@ export function createTtydManager(input: TtydManagerConfig = {}): TtydManager { return Object.assign(manager, { publicPathFor }); } -/** - * Build the `-t` ttyd client-option flags that paint xterm to match the - * cockpit theme. Palette is derived from the meshes-studio design system - * (warm beige + navy for light, deep navy + soft white for dark) so the - * terminal blends with the rest of the UI. - */ -function ttydThemeArgs(theme: TtydTheme): string[] { - const palette = theme === "light" ? LIGHT_XTERM_THEME : DARK_XTERM_THEME; - return [ - "-t", - `theme=${JSON.stringify(palette)}`, - "-t", - "fontFamily=ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - // Auto-reconnect 3s after the websocket drops (laptop sleep, network - // blip). Without this, ttyd's xterm shows "Press any key to reconnect" - // and waits for a manual key press. - "-t", - "reconnect=3", - ]; -} - -// Palette matches the cockpit's warm-cream redesign so the terminal pane -// reads as part of the surface, not a stark white island. Background tracks -// --c-elev (the stage card colour); foreground tracks --c-fg-1. Ansi colour -// hues are unchanged from the previous palette — only their saturation has -// been pushed up so each colour reads clearly on the cream/dark surfaces -// without losing the warm-leaning character of the cockpit. -// `white` (ansi 7) and `brightWhite` (ansi 15) are deliberately remapped to -// dark values on the light theme: a program that explicitly prints white text -// would otherwise be invisible on the cream surface. Everything else is the -// same hue as before, just dropped in lightness so it reads cleanly on a -// light background — pulling the bright variants down at the same time so -// the "bright" tier stays distinguishable from base without going pastel. -const LIGHT_XTERM_THEME = { - background: "#f5f1e8", - foreground: "#1a1814", - cursor: "#14171f", - cursorAccent: "#f5f1e8", - selectionBackground: "rgba(20, 23, 31, 0.18)", - black: "#1a1814", - red: "#9a1d12", - green: "#36680c", - yellow: "#825507", - blue: "#194d8e", - magenta: "#5f2a7a", - cyan: "#0a5d6e", - white: "#1a1814", - brightBlack: "#4a463e", - brightRed: "#b8281c", - brightGreen: "#4a8a14", - brightYellow: "#a06b0a", - brightBlue: "#2864ad", - brightMagenta: "#7d3a98", - brightCyan: "#0f7d92", - brightWhite: "#0c0a06", -}; - -const DARK_XTERM_THEME = { - background: "#1a1814", - foreground: "#e8e3d3", - cursor: "#f0ebdd", - cursorAccent: "#1a1814", - selectionBackground: "rgba(240, 235, 221, 0.18)", - black: "#1a1814", - red: "#ec7468", - green: "#a3d364", - yellow: "#e8b552", - blue: "#7eb5e4", - magenta: "#c896d4", - cyan: "#7dbedc", - white: "#e8e3d3", - brightBlack: "#948d7b", - brightRed: "#ff8d80", - brightGreen: "#bbe683", - brightYellow: "#f5c66a", - brightBlue: "#a2cef0", - brightMagenta: "#dcb1e4", - brightCyan: "#9ad0e8", - brightWhite: "#fffaef", -}; - function buildAttachCommand(tmuxSession: string, options: { enableMouse: boolean }) { const safe = tmuxSession.replace(/"/g, '\\"'); // Inline socket flag so the shell ttyd execs into talks to the same tmux @@ -844,12 +764,7 @@ function readTtydEntryFromProc(pid: number, port: number, basePathPrefix: string const sessionRaw = sessionMatch?.[1]; if (!sessionRaw) return null; const tmuxSession = sessionRaw.replace(/\\(.)/g, "$1"); - // Theme: match by the unique light/dark background hex; default to dark. - let theme: TtydTheme = "dark"; - if (themeJson) { - if (themeJson.includes(`"${LIGHT_XTERM_THEME.background}"`)) theme = "light"; - else if (themeJson.includes(`"${DARK_XTERM_THEME.background}"`)) theme = "dark"; - } + const theme = detectTtydThemeFromThemeJson(themeJson); let startedAt = new Date().toISOString(); try { const stat = fs.statSync(`/proc/${pid}`); 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 972091fa..44e7a163 100644 --- a/specs/B.2-ade-cockpit.md +++ b/specs/B.2-ade-cockpit.md @@ -143,6 +143,17 @@ The cockpit's scratchpad opens as a **right-anchored overlay drawer** rendered a [ ] 17. The `/scratchpad` URL is a one-shot deep link: on visit, opens the drawer and normalizes the URL to `?scratchpad=1` (or `/?scratchpad=1` if no prior route). The `scratchpad` query param drives drawer state at cold start; the in-memory drawer store is the source of truth thereafter. +## 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, drawer, fuzzy search, refine, citadel actions, shortcuts +keywords: ade, cockpit, readiness, next action, workspace detail, operator, attention state, agents, agent definitions, scratchpad, blocks, drawer, fuzzy search, refine, citadel actions, shortcuts diff --git a/specs/B.3-agent-sessions-terminal.md b/specs/B.3-agent-sessions-terminal.md index a1fa3981..0a8ba2ea 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 aec785db..f505a3c8 100644 --- a/specs/B.6-providers-hooks-config.md +++ b/specs/B.6-providers-hooks-config.md @@ -85,6 +85,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 e9f47a6d..a0533ac7 100644 --- a/specs/B.7-operations-activity-mcp.md +++ b/specs/B.7-operations-activity-mcp.md @@ -126,6 +126,27 @@ The `path` field is always populated on the daemon-dispatched MCP path (`scratch **First-read migration on a user-supplied file.** If `scratchpad.path` points at a pre-existing non-fenced markdown file, the first read triggers `migrateIfNeeded` and rewrites it to fenced-block form (history entry: `migrate-to-blocks`). The daemon emits a single `console.warn` line naming the path so the rewrite is not silent. A UI banner is **future polish** — not in the initial implementation. +## 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, fuzzy search, refine, citadel actions +keywords: operations, activity, audit, progress, logs, mcp, automation, agents, agent definitions, agent launchers, plan handoff, scratchpad, fuzzy search, refine, citadel actions