diff --git a/AGENTS.md b/AGENTS.md index fa13d5081..e1f7fe436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # DevSpace -DevSpace is a local development execution layer for MCP hosts such as ChatGPT and Claude. It gives a remote host workspace-scoped tools for reading, editing, searching, running commands, managing Git worktrees, reviewing changes, and coordinating bounded subagents on the user's machine. +DevSpace is a local development execution layer for MCP hosts such as ChatGPT and Claude. It gives a remote host workspace-scoped tools for reading, editing, searching, running commands, managing Git worktrees, and reviewing changes on the user's machine. -Pi's SDK currently provides mature local coding primitives. DevSpace wraps those primitives in a Streamable HTTP MCP server and adds the product-specific boundaries around them: approved roots, workspace state, instructions, process sessions, worktrees, artifacts, review checkpoints, widgets, and subagent execution. +Pi's SDK currently provides mature local coding primitives. DevSpace wraps those primitives in a Streamable HTTP MCP server and adds the product-specific boundaries around them: approved roots, workspace state, instructions, process sessions, worktrees, artifacts, review checkpoints, and widgets. The MCP host is the coding agent; DevSpace must not invoke models or coding-agent providers. DevSpace owns tooling mechanics. The model receives only meaningful and actionable choices. The user sees outcomes. Tool defination should not leak internal implementation or it shoudn't be giving unwanted options to model to choose from if tooling can handle this. @@ -13,9 +13,9 @@ These ideas should stay true as the project evolves: 1. **The host is the orchestrator.** DevSpace exposes clear capabilities and execution state. It should not hide the workflow inside an opaque, uninspectable agent loop. 2. **Everything happens in a workspace.** A workspace represents one local project directory or worktree plus the instructions and state accumulated while operating in it. 3. **Local authority stays explicit.** DevSpace runs with access to the user's machine. Roots, paths, commands, processes, credentials, and destructive operations must be treated as product boundaries. -4. **Subagents are bounded workers.** A subagent should have an explicit task, profile, working context, lifecycle, and result that the host can inspect and coordinate. -5. **Adapters stay at the edges.** Pi, MCP hosts, and model providers each have their own terminology and capabilities. Provider-specific behavior should not become the core domain model. -6. **Prefer composable primitives.** Build a small set of reliable operations that can be combined into larger workflows instead of baking every workflow into the server. +4. **The host is the agent.** DevSpace exposes runtime tools and never delegates coding or reasoning to another model or coding-agent provider. +5. **Adapters stay at the edges.** Pi and MCP hosts have their own terminology and capabilities. Adapter-specific behavior should not become the core domain model. +6. **Prefer composable primitives.** Build a small set of reliable operations that can be combined into larger workflows instead of baking workflows into the server. ## Glossary @@ -26,11 +26,9 @@ These ideas should stay true as the project evolves: - **Allowed root** — a configured filesystem boundary within which a workspace may be opened. It is not itself necessarily a workspace. - **Checkout mode** — operating on an existing checkout supplied by the user. - **Worktree mode** — operating in an isolated Git worktree. -- **Tool surface** — the tools exposed by a configured mode, such as minimal, full, or Codex-compatible. +- **Tool surface** — the tools exposed by a configured mode, such as native, minimal, or full. - **Process session** — a long-running command tracked for later input, output, or termination. - **Instruction file** — an `AGENTS.md` or `CLAUDE.md` discovered while navigating a workspace. -- **Subagent** — a bounded model invocation delegated and coordinated by the host. -- **Agent profile** — the model, provider, tools, and instructions used for a subagent. - **Artifact** — an output surfaced for the host or user to inspect. - **Review checkpoint** — stored state representing a coherent set of changes. - **Widget** — host-rendered UI/Cards attached to an MCP response. @@ -47,9 +45,9 @@ Keep tunnel ownership and credentials with the user. DevSpace may operate throug ## Diagnose the correct layer -A failure may belong to the host, MCP transport, DevSpace, a Pi adapter, a provider, a model, a tool implementation, or the target project. Preserve the original error and identify the failing boundary before changing code. +A failure may belong to the host, MCP transport, DevSpace, a Pi primitive, a tool implementation, or the target project. Preserve the original error and identify the failing boundary before changing code. -An adapter exception is not evidence that a model failed. A successful command is not evidence that a GUI opened, a host refreshed, or a user-visible workflow succeeded. +A successful command is not evidence that a GUI opened, a host refreshed, or a user-visible workflow succeeded. Do not expand DevSpace's responsibility while fixing a local symptom. Host UI, provider model naming, tunnel management, and duplicated review experiences require an explicit product decision. @@ -62,7 +60,7 @@ Determine how the user will consume the change and verify that path. Behavior ma - a fresh process and a server or host that needs restarting; - checkout mode and worktree mode; - Linux, macOS, and Windows Bash environments; -- minimal, full, and Codex-compatible tool surfaces; +- native, minimal, and full tool surfaces; - widgets enabled, disabled, or limited to change review. State clearly when only a narrower proxy was verified. For model-facing schemas, inspect what the host receives. For UI and artifacts, inspect the rendered result rather than inferring success from the producing command. @@ -75,7 +73,7 @@ When changing a cross-cutting concept, check every surface it actually reaches: - workspace lifecycle and instruction loading; - allowed-root and path-containment behavior; - checkout and worktree modes; -- process and subagent lifecycle; +- process lifecycle; - tool-surface filtering; - widgets, artifacts, and review checkpoints; - persistence and migrations; @@ -94,25 +92,24 @@ For UI changes, include before/after images and a short interaction video when b ## Where code lives - `src/server.ts` — MCP server setup, tool registration, and response wiring. -- `src/workspaces.ts` — workspace lifecycle, instructions, skills, and profiles. +- `src/workspaces.ts` — workspace lifecycle, instructions, and skills. - `src/roots.ts` — allowed roots and path containment. - `src/process-sessions.ts` — long-running process lifecycle. - `src/git.ts` and `src/git-worktrees.ts` — Git and worktree operations. -- `src/local-agent-*.ts` — subagent configuration, providers, and execution. - `src/artifact-*.ts` and `src/incoming-artifacts.ts` — artifact handling. - `src/review-checkpoints.ts` — persisted change-review checkpoints. - `src/ui/` — MCP widgets. - `src/db/` — persisted local state and migrations. - `test/` — behavior and regression tests. -Start at the boundary named by the problem and follow the data. Keep policy in DevSpace, provider translation in adapters, and important behavior in schemas, types, checks, or explicit tool results rather than hidden prompt conventions. +Start at the boundary named by the problem and follow the data. Keep policy in DevSpace and important behavior in schemas, types, checks, or explicit tool results rather than hidden prompt conventions. ## Project taste - Prefer explicit lifecycle and state over hidden autonomy. - Make tasks, inputs, outputs, failures, and ownership inspectable. -- Keep subagent execution composable and independently testable. -- Preserve host and provider data unless DevSpace has a concrete reason to normalize it. +- Keep runtime operations composable and independently testable. +- Preserve host data unless DevSpace has a concrete reason to normalize it. - Add compatibility behavior only for an identified consumer with a real upgrade path. - Reuse glossary terms in schemas, types, documentation, and errors. -- Keep the execution layer small, reliable, and unsurprising. \ No newline at end of file +- Keep the execution layer small, reliable, and unsurprising. diff --git a/README.md b/README.md index 26853cfa2..9abb15ae5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

DevSpace

-

Bring a Codex-style coding workflow to ChatGPT.

+

A local coding MCP runtime for ChatGPT.

npm @@ -16,9 +16,9 @@ [![DevSpace connected to ChatGPT](https://raw.githubusercontent.com/Waishnav/devspace/main/docs/assets/devspace-screenshot.png)](https://raw.githubusercontent.com/Waishnav/devspace/main/docs/assets/devspace-screenshot.png) -**Give ChatGPT a secure connection to your own machine and Turn ChatGPT into Codex** +**ChatGPT is the coding agent. DevSpace is the local runtime and tool layer.** -DevSpace is a self-hosted MCP server that lets ChatGPT read, edit, search, and run code in your real local projects — your files, your tools, your terminal — without uploading anything to a third party. You run it on your machine, expose it through a tunnel you control, and approve the connection with a password only you have. +DevSpace is a self-hosted MCP server that lets ChatGPT and other MCP hosts directly work with local projects through workspace-scoped filesystem tools, native shell execution, persistent process sessions, Git worktrees, artifacts, and change review. DevSpace does not invoke another coding model or coding-agent provider behind the scenes. ## Sponsors and Special Thanks @@ -133,9 +133,10 @@ and show you what changed. DevSpace gives ChatGPT tools to: -- read, write, and edit files inside the opened workspace -- search code and inspect directories -- run shell commands for tests, builds, git, and package scripts +- read and search files inside the opened workspace +- apply structured patches for precise source changes +- run normal local development commands, including file operations, Git, package managers, generators, tests, builds, and project scripts +- keep long-running and interactive processes available through persistent process sessions - use isolated Git worktrees for parallel coding sessions - follow project instructions from `AGENTS.md` and `CLAUDE.md` - discover local agent skills from your skill folders @@ -143,11 +144,9 @@ DevSpace gives ChatGPT tools to: ## Mental Model -DevSpace is remote access to selected local folders. +The MCP host is the coding agent. DevSpace is remote access to selected local folders and the local development runtime. -You decide which roots are allowed. The MCP client still has powerful local -capabilities inside an opened workspace, including shell execution. Treat a -connected client like a trusted coding partner with access to your machine. +You decide which roots are allowed for structured filesystem tools. Shell commands run with the authority of the local user running DevSpace and are not an OS sandbox. Treat a connected client like a trusted coding partner with access to your machine. For a normal ChatGPT coding session: @@ -189,15 +188,7 @@ devspace doctor Every piece of software is becoming conversational. Natural language is redefining how we interact with tools, workflows, and systems. -My bet is that ChatGPT becomes the operating system for everything. Once we -reach AGI, we will simply talk to ChatGPT, and it will prompt, coordinate, and -orchestrate sub-agents that set up the right loops for us. - -We are not there yet. - -DevSpace is one attempt to fast-forward that future: a way for MCP-capable -hosts like ChatGPT and Claude to work directly with local project files through -explicit, inspectable tools. +DevSpace keeps that relationship direct: MCP-capable hosts such as ChatGPT and Claude perform the reasoning and coding work themselves, while DevSpace provides explicit, inspectable access to the local development environment. ## Built by Waishnav diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md deleted file mode 100644 index 0dc3db951..000000000 --- a/docs/agent-profile-schema.md +++ /dev/null @@ -1,165 +0,0 @@ -# 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 -thinking: high -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 -``` - -### `thinking` - -Optional provider reasoning effort, thinking level, or model variant. If omitted, -DevSpace lets the provider default apply. Values are provider-specific -passthrough strings; DevSpace does not translate names between harnesses. - -```yaml -thinking: low -thinking: high -thinking: xhigh -``` - -DevSpace passes this through to providers that expose a matching control: - -- `claude`: SDK effort with adaptive thinking. -- `codex`: SDK model reasoning effort. -- `pi`: `--thinking`. -- `opencode`: model variant. -- `cursor` and `copilot`: ACP thought-level config when supported. - -### `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", - "thinking": "high" -} -``` - -`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 ac2bdc7c2..9c058202a 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -1,6 +1,6 @@ # ChatGPT Coding Workflow -DevSpace brings a Codex-style coding-agent loop to ChatGPT and other MCP hosts: +DevSpace gives ChatGPT and other MCP hosts a direct local coding runtime: inspect the repo, follow local instructions, make scoped edits, run verification, and show the user what changed. @@ -116,20 +116,9 @@ DevSpace discovers standard Agent Skills from: 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/thinking levels 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 @@ -140,42 +129,35 @@ 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_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. +Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. ## Tool Names -DevSpace exposes these tool names: - -- `open_workspace` -- `read` -- `write` -- `edit` -- `bash` - -By default, DevSpace also runs in `DEVSPACE_TOOL_MODE=minimal`, so dedicated -`grep`, `glob`, and `ls` tools are hidden. Use `bash` with command-line tools -such as `rg`, `find`, and `ls` for search and directory inspection. - -Use `DEVSPACE_TOOL_MODE=full` to restore dedicated search and directory tools. - -The experimental Codex-style surface is enabled with -`DEVSPACE_TOOL_MODE=codex`. It exposes: +Native mode is the default and exposes: - `open_workspace` - `read` +- `grep` +- `glob` +- `ls` - `apply_patch` - `exec_command` - `write_stdin` -In this mode, `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are not -registered. `exec_command` returns a process session ID when a command is still -running after its yield window. Use `write_stdin` to poll it, send input, resize -a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. +Use `apply_patch` when a structured patch is the clearest way to edit source. +Use `exec_command` naturally for shell operations, including file mutations, +Git, package managers, generators, formatters, tests, builds, Docker, and +project scripts. A command that remains active returns a process session ID; +use `write_stdin` to poll it, send input, resize a PTY, or send Ctrl-C. + +`minimal` and `full` remain available for older clients. `minimal` exposes the +legacy `write`, `edit`, and `bash` tools, while `full` also exposes dedicated +search tools. `DEVSPACE_TOOL_MODE=codex` is accepted only as a deprecated alias +for `native`; it does not invoke Codex or any other coding agent. + +Shell commands run with the authority of the local user running DevSpace and +are not an OS sandbox. Workspace containment applies to structured filesystem +tools, not arbitrary shell commands. ## Show Changes @@ -194,13 +176,13 @@ not change this workflow. ## Shell Use -The shell tool is for commands that belong in a terminal: +The native shell supports normal local development operations, including: -- tests -- builds -- git inspection -- package scripts -- environment checks +- file creation, modification, movement, renaming, and deletion +- Git and worktree operations +- package managers, generators, and project scripts +- formatters, linters, tests, and builds +- compilers, interpreters, Docker, and long-running processes -File writes should go through the edit/write tools rather than shell -redirection, heredocs, `tee`, `sed -i`, or generated scripts. +Use `apply_patch` when it is convenient for precise source edits. Shell +redirection, scripts, and other normal command-line file operations are allowed. diff --git a/docs/configuration.md b/docs/configuration.md index 3502a98b2..9d9af14f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -95,19 +95,22 @@ MCP clients discover metadata from: | Value | Behavior | | --- | --- | -| `minimal` | Default. Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | +| `native` | Default. Exposes `open_workspace`, `read`, `grep`, `glob`, `ls`, `apply_patch`, `exec_command`, and `write_stdin`. | +| `minimal` | Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | | `full` | Exposes the minimal tools plus dedicated `grep`, `glob`, and `ls` tools. | -| `codex` | Experimental. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. Existing mutation and shell tools are hidden. | +| `codex` | Deprecated compatibility alias for `native`. | `DEVSPACE_MINIMAL_TOOLS` remains a backward-compatible alias when `DEVSPACE_TOOL_MODE` is unset: `1` selects `minimal` and `0` selects `full`. -The `codex` mode must be selected through `DEVSPACE_TOOL_MODE` and always uses -its fixed short tool names regardless of `DEVSPACE_TOOL_NAMING`. +When `DEVSPACE_TOOL_MODE=codex` is present in an older configuration, DevSpace +normalizes it to `native`. The name does not enable a Codex integration. -Codex-mode commands run without a PTY by default. Set `tty: true` on +Native-mode commands run without a PTY by default. Set `tty: true` on `exec_command` for interactive terminal programs. PTY support uses the optional `node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY -sessions. +sessions. Commands may create, modify, move, rename, or delete workspace files. +They run with the authority of the local operating-system user and are not an +OS sandbox. ## Widgets @@ -124,7 +127,6 @@ 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. | @@ -136,27 +138,9 @@ DevSpace discovers standard Agent Skills from: 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/thinking levels 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: @@ -191,7 +175,7 @@ DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ DEVSPACE_ARTIFACTS="1" \ -DEVSPACE_TOOL_MODE="minimal" \ +DEVSPACE_TOOL_MODE="native" \ DEVSPACE_WIDGETS="full" \ npx @waishnav/devspace serve ``` diff --git a/docs/gotchas.md b/docs/gotchas.md index 639f54a9a..f23a8d4ba 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -216,21 +216,9 @@ DevSpace looks in standard Agent Skills locations: 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 deleted file mode 100644 index b907659fe..000000000 --- a/examples/agents/claude-implementer.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -schema: devspace-agent/v1 -name: claude-implementer -description: Implementation profile for multi-file changes, careful refactors, and failing test repair. -provider: claude -model: sonnet -thinking: high ---- - -Take ownership of the requested implementation while keeping the change narrow. -Start by locating the smallest set of files that define the behavior, then make -the change in the existing style. - -Working rules: - -- Preserve existing public behavior unless the prompt explicitly asks to change it. -- Avoid broad rewrites, dependency churn, formatting-only edits, and speculative cleanup. -- Update or add focused tests when behavior changes. -- Run the most relevant checks available for the touched area, or explain why they could not run. -- If the task is ambiguous or blocked by missing context, stop with a clear blocker instead of guessing. - -Report: - -```text -summary: -tests_run: -blockers: -risks: -follow_up_needed: -``` diff --git a/examples/agents/codex-explorer.md b/examples/agents/codex-explorer.md deleted file mode 100644 index 93a92db6c..000000000 --- a/examples/agents/codex-explorer.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -schema: devspace-agent/v1 -name: codex-explorer -description: Read-only profile for bounded codebase questions, architecture tracing, and risk discovery. -provider: codex -model: gpt-5.4-mini -thinking: high ---- - -Investigate without editing. Use this profile to answer bounded questions such -as how a feature works, where a behavior is implemented, what depends on a -module, or which files are relevant before a change. - -- Do not modify files. -- Prefer direct evidence from code over broad repository summaries. -- Cite file paths, symbols, and commands that support the conclusion. -- Separate confirmed facts from inferences. -- Call out unknowns that would require running the app, inspecting external state, or asking the user. - -Report: - -```text -answer: -evidence: -relevant_files: -unknowns: -``` diff --git a/examples/agents/codex-qa-tester.md b/examples/agents/codex-qa-tester.md deleted file mode 100644 index f85706197..000000000 --- a/examples/agents/codex-qa-tester.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -schema: devspace-agent/v1 -name: codex-qa-tester -description: Manual QA profile for browser testing, workflow verification, and regression checks. -provider: codex -model: gpt-5.4-mini -thinking: high ---- - -Verify the requested user workflow from the outside, like a QA pass before -release. Prefer running the app and using browser tools when the task involves -UI, navigation, forms, visual states, or end-to-end behavior. - -- Do not modify files. -- Start from the acceptance criteria in the prompt; turn vague requests into a small checklist. -- Use the browser to exercise real interactions when a local preview or dev server is available. -- Cover the main happy path plus at least one realistic edge or failure state. -- Capture exact reproduction steps for every issue found. -- Distinguish confirmed failures from untested risks. - -Report: - -```text -qa_summary: -checks_run: -issues_found: -reproduction_steps: -untested_risks: -``` diff --git a/examples/agents/codex-worker.md b/examples/agents/codex-worker.md deleted file mode 100644 index 0eea65c92..000000000 --- a/examples/agents/codex-worker.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -schema: devspace-agent/v1 -name: codex-worker -description: Implementation profile for focused coding tasks with clear acceptance criteria. -provider: codex -model: gpt-5.4 ---- - -Implement the requested change with minimal surface area. Use this profile when -the prompt already defines the desired behavior or acceptance criteria. - -- Read nearby code before editing. -- Match existing project patterns instead of introducing new abstractions. -- Keep unrelated files, formatting, and dependency metadata untouched. -- Prefer targeted tests for the changed behavior. -- Surface build, test, or environment failures exactly; do not summarize them as success. - -Report: - -```text -summary: -tests_run: -blockers: -notes: -``` diff --git a/examples/agents/copilot-reviewer.md b/examples/agents/copilot-reviewer.md deleted file mode 100644 index 3afaf1caa..000000000 --- a/examples/agents/copilot-reviewer.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -schema: devspace-agent/v1 -name: copilot-reviewer -description: Read-only review profile for bug risk, regressions, and missing test coverage. -provider: copilot ---- - -Review the requested code path or diff without editing. Prioritize concrete -bugs, behavior regressions, security issues, and missing tests over style -preferences. - -- Do not modify files. -- Lead with findings ordered by severity. -- Tie each finding to a specific file, symbol, or behavior. -- Ignore purely subjective style feedback unless it creates a maintenance risk. -- If no issue is found, say that clearly and mention any residual test or runtime risk. - -Report: - -```text -findings: -evidence: -test_gaps: -residual_risk: -``` diff --git a/examples/agents/cursor-agent-worker.md b/examples/agents/cursor-agent-worker.md deleted file mode 100644 index 985a7a53f..000000000 --- a/examples/agents/cursor-agent-worker.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -schema: devspace-agent/v1 -name: cursor-agent-worker -description: Implementation profile for UI-heavy changes, small refactors, and alternative solution passes. -provider: cursor -model: composer-2.5-fast ---- - -Work on the requested change with a bias toward practical, shippable edits. This -profile is useful for UI polish, small refactors, and trying an alternate -implementation path. - -- Keep edits scoped to the requested workflow or component. -- Preserve existing visual language and interaction patterns. -- Avoid changing data contracts or public APIs unless the prompt asks for it. -- Check responsive and empty/error states when touching UI. -- Report what was verified and what still needs a human look. - -Report: - -```text -summary: -verification: -blockers: -open_questions: -``` diff --git a/examples/agents/opencode-explorer.md b/examples/agents/opencode-explorer.md deleted file mode 100644 index 250a4d84a..000000000 --- a/examples/agents/opencode-explorer.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -schema: devspace-agent/v1 -name: opencode-explorer -description: Read-only profile for fast relevant-file discovery and small architecture questions. -provider: opencode -model: opencode/deepseek-v4-flash-free -thinking: high ---- - -Find the answer quickly without editing. Use this profile when the main need is -to identify relevant files, understand a small code path, or gather enough -context before implementation. - -- Do not modify files. -- Search first, then read only the files needed to answer the prompt. -- Prefer precise file paths and symbols over broad summaries. -- Keep the response short unless the code path is genuinely complex. -- State uncertainty when evidence is incomplete. - -Report: - -```text -answer: -evidence: -relevant_files: -unknowns: -``` diff --git a/examples/agents/pi-reviewer.md b/examples/agents/pi-reviewer.md deleted file mode 100644 index 4ed2b9ded..000000000 --- a/examples/agents/pi-reviewer.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -schema: devspace-agent/v1 -name: pi-reviewer -description: Read-only review profile for quick risk checks and targeted implementation questions. -provider: pi -model: openai-codex/gpt-5.5 -thinking: high ---- - -Review or investigate only the area requested. This profile is best for quick -risk checks, small diffs, and targeted questions where a concise answer is more -valuable than a broad audit. - -- Do not modify files. -- Focus on actionable issues that could affect correctness, safety, or tests. -- Cite the specific code evidence for each point. -- Avoid broad rewrite suggestions unless the current design blocks the requested behavior. -- Keep low-confidence observations under `unknowns`. - -Report: - -```text -findings: -evidence: -risk_level: -unknowns: -``` diff --git a/package-lock.json b/package-lock.json index 03bf36880..5b5914dc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,10 @@ "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.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", @@ -52,175 +48,6 @@ "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", @@ -868,6 +695,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -884,6 +714,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -900,6 +733,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -916,6 +752,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -932,6 +771,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1057,7 +899,7 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { - "version": "1.0.3", + "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" @@ -2643,149 +2485,6 @@ "@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", @@ -3164,13 +2863,6 @@ "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", @@ -4126,13 +3818,6 @@ "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", @@ -4507,20 +4192,6 @@ "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", @@ -5720,17 +5391,6 @@ "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", @@ -5836,13 +5496,6 @@ "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", diff --git a/package.json b/package.json index 5d4a7fafd..813bd0c35 100644 --- a/package.json +++ b/package.json @@ -28,21 +28,17 @@ "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/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.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/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.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.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", diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md deleted file mode 100644 index fb269df5c..000000000 --- a/skills/subagent-delegation/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -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 and optional thinking level. To override the -configured/default provider model or thinking level for a run, pass `--model` -or `--thinking`: - -```bash -devspace agents run --model "" -devspace agents run --thinking "" -``` - -Use `--thinking` only when the user asks for a specific reasoning depth or when -the task clearly needs a different effort than the configured profile default. -Thinking values are provider-specific passthrough values. Use names supported by -the selected local agent harness; DevSpace does not translate values between -providers. - -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 97b7084a8..b5efb9281 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,10 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -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"; +import { readFileSync } from "node:fs"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -19,78 +15,18 @@ 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", - "thinking: high", - "---", - "", - "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", - thinking: "high", - }).id, - { status: "idle" }, - ); - const other = store.update( - store.create({ - workspaceId: "ws_other", - workspaceRoot: projectRoot, - profileName: "reviewer", - provider: "codex", - }).id, - { status: "running" }, - ); - store.close(); +const help = execFileSync("node", ["--import", "tsx", "src/cli.ts", "--help"], { + encoding: "utf8", + env: { ...process.env, DEVSPACE_CONFIG_DIR: "/tmp/devspace-cli-help-test" }, +}); +assert.match(help, /devspace serve/); +assert.doesNotMatch(help, /devspace agents/); - const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { - cwd: process.cwd(), +assert.throws( + () => execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents"], { 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 thinking=high`)); - 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 }); -} + stdio: "pipe", + env: { ...process.env, DEVSPACE_CONFIG_DIR: "/tmp/devspace-cli-agents-removed-test" }, + }), + /Unknown command: agents/, +); diff --git a/src/cli.ts b/src/cli.ts index 7a1ac63fe..0d417d545 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,38 +1,14 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { stdin as input, stdout as output } from "node:process"; -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 { resolve } from "node:path"; 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, @@ -40,7 +16,7 @@ import { import { expandHomePath } from "./roots.js"; import { shutdownHttpServer } from "./server-shutdown.js"; -type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version"; +type Command = "serve" | "init" | "doctor" | "config" | "help" | "version"; const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; @@ -64,9 +40,6 @@ async function main(argv: string[]): Promise { case "config": runConfigCommand(args); return; - case "agents": - await runAgentsCommand(args); - return; case "help": printHelp(); return; @@ -78,7 +51,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" || command === "agents") return command; + if (command === "init" || command === "doctor" || command === "config") 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}`); @@ -161,7 +134,6 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, - subagents: resolveSubagentsFlag(files.config), }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -169,12 +141,10 @@ 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`] : []), ]; @@ -213,7 +183,7 @@ async function serve(): Promise { const { createServer } = await import("./server.js"); const config = loadConfig(); - const { app, close, localAgentProviders } = createServer(config); + const { app, close } = 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}`); @@ -224,9 +194,6 @@ 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)}`); - } }); let shuttingDown = false; @@ -309,9 +276,6 @@ 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:", @@ -320,260 +284,6 @@ 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, - thinking: parsed.thinking ?? existing.thinking, - latestResponse: undefined, - error: undefined, - }); - spawnAgentWorker(existing.id, promptFile); - console.log(formatAgentLine({ - ...existing, - status: "running", - model: parsed.model ?? existing.model, - thinking: parsed.thinking ?? existing.thinking, - })); - return; - } - - const profiles = await loadLocalAgentProfiles(config, workspaceRoot); - const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model, parsed.thinking); - 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, - thinking: target.thinking, - }); - - 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, - thinking: record.thinking ?? profile.thinking, - }); -} - -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, - thinking: record.thinking, - }); -} - -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" | "thinking" ->): string { - const model = agent.model ? ` ${agent.model}` : ""; - const thinking = agent.thinking ? ` thinking=${agent.thinking}` : ""; - return `${agent.id} ${agent.status} ${agent.profileName} ${agent.provider}${model}${thinking}`; -} - -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 ] [--thinking ] ", - " 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 9bc8b4c9d..ebff0e73e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,9 +1,8 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtempSync, 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 = { @@ -16,16 +15,15 @@ assert.equal(loadConfig(baseEnv).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); -assert.equal(loadConfig(baseEnv).toolMode, "minimal"); +assert.equal(loadConfig(baseEnv).toolMode, "native"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "native" }).toolMode, "native"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).toolMode, "minimal"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "native"); 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).artifactsEnabled, false); assert.equal(loadConfig(baseEnv).artifactMaxFileBytes, 100 * 1024 * 1024); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); @@ -35,21 +33,6 @@ assert.equal( ); 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" }), @@ -173,7 +156,6 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", - subagents: true, artifactsEnabled: true, artifactMaxFileBytes: 321, }), @@ -189,7 +171,6 @@ 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.equal(fileConfig.artifactsEnabled, true); assert.equal(fileConfig.artifactMaxFileBytes, 321); assert.deepEqual(fileConfig.allowedHosts, [ diff --git a/src/config.ts b/src/config.ts index f8c8b9954..ba9083d7e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,9 +3,9 @@ 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 { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; +import { devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; -export type ToolMode = "minimal" | "full" | "codex"; +export type ToolMode = "minimal" | "full" | "native"; export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -27,8 +27,6 @@ export interface ServerConfig { skillsEnabled: boolean; skillPaths: string[]; devspaceSkillsDir: string; - devspaceAgentsDir: string; - subagents: boolean; agentDir: string; logging: LoggingConfig; } @@ -86,13 +84,14 @@ function parseBoolean(value: string | undefined): boolean { function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { const mode = env.DEVSPACE_TOOL_MODE; - if (mode === "minimal" || mode === "full" || mode === "codex") return mode; + if (mode === "native" || mode === "codex") return "native"; + if (mode === "minimal" || mode === "full") return mode; if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) { return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS) ? "minimal" : "full"; } - return "minimal"; + return "native"; } function parseLogLevel(value: string | undefined): LogLevel { @@ -246,11 +245,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), - 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 1c5c3298a..c8861114e 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -17,11 +17,6 @@ const migrations: Migration[] = [ name: "oauth-state", up: migrateOAuthState, }, - { - version: 3, - name: "local-agent-sessions", - up: migrateLocalAgentSessions, - }, { version: 4, name: "workspace-conversation-bindings", @@ -148,37 +143,6 @@ 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, - thinking 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); - `); - - addColumnIfMissing(sqlite, "local_agent_sessions", "thinking", "text"); -} - function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { sqlite.exec(` create table if not exists workspace_conversation_bindings ( @@ -200,7 +164,7 @@ function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { function addColumnIfMissing( sqlite: Database.Database, - table: "workspace_sessions" | "local_agent_sessions", + table: "workspace_sessions", column: string, definition: string, ): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 215c6c1a5..34c684a3f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -90,35 +90,9 @@ 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"), - thinking: text("thinking"), - 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 WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$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 deleted file mode 100644 index 0e4cbf0fa..000000000 --- a/src/local-agent-adapters.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import assert from "node:assert/strict"; -import { delimiter } from "node:path"; -import { - claudeCommandEnvironment, - createLocalAgentAdapter, - extractOpenCodeFinalResponse, - extractPiFinalResponse, - extractPiProviderError, - extractPiStreamingText, - piCommandEnvironment, - resolveAcpModelConfigUpdate, - resolveAcpThinkingConfigUpdate, -} 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"); -} - -assert.deepEqual( - resolveAcpModelConfigUpdate({ - sessionId: "session_model_1", - newSessionResponse: { - configOptions: [ - { - type: "select", - id: "model", - category: "model", - options: [ - { value: "claude-sonnet-4.5", name: "Sonnet" }, - { value: "gpt-5.4", name: "GPT 5.4" }, - ], - }, - ], - }, - }, "gpt-5.4", "cursor"), - { sessionId: "session_model_1", configId: "model", value: "gpt-5.4" }, -); - -assert.deepEqual( - resolveAcpModelConfigUpdate({ - sessionId: "session_model_2", - newSessionResponse: { - configOptions: [ - { - type: "select", - id: "model_config", - category: "model", - options: [ - { - group: "claude", - name: "Claude", - options: [ - { value: "claude-sonnet-4.5", name: "Sonnet" }, - { value: "claude-opus-4.5", name: "Opus" }, - ], - }, - ], - }, - ], - }, - }, "claude-opus-4.5", "copilot"), - { sessionId: "session_model_2", configId: "model_config", value: "claude-opus-4.5" }, -); - -assert.throws( - () => resolveAcpModelConfigUpdate({ - sessionId: "session_model_3", - newSessionResponse: { - configOptions: [ - { - type: "select", - id: "model", - category: "model", - options: [{ value: "gpt-5.4", name: "GPT 5.4" }], - }, - ], - }, - }, "unknown-model", "cursor"), - /Available values: gpt-5\.4/, -); - -assert.throws( - () => resolveAcpModelConfigUpdate(undefined, "gpt-5.4", "cursor"), - /session metadata/, -); - -assert.throws( - () => resolveAcpModelConfigUpdate({ newSessionResponse: { configOptions: [] } }, "gpt-5.4", "cursor"), - /session id/, -); - -assert.throws( - () => resolveAcpModelConfigUpdate({ - sessionId: "session_model_4", - newSessionResponse: { configOptions: [] }, - }, "gpt-5.4", "cursor"), - /does not expose a model/, -); - -assert.deepEqual( - resolveAcpThinkingConfigUpdate({ - sessionId: "session_1", - newSessionResponse: { - configOptions: [ - { - type: "select", - id: "effort", - category: "thought_level", - options: [ - { value: "low", name: "Low" }, - { value: "high", name: "High" }, - ], - }, - ], - }, - }, "high", "cursor"), - { sessionId: "session_1", configId: "effort", value: "high" }, -); - -assert.deepEqual( - resolveAcpThinkingConfigUpdate({ - sessionId: "session_2", - newSessionResponse: { - configOptions: [ - { - type: "select", - id: "thoughts", - category: "thought_level", - options: [ - { - group: "reasoning", - name: "Reasoning", - options: [ - { value: "medium", name: "Medium" }, - { value: "xhigh", name: "X High" }, - ], - }, - ], - }, - ], - }, - }, "xhigh", "copilot"), - { sessionId: "session_2", configId: "thoughts", value: "xhigh" }, -); - -assert.throws( - () => resolveAcpThinkingConfigUpdate({ - sessionId: "session_3", - newSessionResponse: { - configOptions: [ - { - type: "select", - id: "thoughts", - category: "thought_level", - options: [{ value: "low", name: "Low" }], - }, - ], - }, - }, "max", "cursor"), - /Available values: low/, -); - -assert.throws( - () => resolveAcpThinkingConfigUpdate(undefined, "high", "copilot"), - /session metadata/, -); - -assert.throws( - () => resolveAcpThinkingConfigUpdate({ newSessionResponse: { configOptions: [] } }, "high", "copilot"), - /session id/, -); - -assert.throws( - () => resolveAcpThinkingConfigUpdate({ - sessionId: "session_4", - newSessionResponse: { configOptions: [] }, - }, "high", "copilot"), - /does not expose a thinking option/, -); - -{ - 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 deleted file mode 100644 index 457b8e08e..000000000 --- a/src/local-agent-adapters.ts +++ /dev/null @@ -1,726 +0,0 @@ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { resolve } from "node:path"; -import { Readable, Writable } from "node:stream"; -import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk"; -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, - ...(input.thinking ? { thinking: { type: "adaptive" } as const, effort: input.thinking as EffortLevel } : {}), - 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 { - if (input.model) { - const config = resolveAcpModelConfigUpdate(session, input.model, this.provider); - await context.request(methods.agent.session.setConfigOption, config); - } - if (input.thinking) { - const config = resolveAcpThinkingConfigUpdate(session, input.thinking, this.provider); - await context.request(methods.agent.session.setConfigOption, config); - } - 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(); - } - } -} - -export function resolveAcpModelConfigUpdate( - session: unknown, - model: string, - provider: string, -): { sessionId: string; configId: string; value: string } { - return resolveAcpSelectConfigUpdate(session, { - category: "model", - label: "model", - provider, - value: model, - }); -} - -export function resolveAcpThinkingConfigUpdate( - session: unknown, - thinking: string, - provider: string, -): { sessionId: string; configId: string; value: string } { - return resolveAcpSelectConfigUpdate(session, { - category: "thought_level", - label: "thinking option", - provider, - value: thinking, - }); -} - -function resolveAcpSelectConfigUpdate( - session: unknown, - options: { - category: string; - label: string; - provider: string; - value: string; - }, -): { sessionId: string; configId: string; value: string } { - const record = asRecord(session); - if (!record) throw new Error(`${options.provider} ACP session did not return session metadata.`); - const sessionId = typeof record?.sessionId === "string" ? record.sessionId : undefined; - if (!sessionId) throw new Error(`${options.provider} ACP session did not return a session id.`); - - const response = asRecord(record.newSessionResponse); - const configOptions = response ? readArray(response, "configOptions") ?? [] : []; - const config = configOptions - .map(asRecord) - .find((option) => option?.type === "select" && option.category === options.category); - if (!config) { - throw new Error(`${options.provider} ACP server does not expose a ${options.label}.`); - } - - const configId = directString(config.id); - if (!configId) throw new Error(`${options.provider} ACP ${options.label} is missing an id.`); - - const available = flattenAcpSelectValues(config); - if (!available.includes(options.value)) { - const suffix = available.length > 0 ? ` Available values: ${available.join(", ")}.` : ""; - throw new Error(`${options.provider} ACP ${options.label} does not support '${options.value}'.${suffix}`); - } - - return { sessionId, configId, value: options.value }; -} - -function flattenAcpSelectValues(option: Record): string[] { - const values: string[] = []; - for (const item of readArray(option, "options") ?? []) { - const record = asRecord(item); - const value = directString(record?.value); - if (value) { - values.push(value); - continue; - } - for (const nested of readArray(record, "options") ?? []) { - const nestedValue = directString(asRecord(nested)?.value); - if (nestedValue) values.push(nestedValue); - } - } - return values; -} - -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.thinking) args.push("--thinking", input.thinking); - 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) } : {}), - ...(input.thinking ? { variant: input.thinking } : {}), - }; - 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 deleted file mode 100644 index 5d56697c9..000000000 --- a/src/local-agent-availability.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 747f304fa..000000000 --- a/src/local-agent-availability.ts +++ /dev/null @@ -1,151 +0,0 @@ -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 deleted file mode 100644 index c8ff2935f..000000000 --- a/src/local-agent-path.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 7665b9f8a..000000000 --- a/src/local-agent-profiles.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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", - "thinking: high", - "---", - "", - "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]?.thinking, "high"); - assert.equal(profiles[0]?.body, "Project body."); - assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { - name: "reviewer", - description: "Project reviewer #1.", - provider: "claude", - model: "sonnet", - thinking: "high", - }); - - 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 deleted file mode 100644 index a7fa0d876..000000000 --- a/src/local-agent-profiles.ts +++ /dev/null @@ -1,193 +0,0 @@ -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; - thinking?: string; - filePath: string; - body: string; - disabled: boolean; -} - -export interface LocalAgentProfileSummary { - name: string; - description: string; - provider: LocalAgentProvider; - model?: string; - thinking?: 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, - thinking: profile.thinking, - }; -} - -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"), - thinking: readString(frontmatter, "thinking"), - 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 deleted file mode 100644 index 1d45d1662..000000000 --- a/src/local-agent-runtime.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -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, - modelReasoningEffort: undefined, -}); - -await runtime.run({ - prompt: "make change", - workspace: "/tmp/project", - writeMode: "allowed", - model: "gpt-5.4", - thinking: "high", -}); - -assert.deepEqual(codex.started[1], { - workingDirectory: "/tmp/project", - sandboxMode: "workspace-write", - approvalPolicy: "never", - model: "gpt-5.4", - modelReasoningEffort: "high", -}); - -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, - modelReasoningEffort: 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 deleted file mode 100644 index 54130c2e2..000000000 --- a/src/local-agent-runtime.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { - Codex, - CodexOptions, - ModelReasoningEffort, - 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; - thinking?: 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, - modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, - }; -} - -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 deleted file mode 100644 index cf7265a9f..000000000 --- a/src/local-agent-store.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -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", - thinking: "high", - }); - - assert.match(created.id, /^agt_[a-f0-9]{8}$/); - assert.equal(created.status, "starting"); - assert.equal(store.get(created.id)?.thinking, "high"); - 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", - thinking: "medium", - }); - - assert.equal(updated.status, "idle"); - assert.equal(updated.thinking, "medium"); - assert.equal(store.get("thread_123")?.id, created.id); - assert.equal(store.get(created.id)?.thinking, "medium"); - 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 deleted file mode 100644 index a850ca9f6..000000000 --- a/src/local-agent-store.ts +++ /dev/null @@ -1,248 +0,0 @@ -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; - thinking?: 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; - thinking?: 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; - thinking: 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, - thinking: input.thinking, - status: "starting", - createdAt: now, - updatedAt: now, - }; - - this.database.sqlite - .prepare( - `insert into local_agent_sessions ( - id, - workspace_id, - workspace_root, - profile_name, - provider, - model, - thinking, - status, - created_at, - updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - record.id, - record.workspaceId ?? null, - record.workspaceRoot, - record.profileName, - record.provider, - record.model ?? null, - record.thinking ?? 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 = ?, - thinking = ?, - 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.thinking ?? 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, - thinking: row.thinking ?? 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 deleted file mode 100644 index 3f1ae08f0..000000000 --- a/src/local-agent-targets.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -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", - thinking: "high", - 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, - thinking: undefined, -}); - -assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model", "gpt-5.1", "hello"]), { - target: "codex", - prompt: "hello", - model: "gpt-5.1", - thinking: undefined, -}); - -assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model=gpt-5.1", "hello"]), { - target: "codex", - prompt: "hello", - model: "gpt-5.1", - thinking: undefined, -}); - -assert.deepEqual(parseLocalAgentRunArgs(["codex", "--thinking", "high", "hello"]), { - target: "codex", - prompt: "hello", - model: undefined, - thinking: "high", -}); - -assert.deepEqual(parseLocalAgentRunArgs(["codex", "--thinking=high", "hello"]), { - target: "codex", - prompt: "hello", - model: undefined, - thinking: "high", -}); - -assert.throws( - () => parseLocalAgentRunArgs(["codex", "--model"]), - /Missing value for --model/, -); - -assert.throws( - () => parseLocalAgentRunArgs(["codex", "--thinking"]), - /Missing value for --thinking/, -); - -{ - 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"); - assert.equal(target?.thinking, "high"); -} - -{ - const target = resolveLocalAgentTarget("reviewer", profiles, "gpt-5.2", "xhigh"); - assert.equal(target?.kind, "profile"); - assert.equal(target?.model, "gpt-5.2"); - assert.equal(target?.thinking, "xhigh"); -} - -{ - 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); - assert.equal(target?.thinking, undefined); -} - -{ - const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep"); - assert.equal(target?.kind, "provider"); - assert.equal(target?.model, "kimi-k2"); - assert.equal(target?.thinking, "deep"); -} - -{ - 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 deleted file mode 100644 index 917e28041..000000000 --- a/src/local-agent-targets.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - isLocalAgentProvider, - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; - -export interface ParsedLocalAgentRunArgs { - target: string; - prompt: string; - model?: string; - thinking?: string; -} - -export type LocalAgentTarget = - | { - kind: "profile"; - name: string; - provider: LocalAgentProvider; - model?: string; - thinking?: string; - profile: LocalAgentProfile; - } - | { - kind: "provider"; - name: LocalAgentProvider; - provider: LocalAgentProvider; - model?: string; - thinking?: string; - }; - -export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs { - const [target, ...rest] = args; - if (!target) { - throw new Error('Usage: devspace agents run [--model ] [--thinking ] ""'); - } - - let model: string | undefined; - let thinking: 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; - } - if (part === "--thinking") { - const value = rest[index + 1]?.trim(); - if (!value) throw new Error("Missing value for --thinking."); - thinking = value; - index += 1; - continue; - } - if (part?.startsWith("--thinking=")) { - const value = part.slice("--thinking=".length).trim(); - if (!value) throw new Error("Missing value for --thinking."); - thinking = value; - continue; - } - promptParts.push(part ?? ""); - } - - const prompt = promptParts.join(" ").trim(); - if (!prompt) { - throw new Error('Usage: devspace agents run [--model ] [--thinking ] ""'); - } - - return { target, prompt, model, thinking }; -} - -export function resolveLocalAgentTarget( - target: string, - profiles: LocalAgentProfile[], - modelOverride?: string, - thinkingOverride?: 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, - thinking: thinkingOverride ?? profile.thinking, - profile, - }; - } - - if (isLocalAgentProvider(target)) { - return { - kind: "provider", - name: target, - provider: target, - model: modelOverride, - thinking: thinkingOverride, - }; - } - - 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 e47f81216..02df6bed8 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -43,7 +43,6 @@ 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" }, { version: 4, name: "workspace-conversation-bindings" }, ]); } finally { diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index b050e7903..b7f232b19 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -52,11 +52,11 @@ 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, process.env.DEVSPACE_WORKSPACE_ID, process.env.DEVSPACE_WORKSPACE_ROOT].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.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,workspace-a,\/tmp\/devspace-workspace-a/); +assert.match(environment.output, /1,dumb,cat,cat,cat,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 f414df193..277ed467d 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -100,7 +100,6 @@ function processEnvironment(input?: { PAGER: "cat", GIT_PAGER: "cat", GH_PAGER: "cat", - 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 } : {}), diff --git a/src/server.test.ts b/src/server.test.ts index c2f659d10..3f3830f8d 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -10,7 +10,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { loadConfig, type ServerConfig } from "./config.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; -import { createMcpServer } from "./server.js"; +import { createMcpServer, serverInstructions } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; @@ -32,8 +32,8 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(firstStructured.agentsFiles)); assert.ok(Array.isArray(firstStructured.availableAgentsFiles)); assert.ok(Array.isArray(firstStructured.skills)); - assert.ok(Array.isArray(firstStructured.agentProviders)); - assert.ok(Array.isArray(firstStructured.agents)); + assert.equal("agentProviders" in firstStructured, false); + assert.equal("agents" in firstStructured, false); assert.ok(Array.isArray(firstStructured.skillDiagnostics)); assert.equal("workspaceReused" in firstStructured, false); assert.equal("includeBootstrapContext" in firstStructured, false); @@ -42,8 +42,6 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.equal(repeatedStructured.agentsFiles, undefined); assert.equal(repeatedStructured.availableAgentsFiles, undefined); assert.equal(repeatedStructured.skills, undefined); - assert.equal(repeatedStructured.agentProviders, undefined); - assert.equal(repeatedStructured.agents, undefined); assert.equal(repeatedStructured.skillDiagnostics, undefined); assert.equal("workspaceReused" in repeatedStructured, false); assert.equal("includeBootstrapContext" in repeatedStructured, false); @@ -61,8 +59,8 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(card.agentsFiles)); assert.ok(Array.isArray(card.availableAgentsFiles)); assert.ok(Array.isArray(card.skills)); - assert.ok(Array.isArray(card.agentProviders)); - assert.ok(Array.isArray(card.agents)); + assert.equal("agentProviders" in card, false); + assert.equal("agents" in card, false); }); test("concurrent checkout opens return one full context and one reuse instruction", async (t) => { @@ -98,8 +96,8 @@ test("new worktrees always receive a fresh workspace and complete worktree conte assert.ok(Array.isArray(structured.agentsFiles)); assert.ok(Array.isArray(structured.availableAgentsFiles)); assert.ok(Array.isArray(structured.skills)); - assert.ok(Array.isArray(structured.agentProviders)); - assert.ok(Array.isArray(structured.agents)); + assert.equal("agentProviders" in structured, false); + assert.equal("agents" in structured, false); assert.ok(Array.isArray(structured.skillDiagnostics)); assert.match(responseText(result), /Opened isolated worktree workspace/); } @@ -148,7 +146,6 @@ test("checkout reuse and context suppression survive a registry restart", async createReviewCheckpointManager(), new ProcessSessionManager(), [], - [], ); const [restoredClientTransport, restoredServerTransport] = InMemoryTransport.createLinkedPair(); const restoredClient = new Client({ name: "devspace-restored-test-client", version: "1.0.0" }); @@ -177,6 +174,41 @@ test("checkout reuse and context suppression survive a registry restart", async } }); +test("native mode exposes the coding runtime tool surface without duplicate legacy tools", async (t) => { + const context = await fixture(t, { toolMode: "native" }); + const tools = await context.client.listTools(); + const names = tools.tools.map((tool) => tool.name).sort(); + + assert.deepEqual(names, [ + "apply_patch", + "exec_command", + "glob", + "grep", + "ls", + "open_workspace", + "read", + "write_stdin", + ]); + for (const legacyName of ["bash", "write", "edit"]) { + assert.equal(names.includes(legacyName), false); + } + + const execCommand = tools.tools.find((tool) => tool.name === "exec_command"); + assert.ok(execCommand?.description); + assert.match(execCommand.description, /may create, modify, rename, move, or delete files/i); + assert.match(execCommand.description, /rm, mv, cp, mkdir/i); + assert.match(execCommand.description, /not an OS sandbox/i); + assert.doesNotMatch(execCommand.description, /must not modify project files/i); + assert.doesNotMatch(execCommand.description, /do not create or modify files/i); + + const instructions = serverInstructions(context.config); + assert.match(instructions, /The MCP host is the coding agent/i); + assert.match(instructions, /Shell commands may create, modify, rename, move, or delete project files/i); + assert.match(instructions, /not an OS sandbox/i); + assert.doesNotMatch(instructions, /all file modifications/i); + assert.doesNotMatch(instructions, /Do not create or modify files/i); +}); + interface ServerFixture { client: Client; project: string; @@ -185,24 +217,19 @@ interface ServerFixture { close: () => Promise; } -async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise { +async function fixture( + t: TestContext, + options: { git?: boolean; toolMode?: "minimal" | "full" | "native" } = {}, +): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); const project = join(root, "project"); const agentDir = join(root, "agent"); const stateDir = join(root, ".state"); - await mkdir(join(project, ".devspace", "agents"), { recursive: true }); + await mkdir(project, { recursive: true }); await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); await writeFile(join(project, "AGENTS.md"), "project instructions\n"); - await writeFile(join(project, ".devspace", "agents", "reviewer.md"), [ - "---", - "name: reviewer", - "description: Reviews project changes.", - "provider: codex", - "---", - "Review changes.", - ].join("\n")); if (options.git) { await writeFile(join(project, "README.md"), "hello\n"); @@ -219,7 +246,7 @@ async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, DEVSPACE_WIDGETS: "full", - DEVSPACE_TOOL_MODE: "full", + DEVSPACE_TOOL_MODE: options.toolMode ?? "full", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); @@ -231,7 +258,6 @@ async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise createReviewCheckpointManager(), new ProcessSessionManager(), [], - [], ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "devspace-test-client", version: "1.0.0" }); diff --git a/src/server.ts b/src/server.ts index 37ec41654..af73e78de 100644 --- a/src/server.ts +++ b/src/server.ts @@ -55,12 +55,6 @@ import { shutdownHttpServer } from "./server-shutdown.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; // MCP clients can reconnect without closing the previous transport. Bound stale @@ -91,7 +85,6 @@ const SHELL_TOOL_ANNOTATIONS = { interface RunningServer { app: ReturnType; config: ServerConfig; - localAgentProviders: LocalAgentProviderAvailability[]; close(): Promise; } @@ -187,17 +180,17 @@ interface ToolLogFields { error?: string; } -function serverInstructions(config: ServerConfig): string { +export function serverInstructions(config: ServerConfig): string { const artifactInstruction = config.artifactsEnabled && isArtifactDownloadSupportedPlatform() - ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." + ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with text-oriented workspace tools or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." : ""; const showChangesInstruction = config.widgets === "changes" ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." : ""; - if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Open it again when the workspaceId is invalid, the project changes, checkout/worktree mode changes, or another isolated worktree is needed. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; + if (config.toolMode === "native") { + return `Use DevSpace as a local coding runtime. The MCP host is the coding agent; DevSpace does not delegate reasoning or coding work to another model or coding-agent provider. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for efficient workspace inspection. Use apply_patch when a structured patch is the clearest way to edit source code. Use exec_command naturally for normal local development operations, including git, rm, mv, cp, mkdir, package managers, generators, formatters, tests, builds, compilers, interpreters, Docker, and project scripts. Shell commands may create, modify, rename, move, or delete project files. Use write_stdin to poll or interact with running processes. Follow project instruction files and applicable skills. Before completing a coding task, run relevant tests and inspect the resulting changes when appropriate. Shell commands run with the authority of the local operating-system user and are not an OS sandbox.${artifactInstruction}${showChangesInstruction}`; } const inspection = config.toolMode !== "full" @@ -210,27 +203,7 @@ function serverInstructions(config: ServerConfig): string { const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - 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 to a different project folder, changing checkout/worktree mode, the workspaceId is rejected as unknown, or a new isolated worktree is requested. ${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.${artifactInstruction}${showChangesInstruction}`; -} - -function formatVisibleAgent(agent: { - name: string; - provider: string; - model?: string; - thinking?: string; - providerAvailable?: boolean; - providerUnavailableReason?: string; -}): string { - const model = agent.model ? `, model ${agent.model}` : ""; - const thinking = agent.thinking ? `, thinking ${agent.thinking}` : ""; - const availability = agent.providerAvailable === false - ? `, unavailable: ${agent.providerUnavailableReason ?? "provider unavailable"}` - : ""; - return `${agent.name} (${agent.provider}${model}${thinking}${availability})`; -} - -function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability): string { - return `${provider.name} (${provider.reason ?? "unavailable"})`; + 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 to a different project folder, changing checkout/worktree mode, the workspaceId is rejected as unknown, or a new isolated worktree is requested. ${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.${artifactInstruction}${showChangesInstruction}`; } function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { @@ -255,22 +228,6 @@ const workspaceAgentsFileOutputSchema = z.object({ content: z.string(), }); -const workspaceLocalAgentOutputSchema = z.object({ - name: z.string(), - description: z.string(), - provider: z.string(), - model: z.string().optional(), - thinking: 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(), }); @@ -550,7 +507,7 @@ function processToolResponse( }; } -function registerCodexProcessTools( +function registerNativeProcessTools( server: McpServer, config: ServerConfig, workspaces: WorkspaceRegistry, @@ -562,7 +519,7 @@ function registerCodexProcessTools( { title: "Execute command", description: - "Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes. Call open_workspace first and pass workspaceId.", + "Run a normal local development shell command from an open workspace. Commands may create, modify, rename, move, or delete files and may use rm, mv, cp, mkdir, git, package managers, generators, formatters, tests, builds, compilers, interpreters, Docker, and project scripts. Returns output when the command exits during the yield window; otherwise returns a sessionId for write_stdin. Shell execution has the authority of the local operating-system user and is not an OS sandbox. Workspace containment applies to structured filesystem tools, not arbitrary shell commands. Call open_workspace first and pass workspaceId.", inputSchema: { workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), cmd: z.string().min(1).describe("Shell command to execute."), @@ -699,7 +656,6 @@ export function createMcpServer( workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, - localAgentProviders: LocalAgentProviderAvailability[], incomingArtifactAdapters: readonly IncomingArtifactAdapter[], ): McpServer { const server = new McpServer( @@ -788,8 +744,6 @@ export function createMcpServer( agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(), availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(), skills: z.array(workspaceSkillOutputSchema).optional(), - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(), - agents: z.array(workspaceLocalAgentOutputSchema).optional(), skillDiagnostics: z.array(z.unknown()).optional(), instruction: z.string(), }, @@ -821,16 +775,6 @@ export function createMcpServer( description: skill.description, path: formatPathForPrompt(skill.filePath), })); - const cardAgentProviders = config.subagents ? localAgentProviders : []; - const cardAgents = workspace.agentProfiles.map((profile) => { - const summary = summarizeLocalAgentProfile(profile); - const availability = cardAgentProviders.find((provider) => provider.name === summary.provider); - return { - ...summary, - providerAvailable: availability?.available, - providerUnavailableReason: availability?.reason, - }; - }); const cardAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, @@ -839,8 +783,6 @@ export function createMcpServer( path: formatAgentsPath(file.path, workspace.root), })); const visibleSkills = includeBootstrapContext ? cardSkills : []; - const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : []; - const visibleAgents = includeBootstrapContext ? cardAgents : []; const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; const cardInstruction = config.skillsEnabled @@ -850,10 +792,10 @@ export function createMcpServer( ? [ `Workspace already open as ${workspace.id}.`, "Reuse this workspaceId for subsequent tool calls. This is the same checkout previously opened for this project in this conversation.", - "Continue following the project instructions, nested instruction files, skills, agent profiles, and diagnostics previously provided for this workspace. They remain the active workspace context and are not repeated here.", + "Continue following the project instructions, nested instruction files, skills, and diagnostics previously provided for this workspace. They remain the active workspace context and are not repeated here.", ].join("\n\n") : workspace.mode === "worktree" - ? "Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree." + ? "Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, and diagnostics returned for this isolated worktree." : cardInstruction; const resultContent: ToolContent[] = [ { @@ -875,15 +817,6 @@ export 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"), }, @@ -912,16 +845,12 @@ export function createMcpServer( agentsFiles: cardAgentsFiles, availableAgentsFiles: cardAvailableAgentsFiles, skills: cardSkills, - agentProviders: cardAgentProviders, - agents: cardAgents, instruction: cardInstruction, summary: { mode: workspace.mode, agentsFiles: cardAgentsFiles.length, availableAgentsFiles: cardAvailableAgentsFiles.length, skills: cardSkills.length, - agentProviders: cardAgentProviders.length, - agents: cardAgents.length, }, }, }, @@ -936,8 +865,6 @@ export function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, skillDiagnostics: workspace.skillDiagnostics, } : {}), @@ -1044,7 +971,7 @@ export function createMcpServer( }, ); - if (config.toolMode !== "codex") { + if (config.toolMode !== "native") { registerAppTool( server, toolNames.write, @@ -1210,14 +1137,14 @@ export function createMcpServer( ); } - if (config.toolMode === "codex") { + if (config.toolMode === "native") { registerAppTool( server, "apply_patch", { title: "Apply patch", description: - "Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", + "Apply one structured patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. Use this when a patch is the clearest way to make precise source edits; shell commands may also modify files when more appropriate. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", inputSchema: { workspaceId: z .string() @@ -1340,7 +1267,7 @@ export function createMcpServer( ); } - if (config.toolMode === "full") { + if (config.toolMode === "full" || config.toolMode === "native") { registerAppTool( server, toolNames.grep, @@ -1551,7 +1478,7 @@ export function createMcpServer( ); } - if (config.toolMode !== "codex") { + if (config.toolMode !== "native") { registerAppTool( server, toolNames.shell, @@ -1643,8 +1570,8 @@ export function createMcpServer( ); } - if (config.toolMode === "codex") { - registerCodexProcessTools(server, config, workspaces, processSessions); + if (config.toolMode === "native") { + registerNativeProcessTools(server, config, workspaces, processSessions); } if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) { @@ -1688,9 +1615,6 @@ export function createServer( const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); - const localAgentProviders = config.subagents - ? getLocalAgentProviderAvailabilitySnapshot() - : []; const logSessionCloseResults = ( reason: "idle_timeout" | "server_shutdown", @@ -1850,7 +1774,6 @@ export function createServer( workspaces, reviewCheckpoints, processSessions, - localAgentProviders, incomingArtifactAdapters, ); await server.connect(transport); @@ -1875,7 +1798,6 @@ export function createServer( return { app, config, - localAgentProviders, close: () => { closePromise ??= (async () => { clearInterval(sessionCleanupTimer); @@ -1899,7 +1821,7 @@ async function isMainModule(): Promise { } if (await isMainModule()) { - const { app, config, close, localAgentProviders } = createServer(); + const { app, config, close } = createServer(); const httpServer = app.listen(config.port, config.host, () => { console.log( `devspace listening on http://${config.host}:${config.port}/mcp`, @@ -1916,9 +1838,6 @@ if (await isMainModule()) { ? "enabled" : `unsupported on ${process.platform}`; console.log(`native artifact download: ${artifactDownloadStatus}`); - if (config.subagents) { - console.log(`subagent providers: ${formatLocalAgentProviderAvailabilitySummary(localAgentProviders)}`); - } }); let shuttingDown = false; diff --git a/src/skills.test.ts b/src/skills.test.ts index 707dda262..d35612e90 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -31,10 +31,8 @@ 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( @@ -125,28 +123,6 @@ 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"), [ @@ -184,30 +160,9 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "claude-project-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), false); assert.equal(loaded.skills.some((skill) => skill.name === "devspace-local-skill"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "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 c1f146a9f..9bc495e77 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,7 +1,6 @@ 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, @@ -21,27 +20,12 @@ 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 bundledSkills = bundledSkillsDir(); const defaultPathCandidates = [ join(homedir(), ".agents", "skills"), resolve(cwd, ".agents", "skills"), config.devspaceSkillsDir, join(config.agentDir, "skills"), - config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir) - ? bundledSkills - : undefined, ]; const defaultPaths = defaultPathCandidates.filter( (path): path is string => path !== undefined && existsSync(path), @@ -71,15 +55,7 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk 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); - }), - }; + return result; } export function resolveSkillReadPath( diff --git a/src/ui/assets/provider-logos/claude.svg b/src/ui/assets/provider-logos/claude.svg deleted file mode 100644 index a85b80fab..000000000 --- a/src/ui/assets/provider-logos/claude.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/ui/assets/provider-logos/copilot-dark.svg b/src/ui/assets/provider-logos/copilot-dark.svg deleted file mode 100644 index d09df8056..000000000 --- a/src/ui/assets/provider-logos/copilot-dark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/ui/assets/provider-logos/cursor-dark.svg b/src/ui/assets/provider-logos/cursor-dark.svg deleted file mode 100644 index d50421b51..000000000 --- a/src/ui/assets/provider-logos/cursor-dark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/ui/assets/provider-logos/openai-dark.svg b/src/ui/assets/provider-logos/openai-dark.svg deleted file mode 100644 index 7e19c92d2..000000000 --- a/src/ui/assets/provider-logos/openai-dark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/ui/assets/provider-logos/opencode-dark.svg b/src/ui/assets/provider-logos/opencode-dark.svg deleted file mode 100644 index 62e10df44..000000000 --- a/src/ui/assets/provider-logos/opencode-dark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/ui/assets/provider-logos/pi-on-dark.svg b/src/ui/assets/provider-logos/pi-on-dark.svg deleted file mode 100644 index 7b55eca7f..000000000 --- a/src/ui/assets/provider-logos/pi-on-dark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 1648f0254..ab54d74d5 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -68,16 +68,6 @@ test("show changes still opens immediately", () => { ); }); -test("a workspace card expands when it contains provider metadata", () => { - assert.equal( - isExpandableCard({ - tool: "open_workspace", - agentProviders: [{ name: "codex", available: true }], - }), - true, - ); -}); - test("a workspace card with details opens immediately", () => { assert.equal( isInitiallyExpandedCard({ @@ -88,16 +78,6 @@ test("a workspace card with details opens immediately", () => { ); }); -test("a workspace card expands when it contains agent metadata", () => { - assert.equal( - isExpandableCard({ - tool: "open_workspace", - agents: [{ name: "reviewer", provider: "codex" }], - }), - true, - ); -}); - test("a workspace card expands when it contains available instruction files", () => { assert.equal( isExpandableCard({ diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 3d2380830..cc6465ca1 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -64,20 +64,6 @@ export interface ToolResultCard { description?: string; path?: string; }>; - agentProviders?: Array<{ - name?: string; - available?: boolean; - reason?: string; - }>; - agents?: Array<{ - name?: string; - description?: string; - provider?: string; - model?: string; - thinking?: string; - providerAvailable?: boolean; - providerUnavailableReason?: string; - }>; instruction?: string; } @@ -168,13 +154,9 @@ export function isExpandableCard(card: ToolResultCard): boolean { return ( Number(card.summary?.agentsFiles ?? 0) > 0 || Number(card.summary?.skills ?? 0) > 0 || - Number(card.summary?.agentProviders ?? 0) > 0 || - Number(card.summary?.agents ?? 0) > 0 || Boolean(card.agentsFiles?.length) || Boolean(card.availableAgentsFiles?.length) || Boolean(card.skills?.length) || - Boolean(card.agentProviders?.length) || - Boolean(card.agents?.length) || Boolean(card.worktree) || Boolean(card.instruction) ); diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 022105d03..be898bf9d 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -1,9 +1,7 @@ import { - Bot, Blocks, ChevronDown, CircleAlert, - Cpu, FileDiff, FileCheck2, FileMinus, @@ -25,7 +23,6 @@ import { } from "lucide"; export const toolIcons = { - agents: Bot, base: GitCommitHorizontal, chevronDown: ChevronDown, deleteFile: FileMinus, @@ -39,7 +36,6 @@ export const toolIcons = { instructionAvailable: FileText, instructionLoaded: FileCheck2, loading: LoaderCircle, - providers: Cpu, readFile: FileText, search: Search, skills: Blocks, @@ -52,20 +48,6 @@ export const toolIcons = { export type ToolIcon = IconNode; -const providerLogos = { - claude: new URL("./assets/provider-logos/claude.svg", import.meta.url).href, - codex: new URL("./assets/provider-logos/openai-dark.svg", import.meta.url).href, - copilot: new URL("./assets/provider-logos/copilot-dark.svg", import.meta.url).href, - cursor: new URL("./assets/provider-logos/cursor-dark.svg", import.meta.url).href, - opencode: new URL("./assets/provider-logos/opencode-dark.svg", import.meta.url).href, - pi: new URL("./assets/provider-logos/pi-on-dark.svg", import.meta.url).href, -} as const; - -export function getProviderLogo(name: string): string | undefined { - const normalizedName = name.trim().toLowerCase() as keyof typeof providerLogos; - return providerLogos[normalizedName]; -} - export function renderIcon(icon: ToolIcon, className = "icon-svg"): SVGElement { return createElement(icon, { class: className, diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index bee72228f..6ec424407 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -390,14 +390,6 @@ body { white-space: nowrap; } -.workspace-chip-logo { - display: block; - width: 13px; - height: 13px; - flex: 0 0 auto; - object-fit: contain; -} - .workspace-chip-label { min-width: 0; overflow: hidden; @@ -405,72 +397,8 @@ body { white-space: nowrap; } -.workspace-agent-profile { - display: inline-flex; - max-width: 100%; - min-height: 24px; - align-items: center; - gap: 5px; - overflow: hidden; - padding: 3px 2px 4px; - border: 0; - border-bottom: 1px solid color-mix(in srgb, var(--tool-accent) 34%, var(--tool-card-divider)); - border-radius: 0; - background: transparent; - color: var(--color-text-secondary, #c7c7ce); - font-size: 11px; - line-height: 1.25; - white-space: nowrap; -} - -.workspace-agent-profile-logo { - display: block; - width: 14px; - height: 14px; - flex: 0 0 auto; - object-fit: contain; -} - -.workspace-agent-profile:hover { - border-bottom-color: var(--tool-accent); - color: var(--color-text-primary, #f5f5f6); -} - -.workspace-agent-profile.muted { - border-bottom-style: dashed; - color: var(--color-text-tertiary, #a3a3aa); - opacity: 0.72; -} - -.workspace-provider-logo { - display: inline-grid; - width: 20px; - height: 24px; - flex: 0 0 auto; - place-items: center; - cursor: help; -} - -.workspace-provider-logo-image { - display: block; - width: 16px; - height: 16px; - object-fit: contain; -} - -.workspace-provider-logo.muted { - opacity: 0.62; -} - -.workspace-chip.muted { - border-style: dashed; - color: var(--color-text-tertiary, #a3a3aa); - opacity: 0.72; -} - .workspace-skills-row, -.workspace-instructions-row, -.workspace-agents-row { +.workspace-instructions-row { align-items: start; } @@ -479,11 +407,6 @@ body { overflow: visible; } -.workspace-agents-list { - flex-wrap: wrap; - overflow: visible; -} - .workspace-instruction-status { display: grid; width: 18px; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index eab9c501d..3d8513d93 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -20,7 +20,7 @@ import { type ToolName, type ToolResultCard, } from "./card-types.js"; -import { getProviderLogo, renderIcon, toolIcons, type ToolIcon } from "./icons.js"; +import { renderIcon, toolIcons, type ToolIcon } from "./icons.js"; import { getToolDisplay, getToolHeaderSummary, @@ -521,51 +521,6 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v appendWorkspaceSkills(rows, skills); } - const providers = card.agentProviders ?? []; - const agents = card.agents ?? []; - const agentChips: WorkspaceChip[] = agents.map((agent) => { - const name = agent.name ?? "Unnamed agent"; - const providerName = agent.provider?.trim(); - const unavailable = agent.providerAvailable === false; - const title = [ - agent.description, - providerName ? `Provider: ${providerName}` : undefined, - agent.model ? `Model: ${agent.model}` : undefined, - agent.thinking ? `Thinking: ${agent.thinking}` : undefined, - unavailable - ? agent.providerUnavailableReason ?? "Provider unavailable" - : undefined, - ].filter((value): value is string => Boolean(value)).join("\n"); - return { - label: name, - logo: providerName ? getProviderLogo(providerName) : undefined, - profile: true, - tone: unavailable ? "muted" as const : undefined, - title: title || undefined, - }; - }); - const providerChips: WorkspaceChip[] = providers.map((provider) => { - const name = provider.name?.trim() || "Unknown provider"; - const unavailable = provider.available === false; - const logo = getProviderLogo(name); - return { - label: name, - logo, - bareLogo: Boolean(logo), - ariaLabel: name, - tone: unavailable ? "muted" as const : undefined, - title: unavailable ? provider.reason ?? "Provider unavailable" : name, - }; - }); - - if (agentChips.length > 0) { - const chipList = renderWorkspaceChips([...agentChips, ...providerChips]); - chipList.classList.add("workspace-agents-list"); - appendWorkspaceRow(rows, "Agents", chipList, toolIcons.agents, "workspace-agents-row"); - } else if (providerChips.length > 0) { - appendWorkspaceChipRow(rows, "Providers", providerChips, toolIcons.providers); - } - if (rows.childElementCount > 0) details.append(rows); if (details.childElementCount === 0) { @@ -577,12 +532,7 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v interface WorkspaceChip { label: string; - logo?: string; - profile?: boolean; - bareLogo?: boolean; - ariaLabel?: string; title?: string; - tone?: "muted"; } interface WorkspaceInstruction { @@ -778,15 +728,6 @@ function appendWorkspaceTextRow( appendWorkspaceRow(container, label, content, icon); } -function appendWorkspaceChipRow( - container: HTMLElement, - label: string, - chips: WorkspaceChip[], - icon: ToolIcon, -): void { - appendWorkspaceRow(container, label, renderWorkspaceChips(chips), icon); -} - function appendWorkspaceRow( container: HTMLElement, label: string, @@ -831,37 +772,11 @@ function renderWorkspaceRowIcon(icon: ToolIcon): HTMLElement { function renderWorkspaceChips(chips: WorkspaceChip[]): HTMLElement { const list = element("span", { className: "workspace-chip-list" }); for (const chip of chips) { - const bareLogo = Boolean(chip.bareLogo && chip.logo); const item = element("span", { - className: [ - bareLogo - ? "workspace-provider-logo" - : chip.profile - ? "workspace-agent-profile" - : "workspace-chip", - chip.tone, - ].filter(Boolean).join(" "), + className: "workspace-chip", title: chip.title, }); - if (bareLogo) { - item.setAttribute("role", "img"); - item.setAttribute("aria-label", chip.ariaLabel ?? chip.label); - } - if (chip.logo) { - const logo = document.createElement("img"); - logo.className = bareLogo - ? "workspace-provider-logo-image" - : chip.profile - ? "workspace-agent-profile-logo" - : "workspace-chip-logo"; - logo.src = chip.logo; - logo.alt = ""; - logo.setAttribute("aria-hidden", "true"); - item.append(logo); - } - if (!bareLogo) { - item.append(element("span", { className: "workspace-chip-label", text: chip.label })); - } + item.append(element("span", { className: "workspace-chip-label", text: chip.label })); list.append(item); } return list; diff --git a/src/user-config.ts b/src/user-config.ts index 5dd793ef8..7f03dfef0 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 { dirname, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; export interface DevspaceUserConfig { @@ -20,7 +20,6 @@ export interface DevspaceUserConfig { artifactsEnabled?: boolean; artifactMaxFileBytes?: number; agentDir?: string; - subagents?: boolean; } export interface DevspaceAuthConfig { @@ -53,10 +52,6 @@ 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"); @@ -99,24 +94,6 @@ 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/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 5af9f991d..912f6d486 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import test, { type TestContext } from "node:test"; @@ -23,7 +23,6 @@ test("a conversation reuses its checkout context", async (t) => { assert.deepEqual(second.availableAgentsFiles, first.availableAgentsFiles); assert.deepEqual(second.workspace.skills, first.workspace.skills); assert.deepEqual(second.workspace.skillDiagnostics, first.workspace.skillDiagnostics); - assert.deepEqual(second.workspace.agentProfiles, first.workspace.agentProfiles); }); test("different conversations receive separate checkout workspaces", async (t) => { @@ -151,44 +150,6 @@ test("checkout reuse survives a registry restart", async (t) => { assert.equal(restored.workspace.id, first.workspace.id); }); -test("a failed first context load does not consume bootstrap", async (t) => { - const { project, registry } = await fixture(t); - const agentsDir = join(project, ".devspace", "agents"); - const backupDir = join(project, ".devspace", "agents-backup"); - - await breakAgentsDirectory(agentsDir, backupDir); - try { - await assert.rejects( - () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), - /directory|ENOTDIR/i, - ); - } finally { - await restoreAgentsDirectory(agentsDir, backupDir); - } - - const successfulOpen = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); -}); - -test("a context-loading failure preserves a valid checkout binding", async (t) => { - const { project, registry } = await fixture(t); - const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - const agentsDir = join(project, ".devspace", "agents"); - const backupDir = join(project, ".devspace", "agents-backup"); - - await breakAgentsDirectory(agentsDir, backupDir); - try { - await assert.rejects( - () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), - /directory|ENOTDIR/i, - ); - } finally { - await restoreAgentsDirectory(agentsDir, backupDir); - } - - const recovered = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - assert.equal(recovered.workspace.id, first.workspace.id); -}); - test("a deleted checkout is replaced with a new workspace", async (t) => { const { project, registry } = await fixture(t); const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); @@ -401,18 +362,10 @@ async function fixture( const stateDir = join(root, ".state"); const stores = new Set(); - await mkdir(join(project, ".devspace", "agents"), { recursive: true }); + await mkdir(project, { recursive: true }); await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); await writeFile(join(project, "AGENTS.md"), "project instructions\n"); - await writeFile(join(project, ".devspace", "agents", "reviewer.md"), [ - "---", - "name: reviewer", - "description: Reviews project changes.", - "provider: codex", - "---", - "Review changes.", - ].join("\n")); if (options.git) await initializeGitRepository(project); @@ -421,7 +374,6 @@ async function fixture( DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); @@ -452,16 +404,6 @@ async function fixture( }; } -async function breakAgentsDirectory(agentsDir: string, backupDir: string): Promise { - await rename(agentsDir, backupDir); - await writeFile(agentsDir, "not a directory\n"); -} - -async function restoreAgentsDirectory(agentsDir: string, backupDir: string): Promise { - await rm(agentsDir, { force: true }); - await rename(backupDir, agentsDir); -} - async function initializeGitRepository(root: string): Promise { await writeFile(join(root, "README.md"), "hello\n"); await git(root, ["init"]); diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 8584c1b7e..658fb6bee 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -26,21 +26,6 @@ test("a checkout exposes initial and nested instruction context while filtering opened.availableAgentsFiles.map((file) => file.path), [join(context.root, "nested", "AGENTS.md")], ); - assert.deepEqual( - opened.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.", - }], - ); - if (platform() !== "win32") { const unsafeAgentDir = join(context.root, ".pi", "unsafe-agent"); await mkdir(unsafeAgentDir, { recursive: true }); @@ -189,20 +174,6 @@ async function fixture(t: TestContext): Promise { } 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"); @@ -212,7 +183,6 @@ async function fixture(t: TestContext): Promise { 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", }); diff --git a/src/workspaces.ts b/src/workspaces.ts index 05c8b9d24..f034557b3 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -23,10 +23,6 @@ import { type LoadedSkills, type SkillReadResolution, } from "./skills.js"; -import { - loadLocalAgentProfiles, - type LocalAgentProfile, -} from "./local-agent-profiles.js"; export interface LoadedAgentsFile { path: string; @@ -54,7 +50,6 @@ export interface Workspace { worktree?: WorkspaceWorktree; skills: LoadedSkills["skills"]; skillDiagnostics: LoadedSkills["diagnostics"]; - agentProfiles: LocalAgentProfile[]; activatedSkillDirs: Set; } @@ -229,7 +224,6 @@ export class WorkspaceRegistry { } private async reusedWorkspaceContext(workspace: Workspace): Promise { - workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); @@ -272,7 +266,6 @@ export class WorkspaceRegistry { } : undefined, ...this.loadSkillsForWorkspace(root), - agentProfiles: [], activatedSkillDirs: new Set(), }; this.store?.touchSession(workspaceId); @@ -361,7 +354,6 @@ export class WorkspaceRegistry { sourceRoot: input.sourceRoot, worktree: input.worktree, ...this.loadSkillsForWorkspace(input.root), - agentProfiles: await loadLocalAgentProfiles(this.config, input.root), activatedSkillDirs: new Set(), };