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 new file mode 100644 index 00000000..382911b6 --- /dev/null +++ b/docs/agent-profile-schema.md @@ -0,0 +1,143 @@ +# Subagent profile schema + +DevSpace agent profiles are user-owned markdown files with YAML +frontmatter. They describe roles such as reviewer, explorer, or implementer. +DevSpace owns provider invocation. + +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: reviewer +description: Read-only reviewer for bugs, security risks, and missing tests. +provider: codex +model: gpt-5.4 +disabled: false +--- + +You are a read-only reviewer. Do not edit files. +Focus on correctness, security, test gaps, and maintainability. +Cite files and return concise findings. +``` + +## Frontmatter fields + +### `schema` + +Optional schema identifier: + +```yaml +schema: devspace-agent/v1 +``` + +### `name` + +Stable profile identifier shown to the model and accepted by: + +```bash +devspace agents run "" +``` + +Use lowercase kebab-case names. If omitted, DevSpace uses the filename without +`.md`. + +### `description` + +Required short purpose. This is exposed by `open_workspace` so the supervising +model can choose the right profile. + +### `provider` + +Required built-in provider id: + +```yaml +provider: codex +provider: claude +provider: opencode +provider: pi +provider: cursor +provider: copilot +``` + +Unsupported or custom providers are rejected. DevSpace maps providers to their +native integration: + +- `codex`: Codex SDK +- `claude`: Claude Code SDK +- `opencode`: OpenCode SDK +- `pi`: Pi RPC mode +- `cursor`: ACP +- `copilot`: ACP + +### `model` + +Optional provider model id or alias. + +```yaml +model: gpt-5.4 +model: sonnet +``` + +### `disabled` + +Optional boolean. Disabled profiles are not exposed. + +```yaml +disabled: true +``` + +## Markdown body + +The body is the profile prompt prefix DevSpace prepends when launching that +profile. It is not included in `open_workspace` by default. + +Recommended body content: + +- When to use this profile. +- Whether the worker should act read-only or may make changes. +- Output format. +- Review or testing expectations. + +## Model-facing workflow + +The Subagent skill teaches only: + +```bash +devspace agents ls +devspace agents run "" +devspace agents show +``` + +`open_workspace` exposes compact profile metadata: + +```json +{ + "name": "reviewer", + "description": "Read-only reviewer for bugs, security risks, and missing tests.", + "provider": "codex", + "model": "gpt-5.4" +} +``` + +`devspace agents ls` lists existing subagent sessions for the current workspace; +it does not list profile definitions. + +The full profile body stays out of the model context until DevSpace launches the +profile. + +## Current non-goals + +- 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 8f75d57b..e0ec816a 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -83,12 +83,24 @@ DevSpace discovers standard Agent Skills from: - `~/.agents/skills` - project `.agents/skills` +- `~/.devspace/skills` It also keeps compatibility with: +- 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` +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. When `open_workspace` returns matching skills, the model should read the @@ -99,7 +111,12 @@ Skill paths may be outside the workspace. DevSpace only permits reading: - advertised `SKILL.md` files - 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_SKILLS=0` to hide skills from workspace output. Set +`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 84589ac3..1601e960 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,6 +92,7 @@ sessions. | Variable | Purpose | | --- | --- | | `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled 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. | @@ -99,12 +100,31 @@ DevSpace discovers standard Agent Skills from: - `~/.agents/skills` - project `.agents/skills` +- `~/.devspace/skills` It also keeps compatibility with: +- 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` +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. Example: diff --git a/docs/gotchas.md b/docs/gotchas.md index c6ef1d2e..779e37ef 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -197,12 +197,25 @@ DevSpace looks in standard Agent Skills locations: - `~/.agents/skills` - project `.agents/skills` +- `~/.devspace/skills` It also checks compatibility and custom paths: +- 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` +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 new file mode 100644 index 00000000..f4f2fb3b --- /dev/null +++ b/examples/agents/claude-implementer.md @@ -0,0 +1,31 @@ +--- +schema: devspace-agent/v1 +name: claude-implementer +description: Claude Code profile for larger implementation, refactor, and repair tasks. +provider: claude +model: sonnet +--- + +You are a local Claude Code implementation worker under supervisor review. + +Use this profile for multi-file implementation, careful refactors, and test +repair loops when the user asked for delegated implementation. + +Rules: + +- Keep changes focused. +- 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: +risks: +blockers: +follow_up_needed: +``` diff --git a/examples/agents/codex-explorer.md b/examples/agents/codex-explorer.md new file mode 100644 index 00000000..78b1de4a --- /dev/null +++ b/examples/agents/codex-explorer.md @@ -0,0 +1,29 @@ +--- +schema: devspace-agent/v1 +name: codex-explorer +description: Read-only Codex profile for bounded codebase questions and architecture exploration. +provider: codex +model: gpt-5.4 +--- + +You are a read-only codebase explorer. + +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: +confidence: +unknowns: +``` diff --git a/examples/agents/codex-worker.md b/examples/agents/codex-worker.md new file mode 100644 index 00000000..cbae95c2 --- /dev/null +++ b/examples/agents/codex-worker.md @@ -0,0 +1,29 @@ +--- +schema: devspace-agent/v1 +name: codex-worker +description: Codex implementation profile for focused, user-approved coding tasks. +provider: codex +model: gpt-5.4 +--- + +You are a local implementation worker under supervisor review. + +Use this profile only for focused tasks with clear acceptance criteria. + +Rules: + +- Keep changes focused. +- Follow the existing project style. +- Do not perform unrelated refactors. +- Do not hide failures. +- Report tests run and blockers. + +Final report format: + +```text +summary: +files_changed: +tests_run: +blockers: +follow_up_needed: +``` diff --git a/examples/agents/copilot-reviewer.md b/examples/agents/copilot-reviewer.md new file mode 100644 index 00000000..e6ebd176 --- /dev/null +++ b/examples/agents/copilot-reviewer.md @@ -0,0 +1,29 @@ +--- +schema: devspace-agent/v1 +name: copilot-reviewer +description: GitHub Copilot read-only profile for code questions and review passes. +provider: copilot +--- + +You are a read-only Copilot reviewer under supervisor review. + +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: +relevant_files: +confidence: +unknowns: +``` diff --git a/examples/agents/cursor-agent-worker.md b/examples/agents/cursor-agent-worker.md new file mode 100644 index 00000000..91fffc9c --- /dev/null +++ b/examples/agents/cursor-agent-worker.md @@ -0,0 +1,28 @@ +--- +schema: devspace-agent/v1 +name: cursor-agent-worker +description: Cursor Agent profile for fast implementation or UI-oriented review. +provider: cursor +--- + +You are a local Cursor Agent worker under supervisor review. + +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: +``` diff --git a/examples/agents/opencode-explorer.md b/examples/agents/opencode-explorer.md new file mode 100644 index 00000000..beb52865 --- /dev/null +++ b/examples/agents/opencode-explorer.md @@ -0,0 +1,28 @@ +--- +schema: devspace-agent/v1 +name: opencode-explorer +description: OpenCode read-only profile for fast lookup and bounded codebase questions. +provider: opencode +--- + +You are a read-only OpenCode explorer. + +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: +confidence: +unknowns: +``` diff --git a/examples/agents/pi-reviewer.md b/examples/agents/pi-reviewer.md new file mode 100644 index 00000000..67eded03 --- /dev/null +++ b/examples/agents/pi-reviewer.md @@ -0,0 +1,28 @@ +--- +schema: devspace-agent/v1 +name: pi-reviewer +description: Pi read-only profile for quick code review and targeted questions. +provider: pi +--- + +You are a read-only local code reviewer. + +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 rewrite suggestions. + +Final report format: + +```text +findings: +evidence: +risk_level: +recommended_next_steps: +unknowns: +``` diff --git a/package-lock.json b/package-lock.json index c226ef47..28f65f87 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", "diff": "^8.0.3", @@ -22,6 +26,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { @@ -46,6 +51,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", @@ -75,15 +249,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", @@ -546,11 +720,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" @@ -560,8 +734,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", @@ -584,8 +758,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", @@ -2468,6 +2642,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", @@ -2846,6 +3163,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", @@ -3801,6 +4125,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", @@ -4175,6 +4506,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", @@ -5368,6 +5713,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", @@ -5473,6 +5829,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", @@ -5783,6 +6146,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 e16711b3..a37581fc 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "files": [ "dist", "docs", + "examples", "scripts", + "skills", "README.md" ], "publishConfig": { @@ -26,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/ui/patch-display.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/ui/patch-display.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", "diff": "^8.0.3", @@ -45,6 +51,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/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 87ba662d..7c0a01a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,21 +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, + 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"; @@ -39,6 +63,9 @@ async function main(argv: string[]): Promise { case "config": runConfigCommand(args); return; + case "agents": + await runAgentsCommand(args); + return; case "help": printHelp(); return; @@ -50,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}`); @@ -133,6 +160,7 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, + subagents: resolveSubagentsFlag(files.config), }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -140,10 +168,12 @@ async function runInit({ force }: { force: boolean }): Promise { const configPath = writeDevspaceConfig(config); const authPath = writeDevspaceAuth(auth); + const seededSkillPaths = config.subagents ? ensureDevspaceDefaultSkills() : []; const lines = [ `Config: ${configPath}`, `Auth: ${authPath}`, + ...seededSkillPaths.map((path) => `Default skill: ${path}`), `Local MCP URL: http://${config.host}:${config.port}/mcp`, ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []), ]; @@ -182,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}`); @@ -193,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 = () => { @@ -268,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:", @@ -276,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 75e007f3..0b4f99a8 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +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, resolveSubagentsFlag } from "./user-config.js"; const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); const baseEnv = { @@ -22,8 +23,26 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, " assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); 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).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_SUBAGENTS: "1" }).subagents, + 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", "subagent-delegation", "SKILL.md")]); +assert.equal(existsSync(seededSkillPaths[0]), true); +assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagent-delegation/); +assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []); assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), @@ -143,6 +162,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", + subagents: true, }), ); writeFileSync( @@ -156,6 +176,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.subagents, true); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index a5f84bac..4fc1bcbb 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 { loadDevspaceFiles } from "./user-config.js"; +import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; @@ -23,6 +23,9 @@ export interface ServerConfig { worktreeRoot: string; skillsEnabled: boolean; skillPaths: string[]; + devspaceSkillsDir: string; + devspaceAgentsDir: string; + subagents: boolean; agentDir: string; logging: LoggingConfig; } @@ -225,6 +228,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), + devspaceSkillsDir: devspaceSkillsDir(env), + 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 f13a1b8e..785229e6 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; } @@ -184,6 +191,25 @@ function serverInstructions(config: ServerConfig): 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.${showChangesInstruction}`; } + +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 @@ -206,6 +232,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(), }); @@ -538,6 +579,7 @@ function registerCodexProcessTools( workspaceId, command: cmd, cwd, + workspaceRoot: workspace.root, tty, columns, rows, @@ -633,6 +675,7 @@ function createMcpServer( workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, + localAgentProviders: LocalAgentProviderAvailability[], ): McpServer { const server = new McpServer( { @@ -720,6 +763,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(), }, @@ -742,6 +787,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, @@ -768,6 +823,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"), }, @@ -792,6 +856,8 @@ function createMcpServer( agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, + agentProviders: visibleAgentProviders.length, + agents: visibleAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, }, }, @@ -805,6 +871,8 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, + agentProviders: visibleAgentProviders, + agents: visibleAgents, skillDiagnostics: workspace.skillDiagnostics, instruction, }, @@ -1536,6 +1604,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); @@ -1659,7 +1730,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"); @@ -1682,6 +1759,7 @@ export function createServer(config = loadConfig()): RunningServer { return { app, config, + localAgentProviders, close: () => { if (closed) return; closed = true; @@ -1701,7 +1779,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`, @@ -1712,6 +1790,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 16a49c8a..707dda26 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -20,6 +20,7 @@ try { const projectRoot = join(root, "project"); const agentDir = join(root, "agent"); const explicitSkills = join(root, "explicit-skills"); + const devspaceSkills = join(root, ".devspace", "skills"); const globalAgentsSkills = join(root, ".agents", "skills"); const projectAgentsSkills = join(projectRoot, ".agents", "skills"); const globalClaudeSkills = join(root, ".claude", "skills"); @@ -30,8 +31,11 @@ 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", "subagent-delegation"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); + await mkdir(join(explicitSkills, "subagent-delegation"), { recursive: true }); + await mkdir(join(devspaceSkills, "devspace-local-skill"), { recursive: true }); await writeFile( join(globalAgentsSkills, "agent-global-skill", "SKILL.md"), @@ -88,6 +92,17 @@ try { "# Project Skill", ].join("\n"), ); + await writeFile( + join(devspaceSkills, "devspace-local-skill", "SKILL.md"), + [ + "---", + "name: devspace-local-skill", + "description: DevSpace local skill description.", + "---", + "", + "# DevSpace Local Skill", + ].join("\n"), + ); await writeFile( join(agentDir, "skills", "global-skill", "SKILL.md"), [ @@ -110,6 +125,28 @@ try { "# Duplicate Skill", ].join("\n"), ); + await writeFile( + join(agentDir, "skills", "subagent-delegation", "SKILL.md"), + [ + "---", + "name: subagent-delegation", + "description: Hidden subagent skill winner.", + "---", + "", + "# Subagent Delegation", + ].join("\n"), + ); + await writeFile( + join(explicitSkills, "subagent-delegation", "SKILL.md"), + [ + "---", + "name: subagent-delegation", + "description: Hidden subagent skill loser.", + "---", + "", + "# Subagent Delegation Duplicate", + ].join("\n"), + ); await writeFile( join(explicitSkills, "disabled", "SKILL.md"), [ @@ -146,9 +183,31 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "claude-global-skill"), true); 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 === "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 === "subagent-delegation", + ), + false, + ); + + const experimentalConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_AGENT_DIR: agentDir, + 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 === "subagent-delegation", + ), + true, + ); const duplicateConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, diff --git a/src/skills.ts b/src/skills.ts index e3da62ff..c1f146a9 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { loadSkills, type Skill, @@ -20,12 +21,31 @@ export interface SkillReadResolution { isSkillFile: boolean; } +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 hasSubagentDelegationSkill(skillDir: string): boolean { + return existsSync(join(skillDir, SUBAGENT_DELEGATION_SKILL)); +} + export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] { - const defaultPaths = [ + const bundledSkills = bundledSkillsDir(); + const defaultPathCandidates = [ join(homedir(), ".agents", "skills"), resolve(cwd, ".agents", "skills"), + config.devspaceSkillsDir, join(config.agentDir, "skills"), - ].filter((path) => existsSync(path)); + config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir) + ? bundledSkills + : undefined, + ]; + const defaultPaths = defaultPathCandidates.filter( + (path): path is string => path !== undefined && existsSync(path), + ); const seen = new Set(); return [...defaultPaths, ...config.skillPaths] @@ -44,12 +64,22 @@ function resolveSkillPath(path: string, cwd: string): string { export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; - return loadSkills({ + const result = loadSkills({ cwd, agentDir: config.agentDir, skillPaths: effectiveSkillPaths(config, cwd), includeDefaults: false, }); + + if (config.subagents) return result; + + return { + 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 === SUBAGENT_DELEGATION_NAME); + }), + }; } export function resolveSkillReadPath( diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c519..c8da90b5 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -6,7 +6,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; export interface DevspaceUserConfig { @@ -18,6 +18,7 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; + subagents?: boolean; } export interface DevspaceAuthConfig { @@ -46,6 +47,14 @@ export function devspaceAuthPath(env: NodeJS.ProcessEnv = process.env): string { return join(devspaceConfigDir(env), "auth.json"); } +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"); @@ -88,6 +97,24 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } +export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env): string[] { + const targetPath = join(devspaceSkillsDir(env), "subagent-delegation", "SKILL.md"); + if (existsSync(targetPath)) return []; + + 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 resolveSubagentsFlag( + config: Pick, + env: NodeJS.ProcessEnv = process.env, +): boolean | undefined { + if (env.DEVSPACE_SUBAGENTS === undefined) return config.subagents; + return ["1", "true", "yes", "on"].includes(env.DEVSPACE_SUBAGENTS.toLowerCase()); +} + function readJsonFile(filePath: string): T { try { return JSON.parse(readFileSync(filePath, "utf8")) as 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(), };