diff --git a/.agents/plan/local-agent-profiles-and-cli-plan.md b/.agents/plan/local-agent-profiles-and-cli-plan.md new file mode 100644 index 00000000..27c82142 --- /dev/null +++ b/.agents/plan/local-agent-profiles-and-cli-plan.md @@ -0,0 +1,96 @@ +# Subagent profiles and DevSpace agent CLI plan + +## Decision + +Subagent profiles describe roles over built-in coding-agent providers. +DevSpace owns provider invocation and lifecycle. Custom CLI-backed agents, +provider action objects, and model-visible backend details are out of scope for +v1. + +The model-facing workflow stays small: + +```bash +devspace agents ls +devspace agents run "" +devspace agents show +``` + +Profile discovery happens through the compact catalog returned by +`open_workspace`. `devspace agents ls` lists existing subagent sessions for the +current workspace; it does not list profile definitions. + +## Profile schema + +Profiles are discovered from: + +- `~/.devspace/agents/*.md` +- project `.devspace/agents/*.md` + +Supported frontmatter fields: + +```yaml +schema: devspace-agent/v1 +name: reviewer +description: Read-only reviewer for bugs, security risks, and missing tests. +provider: codex +model: gpt-5.4 +disabled: false +``` + +Supported providers: + +- `codex` +- `claude` +- `opencode` +- `pi` +- `cursor` +- `copilot` + +Removed from v1 profile schema: + +- `backend` +- `command` +- `mode` +- `permissions` +- `actions` + +## Provider mapping + +DevSpace maps provider ids to native integrations: + +- `codex`: Codex SDK +- `claude`: Claude Code SDK +- `opencode`: OpenCode SDK +- `pi`: Pi RPC mode +- `cursor`: ACP +- `copilot`: ACP + +The adapter registry is the internal seam future MCP tools can reuse if we move +from skill plus CLI guidance to first-class MCP agent tools. + +## Model exposure + +`open_workspace` exposes only compact profile metadata: + +```json +{ + "name": "reviewer", + "description": "Read-only reviewer for bugs, security risks, and missing tests.", + "provider": "codex", + "model": "gpt-5.4" +} +``` + +The profile body, provider protocol, raw provider transcript, and adapter +details stay outside the default model context. + +Shell calls launched through DevSpace receive `DEVSPACE_WORKSPACE_ID` and +`DEVSPACE_WORKSPACE_ROOT`, so `devspace agents ls` can scope itself without the +model passing workspace flags. + +## Non-goals + +- Custom or arbitrary subagent commands. +- Provider-specific action DSLs. +- Exposing raw provider transcripts by default. +- Tracking changed files or tests from provider output. diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 20709160..382911b6 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -1,83 +1,38 @@ -# Local agent profile schema +# Subagent profile schema -DevSpace local agent profiles are user-owned markdown files with YAML front matter. -They describe how a local coding-agent CLI can be used as a worker under ChatGPT -supervision. +DevSpace agent profiles are user-owned markdown files with YAML +frontmatter. They describe roles such as reviewer, explorer, or implementer. +DevSpace owns provider invocation. -The packaged files in `examples/agents/` are starter templates only. DevSpace does -not currently parse, load, activate, or run local agent profile definitions. A -future profile loader can define the user-owned directory for active profiles. +Profiles are discovered from: + +- `~/.devspace/agents/*.md` +- `.devspace/agents/*.md` + +Packaged files under `examples/agents/` are starter templates only. ## Minimal shape ```md --- schema: devspace-agent/v1 -name: codex-explorer -description: Read-only Codex agent for bounded codebase questions. +name: reviewer +description: Read-only reviewer for bugs, security risks, and missing tests. provider: codex -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: codex - args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: codex - args: - - exec - - resume - - "{externalSessionId}" - - --json - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +model: gpt-5.4 +disabled: false --- -Use this agent for bounded read-only codebase investigation. +You are a read-only reviewer. Do not edit files. +Focus on correctness, security, test gaps, and maintainability. +Cite files and return concise findings. ``` -## Front matter fields +## Frontmatter fields ### `schema` -Required schema identifier. - -Current value: +Optional schema identifier: ```yaml schema: devspace-agent/v1 @@ -85,258 +40,104 @@ schema: devspace-agent/v1 ### `name` -Stable profile identifier shown to the model and user. +Stable profile identifier shown to the model and accepted by: -Use lowercase kebab-case names, for example: - -```yaml -name: codex-explorer +```bash +devspace agents run "" ``` +Use lowercase kebab-case names. If omitted, DevSpace uses the filename without +`.md`. + ### `description` -Short human-readable purpose. This should help the supervising model decide when -the agent is appropriate. +Required short purpose. This is exposed by `open_workspace` so the supervising +model can choose the right profile. ### `provider` -The local agent family or CLI provider. - -Examples: +Required built-in provider id: ```yaml provider: codex provider: claude provider: opencode -provider: cursor provider: pi +provider: cursor provider: copilot ``` -### `backend` - -Execution backend. The near-term templates use CLI-backed agents: - -```yaml -backend: cli -``` - -Future profiles may support protocol-backed backends such as ACP without changing -the high-level profile name. - -## Capabilities - -Capabilities describe what the worker is allowed or expected to do. - -```yaml -capabilities: - read: true - write: false - shell: false - background: true - resume: true -``` - -- `read`: the agent can inspect project files. -- `write`: the agent may modify files. -- `shell`: the agent may run shell commands. -- `background`: the agent can be started as a long-running process. -- `resume`: the agent supports follow-up prompts against an existing session. - -These fields are descriptive in the current template-only stage. A future parser -should validate them before rendering an available-agent catalog or exposing -runtime tools. - -## Workspace policy - -```yaml -workspace: - default: current - isolation: user_decides - writeMode: allowed -``` - -- `default`: default workspace source. Current templates use `current`. -- `isolation`: whether to use the same checkout, a branch, a worktree, or let the - user decide. -- `writeMode`: whether writes are allowed. - -Recommended values: - -```yaml -isolation: none -isolation: user_decides - -writeMode: read_only -writeMode: allowed -``` - -Use read-only profiles for review, lookup, and second opinions. Use write-capable -profiles only when the user explicitly asks a local agent to implement or edit. - -## Actions - -Actions define lifecycle commands and strategies. - -### `actions.start` - -Starts the local agent. - -```yaml -actions: - start: - command: codex - args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" - background: true - output: jsonl -``` - -Use `command` plus `args` arrays. Do not use free-form shell strings. - -Good: - -```yaml -command: codex -args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" -``` - -Avoid: - -```yaml -run: "codex exec --json -C {workspace} {prompt}" -``` - -Argv arrays are easier to validate, escape, log, review, and migrate to future -backends. +Unsupported or custom providers are rejected. DevSpace maps providers to their +native integration: -### `actions.followup` +- `codex`: Codex SDK +- `claude`: Claude Code SDK +- `opencode`: OpenCode SDK +- `pi`: Pi RPC mode +- `cursor`: ACP +- `copilot`: ACP -Defines how to continue a previous worker session. +### `model` -Supported strategy names used by the starter templates: +Optional provider model id or alias. ```yaml -strategy: resume_command -strategy: fresh_prompt_with_context +model: gpt-5.4 +model: sonnet ``` -Use `resume_command` when the provider has an explicit resume/session flag. Use -`fresh_prompt_with_context` when follow-up work must include previous summaries, -review findings, and diff context in a new prompt. +### `disabled` -### `actions.read` - -Defines how DevSpace should read worker output. - -The starter templates use: - -```yaml -read: - strategy: devspace_process_poll -``` - -### `actions.cancel` - -Defines how DevSpace should interrupt a running worker. - -The starter templates use: +Optional boolean. Disabled profiles are not exposed. ```yaml -cancel: - strategy: devspace_process_signal - signal: SIGINT +disabled: true ``` -Prefer interruption over deleting provider session history. - -### `actions.diff` - -Defines how DevSpace can inspect worker file changes. +## Markdown body -Read-only profiles should use: +The body is the profile prompt prefix DevSpace prepends when launching that +profile. It is not included in `open_workspace` by default. -```yaml -diff: - strategy: none -``` +Recommended body content: -Write-capable profiles should use: - -```yaml -diff: - strategy: git_diff -``` +- When to use this profile. +- Whether the worker should act read-only or may make changes. +- Output format. +- Review or testing expectations. -## Placeholders +## Model-facing workflow -The examples use placeholders that a future runtime can substitute safely: +The Subagent skill teaches only: -```text -{workspace} -{prompt} -{externalSessionId} +```bash +devspace agents ls +devspace agents run "" +devspace agents show ``` -- `{workspace}`: absolute workspace path selected by DevSpace or the user. -- `{prompt}`: focused worker prompt created by the supervising model. -- `{externalSessionId}`: provider session id returned by a previous agent run. - -## Safety policy +`open_workspace` exposes compact profile metadata: -```yaml -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +```json +{ + "name": "reviewer", + "description": "Read-only reviewer for bugs, security risks, and missing tests.", + "provider": "codex", + "model": "gpt-5.4" +} ``` -Recommended write-capable profile safety: +`devspace agents ls` lists existing subagent sessions for the current workspace; +it does not list profile definitions. -```yaml -safety: - requireExplicitUserIntent: true - allowWrites: true - requireDiffReview: true - requireTestsOrExplanation: true -``` - -Profiles should make user intent and review requirements explicit. DevSpace should -not silently delegate work to local agents, and the supervising model should not -present worker output as verified until it has reviewed the result. - -## Markdown body - -The markdown body should explain when to use the agent and provide a worker prompt -template. - -Recommended sections: - -- `Use this agent when ...` -- `Good tasks:` -- `Worker prompt style:` -- A final report format that the supervising model can review. - -The body is model-facing guidance. Keep it practical and concise. +The full profile body stays out of the model context until DevSpace launches the +profile. ## Current non-goals -The current examples do not add: - -- local agent profile parsing. -- automatic activation of packaged examples. -- `devspace agents init`. -- generated available-agent catalogs. -- first-class agent runtime tools. -- ACP-backed execution. - -Those can be added in later PRs without changing the template intent. +- Custom or arbitrary CLI-backed agents. +- Inferring changed files, tests, or diffs from worker output. +- Exposing raw provider transcripts by default. +- Teaching the model provider-specific CLIs. +- First-class MCP agent tools. Future tools should wrap the same provider + adapter registry used by `devspace agents`. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 2a734337..4b73c19f 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -87,13 +87,19 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: -- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -Example local coding-agent profiles are packaged under `examples/agents/` for -users who want starter templates. These examples are inert: DevSpace does not -currently parse, load, activate, or run local agent profile definitions. +When Subagents are enabled, DevSpace discovers agent profiles +from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. +`open_workspace` exposes a compact catalog with profile names, descriptions, +providers, and optional models so the model can choose a configured agent +without seeing provider-specific launch details. + +Example profiles are packaged under `examples/agents/` for users who want +starter templates. Copy or adapt them into one of the active profile directories +before use. Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. @@ -106,8 +112,11 @@ Skill paths may be outside the workspace. DevSpace only permits reading: - files under a skill directory after that skill's `SKILL.md` has been read Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Set -`DEVSPACE_LOCAL_AGENTS=1` to expose the experimental `local-agent-delegation` -skill. +`DEVSPACE_SUBAGENTS=1` to expose the experimental subagent catalog and +`subagent-delegation` skill. That skill teaches the minimal +`devspace agents ls`, `devspace agents run`, and `devspace agents show` +workflow. The catalog comes from `open_workspace`; `devspace agents ls` lists +existing subagent sessions for that workspace. ## Tool Names diff --git a/docs/configuration.md b/docs/configuration.md index 29cf1169..81f0bb0d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,7 +99,7 @@ sessions. | Variable | Purpose | | --- | --- | | `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | -| `DEVSPACE_LOCAL_AGENTS` | Set to `1` to expose the local-agent delegation skill. Experimental and disabled by default. | +| `DEVSPACE_SUBAGENTS` | Set to `1` to expose configured agent profiles as Subagents. Experimental and disabled by default. | | `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | @@ -111,13 +111,26 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: -- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -Starter local coding-agent profile templates are available under -`examples/agents/`. These files are inert examples: DevSpace does not currently -parse, load, activate, or run local agent profile definitions. +When Subagents are enabled, DevSpace discovers agent profiles +from: + +- `~/.devspace/agents/*.md` +- project `.devspace/agents/*.md` + +`open_workspace` returns a compact catalog containing profile names, +descriptions, providers, and optional models so the host model can choose an +agent without reading provider-specific launch details. `devspace agents ls` +lists existing subagent sessions for the current workspace, scoped by the +workspace environment injected into shell commands. The `subagent-delegation` +skill teaches the model to use only the minimal `devspace agents ls`, +`devspace agents run`, and `devspace agents show` workflow. + +Starter profile templates are available under `examples/agents/`. Copy or adapt +them into one of the active profile directories before use. Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. diff --git a/docs/gotchas.md b/docs/gotchas.md index e6874d1b..779e37ef 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,14 +201,21 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: -- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -Packaged local-agent examples under `examples/agents/` are inert templates only. -DevSpace does not currently parse, load, activate, or run local agent profile +When `DEVSPACE_SUBAGENTS=1`, DevSpace loads agent profiles from +`~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a +compact profile catalog through `open_workspace`. The bundled +`subagent-delegation` skill keeps the model-facing workflow to +`devspace agents ls`, `devspace agents run`, and `devspace agents show`. +`devspace agents ls` lists existing subagent sessions, not profile definitions. +Packaged agent profile examples under `examples/agents/` are starter templates. +Copy or adapt them into one of the active profile directories before use. + Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. If a skill appears in `open_workspace`, the model must read that skill's diff --git a/examples/agents/claude-implementer.md b/examples/agents/claude-implementer.md index 235d7e5f..f4f2fb3b 100644 --- a/examples/agents/claude-implementer.md +++ b/examples/agents/claude-implementer.md @@ -1,104 +1,27 @@ --- schema: devspace-agent/v1 name: claude-implementer -description: Claude Code implementation worker for larger edits, refactors, and follow-up loops. +description: Claude Code profile for larger implementation, refactor, and repair tasks. provider: claude -backend: cli - -capabilities: - read: true - write: true - shell: true - background: true - resume: true - -workspace: - default: current - isolation: user_decides - writeMode: allowed - -actions: - start: - command: claude - args: - - -p - - --output-format - - stream-json - - --verbose - - "{prompt}" - background: true - output: stream-json - - followup: - strategy: resume_command - command: claude - args: - - --resume - - "{externalSessionId}" - - -p - - --output-format - - stream-json - - --verbose - - "{prompt}" - - list: - command: claude - args: - - agents - - --json - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: git_diff - -safety: - requireExplicitUserIntent: true - allowWrites: true - requireDiffReview: true - requireTestsOrExplanation: true +model: sonnet --- -Use this agent when the task benefits from a stronger implementation worker. - -Good tasks: - -- Multi-file implementation. -- Refactor with clear boundaries. -- Test repair loop. -- Apply detailed review comments. -- Continue an already-started implementation. +You are a local Claude Code implementation worker under supervisor review. -Worker prompt style: - -```text -You are a local Claude Code implementation worker under ChatGPT supervision. - -Goal: - - -Context: - - -Plan: - - -Constraints: - +Use this profile for multi-file implementation, careful refactors, and test +repair loops when the user asked for delegated implementation. Rules: + - Keep changes focused. -- Do not rewrite unrelated code. - Preserve public behavior unless asked. +- Do not rewrite unrelated code. - Run or explain relevant tests. - Return a concise final report. Final report format: + +```text summary: files_changed: tests_run: diff --git a/examples/agents/codex-explorer.md b/examples/agents/codex-explorer.md index 5ca22120..78b1de4a 100644 --- a/examples/agents/codex-explorer.md +++ b/examples/agents/codex-explorer.md @@ -1,88 +1,26 @@ --- schema: devspace-agent/v1 name: codex-explorer -description: Read-only Codex agent for bounded codebase questions and architecture exploration. +description: Read-only Codex profile for bounded codebase questions and architecture exploration. provider: codex -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: codex - args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: codex - args: - - exec - - resume - - "{externalSessionId}" - - --json - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +model: gpt-5.4 --- -Use this agent when the user wants a bounded read-only investigation, second opinion, -or explanation of a code path. - -Good tasks: - -- Find where a feature is implemented. -- Explain an architecture boundary. -- Review a module without changing files. -- Identify likely files for a future change. - -Worker prompt style: - -```text You are a read-only codebase explorer. -Question: - - -Scope: - +Use this profile for bounded investigation, second opinions, and explanations of +code paths. Rules: + - Do not modify files. - Cite file paths and symbols. - Separate facts from guesses. - Keep the answer concise. Final report format: + +```text answer: evidence: relevant_files: diff --git a/examples/agents/codex-worker.md b/examples/agents/codex-worker.md index 00d89b02..cbae95c2 100644 --- a/examples/agents/codex-worker.md +++ b/examples/agents/codex-worker.md @@ -1,99 +1,26 @@ --- schema: devspace-agent/v1 name: codex-worker -description: Codex implementation worker for focused, user-approved coding tasks. +description: Codex implementation profile for focused, user-approved coding tasks. provider: codex -backend: cli - -capabilities: - read: true - write: true - shell: true - background: true - resume: true - -workspace: - default: current - isolation: user_decides - writeMode: allowed - -actions: - start: - command: codex - args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: codex - args: - - exec - - resume - - "{externalSessionId}" - - --json - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: git_diff - -safety: - requireExplicitUserIntent: true - allowWrites: true - requireDiffReview: true - requireTestsOrExplanation: true +model: gpt-5.4 --- -Use this agent when ChatGPT has already planned a focused implementation and the -user wants Codex to execute it locally. - -Good tasks: - -- Implement a small feature from a clear plan. -- Fix a bug with known reproduction steps. -- Add tests for an existing code path. -- Apply review comments. +You are a local implementation worker under supervisor review. -Worker prompt style: - -```text -You are a local implementation worker under ChatGPT supervision. - -Goal: - - -Plan: - - -Constraints: - - -Files to focus: - - -Tests to run: - +Use this profile only for focused tasks with clear acceptance criteria. Rules: + - Keep changes focused. -- Follow existing project style. +- Follow the existing project style. - Do not perform unrelated refactors. - Do not hide failures. -- Return a final report. +- Report tests run and blockers. Final report format: + +```text summary: files_changed: tests_run: diff --git a/examples/agents/copilot-reviewer.md b/examples/agents/copilot-reviewer.md index 640b405a..e6ebd176 100644 --- a/examples/agents/copilot-reviewer.md +++ b/examples/agents/copilot-reviewer.md @@ -1,92 +1,25 @@ --- schema: devspace-agent/v1 name: copilot-reviewer -description: GitHub Copilot CLI reviewer for read-only code questions and review passes. +description: GitHub Copilot read-only profile for code questions and review passes. provider: copilot -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: copilot - args: - - --model - - auto - - -p - - "{prompt}" - - --output-format - - json - background: true - output: jsonl - - followup: - strategy: resume_command - command: copilot - args: - - --model - - auto - - -p - - "{prompt}" - - --resume - - "{externalSessionId}" - - --output-format - - json - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true --- -Use this agent when the user wants a GitHub Copilot-powered second opinion, -review, or codebase answer. - -Good tasks: +You are a read-only Copilot reviewer under supervisor review. -- Review changed files. -- Find likely bug sources. -- Explain repository structure. -- Suggest tests. - -Worker prompt style: - -```text -You are a read-only Copilot reviewer under ChatGPT supervision. - -Question: - - -Scope: - +Use this profile for second opinions, changed-file review, likely bug sources, +and test suggestions. Rules: + - Do not modify files. - Cite exact files and symbols. - Return concise findings. - Separate facts from guesses. Final report format: + +```text answer: findings: evidence: diff --git a/examples/agents/cursor-agent-worker.md b/examples/agents/cursor-agent-worker.md index 89be2b17..91fffc9c 100644 --- a/examples/agents/cursor-agent-worker.md +++ b/examples/agents/cursor-agent-worker.md @@ -1,91 +1,28 @@ --- schema: devspace-agent/v1 name: cursor-agent-worker -description: Cursor Agent worker for fast implementation or review using local Cursor CLI. +description: Cursor Agent profile for fast implementation or UI-oriented review. provider: cursor -backend: cli - -capabilities: - read: true - write: true - shell: true - background: true - resume: false - -workspace: - default: current - isolation: user_decides - writeMode: allowed - -actions: - start: - command: cursor-agent - args: - - -p - - --output-format - - stream-json - - --trust - - "{prompt}" - background: true - output: stream-json - - followup: - strategy: fresh_prompt_with_context - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: git_diff - -safety: - requireExplicitUserIntent: true - allowWrites: true - requireDiffReview: true - requireTestsOrExplanation: true --- -Use this agent when the user wants Cursor's local agent/model to quickly execute -or inspect a task. - -Good tasks: - -- Fast implementation pass. -- UI/UX-oriented code review. -- Alternative implementation idea. -- Lightweight refactor. +You are a local Cursor Agent worker under supervisor review. -Worker prompt style: - -```text -You are a local Cursor Agent worker under ChatGPT supervision. - -Goal: - - -Context: - - -Plan: - +Use this profile for fast implementation passes, UI-oriented code review, +alternative implementation ideas, and lightweight refactors. Rules: + - Keep changes focused. - Do not make unrelated edits. - Preserve existing style. - Report tests and blockers. Final report format: + +```text summary: files_changed: tests_run: blockers: follow_up_needed: ``` - -Because this profile uses `fresh_prompt_with_context`, include the previous worker -summary and review findings when sending follow-up work. diff --git a/examples/agents/opencode-explorer.md b/examples/agents/opencode-explorer.md index 9a86ce6a..beb52865 100644 --- a/examples/agents/opencode-explorer.md +++ b/examples/agents/opencode-explorer.md @@ -1,91 +1,25 @@ --- schema: devspace-agent/v1 name: opencode-explorer -description: OpenCode read-only explorer for fast codebase lookup and bounded questions. +description: OpenCode read-only profile for fast lookup and bounded codebase questions. provider: opencode -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: opencode - args: - - run - - --format - - json - - --dir - - "{workspace}" - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: opencode - args: - - run - - --format - - json - - --session - - "{externalSessionId}" - - --dir - - "{workspace}" - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true --- -Use this agent for fast, read-only codebase exploration. - -Good tasks: - -- Find relevant files. -- Explain a subsystem. -- Identify test coverage gaps. -- Compare possible implementation locations. - -Worker prompt style: - -```text You are a read-only OpenCode explorer. -Question: - - -Scope: - +Use this profile for fast codebase exploration, relevant-file discovery, and +small architecture questions. Rules: + - Do not modify files. - Cite exact file paths. - Prefer concise findings. - State uncertainty. Final report format: + +```text answer: evidence: relevant_files: diff --git a/examples/agents/pi-reviewer.md b/examples/agents/pi-reviewer.md index 21e842e9..67eded03 100644 --- a/examples/agents/pi-reviewer.md +++ b/examples/agents/pi-reviewer.md @@ -1,87 +1,25 @@ --- schema: devspace-agent/v1 name: pi-reviewer -description: Pi read-only reviewer for quick code review and targeted questions. +description: Pi read-only profile for quick code review and targeted questions. provider: pi -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: pi - args: - - -p - - --mode - - json - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: pi - args: - - -p - - --mode - - json - - --session-id - - "{externalSessionId}" - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true --- -Use this agent for lightweight review and targeted read-only investigation. - -Good tasks: - -- Review a diff. -- Find possible bugs. -- Explain a small subsystem. -- Check whether tests cover an edge case. - -Worker prompt style: - -```text You are a read-only local code reviewer. -Question: - - -Scope: - +Use this profile for lightweight review, risk checks, and targeted codebase +questions. Rules: + - Do not modify files. - Cite evidence. - Focus on actionable findings. -- Avoid broad rewrites. +- Avoid broad rewrite suggestions. Final report format: + +```text findings: evidence: risk_level: diff --git a/package-lock.json b/package-lock.json index 159a7df4..19172549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,14 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@agentclientprotocol/sdk": "^1.1.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.200", "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.80.1", + "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@openai/codex-sdk": "^0.142.5", + "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", @@ -21,6 +25,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { @@ -45,6 +50,175 @@ "node-pty": "^1.1.0" } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.1.0.tgz", + "integrity": "sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.200.tgz", + "integrity": "sha512-o13TM3boFIJE4oZdQDFw5TQfiev1sBoxwzKM2QGj/NPtxriGTP0PKNAQsGZvTsiEOIIH5rzPr/H81xVkkAw23g==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.200", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.200", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.200" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.200.tgz", + "integrity": "sha512-8UzzInVdRPDNIOvrAxYbHHJD/u13WSBx9fvEeuZnsZ6rZh0qnSI1QwU8Due0V2+m+ZnT3cEonmXDvo2ee/icWg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.200.tgz", + "integrity": "sha512-DCwlQoO8HWGuFElE+Q5pYkiBTalXjjMATRAxXyc94fI6m1ZRqyba66dOea+zTmzHPpOb6zSoHYNLiXy7EjNpcg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.200.tgz", + "integrity": "sha512-NAEonp086ZOsf+3o/9Y5JRclO6C4n4ceiSuCpSDV6SSUOLBmCRi7r/PJOoMsIWwMshC6fnnkDKZamTpHjr75eg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.200.tgz", + "integrity": "sha512-ak0l+zpz3dKPjnBegUhOs1Y5xFveEQ1AVqmq6s8Q7qd3vO4SrDPiUOpxRkjkqWyGD8r8w+ezG+unf3U9IZ6DRg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.200.tgz", + "integrity": "sha512-0R/In8G4fZLFFEIA1SqXRRf9mzDGx7roHpMawNdTT1QlG4XftGTlKMxfukt/YcxwzsNPWg4hJSkEDxsb+3J6FA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.200.tgz", + "integrity": "sha512-Sf5TTCO3bc5ty7FX5F19WT3xbtU+f1biYD9+dDJ7YHyYFWuiPlWcnCJ8El8RSwCTuvz3OexJLwCqGHRWOC3eBg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.200.tgz", + "integrity": "sha512-iJx10bdrk3afa/Oq9QHRh2HaINT/xnsm5OrFNNLbix2CoOEY5lA7f0lk/s0OMiWnfXdv5vvtADpgZ5tvUoQykA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.200.tgz", + "integrity": "sha512-Mka8YDpDIiSJcbrdoBhzX3S0n9DYcoYaEjS7lxwX3GyPi5PvXV4UBuXzj++7ieV/KS4w32Sm3mHQRpeVwnJZ0A==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@clack/core": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz", @@ -74,15 +248,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.1.tgz", - "integrity": "sha512-tlFNPF5cbQsVOASvgxNmC19+CkncR4DZwa8e+uNf74D0Y2Pruqgqvh8biFzHcwQVqVqagFRQ7QM1yhFX1z+lEw==", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.3.tgz", + "integrity": "sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==", "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.1", - "@earendil-works/pi-ai": "^0.80.1", - "@earendil-works/pi-tui": "^0.80.1", + "@earendil-works/pi-agent-core": "^0.80.3", + "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-tui": "^0.80.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -545,11 +719,11 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.1.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.1", + "@earendil-works/pi-ai": "^0.80.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -559,8 +733,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.1.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -583,8 +757,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.1.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -2467,6 +2641,149 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@openai/codex": { + "version": "0.142.5", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5.tgz", + "integrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.5-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.5-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.5-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.142.5-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.5-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.142.5-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.142.5-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-arm64.tgz", + "integrity": "sha512-l43p8xv+Z/2/b6fCUc7/FmcQZsaPB7RFizLponGwHAnFOWe3i9Vky69p+up3BUam9AetoQQUv7Mo+2KdaFEqhA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.142.5-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-x64.tgz", + "integrity": "sha512-yk6A06/VmW7NFsa48OVPaj//g/zeSpd79wjuqfXZwW8ZKRYQm3+wCd3hWjPl79F3QnXvDvM2j3JMIBL3m3GXXg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.142.5-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-arm64.tgz", + "integrity": "sha512-77ka5PSnm5HdxdBT99IwntCasmbqevlS0eiC0AtEb6ZXCLkim2gm0AWm+jNYy0EhbssvNK+KghayWo34HMgXeA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.142.5-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-x64.tgz", + "integrity": "sha512-pxY+d3NgNE57Y/MApD3/TZUAygxJN6I9h3ZeDUwe67mxWjUxsuapxMRFTKSznCalYbRAeZp752+AAXmUbmguEg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-sdk": { + "version": "0.142.5", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.142.5.tgz", + "integrity": "sha512-MConZ+eoBoZmkc4reezuzOgLtoI1BQBzo/nVYsSjtAIBpwKcgeEm1rfmqfUnTfFaBNHFTxBntcS7ZeQYuDPbWA==", + "license": "Apache-2.0", + "dependencies": { + "@openai/codex": "0.142.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.142.5-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-arm64.tgz", + "integrity": "sha512-65BEqGbUZ7r0ayunIHdBjo5crwgbwKX/6puOcO+VCswUw/dXvDsN2IGcbXB52+bS9U5+FxP783cUHfTT6m40DQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.142.5-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-x64.tgz", + "integrity": "sha512-a+wI4PEx9a2fg6V5ueTTDkOkr1XpEvA5RFXIbo/L2hOfzMmGtyRnbG24bCGu5Q2RSgVxSQV0aLkdb3vdYMNH9A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.17.13", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.13.tgz", + "integrity": "sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -2845,6 +3162,13 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3800,6 +4124,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -4174,6 +4505,20 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5367,6 +5712,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5472,6 +5828,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5782,6 +6145,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 4a8d6057..c0f0b24a 100644 --- a/package.json +++ b/package.json @@ -28,17 +28,21 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], "author": "", "license": "MIT", "dependencies": { + "@agentclientprotocol/sdk": "^1.1.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.200", "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.80.1", + "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@openai/codex-sdk": "^0.142.5", + "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", @@ -46,6 +50,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/skills/local-agent-delegation/SKILL.md b/skills/local-agent-delegation/SKILL.md deleted file mode 100644 index 5af2f9a4..00000000 --- a/skills/local-agent-delegation/SKILL.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -name: local-agent-delegation -description: Delegate coding tasks to user-configured local coding agents such as Codex, Claude Code, OpenCode, Cursor Agent, Pi, or Copilot CLI. ---- - -# Local Agent Delegation - -Use this skill when the user explicitly asks to delegate work to another coding agent, use a named local agent, get a second opinion, compare implementations, run agents in parallel, or create a subagent-like workflow. - -Do not use local agents silently. Tell the user when another local agent is being used. - -## Core idea - -You are the supervisor. A local coding agent is the worker. - -Your responsibilities are: - -1. Understand the user's goal. -2. Decide whether delegation is useful. -3. Choose the right local agent or CLI. -4. Give the worker a focused prompt. -5. Inspect the result yourself. -6. Review diffs, tests, and risks before telling the user the work is done. - -## When to delegate - -Good delegation requests include: - -- "Ask another agent to look at this." -- "Have Claude/Codex/OpenCode/Pi implement this." -- "Run this in the background." -- "Compare two approaches." -- "Use a local subagent for this." -- "Get a second opinion on the architecture/test gaps/security risk." - -Do not delegate just because the task is coding-related. Use the normal DevSpace tools directly unless the user asks for delegation, another agent's opinion, parallel work, or a named local coding agent. - -## CLI execution guidance - -Prefer structured non-interactive CLI modes when available. - -Examples of useful patterns: - -```bash -codex exec --json -C "$WORKSPACE" "$PROMPT" -claude -p --output-format stream-json "$PROMPT" -opencode run --format json --dir "$WORKSPACE" "$PROMPT" -cursor-agent -p --output-format stream-json "$PROMPT" -pi -p --mode json "$PROMPT" -copilot -p "$PROMPT" --output-format json -``` - -These examples are illustrative. Real implementations should pass workspace and prompt values as separate argv entries without a shell, not concatenate untrusted prompt text into a shell command string. - -Use exact command templates from user-provided instructions when they are available. Do not invent provider-specific flags when the user has already supplied a command shape. - -Packaged files under `examples/agents/` are templates only. DevSpace does not currently parse, load, activate, or run local agent profile definitions. - -If no command shape exists for a requested agent, use the installed CLI's help output only when needed, then summarize what you found before running it. - -## Background execution - -When DevSpace exposes long-running process tools, prefer background execution for long tasks. - -Start the local agent process, keep the returned process/session id, and poll output later. - -Use this pattern for long implementations, test repair loops, large reviews, multi-step investigations, and agents that stream JSON or progress logs. - -When polling output, summarize useful progress instead of forwarding noisy terminal logs. - -If the worker is clearly stuck, running the wrong task, or burning resources, interrupt the current process or turn. Do not delete provider session history unless the user explicitly asks. - -## Follow-up prompts - -When sending a follow-up to the same local agent, include: - -```text -previous_task: -current_status: -review_findings: -requested_changes: -success_criteria: -``` - -Prefer the agent profile's resume/session mechanism when available. - -If resume is not available, include the previous worker summary and relevant diff context in a fresh prompt. - -## Worker prompt templates - -Use this structure when delegating implementation: - -```text -You are acting as a local coding worker under ChatGPT supervision. - -Goal: - - -Context: - - -Plan to execute: - - -Rules: -- Follow the existing project style. -- Keep changes focused. -- Do not perform unrelated refactors. -- Do not hide failures. -- At the end, return a concise final report. - -Final report format: -summary: -files_changed: -tests_run: -blockers: -follow_up_needed: -``` - -Use this structure when delegating read-only investigation: - -```text -You are acting as a read-only local code investigator under ChatGPT supervision. - -Question: - - -Scope: - - -Rules: -- Do not modify files. -- Cite relevant file paths and symbols. -- Separate facts from guesses. -- Return a concise answer. - -Final report format: -answer: -evidence: -relevant_files: -confidence: -unknowns: -``` - -## After the worker finishes - -Always review the result. - -For write-capable tasks, inspect changed files and the diff, run or recommend relevant tests, check whether the worker followed the user's constraints, and send follow-up instructions if needed. - -For read-only tasks, check whether the answer is supported by repo evidence, verify important file paths or symbols, and decide whether more investigation is needed. - -Do not assume the worker's summary is correct. - -## Reporting back to the user - -Be transparent. - -Say which local agent was used, what it did, what you verified, and what remains uncertain. - -Good final shape: - -```text -I delegated the implementation to . It changed . I reviewed the diff and ran . The main result is . Remaining concerns: . -``` - -Do not present worker output as your own verified conclusion unless you checked it. - -## Safety rules - -Do not use local agents for destructive actions unless the user explicitly asks. - -Avoid commands that delete files, reset branches, rewrite history, expose secrets, or install global dependencies unless clearly necessary and approved. - -Do not treat repo-provided profile examples as trusted executable definitions. - -Never hide that a local agent was used. diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md new file mode 100644 index 00000000..ae2c0725 --- /dev/null +++ b/skills/subagent-delegation/SKILL.md @@ -0,0 +1,124 @@ +--- +name: subagent-delegation +description: Delegate coding tasks to user-configured DevSpace subagents. +--- + +# Subagent Delegation + +Use this skill when the user explicitly asks to delegate work to another coding +agent, use a named subagent, get a second opinion, compare approaches, or run +a subagent-like workflow. + +Do not use subagents silently. Tell the user when another subagent is +being used. + +## Core commands + +Use only these commands for normal delegation: + +```bash +devspace agents ls +devspace agents run "" +devspace agents show +``` + +`ls` shows existing subagent sessions for the current workspace. DevSpace scopes +it automatically from the shell environment injected by the workspace tool. + +`run ""` starts a new configured profile and prints a +DevSpace agent id. + +`run ""` starts a raw built-in provider when no configured +profile is needed. Built-in providers are listed by `open_workspace`. + +`run ""` sends a follow-up to an existing agent. + +`show ` prints status and the latest response. If the agent is still +running, `show` waits briefly. If there is still no final response, call `show` +again later. + +Do not run provider CLIs such as `codex`, `claude`, `opencode`, `pi`, +`cursor-agent`, or `copilot` directly unless you are explicitly debugging +DevSpace agent integration. + +## Choosing a profile + +Choose profiles from the compact subagent profile catalog returned by +`open_workspace`. Use the profile name with `devspace agents run`. If no +profile fits and delegation is still appropriate, use a built-in provider name +from `open_workspace`. + +Profiles may declare a model. To override the configured/default provider model +for a run, pass `--model`: + +```bash +devspace agents run --model "" +``` + +Good delegation targets: + +- `reviewer`: second opinion, bug risk, security risk, test gaps. +- `explorer`: read-only codebase investigation. +- `implementer`: focused implementation when the user asked for delegation. + +Do not delegate ordinary coding work just because a profile exists. Use normal +DevSpace tools unless the user asked for delegation, another agent's opinion, +parallel work, or a named subagent. + +## Worker prompts + +Agents start with only the prompt you send plus their configured profile +instructions. Make prompts self-contained. + +Implementation prompt shape: + +```text +Goal: + + +Context: + + +Relevant files: + + +Acceptance criteria: +- + +Rules: +- Keep changes focused. +- Do not perform unrelated refactors. +- Report blockers clearly. +``` + +Read-only investigation prompt shape: + +```text +Question: + + +Scope: + + +Rules: +- Do not modify files. +- Cite relevant file paths and symbols. +- Separate facts from guesses. +``` + +## After the worker responds + +Always review the result before presenting it as verified. + +For write-capable tasks, inspect changed files and run or explain relevant +tests. For read-only tasks, verify that important claims are supported by repo +evidence. + +Be transparent in the final response: + +```text +I used . It reported . I verified . Remaining risk: +. +``` + +Never hide that a subagent was used. diff --git a/src/cli.test.ts b/src/cli.test.ts index 96dfd55c..6b636e1d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "./config.js"; +import { LocalAgentStore } from "./local-agent-store.js"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -14,3 +18,77 @@ for (const flag of ["-v", "--version"]) { assert.equal(output, packageJson.version); } + +const root = mkdtempSync(join(tmpdir(), "devspace-cli-agents-test-")); +try { + const configDir = join(root, ".devspace"); + const stateDir = join(root, ".state"); + const projectRoot = join(root, "project"); + mkdirSync(stateDir, { recursive: true }); + mkdirSync(join(configDir, "agents"), { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + join(configDir, "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + "description: Read-only reviewer.", + "provider: codex", + "model: gpt-5.4", + "---", + "", + "Review only.", + "", + ].join("\n"), + ); + const store = new LocalAgentStore(stateDir); + const current = store.update( + store.create({ + workspaceId: "ws_current", + workspaceRoot: projectRoot, + profileName: "reviewer", + provider: "codex", + model: "gpt-5.4", + }).id, + { status: "idle" }, + ); + const other = store.update( + store.create({ + workspaceId: "ws_other", + workspaceRoot: projectRoot, + profileName: "reviewer", + provider: "codex", + }).id, + { status: "running" }, + ); + store.close(); + + const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }); + + assert.match(output, new RegExp(`${current.id} idle reviewer codex gpt-5\\.4`)); + assert.doesNotMatch(output, /profile reviewer/); + assert.doesNotMatch(output, new RegExp(other.id)); + + assert.equal(loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }).subagents, true); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/cli.ts b/src/cli.ts index 8010120d..7c0a01a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,23 +1,45 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { stdin as input, stdout as output } from "node:process"; -import { resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; +import { runLocalAgentProvider } from "./local-agent-adapters.js"; +import { + isLocalAgentProvider, + loadLocalAgentProfiles, + type LocalAgentProfile, +} from "./local-agent-profiles.js"; +import { + assertLocalAgentProviderAvailable, + formatLocalAgentProviderAvailabilitySummary, +} from "./local-agent-availability.js"; +import { + formatAvailableLocalAgentTargets, + parseLocalAgentRunArgs, + resolveLocalAgentTarget, +} from "./local-agent-targets.js"; +import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-store.js"; +import type { LocalAgentRunResult } from "./local-agent-runtime.js"; import { ensureDevspaceDefaultSkills, generateOwnerToken, loadDevspaceFiles, - resolveLocalAgentsFlag, + resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, type DevspaceUserConfig, } from "./user-config.js"; import { expandHomePath } from "./roots.js"; -type Command = "serve" | "init" | "doctor" | "config" | "help" | "version"; +type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version"; const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; @@ -41,6 +63,9 @@ async function main(argv: string[]): Promise { case "config": runConfigCommand(args); return; + case "agents": + await runAgentsCommand(args); + return; case "help": printHelp(); return; @@ -52,7 +77,7 @@ async function main(argv: string[]): Promise { function normalizeCommand(command: string | undefined): Command { if (!command || command === "serve" || command === "start") return "serve"; - if (command === "init" || command === "doctor" || command === "config") return command; + if (command === "init" || command === "doctor" || command === "config" || command === "agents") return command; if (command === "help" || command === "--help" || command === "-h") return "help"; if (command === "version" || command === "--version" || command === "-v") return "version"; throw new Error(`Unknown command: ${command}`); @@ -135,7 +160,7 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, - localAgents: resolveLocalAgentsFlag(files.config), + subagents: resolveSubagentsFlag(files.config), }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -143,7 +168,7 @@ async function runInit({ force }: { force: boolean }): Promise { const configPath = writeDevspaceConfig(config); const authPath = writeDevspaceAuth(auth); - const seededSkillPaths = config.localAgents ? ensureDevspaceDefaultSkills() : []; + const seededSkillPaths = config.subagents ? ensureDevspaceDefaultSkills() : []; const lines = [ `Config: ${configPath}`, @@ -187,7 +212,7 @@ async function serve(): Promise { const { createServer } = await import("./server.js"); const config = loadConfig(); - const { app, close } = createServer(config); + const { app, close, localAgentProviders } = createServer(config); const httpServer = app.listen(config.port, config.host, () => { console.log(`devspace listening on http://${config.host}:${config.port}/mcp`); console.log(`public base url: ${config.publicBaseUrl}`); @@ -198,6 +223,9 @@ async function serve(): Promise { } console.log("auth: Owner password approval required"); console.log(`logging: ${config.logging.level} ${config.logging.format}`); + if (config.subagents) { + console.log(`subagent providers: ${formatLocalAgentProviderAvailabilitySummary(localAgentProviders)}`); + } }); const shutdown = () => { @@ -273,6 +301,9 @@ function printHelp(): void { " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", + " devspace agents ls List subagent sessions", + " devspace agents run [--model ] ", + " devspace agents show ", " devspace -v, --version Print the installed version", "", "For temporary tunnels:", @@ -281,6 +312,250 @@ function printHelp(): void { ); } +async function runAgentsCommand(args: string[]): Promise { + const [subcommand, ...rest] = args; + switch (subcommand) { + case "ls": + case "list": + await runAgentsList(); + return; + case "run": + await runAgentsRun(rest); + return; + case "show": + await runAgentsShow(rest); + return; + case "__worker": + await runAgentsWorker(rest); + return; + case undefined: + case "help": + case "--help": + case "-h": + printAgentsHelp(); + return; + default: + throw new Error(`Unknown agents command: ${subcommand}`); + } +} + +async function runAgentsList(): Promise { + const config = loadConfig(); + const store = createLocalAgentStore(config); + const agents = store.list(resolveCurrentWorkspaceScope()); + + if (agents.length === 0) { + console.log("No subagent sessions found for this workspace."); + return; + } + + for (const agent of agents) { + console.log(formatAgentLine(agent)); + } +} + +async function runAgentsRun(args: string[]): Promise { + const parsed = parseLocalAgentRunArgs(args); + + const config = loadConfig(); + const workspaceRoot = resolveCurrentWorkspaceRoot(); + const store = createLocalAgentStore(config); + const existing = store.get(parsed.target); + + if (existing) { + if (!isLocalAgentProvider(existing.provider)) { + throw new Error(`Unknown subagent provider for existing session: ${existing.provider}`); + } + assertLocalAgentProviderAvailable(existing.provider); + const promptFile = writeAgentPromptFile(parsed.prompt); + store.update(existing.id, { + status: "starting", + model: parsed.model ?? existing.model, + latestResponse: undefined, + error: undefined, + }); + spawnAgentWorker(existing.id, promptFile); + console.log(formatAgentLine({ ...existing, status: "running", model: parsed.model ?? existing.model })); + return; + } + + const profiles = await loadLocalAgentProfiles(config, workspaceRoot); + const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model); + if (!target) { + throw new Error( + `Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`, + ); + } + assertLocalAgentProviderAvailable(target.provider); + + const promptFile = writeAgentPromptFile(parsed.prompt); + const record = store.create({ + workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + workspaceRoot, + profileName: target.name, + provider: target.provider, + model: target.model, + }); + + spawnAgentWorker(record.id, promptFile); + console.log(formatAgentLine({ ...record, status: "running" })); +} + +async function runAgentsShow(args: string[]): Promise { + const [id] = args; + if (!id) throw new Error("Usage: devspace agents show "); + + const config = loadConfig(); + const store = createLocalAgentStore(config); + let record = store.get(id); + if (!record) throw new Error(`Unknown subagent id: ${id}`); + + const deadline = Date.now() + 15_000; + while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { + await sleep(500); + record = store.get(id) ?? record; + } + + console.log(formatAgentLine(record)); + if (record.latestResponse) { + console.log(record.latestResponse); + return; + } + if (record.error) { + console.log(record.error); + return; + } + if (record.status === "starting" || record.status === "running") { + console.log(`No final response yet. Call \`devspace agents show ${record.id}\` again later.`); + } +} + +async function runAgentsWorker(args: string[]): Promise { + const [id, promptFileFlag, promptFile] = args; + if (!id || promptFileFlag !== "--prompt-file" || !promptFile) { + throw new Error("Usage: devspace agents __worker --prompt-file "); + } + + const config = loadConfig(); + const store = createLocalAgentStore(config); + const record = store.get(id); + if (!record) throw new Error(`Unknown subagent id: ${id}`); + + store.update(record.id, { status: "running", error: undefined }); + try { + const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot); + const profile = profiles.find((candidate) => candidate.name === record.profileName); + const prompt = await readFile(promptFile, "utf8"); + const result = profile + ? await runLocalAgentProfile(profile, record, prompt) + : await runRawLocalAgentProvider(record, prompt); + store.update(record.id, { + providerSessionId: result.providerSessionId ?? undefined, + status: "idle", + latestResponse: result.finalResponse, + error: undefined, + }); + } catch (error) { + store.update(record.id, { + status: "error", + error: error instanceof Error ? error.message : String(error), + }); + } +} + +async function runLocalAgentProfile( + profile: LocalAgentProfile, + record: LocalAgentRecord, + prompt: string, +): Promise { + const body = profile.body.trim(); + const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; + return runLocalAgentProvider(profile.provider, { + prompt: fullPrompt, + workspace: record.workspaceRoot, + providerSessionId: record.providerSessionId, + writeMode: "allowed", + model: record.model ?? profile.model, + }); +} + +async function runRawLocalAgentProvider( + record: LocalAgentRecord, + prompt: string, +): Promise { + if (record.profileName !== record.provider || !isLocalAgentProvider(record.provider)) { + throw new Error(`Subagent profile not found: ${record.profileName}`); + } + + return runLocalAgentProvider(record.provider, { + prompt, + workspace: record.workspaceRoot, + providerSessionId: record.providerSessionId, + writeMode: "allowed", + model: record.model, + }); +} + +function spawnAgentWorker(agentId: string, promptFile: string): void { + const child = spawn(process.execPath, [ + ...process.execArgv, + fileURLToPath(import.meta.url), + "agents", + "__worker", + agentId, + "--prompt-file", + promptFile, + ], { + detached: true, + stdio: "ignore", + env: process.env, + }); + child.unref(); +} + +function writeAgentPromptFile(prompt: string): string { + const directory = mkdtempSync(join(tmpdir(), "devspace-agent-prompt-")); + const filePath = join(directory, "prompt.txt"); + writeFileSync(filePath, prompt, { mode: 0o600 }); + return filePath; +} + +function resolveCurrentWorkspaceRoot(): string { + return resolve(process.env.DEVSPACE_WORKSPACE_ROOT || process.cwd()); +} + +function resolveCurrentWorkspaceScope(): { workspaceId?: string; workspaceRoot: string } { + return { + workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + workspaceRoot: resolveCurrentWorkspaceRoot(), + }; +} + +function formatAgentLine(agent: Pick< + LocalAgentRecord, + "id" | "status" | "profileName" | "provider" | "model" +>): string { + const model = agent.model ? ` ${agent.model}` : ""; + return `${agent.id} ${agent.status} ${agent.profileName} ${agent.provider}${model}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +function printAgentsHelp(): void { + console.log( + [ + "DevSpace agents", + "", + "Usage:", + " devspace agents ls", + " devspace agents run [--model ] ", + " devspace agents show ", + ].join("\n"), + ); +} + function printVersion(): void { const packageJson = require("../package.json") as { version?: unknown }; if (typeof packageJson.version !== "string") { diff --git a/src/config.test.ts b/src/config.test.ts index e6054f00..cbe870d6 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; -import { ensureDevspaceDefaultSkills, resolveLocalAgentsFlag } from "./user-config.js"; +import { ensureDevspaceDefaultSkills, resolveSubagentsFlag } from "./user-config.js"; const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); const baseEnv = { @@ -27,23 +27,24 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, " assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); -assert.equal(loadConfig(baseEnv).localAgents, false); +assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); +assert.equal(loadConfig(baseEnv).subagents, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_LOCAL_AGENTS: "1" }).localAgents, + loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, true, ); -assert.equal(resolveLocalAgentsFlag({}, {}), undefined); -assert.equal(resolveLocalAgentsFlag({ localAgents: true }, {}), true); -assert.equal(resolveLocalAgentsFlag({ localAgents: true }, { DEVSPACE_LOCAL_AGENTS: "0" }), false); -assert.equal(resolveLocalAgentsFlag({}, { DEVSPACE_LOCAL_AGENTS: "1" }), true); +assert.equal(resolveSubagentsFlag({}, {}), undefined); +assert.equal(resolveSubagentsFlag({ subagents: true }, {}), true); +assert.equal(resolveSubagentsFlag({ subagents: true }, { DEVSPACE_SUBAGENTS: "0" }), false); +assert.equal(resolveSubagentsFlag({}, { DEVSPACE_SUBAGENTS: "1" }), true); const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-")); const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }); -assert.deepEqual(seededSkillPaths, [join(seededConfigDir, "skills", "local-agent-delegation", "SKILL.md")]); +assert.deepEqual(seededSkillPaths, [join(seededConfigDir, "skills", "subagent-delegation", "SKILL.md")]); assert.equal(existsSync(seededSkillPaths[0]), true); -assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: local-agent-delegation/); +assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagent-delegation/); assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []); assert.throws( @@ -168,7 +169,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", - localAgents: true, + subagents: true, }), ); writeFileSync( @@ -182,7 +183,7 @@ const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); -assert.equal(fileConfig.localAgents, true); +assert.equal(fileConfig.subagents, true); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index 2fb7ec9a..c168af43 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; -import { devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; +import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; export type ToolMode = "minimal" | "full" | "codex"; @@ -26,7 +26,8 @@ export interface ServerConfig { skillsEnabled: boolean; skillPaths: string[]; devspaceSkillsDir: string; - localAgents: boolean; + devspaceAgentsDir: string; + subagents: boolean; agentDir: string; logging: LoggingConfig; } @@ -238,10 +239,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), - localAgents: - env.DEVSPACE_LOCAL_AGENTS === undefined - ? files.config.localAgents === true - : parseBoolean(env.DEVSPACE_LOCAL_AGENTS), + devspaceAgentsDir: devspaceAgentsDir(env), + subagents: + env.DEVSPACE_SUBAGENTS === undefined + ? files.config.subagents === true + : parseBoolean(env.DEVSPACE_SUBAGENTS), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), logging: parseLoggingConfig(env), }; diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1ce1e1c7..796f72fc 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -17,6 +17,11 @@ const migrations: Migration[] = [ name: "oauth-state", up: migrateOAuthState, }, + { + version: 3, + name: "local-agent-sessions", + up: migrateLocalAgentSessions, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -138,6 +143,34 @@ function migrateOAuthState(sqlite: Database.Database): void { `); } +function migrateLocalAgentSessions(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists local_agent_sessions ( + id text primary key, + workspace_id text, + workspace_root text not null, + profile_name text not null, + provider text not null, + model text, + provider_session_id text, + status text not null, + latest_response text, + error text, + created_at text not null, + updated_at text not null + ); + + create index if not exists local_agent_sessions_workspace_id_idx + on local_agent_sessions(workspace_id, updated_at desc); + + create index if not exists local_agent_sessions_workspace_root_idx + on local_agent_sessions(workspace_root, updated_at desc); + + create index if not exists local_agent_sessions_provider_session_id_idx + on local_agent_sessions(provider_session_id); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 94b3862b..6ade5459 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -73,7 +73,32 @@ export const oauthRefreshTokens = sqliteTable( }, ); +export const localAgentSessions = sqliteTable( + "local_agent_sessions", + { + id: text("id").primaryKey(), + workspaceId: text("workspace_id"), + workspaceRoot: text("workspace_root").notNull(), + profileName: text("profile_name").notNull(), + provider: text("provider").notNull(), + model: text("model"), + providerSessionId: text("provider_session_id"), + status: text("status").notNull(), + latestResponse: text("latest_response"), + error: text("error"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + }, + (table) => [ + index("local_agent_sessions_workspace_id_idx").on(table.workspaceId, table.updatedAt), + index("local_agent_sessions_workspace_root_idx").on(table.workspaceRoot, table.updatedAt), + index("local_agent_sessions_provider_session_id_idx").on(table.providerSessionId), + ], +); + export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; +export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts new file mode 100644 index 00000000..8dcea38e --- /dev/null +++ b/src/local-agent-adapters.test.ts @@ -0,0 +1,226 @@ +import assert from "node:assert/strict"; +import { delimiter } from "node:path"; +import { + claudeCommandEnvironment, + createLocalAgentAdapter, + extractOpenCodeFinalResponse, + extractPiFinalResponse, + extractPiProviderError, + extractPiStreamingText, + piCommandEnvironment, +} from "./local-agent-adapters.js"; +import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; + +const providers: LocalAgentProvider[] = [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", +]; + +for (const provider of providers) { + const adapter = createLocalAgentAdapter(provider); + assert.equal(adapter.provider, provider); + assert.equal(typeof adapter.run, "function"); +} + +{ + const env = claudeCommandEnvironment({ + CLAUDECODE: "1", + CLAUDE_CODE_ENTRYPOINT: "cli", + CLAUDE_CODE_SSE_PORT: "1234", + CLAUDE_AGENT_SDK_VERSION: "test", + PATH: "/usr/bin", + }); + + assert.equal(env.CLAUDECODE, undefined); + assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined); + assert.equal(env.CLAUDE_CODE_SSE_PORT, undefined); + assert.equal(env.CLAUDE_AGENT_SDK_VERSION, undefined); + assert.equal(env.PATH, "/usr/bin"); +} + +assert.equal( + extractOpenCodeFinalResponse({ + data: [ + { + info: { id: "msg_user", role: "user" }, + parts: [{ type: "text", text: "Review the change." }], + }, + { + info: { id: "msg_assistant", role: "assistant" }, + parts: [ + { type: "reasoning", text: "thinking" }, + { type: "tool", tool: "grep", input: { pattern: "secret" }, output: "src/foo.ts" }, + { type: "text", text: "Final OpenCode response." }, + ], + }, + ], + }), + "Final OpenCode response.", +); + +assert.equal( + extractOpenCodeFinalResponse({ + data: [ + { + id: "msg_user", + type: "user", + text: "Review the change.", + }, + { + id: "msg_assistant", + type: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "tool", name: "grep", state: { status: "completed", result: "src/foo.ts" } }, + { type: "text", text: "Final OpenCode v2 response." }, + ], + }, + ], + }), + "Final OpenCode v2 response.", +); + +assert.equal( + extractOpenCodeFinalResponse({ + data: { + info: { + id: "msg_structured", + role: "assistant", + structured: { summary: "structured answer" }, + }, + parts: [{ type: "reasoning", text: "thinking" }], + }, + }), + '{"summary":"structured answer"}', +); + +assert.equal( + extractOpenCodeFinalResponse({ + data: { + info: { id: "msg_tool_only", role: "assistant" }, + parts: [ + { type: "reasoning", text: "thinking" }, + { type: "tool", tool: "bash", input: { command: "cat src/secret.ts" }, output: "secret" }, + ], + }, + }), + "", +); + +assert.equal( + extractPiFinalResponse({ + data: { + messages: [ + { role: "user", content: "Review the change." }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "thinking" }, + { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "src/foo.ts" } }, + { type: "text", text: "Final Pi response." }, + ], + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + content: [{ type: "text", text: "tool output" }], + }, + ], + }, + }), + "Final Pi response.", +); + +assert.equal( + extractPiFinalResponse({ + messages: [ + { + role: "assistant", + content: [ + { type: "text", text: "first part" }, + { type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "npm test" } }, + { type: "text", text: "second part" }, + ], + }, + ], + }), + "first part\n\nsecond part", +); + +assert.equal( + extractPiFinalResponse({ + messages: [ + { role: "assistant", content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: {} }] }, + { role: "toolResult", toolCallId: "tool-1", toolName: "bash", content: "secret output" }, + { role: "bashExecution", command: "cat src/secret.ts", output: "secret output", timestamp: 1 }, + ], + }), + "", +); + +assert.equal( + extractPiProviderError({ + type: "agent_end", + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "" }], + stopReason: "error", + errorMessage: "(0 , _piAi.streamSimpleOpenAIResponses) is not a function", + }, + ], + }), + "(0 , _piAi.streamSimpleOpenAIResponses) is not a function", +); + +assert.equal( + extractPiStreamingText([ + { + type: "message_update", + message: { role: "assistant", content: [{ type: "thinking", thinking: "hidden" }] }, + assistantMessageEvent: { type: "thinking_delta", delta: "hidden" }, + }, + { + type: "message_update", + message: { role: "assistant", content: [{ type: "text", text: "Final " }] }, + assistantMessageEvent: { type: "text_delta", delta: "Final " }, + }, + { + type: "message_update", + message: { role: "assistant", content: [{ type: "text", text: "Pi response." }] }, + assistantMessageEvent: { type: "text_delta", delta: "Pi response." }, + }, + ]), + "Final Pi response.", +); + +{ + const devspaceBin = `${process.cwd()}/node_modules/.bin`; + const userBin = "/home/user/.local/bin"; + assert.equal( + removeDevspaceNodeModulesBinFromPath([devspaceBin, userBin].join(delimiter)), + userBin, + ); + + const env = piCommandEnvironment({ + PATH: [devspaceBin, userBin].join(delimiter), + }); + + assert.equal(env.PATH, userBin); +} + +{ + const devspaceBin = `${process.cwd()}/node_modules/.bin`; + const env = piCommandEnvironment({ + PI_COMMAND: "/custom/pi", + PATH: [devspaceBin, "/home/user/.local/bin"].join(delimiter), + }); + + assert.equal(env.PATH, [devspaceBin, "/home/user/.local/bin"].join(delimiter)); +} diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts new file mode 100644 index 00000000..267e7f83 --- /dev/null +++ b/src/local-agent-adapters.ts @@ -0,0 +1,636 @@ +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { resolve } from "node:path"; +import { Readable, Writable } from "node:stream"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; +import { + createCodexSdkLocalAgentRuntime, + type LocalAgentRunInput, + type LocalAgentRunResult, +} from "./local-agent-runtime.js"; + +export interface LocalAgentAdapter { + readonly provider: LocalAgentProvider; + run(input: LocalAgentRunInput): Promise; +} + +const ACP_COMMANDS: Record<"cursor" | "copilot", [string, ...string[]]> = { + cursor: ["cursor-agent", "acp"], + copilot: ["copilot", "--acp"], +}; +const PI_AGENT_TIMEOUT_MS = 120_000; + +export async function runLocalAgentProvider( + provider: LocalAgentProvider, + input: LocalAgentRunInput, +): Promise { + return createLocalAgentAdapter(provider).run(input); +} + +export function createLocalAgentAdapter(provider: LocalAgentProvider): LocalAgentAdapter { + switch (provider) { + case "codex": + return new CodexLocalAgentAdapter(); + case "claude": + return new ClaudeLocalAgentAdapter(); + case "opencode": + return new OpencodeLocalAgentAdapter(); + case "pi": + return new PiRpcLocalAgentAdapter(); + case "cursor": + case "copilot": + return new AcpLocalAgentAdapter(provider, ACP_COMMANDS[provider]); + } +} + +class CodexLocalAgentAdapter implements LocalAgentAdapter { + readonly provider = "codex" as const; + + async run(input: LocalAgentRunInput): Promise { + const runtime = await createCodexSdkLocalAgentRuntime(); + return runtime.run(input); + } +} + +class ClaudeLocalAgentAdapter implements LocalAgentAdapter { + readonly provider = "claude" as const; + + async run(input: LocalAgentRunInput): Promise { + const { query } = await import("@anthropic-ai/claude-agent-sdk"); + const claudeExecutable = process.env.CLAUDE_COMMAND ?? resolveExecutable("claude"); + const messages = query({ + prompt: input.prompt, + options: { + cwd: input.workspace, + model: input.model, + resume: input.providerSessionId, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + env: claudeCommandEnvironment(process.env), + ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}), + }, + }); + + let providerSessionId = input.providerSessionId ?? null; + let finalResponse = ""; + const items: unknown[] = []; + for await (const message of messages) { + items.push(message); + const record = message as Record; + if (typeof record.session_id === "string") providerSessionId = record.session_id; + if (record.type === "result" && typeof record.result === "string") { + const resultError = claudeResultError(record); + if (resultError) throw new Error(resultError); + finalResponse = record.result; + } + } + + finalResponse = requireFinalResponse("Claude", finalResponse); + return { + provider: this.provider, + providerSessionId, + finalResponse, + items, + }; + } +} + +function claudeResultError(record: Record): string | undefined { + const subtype = typeof record.subtype === "string" ? record.subtype : undefined; + const isError = record.is_error === true || subtype?.startsWith("error"); + if (!isError) return undefined; + const message = + directString(record.error) ?? + directString(record.message) ?? + directString(record.result) ?? + subtype ?? + "Claude returned an error result."; + return `Claude returned an error result: ${message}`; +} + +function directString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function resolveExecutable(command: string): string | undefined { + const result = spawnSync(process.platform === "win32" ? "where.exe" : "command", [ + ...(process.platform === "win32" ? [command] : ["-v", command]), + ], { + encoding: "utf8", + shell: process.platform !== "win32", + }); + const executable = result.stdout?.split(/\r?\n/).find((line) => line.trim()); + return executable?.trim() || undefined; +} + +export function claudeCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next = { ...env }; + for (const key of [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SSE_PORT", + "CLAUDE_AGENT_SDK_VERSION", + ]) { + delete next[key]; + } + return next; +} + +class OpencodeLocalAgentAdapter implements LocalAgentAdapter { + readonly provider = "opencode" as const; + + async run(input: LocalAgentRunInput): Promise { + const { createOpencode } = await import("@opencode-ai/sdk/v2"); + const { client, server } = await createOpencode(); + try { + const sessionId = input.providerSessionId ?? await createOpencodeSession(client, input); + const promptResult = await promptOpencodeSession(client, sessionId, input); + await waitForOpencodeSession(client, sessionId); + const messages = await readOpencodeMessages(client, sessionId); + const finalResponse = requireFinalResponse( + "OpenCode", + extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), + ); + return { + provider: this.provider, + providerSessionId: sessionId, + finalResponse, + items: [promptResult, messages], + }; + } finally { + server.close(); + } + } +} + +class AcpLocalAgentAdapter implements LocalAgentAdapter { + constructor( + readonly provider: "cursor" | "copilot", + private readonly command: [string, ...string[]], + ) {} + + async run(input: LocalAgentRunInput): Promise { + const { client } = await import("@agentclientprotocol/sdk"); + const { methods } = await import("@agentclientprotocol/sdk"); + const { ndJsonStream } = await import("@agentclientprotocol/sdk"); + const [command, ...args] = this.command; + const child = spawn(command, args, { + cwd: input.workspace, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + assertPipedChild(child); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ); + try { + let providerSessionId = input.providerSessionId ?? null; + const finalResponse = await client({ name: "DevSpace" }) + .onRequest(methods.client.session.requestPermission, (context) => { + const selected = selectAcpAllowPermissionOption(context.params.options); + return selected + ? { outcome: { outcome: "selected", optionId: selected.optionId } } + : { outcome: { outcome: "cancelled" } }; + }) + .connectWith(stream, async (context) => { + const session = await context.buildSession(input.workspace).start(); + providerSessionId = session.sessionId; + try { + const prompt = session.prompt(input.prompt); + const textParts: string[] = []; + for (;;) { + const message = await session.nextUpdate(); + if (message.kind === "stop") { + await prompt; + return textParts.join("").trim(); + } + + const update = message.update; + if (update.sessionUpdate !== "agent_message_chunk") continue; + const content = update.content; + if (content.type === "text") textParts.push(content.text); + } + } finally { + session.dispose(); + } + }); + return { + provider: this.provider, + providerSessionId, + finalResponse: finalResponse.trim(), + items: [], + }; + } catch (error) { + throw new Error(`${this.provider} ACP run failed: ${errorMessage(error)}${stderr ? `\n${stderr.trim()}` : ""}`); + } finally { + child.kill(); + } + } +} + +function selectAcpAllowPermissionOption(options: Array<{ optionId: string; kind: string }>): { optionId: string } | undefined { + return ( + options.find((option) => option.kind === "allow_once") ?? + options.find((option) => option.kind === "allow_always") + ); +} + +class PiRpcLocalAgentAdapter implements LocalAgentAdapter { + readonly provider = "pi" as const; + + async run(input: LocalAgentRunInput): Promise { + const args = ["--mode", "rpc"]; + if (input.model) args.push("--model", input.model); + if (input.providerSessionId) args.push("--session", input.providerSessionId); + const child = spawn(process.env.PI_COMMAND ?? "pi", args, { + cwd: input.workspace, + env: piCommandEnvironment(process.env), + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + assertPipedChild(child); + const rpc = new JsonLineRpc(child); + const events: unknown[] = []; + rpc.onEvent((event) => events.push(event)); + try { + const state = await rpc.request({ type: "get_state" }); + const providerSessionId = readNestedString(state, ["sessionId"]) ?? input.providerSessionId ?? null; + const done = rpc.waitForEvent((event) => asRecord(event)?.type === "agent_end", PI_AGENT_TIMEOUT_MS); + await rpc.request({ type: "prompt", message: input.prompt }); + const agentEnd = await done; + const sessionMessages = await rpc.request({ type: "get_messages" }); + const finalResponse = + extractPiFinalResponse(agentEnd) || + extractPiFinalResponse(sessionMessages) || + extractPiStreamingText(events); + if (!finalResponse) { + const providerError = + extractPiProviderError(agentEnd) || + extractPiProviderError(sessionMessages) || + extractPiProviderError(events); + if (providerError) throw new Error(`Pi returned an error: ${providerError}`); + } + requireFinalResponse("Pi", finalResponse); + return { + provider: this.provider, + providerSessionId, + finalResponse, + items: [...events, sessionMessages], + }; + } finally { + child.kill(); + } + } +} + +export function piCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (env.PI_COMMAND) return env; + const path = env.PATH; + if (!path) return env; + + return { + ...env, + PATH: removeDevspaceNodeModulesBinFromPath(path), + }; +} + +class JsonLineRpc { + private readonly pending = new Map void; + reject: (error: Error) => void; + }>(); + private readonly eventSubscribers = new Set<(event: unknown) => void>(); + private buffer = ""; + private nextId = 1; + private stderr = ""; + private fatalError: Error | undefined; + + constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.on("data", (chunk: Buffer) => this.handleStdout(chunk.toString("utf8"))); + child.stderr.on("data", (chunk: Buffer) => { + this.stderr += chunk.toString("utf8"); + }); + child.on("exit", (code, signal) => { + this.failAll(new Error(`Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderr}`.trim())); + }); + } + + request(command: Record): Promise { + if (this.fatalError) { + return Promise.reject(this.fatalError); + } + const id = `req_${this.nextId}`; + this.nextId += 1; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.child.stdin.write(`${JSON.stringify({ ...command, id })}\n`); + }); + } + + onEvent(callback: (event: unknown) => void): () => void { + this.eventSubscribers.add(callback); + return () => this.eventSubscribers.delete(callback); + } + + waitForEvent(predicate: (event: unknown) => boolean, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error(`Pi RPC timed out waiting for agent completion\n${this.stderr}`.trim())); + }, timeoutMs); + const unsubscribe = this.onEvent((event) => { + if (!predicate(event)) return; + clearTimeout(timer); + unsubscribe(); + resolve(event); + }); + }); + } + + private handleStdout(chunk: string): void { + this.buffer += chunk; + for (;;) { + const newline = this.buffer.indexOf("\n"); + if (newline === -1) return; + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + let message: Record; + try { + message = JSON.parse(line) as Record; + } catch { + this.stderr += `${line}\n`; + this.failAll(new Error(`Pi RPC emitted malformed JSON on stdout: ${line}`)); + return; + } + if (message.type !== "response") { + for (const subscriber of this.eventSubscribers) subscriber(message); + continue; + } + + const id = typeof message.id === "string" ? message.id : undefined; + if (!id) continue; + const pending = this.pending.get(id); + if (!pending) continue; + this.pending.delete(id); + if (message.success === false || message.error) { + pending.reject(new Error(errorMessage(message.error ?? `Pi RPC request failed: ${message.command ?? id}`))); + } else { + pending.resolve(message.data ?? message.result ?? message); + } + } + } + + private failAll(error: Error): void { + this.fatalError = error; + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } +} + +async function createOpencodeSession(client: unknown, input: LocalAgentRunInput): Promise { + const sessionClient = client as { + session: { + create(parameters?: unknown, options?: unknown): Promise; + }; + }; + const result = await sessionClient.session.create({ + directory: input.workspace, + location: { directory: input.workspace }, + ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), + }, { throwOnError: true }); + const id = + readNestedString(result, ["id"]) ?? + readNestedString(result, ["data", "id"]) ?? + readNestedString(result, ["session", "id"]) ?? + readNestedString(result, ["data", "session", "id"]); + if (typeof id !== "string") { + throw new Error("OpenCode did not return a session id."); + } + return id; +} + +async function promptOpencodeSession( + client: unknown, + sessionId: string, + input: LocalAgentRunInput, +): Promise { + const session = (client as { + session: { + prompt(parameters?: unknown, options?: unknown): Promise; + }; + }).session; + const promptInput = { + sessionID: sessionId, + directory: input.workspace, + prompt: { parts: [{ type: "text", text: input.prompt }] }, + parts: [{ type: "text", text: input.prompt }], + ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), + }; + return session.prompt(promptInput, { throwOnError: true }); +} + +async function waitForOpencodeSession(client: unknown, sessionId: string): Promise { + const session = (client as { + session?: { wait?: (parameters?: unknown, options?: unknown) => Promise }; + }).session; + if (!session?.wait) return; + await session.wait({ sessionID: sessionId }, { throwOnError: true }); +} + +async function readOpencodeMessages(client: unknown, sessionId: string): Promise { + const session = (client as { + session?: { + messages?: (parameters?: unknown, options?: unknown) => Promise; + }; + }).session; + if (!session?.messages) return undefined; + return session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true }); +} + +function parseOpencodeModel(model: string): { providerID: string; modelID: string } { + const separator = model.indexOf("/"); + if (separator === -1) return { providerID: "opencode", modelID: model }; + return { + providerID: model.slice(0, separator), + modelID: model.slice(separator + 1), + }; +} + +export function extractLocalAgentResponseText(value: unknown): string { + return extractOpenCodeFinalResponse(value) || extractPiFinalResponse(value); +} + +function assertPipedChild(child: ReturnType): asserts child is ChildProcessWithoutNullStreams { + if (!child.stdin || !child.stdout || !child.stderr) { + throw new Error("Agent process did not expose stdio pipes."); + } +} + +export function extractOpenCodeFinalResponse(value: unknown): string { + const root = unwrapProviderPayload(value); + const messages = Array.isArray(root) ? root : readArray(root, "messages"); + if (messages) return extractLastOpenCodeAssistantMessageText(messages); + return extractOpenCodeAssistantMessageText(root); +} + +export function extractPiFinalResponse(value: unknown): string { + const root = unwrapProviderPayload(value); + const messages = Array.isArray(root) ? root : readArray(root, "messages"); + if (!messages) return ""; + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = asRecord(messages[index]); + if (!message || message.role !== "assistant") continue; + const text = extractPiAssistantMessageText(message); + if (text) return text; + } + return ""; +} + +export function extractPiStreamingText(events: unknown[]): string { + return events + .map((event) => { + const record = asRecord(event); + if (!record || record.type !== "message_update") return ""; + const update = asRecord(record.assistantMessageEvent); + if (!update || update.type !== "text_delta") return ""; + return typeof update.delta === "string" ? update.delta : ""; + }) + .filter(Boolean) + .join("") + .trim(); +} + +export function extractPiProviderError(value: unknown): string { + const root = unwrapProviderPayload(value); + if (Array.isArray(root)) { + for (let index = root.length - 1; index >= 0; index -= 1) { + const error = extractPiProviderError(root[index]); + if (error) return error; + } + return ""; + } + + const messages = readArray(root, "messages"); + if (messages) return extractPiProviderError(messages); + + const message = asRecord(root)?.message ?? root; + const record = asRecord(message); + if (!record) return ""; + const error = record.errorMessage ?? record.error; + return typeof error === "string" ? error.trim() : ""; +} + +function extractLastOpenCodeAssistantMessageText(messages: unknown[]): string { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = asRecord(messages[index]); + if (!message) continue; + const info = asRecord(message.info); + const role = typeof info?.role === "string" ? info.role : message.role; + const type = typeof message.type === "string" ? message.type : undefined; + if (role !== "assistant" && type !== "assistant") continue; + const text = extractOpenCodeAssistantMessageText(message); + if (text) return text; + } + return ""; +} + +function extractOpenCodeAssistantMessageText(value: unknown): string { + const message = asRecord(value); + if (!message) return ""; + + const content = readArray(message, "content"); + if (content) { + const text = content + .map((part) => { + const partRecord = asRecord(part); + if (!partRecord || partRecord.type !== "text") return ""; + return typeof partRecord.text === "string" ? partRecord.text : ""; + }) + .filter(Boolean) + .join(""); + if (text.trim()) return text.trim(); + } + + const parts = readArray(message, "parts"); + if (parts) { + const text = parts + .map((part) => { + const partRecord = asRecord(part); + if (!partRecord || partRecord.type !== "text") return ""; + return typeof partRecord.text === "string" ? partRecord.text : ""; + }) + .filter(Boolean) + .join(""); + if (text.trim()) return text.trim(); + } + + const info = asRecord(message.info) ?? message; + return stringifyStructuredAssistantMessage(info.structured); +} + +function extractPiAssistantMessageText(message: Record): string { + const content = message.content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => { + const partRecord = asRecord(part); + if (!partRecord || partRecord.type !== "text") return ""; + return typeof partRecord.text === "string" ? partRecord.text : ""; + }) + .filter(Boolean) + .join("\n\n") + .trim(); +} + +function stringifyStructuredAssistantMessage(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value.trim(); + return JSON.stringify(value); +} + +function unwrapProviderPayload(value: unknown): unknown { + const record = asRecord(value); + if (!record) return value; + return record.data ?? record.result ?? value; +} + +function readArray(record: unknown, key: string): unknown[] | undefined { + const value = asRecord(record)?.[key]; + return Array.isArray(value) ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +function readNestedString(value: unknown, path: string[]): string | undefined { + let current: unknown = value; + for (const key of path) { + current = asRecord(current)?.[key]; + } + return typeof current === "string" ? current : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function requireFinalResponse(provider: string, response: string): string { + const trimmed = response.trim(); + if (!trimmed) { + throw new Error(`${provider} did not return a final assistant response.`); + } + return trimmed; +} diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts new file mode 100644 index 00000000..5d56697c --- /dev/null +++ b/src/local-agent-availability.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { + checkLocalAgentProviderAvailability, + formatLocalAgentProviderAvailabilitySummary, + getLocalAgentProviderAvailabilitySnapshot, +} from "./local-agent-availability.js"; + +assert.equal(checkLocalAgentProviderAvailability("codex").available, true); + +{ + const availability = checkLocalAgentProviderAvailability("pi", { + ...process.env, + PI_COMMAND: "/definitely/missing/devspace-pi", + }); + assert.equal(availability.available, false); + assert.match(availability.reason ?? "", /executable not found/); +} + +{ + const snapshot = getLocalAgentProviderAvailabilitySnapshot({ + ...process.env, + PI_COMMAND: "/definitely/missing/devspace-pi", + }); + assert.deepEqual( + snapshot.map((provider) => provider.name), + ["codex", "claude", "opencode", "pi", "cursor", "copilot"], + ); + assert.equal(snapshot.find((provider) => provider.name === "pi")?.available, false); +} + +assert.equal( + formatLocalAgentProviderAvailabilitySummary([ + { name: "codex", available: true }, + { name: "pi", available: false, reason: "pi executable not found" }, + ]), + "available: codex; unavailable: pi (pi executable not found)", +); diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts new file mode 100644 index 00000000..747f304f --- /dev/null +++ b/src/local-agent-availability.ts @@ -0,0 +1,151 @@ +import { spawnSync } from "node:child_process"; +import { delimiter, resolve } from "node:path"; +import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; +import { + LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; + +export interface LocalAgentProviderAvailability { + name: LocalAgentProvider; + available: boolean; + reason?: string; +} + +export function getLocalAgentProviderAvailabilitySnapshot( + env: NodeJS.ProcessEnv = process.env, +): LocalAgentProviderAvailability[] { + return LOCAL_AGENT_PROVIDERS.map((provider) => checkLocalAgentProviderAvailability(provider, env)); +} + +export function checkLocalAgentProviderAvailability( + provider: LocalAgentProvider, + env: NodeJS.ProcessEnv = process.env, +): LocalAgentProviderAvailability { + switch (provider) { + case "codex": + return packageAvailability(provider, "@openai/codex-sdk"); + case "claude": + return packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); + case "opencode": + return packageAvailability(provider, "@opencode-ai/sdk/v2"); + case "pi": + return commandAvailability(provider, env.PI_COMMAND ?? "pi", { + env: piAvailabilityEnvironment(env), + }); + case "cursor": + return commandAvailability(provider, "cursor-agent"); + case "copilot": + return commandAvailability(provider, "copilot"); + } +} + +export function assertLocalAgentProviderAvailable( + provider: LocalAgentProvider, + env: NodeJS.ProcessEnv = process.env, +): void { + const availability = checkLocalAgentProviderAvailability(provider, env); + if (availability.available) return; + throw new Error( + `${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`, + ); +} + +export function formatLocalAgentProviderAvailabilitySummary( + providers: LocalAgentProviderAvailability[], +): string { + const available = providers + .filter((provider) => provider.available) + .map((provider) => provider.name); + const unavailable = providers + .filter((provider) => !provider.available) + .map((provider) => `${provider.name} (${provider.reason ?? "unavailable"})`); + return [ + available.length > 0 ? `available: ${available.join(", ")}` : undefined, + unavailable.length > 0 ? `unavailable: ${unavailable.join(", ")}` : undefined, + ].filter(Boolean).join("; "); +} + +function packageAvailability( + provider: LocalAgentProvider, + packageName: string, +): LocalAgentProviderAvailability { + try { + import.meta.resolve(packageName); + return { name: provider, available: true }; + } catch { + return { + name: provider, + available: false, + reason: `${packageName} package not found`, + }; + } +} + +function commandAvailability( + provider: LocalAgentProvider, + command: string, + options: { env?: NodeJS.ProcessEnv } = {}, +): LocalAgentProviderAvailability { + const executable = resolveCommand(command, options.env); + if (!executable) { + return { + name: provider, + available: false, + reason: `${command} executable not found`, + }; + } + + return { name: provider, available: true }; +} + +function resolveCommand(command: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + const commandHasPath = command.includes("/") || command.includes("\\"); + if (commandHasPath) return executableExists(command, env) ? command : undefined; + + for (const candidate of candidateCommandPaths(command, env)) { + if (executableExists(candidate, env)) return candidate; + } + return undefined; +} + +function candidateCommandPaths(command: string, env: NodeJS.ProcessEnv): string[] { + const path = env.PATH; + if (!path) return []; + const extensions = process.platform === "win32" + ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter(Boolean) + : [""]; + const candidates: string[] = []; + for (const directory of path.split(delimiter)) { + if (!directory) continue; + for (const extension of extensions) { + candidates.push(resolve(directory, `${command}${extension}`)); + } + } + return candidates; +} + +function executableExists(command: string, env: NodeJS.ProcessEnv): boolean { + const result = spawnSync(command, ["--version"], { + encoding: "utf8", + env, + windowsHide: true, + timeout: 5_000, + }); + const code = typeof result.error === "object" && result.error && "code" in result.error + ? result.error.code + : undefined; + return code !== "ENOENT"; +} + +function piAvailabilityEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (env.PI_COMMAND) return env; + const path = env.PATH; + if (!path) return env; + return { + ...env, + PATH: removeDevspaceNodeModulesBinFromPath(path), + }; +} diff --git a/src/local-agent-path.ts b/src/local-agent-path.ts new file mode 100644 index 00000000..c8ff2935 --- /dev/null +++ b/src/local-agent-path.ts @@ -0,0 +1,26 @@ +import { existsSync, readFileSync } from "node:fs"; +import { delimiter, resolve, sep } from "node:path"; + +export function removeDevspaceNodeModulesBinFromPath(pathValue: string): string { + return pathValue + .split(delimiter) + .filter((entry) => entry && !isDevspaceNodeModulesBin(entry)) + .join(delimiter); +} + +function isDevspaceNodeModulesBin(pathEntry: string): boolean { + const resolvedEntry = resolve(pathEntry); + if (!resolvedEntry.endsWith(`${sep}node_modules${sep}.bin`)) { + return false; + } + + const packageJson = resolve(resolvedEntry, "..", "..", "package.json"); + if (!existsSync(packageJson)) return false; + + try { + const packageInfo = JSON.parse(readFileSync(packageJson, "utf8")) as { name?: unknown }; + return packageInfo.name === "@waishnav/devspace"; + } catch { + return false; + } +} diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts new file mode 100644 index 00000000..4bb0bea0 --- /dev/null +++ b/src/local-agent-profiles.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "./config.js"; +import { loadLocalAgentProfiles, summarizeLocalAgentProfile } from "./local-agent-profiles.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-agent-profiles-test-")); + +try { + const configDir = join(root, ".devspace-home"); + const workspaceRoot = join(root, "project"); + await mkdir(join(configDir, "agents"), { recursive: true }); + await mkdir(join(workspaceRoot, ".devspace", "agents"), { recursive: true }); + + await writeFile( + join(configDir, "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + "description: Global reviewer.", + "provider: codex", + "model: gpt-5.4", + "---", + "", + "Global body.", + "", + ].join("\n"), + ); + await writeFile( + join(workspaceRoot, ".devspace", "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + 'description: "Project reviewer #1."', + "provider: claude", + "model: sonnet", + "---", + "", + "Project body.", + "", + ].join("\n"), + ); + await writeFile( + join(workspaceRoot, ".devspace", "agents", "disabled.md"), + [ + "---", + "name: disabled", + "description: Disabled agent.", + "provider: codex", + "disabled: true", + "---", + "", + "Disabled body.", + "", + ].join("\n"), + ); + + const enabledConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: workspaceRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }); + const profiles = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); + + assert.equal(profiles.length, 1); + assert.equal(profiles[0]?.name, "reviewer"); + assert.equal(profiles[0]?.description, "Project reviewer #1."); + assert.equal(profiles[0]?.provider, "claude"); + assert.equal(profiles[0]?.model, "sonnet"); + assert.equal(profiles[0]?.body, "Project body."); + assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { + name: "reviewer", + description: "Project reviewer #1.", + provider: "claude", + model: "sonnet", + }); + + await writeFile( + join(workspaceRoot, ".devspace", "agents", "custom.md"), + [ + "---", + "name: custom", + "description: Unsupported custom agent.", + "provider: custom", + "---", + "", + "Custom body.", + "", + ].join("\n"), + ); + const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); + assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); + + const disabledConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: workspaceRoot, + DEVSPACE_SUBAGENTS: "0", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }); + assert.deepEqual(await loadLocalAgentProfiles(disabledConfig, workspaceRoot), []); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts new file mode 100644 index 00000000..3c0f7123 --- /dev/null +++ b/src/local-agent-profiles.ts @@ -0,0 +1,189 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import type { ServerConfig } from "./config.js"; + +export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot"; + +export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", +]; + +export interface LocalAgentProfile { + name: string; + description: string; + provider: LocalAgentProvider; + model?: string; + filePath: string; + body: string; + disabled: boolean; +} + +export interface LocalAgentProfileSummary { + name: string; + description: string; + provider: LocalAgentProvider; + model?: string; +} + +interface ParsedFrontmatter { + frontmatter: Record; + body: string; +} + +const FRONTMATTER_DELIMITER = "---"; +const PROVIDERS = new Set(LOCAL_AGENT_PROVIDERS); + +export async function loadLocalAgentProfiles( + config: ServerConfig, + workspaceRoot: string, +): Promise { + if (!config.subagents) return []; + + const profileDirs = [ + config.devspaceAgentsDir, + join(workspaceRoot, ".devspace", "agents"), + ]; + const profilesByName = new Map(); + + for (const directory of profileDirs) { + for (const profile of await loadProfilesFromDirectory(directory)) { + profilesByName.set(profile.name, profile); + } + } + + return Array.from(profilesByName.values()) + .filter((profile) => !profile.disabled) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function summarizeLocalAgentProfile( + profile: LocalAgentProfile, +): LocalAgentProfileSummary { + return { + name: profile.name, + description: profile.description, + provider: profile.provider, + model: profile.model, + }; +} + +async function loadProfilesFromDirectory(directory: string): Promise { + const resolvedDirectory = resolve(directory); + if (!existsSync(resolvedDirectory)) return []; + + const entries = await readdir(resolvedDirectory, { withFileTypes: true }); + const profiles: LocalAgentProfile[] = []; + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith(".md")) continue; + + const filePath = join(resolvedDirectory, entry.name); + try { + profiles.push(await loadProfileFile(filePath)); + } catch (error) { + console.warn(`Skipping invalid subagent profile ${filePath}: ${errorMessage(error)}`); + } + } + + return profiles; +} + +async function loadProfileFile(filePath: string): Promise { + const content = await readFile(filePath, "utf8"); + const parsed = parseFrontmatter(content, filePath); + return profileFromFrontmatter(parsed.frontmatter, parsed.body, filePath); +} + +function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter { + const normalized = content.replace(/^\uFEFF/, ""); + const lines = normalized.split(/\r?\n/); + if (lines[0]?.trim() !== FRONTMATTER_DELIMITER) { + throw new Error(`Subagent profile is missing frontmatter: ${filePath}`); + } + + const endIndex = lines.findIndex( + (line, index) => index > 0 && line.trim() === FRONTMATTER_DELIMITER, + ); + if (endIndex === -1) { + throw new Error(`Subagent profile frontmatter is not closed: ${filePath}`); + } + + return { + frontmatter: parseProfileYaml(lines.slice(1, endIndex).join("\n"), filePath), + body: lines.slice(endIndex + 1).join("\n").trim(), + }; +} + +function parseProfileYaml(source: string, filePath: string): Record { + let parsed: unknown; + try { + parsed = parseYaml(source) ?? {}; + } catch (error) { + throw new Error(`Unable to parse subagent profile frontmatter: ${filePath}: ${errorMessage(error)}`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Subagent profile frontmatter must be a mapping: ${filePath}`); + } + + return parsed as Record; +} + +function profileFromFrontmatter( + frontmatter: Record, + body: string, + filePath: string, +): LocalAgentProfile { + const name = readString(frontmatter, "name") ?? basename(filePath, ".md"); + const description = readString(frontmatter, "description"); + const provider = readProvider(frontmatter, filePath); + if (!description) { + throw new Error(`Subagent profile is missing description: ${filePath}`); + } + + return { + name, + description, + provider, + model: readString(frontmatter, "model"), + filePath, + body, + disabled: frontmatter.disabled === true, + }; +} + +function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { + const provider = readString(frontmatter, "provider"); + if (!provider) { + throw new Error(`Subagent profile is missing provider: ${filePath}`); + } + if (!PROVIDERS.has(provider as LocalAgentProvider)) { + throw new Error( + `Subagent profile provider must be codex, claude, opencode, pi, cursor, or copilot: ${filePath}`, + ); + } + return provider as LocalAgentProvider; +} + +export function isLocalAgentProvider(value: string): value is LocalAgentProvider { + return PROVIDERS.has(value as LocalAgentProvider); +} + +function readString(frontmatter: Record, key: string): string | undefined { + const value = frontmatter[key]; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts new file mode 100644 index 00000000..c56bb46c --- /dev/null +++ b/src/local-agent-runtime.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import type { RunResult, ThreadOptions } from "@openai/codex-sdk"; +import { + CodexSdkLocalAgentRuntime, + createCodexSdkLocalAgentRuntime, +} from "./local-agent-runtime.js"; + +const emptyTurn = (finalResponse: string): RunResult => ({ + finalResponse, + items: [], + usage: null, +}); + +class FakeThread { + prompts: string[] = []; + + constructor(readonly id: string | null) {} + + async run(prompt: string): Promise { + this.prompts.push(prompt); + return emptyTurn(`response:${prompt}`); + } +} + +class FakeCodex { + started: ThreadOptions[] = []; + resumed: Array<{ id: string; options?: ThreadOptions }> = []; + readonly startThreadInstance = new FakeThread("new-thread"); + readonly resumeThreadInstance = new FakeThread("resumed-thread"); + + startThread(options?: ThreadOptions): FakeThread { + this.started.push(options ?? {}); + return this.startThreadInstance; + } + + resumeThread(id: string, options?: ThreadOptions): FakeThread { + this.resumed.push({ id, options }); + return this.resumeThreadInstance; + } +} + +const codex = new FakeCodex(); +const runtime = new CodexSdkLocalAgentRuntime(codex); +const readOnly = await runtime.run({ + prompt: "inspect only", + workspace: "/tmp/project", +}); + +assert.equal(readOnly.provider, "codex"); +assert.equal(readOnly.providerSessionId, "new-thread"); +assert.equal(readOnly.finalResponse, "response:inspect only"); +assert.deepEqual(codex.startThreadInstance.prompts, ["inspect only"]); +assert.deepEqual(codex.started[0], { + workingDirectory: "/tmp/project", + sandboxMode: "read-only", + approvalPolicy: "never", + model: undefined, +}); + +await runtime.run({ + prompt: "make change", + workspace: "/tmp/project", + writeMode: "allowed", + model: "gpt-5.4", +}); + +assert.deepEqual(codex.started[1], { + workingDirectory: "/tmp/project", + sandboxMode: "workspace-write", + approvalPolicy: "never", + model: "gpt-5.4", +}); + +const resumed = await runtime.run({ + prompt: "continue", + workspace: "/tmp/project", + providerSessionId: "existing-thread", + writeMode: "full_access", +}); + +assert.equal(resumed.providerSessionId, "resumed-thread"); +assert.deepEqual(codex.resumeThreadInstance.prompts, ["continue"]); +assert.deepEqual(codex.resumed, [ + { + id: "existing-thread", + options: { + workingDirectory: "/tmp/project", + sandboxMode: "danger-full-access", + approvalPolicy: "never", + model: undefined, + }, + }, +]); + +const created = await createCodexSdkLocalAgentRuntime(undefined, () => new FakeCodex()); +assert.equal(created.provider, "codex"); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts new file mode 100644 index 00000000..949e73ff --- /dev/null +++ b/src/local-agent-runtime.ts @@ -0,0 +1,99 @@ +import type { + Codex, + CodexOptions, + RunResult, + SandboxMode, + ThreadOptions, +} from "@openai/codex-sdk"; + +export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; + +export interface LocalAgentRunInput { + prompt: string; + workspace: string; + providerSessionId?: string; + writeMode?: LocalAgentWriteMode; + model?: string; +} + +export interface LocalAgentRunResult { + provider: string; + providerSessionId: string | null; + finalResponse: string; + items: unknown[]; +} + +export interface LocalAgentRuntime { + readonly provider: string; + run(input: LocalAgentRunInput): Promise; +} + +interface CodexThreadLike { + readonly id: string | null; + run(prompt: string): Promise; +} + +interface CodexClientLike { + startThread(options?: ThreadOptions): CodexThreadLike; + resumeThread(id: string, options?: ThreadOptions): CodexThreadLike; +} + +type CodexFactory = (options?: CodexOptions) => CodexClientLike; + +function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): SandboxMode { + switch (writeMode) { + case "allowed": + return "workspace-write"; + case "full_access": + return "danger-full-access"; + case "read_only": + case undefined: + return "read-only"; + } +} + +function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { + return { + workingDirectory: input.workspace, + sandboxMode: sandboxModeFor(input.writeMode), + approvalPolicy: "never", + model: input.model, + }; +} + +export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { + readonly provider = "codex" as const; + private readonly codex: CodexClientLike; + + constructor(codex: CodexClientLike) { + this.codex = codex; + } + + async run(input: LocalAgentRunInput): Promise { + const options = threadOptionsFor(input); + const thread = input.providerSessionId + ? this.codex.resumeThread(input.providerSessionId, options) + : this.codex.startThread(options); + const turn = await thread.run(input.prompt); + + return { + provider: this.provider, + providerSessionId: thread.id, + finalResponse: turn.finalResponse, + items: turn.items, + }; + } +} + +export async function createCodexSdkLocalAgentRuntime( + options?: CodexOptions, + codexFactory?: CodexFactory, +): Promise { + const factory = codexFactory ?? (await defaultCodexFactory()); + return new CodexSdkLocalAgentRuntime(factory(options)); +} + +async function defaultCodexFactory(): Promise { + const module = await import("@openai/codex-sdk"); + return (options) => new module.Codex(options) as Codex; +} diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts new file mode 100644 index 00000000..caf3c521 --- /dev/null +++ b/src/local-agent-store.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LocalAgentStore } from "./local-agent-store.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-local-agent-store-test-")); +const stores: LocalAgentStore[] = []; + +try { + const store = new LocalAgentStore(root); + stores.push(store); + const created = store.create({ + workspaceId: "ws_1", + workspaceRoot: join(root, "project"), + profileName: "reviewer", + provider: "codex", + model: "gpt-5.4", + }); + + assert.match(created.id, /^agt_[a-f0-9]{8}$/); + assert.equal(created.status, "starting"); + assert.equal(store.get(created.id)?.profileName, "reviewer"); + assert.equal(store.get(created.id.slice(0, 7))?.id, created.id); + + const updated = store.update(created.id, { + status: "idle", + latestResponse: "done", + providerSessionId: "thread_123", + }); + + assert.equal(updated.status, "idle"); + assert.equal(store.get("thread_123")?.id, created.id); + assert.equal(store.update(created.id, { latestResponse: undefined }).latestResponse, undefined); + assert.deepEqual( + store.list({ workspaceRoot: join(root, "project") }).map((agent) => agent.latestResponse), + [undefined], + ); + assert.deepEqual(store.list({ workspaceId: "ws_1" }).map((agent) => agent.id), [created.id]); + assert.deepEqual(store.list({ workspaceId: "ws_other" }), []); + assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); + + const otherStore = new LocalAgentStore(root); + stores.push(otherStore); + const createdFromOtherStore = otherStore.create({ + workspaceId: "ws_1", + workspaceRoot: join(root, "project"), + profileName: "explorer", + provider: "claude", + }); + + assert.deepEqual( + store.list({ workspaceId: "ws_1" }).map((agent) => agent.id).sort(), + [created.id, createdFromOtherStore.id].sort(), + ); +} finally { + for (const store of stores) { + store.close(); + } + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts new file mode 100644 index 00000000..a2629856 --- /dev/null +++ b/src/local-agent-store.ts @@ -0,0 +1,239 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import type { ServerConfig } from "./config.js"; + +export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; + +export interface LocalAgentRecord { + id: string; + workspaceId?: string; + workspaceRoot: string; + profileName: string; + provider: string; + model?: string; + providerSessionId?: string; + status: LocalAgentStatus; + latestResponse?: string; + error?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateLocalAgentRecordInput { + workspaceId?: string; + workspaceRoot: string; + profileName: string; + provider: string; + model?: string; +} + +export interface LocalAgentListScope { + workspaceId?: string; + workspaceRoot?: string; +} + +interface LocalAgentRow { + id: string; + workspace_id: string | null; + workspace_root: string; + profile_name: string; + provider: string; + model: string | null; + provider_session_id: string | null; + status: string; + latest_response: string | null; + error: string | null; + created_at: string; + updated_at: string; +} + +export class LocalAgentStore { + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + } + + list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { + let rows: LocalAgentRow[]; + if (scope.workspaceId) { + rows = this.database.sqlite + .prepare( + `select * from local_agent_sessions + where workspace_id = ? + order by updated_at desc`, + ) + .all(scope.workspaceId) as LocalAgentRow[]; + } else if (scope.workspaceRoot) { + rows = this.database.sqlite + .prepare( + `select * from local_agent_sessions + where workspace_root = ? + order by updated_at desc`, + ) + .all(resolve(scope.workspaceRoot)) as LocalAgentRow[]; + } else { + rows = this.database.sqlite + .prepare("select * from local_agent_sessions order by updated_at desc") + .all() as LocalAgentRow[]; + } + + return rows.map(rowToLocalAgentRecord); + } + + create(input: CreateLocalAgentRecordInput): LocalAgentRecord { + const now = new Date().toISOString(); + const record: LocalAgentRecord = { + id: `agt_${randomUUID().replaceAll("-", "").slice(0, 8)}`, + workspaceId: input.workspaceId, + workspaceRoot: resolve(input.workspaceRoot), + profileName: input.profileName, + provider: input.provider, + model: input.model, + status: "starting", + createdAt: now, + updatedAt: now, + }; + + this.database.sqlite + .prepare( + `insert into local_agent_sessions ( + id, + workspace_id, + workspace_root, + profile_name, + provider, + model, + status, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + record.id, + record.workspaceId ?? null, + record.workspaceRoot, + record.profileName, + record.provider, + record.model ?? null, + record.status, + record.createdAt, + record.updatedAt, + ); + + return record; + } + + get(idOrPrefix: string): LocalAgentRecord | undefined { + const exact = this.database.sqlite + .prepare( + `select * from local_agent_sessions + where id = ? or provider_session_id = ? + limit 1`, + ) + .get(idOrPrefix, idOrPrefix) as LocalAgentRow | undefined; + if (exact) return rowToLocalAgentRecord(exact); + + const matches = this.database.sqlite + .prepare( + `select * from local_agent_sessions + where id like ? escape '\\' or provider_session_id like ? escape '\\' + order by updated_at desc`, + ) + .all(`${escapeLike(idOrPrefix)}%`, `${escapeLike(idOrPrefix)}%`) as LocalAgentRow[]; + + return matches.length === 1 ? rowToLocalAgentRecord(matches[0]!) : undefined; + } + + update(id: string, patch: Partial>): LocalAgentRecord { + const current = this.getById(id); + if (!current) throw new Error(`Unknown subagent id: ${id}`); + + const updated: LocalAgentRecord = { + ...current, + ...patch, + updatedAt: new Date().toISOString(), + }; + + this.database.sqlite + .prepare( + `update local_agent_sessions set + workspace_id = ?, + workspace_root = ?, + profile_name = ?, + provider = ?, + model = ?, + provider_session_id = ?, + status = ?, + latest_response = ?, + error = ?, + updated_at = ? + where id = ?`, + ) + .run( + updated.workspaceId ?? null, + resolve(updated.workspaceRoot), + updated.profileName, + updated.provider, + updated.model ?? null, + updated.providerSessionId ?? null, + updated.status, + updated.latestResponse ?? null, + updated.error ?? null, + updated.updatedAt, + updated.id, + ); + + return updated; + } + + close(): void { + this.database.close(); + } + + private getById(id: string): LocalAgentRecord | undefined { + const row = this.database.sqlite + .prepare("select * from local_agent_sessions where id = ?") + .get(id) as LocalAgentRow | undefined; + return row ? rowToLocalAgentRecord(row) : undefined; + } +} + +export function createLocalAgentStore(config: ServerConfig): LocalAgentStore { + return new LocalAgentStore(config.stateDir); +} + +function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { + return { + id: row.id, + workspaceId: row.workspace_id ?? undefined, + workspaceRoot: row.workspace_root, + profileName: row.profile_name, + provider: row.provider, + model: row.model ?? undefined, + providerSessionId: row.provider_session_id ?? undefined, + status: readStatus(row.status), + latestResponse: row.latest_response ?? undefined, + error: row.error ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function readStatus(status: string): LocalAgentStatus { + if ( + status === "starting" || + status === "running" || + status === "idle" || + status === "error" || + status === "stopped" + ) { + return status; + } + return "error"; +} + +function escapeLike(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); +} diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts new file mode 100644 index 00000000..7dac5cdc --- /dev/null +++ b/src/local-agent-targets.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { + formatAvailableLocalAgentTargets, + parseLocalAgentRunArgs, + resolveLocalAgentTarget, +} from "./local-agent-targets.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; + +const profiles: LocalAgentProfile[] = [ + { + name: "reviewer", + description: "Review changes.", + provider: "codex", + model: "gpt-5-codex", + filePath: "/workspace/.devspace/agents/reviewer.md", + body: "Review carefully.", + disabled: false, + }, + { + name: "claude", + description: "A profile that shadows the raw provider.", + provider: "opencode", + model: "qwen/custom", + filePath: "/workspace/.devspace/agents/claude.md", + body: "Use OpenCode.", + disabled: false, + }, +]; + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "hello", "world"]), { + target: "codex", + prompt: "hello world", + model: undefined, +}); + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model", "gpt-5.1", "hello"]), { + target: "codex", + prompt: "hello", + model: "gpt-5.1", +}); + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model=gpt-5.1", "hello"]), { + target: "codex", + prompt: "hello", + model: "gpt-5.1", +}); + +assert.throws( + () => parseLocalAgentRunArgs(["codex", "--model"]), + /Missing value for --model/, +); + +{ + const target = resolveLocalAgentTarget("reviewer", profiles); + assert.equal(target?.kind, "profile"); + assert.equal(target?.name, "reviewer"); + assert.equal(target?.provider, "codex"); + assert.equal(target?.model, "gpt-5-codex"); +} + +{ + const target = resolveLocalAgentTarget("reviewer", profiles, "gpt-5.2"); + assert.equal(target?.kind, "profile"); + assert.equal(target?.model, "gpt-5.2"); +} + +{ + const target = resolveLocalAgentTarget("opencode", profiles); + assert.equal(target?.kind, "provider"); + assert.equal(target?.name, "opencode"); + assert.equal(target?.provider, "opencode"); + assert.equal(target?.model, undefined); +} + +{ + const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2"); + assert.equal(target?.kind, "provider"); + assert.equal(target?.model, "kimi-k2"); +} + +{ + const target = resolveLocalAgentTarget("claude", profiles); + assert.equal(target?.kind, "profile"); + assert.equal(target?.provider, "opencode"); +} + +assert.equal(resolveLocalAgentTarget("missing", profiles), undefined); +assert.match(formatAvailableLocalAgentTargets(profiles), /profiles: reviewer, claude/); +assert.match(formatAvailableLocalAgentTargets([]), /providers: codex, claude, opencode, pi, cursor, copilot/); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts new file mode 100644 index 00000000..a892d59a --- /dev/null +++ b/src/local-agent-targets.ts @@ -0,0 +1,98 @@ +import { + isLocalAgentProvider, + LOCAL_AGENT_PROVIDERS, + type LocalAgentProfile, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; + +export interface ParsedLocalAgentRunArgs { + target: string; + prompt: string; + model?: string; +} + +export type LocalAgentTarget = + | { + kind: "profile"; + name: string; + provider: LocalAgentProvider; + model?: string; + profile: LocalAgentProfile; + } + | { + kind: "provider"; + name: LocalAgentProvider; + provider: LocalAgentProvider; + model?: string; + }; + +export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs { + const [target, ...rest] = args; + if (!target) { + throw new Error('Usage: devspace agents run [--model ] ""'); + } + + let model: string | undefined; + const promptParts: string[] = []; + for (let index = 0; index < rest.length; index += 1) { + const part = rest[index]; + if (part === "--model") { + const value = rest[index + 1]?.trim(); + if (!value) throw new Error("Missing value for --model."); + model = value; + index += 1; + continue; + } + if (part?.startsWith("--model=")) { + const value = part.slice("--model=".length).trim(); + if (!value) throw new Error("Missing value for --model."); + model = value; + continue; + } + promptParts.push(part ?? ""); + } + + const prompt = promptParts.join(" ").trim(); + if (!prompt) { + throw new Error('Usage: devspace agents run [--model ] ""'); + } + + return { target, prompt, model }; +} + +export function resolveLocalAgentTarget( + target: string, + profiles: LocalAgentProfile[], + modelOverride?: string, +): LocalAgentTarget | undefined { + const profile = profiles.find((candidate) => candidate.name === target); + if (profile) { + return { + kind: "profile", + name: profile.name, + provider: profile.provider, + model: modelOverride ?? profile.model, + profile, + }; + } + + if (isLocalAgentProvider(target)) { + return { + kind: "provider", + name: target, + provider: target, + model: modelOverride, + }; + } + + return undefined; +} + +export function formatAvailableLocalAgentTargets(profiles: LocalAgentProfile[]): string { + const profileNames = profiles.map((profile) => profile.name); + const parts = [ + profileNames.length > 0 ? `profiles: ${profileNames.join(", ")}` : undefined, + `providers: ${LOCAL_AGENT_PROVIDERS.join(", ")}`, + ].filter(Boolean); + return parts.join("; "); +} diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 2f2a873c..e1c00338 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -43,6 +43,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { assert.deepEqual(migrations, [ { version: 1, name: "workspace-state" }, { version: 2, name: "oauth-state" }, + { version: 3, name: "local-agent-sessions" }, ]); } finally { database.close(); diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 346c67cd..b050e790 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -50,12 +50,13 @@ assert.equal(foreground.sessionId, undefined); const environment = await manager.start({ workspaceId: "workspace-a", + workspaceRoot: "/tmp/devspace-workspace-a", cwd: process.cwd(), - command: `${node} -e "console.log([process.env.NO_COLOR, process.env.TERM, process.env.PAGER, process.env.GIT_PAGER, process.env.GH_PAGER, process.env.CODEX_CI].join(','))"`, + command: `${node} -e "console.log([process.env.NO_COLOR, process.env.TERM, process.env.PAGER, process.env.GIT_PAGER, process.env.GH_PAGER, process.env.CODEX_CI, process.env.DEVSPACE_WORKSPACE_ID, process.env.DEVSPACE_WORKSPACE_ROOT].join(','))"`, yieldTimeMs: 2_000, }); assert.equal(environment.running, false); -assert.match(environment.output, /1,dumb,cat,cat,cat,1/); +assert.match(environment.output, /1,dumb,cat,cat,cat,1,workspace-a,\/tmp\/devspace-workspace-a/); const background = await manager.start({ workspaceId: "workspace-a", diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 9884df12..f414df19 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -16,6 +16,7 @@ export interface StartCommandInput { workspaceId: string; command: string; cwd: string; + workspaceRoot?: string; tty?: boolean; columns?: number; rows?: number; @@ -86,7 +87,10 @@ function terminalSize(value: number | undefined, fallback: number): number { return value; } -function processEnvironment(): Record { +function processEnvironment(input?: { + workspaceId?: string; + workspaceRoot?: string; +}): Record { return { ...Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), @@ -99,6 +103,8 @@ function processEnvironment(): Record { CODEX_CI: "1", LANG: process.env.LANG ?? "C.UTF-8", LC_ALL: process.env.LC_ALL ?? "C.UTF-8", + ...(input?.workspaceId ? { DEVSPACE_WORKSPACE_ID: input.workspaceId } : {}), + ...(input?.workspaceRoot ? { DEVSPACE_WORKSPACE_ROOT: input.workspaceRoot } : {}), }; } @@ -321,7 +327,10 @@ export class ProcessSessionManager { const detached = process.platform !== "win32"; const child = spawn(input.command, { cwd: input.cwd, - env: processEnvironment(), + env: processEnvironment({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + }), stdio: "pipe", windowsHide: true, detached, @@ -352,7 +361,10 @@ export class ProcessSessionManager { try { pty = nodePty.spawn(shell.executable, shell.args, { cwd: input.cwd, - env: processEnvironment(), + env: processEnvironment({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + }), name: "xterm-256color", cols: session.columns, rows: session.rows, diff --git a/src/server.ts b/src/server.ts index 9b1c0dbe..fb317a7b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,6 +41,12 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; +import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import { + formatLocalAgentProviderAvailabilitySummary, + getLocalAgentProviderAvailabilitySnapshot, + type LocalAgentProviderAvailability, +} from "./local-agent-availability.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; @@ -67,6 +73,7 @@ const SHELL_TOOL_ANNOTATIONS = { interface RunningServer { app: ReturnType; config: ServerConfig; + localAgentProviders: LocalAgentProviderAvailability[]; close(): void; } @@ -208,6 +215,25 @@ function serverInstructions(config: ServerConfig, toolNames: ToolNames): string return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; } + +function formatVisibleAgent(agent: { + name: string; + provider: string; + model?: string; + providerAvailable?: boolean; + providerUnavailableReason?: string; +}): string { + const model = agent.model ? `, model ${agent.model}` : ""; + const availability = agent.providerAvailable === false + ? `, unavailable: ${agent.providerUnavailableReason ?? "provider unavailable"}` + : ""; + return `${agent.name} (${agent.provider}${model}${availability})`; +} + +function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability): string { + return `${provider.name} (${provider.reason ?? "unavailable"})`; +} + function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { result: z @@ -230,6 +256,21 @@ const workspaceAgentsFileOutputSchema = z.object({ content: z.string(), }); +const workspaceLocalAgentOutputSchema = z.object({ + name: z.string(), + description: z.string(), + provider: z.string(), + model: z.string().optional(), + providerAvailable: z.boolean().optional(), + providerUnavailableReason: z.string().optional(), +}); + +const workspaceLocalAgentProviderOutputSchema = z.object({ + name: z.string(), + available: z.boolean(), + reason: z.string().optional(), +}); + const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); @@ -562,6 +603,7 @@ function registerCodexProcessTools( workspaceId, command: cmd, cwd, + workspaceRoot: workspace.root, tty, columns, rows, @@ -657,6 +699,7 @@ function createMcpServer( workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, + localAgentProviders: LocalAgentProviderAvailability[], ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -745,6 +788,8 @@ function createMcpServer( agentsFiles: z.array(workspaceAgentsFileOutputSchema), availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), skills: z.array(workspaceSkillOutputSchema), + agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), + agents: z.array(workspaceLocalAgentOutputSchema), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), }, @@ -767,6 +812,16 @@ function createMcpServer( description: skill.description, path: formatPathForPrompt(skill.filePath), })); + const visibleAgentProviders = config.subagents ? localAgentProviders : []; + const visibleAgents = workspace.agentProfiles.map((profile) => { + const summary = summarizeLocalAgentProfile(profile); + const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider); + return { + ...summary, + providerAvailable: availability?.available, + providerUnavailableReason: availability?.reason, + }; + }); const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, @@ -793,6 +848,15 @@ function createMcpServer( visibleSkills.length > 0 ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, + visibleAgentProviders.some((provider) => provider.available) + ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}` + : undefined, + visibleAgentProviders.some((provider) => !provider.available) + ? `Unavailable subagent providers: ${visibleAgentProviders.filter((provider) => !provider.available).map(formatUnavailableAgentProvider).join(", ")}` + : undefined, + visibleAgents.length > 0 + ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` + : undefined, instruction, ].filter(Boolean).join("\n"), }, @@ -817,6 +881,8 @@ function createMcpServer( agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, + agentProviders: visibleAgentProviders.length, + agents: visibleAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, }, }, @@ -830,6 +896,8 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, + agentProviders: visibleAgentProviders, + agents: visibleAgents, skillDiagnostics: workspace.skillDiagnostics, instruction, }, @@ -1569,6 +1637,9 @@ export function createServer(config = loadConfig()): RunningServer { const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); + const localAgentProviders = config.subagents + ? getLocalAgentProviderAvailabilitySnapshot() + : []; if (config.logging.trustProxy) { app.set("trust proxy", true); @@ -1692,7 +1763,13 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions); + const server = createMcpServer( + config, + workspaces, + reviewCheckpoints, + processSessions, + localAgentProviders, + ); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1715,6 +1792,7 @@ export function createServer(config = loadConfig()): RunningServer { return { app, config, + localAgentProviders, close: () => { if (closed) return; closed = true; @@ -1734,7 +1812,7 @@ async function isMainModule(): Promise { } if (await isMainModule()) { - const { app, config, close } = createServer(); + const { app, config, close, localAgentProviders } = createServer(); const httpServer = app.listen(config.port, config.host, () => { console.log( `devspace listening on http://${config.host}:${config.port}/mcp`, @@ -1745,6 +1823,9 @@ if (await isMainModule()) { console.log(`request logging: ${config.logging.requests ? "enabled" : "disabled"}`); console.log(`asset logging: ${config.logging.assets ? "enabled" : "disabled"}`); console.log(`trust proxy: ${config.logging.trustProxy ? "enabled" : "disabled"}`); + if (config.subagents) { + console.log(`subagent providers: ${formatLocalAgentProviderAvailabilitySummary(localAgentProviders)}`); + } }); const shutdown = () => { diff --git a/src/skills.test.ts b/src/skills.test.ts index af396bbc..707dda26 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -31,10 +31,10 @@ try { await mkdir(join(projectClaudeSkills, "claude-project-skill"), { recursive: true }); await mkdir(join(projectRoot, ".pi", "skills", "project-skill"), { recursive: true }); await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); - await mkdir(join(agentDir, "skills", "local-agent-delegation"), { recursive: true }); + await mkdir(join(agentDir, "skills", "subagent-delegation"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); - await mkdir(join(explicitSkills, "local-agent-delegation"), { recursive: true }); + await mkdir(join(explicitSkills, "subagent-delegation"), { recursive: true }); await mkdir(join(devspaceSkills, "devspace-local-skill"), { recursive: true }); await writeFile( @@ -126,25 +126,25 @@ try { ].join("\n"), ); await writeFile( - join(agentDir, "skills", "local-agent-delegation", "SKILL.md"), + join(agentDir, "skills", "subagent-delegation", "SKILL.md"), [ "---", - "name: local-agent-delegation", - "description: Hidden local agent skill winner.", + "name: subagent-delegation", + "description: Hidden subagent skill winner.", "---", "", - "# Local Agent Delegation", + "# Subagent Delegation", ].join("\n"), ); await writeFile( - join(explicitSkills, "local-agent-delegation", "SKILL.md"), + join(explicitSkills, "subagent-delegation", "SKILL.md"), [ "---", - "name: local-agent-delegation", - "description: Hidden local agent skill loser.", + "name: subagent-delegation", + "description: Hidden subagent skill loser.", "---", "", - "# Local Agent Delegation Duplicate", + "# Subagent Delegation Duplicate", ].join("\n"), ); await writeFile( @@ -184,13 +184,13 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "claude-project-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), false); assert.equal(loaded.skills.some((skill) => skill.name === "devspace-local-skill"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "local-agent-delegation"), false); + assert.equal(loaded.skills.some((skill) => skill.name === "subagent-delegation"), false); assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); assert.equal( loaded.diagnostics.some( - (diagnostic) => diagnostic.collision?.name === "local-agent-delegation", + (diagnostic) => diagnostic.collision?.name === "subagent-delegation", ), false, ); @@ -198,13 +198,13 @@ try { const experimentalConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); assert.equal( loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some( - (skill) => skill.name === "local-agent-delegation", + (skill) => skill.name === "subagent-delegation", ), true, ); diff --git a/src/skills.ts b/src/skills.ts index c71b7ef7..c1f146a9 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -21,15 +21,15 @@ export interface SkillReadResolution { isSkillFile: boolean; } -const LOCAL_AGENT_DELEGATION_NAME = "local-agent-delegation"; -const LOCAL_AGENT_DELEGATION_SKILL = join(LOCAL_AGENT_DELEGATION_NAME, "SKILL.md"); +const SUBAGENT_DELEGATION_NAME = "subagent-delegation"; +const SUBAGENT_DELEGATION_SKILL = join(SUBAGENT_DELEGATION_NAME, "SKILL.md"); function bundledSkillsDir(): string { return fileURLToPath(new URL("../skills", import.meta.url)); } -function hasLocalAgentDelegationSkill(skillDir: string): boolean { - return existsSync(join(skillDir, LOCAL_AGENT_DELEGATION_SKILL)); +function hasSubagentDelegationSkill(skillDir: string): boolean { + return existsSync(join(skillDir, SUBAGENT_DELEGATION_SKILL)); } export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] { @@ -39,7 +39,7 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] resolve(cwd, ".agents", "skills"), config.devspaceSkillsDir, join(config.agentDir, "skills"), - config.localAgents && !hasLocalAgentDelegationSkill(config.devspaceSkillsDir) + config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir) ? bundledSkills : undefined, ]; @@ -71,13 +71,13 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk includeDefaults: false, }); - if (config.localAgents) return result; + if (config.subagents) return result; return { - skills: result.skills.filter((skill) => skill.name !== LOCAL_AGENT_DELEGATION_NAME), + skills: result.skills.filter((skill) => skill.name !== SUBAGENT_DELEGATION_NAME), diagnostics: result.diagnostics.filter((diagnostic) => { const collision = diagnostic.collision; - return !(collision?.resourceType === "skill" && collision.name === LOCAL_AGENT_DELEGATION_NAME); + return !(collision?.resourceType === "skill" && collision.name === SUBAGENT_DELEGATION_NAME); }), }; } diff --git a/src/user-config.ts b/src/user-config.ts index b8c9a0ba..c8da90b5 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -18,7 +18,7 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; - localAgents?: boolean; + subagents?: boolean; } export interface DevspaceAuthConfig { @@ -51,6 +51,10 @@ export function devspaceSkillsDir(env: NodeJS.ProcessEnv = process.env): string return join(devspaceConfigDir(env), "skills"); } +export function devspaceAgentsDir(env: NodeJS.ProcessEnv = process.env): string { + return join(devspaceConfigDir(env), "agents"); +} + export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): DevspaceFiles { const dir = devspaceConfigDir(env); const configPath = join(dir, "config.json"); @@ -94,21 +98,21 @@ export function generateOwnerToken(): string { } export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env): string[] { - const targetPath = join(devspaceSkillsDir(env), "local-agent-delegation", "SKILL.md"); + const targetPath = join(devspaceSkillsDir(env), "subagent-delegation", "SKILL.md"); if (existsSync(targetPath)) return []; - const sourcePath = new URL("../skills/local-agent-delegation/SKILL.md", import.meta.url); + const sourcePath = new URL("../skills/subagent-delegation/SKILL.md", import.meta.url); mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, readFileSync(sourcePath, "utf8"), { mode: 0o644 }); return [targetPath]; } -export function resolveLocalAgentsFlag( - config: Pick, +export function resolveSubagentsFlag( + config: Pick, env: NodeJS.ProcessEnv = process.env, ): boolean | undefined { - if (env.DEVSPACE_LOCAL_AGENTS === undefined) return config.localAgents; - return ["1", "true", "yes", "on"].includes(env.DEVSPACE_LOCAL_AGENTS.toLowerCase()); + if (env.DEVSPACE_SUBAGENTS === undefined) return config.subagents; + return ["1", "true", "yes", "on"].includes(env.DEVSPACE_SUBAGENTS.toLowerCase()); } function readJsonFile(filePath: string): T { diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 554a3daa..87f6b35a 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -17,14 +17,30 @@ try { await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); await writeFile(join(root, "AGENTS.md"), "root instructions\n"); + await mkdir(join(root, ".devspace", "agents"), { recursive: true }); + await writeFile( + join(root, ".devspace", "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + "description: Read-only project reviewer.", + "provider: codex", + "---", + "", + "Review only.", + "", + ].join("\n"), + ); await mkdir(join(root, "nested")); await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n"); await writeFile(join(root, "nested", "file.txt"), "hello\n"); const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); @@ -40,6 +56,22 @@ try { availableAgentsFiles.map((file) => file.path), [join(root, "nested", "AGENTS.md")], ); + assert.deepEqual( + workspace.agentProfiles.map((profile) => ({ + name: profile.name, + description: profile.description, + provider: profile.provider, + body: profile.body, + })), + [ + { + name: "reviewer", + description: "Read-only project reviewer.", + provider: "codex", + body: "Review only.", + }, + ], + ); const missingWorkspaceRoot = join(root, "missing", "workspace"); const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); diff --git a/src/workspaces.ts b/src/workspaces.ts index 3b7b51e9..673d0823 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -13,6 +13,10 @@ import { type LoadedSkills, type SkillReadResolution, } from "./skills.js"; +import { + loadLocalAgentProfiles, + type LocalAgentProfile, +} from "./local-agent-profiles.js"; export interface LoadedAgentsFile { path: string; @@ -40,6 +44,7 @@ export interface Workspace { worktree?: WorkspaceWorktree; skills: LoadedSkills["skills"]; skillDiagnostics: LoadedSkills["diagnostics"]; + agentProfiles: LocalAgentProfile[]; activatedSkillDirs: Set; } @@ -110,6 +115,7 @@ export class WorkspaceRegistry { } : undefined, ...this.loadSkillsForWorkspace(root), + agentProfiles: [], activatedSkillDirs: new Set(), }; this.store?.touchSession(workspaceId); @@ -200,6 +206,7 @@ export class WorkspaceRegistry { sourceRoot: input.sourceRoot, worktree: input.worktree, ...this.loadSkillsForWorkspace(input.root), + agentProfiles: await loadLocalAgentProfiles(this.config, input.root), activatedSkillDirs: new Set(), };