mcp` — it should hang waiting for JSON-RPC on stdin, which is correct).
diff --git a/docs/getting-started/sessions/index.md b/docs/getting-started/sessions/index.md
index 204134d..98d544f 100644
--- a/docs/getting-started/sessions/index.md
+++ b/docs/getting-started/sessions/index.md
@@ -13,9 +13,11 @@ Operator supports multiple session management backends for running AI coding age
| Option | Status | Notes |
|--------|--------|-------|
| [VS Code Extension](/getting-started/sessions/vscode/) | Recommended (Preferred) | Integrated terminals in VS Code, works on all platforms |
+| [Cursor](/getting-started/sessions/cursor/) | Supported | Cursor IDE (VS Code fork); same extension, native MCP via `~/.cursor/mcp.json` |
| [tmux](/getting-started/sessions/tmux/) | Supported | Terminal multiplexer, ideal for headless/server environments |
| [cmux](/getting-started/sessions/cmux/) | Supported | macOS terminal multiplexer, manages workspaces within cmux |
| [Zellij](/getting-started/sessions/zellij/) | Supported | Terminal workspace manager, tab-per-agent model (macOS/Linux) |
+| [Zed](/getting-started/sessions/zed/) | Supported | Zed editor extension; MCP context server, ACP agent, slash commands |
## How It Works
@@ -30,6 +32,8 @@ Session managers provide:
**VS Code Extension** is the recommended choice for most users. It provides an integrated experience with ticket management, color-coded terminals, and works seamlessly on macOS, Linux, and Windows without additional setup.
+**Cursor** is the right choice if you already use Cursor as your daily editor. The same `operator-terminals` extension installs from OpenVSX, and `Operator: Connect MCP Server` writes to Cursor's native `~/.cursor/mcp.json` (stdio) so the operator tool surface shows up in Cursor's MCP UI and chat.
+
**tmux** remains an excellent choice for headless/server environments, SSH sessions, and users who prefer terminal-based workflows. It's particularly useful for remote servers where VS Code may not be available.
**cmux** is a macOS-native option for users already working within cmux. It launches agents as cmux windows or workspaces. Requires macOS and that Operator is running inside a cmux session.
diff --git a/docs/getting-started/sessions/zed.md b/docs/getting-started/sessions/zed.md
new file mode 100644
index 0000000..d41556b
--- /dev/null
+++ b/docs/getting-started/sessions/zed.md
@@ -0,0 +1,96 @@
+---
+title: "Zed"
+description: "Zed editor integration for Operator via MCP context server, ACP agent, and slash commands."
+layout: doc
+---
+
+# Zed
+
+Alpha
+
+
+This integration is in alpha and may have limited functionality or incomplete support.
+
+
+The [Zed](https://zed.dev) extension for Operator provides three integration layers: an MCP context server for tools and resources, an ACP agent server for delegated prompts, and slash commands for quick operations.
+
+## Prerequisites
+
+- [Operator](https://operator.untra.io) installed and on PATH
+- Zed editor
+
+## Installation
+
+1. Open Zed
+2. Open the Extensions panel (**Zed > Extensions** or `Cmd+Shift+X`)
+3. Search for **Operator**
+4. Click **Install**
+
+## Setup
+
+### MCP Context Server (automatic)
+
+After installing the extension, Zed automatically registers `operator mcp` as a context server. All Operator tools appear in the Agent Panel:
+
+- `operator_health` / `operator_status` — system health
+- `operator_list_tickets` — query queue, in-progress, completed tickets
+- `operator_claim_ticket` / `operator_complete_ticket` / `operator_return_to_queue` — ticket lifecycle
+- `operator_create_ticket` — create tickets from templates
+- `operator_list_issue_types` / `operator_list_collections` / `operator_list_skills` — registry queries
+- `operator_launch_ticket` / `operator_pause_queue` / `operator_resume_queue` — queue operations
+- `operator_approve_agent` / `operator_reject_agent` — review actions
+
+If the `operator` binary is not found, the extension shows installation instructions.
+
+### ACP Agent Server (one-time setup)
+
+Run `/op-setup-agent` in the AI assistant to generate the config snippet, then paste it into `~/.config/zed/settings.json`. After restarting Zed, Operator appears as an agent in the Agent Panel — you can send prompts that flow through ACP to a Claude Code delegator.
+
+## Slash Commands
+
+| Command | Description |
+|---------|-------------|
+| `/op-status` | Show Operator health and status |
+| `/op-queue` | List tickets in queue |
+| `/op-launch TICKET-ID` | Launch a ticket |
+| `/op-active` | List active agents |
+| `/op-completed` | List recently completed tickets |
+| `/op-ticket TICKET-ID` | Show ticket details |
+| `/op-pause` | Pause queue processing |
+| `/op-resume` | Resume queue processing |
+| `/op-sync` | Sync kanban collections |
+| `/op-approve AGENT-ID` | Approve agent review |
+| `/op-reject AGENT-ID REASON` | Reject agent review |
+| `/op-setup-agent` | Generate ACP agent server config |
+
+Commands with arguments support tab-completion from live API data.
+
+## How It Works
+
+Operator integrates with Zed through three communication channels:
+
+- **MCP Context Server** — Runs `operator mcp` via stdio. Tools and ticket resources appear natively in the Agent Panel without additional configuration.
+- **ACP Agent Server** — Runs `operator acp` via stdio. Prompts sent to the Operator agent flow through a delegator to Claude Code, with streaming output back to Zed.
+- **Slash Commands** — Communicate with the Operator REST API for quick status checks and operations directly in the AI assistant.
+
+## Configuration
+
+The Operator binary must be on your PATH. The extension also checks common install locations (`/usr/local/bin`, `/opt/homebrew/bin`). The REST API URL for slash commands defaults to `http://localhost:7008`.
+
+## Troubleshooting
+
+### MCP tools not appearing
+
+1. Verify Operator is on PATH: `which operator`
+2. Test MCP server: `operator mcp` (should wait for JSON-RPC input)
+3. Check Zed's extension logs: **View > Output > Extensions**
+
+### Slash commands failing
+
+1. Check that Operator API is running: `operator api`
+2. Verify connectivity: `curl http://localhost:7008/api/v1/health`
+
+### Extension not appearing
+
+1. Open the Extensions panel and verify Operator is listed as installed
+2. Try **Zed > Extensions > Reload** or restart Zed
diff --git a/docs/index.md b/docs/index.md
index c510c62..b8de192 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -41,11 +41,12 @@ These are tools that comparable and aspirational for Operator
- [agtx](https://github.com/fynnfluegge/agtx)
- [claude-relay](https://github.com/Innestic/claude-relay)
-- [gastown](https://github.com/gastownhall/gastown)
+- [kanbots](https://github.com/Innestic/claude-relay)
## Similar, but Worse:
These are tools that are almost as good, and are inspirational, but just don't quite cut it:
- [Ralph Code](https://github.com/frankbria/ralph-claude-code)
-- [Vibe Kanban](https://www.vibekanban.com/)
\ No newline at end of file
+- [Vibe Kanban](https://www.vibekanban.com/)
+- [gastown](https://github.com/gastownhall/gastown)
\ No newline at end of file
diff --git a/docs/schemas/config.json b/docs/schemas/config.json
index 2da1698..6a7e353 100644
--- a/docs/schemas/config.json
+++ b/docs/schemas/config.json
@@ -91,31 +91,6 @@
"skill_directory_overrides": {}
}
},
- "backstage": {
- "$ref": "#/$defs/BackstageConfig",
- "default": {
- "enabled": true,
- "display": false,
- "port": 7007,
- "auto_start": false,
- "subpath": "backstage",
- "branding_subpath": "branding",
- "release_url": "https://github.com/untra/operator/releases/latest/download",
- "local_binary_path": null,
- "branding": {
- "app_title": "Operator Portal",
- "org_name": "Operator",
- "logo_path": "logo.svg",
- "colors": {
- "primary": "#cc6c55",
- "secondary": "#114145",
- "accent": "#f4dbb7",
- "warning": "#d46048",
- "muted": "#8a4a3a"
- }
- }
- }
- },
"rest_api": {
"$ref": "#/$defs/RestApiConfig",
"default": {
@@ -174,6 +149,32 @@
"$ref": "#/$defs/ModelServer"
},
"default": []
+ },
+ "relay": {
+ "description": "Relay MCP injection configuration",
+ "$ref": "#/$defs/RelayConfig",
+ "default": {
+ "auto_inject_mcp": false
+ }
+ },
+ "mcp": {
+ "description": "Model Context Protocol (MCP) server configuration",
+ "$ref": "#/$defs/McpConfig",
+ "default": {
+ "http_enabled": true,
+ "stdio_advertised": true,
+ "expose_ticket_write_tools": false,
+ "external_servers": []
+ }
+ },
+ "acp": {
+ "description": "Agent Client Protocol (ACP) agent configuration",
+ "$ref": "#/$defs/AcpConfig",
+ "default": {
+ "stdio_advertised": true,
+ "default_delegator": null,
+ "max_concurrent_sessions": 8
+ }
}
},
"required": [
@@ -199,6 +200,13 @@
"format": "uint",
"minimum": 0
},
+ "max_agents_per_repo": {
+ "description": "Maximum concurrent agents per project/repo (default: 1).\nRequires `git.use_worktrees` = true when > 1 to avoid conflicts.",
+ "type": "integer",
+ "format": "uint",
+ "minimum": 0,
+ "default": 1
+ },
"health_check_interval": {
"type": "integer",
"format": "uint64",
@@ -1044,140 +1052,6 @@
}
}
},
- "BackstageConfig": {
- "description": "Backstage integration configuration",
- "type": "object",
- "properties": {
- "enabled": {
- "description": "Whether Backstage integration is enabled",
- "type": "boolean",
- "default": true
- },
- "display": {
- "description": "Whether to show Backstage in the Connections status section",
- "type": "boolean",
- "default": false
- },
- "port": {
- "description": "Port for the Backstage server",
- "type": "integer",
- "format": "uint16",
- "minimum": 0,
- "maximum": 65535,
- "default": 7007
- },
- "auto_start": {
- "description": "Auto-start Backstage server when TUI launches",
- "type": "boolean",
- "default": false
- },
- "subpath": {
- "description": "Subdirectory within `state_path` for Backstage installation",
- "type": "string",
- "default": "backstage"
- },
- "branding_subpath": {
- "description": "Subdirectory within backstage path for branding customization",
- "type": "string",
- "default": "branding"
- },
- "release_url": {
- "description": "Base URL for downloading backstage-server binary",
- "type": "string",
- "default": "https://github.com/untra/operator/releases/latest/download"
- },
- "local_binary_path": {
- "description": "Optional local path to backstage-server binary\nIf set, this is used instead of downloading from `release_url`",
- "type": [
- "string",
- "null"
- ],
- "default": null
- },
- "branding": {
- "description": "Branding and theming configuration",
- "$ref": "#/$defs/BrandingConfig",
- "default": {
- "app_title": "Operator Portal",
- "org_name": "Operator",
- "logo_path": "logo.svg",
- "colors": {
- "primary": "#cc6c55",
- "secondary": "#114145",
- "accent": "#f4dbb7",
- "warning": "#d46048",
- "muted": "#8a4a3a"
- }
- }
- }
- }
- },
- "BrandingConfig": {
- "description": "Branding configuration for Backstage portal",
- "type": "object",
- "properties": {
- "app_title": {
- "description": "App title shown in header",
- "type": "string",
- "default": "Operator Portal"
- },
- "org_name": {
- "description": "Organization name",
- "type": "string",
- "default": "Operator"
- },
- "logo_path": {
- "description": "Path to logo SVG (relative to branding path)",
- "type": [
- "string",
- "null"
- ],
- "default": null
- },
- "colors": {
- "description": "Theme colors (uses Operator defaults if not set)",
- "$ref": "#/$defs/ThemeColors",
- "default": {
- "primary": "#cc6c55",
- "secondary": "#114145",
- "accent": "#f4dbb7",
- "warning": "#d46048",
- "muted": "#8a4a3a"
- }
- }
- }
- },
- "ThemeColors": {
- "description": "Theme color configuration for Backstage\nDefault colors match Operator's tmux theme",
- "type": "object",
- "properties": {
- "primary": {
- "description": "Primary/accent color (default: salmon #cc6c55)",
- "type": "string",
- "default": "#cc6c55"
- },
- "secondary": {
- "description": "Secondary color (default: dark teal #114145)",
- "type": "string",
- "default": "#114145"
- },
- "accent": {
- "description": "Accent/highlight color (default: cream #f4dbb7)",
- "type": "string",
- "default": "#f4dbb7"
- },
- "warning": {
- "description": "Warning/error color (default: coral #d46048)",
- "type": "string",
- "default": "#d46048"
- },
- "muted": {
- "description": "Muted text color (default: darker salmon #8a4a3a)",
- "type": "string",
- "default": "#8a4a3a"
- }
- }
- },
"RestApiConfig": {
"description": "REST API server configuration",
"type": "object",
@@ -1405,6 +1279,11 @@
"type": "string"
},
"default": {}
+ },
+ "bidirectional": {
+ "description": "When true, operator pushes status changes and activity logs back to this kanban project.\nTicket state changes (todo→doing, doing→done) and step completions with delegator info\nare reflected upstream. Default: false.",
+ "type": "boolean",
+ "default": false
}
}
},
@@ -1605,6 +1484,14 @@
"null"
],
"default": null
+ },
+ "operator_relay": {
+ "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)",
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "default": null
}
}
},
@@ -1657,6 +1544,121 @@
"name",
"kind"
]
+ },
+ "RelayConfig": {
+ "description": "Relay MCP injection configuration",
+ "type": "object",
+ "properties": {
+ "auto_inject_mcp": {
+ "description": "When true, automatically inject the relay MCP server for all delegators.\nWhen false (default), relay injection is opt-in per delegator.",
+ "type": "boolean",
+ "default": false
+ }
+ }
+ },
+ "McpConfig": {
+ "description": "Model Context Protocol (MCP) server configuration",
+ "type": "object",
+ "properties": {
+ "http_enabled": {
+ "description": "Whether to mount MCP HTTP/SSE endpoints on the REST API server.\nToggling requires an API restart (no hot-swap of the axum router).",
+ "type": "boolean",
+ "default": true
+ },
+ "stdio_advertised": {
+ "description": "Whether the descriptor endpoint advertises the `operator mcp` stdio\ncommand. Set to false on multi-tenant/remote deployments where clients\nshouldn't spawn local subprocesses.",
+ "type": "boolean",
+ "default": true
+ },
+ "expose_ticket_write_tools": {
+ "description": "Whether to expose ticket-mutating tools (claim, complete, return-to-queue,\ncreate) over MCP. Defaults to `false` because any MCP client can call them.",
+ "type": "boolean",
+ "default": false
+ },
+ "external_servers": {
+ "description": "External MCP servers to inject into spawned agent sessions.\nEach entry produces a separate `--mcp-config` file alongside the\nrelay config when launching Claude Code agents.",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/ExternalMcpServer"
+ },
+ "default": []
+ }
+ },
+ "additionalProperties": false
+ },
+ "ExternalMcpServer": {
+ "description": "An external MCP server to inject into spawned agent sessions.\n\nValues in `command`, `args`, and `env` support `${VAR}` interpolation,\nexpanded at spawn time from the operator process environment.\n\nWhen `discover_from` is set, operator reads an MCP server spec from that\nJSON sidecar file at spawn time. The sidecar must contain a top-level\n`mcpServer` object with `command`, `args`, and `env` fields. If the file\nis absent and `command` is empty, the server is silently skipped.",
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "Server name used as the key in the `mcpServers` JSON object\n(e.g., \"kanbots\"). Must be unique across all external servers.",
+ "type": "string"
+ },
+ "command": {
+ "description": "Command to execute. Supports `${VAR}` interpolation.",
+ "type": "string",
+ "default": ""
+ },
+ "args": {
+ "description": "Command arguments. Each element supports `${VAR}` interpolation.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": []
+ },
+ "env": {
+ "description": "Environment variables passed to the MCP server process.\nValues support `${VAR}` interpolation.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "default": {}
+ },
+ "enabled": {
+ "description": "Whether this server is enabled. Allows disabling without removing config.",
+ "type": "boolean",
+ "default": true
+ },
+ "discover_from": {
+ "description": "Path to a JSON sidecar discovery file. Relative paths resolve from\nthe project directory. The sidecar must contain `{ \"mcpServer\": { ... } }`.\nWhen the file exists, its `mcpServer` spec is used verbatim (overriding\n`command`/`args`/`env`). When absent and `command` is empty, the server\nis silently skipped.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "AcpConfig": {
+ "description": "Agent Client Protocol (ACP) agent configuration.\n\nOperator runs as an ACP agent over stdio when editors (Zed, `JetBrains`,\nEmacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session\nmaps to an in-progress ACP ticket and a delegator subprocess.",
+ "type": "object",
+ "properties": {
+ "stdio_advertised": {
+ "description": "Whether the dashboard advertises the `operator acp` stdio entrypoint\n(and editor-config snippet actions). Set to false on machines that\nshouldn't be used as ACP agents.",
+ "type": "boolean",
+ "default": true
+ },
+ "default_delegator": {
+ "description": "Name of the delegator (from `[[delegators]]`) to use for ACP prompts.\nIf unset or not found, falls back to the operator's default delegator\nresolution.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "max_concurrent_sessions": {
+ "description": "Maximum number of concurrent ACP sessions. New `session/new` requests\nbeyond this limit are rejected with a JSON-RPC error.",
+ "type": "integer",
+ "format": "uint",
+ "minimum": 0,
+ "default": 8
+ }
+ },
+ "additionalProperties": false
}
}
}
\ No newline at end of file
diff --git a/docs/schemas/config.md b/docs/schemas/config.md
index 7ff5b7f..c7d97c1 100644
--- a/docs/schemas/config.md
+++ b/docs/schemas/config.md
@@ -42,13 +42,15 @@ JSON Schema for the Operator configuration file (`config.toml`).
| `tmux` | → `TmuxConfig` | No | |
| `sessions` | → `SessionsConfig` | No | Session wrapper configuration (tmux, vscode, or cmux) |
| `llm_tools` | → `LlmToolsConfig` | No | |
-| `backstage` | → `BackstageConfig` | No | |
| `rest_api` | → `RestApiConfig` | No | |
| `git` | → `GitConfig` | No | |
| `kanban` | → `KanbanConfig` | No | Kanban provider configuration for syncing issues from Jira, Linear, etc. |
| `version_check` | → `VersionCheckConfig` | No | Version check configuration for automatic update notifications |
| `delegators` | `array` | No | Agent delegator configurations for autonomous ticket launching |
| `model_servers` | `array` | No | User-declared model servers (ollama, lmstudio, any OpenAI-compat host). Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. |
+| `relay` | → `RelayConfig` | No | Relay MCP injection configuration |
+| `mcp` | → `McpConfig` | No | Model Context Protocol (MCP) server configuration |
+| `acp` | → `AcpConfig` | No | Agent Client Protocol (ACP) agent configuration |
## Type Definitions
@@ -58,6 +60,7 @@ JSON Schema for the Operator configuration file (`config.toml`).
| --- | --- | --- | --- |
| `max_parallel` | `integer` | Yes | |
| `cores_reserved` | `integer` | Yes | |
+| `max_agents_per_repo` | `integer` | No | Maximum concurrent agents per project/repo (default: 1). Requires `git.use_worktrees` = true when > 1 to avoid conflicts. |
| `health_check_interval` | `integer` | Yes | |
| `generation_timeout_secs` | `integer` | No | Timeout in seconds for each agent generation (default: 300 = 5 min) |
| `sync_interval` | `integer` | No | Interval in seconds between ticket-session syncs (default: 60) |
@@ -349,46 +352,6 @@ Per-tool skill directory overrides
| `global` | `array` | No | Additional global skill directories |
| `project` | `array` | No | Additional project-relative skill directories |
-### BackstageConfig
-
-Backstage integration configuration
-
-| Property | Type | Required | Description |
-| --- | --- | --- | --- |
-| `enabled` | `boolean` | No | Whether Backstage integration is enabled |
-| `display` | `boolean` | No | Whether to show Backstage in the Connections status section |
-| `port` | `integer` | No | Port for the Backstage server |
-| `auto_start` | `boolean` | No | Auto-start Backstage server when TUI launches |
-| `subpath` | `string` | No | Subdirectory within `state_path` for Backstage installation |
-| `branding_subpath` | `string` | No | Subdirectory within backstage path for branding customization |
-| `release_url` | `string` | No | Base URL for downloading backstage-server binary |
-| `local_binary_path` | `string` \| `null` | No | Optional local path to backstage-server binary If set, this is used instead of downloading from `release_url` |
-| `branding` | → `BrandingConfig` | No | Branding and theming configuration |
-
-### BrandingConfig
-
-Branding configuration for Backstage portal
-
-| Property | Type | Required | Description |
-| --- | --- | --- | --- |
-| `app_title` | `string` | No | App title shown in header |
-| `org_name` | `string` | No | Organization name |
-| `logo_path` | `string` \| `null` | No | Path to logo SVG (relative to branding path) |
-| `colors` | → `ThemeColors` | No | Theme colors (uses Operator defaults if not set) |
-
-### ThemeColors
-
-Theme color configuration for Backstage
-Default colors match Operator's tmux theme
-
-| Property | Type | Required | Description |
-| --- | --- | --- | --- |
-| `primary` | `string` | No | Primary/accent color (default: salmon #cc6c55) |
-| `secondary` | `string` | No | Secondary color (default: dark teal #114145) |
-| `accent` | `string` | No | Accent/highlight color (default: cream #f4dbb7) |
-| `warning` | `string` | No | Warning/error color (default: coral #d46048) |
-| `muted` | `string` | No | Muted text color (default: darker salmon #8a4a3a) |
-
### RestApiConfig
REST API server configuration
@@ -479,6 +442,7 @@ Per-project/team sync configuration for a kanban provider
| `sync_statuses` | `array` | No | Workflow statuses to sync (empty = default/first status only) |
| `collection_name` | `string` \| `null` | No | Optional `IssueTypeCollection` name this project maps to. Not required for kanban onboarding or sync. |
| `type_mappings` | `object` | No | Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). Multiple kanban types can map to the same operator template. |
+| `bidirectional` | `boolean` | No | When true, operator pushes status changes and activity logs back to this kanban project. Ticket state changes (todo→doing, doing→done) and step completions with delegator info are reflected upstream. Default: false. |
### LinearConfig
@@ -557,6 +521,7 @@ semantics: `None` = inherit from global config, `Some(true/false)` = override.
| `docker` | `boolean` \| `null` | No | Run in docker container (None = use global `launch.docker.enabled`) |
| `prompt_prefix` | `string` \| `null` | No | Prompt text to prepend before the generated step prompt |
| `prompt_suffix` | `string` \| `null` | No | Prompt text to append after the generated step prompt |
+| `operator_relay` | `boolean` \| `null` | No | Override global relay auto-inject MCP setting per-delegator (None = use global setting) |
### ModelServer
@@ -579,3 +544,57 @@ in config.
| `extra_env` | `object` | No | Additional environment variables set when spawning agents that use this server |
| `display_name` | `string` \| `null` | No | Optional display name for UI |
+### RelayConfig
+
+Relay MCP injection configuration
+
+| Property | Type | Required | Description |
+| --- | --- | --- | --- |
+| `auto_inject_mcp` | `boolean` | No | When true, automatically inject the relay MCP server for all delegators. When false (default), relay injection is opt-in per delegator. |
+
+### McpConfig
+
+Model Context Protocol (MCP) server configuration
+
+| Property | Type | Required | Description |
+| --- | --- | --- | --- |
+| `http_enabled` | `boolean` | No | Whether to mount MCP HTTP/SSE endpoints on the REST API server. Toggling requires an API restart (no hot-swap of the axum router). |
+| `stdio_advertised` | `boolean` | No | Whether the descriptor endpoint advertises the `operator mcp` stdio command. Set to false on multi-tenant/remote deployments where clients shouldn't spawn local subprocesses. |
+| `expose_ticket_write_tools` | `boolean` | No | Whether to expose ticket-mutating tools (claim, complete, return-to-queue, create) over MCP. Defaults to `false` because any MCP client can call them. |
+| `external_servers` | `array` | No | External MCP servers to inject into spawned agent sessions. Each entry produces a separate `--mcp-config` file alongside the relay config when launching Claude Code agents. |
+
+### ExternalMcpServer
+
+An external MCP server to inject into spawned agent sessions.
+
+Values in `command`, `args`, and `env` support `${VAR}` interpolation,
+expanded at spawn time from the operator process environment.
+
+When `discover_from` is set, operator reads an MCP server spec from that
+JSON sidecar file at spawn time. The sidecar must contain a top-level
+`mcpServer` object with `command`, `args`, and `env` fields. If the file
+is absent and `command` is empty, the server is silently skipped.
+
+| Property | Type | Required | Description |
+| --- | --- | --- | --- |
+| `name` | `string` | Yes | Server name used as the key in the `mcpServers` JSON object (e.g., "kanbots"). Must be unique across all external servers. |
+| `command` | `string` | No | Command to execute. Supports `${VAR}` interpolation. |
+| `args` | `array` | No | Command arguments. Each element supports `${VAR}` interpolation. |
+| `env` | `object` | No | Environment variables passed to the MCP server process. Values support `${VAR}` interpolation. |
+| `enabled` | `boolean` | No | Whether this server is enabled. Allows disabling without removing config. |
+| `discover_from` | `string` \| `null` | No | Path to a JSON sidecar discovery file. Relative paths resolve from the project directory. The sidecar must contain `{ "mcpServer": { ... } }`. When the file exists, its `mcpServer` spec is used verbatim (overriding `command`/`args`/`env`). When absent and `command` is empty, the server is silently skipped. |
+
+### AcpConfig
+
+Agent Client Protocol (ACP) agent configuration.
+
+Operator runs as an ACP agent over stdio when editors (Zed, `JetBrains`,
+Emacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session
+maps to an in-progress ACP ticket and a delegator subprocess.
+
+| Property | Type | Required | Description |
+| --- | --- | --- | --- |
+| `stdio_advertised` | `boolean` | No | Whether the dashboard advertises the `operator acp` stdio entrypoint (and editor-config snippet actions). Set to false on machines that shouldn't be used as ACP agents. |
+| `default_delegator` | `string` \| `null` | No | Name of the delegator (from `[[delegators]]`) to use for ACP prompts. If unset or not found, falls back to the operator's default delegator resolution. |
+| `max_concurrent_sessions` | `integer` | No | Maximum number of concurrent ACP sessions. New `session/new` requests beyond this limit are rejected with a JSON-RPC error. |
+
diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json
index 1f3f22e..23d7a4a 100644
--- a/docs/schemas/openapi.json
+++ b/docs/schemas/openapi.json
@@ -10,16 +10,156 @@
"license": {
"name": "MIT"
},
- "version": "0.1.30"
+ "version": "0.2.0"
},
"paths": {
+ "/api/v1/agents/active": {
+ "get": {
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Get all active agents",
+ "description": "Returns a list of all currently running agents with their status and details.",
+ "operationId": "agents_active",
+ "responses": {
+ "200": {
+ "description": "Active agents list",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ActiveAgentsResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/agents/{agent_id}": {
+ "get": {
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Get details for a single agent by ID",
+ "description": "Returns full details for a specific agent, including all tracked state.",
+ "operationId": "agents_get_detail",
+ "parameters": [
+ {
+ "name": "agent_id",
+ "in": "path",
+ "description": "The agent ID to look up",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Agent details",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AgentDetailResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Agent not found"
+ }
+ }
+ }
+ },
+ "/api/v1/agents/{agent_id}/approve": {
+ "post": {
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Approve an agent's pending review",
+ "description": "Clears the review state and signals the agent to continue.\nThe agent must be in `awaiting_input` status with a pending review.",
+ "operationId": "agents_approve_review",
+ "parameters": [
+ {
+ "name": "agent_id",
+ "in": "path",
+ "description": "The agent ID to approve",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Review approved",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReviewResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Agent not found"
+ }
+ }
+ }
+ },
+ "/api/v1/agents/{agent_id}/reject": {
+ "post": {
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Reject an agent's pending review",
+ "description": "Signals the agent that the review was rejected with feedback.\nThe agent should re-do the work based on the rejection reason.",
+ "operationId": "agents_reject_review",
+ "parameters": [
+ {
+ "name": "agent_id",
+ "in": "path",
+ "description": "The agent ID to reject",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RejectReviewRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Review rejected",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReviewResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Agent not found"
+ }
+ }
+ }
+ },
"/api/v1/collections": {
"get": {
"tags": [
"Collections"
],
"summary": "List all collections",
- "operationId": "list",
+ "operationId": "collections_list",
"responses": {
"200": {
"description": "List of all collections",
@@ -43,7 +183,7 @@
"Collections"
],
"summary": "Get the currently active collection",
- "operationId": "get_active",
+ "operationId": "collections_get_active",
"responses": {
"200": {
"description": "Active collection",
@@ -74,7 +214,7 @@
"Collections"
],
"summary": "Get a single collection by name",
- "operationId": "get_one",
+ "operationId": "collections_get_one",
"parameters": [
{
"name": "name",
@@ -116,7 +256,7 @@
"Collections"
],
"summary": "Activate a collection",
- "operationId": "activate",
+ "operationId": "collections_activate",
"parameters": [
{
"name": "name",
@@ -152,13 +292,61 @@
}
}
},
+ "/api/v1/configuration": {
+ "get": {
+ "tags": [
+ "Configuration"
+ ],
+ "summary": "Get the current configuration",
+ "description": "Returns the full operator configuration as a JSON object. The body is left\nopaque in the OpenAPI spec because the `Config` tree is large and no client\nconsumes its OpenAPI schema (the TS `Config` type is generated separately by\nts-rs).",
+ "operationId": "configuration_get",
+ "responses": {
+ "200": {
+ "description": "Current configuration as a JSON object",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "Configuration"
+ ],
+ "summary": "Update configuration and save to disk",
+ "operationId": "configuration_update",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Updated configuration as a JSON object",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "500": {
+ "description": "Failed to save configuration"
+ }
+ }
+ }
+ },
"/api/v1/delegators": {
"get": {
"tags": [
"Delegators"
],
"summary": "List all configured delegators",
- "operationId": "list",
+ "operationId": "delegators_list",
"responses": {
"200": {
"description": "List of delegators",
@@ -177,7 +365,7 @@
"Delegators"
],
"summary": "Create a new delegator",
- "operationId": "create",
+ "operationId": "delegators_create",
"requestBody": {
"content": {
"application/json": {
@@ -212,7 +400,7 @@
],
"summary": "Create a delegator from a detected LLM tool",
"description": "Pre-populates delegator fields from the detected tool, requiring minimal input.",
- "operationId": "create_from_tool",
+ "operationId": "delegators_create_from_tool",
"requestBody": {
"content": {
"application/json": {
@@ -249,7 +437,7 @@
"Delegators"
],
"summary": "Get a single delegator by name",
- "operationId": "get_one",
+ "operationId": "delegators_get_one",
"parameters": [
{
"name": "name",
@@ -282,7 +470,7 @@
"Delegators"
],
"summary": "Update an existing delegator",
- "operationId": "update",
+ "operationId": "delegators_update",
"parameters": [
{
"name": "name",
@@ -325,7 +513,7 @@
"Delegators"
],
"summary": "Delete a delegator by name",
- "operationId": "delete",
+ "operationId": "delegators_delete",
"parameters": [
{
"name": "name",
@@ -360,7 +548,7 @@
"Health"
],
"summary": "Health check endpoint",
- "operationId": "health",
+ "operationId": "health_check",
"responses": {
"200": {
"description": "Service is healthy",
@@ -381,7 +569,7 @@
"Issue Types"
],
"summary": "List all issue types",
- "operationId": "list",
+ "operationId": "issuetypes_list",
"responses": {
"200": {
"description": "List of all issue types",
@@ -403,7 +591,7 @@
"Issue Types"
],
"summary": "Create a new issue type",
- "operationId": "create",
+ "operationId": "issuetypes_create",
"requestBody": {
"content": {
"application/json": {
@@ -454,7 +642,7 @@
"Issue Types"
],
"summary": "Get a single issue type by key",
- "operationId": "get_one",
+ "operationId": "issuetypes_get_one",
"parameters": [
{
"name": "key",
@@ -494,7 +682,7 @@
"Issue Types"
],
"summary": "Update an existing issue type",
- "operationId": "update",
+ "operationId": "issuetypes_update",
"parameters": [
{
"name": "key",
@@ -564,7 +752,7 @@
"Issue Types"
],
"summary": "Delete an issue type",
- "operationId": "delete",
+ "operationId": "issuetypes_delete",
"parameters": [
{
"name": "key",
@@ -609,7 +797,7 @@
"Steps"
],
"summary": "List all steps for an issue type",
- "operationId": "list",
+ "operationId": "steps_list",
"parameters": [
{
"name": "key",
@@ -654,7 +842,7 @@
"Steps"
],
"summary": "Get a single step by name",
- "operationId": "get_one",
+ "operationId": "steps_get_one",
"parameters": [
{
"name": "key",
@@ -703,7 +891,7 @@
"Steps"
],
"summary": "Update a step",
- "operationId": "update",
+ "operationId": "steps_update",
"parameters": [
{
"name": "key",
@@ -778,58 +966,51 @@
}
}
},
- "/api/v1/llm-tools": {
- "get": {
+ "/api/v1/kanban/config": {
+ "put": {
"tags": [
- "LLM Tools"
+ "Kanban"
],
- "summary": "List detected LLM tools with model aliases",
- "operationId": "list",
- "responses": {
- "200": {
- "description": "List of detected LLM tools",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/LlmToolsResponse"
- }
+ "summary": "PUT /`api/v1/kanban/config`",
+ "description": "Write or upsert a kanban provider+project section into `config.toml`.\nDoes NOT receive the actual secret — only the env var name (`api_key_env`).",
+ "operationId": "kanban_write_config",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WriteKanbanConfigRequest"
}
}
- }
- }
- }
- },
- "/api/v1/llm-tools/default": {
- "get": {
- "tags": [
- "LLM Tools"
- ],
- "summary": "Get the current default LLM tool and model",
- "operationId": "get_default",
+ },
+ "required": true
+ },
"responses": {
"200": {
- "description": "Current default LLM",
+ "description": "Config section written/upserted",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/DefaultLlmResponse"
+ "$ref": "#/components/schemas/WriteKanbanConfigResponse"
}
}
}
}
}
- },
- "put": {
+ }
+ },
+ "/api/v1/kanban/projects": {
+ "post": {
"tags": [
- "LLM Tools"
+ "Kanban"
],
- "summary": "Set the global default LLM tool and model",
- "operationId": "set_default",
+ "summary": "POST /`api/v1/kanban/projects`",
+ "description": "List available projects/teams for the given provider using ephemeral\ncredentials. No persistence side effects.",
+ "operationId": "kanban_list_projects",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SetDefaultLlmRequest"
+ "$ref": "#/components/schemas/ListKanbanProjectsRequest"
}
}
},
@@ -837,74 +1018,63 @@
},
"responses": {
"200": {
- "description": "Default LLM set",
+ "description": "Available projects/teams for the provider",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/DefaultLlmResponse"
+ "$ref": "#/components/schemas/ListKanbanProjectsResponse"
}
}
}
- },
- "404": {
- "description": "Tool not detected"
}
}
}
},
- "/api/v1/mcp/descriptor": {
- "get": {
+ "/api/v1/kanban/session-env": {
+ "post": {
"tags": [
- "MCP"
+ "Kanban"
],
- "summary": "MCP descriptor endpoint",
- "description": "Returns metadata for building a VS Code MCP deep link.\nThe transport URL is derived from the request Host header\nso it reflects the actual running port.",
- "operationId": "descriptor",
- "responses": {
- "200": {
- "description": "MCP server descriptor",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/McpDescriptorResponse"
- }
+ "summary": "POST /`api/v1/kanban/session-env`",
+ "description": "Set kanban env vars on the server process for the current session so\nsubsequent `from_config()` calls find the API key. Returns a\n`shell_export_block` with placeholder values for the client to display.",
+ "operationId": "kanban_set_session_env",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SetKanbanSessionEnvRequest"
}
}
- }
- }
- }
- },
- "/api/v1/model-servers": {
- "get": {
- "tags": [
- "ModelServers"
- ],
- "summary": "List all model servers (user-declared + implicit builtins)",
- "operationId": "list",
+ },
+ "required": true
+ },
"responses": {
"200": {
- "description": "List of model servers",
+ "description": "Session env vars set; returns a shell export block",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ModelServersResponse"
+ "$ref": "#/components/schemas/SetKanbanSessionEnvResponse"
}
}
}
}
}
- },
+ }
+ },
+ "/api/v1/kanban/validate": {
"post": {
"tags": [
- "ModelServers"
+ "Kanban"
],
- "summary": "Create a new model server",
- "operationId": "create",
+ "summary": "POST /`api/v1/kanban/validate`",
+ "description": "Validate credentials against the live provider API without persisting\nanything. Auth failures return `valid: false` with an `error` string\nrather than a 4xx/5xx status so clients can display errors inline.",
+ "operationId": "kanban_validate_credentials",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateModelServerRequest"
+ "$ref": "#/components/schemas/ValidateKanbanCredentialsRequest"
}
}
},
@@ -912,33 +1082,40 @@
},
"responses": {
"200": {
- "description": "Model server created",
+ "description": "Validation result (valid flag + optional error)",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ModelServerResponse"
+ "$ref": "#/components/schemas/ValidateKanbanCredentialsResponse"
}
}
}
- },
- "409": {
- "description": "Model server already exists"
}
}
}
},
- "/api/v1/model-servers/{name}": {
+ "/api/v1/kanban/{provider}/{project_key}/issuetypes": {
"get": {
"tags": [
- "ModelServers"
+ "Kanban"
],
- "summary": "Get a single model server by name",
- "operationId": "get_one",
+ "summary": "GET /`api/v1/kanban/:provider/:project_key/issuetypes`",
+ "description": "Returns kanban issue types from the persisted catalog for a given provider/project.\nFalls back to fetching live from the provider if no catalog exists.",
+ "operationId": "kanban_external_issue_types",
"parameters": [
{
- "name": "name",
+ "name": "provider",
"in": "path",
- "description": "Model server name",
+ "description": "Kanban provider name (e.g. jira, linear, github)",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "project_key",
+ "in": "path",
+ "description": "Provider project/team key",
"required": true,
"schema": {
"type": "string"
@@ -947,72 +1124,89 @@
],
"responses": {
"200": {
- "description": "Model server details",
+ "description": "External issue types",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ModelServerResponse"
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExternalIssueTypeSummary"
+ }
}
}
}
},
- "404": {
- "description": "Model server not found"
+ "400": {
+ "description": "Unknown provider/project"
+ },
+ "500": {
+ "description": "Failed to read catalog or fetch from provider"
}
}
- },
- "delete": {
+ }
+ },
+ "/api/v1/kanban/{provider}/{project_key}/issuetypes/sync": {
+ "post": {
"tags": [
- "ModelServers"
+ "Kanban"
],
- "summary": "Delete a user-declared model server by name",
- "description": "Implicit builtin servers cannot be deleted.",
- "operationId": "delete",
+ "summary": "POST /`api/v1/kanban/:provider/:project_key/issuetypes/sync`",
+ "description": "Refreshes the local kanban issue type catalog from the provider.",
+ "operationId": "kanban_sync_issue_types",
"parameters": [
{
- "name": "name",
+ "name": "provider",
"in": "path",
- "description": "Model server name",
+ "description": "Kanban provider name (e.g. jira, linear, github)",
"required": true,
"schema": {
"type": "string"
}
- }
- ],
+ },
+ {
+ "name": "project_key",
+ "in": "path",
+ "description": "Provider project/team key",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
- "description": "Model server deleted",
+ "description": "Synced issue types",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ModelServerResponse"
+ "$ref": "#/components/schemas/SyncKanbanIssueTypesResponse"
}
}
}
},
- "404": {
- "description": "Model server not found"
+ "400": {
+ "description": "Unknown provider/project"
},
- "409": {
- "description": "Cannot delete implicit builtin server"
+ "500": {
+ "description": "Failed to sync from provider"
}
}
}
},
- "/api/v1/skills": {
+ "/api/v1/llm-tools": {
"get": {
"tags": [
- "Skills"
+ "LLM Tools"
],
- "summary": "List all discovered skills across LLM tools",
- "operationId": "list",
+ "summary": "List detected LLM tools with model aliases",
+ "operationId": "llm_tools_list",
"responses": {
"200": {
- "description": "List of discovered skill files",
+ "description": "List of detected LLM tools",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/SkillsResponse"
+ "$ref": "#/components/schemas/LlmToolsResponse"
}
}
}
@@ -1020,51 +1214,37 @@
}
}
},
- "/api/v1/status": {
+ "/api/v1/llm-tools/default": {
"get": {
"tags": [
- "Health"
+ "LLM Tools"
],
- "summary": "Get service status with registry info",
- "operationId": "status",
+ "summary": "Get the current default LLM tool and model",
+ "operationId": "llm_tools_get_default",
"responses": {
"200": {
- "description": "Service status with registry info",
+ "description": "Current default LLM",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/StatusResponse"
+ "$ref": "#/components/schemas/DefaultLlmResponse"
}
}
}
}
}
- }
- },
- "/api/v1/tickets/{id}/launch": {
- "post": {
+ },
+ "put": {
"tags": [
- "Launch"
- ],
- "summary": "Launch a ticket from the queue",
- "description": "Claims the ticket, sets up worktree if needed, generates the LLM command,\nand returns all details needed to execute in an external terminal (VS Code, etc.).",
- "operationId": "launch_ticket",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "description": "Ticket ID to launch",
- "required": true,
- "schema": {
- "type": "string"
- }
- }
+ "LLM Tools"
],
+ "summary": "Set the global default LLM tool and model",
+ "operationId": "llm_tools_set_default",
"requestBody": {
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/LaunchTicketRequest"
+ "$ref": "#/components/schemas/SetDefaultLlmRequest"
}
}
},
@@ -1072,519 +1252,2358 @@
},
"responses": {
"200": {
- "description": "Ticket launched successfully",
+ "description": "Default LLM set",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/LaunchTicketResponse"
+ "$ref": "#/components/schemas/DefaultLlmResponse"
}
}
}
},
- "400": {
- "description": "Invalid request"
- },
"404": {
- "description": "Ticket not found"
- },
- "409": {
- "description": "Ticket already in progress"
+ "description": "Tool not detected"
}
}
}
- }
- },
- "components": {
- "schemas": {
- "CollectionResponse": {
- "type": "object",
- "description": "Response for a collection",
- "required": [
- "name",
- "description",
- "types",
- "is_active"
+ },
+ "/api/v1/mcp/descriptor": {
+ "get": {
+ "tags": [
+ "MCP"
],
- "properties": {
- "description": {
- "type": "string"
- },
- "is_active": {
- "type": "boolean"
- },
- "name": {
- "type": "string"
- },
- "types": {
- "type": "array",
- "items": {
- "type": "string"
+ "summary": "MCP descriptor endpoint",
+ "description": "Returns metadata for registering operator with an MCP-capable client.\nThe transport URL is derived from the request Host header so it reflects\nthe actual running port; the stdio entrypoint reflects this binary's path.",
+ "operationId": "mcp_descriptor",
+ "responses": {
+ "200": {
+ "description": "MCP server descriptor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/McpDescriptorResponse"
+ }
+ }
}
}
}
- },
- "CreateDelegatorFromToolRequest": {
- "type": "object",
- "description": "Request to create a delegator from a detected LLM tool\n\nPre-populates delegator fields from the detected tool, requiring minimal input.\nIf `name` is omitted, auto-generates as `\"{tool_name}-{model}\"`.\nIf `model` is omitted, uses the tool's first model alias.",
- "required": [
- "tool_name"
+ }
+ },
+ "/api/v1/model-servers": {
+ "get": {
+ "tags": [
+ "ModelServers"
],
- "properties": {
- "display_name": {
- "type": [
- "string",
- "null"
- ],
- "description": "Optional display name for UI"
- },
- "launch_config": {
- "oneOf": [
- {
- "type": "null"
- },
- {
- "$ref": "#/components/schemas/DelegatorLaunchConfigDto",
- "description": "Optional launch configuration"
+ "summary": "List all model servers (user-declared + implicit builtins)",
+ "operationId": "model_servers_list",
+ "responses": {
+ "200": {
+ "description": "List of model servers",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ModelServersResponse"
+ }
}
- ]
- },
- "model": {
- "type": [
- "string",
- "null"
- ],
- "description": "Model alias to use (e.g., \"opus\"). If omitted, uses the tool's first model alias."
- },
- "model_server": {
- "type": [
- "string",
- "null"
- ],
- "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default."
- },
- "name": {
- "type": [
- "string",
- "null"
- ],
- "description": "Custom delegator name. If omitted, auto-generates as `\"{tool_name}-{model}\"`."
- },
- "tool_name": {
- "type": "string",
- "description": "Name of the detected tool (e.g., \"claude\", \"codex\", \"gemini\")"
+ }
}
}
},
- "CreateDelegatorRequest": {
- "type": "object",
- "description": "Request to create a new delegator",
- "required": [
- "name",
- "llm_tool",
- "model"
+ "post": {
+ "tags": [
+ "ModelServers"
],
- "properties": {
- "display_name": {
- "type": [
- "string",
- "null"
- ],
- "description": "Optional display name"
- },
- "launch_config": {
- "oneOf": [
- {
- "type": "null"
- },
- {
- "$ref": "#/components/schemas/DelegatorLaunchConfigDto",
- "description": "Optional launch configuration"
+ "summary": "Create a new model server",
+ "operationId": "model_servers_create",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateModelServerRequest"
}
- ]
- },
- "llm_tool": {
- "type": "string",
- "description": "LLM tool name (must match a detected tool)"
- },
- "model": {
- "type": "string",
- "description": "Model alias"
- },
- "model_properties": {
- "type": "object",
- "description": "Arbitrary model properties",
- "additionalProperties": {
- "type": "string"
- },
- "propertyNames": {
- "type": "string"
}
},
- "model_server": {
- "type": [
- "string",
- "null"
- ],
- "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default."
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Model server created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ModelServerResponse"
+ }
+ }
+ }
},
- "name": {
- "type": "string",
- "description": "Unique name for the delegator"
+ "409": {
+ "description": "Model server already exists"
}
}
- },
- "CreateFieldRequest": {
- "type": "object",
- "description": "Request to create a field",
- "required": [
- "name",
+ }
+ },
+ "/api/v1/model-servers/{name}": {
+ "get": {
+ "tags": [
+ "ModelServers"
+ ],
+ "summary": "Get a single model server by name",
+ "operationId": "model_servers_get_one",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "description": "Model server name",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Model server details",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ModelServerResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Model server not found"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "ModelServers"
+ ],
+ "summary": "Delete a user-declared model server by name",
+ "description": "Implicit builtin servers cannot be deleted.",
+ "operationId": "model_servers_delete",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "description": "Model server name",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Model server deleted",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ModelServerResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Model server not found"
+ },
+ "409": {
+ "description": "Cannot delete implicit builtin server"
+ }
+ }
+ }
+ },
+ "/api/v1/projects": {
+ "get": {
+ "tags": [
+ "Projects"
+ ],
+ "summary": "List all configured projects with analysis data",
+ "operationId": "projects_list",
+ "responses": {
+ "200": {
+ "description": "List of projects with analysis data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ProjectSummary"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/projects/{name}/assess": {
+ "post": {
+ "tags": [
+ "Projects"
+ ],
+ "summary": "Create an ASSESS ticket for a project",
+ "operationId": "projects_assess",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "description": "Project name",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "ASSESS ticket created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AssessTicketResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Project not found"
+ }
+ }
+ }
+ },
+ "/api/v1/queue/kanban": {
+ "get": {
+ "tags": [
+ "Queue"
+ ],
+ "summary": "Get kanban board data with tickets grouped by status column",
+ "description": "Returns tickets organized into four columns: queue, running, awaiting, done.\nTickets are sorted by priority within each column, then by timestamp (FIFO).",
+ "operationId": "queue_kanban",
+ "responses": {
+ "200": {
+ "description": "Kanban board data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/KanbanBoardResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/queue/pause": {
+ "post": {
+ "tags": [
+ "Queue"
+ ],
+ "summary": "Pause queue processing",
+ "description": "Sets the queue paused state to true, stopping automatic ticket launches.",
+ "operationId": "queue_pause",
+ "responses": {
+ "200": {
+ "description": "Queue paused successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueControlResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/queue/resume": {
+ "post": {
+ "tags": [
+ "Queue"
+ ],
+ "summary": "Resume queue processing",
+ "description": "Sets the queue paused state to false, resuming automatic ticket launches.",
+ "operationId": "queue_resume",
+ "responses": {
+ "200": {
+ "description": "Queue resumed successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueControlResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/queue/status": {
+ "get": {
+ "tags": [
+ "Queue"
+ ],
+ "summary": "Get queue status with ticket counts",
+ "description": "Returns counts of tickets in each state plus breakdown by type.",
+ "operationId": "queue_status",
+ "responses": {
+ "200": {
+ "description": "Queue status with counts",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueStatusResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/queue/sync": {
+ "post": {
+ "tags": [
+ "Queue"
+ ],
+ "summary": "Sync kanban collections",
+ "description": "Fetches issues from configured external kanban providers (Jira, Linear, etc.)\nand creates local tickets in the queue.",
+ "operationId": "queue_sync",
+ "responses": {
+ "200": {
+ "description": "Kanban sync completed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/KanbanSyncResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/queue/sync/{provider}/{project_key}": {
+ "post": {
+ "tags": [
+ "Queue"
+ ],
+ "summary": "Sync a specific kanban collection",
+ "description": "Fetches issues from a single provider/project combination and creates\nlocal tickets in the queue.",
+ "operationId": "queue_sync_collection",
+ "parameters": [
+ {
+ "name": "provider",
+ "in": "path",
+ "description": "Provider name (jira or linear)",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "project_key",
+ "in": "path",
+ "description": "Project/team key",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Collection sync completed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/KanbanSyncResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/sections": {
+ "get": {
+ "tags": [
+ "Status"
+ ],
+ "summary": "List the canonical status sections with health + child rows.",
+ "description": "Returns all sections (with a `met` flag) rather than hiding unmet ones, so\nthe web UI can render every section and style locked ones. The section logic\nis injected by the binary; without a provider (lib-only/test) this is empty.",
+ "operationId": "sections_list",
+ "responses": {
+ "200": {
+ "description": "Canonical status sections",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SectionDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/skills": {
+ "get": {
+ "tags": [
+ "Skills"
+ ],
+ "summary": "List all discovered skills across LLM tools",
+ "operationId": "skills_list",
+ "responses": {
+ "200": {
+ "description": "List of discovered skill files",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SkillsResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/status": {
+ "get": {
+ "tags": [
+ "Health"
+ ],
+ "summary": "Get service status with registry info",
+ "operationId": "health_status",
+ "responses": {
+ "200": {
+ "description": "Service status with registry info",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StatusResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/tickets/{id}": {
+ "get": {
+ "tags": [
+ "Tickets"
+ ],
+ "summary": "Get full ticket details by ID",
+ "description": "Returns complete ticket data including content, metadata, step history,\nand session information. Searches queue, in-progress, and completed directories.",
+ "operationId": "tickets_get_one",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Ticket ID (e.g., FEAT-7598)",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Ticket details",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TicketDetailResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Ticket not found"
+ }
+ }
+ }
+ },
+ "/api/v1/tickets/{id}/launch": {
+ "post": {
+ "tags": [
+ "Launch"
+ ],
+ "summary": "Launch a ticket from the queue",
+ "description": "Claims the ticket, sets up worktree if needed, generates the LLM command,\nand returns all details needed to execute in an external terminal (VS Code, etc.).",
+ "operationId": "launch_launch_ticket",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Ticket ID to launch",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LaunchTicketRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Ticket launched successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LaunchTicketResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request"
+ },
+ "404": {
+ "description": "Ticket not found"
+ },
+ "409": {
+ "description": "Ticket already in progress"
+ }
+ }
+ }
+ },
+ "/api/v1/tickets/{id}/status": {
+ "put": {
+ "tags": [
+ "Tickets"
+ ],
+ "summary": "Update a ticket's status",
+ "description": "Moves a ticket between queue directories based on the target status.\nValid transitions: queued, running, awaiting, done.",
+ "operationId": "tickets_update_status",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Ticket ID to update",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateTicketStatusRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Ticket status updated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateTicketStatusResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid status value"
+ },
+ "404": {
+ "description": "Ticket not found"
+ }
+ }
+ }
+ },
+ "/api/v1/tickets/{id}/steps/{step}/complete": {
+ "post": {
+ "tags": [
+ "Launch"
+ ],
+ "summary": "Report step completion from opr8r wrapper",
+ "description": "Called by the opr8r wrapper when an LLM command completes.\nReturns next step info and whether to auto-proceed.",
+ "operationId": "launch_complete_step",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Ticket ID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "step",
+ "in": "path",
+ "description": "Step name that completed",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StepCompleteRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Step completion recorded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StepCompleteResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request"
+ },
+ "404": {
+ "description": "Ticket not found"
+ }
+ }
+ }
+ },
+ "/api/v1/tickets/{id}/workflow-export": {
+ "post": {
+ "tags": [
+ "Workflow"
+ ],
+ "summary": "Export a ticket to a Claude dynamic workflow.",
+ "description": "Resolves the ticket (searching queue, in-progress, and completed), looks up\nits issue type in the registry, and returns the rendered `.js` plus a\nsuggested filename.",
+ "operationId": "workflow_export",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Ticket ID (e.g., FEAT-7598)",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Generated workflow",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowExportResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Ticket or issue type not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "ActiveAgentResponse": {
+ "type": "object",
+ "description": "A single active agent",
+ "required": [
+ "id",
+ "ticket_id",
+ "ticket_type",
+ "project",
+ "status",
+ "mode",
+ "started_at"
+ ],
+ "properties": {
+ "current_step": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Current workflow step"
+ },
+ "id": {
+ "type": "string",
+ "description": "Agent ID (e.g., \"op-gamesvc-001\")"
+ },
+ "mode": {
+ "type": "string",
+ "description": "Execution mode: autonomous, paired"
+ },
+ "project": {
+ "type": "string",
+ "description": "Project being worked on"
+ },
+ "session_context_ref": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Session context reference (e.g. cmux workspace, zellij session)"
+ },
+ "session_pane_ref": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Session pane reference (e.g. cmux surface, zellij pane)"
+ },
+ "session_window_ref": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Session window reference ID (e.g. cmux window, tmux session)"
+ },
+ "session_wrapper": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Which session wrapper is in use: \"tmux\", \"vscode\", \"cmux\", or \"zellij\""
+ },
+ "started_at": {
+ "type": "string",
+ "description": "When the agent started (ISO 8601)"
+ },
+ "status": {
+ "type": "string",
+ "description": "Agent status: running, `awaiting_input`, completing"
+ },
+ "ticket_id": {
+ "type": "string",
+ "description": "Associated ticket ID (e.g., \"FEAT-042\")"
+ },
+ "ticket_type": {
+ "type": "string",
+ "description": "Ticket type: FEAT, FIX, INV, SPIKE"
+ }
+ }
+ },
+ "ActiveAgentsResponse": {
+ "type": "object",
+ "description": "Response for active agents list",
+ "required": [
+ "agents",
+ "count"
+ ],
+ "properties": {
+ "agents": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ActiveAgentResponse"
+ },
+ "description": "List of active agents"
+ },
+ "count": {
+ "type": "integer",
+ "description": "Total count of active agents",
+ "minimum": 0
+ }
+ }
+ },
+ "AgentDetailResponse": {
+ "type": "object",
+ "description": "Full details for a single agent",
+ "required": [
+ "id",
+ "ticket_id",
+ "ticket_type",
+ "project",
+ "status",
+ "started_at",
+ "last_activity",
+ "completed_steps",
+ "paired"
+ ],
+ "properties": {
+ "completed_steps": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Completed steps for this ticket"
+ },
+ "current_step": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Current workflow step"
+ },
+ "id": {
+ "type": "string",
+ "description": "Agent ID (UUID)"
+ },
+ "last_activity": {
+ "type": "string",
+ "description": "Last activity timestamp (ISO 8601)"
+ },
+ "launch_mode": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Launch mode: \"default\", \"yolo\", \"docker\", \"docker-yolo\""
+ },
+ "llm_model": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "LLM model alias (e.g., \"opus\", \"sonnet\", \"gpt-4o\")"
+ },
+ "llm_tool": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "LLM tool used (e.g., \"claude\", \"gemini\", \"codex\")"
+ },
+ "paired": {
+ "type": "boolean",
+ "description": "Whether this is a paired (interactive) agent"
+ },
+ "pr_status": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Last known PR status (\"open\", \"approved\", \"`changes_requested`\", \"merged\", \"closed\")"
+ },
+ "pr_url": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "PR URL if created during \"pr\" step"
+ },
+ "project": {
+ "type": "string",
+ "description": "Project being worked on"
+ },
+ "review_state": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Review state for `awaiting_input` agents"
+ },
+ "session_wrapper": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Which session wrapper is in use: \"tmux\", \"vscode\", \"cmux\", or \"zellij\""
+ },
+ "started_at": {
+ "type": "string",
+ "description": "When the agent started (ISO 8601)"
+ },
+ "status": {
+ "type": "string",
+ "description": "Agent status: running, `awaiting_input`, completing, orphaned"
+ },
+ "ticket_id": {
+ "type": "string",
+ "description": "Associated ticket ID (e.g., \"FEAT-042\")"
+ },
+ "ticket_type": {
+ "type": "string",
+ "description": "Ticket type: FEAT, FIX, INV, SPIKE"
+ },
+ "worktree_path": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Path to the git worktree for this ticket"
+ }
+ }
+ },
+ "AssessTicketResponse": {
+ "type": "object",
+ "description": "Response from creating an ASSESS ticket",
+ "required": [
+ "ticket_id",
+ "ticket_path",
+ "project_name"
+ ],
+ "properties": {
+ "project_name": {
+ "type": "string",
+ "description": "Project name that was assessed"
+ },
+ "ticket_id": {
+ "type": "string",
+ "description": "Ticket ID (e.g., \"ASSESS-1234\")"
+ },
+ "ticket_path": {
+ "type": "string",
+ "description": "Path to the created ticket file"
+ }
+ }
+ },
+ "CollectionResponse": {
+ "type": "object",
+ "description": "Response for a collection",
+ "required": [
+ "name",
+ "description",
+ "types",
+ "is_active"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "is_active": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "types": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "CreateDelegatorFromToolRequest": {
+ "type": "object",
+ "description": "Request to create a delegator from a detected LLM tool\n\nPre-populates delegator fields from the detected tool, requiring minimal input.\nIf `name` is omitted, auto-generates as `\"{tool_name}-{model}\"`.\nIf `model` is omitted, uses the tool's first model alias.",
+ "required": [
+ "tool_name"
+ ],
+ "properties": {
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Optional display name for UI"
+ },
+ "launch_config": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/DelegatorLaunchConfigDto",
+ "description": "Optional launch configuration"
+ }
+ ]
+ },
+ "model": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Model alias to use (e.g., \"opus\"). If omitted, uses the tool's first model alias."
+ },
+ "model_server": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default."
+ },
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Custom delegator name. If omitted, auto-generates as `\"{tool_name}-{model}\"`."
+ },
+ "tool_name": {
+ "type": "string",
+ "description": "Name of the detected tool (e.g., \"claude\", \"codex\", \"gemini\")"
+ }
+ }
+ },
+ "CreateDelegatorRequest": {
+ "type": "object",
+ "description": "Request to create a new delegator",
+ "required": [
+ "name",
+ "llm_tool",
+ "model"
+ ],
+ "properties": {
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Optional display name"
+ },
+ "launch_config": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/DelegatorLaunchConfigDto",
+ "description": "Optional launch configuration"
+ }
+ ]
+ },
+ "llm_tool": {
+ "type": "string",
+ "description": "LLM tool name (must match a detected tool)"
+ },
+ "model": {
+ "type": "string",
+ "description": "Model alias"
+ },
+ "model_properties": {
+ "type": "object",
+ "description": "Arbitrary model properties",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "propertyNames": {
+ "type": "string"
+ }
+ },
+ "model_server": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default."
+ },
+ "name": {
+ "type": "string",
+ "description": "Unique name for the delegator"
+ }
+ }
+ },
+ "CreateFieldRequest": {
+ "type": "object",
+ "description": "Request to create a field",
+ "required": [
+ "name",
"description"
],
"properties": {
- "default": {
- "type": [
- "string",
- "null"
- ]
+ "default": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "description": {
+ "type": "string"
+ },
+ "field_type": {
+ "type": "string"
+ },
+ "max_length": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0
+ },
+ "name": {
+ "type": "string"
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "placeholder": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "required": {
+ "type": "boolean"
+ },
+ "user_editable": {
+ "type": "boolean"
+ }
+ }
+ },
+ "CreateIssueTypeRequest": {
+ "type": "object",
+ "description": "Request to create a new issue type",
+ "required": [
+ "key",
+ "name",
+ "description",
+ "glyph",
+ "steps"
+ ],
+ "properties": {
+ "color": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "description": {
+ "type": "string"
+ },
+ "fields": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CreateFieldRequest"
+ }
+ },
+ "glyph": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "project_required": {
+ "type": "boolean"
+ },
+ "steps": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CreateStepRequest"
+ }
+ }
+ }
+ },
+ "CreateModelServerRequest": {
+ "type": "object",
+ "description": "Request to create a new model server",
+ "required": [
+ "name",
+ "kind"
+ ],
+ "properties": {
+ "api_key_env": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Name of an env var providing the API key"
+ },
+ "base_url": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Base URL of the inference endpoint"
+ },
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Optional display name for UI"
+ },
+ "extra_env": {
+ "type": "object",
+ "description": "Additional environment variables",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "propertyNames": {
+ "type": "string"
+ }
+ },
+ "kind": {
+ "type": "string",
+ "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\""
+ },
+ "name": {
+ "type": "string",
+ "description": "Unique name for this model server"
+ }
+ }
+ },
+ "CreateStepRequest": {
+ "type": "object",
+ "description": "Request to create a step",
+ "required": [
+ "name",
+ "prompt"
+ ],
+ "properties": {
+ "allowed_tools": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "next_step": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "outputs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "permission_mode": {
+ "type": "string"
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "review_type": {
+ "type": "string",
+ "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\""
+ }
+ }
+ },
+ "DefaultLlmResponse": {
+ "type": "object",
+ "description": "Response with the current default LLM tool and model",
+ "required": [
+ "tool",
+ "model"
+ ],
+ "properties": {
+ "model": {
+ "type": "string",
+ "description": "Default model alias (empty string if not set)"
+ },
+ "tool": {
+ "type": "string",
+ "description": "Default tool name (empty string if not set)"
+ }
+ }
+ },
+ "DelegatorLaunchConfigDto": {
+ "type": "object",
+ "description": "Launch configuration DTO for delegators\n\nOptional fields use tri-state semantics: `None` = inherit global config,\n`Some(true/false)` = explicit override per-delegator.",
+ "properties": {
+ "create_branch": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Whether to create a git branch for the ticket (None = default behavior)"
+ },
+ "docker": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Run in docker container (None = use global `launch.docker.enabled`)"
+ },
+ "flags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Additional CLI flags"
+ },
+ "operator_relay": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)"
+ },
+ "permission_mode": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Permission mode override"
+ },
+ "prompt_prefix": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Prompt text to prepend before the generated step prompt"
+ },
+ "prompt_suffix": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Prompt text to append after the generated step prompt"
+ },
+ "use_worktrees": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Override global `git.use_worktrees` (None = use global setting)"
+ },
+ "yolo": {
+ "type": "boolean",
+ "description": "Run in YOLO mode"
+ }
+ }
+ },
+ "DelegatorResponse": {
+ "type": "object",
+ "description": "Response for a single delegator",
+ "required": [
+ "name",
+ "llm_tool",
+ "model",
+ "model_properties"
+ ],
+ "properties": {
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Optional display name"
+ },
+ "launch_config": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/DelegatorLaunchConfigDto",
+ "description": "Optional launch configuration"
+ }
+ ]
+ },
+ "llm_tool": {
+ "type": "string",
+ "description": "LLM tool name (e.g., \"claude\")"
+ },
+ "model": {
+ "type": "string",
+ "description": "Model alias (e.g., \"opus\")"
+ },
+ "model_properties": {
+ "type": "object",
+ "description": "Arbitrary model properties",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "propertyNames": {
+ "type": "string"
+ }
+ },
+ "model_server": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default."
+ },
+ "name": {
+ "type": "string",
+ "description": "Unique name"
+ }
+ }
+ },
+ "DelegatorsResponse": {
+ "type": "object",
+ "description": "Response listing all delegators",
+ "required": [
+ "delegators",
+ "total"
+ ],
+ "properties": {
+ "delegators": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/DelegatorResponse"
+ },
+ "description": "List of delegators"
+ },
+ "total": {
+ "type": "integer",
+ "description": "Total count",
+ "minimum": 0
+ }
+ }
+ },
+ "DetectedTool": {
+ "type": "object",
+ "description": "A detected CLI tool (e.g., claude binary)",
+ "required": [
+ "name",
+ "path",
+ "version"
+ ],
+ "properties": {
+ "capabilities": {
+ "$ref": "#/components/schemas/ToolCapabilities",
+ "description": "Tool capabilities"
+ },
+ "command_template": {
+ "type": "string",
+ "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders"
+ },
+ "min_version": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Minimum required version for Operator compatibility"
+ },
+ "model_aliases": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Available model aliases (e.g., [\"opus\", \"sonnet\", \"haiku\"])"
+ },
+ "name": {
+ "type": "string",
+ "description": "Tool name (e.g., \"claude\")"
+ },
+ "path": {
+ "type": "string",
+ "description": "Path to the binary"
+ },
+ "version": {
+ "type": "string",
+ "description": "Version string"
+ },
+ "version_ok": {
+ "type": "boolean",
+ "description": "Whether the installed version meets the minimum requirement"
+ },
+ "yolo_flags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "CLI flags for YOLO (auto-accept) mode"
+ }
+ }
+ },
+ "ErrorResponse": {
+ "type": "object",
+ "description": "Error response body",
+ "required": [
+ "error",
+ "message"
+ ],
+ "properties": {
+ "error": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ },
+ "ExternalIssueTypeSummary": {
+ "type": "object",
+ "description": "Summary of an issue type from an external kanban provider (Jira, Linear)",
+ "required": [
+ "id",
+ "name"
+ ],
+ "properties": {
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Description of the issue type"
+ },
+ "icon_url": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Icon/avatar URL from the provider"
+ },
+ "id": {
+ "type": "string",
+ "description": "Provider-specific unique identifier"
+ },
+ "name": {
+ "type": "string",
+ "description": "Issue type name (e.g., \"Bug\", \"Story\", \"Task\")"
+ }
+ }
+ },
+ "FieldResponse": {
+ "type": "object",
+ "description": "Response for a field",
+ "required": [
+ "name",
+ "description",
+ "field_type",
+ "required",
+ "user_editable"
+ ],
+ "properties": {
+ "default": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "description": {
+ "type": "string"
+ },
+ "field_type": {
+ "type": "string"
+ },
+ "max_length": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0
+ },
+ "name": {
+ "type": "string"
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "placeholder": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "required": {
+ "type": "boolean"
+ },
+ "user_editable": {
+ "type": "boolean"
+ }
+ }
+ },
+ "GithubCredentials": {
+ "type": "object",
+ "description": "Ephemeral GitHub Projects credentials supplied by a client during onboarding.\n\nThe token must have `project` (or `read:project`) scope. A repo-only token\n(the kind used for `GITHUB_TOKEN` and operator's git provider) will be\nrejected at validation time with a friendly \"lacks `project` scope\" error.",
+ "required": [
+ "token"
+ ],
+ "properties": {
+ "token": {
+ "type": "string",
+ "description": "GitHub PAT, fine-grained PAT, or app installation token"
+ }
+ }
+ },
+ "GithubProjectInfoDto": {
+ "type": "object",
+ "description": "A GitHub Project v2 surfaced during onboarding for project picker UIs.",
+ "required": [
+ "node_id",
+ "number",
+ "title",
+ "owner_login",
+ "owner_kind"
+ ],
+ "properties": {
+ "node_id": {
+ "type": "string",
+ "description": "`GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key"
+ },
+ "number": {
+ "type": "integer",
+ "format": "int32",
+ "description": "Project number (e.g., 42) within the owner"
+ },
+ "owner_kind": {
+ "type": "string",
+ "description": "\"Organization\" or \"User\""
+ },
+ "owner_login": {
+ "type": "string",
+ "description": "Owner login (org or user name)"
+ },
+ "title": {
+ "type": "string",
+ "description": "Human-readable project title"
+ }
+ }
+ },
+ "GithubSessionEnv": {
+ "type": "object",
+ "description": "GitHub Projects session env body — includes the actual secret to set in env.",
+ "required": [
+ "token",
+ "api_key_env"
+ ],
+ "properties": {
+ "api_key_env": {
+ "type": "string"
+ },
+ "token": {
+ "type": "string"
+ }
+ }
+ },
+ "GithubValidationDetailsDto": {
+ "type": "object",
+ "description": "GitHub-specific validation details (returned on success).",
+ "required": [
+ "user_login",
+ "user_id",
+ "projects",
+ "resolved_env_var"
+ ],
+ "properties": {
+ "projects": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/GithubProjectInfoDto"
+ },
+ "description": "All Projects v2 visible to the token (across viewer + organizations)"
+ },
+ "resolved_env_var": {
+ "type": "string",
+ "description": "The env var name the validated token came from. Used by clients to\ndisplay \"Connected via `OPERATOR_GITHUB_TOKEN`\" so users can rotate the\nright token. See Token Disambiguation in the kanban github docs."
+ },
+ "user_id": {
+ "type": "string",
+ "description": "Authenticated user's numeric `databaseId` as a string (used as `sync_user_id`)"
+ },
+ "user_login": {
+ "type": "string",
+ "description": "Authenticated user's login (e.g., \"octocat\")"
+ }
+ }
+ },
+ "HealthResponse": {
+ "type": "object",
+ "description": "Health check response",
+ "required": [
+ "status",
+ "version"
+ ],
+ "properties": {
+ "status": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ }
+ },
+ "IssueTypeResponse": {
+ "type": "object",
+ "description": "Response for a single issue type",
+ "required": [
+ "key",
+ "name",
+ "description",
+ "mode",
+ "glyph",
+ "project_required",
+ "source",
+ "fields",
+ "steps"
+ ],
+ "properties": {
+ "color": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "description": {
+ "type": "string"
+ },
+ "fields": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/FieldResponse"
+ }
+ },
+ "glyph": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "project_required": {
+ "type": "boolean"
+ },
+ "source": {
+ "type": "string"
+ },
+ "steps": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/StepResponse"
+ }
+ }
+ }
+ },
+ "IssueTypeSummary": {
+ "type": "object",
+ "description": "Summary response for listing issue types",
+ "required": [
+ "key",
+ "name",
+ "description",
+ "mode",
+ "glyph",
+ "source",
+ "stepCount"
+ ],
+ "properties": {
+ "color": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "description": {
+ "type": "string"
+ },
+ "glyph": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "source": {
+ "type": "string"
+ },
+ "stepCount": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ },
+ "JiraCredentials": {
+ "type": "object",
+ "description": "Ephemeral Jira credentials supplied by a client during onboarding.\n\nThese are never persisted to disk by the onboarding endpoints that take\nthis struct — the actual secret stays in the env var named in\n`api_key_env` once set via `/api/v1/kanban/session-env`.",
+ "required": [
+ "domain",
+ "email",
+ "api_token"
+ ],
+ "properties": {
+ "api_token": {
+ "type": "string",
+ "description": "API token / personal access token"
},
- "description": {
+ "domain": {
+ "type": "string",
+ "description": "Jira Cloud domain (e.g., \"acme.atlassian.net\")"
+ },
+ "email": {
+ "type": "string",
+ "description": "Atlassian account email for Basic Auth"
+ }
+ }
+ },
+ "JiraSessionEnv": {
+ "type": "object",
+ "description": "Jira session env body — includes the actual secret to set in env.",
+ "required": [
+ "domain",
+ "email",
+ "api_token",
+ "api_key_env"
+ ],
+ "properties": {
+ "api_key_env": {
"type": "string"
},
- "field_type": {
+ "api_token": {
"type": "string"
},
- "max_length": {
- "type": [
- "integer",
- "null"
- ],
- "minimum": 0
+ "domain": {
+ "type": "string"
},
- "name": {
+ "email": {
"type": "string"
+ }
+ }
+ },
+ "JiraValidationDetailsDto": {
+ "type": "object",
+ "description": "Jira-specific validation details (returned on success).",
+ "required": [
+ "account_id",
+ "display_name"
+ ],
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": "Atlassian accountId (used as `sync_user_id`)"
},
- "options": {
+ "display_name": {
+ "type": "string",
+ "description": "User display name"
+ }
+ }
+ },
+ "KanbanBoardResponse": {
+ "type": "object",
+ "description": "Kanban board response with tickets grouped by column",
+ "required": [
+ "queue",
+ "running",
+ "awaiting",
+ "done",
+ "total_count",
+ "last_updated"
+ ],
+ "properties": {
+ "awaiting": {
"type": "array",
"items": {
- "type": "string"
- }
+ "$ref": "#/components/schemas/KanbanTicketCard"
+ },
+ "description": "Tickets awaiting review or input"
},
- "placeholder": {
- "type": [
- "string",
- "null"
- ]
+ "done": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/KanbanTicketCard"
+ },
+ "description": "Completed tickets"
},
- "required": {
- "type": "boolean"
+ "last_updated": {
+ "type": "string",
+ "description": "ISO 8601 timestamp of last data refresh"
},
- "user_editable": {
- "type": "boolean"
+ "queue": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/KanbanTicketCard"
+ },
+ "description": "Tickets in queue (not yet started)"
+ },
+ "running": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/KanbanTicketCard"
+ },
+ "description": "Tickets currently being worked on"
+ },
+ "total_count": {
+ "type": "integer",
+ "description": "Total ticket count across all columns",
+ "minimum": 0
}
}
},
- "CreateIssueTypeRequest": {
+ "KanbanIssueTypeResponse": {
"type": "object",
- "description": "Request to create a new issue type",
+ "description": "A synced kanban issue type from the persisted catalog.",
"required": [
- "key",
+ "id",
"name",
- "description",
- "glyph",
- "steps"
+ "provider",
+ "project",
+ "source_kind",
+ "synced_at"
],
"properties": {
- "color": {
+ "description": {
"type": [
"string",
"null"
- ]
+ ],
+ "description": "Description from the provider"
},
- "description": {
- "type": "string"
+ "icon_url": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Icon/avatar URL from the provider"
},
- "fields": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/CreateFieldRequest"
- }
+ "id": {
+ "type": "string",
+ "description": "Provider-specific ID (Jira type ID, Linear label ID)"
},
- "glyph": {
- "type": "string"
+ "name": {
+ "type": "string",
+ "description": "Display name (e.g., \"Bug\", \"Story\", \"Task\")"
},
- "key": {
+ "project": {
+ "type": "string",
+ "description": "Project/team key"
+ },
+ "provider": {
+ "type": "string",
+ "description": "Provider name (\"jira\", \"linear\", or \"github\")"
+ },
+ "source_kind": {
+ "type": "string",
+ "description": "What this type represents in the provider (\"issuetype\" or \"label\")"
+ },
+ "synced_at": {
+ "type": "string",
+ "description": "ISO 8601 timestamp of last sync"
+ }
+ }
+ },
+ "KanbanProjectInfo": {
+ "type": "object",
+ "description": "A project/team entry returned by `list_projects`.",
+ "required": [
+ "id",
+ "key",
+ "name"
+ ],
+ "properties": {
+ "id": {
"type": "string"
},
- "mode": {
+ "key": {
"type": "string"
},
"name": {
"type": "string"
+ }
+ }
+ },
+ "KanbanProviderKind": {
+ "type": "string",
+ "description": "Which kanban provider an onboarding request targets.",
+ "enum": [
+ "jira",
+ "linear",
+ "github"
+ ]
+ },
+ "KanbanSyncResponse": {
+ "type": "object",
+ "description": "Response for kanban sync operations",
+ "required": [
+ "created",
+ "skipped",
+ "errors",
+ "total_processed"
+ ],
+ "properties": {
+ "created": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Ticket IDs that were created"
},
- "project_required": {
- "type": "boolean"
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Error messages for failed syncs"
},
- "steps": {
+ "skipped": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/CreateStepRequest"
- }
+ "type": "string"
+ },
+ "description": "Ticket IDs that were skipped (already exist)"
+ },
+ "total_processed": {
+ "type": "integer",
+ "description": "Total count of issues processed",
+ "minimum": 0
}
}
},
- "CreateModelServerRequest": {
+ "KanbanTicketCard": {
"type": "object",
- "description": "Request to create a new model server",
+ "description": "A ticket card for the kanban board",
"required": [
- "name",
- "kind"
+ "id",
+ "summary",
+ "ticket_type",
+ "project",
+ "status",
+ "step",
+ "priority",
+ "timestamp"
],
"properties": {
- "api_key_env": {
+ "id": {
+ "type": "string",
+ "description": "Ticket ID (e.g., \"FEAT-7598\")"
+ },
+ "priority": {
+ "type": "string",
+ "description": "Priority: P0-critical, P1-high, P2-medium, P3-low"
+ },
+ "project": {
+ "type": "string",
+ "description": "Project name"
+ },
+ "status": {
+ "type": "string",
+ "description": "Current status: queued, running, awaiting, completed"
+ },
+ "step": {
+ "type": "string",
+ "description": "Current step name"
+ },
+ "step_display_name": {
"type": [
"string",
"null"
],
- "description": "Name of an env var providing the API key"
+ "description": "Human-readable step name"
},
- "base_url": {
+ "summary": {
+ "type": "string",
+ "description": "Ticket summary/title"
+ },
+ "ticket_type": {
+ "type": "string",
+ "description": "Ticket type: FEAT, FIX, INV, SPIKE"
+ },
+ "timestamp": {
+ "type": "string",
+ "description": "Timestamp for sorting (YYYYMMDD-HHMM format)"
+ }
+ }
+ },
+ "LaunchTicketRequest": {
+ "type": "object",
+ "description": "Request to launch a ticket",
+ "properties": {
+ "delegator": {
"type": [
"string",
"null"
],
- "description": "Base URL of the inference endpoint"
+ "description": "Named delegator to use (takes precedence over provider/model)"
},
- "display_name": {
+ "model": {
"type": [
"string",
"null"
],
- "description": "Optional display name for UI"
+ "description": "Model to use (e.g., \"sonnet\", \"opus\") — legacy fallback when no delegator"
},
- "extra_env": {
- "type": "object",
- "description": "Additional environment variables",
- "additionalProperties": {
- "type": "string"
- },
- "propertyNames": {
- "type": "string"
- }
+ "provider": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "LLM provider to use (e.g., \"claude\") — legacy fallback when no delegator"
},
- "kind": {
- "type": "string",
- "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\""
+ "resume_session_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Existing session ID to resume (for continuing from where it left off)"
},
- "name": {
- "type": "string",
- "description": "Unique name for this model server"
+ "retry_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Feedback for relaunch (what went wrong on previous attempt)"
+ },
+ "wrapper": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Session wrapper type: \"vscode\", \"tmux\", \"cmux\", \"terminal\""
+ },
+ "yolo_mode": {
+ "type": "boolean",
+ "description": "Run in YOLO mode (auto-accept all prompts)"
}
}
},
- "CreateStepRequest": {
+ "LaunchTicketResponse": {
"type": "object",
- "description": "Request to create a step",
+ "description": "Response from launching a ticket",
"required": [
- "name",
- "prompt"
+ "agent_id",
+ "ticket_id",
+ "working_directory",
+ "command",
+ "terminal_name",
+ "tmux_session_name",
+ "session_id",
+ "worktree_created"
],
"properties": {
- "allowed_tools": {
- "type": "array",
- "items": {
- "type": "string"
- }
+ "agent_id": {
+ "type": "string",
+ "description": "Agent ID assigned to this launch"
+ },
+ "branch": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Branch name (if worktree was created)"
+ },
+ "command": {
+ "type": "string",
+ "description": "Command to execute in terminal"
},
- "display_name": {
+ "session_context_ref": {
"type": [
"string",
"null"
- ]
+ ],
+ "description": "Session context reference (e.g. cmux workspace, zellij session)"
},
- "name": {
- "type": "string"
+ "session_id": {
+ "type": "string",
+ "description": "Session UUID for the LLM tool"
},
- "next_step": {
+ "session_window_ref": {
"type": [
"string",
"null"
- ]
+ ],
+ "description": "Session window reference ID (e.g. cmux window, tmux session)"
},
- "outputs": {
- "type": "array",
- "items": {
- "type": "string"
- }
+ "session_wrapper": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Which session wrapper was used: \"tmux\", \"vscode\", or \"cmux\""
},
- "permission_mode": {
- "type": "string"
+ "terminal_name": {
+ "type": "string",
+ "description": "Terminal name to use (same value as `tmux_session_name`)"
},
- "prompt": {
- "type": "string"
+ "ticket_id": {
+ "type": "string",
+ "description": "Ticket ID that was launched"
},
- "review_type": {
+ "tmux_session_name": {
"type": "string",
- "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\""
+ "description": "Tmux session name for attaching (same value as `terminal_name`, kept for backward compat)"
+ },
+ "working_directory": {
+ "type": "string",
+ "description": "Working directory (worktree if created, else project path)"
+ },
+ "worktree_created": {
+ "type": "boolean",
+ "description": "Whether a worktree was created"
}
}
},
- "DefaultLlmResponse": {
+ "LinearCredentials": {
"type": "object",
- "description": "Response with the current default LLM tool and model",
+ "description": "Ephemeral Linear credentials supplied by a client during onboarding.",
"required": [
- "tool",
- "model"
+ "api_key"
],
"properties": {
- "model": {
+ "api_key": {
"type": "string",
- "description": "Default model alias (empty string if not set)"
+ "description": "Linear API key (prefixed `lin_api_`)"
+ }
+ }
+ },
+ "LinearSessionEnv": {
+ "type": "object",
+ "description": "Linear session env body — includes the actual secret to set in env.",
+ "required": [
+ "api_key",
+ "api_key_env"
+ ],
+ "properties": {
+ "api_key": {
+ "type": "string"
},
- "tool": {
- "type": "string",
- "description": "Default tool name (empty string if not set)"
+ "api_key_env": {
+ "type": "string"
}
}
},
- "DelegatorLaunchConfigDto": {
+ "LinearTeamInfoDto": {
"type": "object",
- "description": "Launch configuration DTO for delegators\n\nOptional fields use tri-state semantics: `None` = inherit global config,\n`Some(true/false)` = explicit override per-delegator.",
+ "description": "A Linear team exposed to onboarding clients for project selection.",
+ "required": [
+ "id",
+ "key",
+ "name"
+ ],
"properties": {
- "create_branch": {
- "type": [
- "boolean",
- "null"
- ],
- "description": "Whether to create a git branch for the ticket (None = default behavior)"
+ "id": {
+ "type": "string"
},
- "docker": {
- "type": [
- "boolean",
- "null"
- ],
- "description": "Run in docker container (None = use global `launch.docker.enabled`)"
+ "key": {
+ "type": "string"
},
- "flags": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "LinearValidationDetailsDto": {
+ "type": "object",
+ "description": "Linear-specific validation details (returned on success).",
+ "required": [
+ "user_id",
+ "user_name",
+ "org_name",
+ "teams"
+ ],
+ "properties": {
+ "org_name": {
+ "type": "string"
+ },
+ "teams": {
"type": "array",
"items": {
- "type": "string"
- },
- "description": "Additional CLI flags"
- },
- "operator_relay": {
- "type": [
- "boolean",
- "null"
- ],
- "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)"
- },
- "permission_mode": {
- "type": [
- "string",
- "null"
- ],
- "description": "Permission mode override"
- },
- "prompt_prefix": {
- "type": [
- "string",
- "null"
- ],
- "description": "Prompt text to prepend before the generated step prompt"
- },
- "prompt_suffix": {
- "type": [
- "string",
- "null"
- ],
- "description": "Prompt text to append after the generated step prompt"
+ "$ref": "#/components/schemas/LinearTeamInfoDto"
+ }
},
- "use_worktrees": {
- "type": [
- "boolean",
- "null"
- ],
- "description": "Override global `git.use_worktrees` (None = use global setting)"
+ "user_id": {
+ "type": "string",
+ "description": "Linear viewer user ID (used as `sync_user_id`)"
},
- "yolo": {
- "type": "boolean",
- "description": "Run in YOLO mode"
+ "user_name": {
+ "type": "string"
}
}
},
- "DelegatorResponse": {
+ "ListKanbanProjectsRequest": {
"type": "object",
- "description": "Response for a single delegator",
+ "description": "Request to list projects/teams from a provider using ephemeral creds.",
"required": [
- "name",
- "llm_tool",
- "model",
- "model_properties"
+ "provider"
],
"properties": {
- "display_name": {
- "type": [
- "string",
- "null"
- ],
- "description": "Optional display name"
- },
- "launch_config": {
+ "github": {
"oneOf": [
{
"type": "null"
},
{
- "$ref": "#/components/schemas/DelegatorLaunchConfigDto",
- "description": "Optional launch configuration"
+ "$ref": "#/components/schemas/GithubCredentials"
}
]
},
- "llm_tool": {
- "type": "string",
- "description": "LLM tool name (e.g., \"claude\")"
+ "jira": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/JiraCredentials"
+ }
+ ]
},
- "model": {
- "type": "string",
- "description": "Model alias (e.g., \"opus\")"
+ "linear": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/LinearCredentials"
+ }
+ ]
},
- "model_properties": {
- "type": "object",
- "description": "Arbitrary model properties",
- "additionalProperties": {
- "type": "string"
- },
- "propertyNames": {
- "type": "string"
+ "provider": {
+ "$ref": "#/components/schemas/KanbanProviderKind"
+ }
+ }
+ },
+ "ListKanbanProjectsResponse": {
+ "type": "object",
+ "description": "Response wrapper for list-projects (wrapped for utoipa compatibility).",
+ "required": [
+ "projects"
+ ],
+ "properties": {
+ "projects": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/KanbanProjectInfo"
}
- },
- "model_server": {
- "type": [
- "string",
- "null"
- ],
- "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default."
- },
- "name": {
- "type": "string",
- "description": "Unique name"
}
}
},
- "DelegatorsResponse": {
+ "LlmToolsResponse": {
"type": "object",
- "description": "Response listing all delegators",
+ "description": "Response listing detected LLM tools",
"required": [
- "delegators",
+ "tools",
"total"
],
"properties": {
- "delegators": {
+ "tools": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/DelegatorResponse"
+ "$ref": "#/components/schemas/DetectedTool"
},
- "description": "List of delegators"
+ "description": "Detected CLI tools with model aliases and capabilities"
},
"total": {
"type": "integer",
@@ -1593,510 +3612,581 @@
}
}
},
- "DetectedTool": {
+ "McpDescriptorResponse": {
"type": "object",
- "description": "A detected CLI tool (e.g., claude binary)",
+ "description": "MCP server descriptor for client discovery",
"required": [
- "name",
- "path",
- "version"
+ "server_name",
+ "server_id",
+ "version",
+ "transport_url",
+ "label"
],
"properties": {
- "capabilities": {
- "$ref": "#/components/schemas/ToolCapabilities",
- "description": "Tool capabilities"
- },
- "command_template": {
+ "label": {
"type": "string",
- "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders"
+ "description": "Human-readable label for the server"
},
- "min_version": {
+ "openapi_url": {
"type": [
"string",
"null"
],
- "description": "Minimum required version for Operator compatibility"
- },
- "model_aliases": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "description": "Available model aliases (e.g., [\"opus\", \"sonnet\", \"haiku\"])"
- },
- "name": {
- "type": "string",
- "description": "Tool name (e.g., \"claude\")"
+ "description": "URL of the OpenAPI spec for reference"
},
- "path": {
+ "server_id": {
"type": "string",
- "description": "Path to the binary"
+ "description": "Unique server identifier (e.g. \"operator-mcp\")"
},
- "version": {
+ "server_name": {
"type": "string",
- "description": "Version string"
+ "description": "Server name used in MCP registration (e.g. \"operator\")"
},
- "version_ok": {
- "type": "boolean",
- "description": "Whether the installed version meets the minimum requirement"
+ "stdio": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/StdioCommand",
+ "description": "Stdio transport entrypoint. Present when `[mcp].stdio_advertised = true`.\nClients may spawn this as a subprocess instead of using `transport_url`."
+ }
+ ]
},
- "yolo_flags": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "description": "CLI flags for YOLO (auto-accept) mode"
- }
- }
- },
- "ErrorResponse": {
- "type": "object",
- "description": "Error response body",
- "required": [
- "error",
- "message"
- ],
- "properties": {
- "error": {
- "type": "string"
+ "transport_url": {
+ "type": "string",
+ "description": "Full URL of the MCP SSE transport endpoint"
},
- "message": {
- "type": "string"
+ "version": {
+ "type": "string",
+ "description": "Server version from Cargo.toml"
}
}
},
- "FieldResponse": {
+ "ModelServerResponse": {
"type": "object",
- "description": "Response for a field",
+ "description": "Response for a single model server",
"required": [
"name",
- "description",
- "field_type",
- "required",
- "user_editable"
+ "kind",
+ "extra_env",
+ "user_declared"
],
"properties": {
- "default": {
+ "api_key_env": {
"type": [
"string",
"null"
- ]
- },
- "description": {
- "type": "string"
- },
- "field_type": {
- "type": "string"
+ ],
+ "description": "Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`)"
},
- "max_length": {
+ "base_url": {
"type": [
- "integer",
+ "string",
"null"
],
- "minimum": 0
+ "description": "Base URL of the inference endpoint (e.g., `http://localhost:11434`)"
},
- "name": {
- "type": "string"
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Optional display name for UI"
},
- "options": {
- "type": "array",
- "items": {
+ "extra_env": {
+ "type": "object",
+ "description": "Additional environment variables set when spawning agents that use this server",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "propertyNames": {
"type": "string"
}
},
- "placeholder": {
- "type": [
- "string",
- "null"
- ]
+ "kind": {
+ "type": "string",
+ "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\""
},
- "required": {
- "type": "boolean"
+ "name": {
+ "type": "string",
+ "description": "Unique name (e.g., \"ollama-local\")"
},
- "user_editable": {
- "type": "boolean"
+ "user_declared": {
+ "type": "boolean",
+ "description": "Whether this is a user-declared server (true) or an implicit builtin (false)"
}
}
},
- "HealthResponse": {
+ "ModelServersResponse": {
"type": "object",
- "description": "Health check response",
+ "description": "Response listing all model servers (declared + implicit builtins)",
"required": [
- "status",
- "version"
+ "servers",
+ "total"
],
"properties": {
- "status": {
- "type": "string"
+ "servers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ModelServerResponse"
+ },
+ "description": "List of model servers"
},
- "version": {
- "type": "string"
+ "total": {
+ "type": "integer",
+ "description": "Total count",
+ "minimum": 0
}
}
},
- "IssueTypeResponse": {
+ "NextStepInfo": {
"type": "object",
- "description": "Response for a single issue type",
+ "description": "Information about the next step in the workflow",
"required": [
- "key",
"name",
- "description",
- "mode",
- "glyph",
- "project_required",
- "source",
- "fields",
- "steps"
+ "display_name",
+ "review_type"
],
"properties": {
- "color": {
- "type": [
- "string",
- "null"
- ]
- },
- "description": {
- "type": "string"
- },
- "fields": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/FieldResponse"
- }
- },
- "glyph": {
- "type": "string"
- },
- "key": {
- "type": "string"
- },
- "mode": {
- "type": "string"
+ "display_name": {
+ "type": "string",
+ "description": "Display name for the step"
},
"name": {
- "type": "string"
- },
- "project_required": {
- "type": "boolean"
+ "type": "string",
+ "description": "Step name"
},
- "source": {
- "type": "string"
+ "prompt": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Prompt template for the step"
},
- "steps": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/StepResponse"
- }
+ "review_type": {
+ "type": "string",
+ "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\""
}
}
},
- "IssueTypeSummary": {
+ "OperatorOutput": {
"type": "object",
- "description": "Summary response for listing issue types",
+ "description": "Standardized agent output for progress tracking and step transitions.\n\nAgents output a status block in their response which is parsed into this structure.\nUsed for progress tracking, loop detection, and intelligent step transitions.",
"required": [
- "key",
- "name",
- "description",
- "mode",
- "glyph",
- "source",
- "stepCount"
+ "status",
+ "exit_signal"
],
"properties": {
- "color": {
+ "blockers": {
"type": [
- "string",
+ "array",
"null"
- ]
- },
- "description": {
- "type": "string"
- },
- "glyph": {
- "type": "string"
- },
- "key": {
- "type": "string"
+ ],
+ "items": {
+ "type": "string"
+ },
+ "description": "Issues preventing progress (signals intervention needed)"
},
- "mode": {
- "type": "string"
+ "confidence": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "int32",
+ "description": "Agent's confidence in completion (0-100%)",
+ "minimum": 0
},
- "name": {
- "type": "string"
+ "error_count": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "int32",
+ "description": "Number of errors encountered",
+ "minimum": 0
},
- "source": {
- "type": "string"
+ "exit_signal": {
+ "type": "boolean",
+ "description": "Agent signals done with step (true) or more work remains (false)"
},
- "stepCount": {
- "type": "integer",
- "minimum": 0
- }
- }
- },
- "LaunchTicketRequest": {
- "type": "object",
- "description": "Request to launch a ticket",
- "properties": {
- "delegator": {
+ "files_modified": {
"type": [
- "string",
+ "integer",
"null"
],
- "description": "Named delegator to use (takes precedence over provider/model)"
+ "format": "int32",
+ "description": "Number of files changed this iteration",
+ "minimum": 0
},
- "model": {
+ "recommendation": {
"type": [
"string",
"null"
],
- "description": "Model to use (e.g., \"sonnet\", \"opus\") — legacy fallback when no delegator"
+ "description": "Suggested next action (max 200 chars)"
},
- "provider": {
+ "status": {
+ "type": "string",
+ "description": "Current work status: `in_progress`, complete, blocked, failed"
+ },
+ "summary": {
"type": [
"string",
"null"
],
- "description": "LLM provider to use (e.g., \"claude\") — legacy fallback when no delegator"
+ "description": "Brief description of work done (max 500 chars)"
},
- "resume_session_id": {
+ "tasks_completed": {
"type": [
- "string",
+ "integer",
"null"
],
- "description": "Existing session ID to resume (for continuing from where it left off)"
+ "format": "int32",
+ "description": "Number of sub-tasks completed this iteration",
+ "minimum": 0
},
- "retry_reason": {
+ "tasks_remaining": {
"type": [
- "string",
+ "integer",
"null"
],
- "description": "Feedback for relaunch (what went wrong on previous attempt)"
+ "format": "int32",
+ "description": "Estimated remaining sub-tasks",
+ "minimum": 0
},
- "wrapper": {
+ "tests_status": {
"type": [
"string",
"null"
],
- "description": "Session wrapper type: \"vscode\", \"tmux\", \"cmux\", \"terminal\""
- },
- "yolo_mode": {
- "type": "boolean",
- "description": "Run in YOLO mode (auto-accept all prompts)"
+ "description": "Test suite status: passing, failing, skipped, `not_run`"
}
}
},
- "LaunchTicketResponse": {
+ "ProjectSummary": {
"type": "object",
- "description": "Response from launching a ticket",
+ "description": "Summary of a project with analysis data",
"required": [
- "agent_id",
- "ticket_id",
- "working_directory",
- "command",
- "terminal_name",
- "tmux_session_name",
- "session_id",
- "worktree_created"
+ "project_name",
+ "project_path",
+ "exists",
+ "has_catalog_info",
+ "has_project_context",
+ "languages",
+ "frameworks",
+ "databases",
+ "ports",
+ "env_var_count",
+ "entry_point_count",
+ "commands"
],
"properties": {
- "agent_id": {
- "type": "string",
- "description": "Agent ID assigned to this launch"
+ "commands": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Available command names (start, dev, test, etc.)"
},
- "branch": {
+ "databases": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Database display names"
+ },
+ "entry_point_count": {
+ "type": "integer",
+ "description": "Number of entry points",
+ "minimum": 0
+ },
+ "env_var_count": {
+ "type": "integer",
+ "description": "Number of environment variables",
+ "minimum": 0
+ },
+ "exists": {
+ "type": "boolean",
+ "description": "Whether the project directory exists on disk"
+ },
+ "frameworks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Framework display names"
+ },
+ "has_catalog_info": {
+ "type": "boolean",
+ "description": "Whether catalog-info.yaml exists"
+ },
+ "has_docker": {
"type": [
- "string",
+ "boolean",
"null"
],
- "description": "Branch name (if worktree was created)"
+ "description": "Has Dockerfile or docker-compose"
},
- "command": {
- "type": "string",
- "description": "Command to execute in terminal"
+ "has_project_context": {
+ "type": "boolean",
+ "description": "Whether project-context.json exists"
},
- "session_context_ref": {
+ "has_tests": {
"type": [
- "string",
+ "boolean",
"null"
],
- "description": "Session context reference (e.g. cmux workspace, zellij session)"
- },
- "session_id": {
- "type": "string",
- "description": "Session UUID for the LLM tool"
+ "description": "Has test frameworks detected"
},
- "session_window_ref": {
+ "kind": {
"type": [
"string",
"null"
],
- "description": "Session window reference ID (e.g. cmux window, tmux session)"
+ "description": "Primary Kind from `kind_assessment` (e.g., \"microservice\")"
},
- "session_wrapper": {
+ "kind_confidence": {
+ "type": [
+ "number",
+ "null"
+ ],
+ "format": "double",
+ "description": "Kind confidence score 0.0-1.0"
+ },
+ "kind_tier": {
"type": [
"string",
"null"
],
- "description": "Which session wrapper was used: \"tmux\", \"vscode\", or \"cmux\""
+ "description": "Taxonomy tier (e.g., \"engines\")"
},
- "terminal_name": {
- "type": "string",
- "description": "Terminal name to use (same value as `tmux_session_name`)"
+ "languages": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Language display names"
},
- "ticket_id": {
- "type": "string",
- "description": "Ticket ID that was launched"
+ "ports": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "format": "int32",
+ "minimum": 0
+ },
+ "description": "Detected port numbers"
},
- "tmux_session_name": {
+ "project_name": {
"type": "string",
- "description": "Tmux session name for attaching (same value as `terminal_name`, kept for backward compat)"
+ "description": "Project directory name"
},
- "working_directory": {
+ "project_path": {
"type": "string",
- "description": "Working directory (worktree if created, else project path)"
- },
- "worktree_created": {
- "type": "boolean",
- "description": "Whether a worktree was created"
+ "description": "Absolute path to project root"
}
}
},
- "LlmToolsResponse": {
+ "QueueByType": {
"type": "object",
- "description": "Response listing detected LLM tools",
+ "description": "Ticket counts by type for queue status",
"required": [
- "tools",
- "total"
+ "inv",
+ "fix",
+ "feat",
+ "spike"
],
"properties": {
- "tools": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/DetectedTool"
- },
- "description": "Detected CLI tools with model aliases and capabilities"
+ "feat": {
+ "type": "integer",
+ "minimum": 0
},
- "total": {
+ "fix": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "inv": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "spike": {
"type": "integer",
- "description": "Total count",
"minimum": 0
}
}
},
- "McpDescriptorResponse": {
+ "QueueControlResponse": {
"type": "object",
- "description": "MCP server descriptor for client discovery",
+ "description": "Response for queue pause/resume operations",
"required": [
- "server_name",
- "server_id",
- "version",
- "transport_url",
- "label"
+ "paused",
+ "message"
],
"properties": {
- "label": {
+ "message": {
"type": "string",
- "description": "Human-readable label for the server"
+ "description": "Human-readable message about the operation"
},
- "openapi_url": {
- "type": [
- "string",
- "null"
- ],
- "description": "URL of the OpenAPI spec for reference"
+ "paused": {
+ "type": "boolean",
+ "description": "Whether the queue is currently paused"
+ }
+ }
+ },
+ "QueueStatusResponse": {
+ "type": "object",
+ "description": "Queue status response with ticket counts",
+ "required": [
+ "queued",
+ "in_progress",
+ "awaiting",
+ "completed",
+ "by_type"
+ ],
+ "properties": {
+ "awaiting": {
+ "type": "integer",
+ "description": "Tickets awaiting review or input",
+ "minimum": 0
},
- "server_id": {
- "type": "string",
- "description": "Unique server identifier (e.g. \"operator-mcp\")"
+ "by_type": {
+ "$ref": "#/components/schemas/QueueByType",
+ "description": "Breakdown by ticket type"
},
- "server_name": {
- "type": "string",
- "description": "Server name used in MCP registration (e.g. \"operator\")"
+ "completed": {
+ "type": "integer",
+ "description": "Completed tickets (today)",
+ "minimum": 0
},
- "transport_url": {
- "type": "string",
- "description": "Full URL of the MCP SSE transport endpoint"
+ "in_progress": {
+ "type": "integer",
+ "description": "Tickets currently being worked on",
+ "minimum": 0
},
- "version": {
+ "queued": {
+ "type": "integer",
+ "description": "Tickets waiting in queue",
+ "minimum": 0
+ }
+ }
+ },
+ "RejectReviewRequest": {
+ "type": "object",
+ "description": "Request to reject an agent's review",
+ "required": [
+ "reason"
+ ],
+ "properties": {
+ "reason": {
"type": "string",
- "description": "Server version from Cargo.toml"
+ "description": "Reason for rejection (feedback for the agent)"
}
}
},
- "ModelServerResponse": {
+ "ReviewResponse": {
"type": "object",
- "description": "Response for a single model server",
+ "description": "Response for agent review operations (approve/reject)",
"required": [
- "name",
- "kind",
- "extra_env",
- "user_declared"
+ "agent_id",
+ "status",
+ "message"
],
"properties": {
- "api_key_env": {
- "type": [
- "string",
- "null"
- ],
- "description": "Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`)"
- },
- "base_url": {
- "type": [
- "string",
- "null"
- ],
- "description": "Base URL of the inference endpoint (e.g., `http://localhost:11434`)"
+ "agent_id": {
+ "type": "string",
+ "description": "Agent ID that was reviewed"
},
- "display_name": {
- "type": [
- "string",
- "null"
- ],
- "description": "Optional display name for UI"
+ "message": {
+ "type": "string",
+ "description": "Human-readable message about the operation"
},
- "extra_env": {
- "type": "object",
- "description": "Additional environment variables set when spawning agents that use this server",
- "additionalProperties": {
- "type": "string"
- },
- "propertyNames": {
- "type": "string"
+ "status": {
+ "type": "string",
+ "description": "Review status: \"approved\" or \"rejected\""
+ }
+ }
+ },
+ "SectionDto": {
+ "type": "object",
+ "description": "A status section with its health and child rows.",
+ "required": [
+ "id",
+ "label",
+ "health",
+ "description",
+ "prerequisites",
+ "met",
+ "children"
+ ],
+ "properties": {
+ "children": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SectionRowDto"
}
},
- "kind": {
+ "description": {
+ "type": "string"
+ },
+ "health": {
"type": "string",
- "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\""
+ "description": "Health: \"green\" | \"yellow\" | \"red\" | \"gray\"."
},
- "name": {
+ "id": {
"type": "string",
- "description": "Unique name (e.g., \"ollama-local\")"
+ "description": "Stable section id (e.g. \"config\", \"connections\", \"kanban\")."
},
- "user_declared": {
+ "label": {
+ "type": "string"
+ },
+ "met": {
"type": "boolean",
- "description": "Whether this is a user-declared server (true) or an implicit builtin (false)"
+ "description": "Whether all prerequisites are met. Sections are always returned (the web\nUI styles unmet ones as locked) rather than hidden by progressive disclosure."
+ },
+ "prerequisites": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Section ids that must be Green before this section is usable."
}
}
},
- "ModelServersResponse": {
+ "SectionRowDto": {
"type": "object",
- "description": "Response listing all model servers (declared + implicit builtins)",
+ "description": "A child row within a status section.",
"required": [
- "servers",
- "total"
+ "id",
+ "depth",
+ "label",
+ "description",
+ "icon",
+ "health"
],
"properties": {
- "servers": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ModelServerResponse"
- },
- "description": "List of model servers"
- },
- "total": {
+ "depth": {
"type": "integer",
- "description": "Total count",
+ "format": "int32",
+ "description": "Nesting depth within the section (1 = direct child, 2 = grandchild).\nLets clients rebuild the tree (e.g. LLM tools → model aliases).",
"minimum": 0
+ },
+ "description": {
+ "type": "string"
+ },
+ "health": {
+ "type": "string",
+ "description": "Health: \"green\" | \"yellow\" | \"red\" | \"gray\"."
+ },
+ "icon": {
+ "type": "string",
+ "description": "Icon hint (e.g. \"check\", \"warning\", \"tool\", \"folder\")."
+ },
+ "id": {
+ "type": "string",
+ "description": "Stable, section-scoped row id. Clients use it as a tree key and to route\nrow-specific commands without matching on the (mutable) display label.\nDynamic rows carry their entity key (issue-type key, project name);\nstatic rows carry a fixed slug (e.g. \"git-token\")."
+ },
+ "label": {
+ "type": "string"
}
}
},
@@ -2118,6 +4208,69 @@
}
}
},
+ "SetKanbanSessionEnvRequest": {
+ "type": "object",
+ "description": "Request to set kanban-related env vars on the server for the current\nsession so subsequent `from_config` calls find the API key.",
+ "required": [
+ "provider"
+ ],
+ "properties": {
+ "github": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/GithubSessionEnv"
+ }
+ ]
+ },
+ "jira": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/JiraSessionEnv"
+ }
+ ]
+ },
+ "linear": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/LinearSessionEnv"
+ }
+ ]
+ },
+ "provider": {
+ "$ref": "#/components/schemas/KanbanProviderKind"
+ }
+ }
+ },
+ "SetKanbanSessionEnvResponse": {
+ "type": "object",
+ "description": "Response from setting session env vars.\n\n`shell_export_block` uses `` placeholders, NOT the actual\nsecret — it is meant for the user to copy into their shell profile.",
+ "required": [
+ "env_vars_set",
+ "shell_export_block"
+ ],
+ "properties": {
+ "env_vars_set": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Names (not values) of env vars that were set in the server process."
+ },
+ "shell_export_block": {
+ "type": "string",
+ "description": "Multi-line `export FOO=\"\"` block for the user to copy\ninto `~/.zshrc` / `~/.bashrc`."
+ }
+ }
+ },
"SkillEntry": {
"type": "object",
"description": "A single discovered skill file",
@@ -2198,6 +4351,172 @@
}
}
},
+ "StdioCommand": {
+ "type": "object",
+ "description": "Stdio entrypoint advertised in the descriptor when\n`[mcp].stdio_advertised = true`. Clients use this to spawn operator\nas an MCP subprocess instead of (or alongside) the SSE transport.",
+ "required": [
+ "command",
+ "args",
+ "cwd"
+ ],
+ "properties": {
+ "args": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Args to pass: typically `[\"mcp\"]`"
+ },
+ "command": {
+ "type": "string",
+ "description": "Absolute path to the operator binary (the same binary serving this descriptor)"
+ },
+ "cwd": {
+ "type": "string",
+ "description": "Working directory the client should set when spawning. Defaults to the\noperator process's current working directory."
+ }
+ }
+ },
+ "StepCompleteRequest": {
+ "type": "object",
+ "description": "Request to report step completion (from opr8r wrapper)",
+ "required": [
+ "exit_code",
+ "duration_secs"
+ ],
+ "properties": {
+ "duration_secs": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Duration of the step in seconds",
+ "minimum": 0
+ },
+ "exit_code": {
+ "type": "integer",
+ "format": "int32",
+ "description": "Exit code from the LLM command"
+ },
+ "output": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/OperatorOutput",
+ "description": "Structured output from agent (parsed `OPERATOR_STATUS` block)"
+ }
+ ]
+ },
+ "output_sample": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Sample of the output (first N chars for debugging)"
+ },
+ "output_schema_errors": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "string"
+ },
+ "description": "List of validation errors (if `output_valid` is false)"
+ },
+ "output_valid": {
+ "type": "boolean",
+ "description": "Whether output validation passed (if schema was specified)"
+ },
+ "session_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Session ID from the LLM session"
+ }
+ }
+ },
+ "StepCompleteResponse": {
+ "type": "object",
+ "description": "Response from step completion endpoint",
+ "required": [
+ "status",
+ "auto_proceed"
+ ],
+ "properties": {
+ "auto_proceed": {
+ "type": "boolean",
+ "description": "Whether to automatically proceed to the next step"
+ },
+ "circuit_state": {
+ "type": "string",
+ "description": "Circuit breaker state: closed (normal), `half_open` (monitoring), open (halted)"
+ },
+ "cumulative_errors": {
+ "type": "integer",
+ "format": "int32",
+ "description": "Cumulative errors across iterations",
+ "minimum": 0
+ },
+ "cumulative_files_modified": {
+ "type": "integer",
+ "format": "int32",
+ "description": "Cumulative files modified across iterations",
+ "minimum": 0
+ },
+ "iteration_count": {
+ "type": "integer",
+ "format": "int32",
+ "description": "How many times this step has run (for circuit breaker)",
+ "minimum": 0
+ },
+ "next_command": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Command to execute for the next step (opr8r wrapped)"
+ },
+ "next_step": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/NextStepInfo",
+ "description": "Information about the next step (if any)"
+ }
+ ]
+ },
+ "output_valid": {
+ "type": "boolean",
+ "description": "Whether `OperatorOutput` was successfully parsed from agent output"
+ },
+ "previous_recommendation": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Recommendation from previous step's `OperatorOutput`"
+ },
+ "previous_summary": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Summary from previous step's `OperatorOutput`"
+ },
+ "should_iterate": {
+ "type": "boolean",
+ "description": "Agent has more work (`exit_signal=false`) - indicates iteration needed"
+ },
+ "status": {
+ "type": "string",
+ "description": "Status of the step: \"completed\", \"`awaiting_review`\", \"failed\", \"iterate\""
+ }
+ }
+ },
"StepResponse": {
"type": "object",
"description": "Response for a step",
@@ -2216,36 +4535,185 @@
"type": "string"
}
},
- "display_name": {
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "next_step": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "outputs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "permission_mode": {
+ "type": "string"
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "review_type": {
+ "type": "string",
+ "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\""
+ }
+ }
+ },
+ "SyncKanbanIssueTypesResponse": {
+ "type": "object",
+ "description": "Response from syncing kanban issue types from a provider.",
+ "required": [
+ "synced",
+ "types"
+ ],
+ "properties": {
+ "synced": {
+ "type": "integer",
+ "description": "Number of issue types synced",
+ "minimum": 0
+ },
+ "types": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/KanbanIssueTypeResponse"
+ },
+ "description": "The synced issue types"
+ }
+ }
+ },
+ "TicketDetailResponse": {
+ "type": "object",
+ "description": "Full ticket details including content and metadata",
+ "required": [
+ "id",
+ "summary",
+ "ticket_type",
+ "project",
+ "status",
+ "step",
+ "priority",
+ "timestamp",
+ "content",
+ "filename",
+ "filepath",
+ "sessions",
+ "step_delegators"
+ ],
+ "properties": {
+ "branch": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Git branch name"
+ },
+ "content": {
+ "type": "string",
+ "description": "Full markdown content of the ticket"
+ },
+ "external_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "External issue ID from kanban provider"
+ },
+ "external_provider": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Provider name (e.g., \"jira\", \"linear\")"
+ },
+ "external_url": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "URL to the issue in the external provider"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Ticket filename"
+ },
+ "filepath": {
+ "type": "string",
+ "description": "Full filesystem path"
+ },
+ "id": {
+ "type": "string",
+ "description": "Ticket ID (e.g., \"FEAT-7598\")"
+ },
+ "priority": {
+ "type": "string",
+ "description": "Priority: P0-critical, P1-high, P2-medium, P3-low"
+ },
+ "project": {
+ "type": "string",
+ "description": "Project name"
+ },
+ "sessions": {
+ "type": "object",
+ "description": "Session IDs per step (`step_name` -> `session_uuid`)",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "propertyNames": {
+ "type": "string"
+ }
+ },
+ "status": {
+ "type": "string",
+ "description": "Current status: queued, running, awaiting, completed"
+ },
+ "step": {
+ "type": "string",
+ "description": "Current step name"
+ },
+ "step_delegators": {
+ "type": "object",
+ "description": "Delegator used per step (`step_name` -> `delegator_name`)",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "propertyNames": {
+ "type": "string"
+ }
+ },
+ "step_display_name": {
"type": [
"string",
"null"
- ]
+ ],
+ "description": "Human-readable step name"
},
- "name": {
- "type": "string"
+ "summary": {
+ "type": "string",
+ "description": "Ticket summary/title"
},
- "next_step": {
+ "ticket_type": {
+ "type": "string",
+ "description": "Ticket type: FEAT, FIX, INV, SPIKE"
+ },
+ "timestamp": {
+ "type": "string",
+ "description": "Timestamp (YYYYMMDD-HHMM format)"
+ },
+ "worktree_path": {
"type": [
"string",
"null"
- ]
- },
- "outputs": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "permission_mode": {
- "type": "string"
- },
- "prompt": {
- "type": "string"
- },
- "review_type": {
- "type": "string",
- "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\""
+ ],
+ "description": "Path to git worktree (if created)"
}
}
},
@@ -2377,6 +4845,305 @@
"description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\""
}
}
+ },
+ "UpdateTicketStatusRequest": {
+ "type": "object",
+ "description": "Request to update a ticket's status",
+ "required": [
+ "status"
+ ],
+ "properties": {
+ "status": {
+ "type": "string",
+ "description": "Target status: queued, running, awaiting, done"
+ }
+ }
+ },
+ "UpdateTicketStatusResponse": {
+ "type": "object",
+ "description": "Response from updating a ticket's status",
+ "required": [
+ "id",
+ "previous_status",
+ "status",
+ "message"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Ticket ID"
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable message"
+ },
+ "previous_status": {
+ "type": "string",
+ "description": "Previous status before the update"
+ },
+ "status": {
+ "type": "string",
+ "description": "New status after the update"
+ }
+ }
+ },
+ "ValidateKanbanCredentialsRequest": {
+ "type": "object",
+ "description": "Request to validate kanban credentials without persisting them.",
+ "required": [
+ "provider"
+ ],
+ "properties": {
+ "github": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/GithubCredentials"
+ }
+ ]
+ },
+ "jira": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/JiraCredentials"
+ }
+ ]
+ },
+ "linear": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/LinearCredentials"
+ }
+ ]
+ },
+ "provider": {
+ "$ref": "#/components/schemas/KanbanProviderKind"
+ }
+ }
+ },
+ "ValidateKanbanCredentialsResponse": {
+ "type": "object",
+ "description": "Response from validating kanban credentials.\n\n`valid: false` is returned for auth failures — never a 4xx/5xx HTTP\nstatus — so clients can display `error` inline without exception handling.",
+ "required": [
+ "valid"
+ ],
+ "properties": {
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "github": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/GithubValidationDetailsDto"
+ }
+ ]
+ },
+ "jira": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/JiraValidationDetailsDto"
+ }
+ ]
+ },
+ "linear": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/LinearValidationDetailsDto"
+ }
+ ]
+ },
+ "valid": {
+ "type": "boolean"
+ }
+ }
+ },
+ "WorkflowExportResponse": {
+ "type": "object",
+ "description": "Response for exporting a ticket to a Claude dynamic workflow (`.js`).",
+ "required": [
+ "ticket_id",
+ "issuetype_key",
+ "suggested_filename",
+ "contents"
+ ],
+ "properties": {
+ "contents": {
+ "type": "string",
+ "description": "The generated `.js` workflow source."
+ },
+ "issuetype_key": {
+ "type": "string",
+ "description": "The issue type key that supplied the step structure."
+ },
+ "suggested_filename": {
+ "type": "string",
+ "description": "Suggested filename for saving the workflow (`.workflow.js`)."
+ },
+ "ticket_id": {
+ "type": "string",
+ "description": "The ticket the workflow was generated from."
+ }
+ }
+ },
+ "WriteGithubConfigBody": {
+ "type": "object",
+ "description": "Body for writing a GitHub Projects v2 config section.",
+ "required": [
+ "owner",
+ "api_key_env",
+ "project_key",
+ "sync_user_id"
+ ],
+ "properties": {
+ "api_key_env": {
+ "type": "string",
+ "description": "Env var name where the project-scoped token is set\n(default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN`\n— see Token Disambiguation in the kanban github docs."
+ },
+ "owner": {
+ "type": "string",
+ "description": "GitHub owner login (user or org), used as the workspace key"
+ },
+ "project_key": {
+ "type": "string",
+ "description": "`GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`)"
+ },
+ "sync_user_id": {
+ "type": "string",
+ "description": "Numeric GitHub `databaseId` of the user whose items to sync"
+ }
+ }
+ },
+ "WriteJiraConfigBody": {
+ "type": "object",
+ "description": "Body for writing a Jira project config section.",
+ "required": [
+ "domain",
+ "email",
+ "api_key_env",
+ "project_key",
+ "sync_user_id"
+ ],
+ "properties": {
+ "api_key_env": {
+ "type": "string"
+ },
+ "domain": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ },
+ "project_key": {
+ "type": "string"
+ },
+ "sync_user_id": {
+ "type": "string"
+ }
+ }
+ },
+ "WriteKanbanConfigRequest": {
+ "type": "object",
+ "description": "Request to write or upsert a kanban config section.\n\nThis endpoint does NOT take the secret — only the env var NAME\n(`api_key_env`). The secret is set via `/api/v1/kanban/session-env`.",
+ "required": [
+ "provider"
+ ],
+ "properties": {
+ "github": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/WriteGithubConfigBody"
+ }
+ ]
+ },
+ "jira": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/WriteJiraConfigBody"
+ }
+ ]
+ },
+ "linear": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/WriteLinearConfigBody"
+ }
+ ]
+ },
+ "provider": {
+ "$ref": "#/components/schemas/KanbanProviderKind"
+ }
+ }
+ },
+ "WriteKanbanConfigResponse": {
+ "type": "object",
+ "description": "Response after writing a kanban config section.",
+ "required": [
+ "written_path",
+ "section_header"
+ ],
+ "properties": {
+ "section_header": {
+ "type": "string",
+ "description": "Header of the top-level section that was upserted\n(e.g., `[kanban.jira.\"acme.atlassian.net\"]`)"
+ },
+ "written_path": {
+ "type": "string",
+ "description": "Filesystem path that was written (e.g., \".tickets/operator/config.toml\")"
+ }
+ }
+ },
+ "WriteLinearConfigBody": {
+ "type": "object",
+ "description": "Body for writing a Linear project/team config section.",
+ "required": [
+ "workspace_key",
+ "api_key_env",
+ "project_key",
+ "sync_user_id"
+ ],
+ "properties": {
+ "api_key_env": {
+ "type": "string"
+ },
+ "project_key": {
+ "type": "string"
+ },
+ "sync_user_id": {
+ "type": "string"
+ },
+ "workspace_key": {
+ "type": "string"
+ }
+ }
}
}
},
@@ -2385,6 +5152,10 @@
"name": "Health",
"description": "Health check and status endpoints"
},
+ {
+ "name": "Status",
+ "description": "Canonical status sections (TUI / VS Code parity)"
+ },
{
"name": "Issue Types",
"description": "Issue type CRUD operations"
@@ -2397,10 +5168,18 @@
"name": "Collections",
"description": "Issue type collection management"
},
+ {
+ "name": "Tickets",
+ "description": "Ticket CRUD and status management"
+ },
{
"name": "Launch",
"description": "Ticket launch operations"
},
+ {
+ "name": "Workflow",
+ "description": "Export tickets to Claude dynamic workflows"
+ },
{
"name": "Skills",
"description": "Skill discovery across LLM tools"
@@ -2416,6 +5195,26 @@
{
"name": "MCP",
"description": "Model Context Protocol integration"
+ },
+ {
+ "name": "Queue",
+ "description": "Ticket queue board, status, and control"
+ },
+ {
+ "name": "Agents",
+ "description": "Active agent tracking and review actions"
+ },
+ {
+ "name": "Projects",
+ "description": "Project discovery and ticket assessment"
+ },
+ {
+ "name": "Configuration",
+ "description": "Operator configuration read/write"
+ },
+ {
+ "name": "Kanban",
+ "description": "Kanban provider issue types and onboarding"
}
]
}
\ No newline at end of file
diff --git a/docs/schemas/project_analysis.json b/docs/schemas/project_analysis.json
index fbd2ca2..a983e69 100644
--- a/docs/schemas/project_analysis.json
+++ b/docs/schemas/project_analysis.json
@@ -915,5 +915,5 @@
}
},
"$id": "https://gbqr.us/operator/project-analysis.schema.json",
- "$comment": "AUTO-GENERATED FROM src/backstage/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema"
+ "$comment": "AUTO-GENERATED FROM src/taxonomy/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema"
}
\ No newline at end of file
diff --git a/docs/shortcuts/index.md b/docs/shortcuts/index.md
index b0956df..e99d3d4 100644
--- a/docs/shortcuts/index.md
+++ b/docs/shortcuts/index.md
@@ -32,7 +32,7 @@ Operator uses vim-style keybindings for navigation and actions. This reference d
| `S` | Sync kanban collections | Dashboard |
| `Y/y` | Approve review (agents panel) | Dashboard |
| `X/x` | Reject review (agents panel) | Dashboard |
-| `W/w` | Toggle Backstage server | Dashboard |
+| `W/w` | Open web UI in browser | Dashboard |
| `V/v` | Show session preview | Dashboard |
| `F` | Focus cmux window | Dashboard |
| `C` | Create new ticket | Dashboard |
@@ -92,7 +92,7 @@ These shortcuts are available in the main dashboard view.
| `S` | Sync kanban collections |
| `Y/y` | Approve review (agents panel) |
| `X/x` | Reject review (agents panel) |
-| `W/w` | Toggle Backstage server |
+| `W/w` | Open web UI in browser |
| `V/v` | Show session preview |
| `F` | Focus cmux window |
diff --git a/docs/superpowers/plans/2026-05-16-acp-agent.md b/docs/superpowers/plans/2026-05-16-acp-agent.md
new file mode 100644
index 0000000..76fddd8
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-16-acp-agent.md
@@ -0,0 +1,977 @@
+# Operator as an ACP Agent — Editor-Hosted Sessions over Stdio
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+>
+> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically.
+
+**Goal:** Make operator runnable as an Agent Client Protocol (ACP) agent so editors that speak ACP — Zed, JetBrains (via the ACP Agent Registry), Emacs (`agent-shell`), Kiro, OpenCode, marimo, and Eclipse — can launch operator as a subprocess and host kanban-aware sessions inside the IDE. The editor becomes the chat surface; operator owns the ticket lifecycle and routes work to the configured delegator.
+
+**Architecture:** ACP is JSON-RPC 2.0 over stdio, bidirectional (both sides may initiate requests), with a session-based model. Editor spawns operator via `operator acp`, sends `initialize`, then `session/new` with a working directory, then `session/prompt` with user text. Operator responds with streaming `session/update` notifications and a final prompt response. Operator's v1 strategy is **bridge mode**: each ACP session corresponds to one operator ticket, and `session/prompt` launches a delegator subprocess (Claude Code, Codex CLI, Gemini CLI — whatever is configured) whose output is translated into ACP `session/update` chunks. Operator does **not** try to be the LLM-driving agent itself; it's the orchestrator that owns "which ticket, which delegator, which project." Lifecycle, config, and status integration mirror the existing `RestApiServer` pattern (`src/rest/server.rs`).
+
+**Tech Stack:** Rust 1.88+, tokio (async stdio + subprocess), `agent-client-protocol` crate (official Rust SDK from `github.com/agentclientprotocol/agent-client-protocol`), serde_json, clap (CLI), ratatui (status integration), the existing delegator infrastructure under `src/agents/`.
+
+---
+
+## Critical Structural Approach
+
+Four decisions lock the rest of the plan:
+
+1. **Depend on the official `agent-client-protocol` Rust crate.** Operator already hand-rolls MCP JSON-RPC types because MCP is simple and the surface is small. ACP is bidirectional and has dozens of method shapes — hand-rolling is a maintenance trap. The Zed-published crate provides the `Agent` trait and message types; operator implements the trait. Verify the latest crate version on crates.io before pinning.
+
+2. **One ACP session = one operator ticket.** When the editor calls `session/new`, operator either (a) parses the working directory and prompt to attach to an existing in-progress ticket, or (b) creates a new ticket from a system prompt. The `sessionId` returned to the editor is the ticket UUID. This makes the ACP session traceable in the kanban board and lets the same session be resumed via `session/load` after a restart.
+
+3. **`session/prompt` does not run an LLM in-process — it delegates.** Operator spawns its configured delegator (Claude Code, Codex CLI, Gemini CLI) via the existing `src/agents/launcher.rs` infrastructure. The delegator's stdout/stderr is translated, line by line, into ACP `session/update` notifications. This keeps operator's orchestration role honest: it does not compete with the agent runtimes; it composes them.
+
+4. **Mirror the `RestApiServer` lifecycle for the stdio listener.** Even though `operator acp` is typically spawned by an editor (so its lifetime is bound to one editor connection), operator's TUI may also launch ACP listeners for inspection/testing. The `AcpAgentServer` handle (Status enum, `Arc>`, oneshot shutdown, session-file in `.operator/acp-session.json`) follows the same shape as `src/rest/server.rs:75-218`. The status panel (`ConnectionsSection`) gets an "ACP" row alongside "Operator API" and "MCP".
+
+The first task verifies the crate name and version. Everything else hangs off task 1.
+
+---
+
+## File Structure
+
+**Create:**
+- `src/acp/mod.rs` — module root, public re-exports
+- `src/acp/agent.rs` — `OperatorAcpAgent` struct implementing the crate's `Agent` trait
+- `src/acp/session.rs` — `AcpSession` (sessionId ↔ ticket ↔ delegator subprocess)
+- `src/acp/translator.rs` — converts delegator stdout lines into ACP `session/update` notifications
+- `src/acp/server.rs` — `AcpAgentServer` lifecycle handle (mirrors `RestApiServer`)
+- `src/acp/client_configs.rs` — config snippets for Zed, JetBrains, Emacs, Kiro
+- `tests/acp_integration.rs` — spawn `operator acp`, send initialize + session/new, assert response shape
+
+**Modify:**
+- `Cargo.toml` — add `agent-client-protocol` dependency
+- `src/main.rs:24-35` (modules), `:131-266` (Commands), `:281-362` (match arm), bottom (`cmd_acp`)
+- `src/config.rs` — add `AcpConfig` struct + field on `Config`
+- `src/ui/status_panel.rs` — add ACP fields to `StatusSnapshot`, new `StatusAction` variants
+- `src/ui/sections/connections_section.rs` — add an ACP row
+
+---
+
+## Tasks
+
+### Task 1: Add the `agent-client-protocol` crate dependency
+
+**Files:**
+- Modify: `Cargo.toml`
+
+- [ ] **Step 1: Find the latest crate version**
+
+Run: `cargo search agent-client-protocol --limit 5`
+Expected output (similar): `agent-client-protocol = "0.21.0" # Rust SDK for ACP`
+
+Record the latest version. If the search fails or returns no results, fall back to checking the GitHub releases page at `https://github.com/agentclientprotocol/agent-client-protocol/releases` and reading the `Cargo.toml` in the `rust/` subdirectory.
+
+- [ ] **Step 2: Add to `Cargo.toml`**
+
+Edit `Cargo.toml`. Under `[dependencies]`, add:
+
+```toml
+agent-client-protocol = "0.21" # pin to the version from Step 1
+```
+
+- [ ] **Step 3: Verify it compiles**
+
+Run: `cargo build`
+Expected: clean build (just downloads + compiles the new crate; no operator code uses it yet).
+
+- [ ] **Step 4: Skim the crate's `Agent` trait**
+
+Run: `cargo doc --open --package agent-client-protocol`
+Read the `Agent` trait and its associated message types. The remaining tasks reference method names from this trait. If the trait has changed shape relative to this plan (rename, new required method), pause and update the plan before proceeding.
+
+- [ ] **Step 5: Stop for commit review**
+
+---
+
+### Task 2: Scaffold `src/acp/` and wire it into the crate
+
+**Files:**
+- Create: `src/acp/mod.rs`
+- Modify: `src/main.rs:24-35` (module list)
+
+- [ ] **Step 1: Create the module file**
+
+Create `src/acp/mod.rs`:
+
+```rust
+//! Agent Client Protocol (ACP) integration for Operator.
+//!
+//! Operator runs as an ACP agent that editors (Zed, JetBrains, Emacs,
+//! Kiro, etc.) launch as a stdio subprocess. Each ACP session maps to
+//! one operator ticket. Prompts are delegated to the configured runtime
+//! (Claude Code, Codex CLI, Gemini CLI), and the delegator's stream is
+//! translated into ACP `session/update` notifications.
+//!
+//! See: https://agentclientprotocol.com/
+
+pub mod agent;
+pub mod client_configs;
+pub mod server;
+pub mod session;
+pub mod translator;
+```
+
+- [ ] **Step 2: Register the module**
+
+In `src/main.rs` around line 27 (alongside `mod mcp;`), add:
+
+```rust
+mod acp;
+```
+
+- [ ] **Step 3: Verify**
+
+Run: `cargo build`
+Expected: FAIL with "unresolved module" for each of `agent`, `client_configs`, `server`, `session`, `translator` — files don't exist yet. Comment out the unresolved lines, leaving only `pub mod agent;` (the first one we'll fill in). Or proceed straight to Task 3.
+
+- [ ] **Step 4: Stop for commit review**
+
+---
+
+### Task 3: Implement `OperatorAcpAgent` skeleton — initialize + capabilities
+
+**Files:**
+- Create: `src/acp/agent.rs`
+
+The exact trait method signatures depend on the crate version pinned in Task 1. Adjust if the crate's `Agent` trait differs from what's shown here. Consult `cargo doc --open --package agent-client-protocol`.
+
+- [ ] **Step 1: Write a failing test for the initialize response**
+
+Create `src/acp/agent.rs`:
+
+```rust
+//! Operator's implementation of the ACP `Agent` trait.
+
+use std::sync::Arc;
+use tokio::sync::Mutex;
+
+use crate::acp::session::SessionRegistry;
+use crate::config::Config;
+
+/// The operator-side ACP agent.
+///
+/// Holds operator state needed to handle ACP requests: config, the
+/// per-session ticket registry, and a handle to the delegator launcher.
+pub struct OperatorAcpAgent {
+ pub config: Config,
+ pub sessions: Arc>,
+}
+
+impl OperatorAcpAgent {
+ pub fn new(config: Config) -> Self {
+ Self {
+ config,
+ sessions: Arc::new(Mutex::new(SessionRegistry::default())),
+ }
+ }
+}
+
+// ---- ACP Agent trait implementation ----
+//
+// The exact trait shape and method signatures come from the
+// `agent-client-protocol` crate. Look at the trait definition (cargo doc)
+// and implement each required method. The skeleton below shows the four
+// methods we need for v1:
+//
+// - initialize: return capabilities
+// - new_session: create a session + ticket, return sessionId
+// - prompt: delegate to the configured runtime, stream updates
+// - cancel: signal the in-flight delegator to stop
+//
+// Use the crate's request/response types verbatim — do not re-define them.
+
+#[async_trait::async_trait]
+impl agent_client_protocol::Agent for OperatorAcpAgent {
+ async fn initialize(
+ &self,
+ _params: agent_client_protocol::InitializeParams,
+ ) -> Result {
+ Ok(agent_client_protocol::InitializeResponse {
+ protocol_version: agent_client_protocol::PROTOCOL_VERSION,
+ agent_capabilities: agent_client_protocol::AgentCapabilities {
+ load_session: false, // v1: no resume
+ prompt_capabilities: Default::default(),
+ },
+ auth_methods: vec![],
+ })
+ }
+
+ async fn new_session(
+ &self,
+ params: agent_client_protocol::NewSessionParams,
+ ) -> Result {
+ let session_id = self.sessions.lock().await.create_session(&self.config, ¶ms).await
+ .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?;
+ Ok(agent_client_protocol::NewSessionResponse { session_id })
+ }
+
+ async fn prompt(
+ &self,
+ _params: agent_client_protocol::PromptParams,
+ ) -> Result {
+ // Implemented in Task 5 — for now return a placeholder so the trait compiles.
+ Err(agent_client_protocol::Error::method_not_supported(
+ "session/prompt not yet implemented",
+ ))
+ }
+
+ async fn cancel(
+ &self,
+ _params: agent_client_protocol::CancelParams,
+ ) -> Result<(), agent_client_protocol::Error> {
+ Err(agent_client_protocol::Error::method_not_supported(
+ "session/cancel not yet implemented",
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn test_initialize_advertises_v1_capabilities() {
+ let agent = OperatorAcpAgent::new(Config::default());
+ let resp = agent.initialize(agent_client_protocol::InitializeParams::default()).await.unwrap();
+ // v1 does not implement session/load — explicitly assert that
+ assert!(!resp.agent_capabilities.load_session);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test (expect compilation issues)**
+
+Run: `cargo test acp::agent`
+Expected: Likely FAIL due to type/method-name mismatches with whatever version of the crate is pinned. Read the compile errors, look at `cargo doc`, and adjust the types/field names to match the crate's actual API. **Do not invent type names**; mirror what the crate exports.
+
+- [ ] **Step 3: Verify the test passes**
+
+Run: `cargo test acp::agent`
+Expected: PASS.
+
+- [ ] **Step 4: Stop for commit review**
+
+---
+
+### Task 4: Implement `SessionRegistry` — sessionId ↔ ticket mapping
+
+**Files:**
+- Create: `src/acp/session.rs`
+
+- [ ] **Step 1: Write a failing test**
+
+```rust
+//! ACP session registry.
+//!
+//! Maps ACP session IDs (UUIDs) to operator tickets and the spawned
+//! delegator subprocess for the in-flight prompt.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::Arc;
+use tokio::process::Child;
+use tokio::sync::Mutex;
+
+use crate::config::Config;
+
+#[derive(Default)]
+pub struct SessionRegistry {
+ sessions: HashMap,
+}
+
+pub struct AcpSession {
+ pub session_id: String,
+ pub ticket_id: String,
+ pub working_directory: PathBuf,
+ pub delegator: Option>>,
+}
+
+impl SessionRegistry {
+ /// Create a new session, allocating a ticket.
+ ///
+ /// The ticket is created in `.tickets/in-progress/` immediately
+ /// (because the editor is actively using it) using the existing
+ /// `services::ticket_manager`.
+ pub async fn create_session(
+ &mut self,
+ config: &Config,
+ params: &agent_client_protocol::NewSessionParams,
+ ) -> Result {
+ let session_id = uuid::Uuid::new_v4().to_string();
+ let ticket_id = crate::services::ticket_manager::create_in_progress(
+ config,
+ ¶ms.cwd,
+ &format!("ACP session from {}", params.cwd.display()),
+ ).await?;
+ let session = AcpSession {
+ session_id: session_id.clone(),
+ ticket_id,
+ working_directory: params.cwd.clone(),
+ delegator: None,
+ };
+ self.sessions.insert(session_id.clone(), session);
+ Ok(session_id)
+ }
+
+ pub fn get(&self, session_id: &str) -> Option<&AcpSession> {
+ self.sessions.get(session_id)
+ }
+
+ pub fn get_mut(&mut self, session_id: &str) -> Option<&mut AcpSession> {
+ self.sessions.get_mut(session_id)
+ }
+
+ pub fn remove(&mut self, session_id: &str) -> Option {
+ self.sessions.remove(session_id)
+ }
+
+ pub fn len(&self) -> usize {
+ self.sessions.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.sessions.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn test_session_registry_create_and_lookup() {
+ let mut reg = SessionRegistry::default();
+ let temp = tempfile::TempDir::new().unwrap();
+ let config = {
+ let mut c = Config::default();
+ c.paths.tickets_dir = temp.path().to_string_lossy().to_string();
+ c
+ };
+ let params = agent_client_protocol::NewSessionParams {
+ cwd: temp.path().to_path_buf(),
+ mcp_servers: vec![],
+ };
+ let session_id = reg.create_session(&config, ¶ms).await.unwrap();
+ assert!(reg.get(&session_id).is_some());
+ assert_eq!(reg.len(), 1);
+ }
+}
+```
+
+- [ ] **Step 2: Add the supporting `ticket_manager::create_in_progress` function**
+
+Grep for the existing ticket creation surface (`rg "fn create_ticket|fn write_ticket" src/`). If `create_in_progress` doesn't exist, add it alongside the existing creation function. It should write `.tickets/in-progress/{id}.md` with minimal frontmatter and return the ticket ID.
+
+- [ ] **Step 3: Run the test**
+
+Run: `cargo test acp::session`
+Expected: PASS.
+
+- [ ] **Step 4: Stop for commit review**
+
+---
+
+### Task 5: Implement `session/prompt` — bridge to delegator + stream updates
+
+**Files:**
+- Modify: `src/acp/agent.rs` (replace the placeholder `prompt` impl)
+- Create: `src/acp/translator.rs`
+
+This is the core of operator-as-ACP. When the editor calls `session/prompt`, operator (a) looks up the session, (b) spawns the configured delegator with the prompt as input, (c) reads delegator stdout line-by-line, (d) emits ACP `session/update` notifications for each chunk, (e) returns the final response when the delegator exits.
+
+- [ ] **Step 1: Implement the translator**
+
+Create `src/acp/translator.rs`:
+
+```rust
+//! Translate delegator subprocess output into ACP session/update notifications.
+//!
+//! Different delegators (Claude Code, Codex CLI, Gemini CLI) have different
+//! stdout formats. This module hosts per-delegator translators. v1 implements
+//! the simplest case: treat each non-empty line as an `assistant_message_chunk`.
+
+use agent_client_protocol::SessionUpdate;
+
+pub fn line_to_update(line: &str) -> Option {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ // Future: parse JSON-formatted output from `claude --output-format stream-json`
+ // and emit structured tool-call / tool-result updates. For v1, plain text.
+ Some(SessionUpdate::AssistantMessageChunk {
+ content: agent_client_protocol::ContentBlock::Text {
+ text: format!("{}\n", trimmed),
+ },
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_blank_line_ignored() {
+ assert!(line_to_update("").is_none());
+ assert!(line_to_update(" ").is_none());
+ }
+
+ #[test]
+ fn test_text_line_becomes_chunk() {
+ let update = line_to_update("hello").unwrap();
+ match update {
+ SessionUpdate::AssistantMessageChunk { content } => match content {
+ agent_client_protocol::ContentBlock::Text { text } => {
+ assert!(text.contains("hello"));
+ }
+ _ => panic!("expected text content"),
+ },
+ _ => panic!("expected assistant message chunk"),
+ }
+ }
+}
+```
+
+(Field names like `SessionUpdate::AssistantMessageChunk` and `ContentBlock::Text` come from the crate — adjust if the crate uses different names.)
+
+- [ ] **Step 2: Replace the placeholder `prompt` in `agent.rs`**
+
+Replace the placeholder `prompt` impl from Task 3 with:
+
+```rust
+ async fn prompt(
+ &self,
+ params: agent_client_protocol::PromptParams,
+ notifier: agent_client_protocol::Notifier,
+ ) -> Result {
+ use tokio::io::{AsyncBufReadExt, BufReader};
+
+ let session_id = params.session_id.clone();
+ let (cwd, ticket_id) = {
+ let sessions = self.sessions.lock().await;
+ let s = sessions.get(&session_id).ok_or_else(|| {
+ agent_client_protocol::Error::invalid_params(format!("Unknown session: {session_id}"))
+ })?;
+ (s.working_directory.clone(), s.ticket_id.clone())
+ };
+
+ // Build the delegator command from operator's configured default
+ let delegator = crate::agents::launcher::resolve_default_delegator(&self.config)
+ .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?;
+ let prompt_text = params.prompt.iter()
+ .filter_map(|block| match block {
+ agent_client_protocol::ContentBlock::Text { text } => Some(text.as_str()),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+
+ let mut child = tokio::process::Command::new(&delegator.command)
+ .args(&delegator.args)
+ .current_dir(&cwd)
+ .env("OPERATOR_TICKET_ID", &ticket_id)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .spawn()
+ .map_err(|e| agent_client_protocol::Error::internal(format!("spawn delegator: {e}")))?;
+
+ // Pipe the prompt to the delegator's stdin
+ if let Some(mut stdin) = child.stdin.take() {
+ use tokio::io::AsyncWriteExt;
+ let _ = stdin.write_all(prompt_text.as_bytes()).await;
+ let _ = stdin.write_all(b"\n").await;
+ }
+
+ // Stream stdout → session/update notifications
+ if let Some(stdout) = child.stdout.take() {
+ let mut lines = BufReader::new(stdout).lines();
+ while let Ok(Some(line)) = lines.next_line().await {
+ if let Some(update) = crate::acp::translator::line_to_update(&line) {
+ notifier.session_update(&session_id, update).await.ok();
+ }
+ }
+ }
+
+ let status = child.wait().await
+ .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?;
+
+ Ok(agent_client_protocol::PromptResponse {
+ stop_reason: if status.success() {
+ agent_client_protocol::StopReason::EndTurn
+ } else {
+ agent_client_protocol::StopReason::Refusal
+ },
+ })
+ }
+```
+
+(`agents::launcher::resolve_default_delegator` may need to be added if not present — it returns a `Delegator` struct with `command` and `args` based on operator's `[delegators]` config.)
+
+- [ ] **Step 3: Add a smoke test using `cat` as the delegator**
+
+Append to `src/acp/agent.rs::tests`:
+
+```rust
+ #[tokio::test]
+ async fn test_prompt_uses_cat_as_delegator_smoke() {
+ // Verifies the pipe-through path end-to-end using /bin/cat as the
+ // fake delegator. Skipped on non-unix.
+ #[cfg(unix)]
+ {
+ // ... configure agent with a Delegator { command: "cat", args: [] }
+ // ... call agent.prompt with text "hello"
+ // ... assert at least one AssistantMessageChunk with "hello"
+ }
+ }
+```
+
+(Filled in by the engineer using whatever mocking surface the crate's `Notifier` provides — likely a `MockNotifier` collected into a `Vec`.)
+
+- [ ] **Step 4: Run**
+
+Run: `cargo test acp::`
+Expected: PASS.
+
+- [ ] **Step 5: Stop for commit review**
+
+---
+
+### Task 6: `operator acp` CLI subcommand
+
+**Files:**
+- Create: `src/acp/server.rs`
+- Modify: `src/main.rs:131-266` (Commands), `:281-362` (match), bottom (`cmd_acp`)
+
+- [ ] **Step 1: Implement the stdio entrypoint in `src/acp/server.rs`**
+
+```rust
+//! ACP server lifecycle — runs the stdio listener.
+//!
+//! Mirrors the shape of `src/rest/server.rs:RestApiServer` so the TUI can
+//! query status / start / stop.
+
+use std::sync::{Arc, Mutex};
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+
+use crate::config::Config;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum AcpStatus {
+ Stopped,
+ Starting,
+ Running { active_sessions: usize },
+ Stopping,
+ Error(String),
+}
+
+pub struct AcpAgentServer {
+ config: Config,
+ status: Arc>,
+ shutdown_tx: Arc>>>,
+ task_handle: Arc>>>,
+}
+
+impl AcpAgentServer {
+ pub fn new(config: Config) -> Self {
+ Self {
+ config,
+ status: Arc::new(Mutex::new(AcpStatus::Stopped)),
+ shutdown_tx: Arc::new(Mutex::new(None)),
+ task_handle: Arc::new(Mutex::new(None)),
+ }
+ }
+
+ pub fn status(&self) -> AcpStatus {
+ self.status.lock().unwrap().clone()
+ }
+
+ pub fn is_running(&self) -> bool {
+ matches!(self.status(), AcpStatus::Running { .. })
+ }
+}
+
+/// Run the ACP stdio listener using the given reader/writer.
+///
+/// Production callers pass `tokio::io::stdin()` / `tokio::io::stdout()`.
+pub async fn run(config: Config, reader: R, writer: W) -> anyhow::Result<()>
+where
+ R: tokio::io::AsyncRead + Unpin + Send + 'static,
+ W: tokio::io::AsyncWrite + Unpin + Send + 'static,
+{
+ let agent = crate::acp::agent::OperatorAcpAgent::new(config);
+ // Use the crate's stdio adapter to wire the Agent impl onto a stdio
+ // transport. Exact function name from `cargo doc --open --package agent-client-protocol`.
+ agent_client_protocol::stdio::serve(agent, reader, writer).await?;
+ Ok(())
+}
+```
+
+- [ ] **Step 2: Add the CLI subcommand**
+
+In `src/main.rs`, add an `Acp` variant after `Mcp`:
+
+```rust
+ /// Run as an ACP agent over stdio (for use by Zed, JetBrains, Emacs, Kiro, etc.).
+ Acp,
+```
+
+Add the match arm:
+
+```rust
+ Some(Commands::Acp) => {
+ cmd_acp(&config).await?;
+ }
+```
+
+Add `cmd_acp` at the bottom of main.rs:
+
+```rust
+async fn cmd_acp(config: &Config) -> Result<()> {
+ tracing::info!("Starting ACP stdio agent");
+ crate::acp::server::run(config.clone(), tokio::io::stdin(), tokio::io::stdout()).await?;
+ tracing::info!("ACP agent stopped (stdin closed)");
+ Ok(())
+}
+```
+
+- [ ] **Step 3: Verify the binary runs**
+
+Run: `cargo build --release`
+Run:
+```
+printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' | ./target/release/operator acp
+```
+Expected: One line of JSON containing the operator agent's `initialize` response. (Exact shape determined by the crate.)
+
+- [ ] **Step 4: Stop for commit review**
+
+---
+
+### Task 7: Add `[acp]` config section
+
+**Files:**
+- Modify: `src/config.rs`
+
+- [ ] **Step 1: Add `AcpConfig`**
+
+```rust
+#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
+#[serde(deny_unknown_fields)]
+pub struct AcpConfig {
+ /// Whether to advertise the ACP stdio entrypoint in the status panel.
+ #[serde(default = "default_true")]
+ pub stdio_advertised: bool,
+ /// Default delegator to use when an ACP session/prompt arrives.
+ /// If None, falls back to `delegators[0]`.
+ #[serde(default)]
+ pub default_delegator: Option,
+ /// Maximum number of concurrent ACP sessions. Defaults to 4.
+ #[serde(default = "default_max_sessions")]
+ pub max_concurrent_sessions: usize,
+}
+
+impl Default for AcpConfig {
+ fn default() -> Self {
+ Self {
+ stdio_advertised: true,
+ default_delegator: None,
+ max_concurrent_sessions: 4,
+ }
+ }
+}
+
+fn default_max_sessions() -> usize { 4 }
+```
+
+- [ ] **Step 2: Add the field to `Config`**
+
+```rust
+ #[serde(default)]
+ pub acp: AcpConfig,
+```
+
+Update `Config::default()`.
+
+- [ ] **Step 3: Regenerate config docs**
+
+Run: `cargo run -- docs --only config`
+Verify the generated docs describe `[acp]`.
+
+- [ ] **Step 4: Stop for commit review**
+
+---
+
+### Task 8: ACP editor config snippet generator
+
+**Files:**
+- Create: `src/acp/client_configs.rs`
+
+Editors integrate ACP agents by registering them in editor-specific config files. Generate the right snippet for each.
+
+- [ ] **Step 1: Implement**
+
+```rust
+//! Generates copy-paste ACP agent registrations for various editors.
+
+use serde_json::{json, Value};
+use std::path::PathBuf;
+
+fn exe() -> PathBuf {
+ std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator"))
+}
+
+/// Zed: agent_servers entry in settings.json
+pub fn zed_snippet() -> Value {
+ json!({
+ "agent_servers": {
+ "operator": {
+ "command": exe().to_string_lossy(),
+ "args": ["acp"],
+ "env": {}
+ }
+ }
+ })
+}
+
+/// JetBrains: registered via the ACP Agent Registry; the operator entry
+/// is a JSON object that JetBrains imports.
+pub fn jetbrains_snippet() -> Value {
+ json!({
+ "name": "operator",
+ "displayName": "Operator (Kanban Orchestrator)",
+ "command": exe().to_string_lossy(),
+ "args": ["acp"],
+ "icon": "https://operator.untra.io/icon.png"
+ })
+}
+
+/// Emacs (agent-shell): elisp form to add to init
+pub fn emacs_snippet() -> String {
+ format!(
+ "(add-to-list 'agent-shell-acp-agents\n '(:name \"operator\" :command \"{}\" :args (\"acp\")))",
+ exe().display()
+ )
+}
+
+/// Kiro CLI: ~/.kiro/agents.toml entry
+pub fn kiro_snippet() -> String {
+ format!(
+ "[[agents]]\nname = \"operator\"\ncommand = \"{}\"\nargs = [\"acp\"]\n",
+ exe().display()
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_zed_snippet_shape() {
+ let snippet = zed_snippet();
+ assert_eq!(snippet["agent_servers"]["operator"]["args"][0], "acp");
+ }
+
+ #[test]
+ fn test_emacs_snippet_is_valid_elisp() {
+ let snippet = emacs_snippet();
+ assert!(snippet.starts_with("(add-to-list"));
+ assert!(snippet.contains("acp"));
+ }
+}
+```
+
+- [ ] **Step 2: Run tests**
+
+Run: `cargo test acp::client_configs`
+Expected: PASS.
+
+- [ ] **Step 3: Stop for commit review**
+
+---
+
+### Task 9: Integrate ACP into `StatusSnapshot` and `ConnectionsSection`
+
+**Files:**
+- Modify: `src/ui/status_panel.rs`
+- Modify: `src/ui/sections/connections_section.rs`
+
+- [ ] **Step 1: Add ACP fields to `StatusSnapshot`**
+
+```rust
+ /// Whether the `[acp]` stdio entrypoint is advertised.
+ pub acp_stdio_advertised: bool,
+ /// Active ACP sessions (only relevant if operator launched an ACP listener itself).
+ pub acp_active_sessions: usize,
+```
+
+- [ ] **Step 2: Add new `StatusAction` variants**
+
+```rust
+ /// Copy an ACP editor config snippet to the clipboard.
+ /// `editor` is one of: "zed", "jetbrains", "emacs", "kiro".
+ CopyAcpEditorConfig { editor: String },
+ /// Open ACP setup docs in the browser.
+ OpenAcpDocs,
+```
+
+- [ ] **Step 3: Add an ACP row to `ConnectionsSection::children`**
+
+After the MCP row added by the MCP plan (Task 9), append:
+
+```rust
+ rows.push(TreeRow {
+ section_id: SectionId::Connections,
+ depth: 1,
+ label: "ACP".into(),
+ description: if snapshot.acp_stdio_advertised {
+ if snapshot.acp_active_sessions > 0 {
+ format!("stdio · {} sessions", snapshot.acp_active_sessions)
+ } else {
+ "stdio ready".into()
+ }
+ } else {
+ "Disabled".into()
+ },
+ icon: if snapshot.acp_stdio_advertised { StatusIcon::Plug } else { StatusIcon::Cross },
+ is_header: false,
+ actions: ActionSet {
+ primary: StatusAction::CopyAcpEditorConfig { editor: "zed".to_string() },
+ back: StatusAction::None,
+ special: StatusAction::CopyAcpEditorConfig { editor: "jetbrains".to_string() },
+ special_meta: Some(ActionMeta { title: "JBrn", tooltip: "Copy JetBrains ACP registry snippet" }),
+ refresh: StatusAction::OpenAcpDocs,
+ refresh_meta: Some(ActionMeta { title: "Docs", tooltip: "Open ACP setup docs" }),
+ },
+ health: SectionHealth::Gray,
+ });
+```
+
+- [ ] **Step 4: Update `StatusSnapshot` construction site**
+
+Same pattern as the MCP plan's Task 9 Step 4: populate the new fields. `acp_active_sessions` defaults to `0` unless operator is also hosting an ACP listener itself (uncommon — the editor typically hosts).
+
+- [ ] **Step 5: Update test snapshots**
+
+Search test files for `StatusSnapshot {` (now including the MCP fields from the MCP plan) and add `acp_stdio_advertised: true, acp_active_sessions: 0`.
+
+- [ ] **Step 6: Add a test for the ACP row**
+
+```rust
+ #[test]
+ fn test_connections_acp_row_present() {
+ let section = ConnectionsSection;
+ let snap = base_snapshot();
+ let children = section.children(&snap);
+ let row = children.iter().find(|r| r.label == "ACP");
+ assert!(row.is_some(), "ACP row should always be present");
+ }
+```
+
+- [ ] **Step 7: Wire the action handlers**
+
+Find the `StatusAction` dispatch site (grep `StatusAction::StartApi =>`). Add:
+- `CopyAcpEditorConfig { editor }` — generate via `crate::acp::client_configs`, write to clipboard
+- `OpenAcpDocs` — open `https://operator.untra.io/acp/` via the existing `OpenUrl` helper
+
+- [ ] **Step 8: Verify**
+
+Run: `cargo fmt && cargo clippy -- -D warnings && cargo test`
+Expected: green.
+
+- [ ] **Step 9: Stop for commit review**
+
+---
+
+### Task 10: End-to-end integration test
+
+**Files:**
+- Create: `tests/acp_integration.rs`
+
+- [ ] **Step 1: Write the test**
+
+```rust
+//! Spawn `operator acp` and roundtrip an initialize request.
+
+use std::process::Stdio;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::process::Command;
+
+#[tokio::test]
+async fn test_operator_acp_initialize_roundtrip() {
+ let exe = env!("CARGO_BIN_EXE_operator");
+ let mut child = Command::new(exe)
+ .arg("acp")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn operator acp");
+
+ let mut stdin = child.stdin.take().unwrap();
+ let stdout = child.stdout.take().unwrap();
+ let mut reader = BufReader::new(stdout).lines();
+
+ // Initialize message — exact shape depends on the ACP crate version
+ let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
+"#;
+ stdin.write_all(init).await.unwrap();
+ stdin.flush().await.unwrap();
+
+ let line = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line())
+ .await.unwrap().unwrap().unwrap();
+ let resp: serde_json::Value = serde_json::from_str(&line).unwrap();
+ assert_eq!(resp["id"], 1);
+ assert!(resp["result"].is_object());
+
+ drop(stdin);
+ let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await;
+}
+```
+
+- [ ] **Step 2: Run**
+
+Run: `cargo test --test acp_integration -- --nocapture`
+Expected: PASS.
+
+- [ ] **Step 3: Final verification**
+
+Run:
+```
+cargo fmt
+cargo clippy -- -D warnings
+cargo test
+```
+Expected: green.
+
+- [ ] **Step 4: Stop for user to commit**
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- ACP crate dependency (Task 1) — covered
+- `Agent` trait impl with initialize / new_session / prompt / cancel (Tasks 3, 5) — covered (`session/cancel` is left as a stub in Task 3; v1.1 should implement it by signaling the delegator subprocess)
+- Session ↔ ticket mapping (Task 4) — covered
+- Delegator bridge + stream translation (Task 5) — covered
+- CLI subcommand (Task 6) — covered
+- Config section (Task 7) — covered
+- Editor config snippets (Task 8) — covered
+- Status integration (Task 9) — covered
+- End-to-end test (Task 10) — covered
+
+**Open assumptions to verify before starting:**
+1. Exact crate name, version, and trait shape of `agent-client-protocol`. Task 1 validates.
+2. The `Notifier` injection on `Agent::prompt` — the trait method signature in Task 3 shows `_params` only, but Task 5 uses `notifier: Notifier`. Confirm the real signature; the crate likely passes the notifier via a `&self` field or an additional parameter.
+3. The existence of `agents::launcher::resolve_default_delegator` — may need to be added.
+4. `services::ticket_manager::create_in_progress` — may need to be added.
+
+**v1 limitations explicitly accepted:**
+- No `session/load` — ACP sessions don't survive operator restart. (Easy to add later: the registry writes to `.operator/acp-sessions.json`.)
+- No `session/cancel` — a v1.1 task.
+- No structured tool-call translation. The delegator's raw text becomes `AssistantMessageChunk`. When using Claude Code with `--output-format stream-json`, parse and emit structured `ToolCall` updates instead. (Task 5's translator is the single chokepoint to extend.)
+- No `fs/*` request forwarding. The delegator subprocess does its own filesystem access. This means file edits don't surface as approvable actions in the editor — an explicit v1 tradeoff. If a target editor needs approval routing, switch from "delegator owns FS" to "operator owns FS, asks editor for permission" in v2.
+- Single-tenant: operator runs in the project directory the editor opens it in. Multi-project sessions need a v2 design decision (do separate editor windows share an operator process, or each get their own?).
diff --git a/docs/superpowers/plans/2026-05-16-acp-zed-extension.md b/docs/superpowers/plans/2026-05-16-acp-zed-extension.md
new file mode 100644
index 0000000..d8b570a
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-16-acp-zed-extension.md
@@ -0,0 +1,320 @@
+# Plan: ACP Integration for the Operator Zed Extension
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+>
+> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically.
+
+## Context
+
+A sibling plan, `docs/superpowers/plans/2026-05-16-acp-agent.md`, wires the **operator binary** itself as an ACP agent (`operator acp` over stdio). This follow-up plan picks up where that one ends: integrate ACP into the **`zed-extension/`** package so Zed users can launch Operator from the agent panel — not just via slash commands.
+
+Today the Zed extension (`zed-extension/src/lib.rs`, `extension.toml`) is a WASM-sandboxed slash-command bridge: 11 `/op-*` commands shell out to `curl` against the local REST API (`http://localhost:7008`). It does not register an ACP agent. The Zed agent panel surface is unused.
+
+This plan adds ACP-agent registration to the existing extension so:
+- Zed's agent panel shows **Operator** as a selectable agent alongside Claude / Codex / Gemini CLI
+- Selecting Operator and opening a new thread spawns `operator acp` in the project root, wired to Zed via JSON-RPC stdio
+- The existing `/op-*` slash commands stay — they cover different needs (status queries, queue inspection) and complement the agent thread
+
+Workflow this enables: a developer in a Zed window for project X opens the agent panel, picks Operator, and chats. Operator (per the upstream ACP plan) creates a ticket from that chat, picks the next queued ticket if one matches, and delegates to Claude Code / Codex / Gemini under the hood — streaming the delegator's output back as `session/update` notifications visible inside Zed.
+
+## Hard Dependency
+
+This plan **assumes the operator-side ACP plan is complete and merged.** Specifically:
+- `operator acp` subcommand exists and serves a working `Agent` impl over stdio
+- `initialize`, `session/new`, and `session/prompt` roundtrip cleanly
+- `tests/acp_integration.rs` is green
+
+If `operator acp` doesn't exist yet, **execute the upstream plan first.** This plan adds Zed-side packaging on top.
+
+## How Zed Discovers ACP Agents (Key Facts)
+
+From `https://zed.dev/docs/extensions/agent-servers` and the user-config docs:
+
+1. **Manifest registration:** A Zed extension declares ACP agents via `[agent_servers.]` blocks in `extension.toml`. Each block has `name`, optional `icon`, optional `env`, plus per-platform `targets.-` entries with `archive` (download URL), `cmd`, `args`, and recommended `sha256`.
+2. **User override:** Users can override extension-provided agents (or add custom ones) under `agent_servers` in `settings.json`. The custom form is `{"type": "custom", "command": "...", "args": [...], "env": {...}}`. The registry form is `{"type": "registry", ...}` for curated entries.
+3. **Lifecycle:** Zed spawns the configured command as a subprocess with `cwd` = the project root and pipes JSON-RPC over its stdio. No WASM API call is required from the extension code (`src/lib.rs`).
+4. **Forwarded context:** Zed passes the project root as `cwd` in `session/new`, plus MCP server configurations, and forwards model/mode selection if the agent advertises support.
+
+Consequence: the **majority of this plan is `extension.toml` + docs + a release pipeline** — `src/lib.rs` does not need ACP code, because ACP runs in the operator binary, not in the WASM sandbox.
+
+## Critical Files
+
+**Modify:**
+- `zed-extension/extension.toml` — add `[agent_servers.operator]` block with platform targets
+- `zed-extension/README.md` — document the agent panel flow alongside slash commands
+- `zed-extension/TODO.md` — mark ACP agent panel as ✅ implemented; audit "not possible" entries against what ACP unblocks
+- `bump-version.sh` — bump the extension's version when shipping
+- `.github/workflows/*.yml` (or equivalent) — publish per-platform operator archives whose URLs are referenced from `extension.toml`
+
+**Create:**
+- `zed-extension/docs/acp-setup.md` — short walkthrough: install the extension, configure `agent_servers` in `settings.json` for dev, or use the bundled archive in release mode
+- `zed-extension/tests/acp_smoke.sh` (or a CI step) — end-to-end smoke that builds operator, starts it under `operator acp`, sends `initialize`, asserts the JSON response
+- `src/integrations/inventory.rs` (operator crate) — single source-of-truth list of operator capabilities exposed across surfaces
+- `tests/surface_parity.rs` (operator crate) — enforces that every capability has both a slash-command and an ACP-tool entry point
+
+**Do NOT modify:**
+- `zed-extension/src/lib.rs` — slash commands stay as-is. ACP runs out-of-process in the operator binary, not in the extension WASM.
+
+## Tasks
+
+### Task 1: Confirm operator-side ACP is functional
+
+- [ ] **Step 1:** Run `cargo run -- acp < /tmp/init.json` from operator root with a hand-rolled JSON-RPC `initialize` request. Assert it produces a valid `InitializeResponse` containing `agentCapabilities` with `loadSession: false` (per upstream plan v1).
+- [ ] **Step 2:** Run `cargo test --test acp_integration` and confirm green.
+- [ ] **Step 3:** If either fails, **stop** — the upstream plan is the blocker; finish that first.
+
+---
+
+### Task 2: Add `[agent_servers.operator]` to `extension.toml`
+
+**Files:**
+- Modify: `zed-extension/extension.toml`
+
+- [ ] **Step 1:** Add the agent_servers block after `[slash_commands]`:
+
+```toml
+[agent_servers.operator]
+name = "Operator"
+icon = "https://operator.untra.io/icon.png"
+
+[agent_servers.operator.targets.darwin-aarch64]
+archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-darwin-aarch64.tar.gz"
+cmd = "./operator"
+args = ["acp"]
+sha256 = "{SHA256}"
+
+[agent_servers.operator.targets.darwin-x86_64]
+archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-darwin-x86_64.tar.gz"
+cmd = "./operator"
+args = ["acp"]
+sha256 = "{SHA256}"
+
+[agent_servers.operator.targets.linux-x86_64]
+archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-linux-x86_64.tar.gz"
+cmd = "./operator"
+args = ["acp"]
+sha256 = "{SHA256}"
+
+[agent_servers.operator.targets.linux-aarch64]
+archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-linux-aarch64.tar.gz"
+cmd = "./operator"
+args = ["acp"]
+sha256 = "{SHA256}"
+```
+
+- [ ] **Step 2:** Pin `{VERSION}` to the operator version that first contains `operator acp`. Bake `{SHA256}` per target at release time via `bump-version.sh` (or accept manual updates in Task 3).
+- [ ] **Step 3:** Confirm `extension.toml` parses by building the extension:
+ ```bash
+ cd zed-extension && cargo build --release --target wasm32-wasip1
+ ```
+- [ ] **Step 4:** Stop for commit review.
+
+---
+
+### Task 3: Update the release pipeline to ship operator archives
+
+The `archive` URLs in Task 2 must resolve to real artifacts. Inventory `.github/workflows/` and `bump-version.sh` first to see what exists; add the missing pieces.
+
+**Files:**
+- Modify: `.github/workflows/*.yml` (release workflow)
+- Modify: `bump-version.sh`
+
+- [ ] **Step 1:** Add a CI step that, on a tagged release, produces `operator-{os}-{arch}.tar.gz` for the four target tuples in Task 2. Each archive contains the `operator` binary at the archive root (so `cmd = "./operator"` resolves).
+- [ ] **Step 2:** Add a CI step that computes each archive's `sha256` and rewrites `zed-extension/extension.toml` with the real `{SHA256}` and `{VERSION}` values before publishing the extension.
+- [ ] **Step 3:** Verify by tagging a pre-release and downloading one archive locally:
+ ```bash
+ tar -tzf operator-darwin-aarch64.tar.gz | head
+ ```
+ Expected: `operator` appears at the top level.
+- [ ] **Step 4:** Stop for commit review.
+
+---
+
+### Task 4: Document the dev-mode override
+
+Most operator developers will not consume the archive — they'll point Zed at their local debug build. Document this clearly so the extension is usable before the release pipeline is finished.
+
+**Files:**
+- Create: `zed-extension/docs/acp-setup.md`
+
+- [ ] **Step 1:** Write the setup doc, including:
+
+ ````markdown
+ # Operator ACP Setup
+
+ ## Dev mode (local binary)
+
+ Add to `~/.config/zed/settings.json` (or per-project `.zed/settings.json`):
+
+ ```jsonc
+ {
+ "agent_servers": {
+ "operator": {
+ "type": "custom",
+ "command": "/Users/you/Documents/gbqr-us/operator/target/debug/operator",
+ "args": ["acp"],
+ "env": {
+ "RUST_LOG": "operator=debug"
+ }
+ }
+ }
+ }
+ ```
+
+ This override takes precedence over the extension-provided `[agent_servers.operator]` block, so you can run an unreleased build of operator without rebuilding the extension.
+
+ ## Verify it works
+
+ 1. Open Zed's agent panel
+ 2. Pick **Operator** from the agent selector
+ 3. Open a new thread
+ 4. Type `hello` — you should see streamed output
+
+ ## Release mode
+
+ Install the extension from the Zed extension registry. Zed fetches the matching `operator-{os}-{arch}.tar.gz` archive automatically; no `settings.json` changes needed.
+
+ ## Known issues
+
+ (Populated as Task 7 surfaces them.)
+ ````
+
+- [ ] **Step 2:** Cross-link from `zed-extension/README.md` and the operator-side `docs/cli/index.md` ACP section.
+- [ ] **Step 3:** Stop for commit review.
+
+---
+
+### Task 5: Rewrite README + TODO to reflect dual-surface
+
+`zed-extension/README.md` and `zed-extension/TODO.md` currently document only the slash-command surface and list many features as "Not Possible in Zed." ACP unblocks several. Update them honestly.
+
+**Files:**
+- Modify: `zed-extension/README.md`
+- Modify: `zed-extension/TODO.md`
+
+- [ ] **Step 1:** In `README.md`, add a top-level "Two ways to use the extension" section:
+ 1. **Slash commands** — existing, REST-backed status queries surfaced in the AI assistant
+ 2. **Agent panel** — new, ACP-backed full sessions inside Zed
+- [ ] **Step 2:** State explicitly that the two surfaces are intentionally parallel — they cover the same operator concepts (queue, tickets, agents, kanban) but from different entry points. Task 6 enforces this with tests.
+- [ ] **Step 3:** In `TODO.md`, audit each "Not Possible in Zed" row honestly:
+ - Sidebar Views → still N/A (ACP doesn't help here)
+ - Webhook Server → still N/A
+ - **Terminal Management** → N/A in extension, but ACP sessions provide an in-IDE chat surface
+ - **Status Bar** → still N/A
+ - **File System Watching** → still N/A in WASM, but the ACP path lets the agent see filesystem state via `fs/read_text_file` requests routed to Zed
+- [ ] **Step 4:** Be specific about what ACP does and doesn't add. Don't oversell.
+- [ ] **Step 5:** Stop for commit review.
+
+---
+
+### Task 6: Structural-parity tests between slash-command and ACP surfaces
+
+The two surfaces (slash commands, ACP threads) must stay in structural sync: adding a new operator capability shouldn't expose it on only one side. Drive both surfaces from a single shared inventory of operator capabilities and let CI enforce the contract.
+
+**Files:**
+- Create: `src/integrations/inventory.rs` (operator crate)
+- Create: `tests/surface_parity.rs` (operator crate)
+- Create: `zed-extension/tests/acp_smoke.sh`
+
+- [ ] **Step 1: Add `src/integrations/inventory.rs`** (operator-side, not WASM) enumerating user-facing operator capabilities. One entry per:
+ ```rust
+ pub struct Capability {
+ pub id: &'static str, // e.g. "queue.list"
+ pub description: &'static str,
+ pub rest_endpoint: Option<&'static str>, // path matched against OpenAPI
+ pub slash_command_id: Option<&'static str>, // e.g. "op-queue"
+ pub acp_tool_id: Option<&'static str>, // e.g. "operator__queue_list"
+ }
+
+ pub const INVENTORY: &[Capability] = &[ /* ... */ ];
+ ```
+ Use the existing OpenAPI generation output as the source-of-truth for `rest_endpoint` values.
+
+- [ ] **Step 2: Add `tests/surface_parity.rs`** asserting:
+ 1. Every `slash_command_id` in the inventory corresponds to a registered slash command in `zed-extension/extension.toml` (parse the TOML, check the `[slash_commands]` table).
+ 2. Every `acp_tool_id` is exposed by the operator ACP agent. The exact mechanism depends on what the upstream ACP plan ships — if v1 only delegates to Claude/Codex/Gemini, the ACP surface may expose operator-specific tools through the co-shipped MCP server (covered by the MCP plan at `2026-05-16-mcp-stdio-and-tickets.md`).
+ 3. Every entry has BOTH a `slash_command_id` AND an `acp_tool_id`, OR an explicit allow-list reason in a separate `tests/fixtures/surface_exceptions.toml`. The default is parity; deviations require an explicit rationale.
+
+- [ ] **Step 3: Add `zed-extension/tests/acp_smoke.sh`** for runtime smoke (separate from parity):
+ ```bash
+ #!/usr/bin/env bash
+ set -euo pipefail
+ cd "$(dirname "$0")/../.."
+ cargo build --bin operator
+ printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' \
+ | ./target/debug/operator acp \
+ | head -1 \
+ | jq -e '.result.agentCapabilities'
+ ```
+
+- [ ] **Step 4: Wire both into CI.** Parity runs as `cargo test --test surface_parity`. Smoke runs as a shell job. Any failure blocks releases.
+
+- [ ] **Step 5: Document the contract in `zed-extension/README.md`:** "Adding a new operator capability requires registering it in `src/integrations/inventory.rs`. CI will fail if the new entry lacks either a slash command or an ACP tool (without an explicit exception entry)."
+
+- [ ] **Step 6:** Run full validation:
+ ```bash
+ cargo fmt && cargo clippy -- -D warnings && cargo test
+ bash zed-extension/tests/acp_smoke.sh
+ ```
+ Expected: green.
+
+- [ ] **Step 7:** Stop for commit review.
+
+---
+
+### Task 7: User-facing verification in Zed
+
+Before declaring the integration shipped, manually verify in the real editor — CI cannot prove this.
+
+- [ ] **Step 1:** Install the extension as dev:
+ ```bash
+ cd zed-extension && cargo build --release --target wasm32-wasip1
+ mkdir -p ~/.local/share/zed/extensions/installed/operator-dev/
+ cp extension.toml ~/.local/share/zed/extensions/installed/operator-dev/
+ cp target/wasm32-wasip1/release/operator_zed.wasm ~/.local/share/zed/extensions/installed/operator-dev/extension.wasm
+ ```
+- [ ] **Step 2:** Apply the `settings.json` override from Task 4.
+- [ ] **Step 3:** Open a Zed project that has `.tickets/` (the operator repo itself works).
+- [ ] **Step 4:** Open agent panel → confirm **Operator** appears in the agent selector.
+- [ ] **Step 5:** Open a thread → confirm the prompt arrives and streams a response from the configured delegator.
+- [ ] **Step 6:** Cancel a thread mid-stream → confirm the delegator process exits (per upstream plan's `session/cancel` task — may be a v1.1 follow-up).
+- [ ] **Step 7:** Document any rough edges in `zed-extension/docs/acp-setup.md` under "Known issues."
+- [ ] **Step 8:** Stop for user to commit.
+
+---
+
+## Verification
+
+End-to-end acceptance passes when:
+1. `cargo build --release --target wasm32-wasip1` from `zed-extension/` produces a valid WASM artifact.
+2. `bash zed-extension/tests/acp_smoke.sh` exits 0.
+3. `cargo test --test surface_parity` exits 0.
+4. In Zed with the dev override: opening the agent panel → Operator → new thread → typing `hello` → streamed text returns. (Human verification — primary gate.)
+5. The four release archive URLs in `extension.toml` resolve to real artifacts whose SHA256 matches.
+
+## Self-Review
+
+**Spec coverage:**
+- Operator-side ACP confirmation (Task 1) — covered
+- Extension manifest (Task 2) — covered
+- Release pipeline (Task 3) — covered
+- Dev-mode override docs (Task 4) — covered
+- README + TODO updates (Task 5) — covered
+- Structural-parity tests + smoke (Task 6) — covered
+- Manual Zed verification (Task 7) — covered
+
+**Open assumptions:**
+- The operator-side ACP plan ships first. This plan is gated on `operator acp` working — without it there's nothing for Zed to connect to.
+- Operator's release pipeline can produce per-target archives. If today's pipeline only produces a single platform, Task 3 expands.
+- The Zed `[agent_servers.]` manifest schema is stable. Zed documents it publicly, but the schema is newer than the slash-command API and may shift.
+
+**Explicit non-goals (v1):**
+- No JetBrains, Emacs, Kiro, etc. integration. The operator-side plan generates config snippets (Task 8 there) covering those — they don't need a per-editor extension because they read user config directly.
+- No deprecation of slash commands. They serve different workflows.
+- No sidebar / status bar / file watcher work — ACP doesn't unblock these in Zed's current extension API.
+- No per-ticket "Open in Operator agent panel" deep-link from slash commands. Conceivable but out of scope here.
+
+**Resolved decisions:**
+1. Release archives are in scope for v1 (Task 3 ships per-platform tarballs + SHA256 + `extension.toml` pinning).
+2. Slash commands stay as a parallel surface. Task 6 enforces structural parity between the two surfaces with tests so they cannot drift silently.
+3. Plan file lives at `docs/superpowers/plans/2026-05-16-acp-zed-extension.md` (sibling of the operator-side ACP plan).
diff --git a/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md b/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md
new file mode 100644
index 0000000..2004b80
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md
@@ -0,0 +1,1580 @@
+# Operator MCP — Stdio Transport, Ticket Tools, and Status Integration
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+>
+> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically.
+
+**Goal:** Add stdio transport for operator's MCP server, expand the tool surface to cover ticket queue read/write operations, advertise the stdio entrypoint through the existing `McpDescriptorResponse` so the vscode-extension can pick it up, and surface MCP lifecycle through operator's existing `StatusSection` pattern so users can toggle it and copy client configs from the dashboard.
+
+**Architecture:** The HTTP/SSE MCP transport (`src/mcp/transport.rs`) already implements the protocol bound to `ApiState` and exposes seven read-only tools. `src/mcp/descriptor.rs` already publishes a discovery endpoint that the vscode-extension consumes. The structural move is to (1) extract the JSON-RPC dispatch core into a transport-agnostic module, (2) add a stdio transport that reads line-delimited JSON-RPC from stdin and writes to stdout, (3) add an `operator mcp` CLI subcommand as the entrypoint MCP clients launch, (4) expand the tool surface with ticket-queue operations that call into the existing `src/queue/Queue` (sync API, wrapped via `tokio::task::spawn_blocking`) and `TicketCreator`, (5) extend the existing descriptor with an optional `stdio: StdioCommand` field so IDE extensions can switch transports without a new endpoint, and (6) add a row to `ConnectionsSection` mirroring the `Operator API` lifecycle pattern. Stdio is the dominant MCP transport across Claude Code, Cursor, VS Code, Zed, and JetBrains; the existing HTTP transport stays as-is for network use.
+
+**Tech Stack:** Rust 1.88+, tokio, serde_json, axum (existing for HTTP), clap (CLI), ratatui (status integration), ts-rs + schemars (for config + descriptor binding regeneration). No new top-level dependencies required — `tokio::io::{AsyncBufReadExt, AsyncWriteExt}` is sufficient for the stdio loop, and `EditFile` action + existing file I/O cover the client-config snippet delivery (no clipboard dependency).
+
+---
+
+## Pre-Flight (verify before starting)
+
+Run each of these from the operator project root and confirm the output matches the assumption. If any differ, fix the relevant task before implementing.
+
+1. `rg -n "pub fn claim_ticket|pub fn complete_ticket|pub fn list_queue" src/queue/`
+ → should find sync methods in `src/queue/mod.rs` around lines 134-158.
+2. `rg -n "pub struct McpDescriptorResponse" src/mcp/`
+ → should find `src/mcp/descriptor.rs:14`. Confirms the descriptor already exists (do not re-create it).
+3. `rg -n "mcp_sessions" src/rest/state.rs`
+ → should find the field on `ApiState` typed `Arc>>` where `Mutex` is `tokio::sync::Mutex` (line 7 imports).
+4. `head -50 vscode-extension/src/mcp-connect.ts`
+ → confirm the consumer reads `server_name`, `transport_url` from the descriptor. The descriptor extension in Task 6.5 must stay additive (Option field with `skip_serializing_if`).
+5. `cargo build --release && ls target/release/operator`
+ → confirms the binary path that `client_configs::current_exe()` will return.
+6. `rg -n "pub struct TicketCreator|pub fn create_ticket_with_values" src/queue/creator.rs`
+ → should find the existing creator at lines 16-68. Confirms the headless variant in Task 5.5 is additive.
+
+---
+
+## Critical Structural Approach
+
+Three decisions lock the rest of the plan:
+
+1. **Transport-agnostic handler.** The function `handle_jsonrpc(&JsonRpcRequest, &ApiState) -> JsonRpcResponse` in `src/mcp/transport.rs` already has the right shape but lives in a file named after HTTP. Extract it to `src/mcp/handler.rs` unchanged. Both transports import it.
+
+2. **`ApiState` is the shared substrate; `Queue` is the ticket-write surface.** Existing tools call `routes::*` handlers, which take `State`. New ticket-queue tools should construct `crate::queue::Queue::new(&state.config)` and call its **sync** methods (`list_queue`, `claim_ticket(&Ticket)`, `complete_ticket(&Ticket)`, `return_to_queue(&Ticket)`) inside `tokio::task::spawn_blocking`. Creation uses `crate::queue::creator::TicketCreator` via a new headless variant (Task 5.5). Do **not** introduce an in-process HTTP roundtrip.
+
+3. **No new server lifecycle for stdio.** Stdio MCP is spawned by the client (Claude Code, Cursor, VS Code, …) as a subprocess — it does not run inside the operator TUI. The HTTP-MCP toggle (`config.mcp.http_enabled`) is implemented by conditionally including the MCP routes in `build_router`; flipping it requires an API restart. There is **no** `McpStdioServer` struct, no shutdown channels for stdio. Status display in `ConnectionsSection` reflects (a) whether HTTP MCP routes are mounted on the current API server and (b) whether the stdio entrypoint is advertised in the descriptor.
+
+---
+
+## File Structure
+
+**Create:**
+- `src/mcp/handler.rs` — transport-agnostic `handle_jsonrpc` + JSON-RPC types
+- `src/mcp/stdio.rs` — line-delimited stdio JSON-RPC loop
+- `src/mcp/tickets.rs` — ticket-queue tools (separated from REST-wrapping `tools.rs`)
+- `src/mcp/resources.rs` — MCP resources (tickets exposed as URIs)
+- `src/mcp/client_configs.rs` — generates copy-paste config snippets for Claude Code, Claude Desktop, Cursor, VS Code, Zed
+- `tests/mcp_stdio_integration.rs` — end-to-end test: spawn `operator mcp`, send init + tools/list over a pipe
+
+**Modify:**
+- `src/mcp/mod.rs` — add `handler`, `stdio`, `tickets`, `resources`, `client_configs` modules
+- `src/mcp/transport.rs` — import `handle_jsonrpc` from `handler.rs`; delete the local copy
+- `src/mcp/tools.rs` — merge ticket tools from `tickets.rs` into `all_tool_definitions` and `execute_tool`; update tool-count assertion
+- `src/mcp/descriptor.rs` — extend existing `McpDescriptorResponse` with `stdio: Option`; inject `State` into the handler so it can read `config.mcp.stdio_advertised`
+- `src/rest/mod.rs` — gate MCP route mounting on `config.mcp.http_enabled`
+- `src/queue/creator.rs` — add `create_ticket_headless` (no editor launch)
+- `src/main.rs` — add `Commands::Mcp` variant and `cmd_mcp` async fn
+- `src/config.rs` — add `McpConfig` struct (fields: `http_enabled`, `stdio_advertised`, `expose_ticket_write_tools`) with `JsonSchema + TS` derives, and field on `Config`
+- `src/ui/status_panel.rs` — add `mcp_http_status: McpHttpStatus` + `mcp_stdio_advertised: bool` + `mcp_active_sessions: usize` to `StatusSnapshot`; add new `StatusAction` variants: `ToggleMcpHttp`, `WriteAndOpenMcpClientConfig { client: String }`, `OpenMcpDocs`
+- `src/ui/sections/connections_section.rs` — add an "MCP" row after the "Operator API" row
+- `src/ui/dashboard.rs` — populate the new `StatusSnapshot` fields at the construction site
+- `src/app/status_actions.rs` — handle the three new `StatusAction` variants
+
+Session files live under `/operator/` (see `src/rest/server.rs:27`). Generated client-config snippets go to `/operator/mcp/.json`.
+
+---
+
+## Tasks
+
+### Task 1: Extract the JSON-RPC handler to a transport-agnostic module
+
+**Files:**
+- Create: `src/mcp/handler.rs`
+- Modify: `src/mcp/transport.rs`
+- Modify: `src/mcp/mod.rs:7-9`
+
+- [ ] **Step 1: Move types and dispatch to `handler.rs`**
+
+Create `src/mcp/handler.rs` with the contents below. These are the existing types from `transport.rs:24-51` plus the existing `handle_jsonrpc` fn from `transport.rs:136-233`, with `pub` added to `handle_jsonrpc` and `JsonRpcResponse`/`JsonRpcError` so other transports can use them.
+
+```rust
+//! Transport-agnostic JSON-RPC handler for MCP.
+//!
+//! Both the HTTP/SSE transport (`transport.rs`) and the stdio transport
+//! (`stdio.rs`) dispatch through `handle_jsonrpc`.
+
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use crate::mcp::tools;
+use crate::rest::state::ApiState;
+
+#[derive(Debug, Deserialize)]
+pub struct JsonRpcRequest {
+ #[allow(dead_code)]
+ pub jsonrpc: String,
+ pub id: Option,
+ pub method: String,
+ #[serde(default)]
+ pub params: Value,
+}
+
+#[derive(Debug, Serialize)]
+pub struct JsonRpcResponse {
+ pub jsonrpc: String,
+ pub id: Value,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub result: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct JsonRpcError {
+ pub code: i64,
+ pub message: String,
+}
+
+pub async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse {
+ let id = request.id.clone().unwrap_or(Value::Null);
+ match request.method.as_str() {
+ "initialize" => JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: Some(json!({
+ "protocolVersion": "2024-11-05",
+ "capabilities": { "tools": {}, "resources": { "subscribe": false, "listChanged": false } },
+ "serverInfo": { "name": "operator", "version": env!("CARGO_PKG_VERSION") }
+ })),
+ error: None,
+ },
+ "notifications/initialized" => JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: Some(json!({})),
+ error: None,
+ },
+ "tools/list" => {
+ let tool_defs = tools::all_tool_definitions();
+ let tools_json: Vec = tool_defs.into_iter().map(|t| json!({
+ "name": t.name,
+ "description": t.description,
+ "inputSchema": t.input_schema,
+ })).collect();
+ JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: Some(json!({ "tools": tools_json })),
+ error: None,
+ }
+ }
+ "tools/call" => {
+ let tool_name = request.params.get("name").and_then(|v| v.as_str()).unwrap_or("");
+ let arguments = request.params.get("arguments").cloned().unwrap_or_else(|| json!({}));
+ match tools::execute_tool(tool_name, arguments, state).await {
+ Ok(result) => {
+ let text = serde_json::to_string_pretty(&result).unwrap_or_default();
+ JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: Some(json!({ "content": [{ "type": "text", "text": text }] })),
+ error: None,
+ }
+ }
+ Err(e) => JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: None,
+ error: Some(JsonRpcError { code: -32000, message: e }),
+ },
+ }
+ }
+ _ => JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: None,
+ error: Some(JsonRpcError {
+ code: -32601,
+ message: format!("Method not found: {}", request.method),
+ }),
+ },
+ }
+}
+```
+
+Note: the `initialize` capabilities object already advertises `resources` here so Task 6 doesn't need to re-edit it.
+
+- [ ] **Step 2: Update `transport.rs` to import from `handler.rs`**
+
+Replace lines 24-51 and the entire `handle_jsonrpc` function (lines 136-233) in `src/mcp/transport.rs` with:
+
+```rust
+use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest};
+```
+
+at the top, and update the `message_handler` call site (line 125) to use the imported `handle_jsonrpc`. The local `JsonRpcResponse` and `JsonRpcError` types are no longer needed in `transport.rs` — delete them.
+
+- [ ] **Step 3: Wire the new module into `src/mcp/mod.rs`**
+
+Edit `src/mcp/mod.rs` to add the new modules (some don't exist yet — comment them out until the corresponding task creates the file, or add them all and let `cargo check` fail until the files land):
+
+```rust
+//! Model Context Protocol (MCP) integration for Operator.
+
+pub mod client_configs;
+pub mod descriptor;
+pub mod handler;
+pub mod resources;
+pub mod stdio;
+pub mod tickets;
+pub mod tools;
+pub mod transport;
+```
+
+- [ ] **Step 4: Move the existing handler tests to `handler.rs`**
+
+The six tests in `src/mcp/transport.rs:236-371` (`test_handle_initialize`, `test_handle_tools_list`, `test_handle_tools_call_health`, `test_handle_tools_call_unknown`, `test_handle_unknown_method`, `test_handle_notifications_initialized`) all test `handle_jsonrpc` directly. Move them verbatim to a `#[cfg(test)] mod tests { ... }` block in `src/mcp/handler.rs`. Update `test_handle_initialize` to also assert the `resources` capability is present.
+
+- [ ] **Step 5: Verify**
+
+Run: `cargo test mcp::handler`
+Expected: All six tests PASS (with the updated capabilities assertion).
+
+Run: `cargo test mcp::transport`
+Expected: Compiles, no tests left in transport.rs.
+
+Run: `cargo clippy -- -D warnings`
+Expected: No warnings.
+
+- [ ] **Step 6: Stop for user commit review**
+
+This task is structurally complete (refactor only, plus the additive resources capability). Surface the diff to the user.
+
+---
+
+### Task 2: Add stdio transport
+
+**Files:**
+- Create: `src/mcp/stdio.rs`
+- Test: inline `#[cfg(test)]` block
+
+- [ ] **Step 1: Write a failing test for one round-trip**
+
+In `src/mcp/stdio.rs`, write the function shell and a test that pipes a JSON-RPC request through it. The test uses a `Vec` for both input and output. Tests use `tempfile::TempDir` because `ApiState::new` initializes templates on disk.
+
+```rust
+//! Stdio transport for MCP — line-delimited JSON-RPC over stdin/stdout.
+//!
+//! Each line on stdin is one JSON-RPC request. Each response is one JSON
+//! object written to stdout terminated by `\n`. Logs and diagnostics go to
+//! stderr (via `tracing`). This is the transport MCP clients use when they
+//! spawn `operator mcp` as a subprocess.
+
+use std::io;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+
+use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest};
+use crate::rest::state::ApiState;
+
+/// Run the stdio MCP loop until stdin closes.
+///
+/// `reader`/`writer` are generic for testability; production callers pass
+/// `tokio::io::stdin()` and `tokio::io::stdout()`.
+pub async fn run(state: ApiState, reader: R, mut writer: W) -> io::Result<()>
+where
+ R: tokio::io::AsyncRead + Unpin,
+ W: tokio::io::AsyncWrite + Unpin,
+{
+ let mut lines = BufReader::new(reader).lines();
+ while let Some(line) = lines.next_line().await? {
+ if line.trim().is_empty() {
+ continue;
+ }
+ let request: JsonRpcRequest = match serde_json::from_str(&line) {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!(error = %e, line = %line, "Malformed JSON-RPC request");
+ continue;
+ }
+ };
+ let response = handle_jsonrpc(&request, &state).await;
+ let json = serde_json::to_string(&response)
+ .unwrap_or_else(|_| r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"serialization failed"}}"#.to_string());
+ writer.write_all(json.as_bytes()).await?;
+ writer.write_all(b"\n").await?;
+ writer.flush().await?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Config;
+
+ fn test_state() -> ApiState {
+ let temp = tempfile::TempDir::new().unwrap();
+ // ApiState::new writes default templates into tickets_path; tempdir handles cleanup.
+ ApiState::new(Config::default(), temp.path().to_path_buf())
+ }
+
+ #[tokio::test]
+ async fn test_stdio_roundtrip_initialize() {
+ let state = test_state();
+ let input = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
+"#;
+ let mut output: Vec = Vec::new();
+ run(state, &input[..], &mut output).await.unwrap();
+
+ let response_str = std::str::from_utf8(&output).unwrap();
+ let response: serde_json::Value = serde_json::from_str(response_str.trim()).unwrap();
+ assert_eq!(response["jsonrpc"], "2.0");
+ assert_eq!(response["id"], 1);
+ assert_eq!(response["result"]["serverInfo"]["name"], "operator");
+ }
+
+ #[tokio::test]
+ async fn test_stdio_ignores_blank_lines() {
+ let state = test_state();
+ let input = b"\n\n";
+ let mut output: Vec = Vec::new();
+ run(state, &input[..], &mut output).await.unwrap();
+ assert!(output.is_empty());
+ }
+
+ #[tokio::test]
+ async fn test_stdio_malformed_line_is_skipped() {
+ let state = test_state();
+ let input = b"not json\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n";
+ let mut output: Vec = Vec::new();
+ run(state, &input[..], &mut output).await.unwrap();
+ let response_str = std::str::from_utf8(&output).unwrap();
+ // Only one response should be present (the valid one)
+ assert_eq!(response_str.matches('\n').count(), 1);
+ }
+}
+```
+
+Confirm `tempfile` is already a dev-dependency (it's used elsewhere in the project). If not, add `tempfile = "3"` under `[dev-dependencies]`.
+
+- [ ] **Step 2: Run the tests**
+
+Run: `cargo test mcp::stdio`
+Expected: 3 tests PASS.
+
+- [ ] **Step 3: Stop for commit review**
+
+---
+
+### Task 3: Add `operator mcp` CLI subcommand
+
+**Files:**
+- Modify: `src/main.rs` (add variant, match arm, async fn)
+
+- [ ] **Step 1: Add the `Mcp` variant to the `Commands` enum**
+
+Edit `src/main.rs`. Insert after the `Api { port: Option }` variant (around line 230):
+
+```rust
+ /// Run as an MCP stdio server (for use by Claude Code, Cursor, Zed, JetBrains, etc.).
+ ///
+ /// Reads line-delimited JSON-RPC from stdin and writes responses to stdout.
+ /// Log output goes to stderr. Intended to be spawned by an MCP-capable client.
+ Mcp,
+```
+
+- [ ] **Step 2: Add the match arm**
+
+In the main `match cli.command` block (around `src/main.rs:281`), add a new arm before `Some(Commands::Setup { ... })`:
+
+```rust
+ Some(Commands::Mcp) => {
+ cmd_mcp(&config).await?;
+ }
+```
+
+- [ ] **Step 3: Implement `cmd_mcp`**
+
+Add to the bottom of `src/main.rs`, alongside `cmd_api`:
+
+```rust
+async fn cmd_mcp(config: &Config) -> Result<()> {
+ use crate::rest::state::ApiState;
+ let state = ApiState::new(config.clone(), config.tickets_path());
+ tracing::info!("Starting MCP stdio server");
+ crate::mcp::stdio::run(state, tokio::io::stdin(), tokio::io::stdout()).await?;
+ tracing::info!("MCP stdio server stopped (stdin closed)");
+ Ok(())
+}
+```
+
+- [ ] **Step 4: Verify it runs and responds**
+
+Run: `cargo build --release`
+Run interactively in a shell:
+```
+echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | ./target/release/operator mcp
+```
+Expected: One line of JSON output containing `"serverInfo":{"name":"operator"`. Process exits cleanly after stdin closes.
+
+- [ ] **Step 5: Stop for commit review**
+
+---
+
+### Task 4: Add ticket-queue MCP read tool (`operator_list_tickets`)
+
+**Files:**
+- Create: `src/mcp/tickets.rs`
+- Modify: `src/mcp/tools.rs:23-99` (definitions), `:101-150` (dispatch), `:162` (count assertion)
+
+- [ ] **Step 1: Write a failing test for `operator_list_tickets`**
+
+In `src/mcp/tickets.rs`:
+
+```rust
+//! Ticket-queue MCP tools.
+//!
+//! Reads/writes via `crate::queue::Queue` which uses blocking `std::fs`,
+//! so all calls are wrapped in `tokio::task::spawn_blocking`.
+
+use serde_json::{json, Value};
+
+use crate::queue::ticket::Ticket;
+use crate::queue::Queue;
+use crate::rest::state::ApiState;
+
+fn ticket_to_json(t: &Ticket) -> Value {
+ json!({
+ "id": t.id,
+ "filename": t.filename,
+ "project": t.project,
+ "ticket_type": t.ticket_type,
+ "summary": t.summary,
+ "priority": t.priority,
+ "status": t.status,
+ "branch": t.branch,
+ "external_id": t.external_id,
+ "external_url": t.external_url,
+ "external_provider": t.external_provider,
+ })
+}
+
+pub async fn list_tickets(args: Value, state: &ApiState) -> Result {
+ let status = args.get("status").and_then(|v| v.as_str()).unwrap_or("queue").to_string();
+ let config = (*state.config).clone();
+ let tickets = tokio::task::spawn_blocking(move || -> Result, String> {
+ let queue = Queue::new(&config).map_err(|e| e.to_string())?;
+ match status.as_str() {
+ "queue" => queue.list_queue().map_err(|e| e.to_string()),
+ "in-progress" => queue.list_in_progress().map_err(|e| e.to_string()),
+ "completed" => queue.list_completed().map_err(|e| e.to_string()),
+ other => Err(format!("Unknown ticket status: {other}")),
+ }
+ })
+ .await
+ .map_err(|e| e.to_string())??;
+
+ let json_tickets: Vec = tickets.iter().map(ticket_to_json).collect();
+ Ok(json!({ "tickets": json_tickets, "count": json_tickets.len() }))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Config;
+
+ fn test_state() -> ApiState {
+ let temp = tempfile::TempDir::new().unwrap();
+ // Leak the tempdir so it survives the test; in real test isolation use a guard.
+ let path = temp.into_path();
+ ApiState::new(Config::default(), path)
+ }
+
+ #[tokio::test]
+ async fn test_list_tickets_empty_queue() {
+ let state = test_state();
+ let result = list_tickets(json!({}), &state).await.unwrap();
+ assert_eq!(result["count"], 0);
+ }
+
+ #[tokio::test]
+ async fn test_list_tickets_unknown_status_errors() {
+ let state = test_state();
+ let err = list_tickets(json!({ "status": "bogus" }), &state).await.unwrap_err();
+ assert!(err.contains("Unknown ticket status"));
+ }
+}
+```
+
+Verify the actual `Queue::new` signature first (Pre-Flight #1). If it takes a different argument (e.g. `&Config` vs. owned `Config`), adjust the closure capture. The `(*state.config).clone()` pattern handles the `Arc` deref.
+
+Run: `cargo test mcp::tickets::tests::test_list_tickets_empty_queue`
+Expected: PASS.
+
+- [ ] **Step 2: Register the tool in `tools.rs`**
+
+In `src/mcp/tools.rs:23-99`, append to the `vec!` in `all_tool_definitions`:
+
+```rust
+ McpToolDefinition {
+ name: "operator_list_tickets".to_string(),
+ description: "List tickets in the operator queue. Filter by status: queue, in-progress, completed. Returns id, project, type, summary, priority, branch, and external links — not body content.".to_string(),
+ input_schema: json!({
+ "type": "object",
+ "properties": {
+ "status": { "type": "string", "enum": ["queue", "in-progress", "completed"], "default": "queue" }
+ },
+ "required": []
+ }),
+ },
+```
+
+In `execute_tool` (around `src/mcp/tools.rs:103`), add the dispatch arm before the catch-all `_ =>`:
+
+```rust
+ "operator_list_tickets" => crate::mcp::tickets::list_tickets(args, state).await,
+```
+
+- [ ] **Step 3: Update the tool-count assertions**
+
+In `src/mcp/handler.rs::tests::test_handle_tools_list` (moved in Task 1, Step 4) and `src/mcp/tools.rs:162`'s count assertion, change the expected count from `7` to `8`.
+
+- [ ] **Step 4: Run everything**
+
+Run: `cargo test mcp::`
+Expected: All MCP tests PASS.
+
+- [ ] **Step 5: Stop for commit review**
+
+---
+
+### Task 5: Add ticket-queue MCP write tools (claim, complete, return-to-queue)
+
+**Files:**
+- Modify: `src/mcp/tickets.rs` (three new fns + tests)
+- Modify: `src/mcp/tools.rs` (three definitions, three dispatch arms, count assertion → 11)
+
+All three write tools follow the same pattern: look up the ticket by `id` in the appropriate source list, call the corresponding `Queue` method on it, return the new path. They share a permission gate on `config.mcp.expose_ticket_write_tools` (added in Task 7).
+
+- [ ] **Step 1: Add the shared lookup helper in `tickets.rs`**
+
+```rust
+async fn find_ticket(state: &ApiState, id: &str, in_status: &str) -> Result {
+ let id = id.to_string();
+ let in_status = in_status.to_string();
+ let config = (*state.config).clone();
+ tokio::task::spawn_blocking(move || -> Result {
+ let queue = Queue::new(&config).map_err(|e| e.to_string())?;
+ let list = match in_status.as_str() {
+ "queue" => queue.list_queue(),
+ "in-progress" => queue.list_in_progress(),
+ "completed" => queue.list_completed(),
+ other => return Err(format!("Unknown status: {other}")),
+ }
+ .map_err(|e| e.to_string())?;
+ list.into_iter()
+ .find(|t| t.id == id)
+ .ok_or_else(|| format!("Ticket {id} not found in {in_status}"))
+ })
+ .await
+ .map_err(|e| e.to_string())?
+}
+```
+
+- [ ] **Step 2: Implement `claim_ticket`**
+
+```rust
+pub async fn claim_ticket(args: Value, state: &ApiState) -> Result {
+ let id = args.get("id").and_then(|v| v.as_str()).ok_or("Missing required arg: id")?;
+ let ticket = find_ticket(state, id, "queue").await?;
+ let config = (*state.config).clone();
+ let id_str = id.to_string();
+ tokio::task::spawn_blocking(move || -> Result<(), String> {
+ let queue = Queue::new(&config).map_err(|e| e.to_string())?;
+ queue.claim_ticket(&ticket).map_err(|e| e.to_string())
+ })
+ .await
+ .map_err(|e| e.to_string())??;
+ Ok(json!({ "id": id_str, "moved_to": "in-progress" }))
+}
+```
+
+Add test that creates a temp tickets dir, writes a fake ticket file into `queue/`, calls `claim_ticket`, then asserts the file exists in `in-progress/` and not in `queue/`. Use a real timestamped filename matching the project's expected pattern (see `src/queue/ticket.rs` for parse rules).
+
+- [ ] **Step 3: Implement `complete_ticket` and `return_to_queue`**
+
+Identical shape — source status is `"in-progress"` for both, target differs:
+
+```rust
+pub async fn complete_ticket(args: Value, state: &ApiState) -> Result { /* lookup in-progress, call queue.complete_ticket */ }
+pub async fn return_to_queue(args: Value, state: &ApiState) -> Result { /* lookup in-progress, call queue.return_to_queue */ }
+```
+
+Add a test for each.
+
+- [ ] **Step 4: Register the three tools in `tools.rs` with the permission gate**
+
+Append three `McpToolDefinition` entries to `all_tool_definitions`:
+
+```rust
+ McpToolDefinition {
+ name: "operator_claim_ticket".to_string(),
+ description: "Move a ticket from queue to in-progress. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(),
+ input_schema: json!({
+ "type": "object",
+ "properties": { "id": { "type": "string", "description": "Ticket id (e.g. FEAT-1234)" } },
+ "required": ["id"]
+ }),
+ },
+ McpToolDefinition {
+ name: "operator_complete_ticket".to_string(),
+ description: "Move a ticket from in-progress to completed.".to_string(),
+ input_schema: json!({
+ "type": "object",
+ "properties": { "id": { "type": "string" } },
+ "required": ["id"]
+ }),
+ },
+ McpToolDefinition {
+ name: "operator_return_to_queue".to_string(),
+ description: "Move a ticket from in-progress back to queue (un-claim).".to_string(),
+ input_schema: json!({
+ "type": "object",
+ "properties": { "id": { "type": "string" } },
+ "required": ["id"]
+ }),
+ },
+```
+
+In `execute_tool`, add three dispatch arms with the shared permission gate. Extract the gate to a helper:
+
+```rust
+fn require_write_tools(state: &ApiState) -> Result<(), String> {
+ if !state.config.mcp.expose_ticket_write_tools {
+ Err("Ticket write tools disabled in config ([mcp].expose_ticket_write_tools = true to enable)".to_string())
+ } else {
+ Ok(())
+ }
+}
+```
+
+```rust
+ "operator_claim_ticket" => {
+ require_write_tools(state)?;
+ crate::mcp::tickets::claim_ticket(args, state).await
+ }
+ "operator_complete_ticket" => {
+ require_write_tools(state)?;
+ crate::mcp::tickets::complete_ticket(args, state).await
+ }
+ "operator_return_to_queue" => {
+ require_write_tools(state)?;
+ crate::mcp::tickets::return_to_queue(args, state).await
+ }
+```
+
+- [ ] **Step 5: Add a gate test**
+
+In `tickets.rs::tests`, assert that `claim_ticket` returns the gate error when `config.mcp.expose_ticket_write_tools = false`. Construct the state with a config where the flag is false, then call `execute_tool("operator_claim_ticket", ...)` and assert the error string.
+
+- [ ] **Step 6: Update tool-count assertions to 11**
+
+(8 existing + 3 new write tools = 11. Task 5.5 will add a 12th, Task 6 doesn't add tools.)
+
+- [ ] **Step 7: Verify**
+
+Run: `cargo test mcp::`
+Expected: All MCP tests PASS.
+
+- [ ] **Step 8: Stop for commit review**
+
+---
+
+### Task 5.5: Add `operator_create_ticket` write tool
+
+**Files:**
+- Modify: `src/queue/creator.rs` (add `create_ticket_headless`)
+- Modify: `src/mcp/tickets.rs` (add `create_ticket` MCP fn)
+- Modify: `src/mcp/tools.rs` (one definition, one dispatch arm, count → 12)
+
+The existing `TicketCreator::create_ticket_with_values` (lines 33-68) opens `$EDITOR` after writing the file. That's wrong for MCP — there's no terminal. Add a headless variant that returns the path without launching an editor.
+
+- [ ] **Step 1: Add `create_ticket_headless` to `TicketCreator`**
+
+In `src/queue/creator.rs`, beside the existing `create_ticket_with_values`, add:
+
+```rust
+/// Create a ticket without opening it in an editor (for MCP / API use).
+pub fn create_ticket_headless(
+ &self,
+ template_type: TemplateType,
+ values: &HashMap,
+) -> Result {
+ let now = Utc::now();
+ let timestamp = now.format("%Y%m%d-%H%M").to_string();
+ let type_str = template_type.as_str();
+ let project = values
+ .get("project")
+ .filter(|p| !p.is_empty())
+ .cloned()
+ .unwrap_or_else(|| "global".to_string());
+
+ let filename = format!("{timestamp}-{type_str}-{project}-new-ticket.md");
+ let filepath = self.queue_path.join(&filename);
+
+ let template = template_type.template_content();
+ let content = render_template(template, values)?;
+ fs::create_dir_all(&self.queue_path).context("Failed to create queue directory")?;
+ fs::write(&filepath, &content).context("Failed to write ticket file")?;
+
+ Ok(filepath)
+}
+```
+
+Refactor `create_ticket_with_values` to call `create_ticket_headless` and then `open_in_editor` (DRY). Run existing tests to confirm no regression.
+
+- [ ] **Step 2: Add MCP fn `create_ticket` in `tickets.rs`**
+
+```rust
+pub async fn create_ticket(args: Value, state: &ApiState) -> Result {
+ use crate::queue::creator::TicketCreator;
+ use crate::templates::TemplateType;
+ use std::collections::HashMap;
+
+ let template_str = args.get("template").and_then(|v| v.as_str()).ok_or("Missing required arg: template")?;
+ let template_type = TemplateType::from_str(template_str).map_err(|e| e.to_string())?;
+ let mut values: HashMap = HashMap::new();
+ if let Some(obj) = args.get("values").and_then(|v| v.as_object()) {
+ for (k, v) in obj {
+ if let Some(s) = v.as_str() {
+ values.insert(k.clone(), s.to_string());
+ }
+ }
+ }
+
+ let config = (*state.config).clone();
+ let path = tokio::task::spawn_blocking(move || -> Result {
+ let creator = TicketCreator::new(&config);
+ creator.create_ticket_headless(template_type, &values).map_err(|e| e.to_string())
+ })
+ .await
+ .map_err(|e| e.to_string())??;
+
+ Ok(json!({ "path": path.to_string_lossy(), "filename": path.file_name().and_then(|n| n.to_str()).unwrap_or("") }))
+}
+```
+
+Verify `TemplateType::from_str` exists. If not, use the project's actual enum-parsing convention.
+
+- [ ] **Step 3: Register `operator_create_ticket` in `tools.rs`**
+
+```rust
+ McpToolDefinition {
+ name: "operator_create_ticket".to_string(),
+ description: "Create a new ticket from a template (FEAT, FIX, INV, SPIKE, etc.) and write it to the queue. Returns the filename. Gated by [mcp].expose_ticket_write_tools.".to_string(),
+ input_schema: json!({
+ "type": "object",
+ "properties": {
+ "template": { "type": "string", "description": "Template type (FEAT, FIX, INV, SPIKE, ...)" },
+ "values": { "type": "object", "description": "Handlebars values for the template (project, summary, etc.)" }
+ },
+ "required": ["template"]
+ }),
+ },
+```
+
+```rust
+ "operator_create_ticket" => {
+ require_write_tools(state)?;
+ crate::mcp::tickets::create_ticket(args, state).await
+ }
+```
+
+- [ ] **Step 4: Add test**
+
+Temp tickets dir, call `create_ticket` with `template = "FEAT", values = { "summary": "test", "project": "demo" }`, assert a `.md` file lands in `queue/` with a name containing `demo`.
+
+- [ ] **Step 5: Update tool-count assertions to 12**
+
+- [ ] **Step 6: Verify**
+
+Run: `cargo test mcp::`
+Expected: PASS.
+
+- [ ] **Step 7: Stop for commit review**
+
+---
+
+### Task 6: MCP resources capability — expose tickets as resources
+
+**Files:**
+- Modify: `src/mcp/handler.rs` (add `resources/list` and `resources/read` handlers; the capability is already advertised after Task 1 Step 1)
+- Create: `src/mcp/resources.rs`
+
+MCP clients can subscribe to resources to read context. Expose each ticket as a resource with URI `operator://tickets/{status}/{id}`. This is the highest-leverage capability for IDEs that want to surface tickets natively.
+
+- [ ] **Step 1: Add `resources/list` and `resources/read` handlers**
+
+After the `tools/call` arm in `handle_jsonrpc`:
+
+```rust
+ "resources/list" => {
+ let resources = crate::mcp::resources::list_resources(state).await
+ .unwrap_or_else(|_| vec![]);
+ JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: Some(json!({ "resources": resources })),
+ error: None,
+ }
+ }
+ "resources/read" => {
+ let uri = request.params.get("uri").and_then(|v| v.as_str()).unwrap_or("");
+ match crate::mcp::resources::read_resource(uri, state).await {
+ Ok(contents) => JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: Some(json!({ "contents": [{ "uri": uri, "mimeType": "text/markdown", "text": contents }] })),
+ error: None,
+ },
+ Err(e) => JsonRpcResponse {
+ jsonrpc: "2.0".to_string(),
+ id,
+ result: None,
+ error: Some(JsonRpcError { code: -32000, message: e }),
+ },
+ }
+ }
+```
+
+- [ ] **Step 2: Implement `src/mcp/resources.rs` via `Queue`**
+
+```rust
+//! MCP resources — exposes tickets as URI-addressable resources.
+
+use serde_json::{json, Value};
+
+use crate::queue::Queue;
+use crate::rest::state::ApiState;
+
+pub async fn list_resources(state: &ApiState) -> Result, String> {
+ let config = (*state.config).clone();
+ tokio::task::spawn_blocking(move || -> Result, String> {
+ let queue = Queue::new(&config).map_err(|e| e.to_string())?;
+ let mut all = Vec::new();
+ for (status, list) in [
+ ("queue", queue.list_queue()),
+ ("in-progress", queue.list_in_progress()),
+ ("completed", queue.list_completed()),
+ ] {
+ for t in list.map_err(|e| e.to_string())? {
+ all.push(json!({
+ "uri": format!("operator://tickets/{status}/{}", t.id),
+ "name": t.filename,
+ "mimeType": "text/markdown",
+ "description": t.summary,
+ }));
+ }
+ }
+ Ok(all)
+ })
+ .await
+ .map_err(|e| e.to_string())?
+}
+
+pub async fn read_resource(uri: &str, state: &ApiState) -> Result {
+ let prefix = "operator://tickets/";
+ let rest = uri.strip_prefix(prefix).ok_or_else(|| format!("Unknown URI scheme: {uri}"))?;
+ let (status, id) = rest.split_once('/').ok_or_else(|| format!("Malformed URI: {uri}"))?;
+
+ let config = (*state.config).clone();
+ let status = status.to_string();
+ let id = id.to_string();
+ tokio::task::spawn_blocking(move || -> Result {
+ let queue = Queue::new(&config).map_err(|e| e.to_string())?;
+ let list = match status.as_str() {
+ "queue" => queue.list_queue(),
+ "in-progress" => queue.list_in_progress(),
+ "completed" => queue.list_completed(),
+ other => return Err(format!("Unknown status: {other}")),
+ }
+ .map_err(|e| e.to_string())?;
+ let ticket = list.into_iter().find(|t| t.id == id).ok_or_else(|| format!("Ticket {id} not found"))?;
+ std::fs::read_to_string(&ticket.filepath).map_err(|e| e.to_string())
+ })
+ .await
+ .map_err(|e| e.to_string())?
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Config;
+
+ fn test_state() -> ApiState {
+ let temp = tempfile::TempDir::new().unwrap();
+ ApiState::new(Config::default(), temp.into_path())
+ }
+
+ #[tokio::test]
+ async fn test_list_resources_empty() {
+ let state = test_state();
+ let resources = list_resources(&state).await.unwrap();
+ assert!(resources.is_empty());
+ }
+
+ #[tokio::test]
+ async fn test_read_resource_unknown_scheme() {
+ let state = test_state();
+ let err = read_resource("file:///tmp/x", &state).await.unwrap_err();
+ assert!(err.contains("Unknown URI scheme"));
+ }
+
+ #[tokio::test]
+ async fn test_read_resource_malformed() {
+ let state = test_state();
+ let err = read_resource("operator://tickets/queue", &state).await.unwrap_err();
+ assert!(err.contains("Malformed URI"));
+ }
+}
+```
+
+- [ ] **Step 3: Verify**
+
+Run: `cargo test mcp::`
+Expected: All PASS.
+
+- [ ] **Step 4: Stop for commit review**
+
+---
+
+### Task 6.5: Extend `McpDescriptorResponse` with stdio command (vscode-extension Phase 2 enabler)
+
+**Files:**
+- Modify: `src/mcp/descriptor.rs`
+
+The existing descriptor at `src/mcp/descriptor.rs:14-30` is consumed by `vscode-extension/src/mcp-connect.ts` to register operator as an SSE MCP server. Extending it with an optional `stdio` field is purely additive (gated by `skip_serializing_if`) and unlocks the Phase 2 work where the extension can choose to spawn `operator mcp` instead of (or alongside) the SSE transport.
+
+- [ ] **Step 1: Add `StdioCommand` and the optional field**
+
+Edit `src/mcp/descriptor.rs`:
+
+```rust
+#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)]
+#[ts(export)]
+pub struct StdioCommand {
+ /// Absolute path to the operator binary (the same binary serving this descriptor)
+ pub command: String,
+ /// Args to pass: typically ["mcp"]
+ pub args: Vec,
+ /// Working directory the client should set when spawning. Defaults to the
+ /// operator process's current working directory.
+ pub cwd: String,
+}
+
+// Add to McpDescriptorResponse:
+ /// Stdio transport entrypoint. Present when [mcp].stdio_advertised = true.
+ /// Clients may spawn this as a subprocess instead of using transport_url.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub stdio: Option,
+```
+
+- [ ] **Step 2: Inject `State` into the handler and populate `stdio`**
+
+Change the handler signature from `descriptor(Host(host): Host)` to `descriptor(State(state): State, Host(host): Host)` and populate:
+
+```rust
+pub async fn descriptor(
+ State(state): State,
+ Host(host): Host,
+) -> Json {
+ let base = format!("http://{host}");
+
+ let stdio = if state.config.mcp.stdio_advertised {
+ let command = std::env::current_exe()
+ .ok()
+ .and_then(|p| p.to_str().map(|s| s.to_string()))
+ .unwrap_or_else(|| "operator".to_string());
+ let cwd = std::env::current_dir()
+ .ok()
+ .and_then(|p| p.to_str().map(|s| s.to_string()))
+ .unwrap_or_default();
+ Some(StdioCommand {
+ command,
+ args: vec!["mcp".to_string()],
+ cwd,
+ })
+ } else {
+ None
+ };
+
+ Json(McpDescriptorResponse {
+ server_name: "operator".to_string(),
+ server_id: "operator-mcp".to_string(),
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ transport_url: format!("{base}/api/v1/mcp/sse"),
+ label: "Operator MCP Server".to_string(),
+ openapi_url: Some(format!("{base}/api-docs/openapi.json")),
+ stdio,
+ })
+}
+```
+
+The route registration in `src/rest/mod.rs` already passes `ApiState` (other handlers use it), but verify the descriptor's route line and add the `State` extractor if it currently uses a different shape.
+
+- [ ] **Step 3: Update existing descriptor tests + add stdio coverage**
+
+Update both `test_descriptor_response` and `test_descriptor_custom_port` to construct a test `ApiState` and pass it. Add two new tests:
+- `test_descriptor_stdio_present_when_advertised` — config with `stdio_advertised = true` → `resp.stdio.is_some()` and `resp.stdio.unwrap().args == vec!["mcp"]`.
+- `test_descriptor_stdio_absent_when_disabled` — config with `stdio_advertised = false` → `resp.stdio.is_none()`.
+
+(Both tests need Task 7's `McpConfig` to exist. Either land Task 7 first, or temporarily inline the field default behind a feature flag. **Preferred order:** Task 7 before Task 6.5 — see ordering note below.)
+
+- [ ] **Step 4: Regenerate TypeScript bindings**
+
+Run: `cargo test` — `ts-rs` regenerates `bindings/` (or wherever the project configures `#[ts(export)]` output) including the new `StdioCommand` type.
+
+- [ ] **Step 5: Verify**
+
+Run: `cargo test mcp::descriptor`
+Expected: PASS, including the two new stdio tests.
+
+Run: `cargo clippy -- -D warnings`
+Expected: clean.
+
+- [ ] **Step 6: Stop for commit review**
+
+> **Ordering note:** This task reads `config.mcp.stdio_advertised`, which is defined in Task 7. If you're executing strictly in numeric order, swap: do Task 7 first, then return to Task 6.5. Tasks 1-6 are independent of `McpConfig`.
+
+---
+
+### Task 7: Add `[mcp]` config section
+
+**Files:**
+- Modify: `src/config.rs` (Config struct around lines 28-74; new `McpConfig` struct alongside `RestApiConfig` and `RelayConfig`)
+
+- [ ] **Step 1: Add the `McpConfig` struct**
+
+Add in `src/config.rs`, near other sub-structs like `RestApiConfig` (`src/config.rs:263-291`) and `RelayConfig`:
+
+```rust
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(deny_unknown_fields)]
+#[ts(export)]
+pub struct McpConfig {
+ /// Whether to mount MCP HTTP/SSE endpoints on the REST API server.
+ /// Toggling requires an API restart (no hot-swap of the axum router).
+ #[serde(default = "default_true")]
+ pub http_enabled: bool,
+ /// Whether the descriptor endpoint advertises the `operator mcp` stdio
+ /// command. Set to false on multi-tenant/remote deployments where clients
+ /// shouldn't spawn local subprocesses.
+ #[serde(default = "default_true")]
+ pub stdio_advertised: bool,
+ /// Whether to expose ticket-mutating tools (claim, complete, return-to-queue,
+ /// create) over MCP. Defaults to `false` because any MCP client can call them.
+ #[serde(default)]
+ pub expose_ticket_write_tools: bool,
+}
+
+impl Default for McpConfig {
+ fn default() -> Self {
+ Self {
+ http_enabled: true,
+ stdio_advertised: true,
+ expose_ticket_write_tools: false,
+ }
+ }
+}
+
+fn default_true() -> bool { true }
+```
+
+The `JsonSchema + TS` derive pair matches the rest of the codebase (`src/config.rs`'s other structs). The `TS` derive triggers TypeScript binding regeneration consumed by `vscode-extension/scripts/copy-types.js`.
+
+- [ ] **Step 2: Add the field to `Config`**
+
+Add to the `Config` struct (alongside `relay: RelayConfig`):
+
+```rust
+ #[serde(default)]
+ pub mcp: McpConfig,
+```
+
+Update `Config::default()` to include `mcp: McpConfig::default()`.
+
+- [ ] **Step 3: Wire `http_enabled` into the router build**
+
+In `src/rest/mod.rs` (around lines 175-181, where MCP routes are currently mounted unconditionally), wrap the MCP route registrations:
+
+```rust
+if state.config.mcp.http_enabled {
+ router = router
+ .route("/api/v1/mcp/descriptor", get(descriptor::descriptor))
+ .route("/api/v1/mcp/sse", get(transport::sse_handler))
+ .route("/api/v1/mcp/message", post(transport::message_handler));
+}
+```
+
+(Exact shape depends on the existing router-building pattern. Verify by reading `src/rest/mod.rs:175-181`.)
+
+- [ ] **Step 4: Verify the gate from Task 5 now compiles**
+
+Run: `cargo test mcp::`
+Expected: PASS, including the disabled-write-tools gate test from Task 5.
+
+- [ ] **Step 5: Regen docs and TS bindings**
+
+```
+cargo run -- docs --only config # regenerates docs/configuration/index.md with [mcp] section
+cargo test # ts-rs regenerates bindings
+```
+
+Then refresh the vscode-extension's copy (Phase 2 will need this):
+
+```
+cd vscode-extension && npm run copy-types
+```
+
+If `copy-types` isn't yet a script in `package.json`, fall back to the manual path the project uses (`scripts/copy-types.js`).
+
+Verify the generated `docs/configuration/index.md` now describes `[mcp]`.
+
+- [ ] **Step 6: Stop for commit review**
+
+---
+
+### Task 8: Client config snippet generator
+
+**Files:**
+- Create: `src/mcp/client_configs.rs`
+
+Operator users adopting MCP need to be told "paste this into your client config." Generate snippets at runtime so they always carry the correct absolute path to the operator binary and the project's working directory.
+
+- [ ] **Step 0: Verify modern client config shapes**
+
+Before writing snippets, confirm the current expected shape for each:
+- Run: `head -100 vscode-extension/src/mcp-connect.ts` — verify what the extension currently writes to workspace `mcp.servers` (or the modern `.vscode/mcp.json` `servers` shape). The snippet for VS Code must match what the extension expects.
+- Cursor: `~/.cursor/mcp.json` uses the `mcpServers` shape (same as Claude Code's `claude.json`).
+- Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` uses `mcpServers`.
+- Zed: `settings.json` under `context_servers`.
+
+If any shape has drifted, update the snippet in Step 1 before testing.
+
+- [ ] **Step 1: Implement `client_configs.rs`**
+
+```rust
+//! Generates copy-paste MCP client configuration snippets pointing at this operator binary.
+
+use serde_json::{json, Value};
+use std::path::{Path, PathBuf};
+
+pub fn current_exe() -> PathBuf {
+ std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator"))
+}
+
+fn mcp_servers_shape(cwd: &Path) -> Value {
+ // Used by Claude Code (~/.claude.json), Claude Desktop, and Cursor (~/.cursor/mcp.json).
+ json!({
+ "mcpServers": {
+ "operator": {
+ "command": current_exe().to_string_lossy(),
+ "args": ["mcp"],
+ "cwd": cwd.to_string_lossy(),
+ }
+ }
+ })
+}
+
+pub fn claude_code_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) }
+pub fn claude_desktop_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) }
+
+/// Cursor's `~/.cursor/mcp.json` uses the same `mcpServers` shape as Claude Code.
+pub fn cursor_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) }
+
+/// VS Code (1.94+) per-workspace `.vscode/mcp.json` uses a `servers` block with explicit `type`.
+pub fn vscode_snippet(cwd: &Path) -> Value {
+ json!({
+ "servers": {
+ "operator": {
+ "type": "stdio",
+ "command": current_exe().to_string_lossy(),
+ "args": ["mcp"],
+ "cwd": cwd.to_string_lossy(),
+ }
+ }
+ })
+}
+
+/// Zed config under `context_servers` in user settings.
+pub fn zed_snippet(cwd: &Path) -> Value {
+ json!({
+ "context_servers": {
+ "operator": {
+ "command": { "path": current_exe().to_string_lossy(), "args": ["mcp"], "env": {} },
+ "settings": { "cwd": cwd.to_string_lossy() }
+ }
+ }
+ })
+}
+
+/// Dispatch by client name. Returns `None` for unknown clients.
+pub fn snippet_for(client: &str, cwd: &Path) -> Option {
+ match client {
+ "claude-code" => Some(claude_code_snippet(cwd)),
+ "claude-desktop" => Some(claude_desktop_snippet(cwd)),
+ "cursor" => Some(cursor_snippet(cwd)),
+ "vscode" => Some(vscode_snippet(cwd)),
+ "zed" => Some(zed_snippet(cwd)),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_claude_code_snippet_shape() {
+ let cfg = claude_code_snippet(&PathBuf::from("/work"));
+ assert_eq!(cfg["mcpServers"]["operator"]["args"][0], "mcp");
+ assert_eq!(cfg["mcpServers"]["operator"]["cwd"], "/work");
+ }
+
+ #[test]
+ fn test_cursor_snippet_matches_claude_code() {
+ let cursor = cursor_snippet(&PathBuf::from("/work"));
+ let claude = claude_code_snippet(&PathBuf::from("/work"));
+ assert_eq!(cursor, claude);
+ }
+
+ #[test]
+ fn test_vscode_snippet_uses_servers_with_type() {
+ let cfg = vscode_snippet(&PathBuf::from("/work"));
+ assert_eq!(cfg["servers"]["operator"]["type"], "stdio");
+ assert_eq!(cfg["servers"]["operator"]["args"][0], "mcp");
+ }
+
+ #[test]
+ fn test_zed_snippet_uses_context_servers() {
+ let cfg = zed_snippet(&PathBuf::from("/work"));
+ assert!(cfg["context_servers"]["operator"]["command"]["path"].is_string());
+ }
+
+ #[test]
+ fn test_snippet_for_unknown_client_is_none() {
+ assert!(snippet_for("notepad++", &PathBuf::from("/w")).is_none());
+ }
+}
+```
+
+- [ ] **Step 2: Run tests**
+
+Run: `cargo test mcp::client_configs`
+Expected: PASS.
+
+- [ ] **Step 3: Stop for commit review**
+
+---
+
+### Task 9: Integrate MCP into `StatusSnapshot` and `ConnectionsSection`
+
+**Files:**
+- Modify: `src/ui/status_panel.rs:401-431` (StatusSnapshot fields), `:143-174` (StatusAction)
+- Modify: `src/ui/sections/connections_section.rs:70-138` (children)
+- Modify: `src/ui/dashboard.rs:203-317` (snapshot construction)
+- Modify: `src/app/status_actions.rs` (action handlers)
+
+Mirror the existing `Operator API` row pattern exactly. No new clipboard dependency — `WriteAndOpenMcpClientConfig` writes the snippet to a file and dispatches the existing `EditFile(path)` action.
+
+- [ ] **Step 1: Add `McpHttpStatus` enum**
+
+In `src/rest/server.rs` (alongside `RestApiStatus`) or a new `src/mcp/status.rs` if you prefer to keep MCP types together:
+
+```rust
+#[derive(Debug, Clone, PartialEq)]
+pub enum McpHttpStatus {
+ /// MCP HTTP routes mounted on the REST API server on the given port.
+ Mounted { port: u16 },
+ /// MCP HTTP routes disabled via [mcp].http_enabled = false.
+ NotMounted,
+}
+```
+
+- [ ] **Step 2: Add MCP fields to `StatusSnapshot`**
+
+In `src/ui/status_panel.rs` around line 425, before the closing `}`:
+
+```rust
+ /// MCP HTTP transport status (mounted on the API server, or disabled by config).
+ pub mcp_http_status: McpHttpStatus,
+ /// Whether the descriptor advertises the stdio entrypoint.
+ pub mcp_stdio_advertised: bool,
+ /// Currently active MCP SSE sessions on the HTTP transport.
+ pub mcp_active_sessions: usize,
+```
+
+- [ ] **Step 3: Add new `StatusAction` variants**
+
+In `src/ui/status_panel.rs:143-174`, add before `None`:
+
+```rust
+ /// Toggle [mcp].http_enabled (requires API restart to take effect).
+ ToggleMcpHttp,
+ /// Generate a client config snippet, write it to /operator/mcp/.json,
+ /// and open it in $EDITOR. `client` is one of: "claude-code", "claude-desktop", "cursor", "vscode", "zed".
+ WriteAndOpenMcpClientConfig { client: String },
+ /// Open the operator MCP docs page in the default browser.
+ OpenMcpDocs,
+```
+
+- [ ] **Step 4: Add the "MCP" row in `ConnectionsSection::children`**
+
+In `src/ui/sections/connections_section.rs` after the "Operator API" row push (around line 117), insert:
+
+```rust
+ rows.push(TreeRow {
+ section_id: SectionId::Connections,
+ depth: 1,
+ label: "MCP".into(),
+ description: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised, snapshot.mcp_active_sessions) {
+ (McpHttpStatus::Mounted { port }, true, n) if n > 0 => format!(":{port} + stdio · {n} sessions"),
+ (McpHttpStatus::Mounted { port }, true, _) => format!(":{port} + stdio"),
+ (McpHttpStatus::Mounted { port }, false, _) => format!(":{port} (HTTP only)"),
+ (McpHttpStatus::NotMounted, true, _) => "stdio only".into(),
+ (McpHttpStatus::NotMounted, false, _) => "Disabled".into(),
+ },
+ icon: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised) {
+ (McpHttpStatus::Mounted { .. }, _) | (_, true) => StatusIcon::Plug,
+ _ => StatusIcon::Cross,
+ },
+ is_header: false,
+ actions: ActionSet {
+ primary: StatusAction::WriteAndOpenMcpClientConfig { client: "claude-code".to_string() },
+ back: StatusAction::None,
+ special: StatusAction::ToggleMcpHttp,
+ special_meta: Some(ActionMeta { title: "HTTP", tooltip: "Toggle the MCP HTTP transport (restart required)" }),
+ refresh: StatusAction::OpenMcpDocs,
+ refresh_meta: Some(ActionMeta { title: "Docs", tooltip: "Open MCP setup docs in browser" }),
+ },
+ health: SectionHealth::Gray,
+ });
+```
+
+`McpHttpStatus` and `StatusIcon::Plug` need imports added at the top of the file.
+
+- [ ] **Step 5: Populate the snapshot in `dashboard.rs`**
+
+In `src/ui/dashboard.rs:203-317`'s `build_status_snapshot`, populate the three new fields:
+
+```rust
+ mcp_http_status: if self.config.mcp.http_enabled {
+ match &self.rest_api_status {
+ RestApiStatus::Running { port } => McpHttpStatus::Mounted { port: *port },
+ _ => McpHttpStatus::NotMounted,
+ }
+ } else {
+ McpHttpStatus::NotMounted
+ },
+ mcp_stdio_advertised: self.config.mcp.stdio_advertised,
+ mcp_active_sessions: self.api_state.as_ref()
+ .map(|s| s.mcp_sessions.try_lock().map(|m| m.len()).unwrap_or(0))
+ .unwrap_or(0),
+```
+
+Verify the `Dashboard` struct's field that holds `ApiState` (might not be `api_state`; grep for `ApiState` in `src/ui/dashboard.rs`). If the dashboard doesn't currently hold an `ApiState` reference, route the session count through the `RestApiServer` lifecycle handle (which already holds the state) or default to `0` until the API is running.
+
+- [ ] **Step 6: Wire the action handlers in `src/app/status_actions.rs`**
+
+Add three new match arms in the existing dispatcher (around `src/app/status_actions.rs:66`):
+
+```rust
+StatusAction::ToggleMcpHttp => {
+ // Flip config.mcp.http_enabled in the running Config and surface a notice.
+ // No hot-swap: tell the user to restart the API.
+ self.config.mcp.http_enabled = !self.config.mcp.http_enabled;
+ self.dashboard.set_status(if self.config.mcp.http_enabled {
+ "MCP HTTP enabled — restart the API to mount routes"
+ } else {
+ "MCP HTTP disabled — restart the API to unmount routes"
+ });
+}
+
+StatusAction::WriteAndOpenMcpClientConfig { client } => {
+ use crate::mcp::client_configs;
+ let cwd = std::env::current_dir().unwrap_or_default();
+ let Some(snippet) = client_configs::snippet_for(&client, &cwd) else {
+ self.dashboard.set_status(&format!("Unknown MCP client: {client}"));
+ return;
+ };
+ let dir = self.config.tickets_path().join("operator/mcp");
+ if let Err(e) = std::fs::create_dir_all(&dir) {
+ self.dashboard.set_status(&format!("Failed to create {}: {e}", dir.display()));
+ return;
+ }
+ let path = dir.join(format!("{client}.json"));
+ let body = serde_json::to_string_pretty(&snippet).unwrap_or_default();
+ if let Err(e) = std::fs::write(&path, body) {
+ self.dashboard.set_status(&format!("Failed to write {}: {e}", path.display()));
+ return;
+ }
+ // Reuse the existing EditFile dispatcher.
+ self.dispatch(StatusAction::EditFile(path.to_string_lossy().into_owned()));
+}
+
+StatusAction::OpenMcpDocs => {
+ // Use the existing open_in_browser helper.
+ if let Err(e) = open_in_browser("https://operator.untra.io/mcp/") {
+ self.dashboard.set_status(&format!("Failed to open docs: {e}"));
+ }
+}
+```
+
+Confirm the docs URL before merging (TODO marker; pick the actual operator docs URL).
+
+- [ ] **Step 7: Update all test snapshots**
+
+Search test files for `StatusSnapshot {` (`rg "StatusSnapshot \{" --type rust`) and add the three new fields with defaults:
+
+```rust
+ mcp_http_status: McpHttpStatus::Mounted { port: 7008 },
+ mcp_stdio_advertised: true,
+ mcp_active_sessions: 0,
+```
+
+- [ ] **Step 8: Add a test for the new MCP row**
+
+Append to `src/ui/sections/connections_section.rs::tests`:
+
+```rust
+ #[test]
+ fn test_connections_mcp_row_present() {
+ let section = ConnectionsSection;
+ let snap = base_snapshot();
+ let children = section.children(&snap);
+ let mcp_row = children.iter().find(|r| r.label == "MCP");
+ assert!(mcp_row.is_some(), "MCP row should always be present");
+ }
+
+ #[test]
+ fn test_connections_mcp_row_description_disabled() {
+ let section = ConnectionsSection;
+ let mut snap = base_snapshot();
+ snap.mcp_http_status = McpHttpStatus::NotMounted;
+ snap.mcp_stdio_advertised = false;
+ let children = section.children(&snap);
+ let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap();
+ assert_eq!(mcp_row.description, "Disabled");
+ }
+```
+
+- [ ] **Step 9: Verify**
+
+Run: `cargo fmt && cargo clippy -- -D warnings && cargo test`
+Expected: All PASS, no warnings.
+
+- [ ] **Step 10: Stop for commit review**
+
+---
+
+### Task 10: End-to-end integration test
+
+**Files:**
+- Create: `tests/mcp_stdio_integration.rs`
+
+- [ ] **Step 1: Write the test**
+
+```rust
+//! End-to-end test: spawn `operator mcp` as a subprocess and roundtrip
+//! a real JSON-RPC handshake.
+
+use std::process::Stdio;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::process::Command;
+
+#[tokio::test]
+async fn test_operator_mcp_stdio_initialize_and_list_tools() {
+ let exe = env!("CARGO_BIN_EXE_operator");
+ let mut child = Command::new(exe)
+ .arg("mcp")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn operator mcp");
+
+ let mut stdin = child.stdin.take().unwrap();
+ let stdout = child.stdout.take().unwrap();
+ let mut reader = BufReader::new(stdout).lines();
+
+ stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n").await.unwrap();
+ stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n").await.unwrap();
+ stdin.flush().await.unwrap();
+
+ let line1 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()).await.unwrap().unwrap().unwrap();
+ let resp1: serde_json::Value = serde_json::from_str(&line1).unwrap();
+ assert_eq!(resp1["id"], 1);
+ assert_eq!(resp1["result"]["serverInfo"]["name"], "operator");
+
+ let line2 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()).await.unwrap().unwrap().unwrap();
+ let resp2: serde_json::Value = serde_json::from_str(&line2).unwrap();
+ assert_eq!(resp2["id"], 2);
+ // 8 read + 4 write tools = 12 (or whichever count Task 5.5 left it at)
+ assert!(resp2["result"]["tools"].as_array().unwrap().len() >= 8);
+
+ drop(stdin);
+ let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await;
+}
+```
+
+- [ ] **Step 2: Run**
+
+Run: `cargo test --test mcp_stdio_integration -- --nocapture`
+Expected: PASS (a few seconds to build the binary the first time).
+
+- [ ] **Step 3: Final verification**
+
+```
+cargo fmt
+cargo clippy -- -D warnings
+cargo test
+cargo run -- docs --only config # confirm [mcp] appears in regenerated docs
+cd vscode-extension && npm run copy-types && cd .. # confirm new TS types regenerated
+```
+
+Expected: green across the board, generated docs and TS bindings updated.
+
+- [ ] **Step 4: Stop for user to commit**
+
+---
+
+## Integration Handoff (sets up Phase 2 + Phase 3)
+
+This plan does **not** modify vscode-extension or write a Cursor integration. It sets the stage so the next two plans can be written and executed independently.
+
+**Phase 2 — vscode-extension refinement (separate follow-up plan):**
+- The existing `vscode-extension/src/mcp-connect.ts:34-97` already consumes `/api/v1/mcp/descriptor`. After Task 6.5, the response carries an optional `stdio: StdioCommand` field.
+- Phase 2 will modify `mcp-connect.ts` to: (a) detect the new field, (b) offer a workspace setting `operator.mcpTransport: "sse" | "stdio" | "auto"`, (c) when stdio is chosen/auto-selected, register operator via VS Code's modern MCP API as a stdio server using `descriptor.stdio.command` + `descriptor.stdio.args` + `descriptor.stdio.cwd`. Fallback remains SSE.
+- The TypeScript binding for `StdioCommand` will be available via the existing `scripts/copy-types.js` flow once Task 7 Step 5 runs.
+
+**Phase 3 — Cursor integration (separate follow-up plan):**
+- Cursor has no extension; it consumes `~/.cursor/mcp.json` directly. Task 8's `cursor_snippet()` already produces the right shape.
+- Phase 3 will add either: (a) a `operator mcp install --client cursor` CLI subcommand that writes the snippet to `~/.cursor/mcp.json` (merging with existing servers), or (b) a docs page rendering the snippet with the current binary path, or (c) both. The dashboard's `WriteAndOpenMcpClientConfig { client: "cursor" }` action (Task 9) already covers the local-workspace path.
+- Phase 3 should also document the JetBrains/Claude Desktop install flow using the same `snippet_for(client, cwd)` dispatch since those clients use the same `mcpServers` shape.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Stdio transport — Tasks 2, 3
+- Expanded tool surface (read + 4 write tools) — Tasks 4, 5, 5.5
+- Resources capability — Task 6
+- Descriptor stdio handoff for vscode-extension — Task 6.5
+- Config section — Task 7
+- Client config snippets — Task 8
+- Status integration (toggle + write-and-open snippet + docs link) — Task 9
+- End-to-end test — Task 10
+- Phase 2/3 handoff — Integration Handoff section
+
+**Open assumptions verified in Pre-Flight:**
+1. ✓ `Queue` API at `src/queue/mod.rs:134-158` (sync methods).
+2. ✓ `McpDescriptorResponse` exists at `src/mcp/descriptor.rs:14`.
+3. ✓ `mcp_sessions: Arc>` — `.await` correct.
+4. ⚠ Docs URL for `OpenMcpDocs` — placeholder used (`https://operator.untra.io/mcp/`); confirm before merging.
+5. ⚠ VS Code MCP shape — verify against current extension behaviour in Task 8 Step 0.
+6. ⚠ Dashboard's holding of `ApiState` — Task 9 Step 5 grep verifies; fallback to 0 sessions if not available.
+
+**Tradeoffs locked in:**
+- HTTP and stdio MCP share the handler core. HTTP behavior is unchanged unless `config.mcp.http_enabled = false` (then routes are not mounted at startup; toggle requires restart).
+- Ticket write tools are off by default. Users opt in via `[mcp].expose_ticket_write_tools = true`.
+- The descriptor extension is additive (`Option`, `skip_serializing_if`) so existing vscode-extension code keeps working until Phase 2 chooses to use the new field.
+- Snippet delivery is "write to file + open in editor," reusing `EditFile` — no clipboard dependency.
+- Stdio resource subscription is `listChanged: false` — clients re-list rather than subscribe. Simpler; revisit if a real client demands push.
diff --git a/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md b/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md
new file mode 100644
index 0000000..235944a
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md
@@ -0,0 +1,426 @@
+# Operator Licensing Platform — Template Bootstrap Plan
+
+## Context
+
+Operator is a Rust TUI that the author (Sam / `untra`) wants to license and sell. Beyond Operator itself, the author intends to build other billable software products under the `untra` umbrella. The licensing/billing infrastructure must therefore be **reusable across future untra products**, not bespoke to Operator.
+
+Two concerns were brainstormed in this session:
+
+- **X. Operator's licensing & billing system** — sub-projects A (entitlement model) through F (admin tool). Roadmapped here; detailed designs deferred to follow-up sessions.
+- **Y. Untra platform template & deployment skeleton** — the reusable cloud + DNS + auth + storefront skeleton that every untra product plugs into. **This is the focus of this session.** Y was promoted from a child of X to a top-level peer once the templating goal was made explicit.
+
+Goal of this session: produce a plan for creating six template repositories at `../templates/` — one per archetype. Each template gets a `README.md` and a `HANDOFF.md` and nothing else. A top-level `../templates/README.md` indexes the six archetypes for anyone landing in the directory. The templates are then fleshed out in independent follow-up Claude sessions, one per template, using the handoff briefs.
+
+Cost-deferral is a hard requirement: **no paid commitments should be necessary to complete Phase 0–1**. The first paid commitment is the root-domain registration (~$12/yr), happening at Phase 2.
+
+---
+
+## Reading of the request
+
+This plan rests on one interpretation of the user's template list (`iac api app auth admin license`). Surfacing it loudly so it can be corrected at review time:
+
+- **`api`** — generic product-side backend. Cloud Run service in the public per-project monorepo. Exposes (1) an **unauthenticated** `/version` endpoint and (2) **authenticated** endpoints (verified against the entitlement JWT signed by `license`) that report user details and serve product business logic. Receives the **LemonSqueezy purchase webhook** and, on a successful sale, calls `license` over a signed internal request to mint a license record.
+- **`license`** — privileged entitlement microservice. Lives in the **private** sister repo (`operator-private` for Operator; `-private` for other untra products). Holds the Ed25519 signing key in Secret Manager. Only `auth` and `api` may call it. Verifies a posted license key, returns/issues a signed entitlement JWT (booleans + integer limits, ~14-day TTL). Maintains the revocation list.
+- **`auth`** — identity orchestrator at `auth.`. Wraps Firebase Auth (sign-in UI). After the user signs in, `auth` accepts a license key, calls `license` to verify it, and packages the resulting entitlements into a JWT (signed by `license`) returned to the client. Acts as the trust bridge between Firebase identity and license entitlements.
+
+If this reading is wrong, sections below collapse. Push back at spec review.
+
+---
+
+## Roadmap
+
+### X. Operator licensing sub-projects (decomposition only)
+
+| # | Component | Lives in | Status |
+|---|---|---|---|
+| A | Entitlement model & license-key format | shared schema crate / proto | **Deferred** — detailed spec in a follow-up session. Eight decisions already locked (see "Entitlement model — locked decisions" below). |
+| B | License verification service | `operator-private`, generated from `templates/license` | Built via Y. Detailed implementation in a follow-up session. |
+| C | Operator client integration | this repo (`operator/`) | Future ticket. Reads entitlement JWT, exposes flags via OpenFeature provider. |
+| D | Storefront + billing | LemonSqueezy hosted; no template. Adapters in `templates/app` + `templates/api`. | Built via Y. |
+| E | Customer account site | `templates/app` instance | Built via Y. |
+| F | Admin tool | `templates/admin` instance | Built via Y. |
+
+### Y. Untra platform template (this session)
+
+Six archetype template repos at `../templates/`:
+
+```
+templates/
+├── iac/ # Terraform/OpenTofu modules: Cloudflare, GCP, Firebase, Neon, IAP, Secret Manager
+├── api/ # Generic product backend (Rust + Axum + Cloud Run)
+├── app/ # Customer-facing web app (TypeScript SPA, sign-in via Firebase, license mgmt UI)
+├── auth/ # Identity orchestrator (auth.): Firebase Auth UI + license-exchange endpoint
+├── admin/ # Admin console (IAP-gated): plan editor, license mgmt, revocation
+└── license/ # Privileged entitlement service (vendors into *-private repos only)
+```
+
+### Entitlement model — locked decisions (referenced by `license`)
+
+These were agreed earlier in the session. They become the "Entitlement Model — frozen" section in `license/README.md`. Detailed token schema/crypto is part of the deferred A-spec.
+
+1. **Vintage model:** Adobe-style year-versioned major releases (`standard-2025`, `enterprise-2026`). Each vintage is a distinct SKU.
+2. **Access duration:** Perpetual one-time buy per vintage. No subscription expiry.
+3. **Feature types:** Booleans + integer limits (e.g. `acp_enabled=true`, `max_projects=20`).
+4. **Verification model:** Hybrid — short opaque license key + server-issued signed entitlement JWT cached for ~14 days.
+5. **License scope:** Single user, soft cap of 3 machines per license.
+6. **Account model:** One account owns many licenses over time.
+7. **OpenFeature shape:** Custom OpenFeature provider in client (Rust SDK) reads flags from cached entitlement JWT.
+8. **Revocation:** Revocation list + TTL-driven propagation. Already-cached tokens expire within ~14 days of revocation.
+
+---
+
+## Service trust model
+
+```
+ Firebase ID token
+ user ──signin──▶ auth. ──┐
+ │ (verify license key + Firebase identity)
+ ▼
+ license.
+ (Ed25519 sign entitlement JWT)
+ │
+ client ◀───── entitlement JWT ───┘
+ │
+ ▼ Bearer JWT
+ api. ◀── verifies JWT against license public key (embedded)
+ │
+ └── LemonSqueezy webhook ──▶ signed internal call ──▶ license (mint license)
+```
+
+**Trust boundary:** `license`'s signing key never leaves the private GCP project. `auth` and `api` only hold `license`'s public key. Public-monorepo CI cannot touch `license`'s secrets.
+
+---
+
+## Architectural decisions (named, not buried)
+
+### D1. Two-repo structure per product (public + private)
+
+Every untra product produces **two** GitHub repos at provisioning time:
+
+- `/` — public monorepo, vendors `iac` + `api` + `app` + `auth` + `admin`.
+- `-private/` — private monorepo, vendors `license` and a separate slice of `iac` (the private-side state, signing-key Secret Manager, separate Neon project).
+
+The two repos correspond to two separate GCP projects with no cross-project IAM. The only runtime coupling is the signed internal HTTPS call from `api` (public) to `license` (private).
+
+### D2. LemonSqueezy webhook lands in `api`, not `license`
+
+Webhooks have public, unauthenticated ingress by design. `api` already has a public surface and a Neon DB for user records — it's the right place to receive them. `api` then makes a **signed internal request** (HMAC over webhook payload + nonce) to `license` to mint the actual license. `license` never receives unauthenticated external traffic; the only external endpoints on `license` are token-signing endpoints called by `auth`.
+
+### D3. The first untra product (Operator) eats its own dog food
+
+Operator is both:
+- a **consumer** of the platform (it has license keys, calls `auth` to refresh entitlements, gates features via OpenFeature)
+- the **bootstrap operator** for future products (per its CLAUDE.md, "self-starting work multiplexor")
+
+Therefore Operator's licensing integration (sub-project C) and the platform templates (Y) must be designed so Operator can later orchestrate Phase 2 deployments for *new* untra products. This session does not implement that orchestration; it only avoids painting it into a corner.
+
+---
+
+## Cost-deferral phases
+
+- **Phase 0** *(this session, $0)*: six templates exist at `../templates//`, each `git init`'d, each containing only `README.md` and `HANDOFF.md`.
+- **Phase 1** *(follow-up Claude sessions, $0)*: each template gets fleshed out by a fresh Claude session using its HANDOFF.md. Output: working code, Dockerfiles, IaC modules. Still local; no cloud accounts needed.
+- **Phase 2** *(first deployment, ~$12/yr)*: register Operator's root domain. Set up Cloudflare zone (free), GCP project (uses $300 trial credit), Firebase Auth (free tier), Neon Postgres (free tier), Google Secret Manager (free tier). Deploy all services to Cloud Run with `min_instances=0`. Cost ceiling: ~$12/yr for the year, possibly $0 within trial credit.
+- **Phase 3** *(first sale)*: LemonSqueezy onboarding (~30 min, no business entity required). First transaction triggers the first revenue and the first 5%+50¢ fee. No upfront commitment.
+
+---
+
+## What this session WILL produce (post-ExitPlanMode)
+
+**Top-level index file:**
+1. Write `../templates/README.md` — a one-page index explaining the six archetype repos, the platform stack, and how they fit together. Section outline below.
+
+**Per archetype** (`iac`, `api`, `app`, `auth`, `admin`, `license`):
+1. Create directory `../templates//`.
+2. Run `git init` inside it.
+3. Write `README.md` per the outline below.
+4. Write `HANDOFF.md` per the outline below.
+5. **Do not commit.** Per user instruction (memory: `feedback_no_commits.md`), the user handles all commits.
+
+**Promotion step:**
+1. After the templates exist, copy this plan file to `operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md` so it survives outside `~/.claude/plans/`. (User confirmed promotion at plan approval.)
+
+That is the entirety of the action. **No source files, no Dockerfiles, no Terraform.** Those are Phase 1, in separate sessions.
+
+---
+
+## Top-level `../templates/README.md` outline
+
+A short index file at the root of the templates directory. Anyone who `cd`s into `../templates/` should understand the platform in under a minute. Sections in order:
+
+```
+# untra platform templates
+
+## What this directory is
+One paragraph: these are archetype templates for untra's billable-SaaS platform.
+Each subdirectory is its own git repo; they get vendored into per-product
+monorepos at Phase 2.
+
+## The six archetypes
+A table: archetype name | one-line role | vendors into (public/private).
+
+## Platform stack (defaults)
+The cost-deferral stack table from the master spec, abbreviated:
+domain (Cloudflare), compute (Cloud Run min=0), identity (Firebase Auth),
+data (Neon Postgres), storefront (LemonSqueezy MoR), CI (GitHub Actions),
+IaC (OpenTofu). Cost-at-zero-traffic ceiling: ~$12/yr (domain only).
+
+## Trust model
+The auth → license → JWT → api diagram, in ASCII.
+
+## How a new product is provisioned
+Cross-reference templates/iac/README.md "Quickstart" section for the manual
+checklist.
+
+## Status
+"Phase 0: READMEs and handoff briefs only. See each subdirectory's HANDOFF.md
+to start implementation in a fresh Claude session."
+
+## Master spec
+Pointer to operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md
+```
+
+---
+
+## README.md outline (per template)
+
+A common shape, plus per-archetype detail. Each `README.md` should contain these sections in this order:
+
+```
+# — untra platform template
+
+## Purpose
+One paragraph describing what an instance of this template does in a deployed untra product.
+
+## Role in the platform
+A short list of which other archetypes this template talks to and how.
+
+## Tech stack
+Language, framework, deployment target. From the locked Y stack.
+
+## Repository layout
+The directory shape this template prescribes.
+
+## Quickstart (instantiation)
+How a Phase 2 operator clones this template into a new product monorepo.
+
+## Configuration
+Parameters that must be set per product (e.g. `{root_domain}`, `{gcp_project_id}`).
+
+## Cost profile
+Free-tier story; what triggers paid usage.
+
+## Status
+"Phase 0: README and handoff brief only. See HANDOFF.md to start implementation."
+
+## Links
+Pointer to master spec, the platform stack table, related archetypes.
+```
+
+### Per-archetype README key facts
+
+**`templates/iac/README.md`:**
+- Purpose: Terraform/OpenTofu modules that provision a single untra product's cloud (public + private sides).
+- Modules to enumerate: `cloudflare_zone`, `gcp_project`, `cloud_run_service`, `firebase_auth`, `neon_project`, `secret_manager`, `iap_admin`, `github_oidc_wif`.
+- Two top-level compositions: `iac/public/` and `iac/private/`, run against separate GCP projects.
+- Backend state: GCS bucket per product, configured via `terraform init -backend-config`.
+
+**`templates/api/README.md`:**
+- Purpose: generic product backend. Rust + Axum + Cloud Run.
+- Endpoints (initial): `GET /version` (unauth), `GET /me` (entitlement-JWT-auth, returns user + active license summary), `POST /webhooks/lemonsqueezy` (HMAC-verified).
+- Verifies entitlement JWT against `license` public key, embedded at build time.
+- Talks to: Neon Postgres (user table, license cache table); calls `license` over signed HMAC internal request.
+
+**`templates/app/README.md`:**
+- Purpose: customer-facing web app at `app.`. TypeScript + React + Vite + Firebase JS SDK.
+- Routes (initial): `/` (landing/download), `/signin` (redirects to `auth.`), `/account` (signed-in: shows licenses, machines), `/licenses/:id` (manage machines for a license).
+- Calls `auth.` for sign-in flow; calls `api.` for authenticated data.
+- Static-friendly: deployable to Cloud Storage + Cloud CDN or Cloudflare Pages.
+
+**`templates/auth/README.md`:**
+- Purpose: identity orchestrator at `auth.`. Cloud Run service + small static UI.
+- Flow: user signs in via Firebase (email/password, magic-link, OAuth) → static UI captures Firebase ID token → `auth` calls `license.` to verify entitlement → returns entitlement JWT to client. Also handles license-key claim (first-time activation) and machine registration (machine-fingerprint binding within the 3-machine cap).
+- Endpoints: `POST /claim` (Firebase token + license key + machine fingerprint), `POST /refresh` (Firebase token + machine fingerprint).
+
+**`templates/admin/README.md`:**
+- Purpose: admin console at `admin.`, gated by GCP IAP (no Firebase Auth — admin is internal).
+- Routes (initial): `/plans` (define plans + feature bundles via OpenFeature schema), `/licenses` (search, view, revoke), `/users` (view accounts), `/billing` (LemonSqueezy passthrough links).
+- Calls `api` for read data, calls `license` for write actions (mint license manually, revoke).
+
+**`templates/license/README.md`:**
+- Purpose: privileged entitlement service. Rust + Axum + Cloud Run. Lives in private sister repo only.
+- **Embed the eight locked entitlement decisions verbatim** (see "Entitlement model — locked decisions" above) as a "Frozen entitlement model" section.
+- Endpoints: `POST /internal/verify` (called by `auth`, HMAC-authed, returns entitlement JWT), `POST /internal/mint` (called by `api`, HMAC-authed, creates a license record), `POST /internal/revoke` (called by `admin`, HMAC-authed). All endpoints are internal-only (Cloud Run with IAM-based ingress restriction).
+- Crypto: Ed25519 signing key in Secret Manager; public key available at a public, unauth, cacheable `GET /.well-known/license-public-key` endpoint (used by client-side OpenFeature provider and by `api` for JWT verification).
+- Detailed token schema and key-rotation policy: **deferred to A-spec follow-up.**
+
+---
+
+## HANDOFF.md outline (per template)
+
+Fixed structure across all six templates so the briefs are interchangeable. Each `HANDOFF.md` should contain these sections in this order:
+
+```
+# Handoff brief —
+
+> Read this file before starting implementation. You are a fresh Claude session
+> with no prior context. The README.md in this same directory has the role and
+> stack. This file tells you what "done" looks like for the first milestone.
+
+## Pointer to master spec
+Path: ~/.claude/plans/this-project-operator-is-rustling-tome.md
+(or wherever the user has moved it after approval — check first).
+
+## Role (one paragraph)
+Restate the role from README. Confirms shared interpretation.
+
+## Acceptance criteria (testable)
+Concrete, runnable checks. Examples:
+- "Produces a Cloud Run service that responds HTTP 200 to /healthz"
+- "Returns HTTP 401 to /me when no Authorization header is present"
+- "Terraform plan against a clean GCP project produces zero errors"
+
+## Public interface
+- HTTP endpoints / UI routes / Terraform variables / library exports.
+- Schemas where they exist (link to A-spec for entitlement JWT schema).
+
+## Dependencies
+- Which other archetypes this calls (by name).
+- Which other archetypes call this (by name).
+- External services (Firebase, Neon, Cloudflare, LemonSqueezy).
+
+## Non-goals
+Explicit list of things NOT to build in this session. Examples for `api`:
+- Do not implement license signing — that's `license`'s job.
+- Do not build the admin endpoints — those live in `admin`.
+- Do not add OpenTelemetry exporters yet; add `tracing` only.
+
+## First milestone (smallest deployable slice)
+A specific, minimal end-to-end slice that proves the template works.
+Example for `api`: "GET /version returns {\"version\":\"0.1.0\"} as JSON,
+deployed to Cloud Run min=0, served at api.."
+
+## Out-of-scope flags for later milestones
+List of features to leave as `// TODO(milestone-2)` comments so the next
+session knows what comes next.
+```
+
+### Per-archetype HANDOFF key facts
+
+**`templates/iac/HANDOFF.md`:**
+- First milestone: a `terraform plan` for a hypothetical product `example-product` against a fresh GCP project produces a valid plan (no apply required this milestone).
+- Non-goals: do not write a GitHub Actions workflow that runs `terraform apply` yet; do not provision DNS records for the private domain (private side is its own composition).
+
+**`templates/api/HANDOFF.md`:**
+- First milestone: `GET /version` returns the current version as JSON; service builds into a Cloud Run image via the included Dockerfile; `curl localhost:8080/version` works locally.
+- Non-goals: do not implement webhook signature verification yet (stub it); do not implement `/me` yet; do not connect to Neon yet (stub the DB layer).
+
+**`templates/app/HANDOFF.md`:**
+- First milestone: a static site that renders a landing page with a "Download Operator" button and a "Sign In" link to `auth./signin`; `npm run build` produces a deployable `dist/`.
+- Non-goals: no `/account` page yet; no API integration; no Firebase wiring in JS yet (just a link).
+
+**`templates/auth/HANDOFF.md`:**
+- First milestone: a Cloud Run service exposing a static `/signin` page (Firebase UI or hand-rolled email-link form) that successfully signs a user in and shows their Firebase UID; `POST /claim` is stubbed and returns `501 Not Implemented`.
+- Non-goals: do not call `license` yet (stub the call); do not implement machine-fingerprint logic yet; do not implement `/refresh`.
+
+**`templates/admin/HANDOFF.md`:**
+- First milestone: a Cloud Run service with IAP enforcement that renders a "Hello, {user.email}" page sourced from `X-Goog-Authenticated-User-Email`.
+- Non-goals: no plan editor yet; no license search; no revocation UI; no API integration.
+
+**`templates/license/HANDOFF.md`:**
+- First milestone: a Cloud Run service with a `/healthz` endpoint and a `/.well-known/license-public-key` endpoint that returns a hardcoded Ed25519 public key (real key generation deferred to A-spec).
+- Non-goals: do not implement `/internal/verify`, `/internal/mint`, or `/internal/revoke` yet; do not implement the revocation list; do not implement HMAC validation of internal callers yet (return `501` with a `TODO` comment); do not freeze the entitlement JWT schema (waits on A-spec follow-up).
+
+---
+
+## Critical files to be created
+
+```
+../templates/README.md (top-level index, not in any git repo)
+../templates/iac/README.md
+../templates/iac/HANDOFF.md
+../templates/iac/.git/ (git init only)
+../templates/api/README.md
+../templates/api/HANDOFF.md
+../templates/api/.git/
+../templates/app/README.md
+../templates/app/HANDOFF.md
+../templates/app/.git/
+../templates/auth/README.md
+../templates/auth/HANDOFF.md
+../templates/auth/.git/
+../templates/admin/README.md
+../templates/admin/HANDOFF.md
+../templates/admin/.git/
+../templates/license/README.md
+../templates/license/HANDOFF.md
+../templates/license/.git/
+```
+
+Plus the promotion copy:
+
+```
+operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md
+```
+
+Total: 13 files written, 6 directories `git init`'d, 1 spec file promoted. **Zero commits.**
+
+---
+
+## Verification
+
+After implementation:
+
+```bash
+# 0. Top-level index exists.
+test -f ../templates/README.md && echo "top README OK" || echo "TOP README MISSING"
+
+# 1. All directories exist and are git repos.
+for t in iac api app auth admin license; do
+ test -d ../templates/$t/.git && echo "$t: git OK" || echo "$t: GIT MISSING"
+done
+
+# 2. Both files exist per template.
+for t in iac api app auth admin license; do
+ test -f ../templates/$t/README.md && test -f ../templates/$t/HANDOFF.md \
+ && echo "$t: files OK" || echo "$t: files MISSING"
+done
+
+# 3. No stray files inside template repos.
+find ../templates -mindepth 2 -maxdepth 2 -type f \
+ ! -name README.md ! -name HANDOFF.md ! -path '*/.git/*' \
+ | grep -v "^$" && echo "stray files present" || echo "no stray files"
+
+# 4. No commits yet (user commits manually).
+for t in iac api app auth admin license; do
+ ( cd ../templates/$t && test -z "$(git log 2>/dev/null)" \
+ && echo "$t: no commits OK" || echo "$t: HAS COMMITS" )
+done
+
+# 5. Promoted spec exists.
+test -f operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md \
+ && echo "promoted spec OK" || echo "PROMOTED SPEC MISSING"
+```
+
+After Phase 1 (separate sessions, out of scope here): each template implements its first-milestone acceptance criteria.
+
+---
+
+## Non-goals of this plan (explicit)
+
+- No code beyond `README.md` and `HANDOFF.md`.
+- No commits anywhere.
+- No registration of any cloud account, domain, or LemonSqueezy account.
+- No detailed entitlement-JWT schema or key rotation policy (that's A-spec follow-up).
+- No work in `operator/` itself in this session (Operator's licensing-client integration C is a future ticket).
+- No GitHub Actions workflows, Dockerfiles, or Terraform code.
+- No bootstrap script (user chose "manual checklist" — checklist content lives in `templates/iac/README.md` Quickstart section, not as separate code).
+
+---
+
+## Follow-ups queued after this plan executes
+
+1. **A-spec brainstorm session** — flesh out the entitlement-JWT schema, key format, key rotation, machine-fingerprint algorithm. Produces the detailed `license` data model.
+2. **Per-template implementation sessions (6 of them)** — each starts a fresh Claude in the relevant `../templates//` directory and works from HANDOFF.md.
+3. **Operator integration (C)** — separate ticket in `operator/` to add the OpenFeature provider, license-key UI, refresh loop. Depends on A-spec being done.
+4. **Promote this plan** — confirmed at approval. Copy to `operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md` as part of this session's deliverable (user commits manually).
diff --git a/docs/backstage/taxonomy.md b/docs/taxonomy/index.md
similarity index 93%
rename from docs/backstage/taxonomy.md
rename to docs/taxonomy/index.md
index d1b8e89..b521b5d 100644
--- a/docs/backstage/taxonomy.md
+++ b/docs/taxonomy/index.md
@@ -3,25 +3,25 @@ title: "Project Taxonomy"
layout: doc
---
-
+
# Project Taxonomy
This document defines the **25 project Kinds** organized into **5 tiers**.
-Each Kind represents a category of project that can be cataloged in Backstage. The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files.
+Each Kind represents a category of project that can be classified by Operator. The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files.
## Version
- **Version**: `1.0.0`
-- **Description**: Operator project taxonomy for Backstage catalog
+- **Description**: Operator project taxonomy for project classification
## Quick Reference
All 24 Kinds at a glance:
-| ID | Key | Name | Tier | Backstage Type |
+| ID | Key | Name | Tier | Catalog Type |
| --- | --- | --- | --- | --- |
| 1 | `infrastructure` | Infrastructure (IaC) | foundation | `resource` |
| 2 | `identity-access` | Identity & Access (IAM) | foundation | `resource` |
@@ -67,7 +67,7 @@ Cloud resources and network (Terraform/CDK)
- **Key**: `infrastructure`
- **Stakeholder**: Platform/DevOps
- **Primary Output**: Cloud Environment
-- **Backstage Type**: `resource`
+- **Catalog Type**: `resource`
**Detection** File Patterns:
- `*.tf`
@@ -88,7 +88,7 @@ Service accounts, secrets, and RBAC policies
- **Key**: `identity-access`
- **Stakeholder**: SDET/SecOps
- **Primary Output**: Permissions/Tokens
-- **Backstage Type**: `resource`
+- **Catalog Type**: `resource`
**Detection** File Patterns:
- `iam-*.yaml`
@@ -108,7 +108,7 @@ Global feature flags and environment manifests
- **Key**: `config-policy`
- **Stakeholder**: Platform/DevOps
- **Primary Output**: Runtime Behavior
-- **Backstage Type**: `resource`
+- **Catalog Type**: `resource`
**Detection** File Patterns:
- `config/*.yaml`
@@ -128,7 +128,7 @@ Orchestration for projects and root standards
- **Key**: `monorepo-meta`
- **Stakeholder**: Architect/Lead
- **Primary Output**: Project Standards
-- **Backstage Type**: `system`
+- **Catalog Type**: `system`
**Detection** File Patterns:
- `nx.json`
@@ -161,7 +161,7 @@ Reusable UI components and brand tokens
- **Key**: `design-system`
- **Stakeholder**: Product/UX
- **Primary Output**: Component Libraries
-- **Backstage Type**: `library`
+- **Catalog Type**: `library`
**Detection** File Patterns:
- `tokens/*.json`
@@ -180,7 +180,7 @@ Reusable internal logic packages (Shared Utils)
- **Key**: `software-library`
- **Stakeholder**: Engineering
- **Primary Output**: Versioned Packages
-- **Backstage Type**: `library`
+- **Catalog Type**: `library`
**Detection** File Patterns:
- `lib/*`
@@ -199,7 +199,7 @@ API contracts and generated client libraries
- **Key**: `proto-sdk`
- **Stakeholder**: Engineering
- **Primary Output**: Contract Libraries
-- **Backstage Type**: `api`
+- **Catalog Type**: `api`
**Detection** File Patterns:
- `*.proto`
@@ -222,7 +222,7 @@ Scaffolding templates for bootstrapping repos
- **Key**: `blueprint`
- **Stakeholder**: Architect/Lead
- **Primary Output**: Project Templates
-- **Backstage Type**: `template`
+- **Catalog Type**: `template`
**Detection** File Patterns:
- `template.yaml`
@@ -241,7 +241,7 @@ Custom scanners, audit scripts, and honeytokens
- **Key**: `security-tooling`
- **Stakeholder**: SDET/SecOps
- **Primary Output**: Security Reports
-- **Backstage Type**: `tool`
+- **Catalog Type**: `tool`
**Detection** File Patterns:
- `security/*`
@@ -262,7 +262,7 @@ Evidence, snapshots, and regulatory reports
- **Key**: `compliance-audit`
- **Stakeholder**: SDET/SecOps
- **Primary Output**: Compliance Proofs
-- **Backstage Type**: `documentation`
+- **Catalog Type**: `documentation`
**Detection** File Patterns:
- `compliance/*`
@@ -295,7 +295,7 @@ Training scripts and model weight artifacts
- **Key**: `ml-model`
- **Stakeholder**: Data/ML
- **Primary Output**: Model Artifacts
-- **Backstage Type**: `service`
+- **Catalog Type**: `service`
**Detection** File Patterns:
- `model/*`
@@ -318,7 +318,7 @@ Data transformation logic and SQL models
- **Key**: `data-etl`
- **Stakeholder**: Data/ML
- **Primary Output**: Clean Datasets
-- **Backstage Type**: `service`
+- **Catalog Type**: `service`
**Detection** File Patterns:
- `dbt_project.yml`
@@ -338,7 +338,7 @@ Backend business logic and domain units
- **Key**: `microservice`
- **Stakeholder**: Engineering
- **Primary Output**: Running Binaries
-- **Backstage Type**: `service`
+- **Catalog Type**: `service`
**Detection** File Patterns:
- `src/main.rs`
@@ -359,7 +359,7 @@ Entry points that route and protect traffic
- **Key**: `api-gateway`
- **Stakeholder**: Engineering
- **Primary Output**: Network Endpoints
-- **Backstage Type**: `api`
+- **Catalog Type**: `api`
**Detection** File Patterns:
- `gateway/*`
@@ -380,7 +380,7 @@ Web or mobile apps for end-user interaction
- **Key**: `ui-frontend`
- **Stakeholder**: Engineering
- **Primary Output**: Web/Mobile Assets
-- **Backstage Type**: `website`
+- **Catalog Type**: `website`
**Detection** File Patterns:
- `src/App.tsx`
@@ -402,7 +402,7 @@ Private apps for internal business operations
- **Key**: `internal-tool`
- **Stakeholder**: Engineering
- **Primary Output**: Operational Apps
-- **Backstage Type**: `service`
+- **Catalog Type**: `service`
**Detection** File Patterns:
- `admin/*`
@@ -431,7 +431,7 @@ CI/CD actions and custom build logic
- **Key**: `build-tool`
- **Stakeholder**: Platform/DevOps
- **Primary Output**: Automated Pipelines
-- **Backstage Type**: `tool`
+- **Catalog Type**: `tool`
**Detection** File Patterns:
- `.github/workflows/*`
@@ -452,7 +452,7 @@ Integration tests and smoke test runners
- **Key**: `e2e-test`
- **Stakeholder**: SDET/SecOps
- **Primary Output**: Quality Reports
-- **Backstage Type**: `tool`
+- **Catalog Type**: `tool`
**Detection** File Patterns:
- `e2e/*`
@@ -473,7 +473,7 @@ Documentation, tutorials, and references
- **Key**: `docs-site`
- **Stakeholder**: Product/UX
- **Primary Output**: Static Support Sites
-- **Backstage Type**: `website`
+- **Catalog Type**: `website`
**Detection** File Patterns:
- `docs/*`
@@ -494,7 +494,7 @@ Incident response and on-call runbooks
- **Key**: `playbook`
- **Stakeholder**: Platform/DevOps
- **Primary Output**: Operational Guides
-- **Backstage Type**: `documentation`
+- **Catalog Type**: `documentation`
**Detection** File Patterns:
- `playbooks/*`
@@ -512,7 +512,7 @@ Productivity scripts and developer utilities
- **Key**: `cli-devtool`
- **Stakeholder**: Platform/DevOps
- **Primary Output**: Developer UX Tools
-- **Backstage Type**: `tool`
+- **Catalog Type**: `tool`
**Detection** File Patterns:
- `cli/*`
@@ -541,7 +541,7 @@ Best-practice implementation examples
- **Key**: `reference-example`
- **Stakeholder**: Architect/Lead
- **Primary Output**: Educational Code
-- **Backstage Type**: `documentation`
+- **Catalog Type**: `documentation`
**Detection** File Patterns:
- `examples/*`
@@ -558,7 +558,7 @@ Proof-of-concepts and R&D "spikes"
- **Key**: `experiment-sandbox`
- **Stakeholder**: Engineering
- **Primary Output**: Discardable Code
-- **Backstage Type**: `service`
+- **Catalog Type**: `service`
**Detection** File Patterns:
- `experiments/*`
@@ -576,7 +576,7 @@ Legacy code and forks of 3rd party repos
- **Key**: `archival-fork`
- **Stakeholder**: SDET/SecOps
- **Primary Output**: Historical/Vendor Code
-- **Backstage Type**: `library`
+- **Catalog Type**: `library`
**Detection** File Patterns:
- `vendor/*`
@@ -594,7 +594,7 @@ Repositories containing test data, fixtures, seed data, and mock datasets
- **Key**: `test-data-fixtures`
- **Stakeholder**: SDET/SecOps
- **Primary Output**: Test Data Assets
-- **Backstage Type**: `resource`
+- **Catalog Type**: `resource`
**Detection** File Patterns:
- `fixtures/*`
@@ -901,11 +901,11 @@ Patterns use glob syntax:
- `*.seed.sql`
- `db/seeds/*`
-## Backstage Type Mapping
+## Catalog Type Mapping
-Each Kind maps to a Backstage catalog type:
+Each Kind maps to a catalog type:
-| Backstage Type | Kinds |
+| Catalog Type | Kinds |
| --- | --- |
| `api` | `proto-sdk`, `api-gateway` |
| `documentation` | `compliance-audit`, `playbook`, `reference-example` |
diff --git a/icons/coder.svg b/icons/coder.svg
new file mode 100644
index 0000000..a0acff3
--- /dev/null
+++ b/icons/coder.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/zedindustries.svg b/icons/zedindustries.svg
new file mode 100644
index 0000000..02327fd
--- /dev/null
+++ b/icons/zedindustries.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock
index afb58bb..e6b687b 100644
--- a/opr8r/Cargo.lock
+++ b/opr8r/Cargo.lock
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "anstream"
-version = "0.6.21"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -19,15 +19,15 @@ dependencies = [
[[package]]
name = "anstyle"
-version = "1.0.13"
+version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
-version = "0.2.7"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
@@ -95,9 +95,9 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bumpalo"
-version = "3.19.1"
+version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
@@ -107,9 +107,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
-version = "1.2.52"
+version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
+checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -129,9 +129,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clap"
-version = "4.5.54"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
@@ -139,9 +139,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.54"
+version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
@@ -151,9 +151,9 @@ dependencies = [
[[package]]
name = "clap_derive"
-version = "4.5.49"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
@@ -163,15 +163,15 @@ dependencies = [
[[package]]
name = "clap_lex"
-version = "0.7.7"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
-version = "1.0.4"
+version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "dirs"
@@ -223,26 +223,25 @@ dependencies = [
[[package]]
name = "fastrand"
-version = "2.3.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "filetime"
-version = "0.2.27"
+version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
- "libredox",
]
[[package]]
name = "find-msvc-tools"
-version = "0.1.7"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
@@ -270,35 +269,35 @@ dependencies = [
[[package]]
name = "futures-channel"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
- "pin-utils",
+ "slab",
]
[[package]]
@@ -352,9 +351,9 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.17.0"
+version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
@@ -364,9 +363,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
-version = "1.4.0"
+version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
@@ -403,9 +402,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
-version = "1.8.1"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -416,7 +415,6 @@ dependencies = [
"httparse",
"itoa",
"pin-project-lite",
- "pin-utils",
"smallvec",
"tokio",
"want",
@@ -424,15 +422,14 @@ dependencies = [
[[package]]
name = "hyper-rustls"
-version = "0.27.7"
+version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
- "rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
@@ -441,14 +438,13 @@ dependencies = [
[[package]]
name = "hyper-util"
-version = "0.1.19"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
- "futures-core",
"futures-util",
"http",
"http-body",
@@ -465,12 +461,13 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
+ "utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -478,9 +475,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -491,9 +488,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -505,15 +502,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -525,15 +522,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -563,9 +560,9 @@ dependencies = [
[[package]]
name = "idna_adapter"
-version = "1.2.1"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
@@ -578,7 +575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
- "hashbrown 0.17.0",
+ "hashbrown 0.17.1",
"serde",
"serde_core",
]
@@ -614,19 +611,9 @@ dependencies = [
[[package]]
name = "ipnet"
-version = "2.11.0"
+version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
-
-[[package]]
-name = "iri-string"
-version = "0.7.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
-dependencies = [
- "memchr",
- "serde",
-]
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
@@ -636,16 +623,18 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
-version = "1.0.17"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
-version = "0.3.83"
+version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
+checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
+ "cfg-if",
+ "futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -662,9 +651,9 @@ dependencies = [
[[package]]
name = "kqueue-sys"
-version = "1.1.0"
+version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.11.1",
"libc",
@@ -678,9 +667,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
-version = "0.2.180"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
@@ -688,29 +677,26 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
- "bitflags 2.11.1",
"libc",
- "plain",
- "redox_syscall",
]
[[package]]
name = "linux-raw-sys"
-version = "0.11.0"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
-version = "0.4.29"
+version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "lru-slab"
@@ -720,15 +706,15 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "memchr"
-version = "2.7.6"
+version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mio"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
@@ -766,9 +752,9 @@ dependencies = [
[[package]]
name = "once_cell"
-version = "1.21.3"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
@@ -793,7 +779,7 @@ dependencies = [
[[package]]
name = "opr8r"
-version = "0.1.31"
+version = "0.2.0"
dependencies = [
"clap",
"operator-relay",
@@ -819,27 +805,15 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
-
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-
-[[package]]
-name = "plain"
-version = "0.2.3"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "potential_utf"
-version = "0.1.4"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
@@ -865,9 +839,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
-version = "1.0.105"
+version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
@@ -886,7 +860,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@@ -894,9 +868,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.13"
+version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
@@ -907,7 +881,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@@ -929,9 +903,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.43"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -950,9 +924,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
-version = "0.9.2"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
@@ -970,22 +944,13 @@ dependencies = [
[[package]]
name = "rand_core"
-version = "0.9.4"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
-[[package]]
-name = "redox_syscall"
-version = "0.7.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
-dependencies = [
- "bitflags 2.11.1",
-]
-
[[package]]
name = "redox_users"
version = "0.4.6"
@@ -1051,15 +1016,15 @@ dependencies = [
[[package]]
name = "rustc-hash"
-version = "2.1.1"
+version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
-version = "1.1.3"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"errno",
@@ -1070,9 +1035,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.36"
+version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
@@ -1084,9 +1049,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
-version = "1.13.2"
+version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
@@ -1111,9 +1076,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
-version = "1.0.22"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
@@ -1162,9 +1127,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.149"
+version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -1203,9 +1168,9 @@ dependencies = [
[[package]]
name = "slab"
-version = "0.4.11"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
@@ -1215,12 +1180,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
-version = "0.6.1"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1243,9 +1208,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
-version = "2.0.114"
+version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -1274,12 +1239,12 @@ dependencies = [
[[package]]
name = "tempfile"
-version = "3.24.0"
+version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -1296,11 +1261,11 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.17"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
- "thiserror-impl 2.0.17",
+ "thiserror-impl 2.0.18",
]
[[package]]
@@ -1316,9 +1281,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
-version = "2.0.17"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
@@ -1327,9 +1292,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@@ -1337,9 +1302,9 @@ dependencies = [
[[package]]
name = "tinyvec"
-version = "1.10.0"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
@@ -1352,9 +1317,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.49.0"
+version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@@ -1368,9 +1333,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.6.0"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@@ -1404,20 +1369,20 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.6.8"
+version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.11.1",
"bytes",
"futures-util",
"http",
"http-body",
- "iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
+ "url",
]
[[package]]
@@ -1471,9 +1436,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "unicode-ident"
-version = "1.0.22"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
@@ -1549,11 +1514,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
-version = "1.0.1+wasi-0.2.4"
+version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
- "wit-bindgen 0.46.0",
+ "wit-bindgen 0.57.1",
]
[[package]]
@@ -1567,9 +1532,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.106"
+version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
+checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
@@ -1580,22 +1545,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.56"
+version = "0.4.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
+checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
dependencies = [
- "cfg-if",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.106"
+version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
+checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1603,9 +1565,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.106"
+version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
+checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -1616,9 +1578,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.106"
+version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
+checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
@@ -1659,9 +1621,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.83"
+version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
+checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -1679,9 +1641,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
-version = "1.0.5"
+version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
@@ -1923,12 +1885,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-[[package]]
-name = "wit-bindgen"
-version = "0.46.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
-
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -1938,6 +1894,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
@@ -2019,15 +1981,15 @@ dependencies = [
[[package]]
name = "writeable"
-version = "0.6.2"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yoke"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -2036,9 +1998,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@@ -2048,18 +2010,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.33"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.33"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
@@ -2068,18 +2030,18 @@ dependencies = [
[[package]]
name = "zerofrom"
-version = "0.1.6"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@@ -2095,9 +2057,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
-version = "0.2.3"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@@ -2106,9 +2068,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.5"
+version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@@ -2117,9 +2079,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.2"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
@@ -2128,6 +2090,6 @@ dependencies = [
[[package]]
name = "zmij"
-version = "1.0.13"
+version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/opr8r/Cargo.toml b/opr8r/Cargo.toml
index 8ef3ad5..e096a9b 100644
--- a/opr8r/Cargo.toml
+++ b/opr8r/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "opr8r"
-version = "0.1.31"
+version = "0.2.0"
edition = "2021"
description = "Minimal CLI wrapper for LLM commands in multi-step ticket workflows"
license = "MIT"
diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh
new file mode 100755
index 0000000..76d8581
--- /dev/null
+++ b/scripts/cicdprep.sh
@@ -0,0 +1,276 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$ROOT_DIR"
+
+# --- Colors & helpers ---
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+BOLD='\033[1m'
+RESET='\033[0m'
+
+RUN_ALL=false
+CONTINUE_ON_FAIL=false
+FAILURES=()
+PASSES=()
+SKIPPED=()
+
+usage() {
+ echo "Usage: $(basename "$0") [OPTIONS]"
+ echo ""
+ echo "Run CI/CD checks locally before creating a PR."
+ echo "Auto-detects changed files and runs only relevant workflow checks."
+ echo ""
+ echo "Options:"
+ echo " --all Run all checks regardless of changed files"
+ echo " --continue Don't stop on first failure; run everything and report"
+ echo " -h, --help Show this help"
+}
+
+for arg in "$@"; do
+ case "$arg" in
+ --all) RUN_ALL=true ;;
+ --continue) CONTINUE_ON_FAIL=true ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $arg"; usage; exit 1 ;;
+ esac
+done
+
+section() {
+ echo ""
+ echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════════${RESET}"
+ echo -e "${CYAN}${BOLD} $1${RESET}"
+ echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════════${RESET}"
+}
+
+step() {
+ echo -e "\n${BOLD}▸ $1${RESET}"
+}
+
+pass() {
+ echo -e " ${GREEN}✓ $1${RESET}"
+ PASSES+=("$1")
+}
+
+fail() {
+ echo -e " ${RED}✗ $1${RESET}"
+ FAILURES+=("$1")
+ if [ "$CONTINUE_ON_FAIL" = false ]; then
+ echo -e "\n${RED}${BOLD}FAILED: $1${RESET}"
+ echo -e "${RED}Use --continue to run all checks despite failures.${RESET}"
+ exit 1
+ fi
+}
+
+skip() {
+ echo -e " ${YELLOW}⊘ $1 (skipped — no changes)${RESET}"
+ SKIPPED+=("$1")
+}
+
+require_tool() {
+ local tool="$1"
+ local context="$2"
+ if ! command -v "$tool" &>/dev/null; then
+ echo -e "${RED}Missing required tool: ${BOLD}$tool${RESET}${RED} (needed for $context)${RESET}"
+ echo "Install it and re-run."
+ exit 1
+ fi
+}
+
+run_step() {
+ local label="$1"
+ shift
+ step "$label"
+ if "$@"; then
+ pass "$label"
+ else
+ fail "$label"
+ fi
+}
+
+# --- Detect changed files ---
+
+section "Detecting changes"
+
+MAIN_BRANCH="main"
+if ! git rev-parse --verify "$MAIN_BRANCH" &>/dev/null; then
+ MAIN_BRANCH="origin/main"
+fi
+
+MERGE_BASE=$(git merge-base "$MAIN_BRANCH" HEAD 2>/dev/null || echo "")
+
+if [ -z "$MERGE_BASE" ]; then
+ echo -e "${YELLOW}Could not find merge base with $MAIN_BRANCH — running all checks.${RESET}"
+ RUN_ALL=true
+ CHANGED_FILES=""
+else
+ CHANGED_FILES=$(git diff --name-only "$MERGE_BASE"...HEAD 2>/dev/null || "")
+ UNSTAGED=$(git diff --name-only 2>/dev/null || "")
+ STAGED=$(git diff --name-only --cached 2>/dev/null || "")
+ CHANGED_FILES=$(echo -e "${CHANGED_FILES}\n${UNSTAGED}\n${STAGED}" | sort -u | grep -v '^$' || true)
+fi
+
+if [ "$RUN_ALL" = true ]; then
+ echo -e "${YELLOW}Running ALL checks (--all or no merge base).${RESET}"
+else
+ FILE_COUNT=$(echo "$CHANGED_FILES" | grep -c '.' || echo 0)
+ echo -e "Found ${BOLD}$FILE_COUNT${RESET} changed file(s) vs $MAIN_BRANCH."
+ if [ "$FILE_COUNT" -eq 0 ]; then
+ echo -e "${GREEN}No changes detected. Nothing to check.${RESET}"
+ exit 0
+ fi
+fi
+
+has_changes() {
+ local pattern="$1"
+ if [ "$RUN_ALL" = true ]; then
+ return 0
+ fi
+ echo "$CHANGED_FILES" | grep -qE "$pattern"
+}
+
+# build.yaml triggers on everything EXCEPT docs-only or version-only changes
+needs_operator() {
+ if [ "$RUN_ALL" = true ]; then return 0; fi
+ local non_ignored
+ non_ignored=$(echo "$CHANGED_FILES" | grep -vE '^(docs/|\.github/workflows/docs\.yml$|VERSION$)' || true)
+ [ -n "$non_ignored" ]
+}
+
+needs_opr8r() { has_changes '^opr8r/'; }
+needs_vscode() { has_changes '^(vscode-extension/|icons/)'; }
+needs_zed() { has_changes '^zed-extension/'; }
+needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.toml|src/templates/.*\.json)'; }
+
+# --- 1. Operator (main crate) ---
+
+if needs_operator; then
+ section "Operator (main crate)"
+ require_tool cargo "operator"
+ require_tool bun "operator UI build"
+ require_tool cargo-deny "operator dependency audit"
+
+ step "UI build"
+ (
+ cd ui
+ bun install --frozen-lockfile
+ bun run build
+ DIST_SIZE=$(du -sk dist/ | awk '{print $1 * 1024}')
+ echo " UI dist size: ${DIST_SIZE}B ($(echo "scale=1; $DIST_SIZE/1048576" | bc)MB)"
+ if [ "$DIST_SIZE" -gt 5242880 ]; then
+ echo "UI dist exceeds 5MB budget (${DIST_SIZE}B)" >&2
+ exit 1
+ fi
+ ) && pass "UI build + size check" || fail "UI build + size check"
+
+ run_step "cargo fmt" cargo fmt -- --check
+ run_step "cargo clippy" cargo clippy --locked --all-targets --all-features -- -D warnings
+ run_step "cargo test" cargo test --locked --all-features
+ run_step "cargo deny" cargo deny --manifest-path Cargo.toml check
+else
+ skip "Operator (main crate)"
+fi
+
+# --- 2. opr8r ---
+
+if needs_opr8r; then
+ section "opr8r"
+ require_tool cargo "opr8r"
+ require_tool cargo-deny "opr8r dependency audit"
+
+ run_step "opr8r fmt" bash -c "cd opr8r && cargo fmt -- --check"
+ run_step "opr8r clippy" bash -c "cd opr8r && cargo clippy --locked --all-targets --all-features -- -D warnings"
+ run_step "opr8r test" bash -c "cd opr8r && cargo test --locked --all-features"
+ run_step "opr8r cargo deny" cargo deny --manifest-path opr8r/Cargo.toml check
+else
+ skip "opr8r"
+fi
+
+# --- 3. vscode-extension ---
+
+if needs_vscode; then
+ section "vscode-extension"
+ require_tool node "vscode-extension"
+ require_tool npm "vscode-extension"
+
+ step "Install dependencies"
+ (cd vscode-extension && npm ci) && pass "vscode install" || fail "vscode install"
+
+ run_step "vscode copy-types" bash -c "cd vscode-extension && npm run copy-types"
+ run_step "vscode generate:icons" bash -c "cd vscode-extension && mkdir -p images/icons/dist && npm run generate:icons"
+ run_step "vscode lint" bash -c "cd vscode-extension && npm run lint"
+ run_step "vscode compile" bash -c "cd vscode-extension && npm run compile"
+ run_step "vscode compile:webview" bash -c "cd vscode-extension && npm run compile:webview"
+else
+ skip "vscode-extension"
+fi
+
+# --- 4. zed-extension ---
+
+if needs_zed; then
+ section "zed-extension"
+ require_tool cargo "zed-extension"
+ require_tool cargo-deny "zed-extension dependency audit"
+
+ if ! rustup target list --installed 2>/dev/null | grep -q wasm32-wasip1; then
+ echo -e "${YELLOW}Installing wasm32-wasip1 target...${RESET}"
+ rustup target add wasm32-wasip1
+ fi
+
+ run_step "zed fmt" bash -c "cd zed-extension && cargo fmt -- --check"
+ run_step "zed clippy" bash -c "cd zed-extension && cargo clippy --locked --target wasm32-wasip1 -- -D warnings"
+ run_step "zed build" bash -c "cd zed-extension && cargo build --locked --release --target wasm32-wasip1"
+ run_step "zed cargo deny" cargo deny --manifest-path zed-extension/Cargo.toml check
+else
+ skip "zed-extension"
+fi
+
+# --- 5. docs ---
+
+if needs_docs; then
+ section "docs"
+ require_tool cargo "docs generation"
+ require_tool bundle "docs Jekyll build"
+
+ run_step "docs generate" cargo run --locked -- docs
+ step "Jekyll build"
+ (cd docs && bundle install && bundle exec jekyll build) && pass "Jekyll build" || fail "Jekyll build"
+else
+ skip "docs"
+fi
+
+# --- Summary ---
+
+section "Summary"
+
+if [ ${#PASSES[@]} -gt 0 ]; then
+ echo -e "\n${GREEN}${BOLD}Passed (${#PASSES[@]}):${RESET}"
+ for p in "${PASSES[@]}"; do
+ echo -e " ${GREEN}✓${RESET} $p"
+ done
+fi
+
+if [ ${#SKIPPED[@]} -gt 0 ]; then
+ echo -e "\n${YELLOW}${BOLD}Skipped (${#SKIPPED[@]}):${RESET}"
+ for s in "${SKIPPED[@]}"; do
+ echo -e " ${YELLOW}⊘${RESET} $s"
+ done
+fi
+
+if [ ${#FAILURES[@]} -gt 0 ]; then
+ echo -e "\n${RED}${BOLD}Failed (${#FAILURES[@]}):${RESET}"
+ for f in "${FAILURES[@]}"; do
+ echo -e " ${RED}✗${RESET} $f"
+ done
+ echo ""
+ echo -e "${RED}${BOLD}CI would fail. Fix the above issues before creating a PR.${RESET}"
+ exit 1
+fi
+
+echo ""
+echo -e "${GREEN}${BOLD}All checks passed. Ready to create a PR.${RESET}"
diff --git a/scripts/operator-statusline.sh b/scripts/operator-statusline.sh
new file mode 100755
index 0000000..caaf65b
--- /dev/null
+++ b/scripts/operator-statusline.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# Operator status line for Claude Code sessions.
+# Receives session JSON on stdin; reads OPERATOR_* env vars.
+# Outputs two lines: Line 1 = cwd + git + UI link, Line 2 = operator context.
+
+set -o pipefail
+
+# ANSI color codes
+RESET='\033[0m'
+BOLD='\033[1m'
+BLUE_BG='\033[44m'
+GREEN_BG='\033[42m'
+YELLOW_BG='\033[43m'
+CYAN_BG='\033[46m'
+MAGENTA_BG='\033[45m'
+BLACK_FG='\033[30m'
+WHITE_FG='\033[97m'
+
+# Parse stdin JSON (Claude Code pipes session context)
+if command -v jq >/dev/null 2>&1; then
+ INPUT=$(cat)
+ CWD=$(echo "$INPUT" | jq -r '.cwd // .workspace.current_dir // empty' 2>/dev/null)
+ MODEL=$(echo "$INPUT" | jq -r '.model.display_name // .model // empty' 2>/dev/null)
+ CTX_USED=$(echo "$INPUT" | jq -r '.context_window.used_percentage // empty' 2>/dev/null)
+else
+ # Consume stdin even without jq
+ cat >/dev/null
+ CWD=""
+ MODEL=""
+ CTX_USED=""
+fi
+
+# Fallbacks
+CWD="${CWD:-$(pwd)}"
+
+# Shorten home directory to ~
+home="$HOME"
+SHORT_CWD="${CWD/#$home/\~}"
+
+# Git info (only if cwd is valid)
+GIT_BRANCH=""
+GIT_DIRTY=""
+if [ -d "$CWD" ]; then
+ GIT_BRANCH=$(git -C "$CWD" --no-optional-locks symbolic-ref --short HEAD 2>/dev/null)
+ if [ -n "$GIT_BRANCH" ]; then
+ GIT_STATUS=$(git -C "$CWD" --no-optional-locks status --porcelain 2>/dev/null)
+ if [ -n "$GIT_STATUS" ]; then
+ GIT_DIRTY=" ✚"
+ fi
+ fi
+fi
+
+# --- Line 1: cwd | git branch | View in UI ---
+LINE1=""
+
+# Directory segment
+LINE1="${LINE1}$(printf "${BLUE_BG}${BLACK_FG}${BOLD} %s ${RESET}" "$SHORT_CWD")"
+
+# Git segment
+if [ -n "$GIT_BRANCH" ]; then
+ if [ -n "$GIT_DIRTY" ]; then
+ LINE1="${LINE1}$(printf "${YELLOW_BG}${BLACK_FG}${BOLD} ± %s%s ${RESET}" "$GIT_BRANCH" "$GIT_DIRTY")"
+ else
+ LINE1="${LINE1}$(printf "${GREEN_BG}${BLACK_FG}${BOLD} ± %s ${RESET}" "$GIT_BRANCH")"
+ fi
+fi
+
+# View in UI link (OSC 8 hyperlink if OPERATOR_UI_URL is set)
+if [ -n "$OPERATOR_UI_URL" ]; then
+ LINE1="${LINE1}$(printf " \033]8;;%s\033\\${BOLD}View in UI${RESET}\033]8;;\033\\" "$OPERATOR_UI_URL")"
+fi
+
+# --- Line 2: [OPR8R] ticket | project | model | ctx:% ---
+LINE2=""
+
+# Operator badge
+LINE2="${LINE2}$(printf "${MAGENTA_BG}${WHITE_FG}${BOLD} OPR8R ${RESET}")"
+
+# Ticket ID
+if [ -n "$OPERATOR_TICKET_ID" ]; then
+ LINE2="${LINE2}$(printf " %s" "$OPERATOR_TICKET_ID")"
+fi
+
+# Project
+if [ -n "$OPERATOR_PROJECT" ]; then
+ LINE2="${LINE2}$(printf " | %s" "$OPERATOR_PROJECT")"
+fi
+
+# Model
+if [ -n "$MODEL" ]; then
+ LINE2="${LINE2}$(printf " ${CYAN_BG}${BLACK_FG} %s ${RESET}" "$MODEL")"
+fi
+
+# Context usage
+if [ -n "$CTX_USED" ]; then
+ CTX_INT=$(printf "%.0f" "$CTX_USED" 2>/dev/null || echo "$CTX_USED")
+ LINE2="${LINE2}$(printf " ${WHITE_FG}ctx:%s%%${RESET}" "$CTX_INT")"
+fi
+
+printf "%b\n%b\n" "$LINE1" "$LINE2"
diff --git a/shared/types.ts b/shared/types.ts
index 7bf36da..3a2924e 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -34,7 +34,7 @@ default_branch: string | null,
*/
ai_context_path: string | null,
/**
- * Backstage taxonomy kind (tier 1-5)
+ * Project taxonomy kind (tier 1-5)
*/
kind: string | null,
/**
@@ -272,7 +272,7 @@ projects: Array, agents: AgentsConfig, notifications: NotificationsConfi
/**
* Session wrapper configuration (tmux, vscode, or cmux)
*/
-sessions: SessionsConfig, llm_tools: LlmToolsConfig, backstage: BackstageConfig, rest_api: RestApiConfig, git: GitConfig,
+sessions: SessionsConfig, llm_tools: LlmToolsConfig, rest_api: RestApiConfig, git: GitConfig,
/**
* Kanban provider configuration for syncing issues from Jira, Linear, etc.
*/
@@ -289,9 +289,26 @@ delegators: Array,
* User-declared model servers (ollama, lmstudio, any OpenAI-compat host).
* Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration.
*/
-model_servers: Array, };
+model_servers: Array,
+/**
+ * Relay MCP injection configuration
+ */
+relay: RelayConfig,
+/**
+ * Model Context Protocol (MCP) server configuration
+ */
+mcp: McpConfig,
+/**
+ * Agent Client Protocol (ACP) agent configuration
+ */
+acp: AcpConfig, };
-export type AgentsConfig = { max_parallel: number, cores_reserved: number, health_check_interval: bigint,
+export type AgentsConfig = { max_parallel: number, cores_reserved: number,
+/**
+ * Maximum concurrent agents per project/repo (default: 1).
+ * Requires `git.use_worktrees` = true when > 1 to avoid conflicts.
+ */
+max_agents_per_repo: number, health_check_interval: bigint,
/**
* Timeout in seconds for each agent generation (default: 300 = 5 min)
*/
@@ -383,85 +400,6 @@ export type TmuxConfig = {
*/
config_generated: boolean, };
-export type BackstageConfig = {
-/**
- * Whether Backstage integration is enabled
- */
-enabled: boolean,
-/**
- * Whether to show Backstage in the Connections status section
- */
-display: boolean,
-/**
- * Port for the Backstage server
- */
-port: number,
-/**
- * Auto-start Backstage server when TUI launches
- */
-auto_start: boolean,
-/**
- * Subdirectory within `state_path` for Backstage installation
- */
-subpath: string,
-/**
- * Subdirectory within backstage path for branding customization
- */
-branding_subpath: string,
-/**
- * Base URL for downloading backstage-server binary
- */
-release_url: string,
-/**
- * Optional local path to backstage-server binary
- * If set, this is used instead of downloading from `release_url`
- */
-local_binary_path: string | null,
-/**
- * Branding and theming configuration
- */
-branding: BrandingConfig, };
-
-export type BrandingConfig = {
-/**
- * App title shown in header
- */
-app_title: string,
-/**
- * Organization name
- */
-org_name: string,
-/**
- * Path to logo SVG (relative to branding path)
- */
-logo_path: string | null,
-/**
- * Theme colors (uses Operator defaults if not set)
- */
-colors: ThemeColors, };
-
-export type ThemeColors = {
-/**
- * Primary/accent color (default: salmon #cc6c55)
- */
-primary: string,
-/**
- * Secondary color (default: dark teal #114145)
- */
-secondary: string,
-/**
- * Accent/highlight color (default: cream #f4dbb7)
- */
-accent: string,
-/**
- * Warning/error color (default: coral #d46048)
- */
-warning: string,
-/**
- * Muted text color (default: darker salmon #8a4a3a)
- */
-muted: string, };
-
export type RestApiConfig = {
/**
* Whether the REST API is enabled
@@ -663,7 +601,11 @@ prompt_prefix: string | null,
/**
* Prompt text to append after the generated step prompt
*/
-prompt_suffix: string | null, };
+prompt_suffix: string | null,
+/**
+ * Override global relay auto-inject MCP setting per-delegator (None = use global setting)
+ */
+operator_relay: boolean | null, };
export type CollectionPreset = "simple" | "dev_kanban" | "devops_kanban" | "custom";
@@ -843,6 +785,65 @@ export type HealthResponse = { status: string, version: string, };
export type StatusResponse = { status: string, version: string, issuetype_count: number, collection_count: number, active_collection: string, };
+export type SectionDto = {
+/**
+ * Stable section id (e.g. "config", "connections", "kanban").
+ */
+id: string, label: string,
+/**
+ * Health: "green" | "yellow" | "red" | "gray".
+ */
+health: string, description: string,
+/**
+ * Section ids that must be Green before this section is usable.
+ */
+prerequisites: Array,
+/**
+ * Whether all prerequisites are met. Sections are always returned (the web
+ * UI styles unmet ones as locked) rather than hidden by progressive disclosure.
+ */
+met: boolean, children: Array, };
+
+export type SectionRowDto = {
+/**
+ * Stable, section-scoped row id. Clients use it as a tree key and to route
+ * row-specific commands without matching on the (mutable) display label.
+ * Dynamic rows carry their entity key (issue-type key, project name);
+ * static rows carry a fixed slug (e.g. "git-token").
+ */
+id: string,
+/**
+ * Nesting depth within the section (1 = direct child, 2 = grandchild).
+ * Lets clients rebuild the tree (e.g. LLM tools → model aliases).
+ */
+depth: number, label: string, description: string,
+/**
+ * Icon hint (e.g. "check", "warning", "tool", "folder").
+ */
+icon: string,
+/**
+ * Health: "green" | "yellow" | "red" | "gray".
+ */
+health: string, };
+
+export type WorkflowExportResponse = {
+/**
+ * The ticket the workflow was generated from.
+ */
+ticket_id: string,
+/**
+ * The issue type key that supplied the step structure.
+ */
+issuetype_key: string,
+/**
+ * Suggested filename for saving the workflow (`.workflow.js`).
+ */
+suggested_filename: string,
+/**
+ * The generated `.js` workflow source.
+ */
+contents: string, };
+
export type SkillEntry = {
/**
* Tool this skill belongs to (e.g., "claude", "codex")
@@ -973,7 +974,11 @@ prompt_prefix: string | null,
/**
* Prompt text to append after the generated step prompt
*/
-prompt_suffix: string | null, };
+prompt_suffix: string | null,
+/**
+ * Override global relay auto-inject MCP setting per-delegator (None = use global setting)
+ */
+operator_relay: boolean | null, };
export type LlmTask = {
/**
diff --git a/src/acp/agent.rs b/src/acp/agent.rs
new file mode 100644
index 0000000..061ea95
--- /dev/null
+++ b/src/acp/agent.rs
@@ -0,0 +1,300 @@
+//! Operator's ACP agent over stdio.
+//!
+//! Wires the [`agent_client_protocol::Agent`] role builder up to a [`Stdio`]
+//! transport. Editors that speak ACP launch `operator acp` as a subprocess
+//! and exchange line-delimited JSON-RPC with this loop.
+
+use std::process::Stdio as ProcStdio;
+use std::sync::Arc;
+
+use agent_client_protocol::schema::{
+ AgentCapabilities, CancelNotification, ContentBlock, Implementation, InitializeRequest,
+ InitializeResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse,
+ SessionId, SessionNotification, StopReason,
+};
+use agent_client_protocol::{Agent, Client, ConnectionTo, Dispatch, Stdio};
+use tokio::io::{AsyncBufReadExt, BufReader};
+use tokio::process::Command;
+use tokio::sync::oneshot;
+
+use crate::acp::session::SessionRegistry;
+use crate::acp::translator;
+use crate::config::{Config, Delegator};
+
+/// Build the `InitializeResponse` operator advertises.
+///
+/// Echoes the client's protocol version (the ACP convention — the agent
+/// accepts the protocol version requested unless it cannot satisfy it),
+/// advertises default agent capabilities, and attaches `agentInfo` so
+/// editors can identify operator in their UI.
+pub fn build_initialize_response(request: &InitializeRequest) -> InitializeResponse {
+ InitializeResponse::new(request.protocol_version)
+ .agent_capabilities(AgentCapabilities::default())
+ .agent_info(Implementation::new("operator", env!("CARGO_PKG_VERSION")).title("Operator"))
+}
+
+/// Run operator as an ACP agent over stdin/stdout until the client
+/// disconnects.
+///
+/// Returns the protocol's `Result` so the binary entrypoint can surface
+/// transport errors. Logs go to stderr via `tracing`; stdout is reserved
+/// for line-delimited JSON-RPC (see `src/logging.rs` — global subscriber
+/// writes to stderr).
+pub async fn run_stdio(config: Config) -> agent_client_protocol::Result<()> {
+ let registry = Arc::new(SessionRegistry::new());
+ let config = Arc::new(config);
+
+ let new_session_registry = Arc::clone(®istry);
+ let new_session_config = Arc::clone(&config);
+ let prompt_registry = Arc::clone(®istry);
+ let prompt_config = Arc::clone(&config);
+ let cancel_registry = Arc::clone(®istry);
+
+ Agent
+ .builder()
+ .name("operator")
+ .on_receive_request(
+ async move |request: InitializeRequest, responder, _connection| {
+ responder.respond(build_initialize_response(&request))
+ },
+ agent_client_protocol::on_receive_request!(),
+ )
+ .on_receive_request(
+ async move |request: NewSessionRequest, responder, _connection| {
+ match new_session_registry.create_or_attach(&new_session_config, &request.cwd) {
+ Ok(session_id) => {
+ tracing::info!(?session_id, cwd = %request.cwd.display(), "ACP session opened");
+ responder.respond(NewSessionResponse::new(session_id))
+ }
+ Err(err) => responder.respond_with_error(
+ agent_client_protocol::util::internal_error(format!(
+ "session/new failed: {err}"
+ )),
+ ),
+ }
+ },
+ agent_client_protocol::on_receive_request!(),
+ )
+ .on_receive_request(
+ async move |request: PromptRequest, responder, connection| {
+ let reg = Arc::clone(&prompt_registry);
+ let cfg = Arc::clone(&prompt_config);
+ let cx = connection.clone();
+ connection.spawn(async move {
+ let response = handle_prompt(®, &cfg, request, &cx).await;
+ match response {
+ Ok(resp) => responder.respond(resp)?,
+ Err(message) => responder.respond_with_error(
+ agent_client_protocol::util::internal_error(message),
+ )?,
+ }
+ Ok(())
+ })?;
+ Ok(())
+ },
+ agent_client_protocol::on_receive_request!(),
+ )
+ .on_receive_notification(
+ async move |notif: CancelNotification, _connection| {
+ tracing::info!(session_id = ?notif.session_id, "ACP cancel received");
+ if let Some(tx) = cancel_registry.take_cancel_sender(¬if.session_id) {
+ let _ = tx.send(());
+ tracing::info!(session_id = ?notif.session_id, "ACP cancel signal sent to delegator");
+ }
+ Ok(())
+ },
+ agent_client_protocol::on_receive_notification!(),
+ )
+ .on_receive_dispatch(
+ async move |message: Dispatch, cx: ConnectionTo| {
+ let method = message.method().to_string();
+ message.respond_with_error(
+ agent_client_protocol::util::internal_error(format!(
+ "ACP method not implemented: {method}"
+ )),
+ cx,
+ )
+ },
+ agent_client_protocol::on_receive_dispatch!(),
+ )
+ .connect_to(Stdio::new())
+ .await
+}
+
+/// Concatenate the text content blocks of a `PromptRequest`.
+///
+/// v1 supports text only; other `ContentBlock` variants (image, audio,
+/// resource) are skipped. The result is one newline-joined string suitable
+/// for piping to a CLI delegator's prompt file.
+fn flatten_prompt(blocks: &[ContentBlock]) -> String {
+ blocks
+ .iter()
+ .filter_map(|b| match b {
+ ContentBlock::Text(t) => Some(t.text.as_str()),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n")
+}
+
+/// Pick the delegator to use for an ACP prompt: prefer `[acp].default_delegator`
+/// by name, then fall back to `agents::delegator_resolution::resolve_default_delegator`.
+fn resolve_acp_delegator(config: &Config) -> Option<&Delegator> {
+ if let Some(name) = config.acp.default_delegator.as_deref() {
+ if let Some(d) = config.delegators.iter().find(|d| d.name == name) {
+ return Some(d);
+ }
+ tracing::warn!(
+ requested = name,
+ "acp.default_delegator name not found; falling back to default resolver"
+ );
+ }
+ crate::agents::delegator_resolution::resolve_default_delegator(config)
+}
+
+/// Run the prompt → delegator → stream-back-to-editor pipeline.
+///
+/// Returns the prompt response on success, or an `Err(String)` message that
+/// the caller will wrap in `internal_error`.
+async fn handle_prompt(
+ registry: &SessionRegistry,
+ config: &Config,
+ request: PromptRequest,
+ connection: &ConnectionTo,
+) -> Result {
+ let session_id = request.session_id.clone();
+ let session = registry
+ .get(&session_id)
+ .ok_or_else(|| format!("unknown ACP session: {}", session_id.0))?;
+
+ let prompt_text = flatten_prompt(&request.prompt);
+ let delegator = resolve_acp_delegator(config)
+ .cloned()
+ .ok_or_else(|| "no delegator configured for ACP prompts".to_string())?;
+
+ let session_id_str = session_id.0.to_string();
+ let prompt_file =
+ crate::agents::launcher::prompt::write_prompt_file(config, &session_id_str, &prompt_text)
+ .map_err(|e| format!("write_prompt_file: {e}"))?;
+
+ let mut command_string =
+ crate::agents::launcher::llm_command::build_llm_command_with_permissions_for_tool(
+ config,
+ &delegator.llm_tool,
+ &delegator.model,
+ &session_id_str,
+ &prompt_file,
+ None,
+ None,
+ Some(false),
+ )
+ .map_err(|e| format!("build_llm_command: {e}"))?;
+
+ if delegator.llm_tool == "claude" {
+ command_string.push_str(" --output-format stream-json");
+ }
+
+ tracing::info!(
+ ?session_id,
+ delegator = %delegator.name,
+ cwd = %session.working_directory.display(),
+ "ACP prompt: spawning delegator"
+ );
+
+ let (cancel_tx, cancel_rx) = oneshot::channel();
+ registry.register_cancel_sender(&session_id, cancel_tx);
+
+ let stop_reason = stream_delegator(
+ &command_string,
+ &session.working_directory,
+ &session_id,
+ connection,
+ cancel_rx,
+ )
+ .await
+ .map_err(|e| format!("delegator subprocess: {e}"))?;
+
+ registry.take_cancel_sender(&session_id);
+
+ Ok(PromptResponse::new(stop_reason))
+}
+
+/// Spawn the delegator via `bash -lc ` in `cwd`, stream stdout
+/// line-by-line as ACP `AgentMessageChunk` notifications, and return the
+/// final `StopReason` based on exit status. If `cancel_rx` fires, the
+/// child process is killed and `StopReason::Cancelled` is returned.
+async fn stream_delegator(
+ command_string: &str,
+ cwd: &std::path::Path,
+ session_id: &SessionId,
+ connection: &ConnectionTo,
+ mut cancel_rx: oneshot::Receiver<()>,
+) -> std::io::Result {
+ let mut child = Command::new("bash")
+ .arg("-lc")
+ .arg(command_string)
+ .current_dir(cwd)
+ .stdin(ProcStdio::null())
+ .stdout(ProcStdio::piped())
+ .stderr(ProcStdio::piped())
+ .spawn()?;
+
+ let stdout = child
+ .stdout
+ .take()
+ .ok_or_else(|| std::io::Error::other("failed to capture delegator stdout"))?;
+
+ let mut lines = BufReader::new(stdout).lines();
+ loop {
+ tokio::select! {
+ line_result = lines.next_line() => {
+ match line_result? {
+ Some(line) => {
+ if let Some(update) = translator::line_to_update(&line) {
+ let notif = SessionNotification::new(session_id.clone(), update);
+ if let Err(e) = connection.send_notification(notif) {
+ tracing::warn!(error = %e, "ACP send_notification failed");
+ break;
+ }
+ }
+ }
+ None => break,
+ }
+ }
+ _ = &mut cancel_rx => {
+ tracing::info!(?session_id, "ACP cancel: killing delegator subprocess");
+ child.kill().await.ok();
+ return Ok(StopReason::Cancelled);
+ }
+ }
+ }
+
+ let status = child.wait().await?;
+ Ok(if status.success() {
+ StopReason::EndTurn
+ } else {
+ StopReason::Refusal
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use agent_client_protocol::schema::ProtocolVersion;
+
+ #[test]
+ fn test_initialize_response_echoes_protocol_version() {
+ let request = InitializeRequest::new(ProtocolVersion::V1);
+ let response = build_initialize_response(&request);
+ assert_eq!(response.protocol_version, ProtocolVersion::V1);
+ }
+
+ #[test]
+ fn test_initialize_response_advertises_agent_info() {
+ let request = InitializeRequest::new(ProtocolVersion::V1);
+ let response = build_initialize_response(&request);
+ let info = response.agent_info.expect("agent_info must be populated");
+ assert_eq!(info.name, "operator");
+ assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
+ }
+}
diff --git a/src/acp/client_configs.rs b/src/acp/client_configs.rs
new file mode 100644
index 0000000..53a8216
--- /dev/null
+++ b/src/acp/client_configs.rs
@@ -0,0 +1,123 @@
+//! Generates copy-paste ACP agent registrations pointing at this operator
+//! binary.
+//!
+//! Each `*_snippet()` returns either a `serde_json::Value` (Zed, `JetBrains`)
+//! or a plain `String` (Emacs elisp, Kiro TOML), shaped the way the target
+//! editor expects it. The dashboard writes one of these to
+//! `/operator/acp/.{json,el,toml}` and opens it in the
+//! user's editor; the user pastes the contents into their actual editor
+//! configuration.
+
+use serde_json::{json, Value};
+use std::path::PathBuf;
+
+/// Path to the currently-running operator binary. Falls back to bare
+/// `"operator"` if `current_exe` is unavailable (e.g. in some test contexts).
+pub fn current_exe() -> PathBuf {
+ std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator"))
+}
+
+fn exe_string() -> String {
+ current_exe().to_string_lossy().into_owned()
+}
+
+/// Zed `~/.config/zed/settings.json` — `agent_servers` block.
+pub fn zed_snippet() -> Value {
+ json!({
+ "agent_servers": {
+ "operator": {
+ "command": exe_string(),
+ "args": ["acp"],
+ "env": {}
+ }
+ }
+ })
+}
+
+/// `JetBrains` ACP Agent Registry JSON. Imported via the IDE's ACP plugin.
+pub fn jetbrains_snippet() -> Value {
+ json!({
+ "name": "operator",
+ "displayName": "Operator (Kanban Orchestrator)",
+ "command": exe_string(),
+ "args": ["acp"]
+ })
+}
+
+/// Emacs `agent-shell` — elisp form to add to your init file.
+pub fn emacs_snippet() -> String {
+ format!(
+ "(add-to-list 'agent-shell-acp-agents\n '(:name \"operator\" :command \"{}\" :args (\"acp\")))",
+ exe_string()
+ )
+}
+
+/// Kiro `~/.kiro/agents.toml` entry.
+pub fn kiro_snippet() -> String {
+ format!(
+ "[[agents]]\nname = \"operator\"\ncommand = \"{}\"\nargs = [\"acp\"]\n",
+ exe_string()
+ )
+}
+
+/// Dispatch by editor name. Returns `None` for unknown editors. JSON-shaped
+/// editors (Zed, `JetBrains`) return their snippet directly; text-format
+/// editors (Emacs, Kiro) are wrapped as `Value::String` so callers can treat
+/// the result uniformly.
+pub fn snippet_for(editor: &str) -> Option {
+ match editor {
+ "zed" => Some(zed_snippet()),
+ "jetbrains" => Some(jetbrains_snippet()),
+ "emacs" => Some(Value::String(emacs_snippet())),
+ "kiro" => Some(Value::String(kiro_snippet())),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_zed_snippet_shape() {
+ let cfg = zed_snippet();
+ assert_eq!(cfg["agent_servers"]["operator"]["args"][0], "acp");
+ assert!(cfg["agent_servers"]["operator"]["command"].is_string());
+ }
+
+ #[test]
+ fn test_jetbrains_snippet_has_name_and_command() {
+ let cfg = jetbrains_snippet();
+ assert_eq!(cfg["name"], "operator");
+ assert_eq!(cfg["args"][0], "acp");
+ }
+
+ #[test]
+ fn test_emacs_snippet_is_valid_elisp_form() {
+ let snippet = emacs_snippet();
+ assert!(snippet.starts_with("(add-to-list 'agent-shell-acp-agents"));
+ assert!(snippet.contains("operator"));
+ assert!(snippet.contains(":args (\"acp\")"));
+ }
+
+ #[test]
+ fn test_kiro_snippet_is_toml_array_entry() {
+ let snippet = kiro_snippet();
+ assert!(snippet.starts_with("[[agents]]"));
+ assert!(snippet.contains("name = \"operator\""));
+ assert!(snippet.contains("args = [\"acp\"]"));
+ }
+
+ #[test]
+ fn test_snippet_for_unknown_editor_is_none() {
+ assert!(snippet_for("notepad++").is_none());
+ }
+
+ #[test]
+ fn test_snippet_for_dispatches_all_editors() {
+ assert!(snippet_for("zed").is_some());
+ assert!(snippet_for("jetbrains").is_some());
+ assert!(snippet_for("emacs").is_some());
+ assert!(snippet_for("kiro").is_some());
+ }
+}
diff --git a/src/acp/mod.rs b/src/acp/mod.rs
new file mode 100644
index 0000000..cfffdc9
--- /dev/null
+++ b/src/acp/mod.rs
@@ -0,0 +1,23 @@
+//! Agent Client Protocol (ACP) integration for Operator.
+//!
+//! Operator runs as an ACP agent that editors (Zed, `JetBrains`, Emacs,
+//! Kiro, `OpenCode`, marimo, Eclipse) launch as a stdio subprocess. Each
+//! ACP session maps to one operator ticket (created or attached when
+//! the editor's cwd matches an in-progress ticket).
+//!
+//! Phase A (this file's current scope): only `initialize` is handled.
+//! Sessions, prompts, and editor config snippets land in Phase B.
+//!
+//! See:
+
+pub mod agent;
+pub mod client_configs;
+pub mod server;
+pub mod session;
+pub mod translator;
+
+pub use agent::run_stdio;
+pub use server::{AcpAgentServer, AcpAgentStatus};
+// SessionRegistry and AcpSession are intentionally not re-exported at the
+// `acp::*` root: they're internal to the agent runtime. Callers that need
+// them can use `acp::session::*`.
diff --git a/src/acp/server.rs b/src/acp/server.rs
new file mode 100644
index 0000000..f5f991f
--- /dev/null
+++ b/src/acp/server.rs
@@ -0,0 +1,93 @@
+//! ACP agent status/count handle for the dashboard.
+//!
+//! Unlike [`crate::rest::server::RestApiServer`], this is **not** a listener
+//! lifecycle — editor-spawned `operator acp` runs in a separate stdio
+//! subprocess that the TUI never hosts. [`AcpAgentServer`] just records
+//! whether ACP is advertised in the dashboard and how many sessions are
+//! currently active (always `0` in v1, since out-of-process ACP runs don't
+//! report back to the TUI).
+//!
+//! When a shared file/socket bridge is added later, `active_sessions` can be
+//! populated from there without changing this handle's shape.
+
+use std::sync::{Arc, Mutex};
+
+use crate::config::Config;
+
+/// Coarse status reported to the dashboard.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AcpAgentStatus {
+ /// `[acp].stdio_advertised = false` — operator is intentionally not
+ /// advertising itself as an ACP agent.
+ Disabled,
+ /// Advertised. No active sessions are visible to the TUI (the editor
+ /// runs `operator acp` out-of-process in v1, so the count is always 0
+ /// here). The dashboard can still surface "ready" and offer config
+ /// snippets.
+ Advertised { active_sessions: usize },
+}
+
+impl AcpAgentStatus {
+ pub fn is_advertised(&self) -> bool {
+ matches!(self, AcpAgentStatus::Advertised { .. })
+ }
+
+ pub fn active_sessions(&self) -> usize {
+ match self {
+ AcpAgentStatus::Advertised { active_sessions } => *active_sessions,
+ AcpAgentStatus::Disabled => 0,
+ }
+ }
+}
+
+use serde::{Deserialize, Serialize};
+
+/// Status handle wired into `App` and read by the dashboard. Shape mirrors
+/// the lock-protected pattern of [`crate::rest::server::RestApiServer`] so
+/// future TUI-launched listeners can slot in without changing call sites.
+#[derive(Debug, Clone)]
+pub struct AcpAgentServer {
+ status: Arc>,
+}
+
+impl AcpAgentServer {
+ /// Construct from a config snapshot. Honors `config.acp.stdio_advertised`.
+ pub fn from_config(config: &Config) -> Self {
+ let status = if config.acp.stdio_advertised {
+ AcpAgentStatus::Advertised { active_sessions: 0 }
+ } else {
+ AcpAgentStatus::Disabled
+ };
+ Self {
+ status: Arc::new(Mutex::new(status)),
+ }
+ }
+
+ /// Current status (cloned out of the mutex).
+ pub fn status(&self) -> AcpAgentStatus {
+ self.status.lock().unwrap().clone()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_from_config_advertised_default() {
+ let config = Config::default();
+ let server = AcpAgentServer::from_config(&config);
+ assert!(server.status().is_advertised());
+ assert_eq!(server.status().active_sessions(), 0);
+ }
+
+ #[test]
+ fn test_from_config_disabled_when_flag_off() {
+ let mut config = Config::default();
+ config.acp.stdio_advertised = false;
+ let server = AcpAgentServer::from_config(&config);
+ assert!(!server.status().is_advertised());
+ assert_eq!(server.status(), AcpAgentStatus::Disabled);
+ }
+}
diff --git a/src/acp/session.rs b/src/acp/session.rs
new file mode 100644
index 0000000..a567871
--- /dev/null
+++ b/src/acp/session.rs
@@ -0,0 +1,347 @@
+//! ACP session registry — maps `SessionId` to operator tickets.
+//!
+//! When an editor calls `session/new`, [`SessionRegistry::create_or_attach`]
+//! either attaches to an existing in-progress ACP ticket (if exactly one
+//! matches the editor's cwd) or writes a fresh `ACP-{short}.md` into
+//! `.tickets/in-progress/` and registers the session against it.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+
+use agent_client_protocol::schema::SessionId;
+use anyhow::{anyhow, Context, Result};
+use tokio::sync::oneshot;
+
+use crate::config::Config;
+
+/// One live ACP session: an editor-spawned conversation backed by an
+/// `ACP-*.md` ticket in the in-progress directory.
+#[derive(Debug, Clone)]
+pub struct AcpSession {
+ /// Reserved for future ticket-completion bookkeeping (mark the ACP
+ /// ticket done when the editor disconnects).
+ #[allow(dead_code)]
+ pub session_id: SessionId,
+ /// Reserved for future ticket-update logic.
+ #[allow(dead_code)]
+ pub ticket_path: PathBuf,
+ pub working_directory: PathBuf,
+}
+
+/// Thread-safe registry of live ACP sessions. Handler closures share this
+/// across `Agent.builder()` registrations via `Arc`.
+#[derive(Debug, Default, Clone)]
+pub struct SessionRegistry {
+ sessions: Arc>>,
+ cancel_senders: Arc>>>,
+}
+
+impl SessionRegistry {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Create or attach an ACP session for `cwd`.
+ ///
+ /// Behavior:
+ /// 1. Reject if the registry already holds `config.acp.max_concurrent_sessions`.
+ /// 2. Canonicalize `cwd` (falling back to the literal path on error).
+ /// 3. If exactly one `ACP-*.md` ticket in `in-progress/` has matching
+ /// frontmatter `cwd`, attach to it (do not write a new ticket).
+ /// 4. Otherwise write a fresh `ACP-{session-short}.md` to `in-progress/`.
+ pub fn create_or_attach(&self, config: &Config, cwd: &Path) -> Result {
+ {
+ let active = self.sessions.lock().unwrap().len();
+ if active >= config.acp.max_concurrent_sessions {
+ return Err(anyhow!(
+ "ACP session limit reached: {active}/{}",
+ config.acp.max_concurrent_sessions
+ ));
+ }
+ }
+
+ let canonical_cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
+ let in_progress = config.tickets_path().join("in-progress");
+ let session_id = SessionId::from(uuid::Uuid::new_v4().to_string());
+
+ let ticket_path = match find_matching_acp_ticket(&in_progress, &canonical_cwd) {
+ Some(path) => path,
+ None => write_new_acp_ticket(&in_progress, &session_id, &canonical_cwd)?,
+ };
+
+ let session = AcpSession {
+ session_id: session_id.clone(),
+ ticket_path,
+ working_directory: canonical_cwd,
+ };
+ self.sessions
+ .lock()
+ .unwrap()
+ .insert(session_id.clone(), session);
+ Ok(session_id)
+ }
+
+ /// Number of live ACP sessions. Used by `AcpAgentServer::active_sessions`
+ /// once the registry is shared with the dashboard.
+ #[allow(dead_code)]
+ pub fn len(&self) -> usize {
+ self.sessions.lock().unwrap().len()
+ }
+
+ /// Reserved for the same future as `len`.
+ #[allow(dead_code)]
+ pub fn is_empty(&self) -> bool {
+ self.sessions.lock().unwrap().is_empty()
+ }
+
+ /// Return a clone of the session matching `id`, if any.
+ pub fn get(&self, id: &SessionId) -> Option {
+ self.sessions.lock().unwrap().get(id).cloned()
+ }
+
+ /// Store a cancel sender for an in-flight prompt. The cancel notification
+ /// handler calls [`take_cancel_sender`] to fire it.
+ pub fn register_cancel_sender(&self, id: &SessionId, tx: oneshot::Sender<()>) {
+ self.cancel_senders.lock().unwrap().insert(id.clone(), tx);
+ }
+
+ /// Remove and return the cancel sender for `id`, if one is registered.
+ /// Returns `None` if the prompt already completed (sender was cleaned up)
+ /// or if no prompt is in flight for this session.
+ pub fn take_cancel_sender(&self, id: &SessionId) -> Option> {
+ self.cancel_senders.lock().unwrap().remove(id)
+ }
+}
+
+/// Scan `in_progress` for `ACP-*.md` files whose frontmatter `cwd` matches
+/// `target`. Returns `Some(path)` iff exactly one matches; otherwise `None`.
+fn find_matching_acp_ticket(in_progress: &Path, target: &Path) -> Option {
+ let entries = std::fs::read_dir(in_progress).ok()?;
+ let matches: Vec = entries
+ .filter_map(std::result::Result::ok)
+ .map(|e| e.path())
+ .filter(|p| {
+ p.extension().and_then(|e| e.to_str()) == Some("md")
+ && p.file_name()
+ .and_then(|n| n.to_str())
+ .is_some_and(|n| n.starts_with("ACP-"))
+ })
+ .filter(|p| ticket_cwd_matches(p, target))
+ .collect();
+ if matches.len() == 1 {
+ Some(matches.into_iter().next().unwrap())
+ } else {
+ None
+ }
+}
+
+/// True iff the file at `path` has YAML frontmatter with a `cwd` field that
+/// equals `target` after path comparison.
+fn ticket_cwd_matches(path: &Path, target: &Path) -> bool {
+ let Ok(content) = std::fs::read_to_string(path) else {
+ return false;
+ };
+ let trimmed = content.trim_start();
+ if !trimmed.starts_with("---") {
+ return false;
+ }
+ let after_open = &trimmed[3..];
+ let Some(end_idx) = after_open.find("\n---") else {
+ return false;
+ };
+ let yaml_str = after_open[..end_idx].trim();
+ let Ok(fm) = serde_yaml::from_str::>(yaml_str) else {
+ return false;
+ };
+ fm.get("cwd")
+ .and_then(serde_yaml::Value::as_str)
+ .is_some_and(|s| Path::new(s) == target)
+}
+
+fn write_new_acp_ticket(in_progress: &Path, session_id: &SessionId, cwd: &Path) -> Result {
+ std::fs::create_dir_all(in_progress)
+ .with_context(|| format!("create in-progress dir {}", in_progress.display()))?;
+ let short = session_short(session_id);
+ let filename = format!("ACP-{short}.md");
+ let path = in_progress.join(filename);
+ let now = chrono::Utc::now().format("%Y-%m-%d").to_string();
+ let cwd_str = cwd.display().to_string();
+ let project = cwd.file_name().and_then(|n| n.to_str()).unwrap_or("global");
+ let body = format!(
+ "---\nid: ACP-{short}\nstatus: in-progress\nkind: acp\ncreated: {now}\nproject: {project}\ncwd: {cwd_str}\n---\n\n# ACP session from {cwd_str}\n"
+ );
+ std::fs::write(&path, body).with_context(|| format!("write ACP ticket {}", path.display()))?;
+ Ok(path)
+}
+
+/// First 8 hex chars of the session UUID — short enough for a filename, long
+/// enough that random collisions inside one in-progress dir are negligible.
+fn session_short(session_id: &SessionId) -> String {
+ session_id
+ .0
+ .chars()
+ .filter(char::is_ascii_hexdigit)
+ .take(8)
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Once;
+
+ static INIT: Once = Once::new();
+
+ fn test_config(tickets_dir: &Path, max_sessions: usize) -> Config {
+ INIT.call_once(|| {});
+ let mut config = Config::default();
+ config.paths.tickets = tickets_dir.to_string_lossy().into_owned();
+ config.acp.max_concurrent_sessions = max_sessions;
+ config
+ }
+
+ fn write_acp_ticket(in_progress: &Path, name: &str, cwd: &Path) {
+ std::fs::create_dir_all(in_progress).unwrap();
+ let body = format!(
+ "---\nid: ACP-{name}\nstatus: in-progress\nkind: acp\ncreated: 2026-05-17\nproject: test\ncwd: {}\n---\n\n# pre-existing\n",
+ cwd.display()
+ );
+ std::fs::write(in_progress.join(format!("ACP-{name}.md")), body).unwrap();
+ }
+
+ #[test]
+ fn test_attaches_when_one_acp_ticket_matches_cwd() {
+ let tickets = tempfile::TempDir::new().unwrap();
+ let cwd = tempfile::TempDir::new().unwrap();
+ let canon_cwd = std::fs::canonicalize(cwd.path()).unwrap();
+ let in_progress = tickets.path().join("in-progress");
+ write_acp_ticket(&in_progress, "abcd1234", &canon_cwd);
+
+ let config = test_config(tickets.path(), 4);
+ let registry = SessionRegistry::new();
+ let session_id = registry.create_or_attach(&config, cwd.path()).unwrap();
+
+ let session = registry
+ .sessions
+ .lock()
+ .unwrap()
+ .get(&session_id)
+ .cloned()
+ .expect("session must be registered");
+ assert_eq!(session.ticket_path, in_progress.join("ACP-abcd1234.md"));
+ assert_eq!(session.working_directory, canon_cwd);
+ // No new file written: only the pre-seeded one exists
+ let count = std::fs::read_dir(&in_progress).unwrap().count();
+ assert_eq!(count, 1);
+ }
+
+ #[test]
+ fn test_creates_new_ticket_when_no_match() {
+ let tickets = tempfile::TempDir::new().unwrap();
+ let cwd = tempfile::TempDir::new().unwrap();
+ let in_progress = tickets.path().join("in-progress");
+
+ let config = test_config(tickets.path(), 4);
+ let registry = SessionRegistry::new();
+ let session_id = registry.create_or_attach(&config, cwd.path()).unwrap();
+
+ let path = registry
+ .sessions
+ .lock()
+ .unwrap()
+ .get(&session_id)
+ .unwrap()
+ .ticket_path
+ .clone();
+ assert!(path.exists());
+ assert!(path
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .starts_with("ACP-"));
+ assert_eq!(std::fs::read_dir(&in_progress).unwrap().count(), 1);
+ }
+
+ #[test]
+ fn test_creates_new_when_multiple_acp_tickets_match() {
+ let tickets = tempfile::TempDir::new().unwrap();
+ let cwd = tempfile::TempDir::new().unwrap();
+ let canon_cwd = std::fs::canonicalize(cwd.path()).unwrap();
+ let in_progress = tickets.path().join("in-progress");
+ write_acp_ticket(&in_progress, "aaaaaaaa", &canon_cwd);
+ write_acp_ticket(&in_progress, "bbbbbbbb", &canon_cwd);
+
+ let config = test_config(tickets.path(), 4);
+ let registry = SessionRegistry::new();
+ let session_id = registry.create_or_attach(&config, cwd.path()).unwrap();
+
+ let path = registry
+ .sessions
+ .lock()
+ .unwrap()
+ .get(&session_id)
+ .unwrap()
+ .ticket_path
+ .clone();
+ // Should be a brand-new file, not one of the two pre-seeded ones
+ assert!(path.exists());
+ assert_ne!(path, in_progress.join("ACP-aaaaaaaa.md"));
+ assert_ne!(path, in_progress.join("ACP-bbbbbbbb.md"));
+ assert_eq!(std::fs::read_dir(&in_progress).unwrap().count(), 3);
+ }
+
+ #[test]
+ fn test_rejects_when_max_concurrent_sessions_reached() {
+ let tickets = tempfile::TempDir::new().unwrap();
+ let cwd = tempfile::TempDir::new().unwrap();
+ let config = test_config(tickets.path(), 1);
+ let registry = SessionRegistry::new();
+ registry.create_or_attach(&config, cwd.path()).unwrap();
+
+ let err = registry
+ .create_or_attach(&config, cwd.path())
+ .expect_err("second session must be rejected");
+ assert!(err.to_string().contains("session limit"));
+ }
+
+ #[test]
+ fn test_session_short_is_8_hex_chars() {
+ let id = SessionId::from("deadbeef-cafe-1234-5678-90abcdef0000".to_string());
+ let short = session_short(&id);
+ assert_eq!(short.len(), 8);
+ assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
+ }
+
+ #[test]
+ fn test_register_and_take_cancel_sender() {
+ let registry = SessionRegistry::new();
+ let id = SessionId::from("test-session-1".to_string());
+ let (tx, _rx) = oneshot::channel();
+ registry.register_cancel_sender(&id, tx);
+ assert!(
+ registry.take_cancel_sender(&id).is_some(),
+ "first take should return the sender"
+ );
+ }
+
+ #[test]
+ fn test_double_take_cancel_sender_returns_none() {
+ let registry = SessionRegistry::new();
+ let id = SessionId::from("test-session-2".to_string());
+ let (tx, _rx) = oneshot::channel();
+ registry.register_cancel_sender(&id, tx);
+ registry.take_cancel_sender(&id);
+ assert!(
+ registry.take_cancel_sender(&id).is_none(),
+ "second take should return None"
+ );
+ }
+
+ #[test]
+ fn test_take_cancel_sender_unknown_session_returns_none() {
+ let registry = SessionRegistry::new();
+ let id = SessionId::from("nonexistent".to_string());
+ assert!(registry.take_cancel_sender(&id).is_none());
+ }
+}
diff --git a/src/acp/translator.rs b/src/acp/translator.rs
new file mode 100644
index 0000000..928f7c8
--- /dev/null
+++ b/src/acp/translator.rs
@@ -0,0 +1,183 @@
+//! Translate delegator subprocess output into ACP `SessionUpdate`
+//! notifications.
+//!
+//! Two parsing modes: structured JSON (Claude Code's `--output-format
+//! stream-json`) and plain text (fallback). `line_to_update` tries JSON
+//! first, falls back to plain text on parse failure.
+
+use agent_client_protocol::schema::{ContentBlock, ContentChunk, SessionUpdate, TextContent};
+
+/// Map a single line of delegator stdout to an optional ACP `SessionUpdate`.
+///
+/// Tries structured JSON parse first (for delegators like Claude Code with
+/// `--output-format stream-json`). Falls back to plain text wrapping.
+/// Empty / whitespace-only lines return `None`.
+pub fn line_to_update(line: &str) -> Option {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ if trimmed.starts_with('{') {
+ match try_stream_json(trimmed) {
+ StreamJsonResult::Update(update) => return Some(*update),
+ StreamJsonResult::Skip => return None,
+ StreamJsonResult::NotStreamJson => {}
+ }
+ }
+ plain_text_update(trimmed)
+}
+
+enum StreamJsonResult {
+ Update(Box),
+ Skip,
+ NotStreamJson,
+}
+
+/// Wrap a non-empty text line as an `AgentMessageChunk`.
+fn plain_text_update(trimmed: &str) -> Option {
+ let text = TextContent::new(format!("{trimmed}\n"));
+ let chunk = ContentChunk::new(ContentBlock::Text(text));
+ Some(SessionUpdate::AgentMessageChunk(chunk))
+}
+
+/// Try to parse a line as Claude Code `--output-format stream-json`.
+///
+/// Returns `Update` for displayable events, `Skip` for internal events
+/// (`tool_use`, `system`), and `NotStreamJson` if it doesn't look like
+/// stream-json (so the caller can fall back to plain text).
+fn try_stream_json(line: &str) -> StreamJsonResult {
+ let Ok(obj) = serde_json::from_str::(line) else {
+ return StreamJsonResult::NotStreamJson;
+ };
+ let Some(event_type) = obj.get("type").and_then(|v| v.as_str()) else {
+ return StreamJsonResult::NotStreamJson;
+ };
+
+ match event_type {
+ "assistant" => {
+ let Some(content) = obj
+ .get("message")
+ .and_then(|m| m.get("content"))
+ .and_then(|c| c.as_array())
+ else {
+ return StreamJsonResult::Skip;
+ };
+ let mut texts = Vec::new();
+ for block in content {
+ if block.get("type").and_then(|v| v.as_str()) == Some("text") {
+ if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
+ texts.push(t.to_string());
+ }
+ }
+ }
+ if texts.is_empty() {
+ return StreamJsonResult::Skip;
+ }
+ let joined = texts.join("\n");
+ let text = TextContent::new(format!("{joined}\n"));
+ let chunk = ContentChunk::new(ContentBlock::Text(text));
+ StreamJsonResult::Update(Box::new(SessionUpdate::AgentMessageChunk(chunk)))
+ }
+ "result" => {
+ if let Some(result_text) = obj.get("result").and_then(|v| v.as_str()) {
+ if !result_text.is_empty() {
+ let text = TextContent::new(format!("{result_text}\n"));
+ let chunk = ContentChunk::new(ContentBlock::Text(text));
+ return StreamJsonResult::Update(Box::new(SessionUpdate::AgentMessageChunk(
+ chunk,
+ )));
+ }
+ }
+ StreamJsonResult::Skip
+ }
+ "tool_use" | "tool_result" | "system" => StreamJsonResult::Skip,
+ _ => StreamJsonResult::NotStreamJson,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn extract_text(update: SessionUpdate) -> String {
+ let SessionUpdate::AgentMessageChunk(chunk) = update else {
+ panic!("expected AgentMessageChunk variant");
+ };
+ let ContentBlock::Text(text) = chunk.content else {
+ panic!("expected Text content block");
+ };
+ text.text
+ }
+
+ #[test]
+ fn test_empty_line_yields_none() {
+ assert!(line_to_update("").is_none());
+ assert!(line_to_update(" ").is_none());
+ assert!(line_to_update("\t \n").is_none());
+ }
+
+ #[test]
+ fn test_text_line_yields_agent_message_chunk() {
+ let text = extract_text(line_to_update("hello world").unwrap());
+ assert_eq!(text, "hello world\n");
+ }
+
+ #[test]
+ fn test_leading_and_trailing_whitespace_trimmed() {
+ let text = extract_text(line_to_update(" foo ").unwrap());
+ assert_eq!(text, "foo\n");
+ }
+
+ #[test]
+ fn test_stream_json_assistant_text() {
+ let line = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello from Claude"}]}}"#;
+ let text = extract_text(line_to_update(line).unwrap());
+ assert_eq!(text, "Hello from Claude\n");
+ }
+
+ #[test]
+ fn test_stream_json_assistant_multiple_text_blocks() {
+ let line = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}}"#;
+ let text = extract_text(line_to_update(line).unwrap());
+ assert_eq!(text, "First\nSecond\n");
+ }
+
+ #[test]
+ fn test_stream_json_result_with_text() {
+ let line = r#"{"type":"result","result":"Task complete","cost_usd":0.05}"#;
+ let text = extract_text(line_to_update(line).unwrap());
+ assert_eq!(text, "Task complete\n");
+ }
+
+ #[test]
+ fn test_stream_json_result_empty_yields_none() {
+ let line = r#"{"type":"result","result":"","cost_usd":0.01}"#;
+ assert!(line_to_update(line).is_none());
+ }
+
+ #[test]
+ fn test_stream_json_tool_use_skipped() {
+ let line = r#"{"type":"tool_use","name":"Read","input":{"path":"/tmp/foo"}}"#;
+ assert!(line_to_update(line).is_none());
+ }
+
+ #[test]
+ fn test_stream_json_system_event_skipped() {
+ let line = r#"{"type":"system","message":"thinking..."}"#;
+ assert!(line_to_update(line).is_none());
+ }
+
+ #[test]
+ fn test_malformed_json_falls_back_to_plain_text() {
+ let line = r#"{"broken json"#;
+ let text = extract_text(line_to_update(line).unwrap());
+ assert_eq!(text, r#"{"broken json"#.to_string() + "\n");
+ }
+
+ #[test]
+ fn test_json_without_type_field_falls_back_to_plain_text() {
+ let line = r#"{"key":"value"}"#;
+ let text = extract_text(line_to_update(line).unwrap());
+ assert_eq!(text, r#"{"key":"value"}"#.to_string() + "\n");
+ }
+}
diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs
index 190c7d5..9041541 100644
--- a/src/agents/delegator_resolution.rs
+++ b/src/agents/delegator_resolution.rs
@@ -86,7 +86,7 @@ pub(crate) fn apply_delegator_launch_config(
/// 1. Single configured delegator -> use it
/// 2. Delegator matching the user's preferred LLM tool -> use it
/// 3. None -> caller falls back to first detected tool + first model alias
-fn resolve_default_delegator(config: &Config) -> Option<&Delegator> {
+pub(crate) fn resolve_default_delegator(config: &Config) -> Option<&Delegator> {
match config.delegators.len() {
0 => None,
1 => Some(&config.delegators[0]),
diff --git a/src/agents/generator.rs b/src/agents/generator.rs
index 909d9d7..f199618 100644
--- a/src/agents/generator.rs
+++ b/src/agents/generator.rs
@@ -3,8 +3,8 @@
//! Agent and project ticket creators for operator-managed projects
//!
//! Creates TASK tickets for generating Claude Code agent files in a project's
-//! `.claude/agents/` directory, and ASSESS tickets for Backstage catalog
-//! assessment. These tickets can then be launched via the normal operator workflow.
+//! `.claude/agents/` directory, and ASSESS tickets for project analysis.
+//! These tickets can then be launched via the normal operator workflow.
use anyhow::{Context, Result};
use chrono::Local;
@@ -158,7 +158,7 @@ pub struct AssessTicketResult {
pub project: String,
}
-/// Creates ASSESS tickets for Backstage catalog assessment
+/// Creates ASSESS tickets for project assessment
pub struct AssessTicketCreator;
impl AssessTicketCreator {
@@ -182,9 +182,13 @@ impl AssessTicketCreator {
// Filename: YYYYMMDD-HHMM-ASSESS-project.md
let filename = format!("{timestamp}-ASSESS-{project_name}.md");
- // Check if catalog-info.yaml already exists
- let catalog_exists = project_path.join("catalog-info.yaml").exists();
- let action = if catalog_exists { "Update" } else { "Generate" };
+ // Check if project has been previously assessed
+ let previously_assessed = project_path.join("catalog-info.yaml").exists();
+ let action = if previously_assessed {
+ "Reassess"
+ } else {
+ "Assess"
+ };
// Build ticket content using the ASSESS template format
let content = format!(
@@ -196,7 +200,7 @@ status: queued
created: {datetime}
---
-# Assessment: {action} catalog-info.yaml for {project_name}
+# Assessment: {action} {project_name}
## Project
{project_name}
diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs
index 5da2545..bbc6dd1 100644
--- a/src/agents/launcher/cmux_session.rs
+++ b/src/agents/launcher/cmux_session.rs
@@ -20,7 +20,7 @@ use super::llm_command::{
use super::options::{LaunchOptions, RelaunchOptions};
use super::prompt::{
generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file,
- write_prompt_file,
+ write_prompt_file, OperatorEnvVars,
};
use super::SESSION_PREFIX;
@@ -78,6 +78,7 @@ pub fn launch_in_cmux_with_options(
project_path: &str,
initial_prompt: &str,
options: &LaunchOptions,
+ operator_env: &OperatorEnvVars,
) -> Result {
// Check cmux is available and we're inside cmux
cmux.check_available()
@@ -164,11 +165,17 @@ pub fn launch_in_cmux_with_options(
}
if options.docker_mode {
- llm_cmd = build_docker_command(config, &llm_cmd, project_path)?;
+ llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?;
}
// Write the command to a shell script file
- let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?;
+ let command_file = write_command_file(
+ config,
+ &session_uuid,
+ project_path,
+ &llm_cmd,
+ Some(operator_env),
+ )?;
// Inject relay env vars so agents can find the hub and register with their ticket ID
if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") {
@@ -217,6 +224,7 @@ pub fn launch_in_cmux_with_relaunch_options(
project_path: &str,
initial_prompt: &str,
options: &RelaunchOptions,
+ operator_env: &OperatorEnvVars,
) -> Result {
// Check cmux is available and we're inside cmux
cmux.check_available()
@@ -320,11 +328,17 @@ pub fn launch_in_cmux_with_relaunch_options(
}
if options.launch_options.docker_mode {
- llm_cmd = build_docker_command(config, &llm_cmd, project_path)?;
+ llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?;
}
// Write and send command
- let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?;
+ let command_file = write_command_file(
+ config,
+ &session_uuid,
+ project_path,
+ &llm_cmd,
+ Some(operator_env),
+ )?;
if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") {
let export_cmd = format!(
"export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n",
diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs
index 45a0eef..961e302 100644
--- a/src/agents/launcher/llm_command.rs
+++ b/src/agents/launcher/llm_command.rs
@@ -3,6 +3,9 @@
use std::fs;
use std::path::PathBuf;
+/// Embedded operator status line script, deployed per-session for Claude Code.
+const STATUSLINE_SCRIPT: &str = include_str!("../../../scripts/operator-statusline.sh");
+
/// TEMPORARILY DISABLED: JSON schema support causes command line length issues.
/// Even when writing schemas to files (rather than inline), the --json-schema flag
/// with large schema file paths can exceed OS command line limits.
@@ -91,6 +94,7 @@ pub fn build_docker_command(
config: &Config,
inner_cmd: &str,
project_path: &str,
+ operator_env: Option<&std::collections::HashMap>,
) -> Result {
let docker_config = &config.launch.docker;
@@ -123,6 +127,14 @@ pub fn build_docker_command(
docker_args.push(arg.clone());
}
+ // Add operator environment variables (if provided)
+ if let Some(env) = operator_env {
+ for (key, value) in env {
+ docker_args.push("-e".to_string());
+ docker_args.push(format!("{key}={value}"));
+ }
+ }
+
// Add the image
docker_args.push(docker_config.image.clone());
@@ -219,6 +231,12 @@ fn generate_config_flags(
cli_flags.push("--add-dir".to_string());
cli_flags.push(project_path.to_string());
+ // Inject operator status line settings
+ if let Some(settings_path) = statusline_settings_flag(&session_dir) {
+ cli_flags.push("--settings".to_string());
+ cli_flags.push(settings_path);
+ }
+
// Inject relay MCP server based on effective relay setting
let hub_available = std::env::var("RELAY_HUB_SOCKET").is_ok();
if resolve_relay_injection(operator_relay, hub_available, config.relay.auto_inject_mcp) {
@@ -228,6 +246,27 @@ fn generate_config_flags(
}
}
+ // Inject external MCP servers (skip reserved names and duplicates)
+ let mut seen_names = std::collections::HashSet::new();
+ for server in &config.mcp.external_servers {
+ if !server.enabled {
+ continue;
+ }
+ if server.name == "relay" {
+ tracing::warn!("external MCP server name \"relay\" is reserved; skipping");
+ continue;
+ }
+ if !seen_names.insert(&server.name) {
+ tracing::warn!(name = %server.name, "duplicate external MCP server name; skipping");
+ continue;
+ }
+ if let Some(config_path) = external_mcp_config_flag(&session_dir, server, project_path)
+ {
+ cli_flags.push("--mcp-config".to_string());
+ cli_flags.push(config_path);
+ }
+ }
+
// Add JSON schema flag for structured output (when enabled)
// Write schema to a file to avoid shell escaping issues with inline JSON
// Inline jsonSchema takes precedence over jsonSchemaFile
@@ -279,6 +318,139 @@ fn generate_config_flags(
}
}
+/// Write a per-session MCP server config JSON and return the file path.
+///
+/// Produces `{ "mcpServers": { "": } }` at
+/// `/-mcp.json`.
+fn write_mcp_server_config(
+ session_dir: &std::path::Path,
+ server_name: &str,
+ entry: serde_json::Value,
+) -> Option {
+ let filename = format!("{server_name}-mcp.json");
+ let config_path = session_dir.join(&filename);
+ let config = serde_json::json!({ "mcpServers": { (server_name): entry } });
+ let content = serde_json::to_string_pretty(&config).ok()?;
+ fs::write(&config_path, content).ok()?;
+ Some(config_path.display().to_string())
+}
+
+/// Expand `${VAR}` patterns using the process environment.
+/// Unknown variables expand to the empty string. Single-pass scan so
+/// self-referencing values (e.g. `VAR=${VAR}`) don't cause infinite loops.
+fn expand_env_vars(input: &str) -> String {
+ let mut result = String::with_capacity(input.len());
+ let mut chars = input.char_indices().peekable();
+ while let Some((i, ch)) = chars.next() {
+ if ch == '$' {
+ if let Some(&(_, '{')) = chars.peek() {
+ chars.next(); // consume '{'
+ if let Some(close) = input[i + 2..].find('}') {
+ let var_name = &input[i + 2..i + 2 + close];
+ let value = std::env::var(var_name).unwrap_or_default();
+ result.push_str(&value);
+ // skip chars until after the closing '}'
+ let end_pos = i + 2 + close;
+ while let Some(&(j, _)) = chars.peek() {
+ if j > end_pos {
+ break;
+ }
+ chars.next();
+ }
+ continue;
+ }
+ result.push('$');
+ result.push('{');
+ continue;
+ }
+ }
+ result.push(ch);
+ }
+ result
+}
+
+/// Resolve an external MCP server config and write it to the session dir.
+/// Returns `None` if the server cannot be resolved (missing sidecar, empty command).
+fn external_mcp_config_flag(
+ session_dir: &std::path::Path,
+ server: &crate::config::ExternalMcpServer,
+ project_path: &str,
+) -> Option {
+ if let Some(ref discover_path) = server.discover_from {
+ let resolved = if discover_path.starts_with('/') {
+ PathBuf::from(discover_path)
+ } else {
+ PathBuf::from(project_path).join(discover_path)
+ };
+ if resolved.exists() {
+ let contents = fs::read_to_string(&resolved).ok()?;
+ let sidecar: serde_json::Value = serde_json::from_str(&contents).ok()?;
+ if let Some(mcp_server) = sidecar.get("mcpServer") {
+ return write_mcp_server_config(session_dir, &server.name, mcp_server.clone());
+ }
+ }
+ if server.command.is_empty() {
+ return None;
+ }
+ }
+
+ let command = expand_env_vars(&server.command);
+ if command.is_empty() {
+ return None;
+ }
+
+ let args: Vec = server.args.iter().map(|a| expand_env_vars(a)).collect();
+ let env: std::collections::HashMap = server
+ .env
+ .iter()
+ .map(|(k, v)| (k.clone(), expand_env_vars(v)))
+ .collect();
+
+ let mut entry = serde_json::json!({
+ "command": command,
+ "type": "stdio",
+ });
+ if !args.is_empty() {
+ entry["args"] = serde_json::json!(args);
+ }
+ if !env.is_empty() {
+ entry["env"] = serde_json::json!(env);
+ }
+
+ write_mcp_server_config(session_dir, &server.name, entry)
+}
+
+/// Ensure the operator status line script is deployed in the session directory.
+/// Returns the absolute path to the script, or `None` on failure.
+fn ensure_statusline_script(session_dir: &std::path::Path) -> Option {
+ let script_path = session_dir.join("operator-statusline.sh");
+ if !script_path.exists() {
+ fs::write(&script_path, STATUSLINE_SCRIPT).ok()?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755));
+ }
+ }
+ Some(script_path)
+}
+
+/// Write a per-session `operator-settings.json` that configures Claude Code's
+/// status line to use the operator script, and return the file path.
+fn statusline_settings_flag(session_dir: &std::path::Path) -> Option {
+ let script_path = ensure_statusline_script(session_dir)?;
+ let settings = serde_json::json!({
+ "statusLine": {
+ "type": "command",
+ "command": script_path.display().to_string()
+ }
+ });
+ let settings_file = session_dir.join("operator-settings.json");
+ let content = serde_json::to_string_pretty(&settings).ok()?;
+ fs::write(&settings_file, &content).ok()?;
+ Some(settings_file.display().to_string())
+}
+
/// Write a per-session relay MCP config and return the path for `--mcp-config`.
/// Returns `None` if the relay command cannot be located.
fn relay_mcp_config_flag(session_dir: &std::path::Path) -> Option {
@@ -291,16 +463,12 @@ fn relay_mcp_config_flag_with_command(
binary: PathBuf,
args: Vec,
) -> Option {
- let config_path = session_dir.join("relay-mcp.json");
let relay_entry = if args.is_empty() {
serde_json::json!({ "command": binary.display().to_string(), "type": "stdio" })
} else {
serde_json::json!({ "command": binary.display().to_string(), "args": args, "type": "stdio" })
};
- let config = serde_json::json!({ "mcpServers": { "relay": relay_entry } });
- let content = serde_json::to_string_pretty(&config).ok()?;
- fs::write(&config_path, content).ok()?;
- Some(config_path.display().to_string())
+ write_mcp_server_config(session_dir, "relay", relay_entry)
}
/// Locate the relay command, returning `(binary_path, extra_args)`.
@@ -499,7 +667,8 @@ mod tests {
config.launch.docker.image = "my-claude:latest".to_string();
config.launch.docker.mount_path = "/workspace".to_string();
- let result = build_docker_command(&config, "claude --model sonnet", "/home/user/project");
+ let result =
+ build_docker_command(&config, "claude --model sonnet", "/home/user/project", None);
assert!(result.is_ok());
let cmd = result.unwrap();
@@ -512,7 +681,7 @@ mod tests {
config.launch.docker.image = "my-claude:latest".to_string();
config.launch.docker.mount_path = "/workspace".to_string();
- let result = build_docker_command(&config, "claude", "/home/user/project");
+ let result = build_docker_command(&config, "claude", "/home/user/project", None);
let cmd = result.unwrap();
assert!(
@@ -527,7 +696,7 @@ mod tests {
config.launch.docker.image = "my-claude:latest".to_string();
config.launch.docker.mount_path = "/workspace".to_string();
- let result = build_docker_command(&config, "claude", "/home/user/project");
+ let result = build_docker_command(&config, "claude", "/home/user/project", None);
let cmd = result.unwrap();
assert!(
@@ -544,7 +713,7 @@ mod tests {
config.launch.docker.env_vars =
vec!["ANTHROPIC_API_KEY".to_string(), "HOME=/root".to_string()];
- let result = build_docker_command(&config, "claude", "/project");
+ let result = build_docker_command(&config, "claude", "/project", None);
let cmd = result.unwrap();
assert!(
@@ -565,7 +734,7 @@ mod tests {
config.launch.docker.extra_args =
vec!["--network=host".to_string(), "--privileged".to_string()];
- let result = build_docker_command(&config, "claude", "/project");
+ let result = build_docker_command(&config, "claude", "/project", None);
let cmd = result.unwrap();
assert!(cmd.contains("--network=host"), "Should include extra arg 1");
@@ -576,7 +745,7 @@ mod tests {
fn test_build_docker_command_no_image_errors() {
let config = Config::default(); // image is empty by default
- let result = build_docker_command(&config, "claude", "/project");
+ let result = build_docker_command(&config, "claude", "/project", None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
@@ -592,7 +761,7 @@ mod tests {
config.launch.docker.image = "my-claude:latest".to_string();
config.launch.docker.mount_path = "/workspace".to_string();
- let result = build_docker_command(&config, "claude --model sonnet", "/project");
+ let result = build_docker_command(&config, "claude --model sonnet", "/project", None);
let cmd = result.unwrap();
assert!(
@@ -1262,4 +1431,322 @@ mod tests {
let result = resolve_relay_injection(None, true, true);
assert!(result);
}
+
+ // ========================================
+ // expand_env_vars() tests
+ // ========================================
+
+ #[test]
+ fn test_expand_env_vars_known_var() {
+ std::env::set_var("_TEST_EXPAND_VAR", "hello");
+ let result = expand_env_vars("prefix-${_TEST_EXPAND_VAR}-suffix");
+ assert_eq!(result, "prefix-hello-suffix");
+ std::env::remove_var("_TEST_EXPAND_VAR");
+ }
+
+ #[test]
+ fn test_expand_env_vars_unknown_var_expands_to_empty() {
+ std::env::remove_var("_TEST_NONEXISTENT_VAR");
+ let result = expand_env_vars("before-${_TEST_NONEXISTENT_VAR}-after");
+ assert_eq!(result, "before--after");
+ }
+
+ #[test]
+ fn test_expand_env_vars_no_vars_unchanged() {
+ let result = expand_env_vars("plain string without vars");
+ assert_eq!(result, "plain string without vars");
+ }
+
+ #[test]
+ fn test_expand_env_vars_multiple_vars() {
+ std::env::set_var("_TEST_A", "alpha");
+ std::env::set_var("_TEST_B", "beta");
+ let result = expand_env_vars("${_TEST_A}/${_TEST_B}");
+ assert_eq!(result, "alpha/beta");
+ std::env::remove_var("_TEST_A");
+ std::env::remove_var("_TEST_B");
+ }
+
+ #[test]
+ fn test_expand_env_vars_empty_string() {
+ let result = expand_env_vars("");
+ assert_eq!(result, "");
+ }
+
+ #[test]
+ fn test_expand_env_vars_self_referencing_does_not_loop() {
+ std::env::set_var("_TEST_SELF_REF", "${_TEST_SELF_REF}");
+ let result = expand_env_vars("${_TEST_SELF_REF}");
+ assert_eq!(
+ result, "${_TEST_SELF_REF}",
+ "Should expand once, not infinitely"
+ );
+ std::env::remove_var("_TEST_SELF_REF");
+ }
+
+ #[test]
+ fn test_expand_env_vars_unclosed_brace() {
+ let result = expand_env_vars("prefix-${UNCLOSED");
+ assert_eq!(result, "prefix-${UNCLOSED");
+ }
+
+ // ========================================
+ // write_mcp_server_config() tests
+ // ========================================
+
+ #[test]
+ fn test_write_mcp_server_config_creates_named_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let entry = serde_json::json!({
+ "command": "/usr/bin/test",
+ "type": "stdio"
+ });
+ let result = write_mcp_server_config(dir.path(), "myserver", entry);
+ assert!(result.is_some());
+ let path = result.unwrap();
+ assert!(path.contains("myserver-mcp.json"));
+ assert!(std::path::Path::new(&path).exists());
+ }
+
+ #[test]
+ fn test_write_mcp_server_config_json_structure() {
+ let dir = tempfile::tempdir().unwrap();
+ let entry = serde_json::json!({
+ "command": "/bin/foo",
+ "args": ["--bar"],
+ "env": { "KEY": "val" },
+ "type": "stdio"
+ });
+ let path = write_mcp_server_config(dir.path(), "testsvr", entry).unwrap();
+ let content = std::fs::read_to_string(&path).unwrap();
+ let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(
+ json["mcpServers"]["testsvr"]["command"].as_str(),
+ Some("/bin/foo")
+ );
+ assert_eq!(
+ json["mcpServers"]["testsvr"]["args"][0].as_str(),
+ Some("--bar")
+ );
+ assert_eq!(
+ json["mcpServers"]["testsvr"]["env"]["KEY"].as_str(),
+ Some("val")
+ );
+ }
+
+ // ========================================
+ // external_mcp_config_flag() tests
+ // ========================================
+
+ #[test]
+ fn test_external_mcp_config_flag_static_config() {
+ let dir = tempfile::tempdir().unwrap();
+ std::env::set_var("_TEST_EXT_KEY", "secret123");
+ let server = crate::config::ExternalMcpServer {
+ name: "mytools".to_string(),
+ command: "/usr/bin/my-mcp".to_string(),
+ args: vec!["--port".to_string(), "9090".to_string()],
+ env: std::collections::HashMap::from([(
+ "API_KEY".to_string(),
+ "${_TEST_EXT_KEY}".to_string(),
+ )]),
+ enabled: true,
+ discover_from: None,
+ };
+ let result = external_mcp_config_flag(dir.path(), &server, "/project");
+ assert!(result.is_some());
+ let path = result.unwrap();
+ let content = std::fs::read_to_string(&path).unwrap();
+ let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(
+ json["mcpServers"]["mytools"]["command"].as_str(),
+ Some("/usr/bin/my-mcp")
+ );
+ assert_eq!(
+ json["mcpServers"]["mytools"]["args"][0].as_str(),
+ Some("--port")
+ );
+ assert_eq!(
+ json["mcpServers"]["mytools"]["args"][1].as_str(),
+ Some("9090")
+ );
+ assert_eq!(
+ json["mcpServers"]["mytools"]["env"]["API_KEY"].as_str(),
+ Some("secret123")
+ );
+ std::env::remove_var("_TEST_EXT_KEY");
+ }
+
+ #[test]
+ fn test_external_mcp_config_flag_sidecar_discovery() {
+ let session_dir = tempfile::tempdir().unwrap();
+ let project_dir = tempfile::tempdir().unwrap();
+ let sidecar_path = project_dir.path().join(".kanbots");
+ std::fs::create_dir_all(&sidecar_path).unwrap();
+ let sidecar_file = sidecar_path.join("active-session.json");
+ let sidecar_content = serde_json::json!({
+ "mcpServer": {
+ "command": "/usr/bin/node",
+ "args": ["/path/to/server.js"],
+ "env": {
+ "BRIDGE_URL": "http://127.0.0.1:54321",
+ "BRIDGE_TOKEN": "abc123"
+ }
+ },
+ "pid": 12345
+ });
+ std::fs::write(
+ &sidecar_file,
+ serde_json::to_string_pretty(&sidecar_content).unwrap(),
+ )
+ .unwrap();
+
+ let server = crate::config::ExternalMcpServer {
+ name: "kanbots".to_string(),
+ command: String::new(),
+ args: vec![],
+ env: std::collections::HashMap::new(),
+ enabled: true,
+ discover_from: Some(".kanbots/active-session.json".to_string()),
+ };
+ let result = external_mcp_config_flag(
+ session_dir.path(),
+ &server,
+ &project_dir.path().to_string_lossy(),
+ );
+ assert!(result.is_some());
+ let path = result.unwrap();
+ let content = std::fs::read_to_string(&path).unwrap();
+ let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(
+ json["mcpServers"]["kanbots"]["command"].as_str(),
+ Some("/usr/bin/node")
+ );
+ assert_eq!(
+ json["mcpServers"]["kanbots"]["args"][0].as_str(),
+ Some("/path/to/server.js")
+ );
+ assert_eq!(
+ json["mcpServers"]["kanbots"]["env"]["BRIDGE_TOKEN"].as_str(),
+ Some("abc123")
+ );
+ }
+
+ #[test]
+ fn test_external_mcp_config_flag_missing_sidecar_empty_command_returns_none() {
+ let dir = tempfile::tempdir().unwrap();
+ let server = crate::config::ExternalMcpServer {
+ name: "kanbots".to_string(),
+ command: String::new(),
+ args: vec![],
+ env: std::collections::HashMap::new(),
+ enabled: true,
+ discover_from: Some(".kanbots/active-session.json".to_string()),
+ };
+ let result = external_mcp_config_flag(dir.path(), &server, "/nonexistent/project");
+ assert!(
+ result.is_none(),
+ "Should return None when sidecar missing and command empty"
+ );
+ }
+
+ #[test]
+ fn test_external_mcp_config_flag_missing_sidecar_static_fallback() {
+ let dir = tempfile::tempdir().unwrap();
+ let server = crate::config::ExternalMcpServer {
+ name: "kanbots".to_string(),
+ command: "kanbots-mcp-server".to_string(),
+ args: vec![],
+ env: std::collections::HashMap::new(),
+ enabled: true,
+ discover_from: Some(".kanbots/active-session.json".to_string()),
+ };
+ let result = external_mcp_config_flag(dir.path(), &server, "/nonexistent/project");
+ assert!(
+ result.is_some(),
+ "Should fall back to static command when sidecar missing"
+ );
+ let path = result.unwrap();
+ let content = std::fs::read_to_string(&path).unwrap();
+ let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(
+ json["mcpServers"]["kanbots"]["command"].as_str(),
+ Some("kanbots-mcp-server")
+ );
+ }
+
+ #[test]
+ fn test_external_mcp_config_flag_disabled_server_not_called() {
+ // This is tested at the generate_config_flags level, but we verify
+ // the function itself handles empty command correctly
+ let dir = tempfile::tempdir().unwrap();
+ let server = crate::config::ExternalMcpServer {
+ name: "disabled".to_string(),
+ command: String::new(),
+ args: vec![],
+ env: std::collections::HashMap::new(),
+ enabled: true,
+ discover_from: None,
+ };
+ let result = external_mcp_config_flag(dir.path(), &server, "/project");
+ assert!(
+ result.is_none(),
+ "Empty command without discover_from should return None"
+ );
+ }
+
+ // ========================================
+ // statusline script + settings tests
+ // ========================================
+
+ #[test]
+ fn test_ensure_statusline_script_creates_executable_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let result = ensure_statusline_script(dir.path());
+ assert!(result.is_some());
+ let path = result.unwrap();
+ assert!(path.exists());
+ assert!(path.to_string_lossy().contains("operator-statusline.sh"));
+
+ let content = std::fs::read_to_string(&path).unwrap();
+ assert!(content.contains("#!/usr/bin/env bash"));
+ assert!(content.contains("OPERATOR_"));
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let perms = std::fs::metadata(&path).unwrap().permissions();
+ assert_eq!(perms.mode() & 0o111, 0o111, "Script should be executable");
+ }
+ }
+
+ #[test]
+ fn test_ensure_statusline_script_is_idempotent() {
+ let dir = tempfile::tempdir().unwrap();
+ let path1 = ensure_statusline_script(dir.path()).unwrap();
+ let content1 = std::fs::read_to_string(&path1).unwrap();
+
+ let path2 = ensure_statusline_script(dir.path()).unwrap();
+ let content2 = std::fs::read_to_string(&path2).unwrap();
+
+ assert_eq!(path1, path2);
+ assert_eq!(content1, content2);
+ }
+
+ #[test]
+ fn test_statusline_settings_flag_creates_valid_json() {
+ let dir = tempfile::tempdir().unwrap();
+ let result = statusline_settings_flag(dir.path());
+ assert!(result.is_some());
+ let settings_path = result.unwrap();
+ assert!(settings_path.contains("operator-settings.json"));
+
+ let content = std::fs::read_to_string(&settings_path).unwrap();
+ let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(json["statusLine"]["type"].as_str(), Some("command"));
+ assert!(json["statusLine"]["command"]
+ .as_str()
+ .unwrap()
+ .contains("operator-statusline.sh"));
+ }
}
diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs
index 4a60543..f580ad3 100644
--- a/src/agents/launcher/mod.rs
+++ b/src/agents/launcher/mod.rs
@@ -7,9 +7,9 @@
mod cmux_session;
pub mod interpolation;
-mod llm_command;
+pub(crate) mod llm_command;
mod options;
-mod prompt;
+pub(crate) mod prompt;
mod step_config;
mod tmux_session;
pub mod worktree_setup;
@@ -23,6 +23,8 @@ use std::sync::Arc;
use anyhow::{Context, Result};
+use uuid::Uuid;
+
use crate::agents::cmux::{CmuxClient, SystemCmuxClient};
use crate::agents::tmux::{sanitize_session_name, SystemTmuxClient, TmuxClient, TmuxError};
use crate::agents::zellij::{SystemZellijClient, ZellijClient};
@@ -89,6 +91,8 @@ pub struct PreparedLaunch {
pub session_window_ref: Option,
/// Session context reference (e.g. cmux workspace, zellij session)
pub session_context_ref: Option,
+ /// Operator environment variables for the terminal session
+ pub env_vars: std::collections::HashMap,
}
/// Minimum required tmux version
@@ -317,6 +321,26 @@ impl Launcher {
initial_prompt: &str,
options: &LaunchOptions,
) -> Result<(String, String)> {
+ // Pre-allocate agent ID so we can inject it into the environment
+ let agent_id = Uuid::new_v4().to_string();
+
+ // Build operator environment variables for the terminal session
+ let operator_env = prompt::OperatorEnvVars {
+ agent_id: agent_id.clone(),
+ ticket_id: ticket.id.clone(),
+ project: ticket.project.clone(),
+ step: if ticket.step.is_empty() {
+ "initial".to_string()
+ } else {
+ ticket.step.clone()
+ },
+ ui_url: format!(
+ "http://localhost:{}/#/agent/{}",
+ self.config.rest_api.port, agent_id
+ ),
+ ui_port: self.config.rest_api.port,
+ };
+
// Dispatch based on session wrapper type
let (session_name, wrapper_name, cmux_refs) =
if self.config.sessions.wrapper == SessionWrapperType::Cmux {
@@ -330,6 +354,7 @@ impl Launcher {
working_dir_str,
initial_prompt,
options,
+ &operator_env,
)?;
(
result.session_name,
@@ -347,6 +372,7 @@ impl Launcher {
working_dir_str,
initial_prompt,
options,
+ &operator_env,
)?;
(result.session_name, "zellij", None)
} else {
@@ -358,6 +384,7 @@ impl Launcher {
working_dir_str,
initial_prompt,
options,
+ &operator_env,
)?;
(name, "tmux", None)
};
@@ -375,15 +402,17 @@ impl Launcher {
.map(|t| t.name.clone())
});
- // Update state with launch options
+ // Update state with pre-allocated agent ID
let mut state = State::load(&self.config)?;
- let agent_id = state.add_agent_with_options(
+ let agent_id = state.add_agent_with_explicit_id(
+ agent_id,
ticket.id.clone(),
ticket.ticket_type.clone(),
ticket.project.clone(),
ticket.is_paired(),
llm_tool,
Some(options.launch_mode_string()),
+ None,
)?;
// Store session name in state for later recovery
@@ -499,6 +528,13 @@ impl Launcher {
Ok(cap.saturating_sub(running))
}
+ fn project_available_slots(&self, project: &str) -> Result {
+ let state = State::load(&self.config)?;
+ let count = state.project_agent_count(project);
+ let cap = self.config.effective_max_agents_per_repo();
+ Ok(cap.saturating_sub(count))
+ }
+
/// Fan out a `multi_model` step: N delegators, same prompt for all.
///
/// Launches up to `available_slots()` sub-agents immediately; any that
@@ -685,7 +721,9 @@ impl Launcher {
base_options: &LaunchOptions,
) -> Result<()> {
loop {
- let budget = self.available_slots()?;
+ let budget = self
+ .available_slots()?
+ .min(self.project_available_slots(&ticket.project)?);
if budget == 0 {
break;
}
@@ -771,6 +809,9 @@ impl Launcher {
// Generate session UUID
let session_uuid = generate_session_uuid();
+ // Pre-allocate agent ID so we can inject it into the environment
+ let agent_id = Uuid::new_v4().to_string();
+
// Get the step name (use "initial" if not set)
let step_name = if ticket.step.is_empty() {
"initial".to_string()
@@ -778,6 +819,24 @@ impl Launcher {
ticket.step.clone()
};
+ // Build operator environment variables HashMap for PreparedLaunch
+ let mut env_vars = std::collections::HashMap::new();
+ env_vars.insert("OPERATOR_AGENT_ID".to_string(), agent_id.clone());
+ env_vars.insert("OPERATOR_TICKET_ID".to_string(), ticket.id.clone());
+ env_vars.insert("OPERATOR_PROJECT".to_string(), ticket.project.clone());
+ env_vars.insert("OPERATOR_STEP".to_string(), step_name.clone());
+ env_vars.insert(
+ "OPERATOR_UI_URL".to_string(),
+ format!(
+ "http://localhost:{}/#/agent/{}",
+ self.config.rest_api.port, agent_id
+ ),
+ );
+ env_vars.insert(
+ "OPERATOR_UI_PORT".to_string(),
+ self.config.rest_api.port.to_string(),
+ );
+
// Store the session UUID in the ticket file (now in in-progress)
let ticket_in_progress_path = self
.config
@@ -867,7 +926,7 @@ impl Launcher {
// Wrap in docker command if docker mode is enabled
if options.docker_mode {
- llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str)?;
+ llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str, None)?;
}
// Determine tool name from options or default
@@ -883,15 +942,17 @@ impl Launcher {
.map(|t| t.name.clone())
});
- // Update state with launch
+ // Update state with pre-allocated agent ID
let mut state = State::load(&self.config)?;
- let agent_id = state.add_agent_with_options(
+ let agent_id = state.add_agent_with_explicit_id(
+ agent_id,
ticket.id.clone(),
ticket.ticket_type.clone(),
ticket.project.clone(),
ticket.is_paired(),
llm_tool,
Some(options.launch_mode_string()),
+ None,
)?;
// Store session name in state for later recovery
@@ -931,6 +992,7 @@ impl Launcher {
session_wrapper: None,
session_window_ref: None,
session_context_ref: None,
+ env_vars,
})
}
@@ -1005,6 +1067,9 @@ impl Launcher {
.clone()
.unwrap_or_else(generate_session_uuid);
+ // Pre-allocate agent ID so we can inject it into the environment
+ let agent_id = Uuid::new_v4().to_string();
+
// Get the step name (use "initial" if not set)
let step_name = if ticket.step.is_empty() {
"initial".to_string()
@@ -1012,6 +1077,24 @@ impl Launcher {
ticket.step.clone()
};
+ // Build operator environment variables HashMap for PreparedLaunch
+ let mut env_vars = std::collections::HashMap::new();
+ env_vars.insert("OPERATOR_AGENT_ID".to_string(), agent_id.clone());
+ env_vars.insert("OPERATOR_TICKET_ID".to_string(), ticket.id.clone());
+ env_vars.insert("OPERATOR_PROJECT".to_string(), ticket.project.clone());
+ env_vars.insert("OPERATOR_STEP".to_string(), step_name.clone());
+ env_vars.insert(
+ "OPERATOR_UI_URL".to_string(),
+ format!(
+ "http://localhost:{}/#/agent/{}",
+ self.config.rest_api.port, agent_id
+ ),
+ );
+ env_vars.insert(
+ "OPERATOR_UI_PORT".to_string(),
+ self.config.rest_api.port.to_string(),
+ );
+
// Store the session UUID in the ticket file
let ticket_in_progress_path = self
.config
@@ -1112,7 +1195,7 @@ impl Launcher {
// Wrap in docker command if docker mode is enabled
if options.launch_options.docker_mode {
- llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str)?;
+ llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str, None)?;
}
// Determine tool name from options or default
@@ -1129,15 +1212,17 @@ impl Launcher {
.map(|t| t.name.clone())
});
- // Update state with launch
+ // Update state with pre-allocated agent ID
let mut state = State::load(&self.config)?;
- let agent_id = state.add_agent_with_options(
+ let agent_id = state.add_agent_with_explicit_id(
+ agent_id,
ticket.id.clone(),
ticket.ticket_type.clone(),
ticket.project.clone(),
ticket.is_paired(),
llm_tool,
Some(options.launch_options.launch_mode_string()),
+ None,
)?;
// Store session name in state for later recovery
@@ -1179,6 +1264,7 @@ impl Launcher {
session_wrapper: None,
session_window_ref: None,
session_context_ref: None,
+ env_vars,
})
}
@@ -1234,6 +1320,26 @@ impl Launcher {
let initial_prompt = generate_prompt(&self.config, &ticket);
let initial_prompt = apply_prompt_wrapping(initial_prompt, &options.launch_options);
+ // Pre-allocate agent ID so we can inject it into the environment
+ let agent_id = Uuid::new_v4().to_string();
+
+ // Build operator environment variables for the terminal session
+ let operator_env = prompt::OperatorEnvVars {
+ agent_id: agent_id.clone(),
+ ticket_id: ticket.id.clone(),
+ project: ticket.project.clone(),
+ step: if ticket.step.is_empty() {
+ "initial".to_string()
+ } else {
+ ticket.step.clone()
+ },
+ ui_url: format!(
+ "http://localhost:{}/#/agent/{}",
+ self.config.rest_api.port, agent_id
+ ),
+ ui_port: self.config.rest_api.port,
+ };
+
// Dispatch based on session wrapper type
let (session_name, wrapper_name, cmux_refs) =
if self.config.sessions.wrapper == SessionWrapperType::Cmux {
@@ -1247,6 +1353,7 @@ impl Launcher {
&working_dir_str,
&initial_prompt,
&options,
+ &operator_env,
)?;
(
result.session_name,
@@ -1264,6 +1371,7 @@ impl Launcher {
&working_dir_str,
&initial_prompt,
&options,
+ &operator_env,
)?;
(result.session_name, "zellij", None)
} else {
@@ -1274,6 +1382,7 @@ impl Launcher {
&working_dir_str,
&initial_prompt,
&options,
+ &operator_env,
)?;
(name, "tmux", None)
};
@@ -1292,15 +1401,17 @@ impl Launcher {
.map(|t| t.name.clone())
});
- // Update state with new agent
+ // Update state with pre-allocated agent ID
let mut state = State::load(&self.config)?;
- let agent_id = state.add_agent_with_options(
+ let agent_id = state.add_agent_with_explicit_id(
+ agent_id,
ticket.id.clone(),
ticket.ticket_type.clone(),
ticket.project.clone(),
ticket.is_paired(),
llm_tool,
Some(options.launch_options.launch_mode_string()),
+ None,
)?;
// Store session name in state for later recovery
diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs
index a8719cc..1651234 100644
--- a/src/agents/launcher/prompt.rs
+++ b/src/agents/launcher/prompt.rs
@@ -10,6 +10,42 @@ use crate::config::Config;
use crate::queue::Ticket;
use crate::templates::{schema::TemplateSchema, TemplateType};
+/// Environment variables injected into operator-spawned agent command scripts
+/// for branding (status line, pane title, UI deep-links).
+#[derive(Debug, Clone, Default)]
+pub struct OperatorEnvVars {
+ pub agent_id: String,
+ pub ticket_id: String,
+ pub project: String,
+ pub step: String,
+ pub ui_url: String,
+ pub ui_port: u16,
+}
+
+impl OperatorEnvVars {
+ /// Render shell `export` lines for all operator env vars.
+ pub fn to_export_block(&self) -> String {
+ format!(
+ "export OPERATOR_AGENT_ID={}\nexport OPERATOR_TICKET_ID={}\nexport OPERATOR_PROJECT={}\nexport OPERATOR_STEP={}\nexport OPERATOR_UI_URL={}\nexport OPERATOR_UI_PORT={}\n",
+ shell_escape(&self.agent_id),
+ shell_escape(&self.ticket_id),
+ shell_escape(&self.project),
+ shell_escape(&self.step),
+ shell_escape(&self.ui_url),
+ self.ui_port,
+ )
+ }
+
+ /// Render an OSC 2 escape sequence to set the terminal pane title.
+ pub fn to_pane_title_line(&self) -> String {
+ format!(
+ "printf '\\033]2;[OPR8R] %s | %s\\033\\\\' {} {}\n",
+ shell_escape(&self.ticket_id),
+ shell_escape(&self.project),
+ )
+ }
+}
+
/// Generate the initial prompt for a ticket based on its type
pub fn generate_prompt(config: &Config, ticket: &Ticket) -> String {
let ticket_path = config
@@ -156,15 +192,24 @@ pub fn write_command_file(
session_uuid: &str,
project_path: &str,
llm_command: &str,
+ operator_env: Option<&OperatorEnvVars>,
) -> Result {
let commands_dir = config.tickets_path().join("operator/commands");
fs::create_dir_all(&commands_dir).context("Failed to create commands directory")?;
let command_file = commands_dir.join(format!("{session_uuid}.sh"));
- // Build script content with shebang, cd, and exec
+ // Build script content with shebang, optional env vars, cd, and exec
+ let env_block = operator_env
+ .map(OperatorEnvVars::to_export_block)
+ .unwrap_or_default();
+
+ let pane_title = operator_env
+ .map(OperatorEnvVars::to_pane_title_line)
+ .unwrap_or_default();
+
let script_content = format!(
- "#!/bin/bash\ncd {}\nexec {}\n",
+ "#!/bin/bash\n{env_block}{pane_title}cd {}\nexec {}\n",
shell_escape(project_path),
llm_command
);
@@ -267,7 +312,7 @@ mod tests {
let project_path = "/path/to/project";
let llm_command = "claude --session-id abc123 --print-prompt-path /tmp/prompt.txt";
- let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ let result = write_command_file(&config, session_uuid, project_path, llm_command, None);
assert!(result.is_ok());
let command_file = result.unwrap();
@@ -291,7 +336,7 @@ mod tests {
let project_path = "/path/with spaces/to/project";
let llm_command = "claude --arg value";
- let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ let result = write_command_file(&config, session_uuid, project_path, llm_command, None);
assert!(result.is_ok());
let content = std::fs::read_to_string(result.unwrap()).unwrap();
@@ -310,7 +355,7 @@ mod tests {
let project_path = "/path/with'quotes/and$dollar";
let llm_command = "claude --arg value";
- let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ let result = write_command_file(&config, session_uuid, project_path, llm_command, None);
assert!(result.is_ok());
let content = std::fs::read_to_string(result.unwrap()).unwrap();
@@ -329,7 +374,7 @@ mod tests {
let project_path = "/project";
let llm_command = r#"claude --session-id abc --print-prompt-path /tmp/file.txt --add-dir "/dir with spaces" --model sonnet"#;
- let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ let result = write_command_file(&config, session_uuid, project_path, llm_command, None);
assert!(result.is_ok());
let content = std::fs::read_to_string(result.unwrap()).unwrap();
@@ -350,7 +395,7 @@ mod tests {
let project_path = "/project";
let llm_command = "claude --arg value";
- let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ let result = write_command_file(&config, session_uuid, project_path, llm_command, None);
assert!(result.is_ok());
let command_file = result.unwrap();
@@ -360,4 +405,95 @@ mod tests {
// Check that the file is executable (0o755 = rwxr-xr-x)
assert_eq!(permissions.mode() & 0o777, 0o755);
}
+
+ #[test]
+ fn test_operator_env_vars_to_export_block() {
+ let env = OperatorEnvVars {
+ agent_id: "abc-123".to_string(),
+ ticket_id: "FEAT-042".to_string(),
+ project: "gamesvc".to_string(),
+ step: "implement".to_string(),
+ ui_url: "http://localhost:7007/#/agent/abc-123".to_string(),
+ ui_port: 7007,
+ };
+ let block = env.to_export_block();
+ assert!(block.contains("export OPERATOR_AGENT_ID='abc-123'"));
+ assert!(block.contains("export OPERATOR_TICKET_ID='FEAT-042'"));
+ assert!(block.contains("export OPERATOR_PROJECT='gamesvc'"));
+ assert!(block.contains("export OPERATOR_STEP='implement'"));
+ assert!(block.contains("export OPERATOR_UI_URL='http://localhost:7007/#/agent/abc-123'"));
+ assert!(block.contains("export OPERATOR_UI_PORT=7007"));
+ }
+
+ #[test]
+ fn test_operator_env_vars_to_pane_title_line() {
+ let env = OperatorEnvVars {
+ agent_id: "abc-123".to_string(),
+ ticket_id: "FEAT-042".to_string(),
+ project: "gamesvc".to_string(),
+ step: "implement".to_string(),
+ ui_url: "http://localhost:7007/#/agent/abc-123".to_string(),
+ ui_port: 7007,
+ };
+ let line = env.to_pane_title_line();
+ assert!(line.contains("\\033]2;"));
+ assert!(line.contains("FEAT-042"));
+ assert!(line.contains("gamesvc"));
+ }
+
+ #[test]
+ fn test_write_command_file_with_operator_env() {
+ use tempfile::tempdir;
+
+ let temp_dir = tempdir().unwrap();
+ let config = make_test_config_with_tickets_path(temp_dir.path());
+
+ let env = OperatorEnvVars {
+ agent_id: "test-agent-id".to_string(),
+ ticket_id: "FEAT-001".to_string(),
+ project: "myproject".to_string(),
+ step: "plan".to_string(),
+ ui_url: "http://localhost:7007/#/agent/test-agent-id".to_string(),
+ ui_port: 7007,
+ };
+
+ let result = write_command_file(
+ &config,
+ "test-uuid-env",
+ "/path/to/project",
+ "claude --session-id abc123",
+ Some(&env),
+ );
+ assert!(result.is_ok());
+
+ let content = std::fs::read_to_string(result.unwrap()).unwrap();
+ assert!(content.contains("export OPERATOR_AGENT_ID='test-agent-id'"));
+ assert!(content.contains("export OPERATOR_TICKET_ID='FEAT-001'"));
+ assert!(content.contains("export OPERATOR_PROJECT='myproject'"));
+ assert!(content.contains("\\033]2;"));
+ assert!(content.contains("cd '/path/to/project'"));
+ assert!(content.contains("exec claude --session-id abc123"));
+ }
+
+ #[test]
+ fn test_write_command_file_without_operator_env_unchanged() {
+ use tempfile::tempdir;
+
+ let temp_dir = tempdir().unwrap();
+ let config = make_test_config_with_tickets_path(temp_dir.path());
+
+ let result = write_command_file(
+ &config,
+ "test-uuid-noenv",
+ "/path/to/project",
+ "claude --session-id abc123",
+ None,
+ );
+ assert!(result.is_ok());
+
+ let content = std::fs::read_to_string(result.unwrap()).unwrap();
+ assert!(!content.contains("OPERATOR_"));
+ assert!(!content.contains("\\033]2;"));
+ assert!(content.starts_with("#!/bin/bash\ncd"));
+ }
}
diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs
index 46701fd..78c2786 100644
--- a/src/agents/launcher/tests.rs
+++ b/src/agents/launcher/tests.rs
@@ -501,9 +501,21 @@ async fn test_launch_global_ticket_uses_root() {
// ========================================
use super::options::RelaunchOptions;
+use super::prompt::OperatorEnvVars;
use super::tmux_session::{launch_in_tmux_with_options, launch_in_tmux_with_relaunch_options};
use crate::agents::tmux::TmuxClient;
+fn make_test_operator_env() -> OperatorEnvVars {
+ OperatorEnvVars {
+ agent_id: Uuid::new_v4().to_string(),
+ ticket_id: "TEST-001".to_string(),
+ project: "test-project".to_string(),
+ step: "initial".to_string(),
+ ui_url: "http://localhost:7008/#/agent/test".to_string(),
+ ui_port: 7008,
+ }
+}
+
fn make_test_config_with_docker(temp_dir: &TempDir, image: &str) -> Config {
let mut config = make_test_config(temp_dir);
config.launch.docker.image = image.to_string();
@@ -539,6 +551,7 @@ fn test_launch_in_tmux_session_uses_prefix() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok(), "Launch failed: {:?}", result.err());
@@ -575,6 +588,7 @@ fn test_launch_in_tmux_existing_session_returns_error() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_err());
@@ -607,6 +621,7 @@ fn test_launch_in_tmux_sends_cd_command() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -652,6 +667,7 @@ fn test_launch_in_tmux_sends_llm_command() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -697,6 +713,7 @@ fn test_launch_in_tmux_yolo_mode_applies_flags() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -738,6 +755,7 @@ fn test_launch_in_tmux_yolo_mode_disabled_no_flags() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -779,6 +797,7 @@ fn test_launch_in_tmux_docker_mode_wraps() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -821,6 +840,7 @@ fn test_launch_in_tmux_both_modes() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -884,6 +904,7 @@ fn test_launch_in_tmux_uses_provider_from_options() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -926,6 +947,7 @@ fn test_launch_in_tmux_writes_prompt_file() {
&project_path,
"Test prompt content",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -968,6 +990,7 @@ fn test_launch_in_tmux_tmux_not_installed() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_err());
@@ -1007,6 +1030,7 @@ fn test_relaunch_fresh_start_new_uuid() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -1043,6 +1067,7 @@ fn test_relaunch_inherits_yolo_mode() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -1088,6 +1113,7 @@ fn test_relaunch_inherits_docker_mode() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -1131,6 +1157,7 @@ fn test_relaunch_existing_session_errors() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_err());
@@ -1182,6 +1209,7 @@ fn test_relaunch_with_resume_adds_flag() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -1231,6 +1259,7 @@ fn test_relaunch_missing_prompt_fresh_start() {
&project_path,
"Fallback prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -1310,6 +1339,7 @@ fn test_launch_correct_project_directory_from_ticket() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -1389,6 +1419,7 @@ fn test_launch_provider_from_delegator_determines_tool() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -1434,6 +1465,7 @@ fn test_launch_yolo_flags_per_tool() {
&project_path,
"Test prompt",
&options,
+ &make_test_operator_env(),
);
assert!(result.is_ok());
@@ -1475,6 +1507,7 @@ async fn test_launch_pending_sub_agents_launches_all_when_slots_allow() {
let mut config = make_test_config(&temp_dir);
config.agents.max_parallel = 10;
config.agents.cores_reserved = 0;
+ config.agents.max_agents_per_repo = 10;
add_delegators(&mut config, &["claude-opus", "gemini-pro"]);
let mock = Arc::new(MockTmuxClient::new());
@@ -1547,9 +1580,10 @@ async fn test_launch_pending_sub_agents_launches_all_when_slots_allow() {
async fn test_launch_pending_sub_agents_respects_slot_budget() {
let temp_dir = TempDir::new().unwrap();
let mut config = make_test_config(&temp_dir);
- // Budget of exactly 1 slot
+ // Budget of exactly 1 global slot (per-repo cap is high so only max_parallel constrains)
config.agents.max_parallel = 1;
config.agents.cores_reserved = 0;
+ config.agents.max_agents_per_repo = 10;
add_delegators(&mut config, &["claude-opus", "gemini-pro"]);
let mock = Arc::new(MockTmuxClient::new());
diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs
index 58c1dfb..db0fa55 100644
--- a/src/agents/launcher/tmux_session.rs
+++ b/src/agents/launcher/tmux_session.rs
@@ -16,7 +16,7 @@ use super::llm_command::{
use super::options::{LaunchOptions, RelaunchOptions};
use super::prompt::{
generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file,
- write_prompt_file,
+ write_prompt_file, OperatorEnvVars,
};
use super::SESSION_PREFIX;
@@ -28,6 +28,7 @@ pub fn launch_in_tmux_with_options(
project_path: &str,
initial_prompt: &str,
options: &LaunchOptions,
+ operator_env: &OperatorEnvVars,
) -> Result {
// Create session name from ticket ID (sanitize for tmux).
// For multi-agent fan-out, append the session_suffix to distinguish
@@ -180,12 +181,18 @@ pub fn launch_in_tmux_with_options(
// Wrap in docker command if docker mode is enabled
if options.docker_mode {
- llm_cmd = build_docker_command(config, &llm_cmd, project_path)?;
+ llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?;
}
// Write the command to a shell script file to avoid issues with long commands
// and special characters when using tmux send-keys
- let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?;
+ let command_file = write_command_file(
+ config,
+ &session_uuid,
+ project_path,
+ &llm_cmd,
+ Some(operator_env),
+ )?;
// Inject relay env vars so agents can find the hub and register with their ticket ID
if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") {
@@ -228,6 +235,7 @@ pub fn launch_in_tmux_with_relaunch_options(
project_path: &str,
initial_prompt: &str,
options: &RelaunchOptions,
+ operator_env: &OperatorEnvVars,
) -> Result {
// Create session name from ticket ID (sanitize for tmux)
let session_name = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id));
@@ -404,12 +412,18 @@ pub fn launch_in_tmux_with_relaunch_options(
// Wrap in docker command if docker mode is enabled
if options.launch_options.docker_mode {
- llm_cmd = build_docker_command(config, &llm_cmd, project_path)?;
+ llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?;
}
// Write the command to a shell script file to avoid issues with long commands
// and special characters when using tmux send-keys
- let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?;
+ let command_file = write_command_file(
+ config,
+ &session_uuid,
+ project_path,
+ &llm_cmd,
+ Some(operator_env),
+ )?;
// Inject relay env vars so agents can find the hub and register with their ticket ID
if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") {
diff --git a/src/agents/launcher/worktree_setup.rs b/src/agents/launcher/worktree_setup.rs
index 5f93b73..4ee3d01 100644
--- a/src/agents/launcher/worktree_setup.rs
+++ b/src/agents/launcher/worktree_setup.rs
@@ -273,7 +273,6 @@ async fn detect_default_branch(repo_path: &Path) -> Option {
/// * `cleanup_script` - Optional cleanup script to run before removal
/// * `prune_branch` - Whether to delete the branch
/// * `delete_remote_branch` - Whether to delete the remote branch too
-#[allow(dead_code)] // Will be used in sync.rs for PR merge cleanup
pub async fn cleanup_ticket_worktree(
config: &Config,
worktree_path: &Path,
diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs
index 009b06c..7456461 100644
--- a/src/agents/launcher/zellij_session.rs
+++ b/src/agents/launcher/zellij_session.rs
@@ -20,7 +20,7 @@ use super::llm_command::{
use super::options::{LaunchOptions, RelaunchOptions};
use super::prompt::{
generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file,
- write_prompt_file,
+ write_prompt_file, OperatorEnvVars,
};
/// Result of launching in zellij — includes tab name for state tracking
#[derive(Debug, Clone)]
@@ -38,6 +38,7 @@ pub fn launch_in_zellij_with_options(
project_path: &str,
initial_prompt: &str,
options: &LaunchOptions,
+ operator_env: &OperatorEnvVars,
) -> Result {
// Check zellij is available and we're inside zellij
zellij
@@ -132,11 +133,17 @@ pub fn launch_in_zellij_with_options(
}
if options.docker_mode {
- llm_cmd = build_docker_command(config, &llm_cmd, project_path)?;
+ llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?;
}
// Write the command to a shell script file
- let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?;
+ let command_file = write_command_file(
+ config,
+ &session_uuid,
+ project_path,
+ &llm_cmd,
+ Some(operator_env),
+ )?;
// Inject relay env vars so agents can find the hub and register with their ticket ID
if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") {
@@ -183,6 +190,7 @@ pub fn launch_in_zellij_with_relaunch_options(
project_path: &str,
initial_prompt: &str,
options: &RelaunchOptions,
+ operator_env: &OperatorEnvVars,
) -> Result {
// Check zellij is available and we're inside zellij
zellij
@@ -292,11 +300,17 @@ pub fn launch_in_zellij_with_relaunch_options(
}
if options.launch_options.docker_mode {
- llm_cmd = build_docker_command(config, &llm_cmd, project_path)?;
+ llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?;
}
// Write and send command
- let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?;
+ let command_file = write_command_file(
+ config,
+ &session_uuid,
+ project_path,
+ &llm_cmd,
+ Some(operator_env),
+ )?;
if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") {
let export_cmd = format!(
"export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n",
diff --git a/src/agents/mod.rs b/src/agents/mod.rs
index e0d6edc..7f2e4a6 100644
--- a/src/agents/mod.rs
+++ b/src/agents/mod.rs
@@ -9,7 +9,7 @@ pub mod delegator_resolution;
mod generator;
pub mod hooks;
pub mod idle_detector;
-mod launcher;
+pub(crate) mod launcher;
mod monitor;
mod pr_workflow;
mod session;
diff --git a/src/agents/monitor.rs b/src/agents/monitor.rs
index 619ee02..2dc9244 100644
--- a/src/agents/monitor.rs
+++ b/src/agents/monitor.rs
@@ -396,7 +396,7 @@ impl SessionMonitor {
if elapsed >= self.check_interval {
Duration::ZERO
} else {
- self.check_interval - elapsed
+ self.check_interval.saturating_sub(elapsed)
}
}
}
diff --git a/src/agents/sync.rs b/src/agents/sync.rs
index 686caa0..d4a0044 100644
--- a/src/agents/sync.rs
+++ b/src/agents/sync.rs
@@ -618,7 +618,7 @@ impl TicketSessionSync {
if elapsed >= self.sync_interval {
Duration::ZERO
} else {
- self.sync_interval - elapsed
+ self.sync_interval.saturating_sub(elapsed)
}
}
diff --git a/src/app/agents.rs b/src/app/agents.rs
index 015a428..fee9d3f 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -160,10 +160,11 @@ impl App {
// Get selected ticket
if let Some(ticket) = self.dashboard.selected_ticket().cloned() {
- // Check if project is already busy
- if state.is_project_busy(&ticket.project) {
+ let project_count = state.project_agent_count(&ticket.project);
+ let max_per_repo = self.config.effective_max_agents_per_repo();
+ if project_count >= max_per_repo {
self.dashboard.set_status(&format!(
- "Cannot launch: {} has an active agent",
+ "Cannot launch: {} has {project_count}/{max_per_repo} agents",
ticket.project
));
return Ok(());
@@ -211,9 +212,11 @@ impl App {
return Ok(());
};
- if state.is_project_busy(&ticket.project) {
+ let project_count = state.project_agent_count(&ticket.project);
+ let max_per_repo = self.config.effective_max_agents_per_repo();
+ if project_count >= max_per_repo {
self.dashboard.set_status(&format!(
- "Cannot launch: {} has an active agent",
+ "Cannot launch: {} has {project_count}/{max_per_repo} agents",
ticket.project
));
return Ok(());
diff --git a/src/app/data_sync.rs b/src/app/data_sync.rs
index 3279e74..ef19557 100644
--- a/src/app/data_sync.rs
+++ b/src/app/data_sync.rs
@@ -33,11 +33,6 @@ impl App {
.collect();
self.dashboard.update_completed(completed);
- // Update Backstage server status
- self.backstage_server.refresh_status();
- self.dashboard
- .update_backstage_status(self.backstage_server.status());
-
// Update wrapper connection status
let wrapper_status = self.check_wrapper_connection();
self.dashboard
diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs
index 9f5b7b3..9bc6ad8 100644
--- a/src/app/keyboard.rs
+++ b/src/app/keyboard.rs
@@ -20,12 +20,10 @@ impl App {
// Setup screen takes absolute priority
if let Some(ref mut setup) = self.setup_screen {
match code {
- KeyCode::Char('i' | 'I') => {
- if setup.confirm_selected {
- self.initialize_tickets()?;
- self.setup_screen = None;
- self.refresh_data()?;
- }
+ KeyCode::Char('i' | 'I') if setup.confirm_selected => {
+ self.initialize_tickets()?;
+ self.setup_screen = None;
+ self.refresh_data()?;
}
KeyCode::Enter => {
match setup.confirm() {
@@ -225,11 +223,9 @@ impl App {
// Session recovery dialog handling
if self.session_recovery_dialog.visible {
match code {
- KeyCode::Char('r' | 'R') => {
- if self.session_recovery_dialog.has_session_id() {
- self.handle_session_recovery(SessionRecoverySelection::ResumeSession)
- .await?;
- }
+ KeyCode::Char('r' | 'R') if self.session_recovery_dialog.has_session_id() => {
+ self.handle_session_recovery(SessionRecoverySelection::ResumeSession)
+ .await?;
}
KeyCode::Char('s' | 'S') => {
self.handle_session_recovery(SessionRecoverySelection::StartFresh)
@@ -389,9 +385,8 @@ impl App {
match code {
KeyCode::Char('q') => {
// Stop servers if running before exiting
- if self.rest_api_server.is_running() || self.backstage_server.is_running() {
+ if self.rest_api_server.is_running() {
self.rest_api_server.stop();
- let _ = self.backstage_server.stop();
}
// Shut down PR monitor
if let Some(tx) = self.pr_shutdown_tx.take() {
@@ -491,7 +486,7 @@ impl App {
}
}
KeyCode::Char('W' | 'w') => {
- self.toggle_web_servers(terminal)?;
+ self.open_web_ui()?;
}
KeyCode::Char('T' | 't') => {
// Open collection switch dialog
@@ -530,7 +525,6 @@ impl App {
} else {
// First Ctrl+C - stop servers and enter confirmation mode
self.rest_api_server.stop();
- let _ = self.backstage_server.stop();
self.exit_confirmation_mode = true;
self.exit_confirmation_time = Some(std::time::Instant::now());
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 90c99c4..39edf1f 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -8,7 +8,6 @@ use tokio::sync::{mpsc, RwLock};
use crate::agents::tmux::SystemTmuxClient;
use crate::agents::{SessionMonitor, TicketSessionSync};
-use crate::backstage::BackstageServer;
use crate::config::Config;
use crate::issuetypes::IssueTypeRegistry;
use crate::notifications::NotificationService;
@@ -66,8 +65,6 @@ pub struct App {
pub(crate) ticket_sync: TicketSessionSync,
/// Last sync status message for display
pub(crate) sync_status_message: Option,
- /// Backstage server lifecycle manager
- pub(crate) backstage_server: BackstageServer,
/// REST API server lifecycle manager
pub(crate) rest_api_server: RestApiServer,
/// Exit confirmation mode (first Ctrl+C pressed)
@@ -76,6 +73,8 @@ pub struct App {
pub(crate) exit_confirmation_time: Option,
/// Start web servers on launch (--web flag)
pub(crate) start_web_on_launch: bool,
+ /// Open the embedded web UI in browser on launch (--ui flag)
+ pub(crate) open_ui_on_launch: bool,
/// Session recovery dialog for handling dead tmux sessions
pub(crate) session_recovery_dialog: SessionRecoveryDialog,
/// Collection switch dialog for changing active issue type collection
@@ -118,7 +117,7 @@ pub struct App {
}
impl App {
- pub async fn new(mut config: Config, start_web: bool) -> Result {
+ pub async fn new(mut config: Config, start_web: bool, open_ui: bool) -> Result {
// Run LLM tool detection on first startup
if !config.llm_tools.detection_complete {
tracing::info!("Detecting LLM CLI tools...");
@@ -212,15 +211,6 @@ impl App {
};
let ticket_sync = TicketSessionSync::new(&config, Arc::clone(&tmux_client));
- // Initialize Backstage server lifecycle manager using compiled binary mode
- let backstage_server = BackstageServer::with_compiled_binary(
- config.state_path(),
- config.backstage.release_url.clone(),
- config.backstage.local_binary_path.clone(),
- config.backstage.port,
- )
- .map_err(|e| anyhow::anyhow!("Failed to initialize backstage server: {e}"))?;
-
// Initialize REST API server lifecycle manager
let rest_api_server = RestApiServer::new(config.clone(), config.rest_api.port);
@@ -303,11 +293,11 @@ impl App {
session_preview: SessionPreview::new(),
ticket_sync,
sync_status_message: None,
- backstage_server,
rest_api_server,
exit_confirmation_mode: false,
exit_confirmation_time: None,
start_web_on_launch: start_web,
+ open_ui_on_launch: open_ui,
session_recovery_dialog: SessionRecoveryDialog::new(),
collection_dialog: CollectionSwitchDialog::new(),
kanban_view: KanbanView::new(),
@@ -331,6 +321,7 @@ impl App {
})
}
+ #[allow(clippy::cognitive_complexity)]
pub async fn run(&mut self) -> Result<()> {
// Reconcile state with actual tmux sessions on startup
self.reconcile_sessions()?;
@@ -360,23 +351,21 @@ impl App {
}
}
- // Start Backstage web server if --web flag was passed
- if self.start_web_on_launch {
- if let Err(e) = self.backstage_server.start() {
- tracing::error!("Backstage start failed: {}", e);
+ // Start web servers if --web flag was passed
+ if self.start_web_on_launch && self.rest_api_server.is_running() {
+ let port = self.config.rest_api.port;
+ let url = format!("http://localhost:{port}/");
+ if let Err(e) = status_actions::open_in_browser(&url) {
+ tracing::warn!("Failed to open web UI: {}", e);
}
- // Wait for server to be ready then open browser
- if self.backstage_server.is_running() {
- match self.backstage_server.wait_for_ready(25000) {
- Ok(()) => {
- if let Err(e) = self.backstage_server.open_browser() {
- tracing::warn!("Failed to open browser: {}", e);
- }
- }
- Err(e) => {
- tracing::error!("Server not ready: {}", e);
- }
- }
+ }
+
+ // Open embedded web UI in browser if --ui flag was passed
+ if self.open_ui_on_launch && self.rest_api_server.is_running() {
+ let port = self.config.rest_api.port;
+ let url = format!("http://localhost:{port}/");
+ if let Err(e) = status_actions::open_in_browser(&url) {
+ tracing::warn!("Failed to open web UI: {}", e);
}
}
@@ -396,6 +385,14 @@ impl App {
// Update dashboard with server statuses and exit confirmation mode
self.dashboard
.update_rest_api_status(self.rest_api_server.status());
+ // MCP session count — try_lock so we never block the UI tick;
+ // a contended lock falls back to the previous frame's count.
+ let mcp_sessions = self
+ .rest_api_server
+ .api_state()
+ .and_then(|s| s.mcp_sessions.try_lock().ok().map(|m| m.len()))
+ .unwrap_or(0);
+ self.dashboard.update_mcp_active_sessions(mcp_sessions);
self.dashboard
.update_exit_confirmation_mode(self.exit_confirmation_mode);
diff --git a/src/app/status_actions.rs b/src/app/status_actions.rs
index 1a818df..a718a25 100644
--- a/src/app/status_actions.rs
+++ b/src/app/status_actions.rs
@@ -1,12 +1,49 @@
use anyhow::Result;
use crate::config::SessionWrapperType;
+use crate::rest::web_ui::EmbeddedUiState;
use crate::ui::status_panel::StatusAction;
use crate::ui::with_suspended_tui;
use super::git_onboarding;
use super::{App, AppTerminal};
+/// Decision from `decide_open_web_ui`: either open a URL or surface a status
+/// message explaining why we can't.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) enum WebUiOutcome {
+ Open(String),
+ StatusOnly(String),
+}
+
+/// Pure decision logic for "user pressed `w` / clicked Open Web UI".
+///
+/// Kept free of `&self` so it can be unit-tested without spinning up an `App`.
+/// Callers resolve the inputs from runtime state and act on the returned
+/// outcome (open the URL or just show the status message).
+pub(super) fn decide_open_web_ui(
+ api_running: bool,
+ url: &str,
+ state: EmbeddedUiState,
+) -> WebUiOutcome {
+ if !api_running {
+ return WebUiOutcome::StatusOnly(
+ "API not running — press Enter on the Operator API row to start it.".into(),
+ );
+ }
+ match state {
+ EmbeddedUiState::Ready => WebUiOutcome::Open(url.to_string()),
+ EmbeddedUiState::Placeholder => WebUiOutcome::StatusOnly(
+ "Web UI placeholder detected — run `cd ui && bun run build` and rebuild operator."
+ .into(),
+ ),
+ EmbeddedUiState::Missing => WebUiOutcome::StatusOnly(
+ "Binary built without `embed-ui` feature — rebuild with `cargo build` (default) or `--features embed-ui`."
+ .into(),
+ ),
+ }
+}
+
/// Open a URL in the default browser.
pub(super) fn open_in_browser(url: &str) -> std::io::Result<()> {
let opener = if cfg!(target_os = "macos") {
@@ -83,8 +120,13 @@ impl App {
StatusAction::RestartWrapperConnection => {
self.restart_wrapper_connection();
}
- StatusAction::ToggleWebServers => {
- self.toggle_web_servers(terminal)?;
+ StatusAction::OpenWebUi { port } => {
+ let url = format!("http://localhost:{port}/");
+ self.try_open_web_ui(&url);
+ }
+ StatusAction::OpenWebUiAt { port, route } => {
+ let url = format!("http://localhost:{port}/#{route}");
+ self.try_open_web_ui(&url);
}
StatusAction::SetDefaultLlm { tool_name, model } => {
self.set_default_llm(&tool_name, &model);
@@ -181,6 +223,103 @@ impl App {
.set_status(&format!("Failed to reload config: {e}"));
}
},
+ StatusAction::ToggleMcpHttp => {
+ self.config.mcp.http_enabled = !self.config.mcp.http_enabled;
+ self.dashboard.update_config(&self.config);
+ self.dashboard.set_status(if self.config.mcp.http_enabled {
+ "MCP HTTP enabled — restart the API to mount routes"
+ } else {
+ "MCP HTTP disabled — restart the API to unmount routes"
+ });
+ }
+ StatusAction::WriteAndOpenMcpClientConfig { client } => {
+ let cwd = std::env::current_dir().unwrap_or_default();
+ let Some(snippet) = crate::mcp::client_configs::snippet_for(&client, &cwd) else {
+ self.dashboard
+ .set_status(&format!("Unknown MCP client: {client}"));
+ return Ok(());
+ };
+ let dir = self.config.tickets_path().join("operator/mcp");
+ if let Err(e) = std::fs::create_dir_all(&dir) {
+ self.dashboard
+ .set_status(&format!("Failed to create {}: {e}", dir.display()));
+ return Ok(());
+ }
+ let path = dir.join(format!("{client}.json"));
+ let body = serde_json::to_string_pretty(&snippet).unwrap_or_default();
+ if let Err(e) = std::fs::write(&path, body) {
+ self.dashboard
+ .set_status(&format!("Failed to write {}: {e}", path.display()));
+ return Ok(());
+ }
+ let cmd = self.dashboard.editor_config.file_editor().to_string();
+ with_suspended_tui(terminal, || {
+ let (prog, args) = crate::editors::EditorConfig::split_command(&cmd);
+ let result = std::process::Command::new(prog)
+ .args(&args)
+ .arg(&path)
+ .status();
+ if let Err(e) = result {
+ tracing::warn!("Failed to open editor: {}", e);
+ }
+ Ok(())
+ })?;
+ }
+ StatusAction::OpenMcpDocs => {
+ if let Err(e) = open_in_browser("https://operator.untra.io/mcp/") {
+ self.dashboard
+ .set_status(&format!("Failed to open MCP docs: {e}"));
+ }
+ }
+ StatusAction::WriteAndOpenAcpEditorConfig { editor } => {
+ let Some(snippet) = crate::acp::client_configs::snippet_for(&editor) else {
+ self.dashboard
+ .set_status(&format!("Unknown ACP editor: {editor}"));
+ return Ok(());
+ };
+ let dir = self.config.tickets_path().join("operator/acp");
+ if let Err(e) = std::fs::create_dir_all(&dir) {
+ self.dashboard
+ .set_status(&format!("Failed to create {}: {e}", dir.display()));
+ return Ok(());
+ }
+ // Text-format editors (emacs elisp, kiro TOML) deserialise into
+ // a JSON string; everything else is a structured Value.
+ let (extension, body) = match snippet {
+ serde_json::Value::String(s) => {
+ let ext = if editor == "emacs" { "el" } else { "toml" };
+ (ext, s)
+ }
+ other => (
+ "json",
+ serde_json::to_string_pretty(&other).unwrap_or_default(),
+ ),
+ };
+ let path = dir.join(format!("{editor}.{extension}"));
+ if let Err(e) = std::fs::write(&path, body) {
+ self.dashboard
+ .set_status(&format!("Failed to write {}: {e}", path.display()));
+ return Ok(());
+ }
+ let cmd = self.dashboard.editor_config.file_editor().to_string();
+ with_suspended_tui(terminal, || {
+ let (prog, args) = crate::editors::EditorConfig::split_command(&cmd);
+ let result = std::process::Command::new(prog)
+ .args(&args)
+ .arg(&path)
+ .status();
+ if let Err(e) = result {
+ tracing::warn!("Failed to open editor: {}", e);
+ }
+ Ok(())
+ })?;
+ }
+ StatusAction::OpenAcpDocs => {
+ if let Err(e) = open_in_browser("https://operator.untra.io/acp/") {
+ self.dashboard
+ .set_status(&format!("Failed to open ACP docs: {e}"));
+ }
+ }
StatusAction::None => {}
}
Ok(())
@@ -245,49 +384,92 @@ impl App {
.set_status(&format!("Default LLM set to {tool_name}:{model}"));
}
- /// Toggle both REST API and Backstage servers together.
- pub(super) fn toggle_web_servers(&mut self, terminal: &mut AppTerminal) -> Result<()> {
- let backstage_running = self.backstage_server.is_running();
- let rest_running = self.rest_api_server.is_running();
+ /// Open the embedded web UI in the default browser.
+ pub(super) fn open_web_ui(&mut self) -> Result<()> {
+ let port = self.config.rest_api.port;
+ let url = format!("http://localhost:{port}/");
+ self.try_open_web_ui(&url);
+ Ok(())
+ }
+
+ /// Shared implementation: consult `decide_open_web_ui`, either spawn the
+ /// browser or surface a status message explaining the failure.
+ fn try_open_web_ui(&mut self, url: &str) {
+ let outcome = decide_open_web_ui(
+ self.rest_api_server.is_running(),
+ url,
+ crate::rest::web_ui::embedded_ui_state(),
+ );
+ match outcome {
+ WebUiOutcome::Open(url) => match open_in_browser(&url) {
+ Ok(()) => self
+ .dashboard
+ .set_status(&format!("Opened web UI at {url}")),
+ Err(e) => self
+ .dashboard
+ .set_status(&format!("Failed to open browser: {e}")),
+ },
+ WebUiOutcome::StatusOnly(msg) => self.dashboard.set_status(&msg),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
- if backstage_running && rest_running {
- // Both running - stop both
- self.rest_api_server.stop();
- if let Err(e) = self.backstage_server.stop() {
- tracing::error!("Backstage stop failed: {}", e);
+ const URL: &str = "http://localhost:7008/";
+
+ #[test]
+ fn test_decide_open_web_ui_api_stopped_returns_status_message() {
+ let outcome = decide_open_web_ui(false, URL, EmbeddedUiState::Ready);
+ match outcome {
+ WebUiOutcome::StatusOnly(msg) => {
+ assert!(msg.contains("API not running"), "got: {msg}");
}
- } else {
- // Show yellow "Starting" indicator immediately for feedback
- use crate::backstage::ServerStatus;
- self.dashboard
- .update_backstage_status(ServerStatus::Starting);
- terminal.draw(|f| self.dashboard.render(f))?;
+ other => panic!("expected StatusOnly, got {other:?}"),
+ }
+ }
- // Start both if not running
- if !rest_running {
- if let Err(e) = self.rest_api_server.start() {
- tracing::error!("REST API start failed: {}", e);
- }
+ #[test]
+ fn test_decide_open_web_ui_ready_returns_url() {
+ let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Ready);
+ assert_eq!(outcome, WebUiOutcome::Open(URL.to_string()));
+ }
+
+ #[test]
+ fn test_decide_open_web_ui_placeholder_warns_user() {
+ let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Placeholder);
+ match outcome {
+ WebUiOutcome::StatusOnly(msg) => {
+ assert!(msg.contains("placeholder"), "got: {msg}");
+ assert!(msg.contains("bun run build"), "got: {msg}");
}
- if !backstage_running {
- if let Err(e) = self.backstage_server.start() {
- tracing::error!("Backstage start failed: {}", e);
- }
+ other => panic!("expected StatusOnly, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_decide_open_web_ui_missing_warns_user() {
+ let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Missing);
+ match outcome {
+ WebUiOutcome::StatusOnly(msg) => {
+ assert!(msg.contains("embed-ui"), "got: {msg}");
}
- // Wait for server to be ready before opening browser
- if self.backstage_server.is_running() {
- match self.backstage_server.wait_for_ready(25000) {
- Ok(()) => {
- if let Err(e) = self.backstage_server.open_browser() {
- tracing::warn!("Failed to open browser: {}", e);
- }
- }
- Err(e) => {
- tracing::error!("Server not ready: {}", e);
- }
- }
+ other => panic!("expected StatusOnly, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_decide_open_web_ui_api_stopped_takes_precedence_over_missing() {
+ // Even if the UI is missing, the user's first problem to solve is
+ // starting the API — surface that message, not the embed-ui one.
+ let outcome = decide_open_web_ui(false, URL, EmbeddedUiState::Missing);
+ match outcome {
+ WebUiOutcome::StatusOnly(msg) => {
+ assert!(msg.contains("API not running"), "got: {msg}");
}
+ other => panic!("expected StatusOnly, got {other:?}"),
}
- Ok(())
}
}
diff --git a/src/app/tests.rs b/src/app/tests.rs
index 006d458..bd3a307 100644
--- a/src/app/tests.rs
+++ b/src/app/tests.rs
@@ -247,14 +247,16 @@ mod launch_validation {
let dashboard_paused = state.paused;
let running_count = state.running_agents().len();
let max_agents = config.effective_max_agents();
- let project_busy = state.is_project_busy("test-project");
+ let project_count = state.project_agent_count("test-project");
+ let max_per_repo = config.effective_max_agents_per_repo();
// All conditions for launch should be met
- let can_launch = !dashboard_paused && running_count < max_agents && !project_busy;
+ let can_launch =
+ !dashboard_paused && running_count < max_agents && project_count < max_per_repo;
assert!(
can_launch,
- "Should be allowed to launch when not paused, under max, and project not busy"
+ "Should be allowed to launch when not paused, under max, and project under cap"
);
}
@@ -303,7 +305,7 @@ mod launch_validation {
}
#[test]
- fn test_try_launch_blocked_project_busy() {
+ fn test_try_launch_blocked_project_at_capacity() {
let temp_dir = TempDir::new().unwrap();
let config = make_test_config(&temp_dir);
@@ -318,22 +320,87 @@ mod launch_validation {
)
.unwrap();
- // Check if project is busy
let state = State::load(&config).unwrap();
- let project_busy = state.is_project_busy("test-project");
+ let project_count = state.project_agent_count("test-project");
+ let max_per_repo = config.effective_max_agents_per_repo();
- assert!(project_busy, "Project should be busy with running agent");
+ assert!(
+ project_count >= max_per_repo,
+ "Project should be at capacity with running agent"
+ );
}
#[test]
- fn test_try_launch_project_not_busy_when_empty() {
+ fn test_try_launch_project_empty() {
let temp_dir = TempDir::new().unwrap();
let config = make_test_config(&temp_dir);
let state = State::load(&config).unwrap();
- let project_busy = state.is_project_busy("test-project");
+ let project_count = state.project_agent_count("test-project");
+
+ assert_eq!(project_count, 0, "Project should have no agents");
+ }
+
+ #[test]
+ fn test_try_launch_allowed_when_under_per_repo_cap() {
+ let temp_dir = TempDir::new().unwrap();
+ let mut config = make_test_config(&temp_dir);
+ config.agents.max_agents_per_repo = 2;
+
+ let mut state = State::load(&config).unwrap();
+ state
+ .add_agent(
+ "TASK-001".to_string(),
+ "TASK".to_string(),
+ "test-project".to_string(),
+ false,
+ )
+ .unwrap();
+
+ let state = State::load(&config).unwrap();
+ let project_count = state.project_agent_count("test-project");
+ let max_per_repo = config.effective_max_agents_per_repo();
+
+ assert_eq!(project_count, 1);
+ assert_eq!(max_per_repo, 2);
+ assert!(
+ project_count < max_per_repo,
+ "Should allow second agent when cap is 2"
+ );
+ }
+
+ #[test]
+ fn test_try_launch_blocked_at_per_repo_cap() {
+ let temp_dir = TempDir::new().unwrap();
+ let mut config = make_test_config(&temp_dir);
+ config.agents.max_agents_per_repo = 2;
+
+ let mut state = State::load(&config).unwrap();
+ state
+ .add_agent(
+ "TASK-001".to_string(),
+ "TASK".to_string(),
+ "test-project".to_string(),
+ false,
+ )
+ .unwrap();
+ state
+ .add_agent(
+ "TASK-002".to_string(),
+ "TASK".to_string(),
+ "test-project".to_string(),
+ false,
+ )
+ .unwrap();
+
+ let state = State::load(&config).unwrap();
+ let project_count = state.project_agent_count("test-project");
+ let max_per_repo = config.effective_max_agents_per_repo();
- assert!(!project_busy, "Project should not be busy without agents");
+ assert!(
+ project_count >= max_per_repo,
+ "Should block third agent when cap is 2"
+ );
}
#[test]
@@ -939,3 +1006,670 @@ mod agent_switches {
);
}
}
+
+// ============================================
+// Ticket Creation Tests
+// ============================================
+
+mod ticket_creation {
+ use super::*;
+ use crate::queue::TicketCreator;
+ use crate::templates::TemplateType;
+ use std::collections::HashMap;
+
+ #[test]
+ fn test_headless_ticket_creation_writes_file_to_queue() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let creator = TicketCreator::new(&config);
+ let mut values = HashMap::new();
+ values.insert("project".to_string(), "test-project".to_string());
+ values.insert("summary".to_string(), "Add user auth".to_string());
+
+ let result = creator.create_ticket_headless(TemplateType::Task, &values);
+ assert!(result.is_ok(), "Ticket creation should succeed");
+
+ let filepath = result.unwrap();
+ assert!(filepath.exists(), "Ticket file should exist on disk");
+ assert!(
+ filepath.to_string_lossy().contains("queue"),
+ "Ticket should be created in the queue directory"
+ );
+ }
+
+ #[test]
+ fn test_headless_ticket_filename_contains_type_and_project() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let creator = TicketCreator::new(&config);
+ let mut values = HashMap::new();
+ values.insert("project".to_string(), "test-project".to_string());
+
+ let filepath = creator
+ .create_ticket_headless(TemplateType::Feature, &values)
+ .unwrap();
+ let filename = filepath.file_name().unwrap().to_string_lossy();
+
+ assert!(
+ filename.contains("FEAT"),
+ "Filename should contain the ticket type: {filename}"
+ );
+ assert!(
+ filename.contains("test-project"),
+ "Filename should contain the project: {filename}"
+ );
+ }
+
+ #[test]
+ fn test_headless_ticket_defaults_project_to_global() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let creator = TicketCreator::new(&config);
+ let values = HashMap::new(); // No project specified
+
+ let filepath = creator
+ .create_ticket_headless(TemplateType::Task, &values)
+ .unwrap();
+ let filename = filepath.file_name().unwrap().to_string_lossy();
+
+ assert!(
+ filename.contains("global"),
+ "Filename should default to 'global' when no project specified: {filename}"
+ );
+ }
+
+ #[test]
+ fn test_headless_ticket_content_is_not_empty() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let creator = TicketCreator::new(&config);
+ let mut values = HashMap::new();
+ values.insert("project".to_string(), "test-project".to_string());
+
+ let filepath = creator
+ .create_ticket_headless(TemplateType::Task, &values)
+ .unwrap();
+ let content = std::fs::read_to_string(&filepath).unwrap();
+
+ assert!(!content.is_empty(), "Ticket file should have content");
+ }
+
+ #[test]
+ fn test_created_ticket_appears_in_queue_listing() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let creator = TicketCreator::new(&config);
+ let mut values = HashMap::new();
+ values.insert("project".to_string(), "test-project".to_string());
+ values.insert("summary".to_string(), "Test listing".to_string());
+
+ creator
+ .create_ticket_headless(TemplateType::Task, &values)
+ .unwrap();
+
+ let queue = Queue::new(&config).unwrap();
+ let tickets = queue.list_queue().unwrap();
+
+ assert_eq!(tickets.len(), 1, "Queue should have the created ticket");
+ assert_eq!(tickets[0].ticket_type, "TASK");
+ }
+
+ #[test]
+ fn test_multiple_ticket_types_created_and_sorted_by_priority() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ // Create tickets of different types
+ let types_and_projects = [
+ (TemplateType::Feature, "test-project"),
+ (TemplateType::Fix, "test-project"),
+ (TemplateType::Task, "test-project"),
+ ];
+
+ for (template_type, project) in &types_and_projects {
+ let creator = TicketCreator::new(&config);
+ let mut values = HashMap::new();
+ values.insert("project".to_string(), project.to_string());
+ creator
+ .create_ticket_headless(*template_type, &values)
+ .unwrap();
+ }
+
+ let queue = Queue::new(&config).unwrap();
+ let tickets = queue.list_by_priority().unwrap();
+
+ assert_eq!(tickets.len(), 3, "Queue should have 3 tickets");
+ }
+}
+
+// ============================================
+// Ticket Directory Initialization Tests
+// ============================================
+
+mod ticket_initialization {
+ use super::*;
+
+ #[test]
+ fn test_queue_directories_exist_after_config_setup() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let tickets_path = config.tickets_path();
+ assert!(
+ tickets_path.join("queue").exists(),
+ "queue directory should exist"
+ );
+ assert!(
+ tickets_path.join("in-progress").exists(),
+ "in-progress directory should exist"
+ );
+ assert!(
+ tickets_path.join("completed").exists(),
+ "completed directory should exist"
+ );
+ assert!(
+ tickets_path.join("operator").exists(),
+ "operator directory should exist"
+ );
+ }
+
+ #[test]
+ fn test_queue_starts_empty() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let queue = Queue::new(&config).unwrap();
+ assert!(queue.list_queue().unwrap().is_empty());
+ assert!(queue.list_in_progress().unwrap().is_empty());
+ assert!(queue.list_completed().unwrap().is_empty());
+ }
+
+ #[test]
+ fn test_claim_ticket_moves_to_in_progress() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ // Create a ticket file in the queue
+ let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n";
+ let ticket_filename = "20241225-1200-TASK-test-project-test.md";
+ let queue_path = config.tickets_path().join("queue").join(ticket_filename);
+ std::fs::write(&queue_path, ticket_content).unwrap();
+
+ // Verify it's in the queue
+ let queue = Queue::new(&config).unwrap();
+ let tickets = queue.list_queue().unwrap();
+ assert_eq!(tickets.len(), 1);
+
+ // Claim (move to in-progress)
+ queue.claim_ticket(&tickets[0]).unwrap();
+
+ // Verify moved
+ assert!(queue.list_queue().unwrap().is_empty());
+ assert_eq!(queue.list_in_progress().unwrap().len(), 1);
+ }
+}
+
+// ============================================
+// Auto-Launch Decision Logic Tests
+// ============================================
+
+mod auto_launch_logic {
+ use super::*;
+
+ fn can_auto_launch(config: &Config, state: &State, queue: &Queue) -> bool {
+ if state.paused {
+ return false;
+ }
+ let running = state.running_agents().len();
+ let max = config.effective_max_agents();
+ if running >= max {
+ return false;
+ }
+ let tickets = queue.list_by_priority().unwrap_or_default();
+ if tickets.is_empty() {
+ return false;
+ }
+ // Check per-repo cap for the top ticket's project
+ let top = &tickets[0];
+ let project_count = state.project_agent_count(&top.project);
+ let max_per_repo = config.effective_max_agents_per_repo();
+ project_count < max_per_repo
+ }
+
+ #[test]
+ fn test_auto_launch_blocked_when_paused() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let mut state = State::load(&config).unwrap();
+ state.set_paused(true).unwrap();
+ let state = State::load(&config).unwrap();
+
+ // Add a ticket to queue
+ let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n";
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1200-TASK-test-project-test.md"),
+ ticket_content,
+ )
+ .unwrap();
+ let queue = Queue::new(&config).unwrap();
+
+ assert!(
+ !can_auto_launch(&config, &state, &queue),
+ "Should not auto-launch when paused"
+ );
+ }
+
+ #[test]
+ fn test_auto_launch_blocked_when_queue_empty() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+ let state = State::load(&config).unwrap();
+ let queue = Queue::new(&config).unwrap();
+
+ assert!(
+ !can_auto_launch(&config, &state, &queue),
+ "Should not auto-launch with empty queue"
+ );
+ }
+
+ #[test]
+ fn test_auto_launch_blocked_at_max_agents() {
+ let temp_dir = TempDir::new().unwrap();
+ let mut config = make_test_config(&temp_dir);
+ config.agents.max_parallel = 1;
+
+ let mut state = State::load(&config).unwrap();
+ state
+ .add_agent(
+ "TASK-001".to_string(),
+ "TASK".to_string(),
+ "test-project".to_string(),
+ false,
+ )
+ .unwrap();
+ let state = State::load(&config).unwrap();
+
+ let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n";
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1200-TASK-test-project-launch.md"),
+ ticket_content,
+ )
+ .unwrap();
+ let queue = Queue::new(&config).unwrap();
+
+ assert!(
+ !can_auto_launch(&config, &state, &queue),
+ "Should not auto-launch when at max agents"
+ );
+ }
+
+ #[test]
+ fn test_auto_launch_allowed_when_conditions_met() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+ let state = State::load(&config).unwrap();
+
+ let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n";
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1200-TASK-test-project-go.md"),
+ ticket_content,
+ )
+ .unwrap();
+ let queue = Queue::new(&config).unwrap();
+
+ assert!(
+ can_auto_launch(&config, &state, &queue),
+ "Should auto-launch when not paused, under capacity, with tickets"
+ );
+ }
+
+ #[test]
+ fn test_auto_launch_blocked_when_project_at_per_repo_cap() {
+ let temp_dir = TempDir::new().unwrap();
+ let mut config = make_test_config(&temp_dir);
+ config.agents.max_parallel = 5;
+ config.agents.max_agents_per_repo = 1;
+
+ let mut state = State::load(&config).unwrap();
+ state
+ .add_agent(
+ "TASK-existing".to_string(),
+ "TASK".to_string(),
+ "testproject".to_string(),
+ false,
+ )
+ .unwrap();
+ let state = State::load(&config).unwrap();
+
+ // Project name in filename must be [a-z0-9]+ (no hyphens) to match parser regex
+ let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n";
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1200-TASK-testproject-blocked.md"),
+ ticket_content,
+ )
+ .unwrap();
+ let queue = Queue::new(&config).unwrap();
+
+ assert!(
+ !can_auto_launch(&config, &state, &queue),
+ "Should not auto-launch when project is at per-repo capacity"
+ );
+ }
+}
+
+// ============================================
+// Status Action Tests
+// ============================================
+
+mod status_actions {
+ use crate::ui::status_panel::StatusAction;
+
+ #[test]
+ fn test_status_action_none_is_noop() {
+ let action = StatusAction::None;
+ assert!(matches!(action, StatusAction::None));
+ }
+
+ #[test]
+ fn test_status_action_toggle_section_carries_id() {
+ use crate::ui::status_panel::SectionId;
+ let action = StatusAction::ToggleSection(SectionId::Configuration);
+ if let StatusAction::ToggleSection(id) = action {
+ assert!(matches!(id, SectionId::Configuration));
+ } else {
+ panic!("Expected ToggleSection");
+ }
+ }
+
+ #[test]
+ fn test_open_in_browser_constructs_url() {
+ let port = 8080;
+ let url = format!("http://localhost:{port}/swagger-ui/");
+ assert_eq!(url, "http://localhost:8080/swagger-ui/");
+ }
+}
+
+// ============================================
+// Queue Priority Ordering Tests
+// ============================================
+
+mod queue_priority {
+ use super::*;
+
+ #[test]
+ fn test_priority_ordering_inv_before_feat() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ // Create tickets with different types
+ // INV should sort before FEAT per priority_order config
+ let feat_content = "---\npriority: P2-medium\n---\n# Feature\n\nContent\n";
+ let inv_content = "---\npriority: P0-critical\n---\n# Investigation\n\nContent\n";
+
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1200-FEAT-test-project-feat.md"),
+ feat_content,
+ )
+ .unwrap();
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1201-INV-test-project-inv.md"),
+ inv_content,
+ )
+ .unwrap();
+
+ let queue = Queue::new(&config).unwrap();
+ let tickets = queue.list_by_priority().unwrap();
+
+ assert_eq!(tickets.len(), 2);
+ // INV has higher priority (lower index) in default priority_order
+ let first_type = &tickets[0].ticket_type;
+ let second_type = &tickets[1].ticket_type;
+
+ let first_idx = config.priority_index(first_type);
+ let second_idx = config.priority_index(second_type);
+ assert!(
+ first_idx <= second_idx,
+ "First ticket type '{first_type}' (idx {first_idx}) should have equal or higher priority than '{second_type}' (idx {second_idx})"
+ );
+ }
+
+ #[test]
+ fn test_same_type_tickets_sorted_fifo_by_timestamp() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let content = "---\npriority: P2-medium\n---\n# Task\n\nContent\n";
+
+ // Earlier timestamp first
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-0800-TASK-test-project-early.md"),
+ content,
+ )
+ .unwrap();
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1400-TASK-test-project-late.md"),
+ content,
+ )
+ .unwrap();
+
+ let queue = Queue::new(&config).unwrap();
+ let tickets = queue.list_by_priority().unwrap();
+
+ assert_eq!(tickets.len(), 2);
+ assert!(
+ tickets[0].timestamp < tickets[1].timestamp,
+ "Earlier timestamp should sort first: {} vs {}",
+ tickets[0].timestamp,
+ tickets[1].timestamp
+ );
+ }
+
+ #[test]
+ fn test_unknown_ticket_type_sorts_last() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let task_content = "---\npriority: P2-medium\n---\n# Task\n\nContent\n";
+ let unknown_content = "---\npriority: P2-medium\n---\n# Custom\n\nContent\n";
+
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1200-TASK-test-project-task.md"),
+ task_content,
+ )
+ .unwrap();
+ std::fs::write(
+ config
+ .tickets_path()
+ .join("queue/20241225-1200-CUSTOM-test-project-custom.md"),
+ unknown_content,
+ )
+ .unwrap();
+
+ let queue = Queue::new(&config).unwrap();
+ let tickets = queue.list_by_priority().unwrap();
+
+ assert_eq!(tickets.len(), 2);
+ // TASK is in priority_order, CUSTOM is not (sorts to usize::MAX)
+ assert_eq!(
+ tickets[0].ticket_type, "TASK",
+ "Known type should sort before unknown"
+ );
+ }
+}
+
+// ============================================
+// Agent State Lifecycle Tests
+// ============================================
+
+mod agent_lifecycle {
+ use super::*;
+
+ #[test]
+ fn test_agent_added_with_correct_metadata() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let mut state = State::load(&config).unwrap();
+ let agent_id = state
+ .add_agent(
+ "FEAT-042".to_string(),
+ "FEAT".to_string(),
+ "test-project".to_string(),
+ false,
+ )
+ .unwrap();
+
+ let state = State::load(&config).unwrap();
+ let agent = state
+ .agents
+ .iter()
+ .find(|a| a.id == agent_id)
+ .expect("Agent should exist");
+
+ assert_eq!(agent.ticket_id, "FEAT-042");
+ assert_eq!(agent.ticket_type, "FEAT");
+ assert_eq!(agent.project, "test-project");
+ }
+
+ #[test]
+ fn test_multiple_agents_tracked_independently() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let mut state = State::load(&config).unwrap();
+ let id1 = state
+ .add_agent(
+ "TASK-001".to_string(),
+ "TASK".to_string(),
+ "project-a".to_string(),
+ false,
+ )
+ .unwrap();
+ let id2 = state
+ .add_agent(
+ "FEAT-002".to_string(),
+ "FEAT".to_string(),
+ "project-b".to_string(),
+ false,
+ )
+ .unwrap();
+
+ let state = State::load(&config).unwrap();
+ assert_eq!(state.running_agents().len(), 2);
+ assert_ne!(id1, id2, "Agent IDs should be unique");
+ }
+
+ #[test]
+ fn test_project_agent_count_tracks_per_project() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let mut state = State::load(&config).unwrap();
+ state
+ .add_agent(
+ "TASK-001".to_string(),
+ "TASK".to_string(),
+ "project-a".to_string(),
+ false,
+ )
+ .unwrap();
+ state
+ .add_agent(
+ "TASK-002".to_string(),
+ "TASK".to_string(),
+ "project-a".to_string(),
+ false,
+ )
+ .unwrap();
+ state
+ .add_agent(
+ "TASK-003".to_string(),
+ "TASK".to_string(),
+ "project-b".to_string(),
+ false,
+ )
+ .unwrap();
+
+ let state = State::load(&config).unwrap();
+ assert_eq!(state.project_agent_count("project-a"), 2);
+ assert_eq!(state.project_agent_count("project-b"), 1);
+ assert_eq!(state.project_agent_count("project-c"), 0);
+ }
+
+ #[test]
+ fn test_agent_session_update_and_lookup() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let mut state = State::load(&config).unwrap();
+ let agent_id = state
+ .add_agent(
+ "TASK-session".to_string(),
+ "TASK".to_string(),
+ "test-project".to_string(),
+ false,
+ )
+ .unwrap();
+
+ state
+ .update_agent_session(&agent_id, "op-TASK-session")
+ .unwrap();
+
+ let state = State::load(&config).unwrap();
+ let agent = state.agent_by_session("op-TASK-session");
+ assert!(agent.is_some(), "Should find agent by session name");
+ assert_eq!(agent.unwrap().ticket_id, "TASK-session");
+ }
+
+ #[test]
+ fn test_remove_agent_by_session_cleans_state() {
+ let temp_dir = TempDir::new().unwrap();
+ let config = make_test_config(&temp_dir);
+
+ let mut state = State::load(&config).unwrap();
+ let agent_id = state
+ .add_agent(
+ "TASK-remove".to_string(),
+ "TASK".to_string(),
+ "test-project".to_string(),
+ false,
+ )
+ .unwrap();
+ state
+ .update_agent_session(&agent_id, "op-TASK-remove")
+ .unwrap();
+
+ assert_eq!(state.running_agents().len(), 1);
+
+ let mut state = State::load(&config).unwrap();
+ state.remove_agent_by_session("op-TASK-remove").unwrap();
+
+ let state = State::load(&config).unwrap();
+ assert_eq!(state.running_agents().len(), 0);
+ assert!(state.agent_by_session("op-TASK-remove").is_none());
+ }
+}
diff --git a/src/app/tickets.rs b/src/app/tickets.rs
index f4797ec..fa3d1cd 100644
--- a/src/app/tickets.rs
+++ b/src/app/tickets.rs
@@ -3,7 +3,6 @@ use std::fs;
use crate::agents::{generate_status_script, generate_tmux_conf};
use crate::agents::{AgentTicketCreator, AssessTicketCreator};
-use crate::backstage::scaffold::{BackstageScaffold, ScaffoldOptions};
use crate::queue::TicketCreator;
use crate::setup::filter_schema_fields;
use crate::state::State;
@@ -171,26 +170,6 @@ impl App {
}
}
- // Generate Backstage scaffold
- let backstage_path = self.config.backstage_path();
- if !BackstageScaffold::exists(&backstage_path) {
- let options = ScaffoldOptions::from_config(&self.config);
- let scaffold = BackstageScaffold::new(backstage_path, options);
- match scaffold.generate() {
- Ok(result) => {
- tracing::info!(
- created = result.created.len(),
- skipped = result.skipped.len(),
- "Generated Backstage scaffold: {}",
- result.summary()
- );
- }
- Err(e) => {
- tracing::warn!("Failed to generate Backstage scaffold: {}", e);
- }
- }
- }
-
Ok(())
}
@@ -297,7 +276,7 @@ impl App {
return Ok(());
}
- // Create ASSESS ticket for Backstage catalog assessment
+ // Create ASSESS ticket for catalog assessment
let ticket_result = AssessTicketCreator::create_assess_ticket(
&result.project_path,
&result.project,
diff --git a/src/backstage/branding.rs b/src/backstage/branding.rs
deleted file mode 100644
index 920bb15..0000000
--- a/src/backstage/branding.rs
+++ /dev/null
@@ -1,119 +0,0 @@
-//! Static branding defaults for Backstage scaffold.
-//!
-//! Provides default branding configuration and assets. Users can edit
-//! generated files manually after scaffold to customize branding.
-
-#![allow(dead_code)]
-
-use serde::{Deserialize, Serialize};
-
-/// Default branding configuration for Backstage app-config.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct BrandingDefaults {
- /// Portal title displayed in header
- pub title: String,
- /// Subtitle shown below title
- pub subtitle: String,
- /// Primary brand color (hex)
- pub primary_color: String,
- /// Secondary brand color (hex)
- pub secondary_color: String,
- /// Optional logo path relative to branding directory
- pub logo_path: Option,
- /// Optional favicon path relative to branding directory
- pub favicon_path: Option,
-}
-
-impl Default for BrandingDefaults {
- fn default() -> Self {
- Self {
- title: "Developer Portal".to_string(),
- subtitle: "Powered by Backstage".to_string(),
- primary_color: "#0052CC".to_string(), // Backstage blue
- secondary_color: "#172B4D".to_string(),
- logo_path: Some("logo.svg".to_string()),
- favicon_path: None,
- }
- }
-}
-
-impl BrandingDefaults {
- /// Create branding with a custom portal name.
- pub fn with_name(name: &str) -> Self {
- Self {
- title: name.to_string(),
- ..Default::default()
- }
- }
-
- /// Generate the app section YAML for branding.
- pub fn to_app_config_yaml(&self) -> String {
- format!(
- r#"app:
- title: {}
- branding:
- theme:
- light:
- primaryColor: "{}"
- secondaryColor: "{}"
- dark:
- primaryColor: "{}"
- secondaryColor: "{}""#,
- self.title,
- self.primary_color,
- self.secondary_color,
- self.primary_color,
- self.secondary_color
- )
- }
-}
-
-/// Static branding assets embedded in the binary.
-pub struct BrandingAssets;
-
-impl BrandingAssets {
- /// Default logo SVG content - a simple developer portal icon.
- pub fn default_logo_svg() -> &'static str {
- r##""##
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_branding_defaults() {
- let branding = BrandingDefaults::default();
- assert_eq!(branding.title, "Developer Portal");
- assert!(branding.primary_color.starts_with('#'));
- assert!(branding.secondary_color.starts_with('#'));
- }
-
- #[test]
- fn test_branding_with_name() {
- let branding = BrandingDefaults::with_name("GBQR Portal");
- assert_eq!(branding.title, "GBQR Portal");
- assert_eq!(branding.subtitle, "Powered by Backstage");
- }
-
- #[test]
- fn test_app_config_yaml() {
- let branding = BrandingDefaults::default();
- let yaml = branding.to_app_config_yaml();
- assert!(yaml.contains("Developer Portal"));
- assert!(yaml.contains("#0052CC"));
- assert!(yaml.contains("primaryColor"));
- }
-
- #[test]
- fn test_default_logo_svg() {
- let svg = BrandingAssets::default_logo_svg();
- assert!(svg.contains("