From e582d3af15d0817248747c8b970e7dc69c16cbf9 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 04:08:16 +0530 Subject: [PATCH 01/33] feat(agents): add local agent profile catalog --- .../plan/local-agent-profiles-and-cli-plan.md | 316 ++++++++++++++++++ package-lock.json | 135 ++++++++ package.json | 3 +- src/config.test.ts | 1 + src/config.ts | 4 +- src/local-agent-profiles.test.ts | 96 ++++++ src/local-agent-profiles.ts | 226 +++++++++++++ src/local-agent-runtime.test.ts | 98 ++++++ src/local-agent-runtime.ts | 104 ++++++ src/server.ts | 17 + src/user-config.ts | 4 + src/workspaces.test.ts | 35 ++ src/workspaces.ts | 7 + 13 files changed, 1044 insertions(+), 2 deletions(-) create mode 100644 .agents/plan/local-agent-profiles-and-cli-plan.md create mode 100644 src/local-agent-profiles.test.ts create mode 100644 src/local-agent-profiles.ts create mode 100644 src/local-agent-runtime.test.ts create mode 100644 src/local-agent-runtime.ts diff --git a/.agents/plan/local-agent-profiles-and-cli-plan.md b/.agents/plan/local-agent-profiles-and-cli-plan.md new file mode 100644 index 00000000..5e7f90d0 --- /dev/null +++ b/.agents/plan/local-agent-profiles-and-cli-plan.md @@ -0,0 +1,316 @@ +# Local Agent Profiles and Minimal CLI Plan + +## Goal + +Add local coding-agent delegation to DevSpace without forcing the supervising +model to stream or reason over raw provider CLI output. + +The model-facing surface should stay small: + +```bash +devspace agents ls +devspace agents run "" +devspace agents show +``` + +Everything richer, including JSON output, explicit workspace ids, logs, stop, +diagnostics, and future MCP tools, can exist as implementation details or +advanced commands but should not be taught in the default skill. + +## Principles + +- DevSpace owns the stable agent handle. Provider session ids are stored as + metadata and aliases, not exposed as the primary handle. +- Agent profiles describe roles. Provider configuration describes how to launch + or connect to a harness. +- Prefer first-class adapters for popular harnesses. Keep CLI as a fallback for + custom or unsupported local agents. +- Do not infer changed files, tests, or diffs in v1. Show only status and the + agent final response or latest known response. +- Keep raw provider transcripts out of the default model context. Store them for + debugging and expose them only through explicit advanced commands. + +## Agent Profiles + +Profiles should be markdown files with frontmatter plus an instruction body. +The frontmatter is for discovery and routing. The body is the worker prompt +prefix used internally when DevSpace launches the profile. + +Default profile locations: + +- Global: `~/.devspace/agents/*.md` +- Project: `.devspace/agents/*.md` + +Packaged examples should remain inert templates. + +Minimal profile shape: + +```md +--- +name: reviewer +description: Read-only reviewer for bugs, security risks, and missing tests. +provider: codex +model: gpt-5.4 +mode: review +permissions: + edit: deny + bash: deny +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. +``` + +For v1, avoid profile fields such as `workspace.mode`, `writeMode`, +`runtime.maxTurns`, or `output`. They add routing complexity without improving +the model-facing workflow. + +## Provider Configuration + +Provider configuration is separate from profiles. + +Profiles can say: + +```yaml +provider: codex +model: gpt-5.4 +``` + +DevSpace resolves the provider backend internally: + +1. SDK or app-server adapter when available. +2. ACP adapter when available. +3. CLI adapter as fallback. + +Future custom provider config can support: + +```json +{ + "agents": { + "providers": { + "my-acp-agent": { + "extends": "acp", + "label": "My ACP Agent", + "command": ["my-agent", "acp"] + }, + "legacy-agent": { + "backend": "cli", + "label": "Legacy Agent", + "command": ["legacy-agent", "run"] + } + } + } +} +``` + +The existing CLI-oriented example profiles should be reworked into role +profiles. Command templates should move to provider config or CLI fallback +adapter tests. + +## `open_workspace` Exposure + +When local agents are enabled, `open_workspace` should expose a compact agent +catalog next to skills. + +Structured output should include: + +```json +{ + "agents": [ + { + "name": "reviewer", + "description": "Read-only reviewer for bugs, security risks, and missing tests.", + "provider": "codex", + "model": "gpt-5.4", + "mode": "review", + "permissions": { + "edit": "deny", + "bash": "deny" + } + } + ] +} +``` + +Model-readable text should stay terse: + +```text +Available local agents: reviewer, implementer. +Use the local-agent-delegation skill before delegating work. +``` + +Do not include the full profile body in `open_workspace`; the body can be read +or used internally only when the profile is launched. + +## Skill Guidance + +The local-agent-delegation skill should teach only: + +```bash +devspace agents ls +devspace agents run "" +devspace agents show +``` + +Rules for the model: + +- Use local agents only when the user asks for delegation, second opinion, + parallel work, named local agent usage, or a task clearly benefits from a + configured specialist. +- Pick a profile by `description` and `permissions`. +- Do not silently delegate normal coding tasks. +- Use `run ""` to start a new agent. +- Use `run ""` to send a follow-up to an existing agent. +- Use `show ` to get status and the latest/final response. +- Review the worker's answer before presenting it as verified. + +## CLI Behavior + +### `devspace agents ls` + +Lists available profiles and active agents for the current DevSpace workspace. + +Workspace scoping should resolve in this order: + +1. `DEVSPACE_WORKSPACE_ID`, injected by DevSpace process tools when available. +2. Current working directory. +3. Advanced hidden `--workspace-id` flag, not taught in the skill. + +### `devspace agents run ""` + +If the first argument matches an existing DevSpace agent id or provider session +alias, send a follow-up. Otherwise, treat it as a profile name and create a new +agent. + +Default output should be compact text: + +```text +agt_123 running reviewer +``` + +Keep `--json` available for tests, scripts, and future MCP wrappers, but do not +mention it in the skill. + +### `devspace agents show ` + +Shows agent status and response. + +Default behavior: + +- If idle or done, return immediately with the latest response. +- If running, wait up to 15 seconds. +- If it finishes within that window, print the final response. +- If still running, print compact status and tell the model to call `show` + again later. +- If waiting for permission, errored, or stopped, print that state. + +Example while running: + +```text +agt_123 running reviewer +No final response yet. Call `devspace agents show agt_123` again later. +``` + +Example when done: + +```text +agt_123 idle reviewer +The likely issue is in src/foo.ts... +``` + +Hidden advanced options can include `--no-wait`, `--timeout `, +`--json`, and `--logs`, but they should not be part of the default skill. + +## Runtime Storage + +Store a DevSpace-owned session record: + +```ts +interface LocalAgentRecord { + id: string; + workspaceId?: string; + workspaceRoot: string; + profileName: string; + provider: string; + model?: string; + backend: "auto" | "sdk" | "app-server" | "acp" | "cli"; + providerSessionId?: string; + status: "starting" | "running" | "idle" | "error" | "stopped"; + latestResponse?: string; + error?: string; + createdAt: string; + updatedAt: string; +} +``` + +Raw event streams and verbose logs should be stored separately and omitted from +default command output. + +## Adapter Plan + +Start with a small provider-neutral interface: + +```ts +interface LocalAgentRuntime { + provider: string; + backend: string; + start(input: StartAgentInput): Promise; + followUp(input: FollowUpAgentInput): Promise; + status?(id: string): Promise; + cancel?(id: string): Promise; +} +``` + +Initial adapters: + +1. Codex SDK/app-server adapter using the existing `local-agent-runtime.ts` + direction. +2. CLI fallback adapter for custom profiles and unsupported harnesses. +3. ACP adapter after the CLI/profile flow is stable. + +Later adapters can cover Claude Code SDK, OpenCode SDK/server, Pi RPC/SDK, and +other harness-specific APIs. + +## Implementation Phases + +### Phase 1: Profile Catalog + +- Add profile loader for global and project profile directories. +- Validate minimal frontmatter. +- Add `agents` to `open_workspace` structured output when local agents are + enabled. +- Update docs and tests. + +### Phase 2: Minimal CLI + +- Add `devspace agents ls`. +- Add local agent records in state storage. +- Add `run` and `show` command skeletons with mocked/runtime-injected adapter + tests. +- Inject `DEVSPACE_WORKSPACE_ID` and `DEVSPACE_WORKSPACE_ROOT` into DevSpace + process tool environments. + +### Phase 3: Runtime Adapters + +- Wire Codex SDK/app-server adapter. +- Wire CLI fallback adapter. +- Keep provider logs out of default output. + +### Phase 4: Skill and Examples + +- Rewrite `local-agent-delegation` skill around the three commands. +- Rework packaged examples into role profiles, not CLI command templates. +- Document provider config and CLI fallback separately. + +### Phase 5: Future MCP Tools + +Add MCP tools only after the CLI/runtime boundary is stable: + +- `spawn_agent` +- `show_agent` +- `run_agent` +- `list_agents` + +These should call the same runtime implementation as the CLI. diff --git a/package-lock.json b/package-lock.json index 159a7df4..8e9e090d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@earendil-works/pi-coding-agent": "^0.80.1", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@openai/codex-sdk": "^0.142.5", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", @@ -2467,6 +2468,140 @@ "@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/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", diff --git a/package.json b/package.json index 4a8d6057..1139eebb 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -39,6 +39,7 @@ "@earendil-works/pi-coding-agent": "^0.80.1", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@openai/codex-sdk": "^0.142.5", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", diff --git a/src/config.test.ts b/src/config.test.ts index e6054f00..c8f69b38 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -27,6 +27,7 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, " assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); +assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); assert.equal(loadConfig(baseEnv).localAgents, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); diff --git a/src/config.ts b/src/config.ts index 2fb7ec9a..83b7bcbd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; -import { devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; +import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; export type ToolMode = "minimal" | "full" | "codex"; @@ -26,6 +26,7 @@ export interface ServerConfig { skillsEnabled: boolean; skillPaths: string[]; devspaceSkillsDir: string; + devspaceAgentsDir: string; localAgents: boolean; agentDir: string; logging: LoggingConfig; @@ -238,6 +239,7 @@ 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), localAgents: env.DEVSPACE_LOCAL_AGENTS === undefined ? files.config.localAgents === true diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts new file mode 100644 index 00000000..1c3f921c --- /dev/null +++ b/src/local-agent-profiles.test.ts @@ -0,0 +1,96 @@ +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", + "permissions:", + " edit: deny", + " bash: deny", + "---", + "", + "Global body.", + "", + ].join("\n"), + ); + await writeFile( + join(workspaceRoot, ".devspace", "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + "description: Project reviewer.", + "provider: claude", + "mode: review", + "permissions:", + " edit: deny", + "---", + "", + "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_LOCAL_AGENTS: "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."); + assert.equal(profiles[0]?.provider, "claude"); + assert.equal(profiles[0]?.mode, "review"); + assert.equal(profiles[0]?.body, "Project body."); + assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { + name: "reviewer", + description: "Project reviewer.", + provider: "claude", + model: undefined, + mode: "review", + permissions: { edit: "deny" }, + }); + + const disabledConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: workspaceRoot, + DEVSPACE_LOCAL_AGENTS: "0", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }); + assert.deepEqual(await loadLocalAgentProfiles(disabledConfig, workspaceRoot), []); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts new file mode 100644 index 00000000..190ae3c3 --- /dev/null +++ b/src/local-agent-profiles.ts @@ -0,0 +1,226 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import type { ServerConfig } from "./config.js"; + +export type AgentPermissionValue = "allow" | "ask" | "deny"; + +export interface LocalAgentProfile { + name: string; + description: string; + provider: string; + model?: string; + mode?: string; + permissions?: Record; + filePath: string; + body: string; + disabled: boolean; +} + +export interface LocalAgentProfileSummary { + name: string; + description: string; + provider: string; + model?: string; + mode?: string; + permissions?: Record; +} + +interface ParsedFrontmatter { + frontmatter: Record; + body: string; +} + +const FRONTMATTER_DELIMITER = "---"; +const PERMISSION_VALUES = new Set(["allow", "ask", "deny"]); + +export async function loadLocalAgentProfiles( + config: ServerConfig, + workspaceRoot: string, +): Promise { + if (!config.localAgents) 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, + mode: profile.mode, + permissions: profile.permissions, + }; +} + +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); + profiles.push(await loadProfileFile(filePath)); + } + + 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(`Local agent profile is missing frontmatter: ${filePath}`); + } + + const endIndex = lines.findIndex( + (line, index) => index > 0 && line.trim() === FRONTMATTER_DELIMITER, + ); + if (endIndex === -1) { + throw new Error(`Local agent profile frontmatter is not closed: ${filePath}`); + } + + return { + frontmatter: parseSimpleYaml(lines.slice(1, endIndex), filePath), + body: lines.slice(endIndex + 1).join("\n").trim(), + }; +} + +function parseSimpleYaml(lines: string[], filePath: string): Record { + const result: Record = {}; + let currentMapKey: string | undefined; + + for (const rawLine of lines) { + const line = rawLine.replace(/\s+#.*$/, ""); + if (!line.trim()) continue; + + const nestedMatch = /^ ([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line); + if (nestedMatch) { + if (!currentMapKey) { + throw new Error(`Unexpected nested frontmatter field in ${filePath}: ${rawLine}`); + } + const current = result[currentMapKey]; + if (!current || typeof current !== "object" || Array.isArray(current)) { + throw new Error(`Invalid nested frontmatter field in ${filePath}: ${rawLine}`); + } + (current as Record)[nestedMatch[1] ?? ""] = parseScalar(nestedMatch[2] ?? ""); + continue; + } + + const topLevelMatch = /^([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line); + if (!topLevelMatch) { + throw new Error(`Unsupported frontmatter line in ${filePath}: ${rawLine}`); + } + + const key = topLevelMatch[1] ?? ""; + const value = topLevelMatch[2] ?? ""; + if (value === "") { + result[key] = {}; + currentMapKey = key; + continue; + } + + result[key] = parseScalar(value); + currentMapKey = undefined; + } + + return result; +} + +function parseScalar(value: string): unknown { + const trimmed = value.trim(); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function profileFromFrontmatter( + frontmatter: Record, + body: string, + filePath: string, +): LocalAgentProfile { + const name = readString(frontmatter, "name") ?? basename(filePath, ".md"); + const description = readString(frontmatter, "description"); + const provider = readString(frontmatter, "provider"); + if (!description) { + throw new Error(`Local agent profile is missing description: ${filePath}`); + } + if (!provider) { + throw new Error(`Local agent profile is missing provider: ${filePath}`); + } + + return { + name, + description, + provider, + model: readString(frontmatter, "model"), + mode: readString(frontmatter, "mode"), + permissions: readPermissions(frontmatter.permissions, filePath), + filePath, + body, + disabled: frontmatter.disabled === true, + }; +} + +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 readPermissions( + value: unknown, + filePath: string, +): Record | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Local agent profile permissions must be a map: ${filePath}`); + } + + const permissions: Record = {}; + for (const [key, rawPermission] of Object.entries(value)) { + if (!PERMISSION_VALUES.has(rawPermission as AgentPermissionValue)) { + throw new Error( + `Local agent profile permission '${key}' must be allow, ask, or deny: ${filePath}`, + ); + } + permissions[key] = rawPermission as AgentPermissionValue; + } + + return Object.keys(permissions).length > 0 ? permissions : undefined; +} diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts new file mode 100644 index 00000000..a3c2001d --- /dev/null +++ b/src/local-agent-runtime.test.ts @@ -0,0 +1,98 @@ +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.backend, "codex-sdk"); +assert.equal(readOnly.providerSessionId, "new-thread"); +assert.equal(readOnly.finalResponse, "response:inspect only"); +assert.deepEqual(codex.startThreadInstance.prompts, ["inspect only"]); +assert.deepEqual(codex.started[0], { + workingDirectory: "/tmp/project", + sandboxMode: "read-only", + approvalPolicy: "never", + model: undefined, +}); + +await runtime.run({ + prompt: "make change", + workspace: "/tmp/project", + writeMode: "allowed", + model: "gpt-5.4", +}); + +assert.deepEqual(codex.started[1], { + workingDirectory: "/tmp/project", + sandboxMode: "workspace-write", + approvalPolicy: "never", + model: "gpt-5.4", +}); + +const resumed = await runtime.run({ + prompt: "continue", + workspace: "/tmp/project", + providerSessionId: "existing-thread", + writeMode: "full_access", +}); + +assert.equal(resumed.providerSessionId, "resumed-thread"); +assert.deepEqual(codex.resumeThreadInstance.prompts, ["continue"]); +assert.deepEqual(codex.resumed, [ + { + id: "existing-thread", + options: { + workingDirectory: "/tmp/project", + sandboxMode: "danger-full-access", + approvalPolicy: "never", + model: undefined, + }, + }, +]); + +const created = await createCodexSdkLocalAgentRuntime(undefined, () => new FakeCodex()); +assert.equal(created.provider, "codex"); +assert.equal(created.backend, "codex-sdk"); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts new file mode 100644 index 00000000..b17b0782 --- /dev/null +++ b/src/local-agent-runtime.ts @@ -0,0 +1,104 @@ +import type { + Codex, + CodexOptions, + RunResult, + SandboxMode, + ThreadOptions, +} from "@openai/codex-sdk"; + +export type LocalAgentBackend = "cli" | "codex-sdk" | "acp"; +export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; + +export interface LocalAgentRunInput { + prompt: string; + workspace: string; + providerSessionId?: string; + writeMode?: LocalAgentWriteMode; + model?: string; +} + +export interface LocalAgentRunResult { + provider: "codex"; + backend: LocalAgentBackend; + providerSessionId: string | null; + finalResponse: string; + items: unknown[]; +} + +export interface LocalAgentRuntime { + readonly provider: "codex"; + readonly backend: LocalAgentBackend; + run(input: LocalAgentRunInput): Promise; +} + +interface CodexThreadLike { + readonly id: string | null; + run(prompt: string): Promise; +} + +interface CodexClientLike { + startThread(options?: ThreadOptions): CodexThreadLike; + resumeThread(id: string, options?: ThreadOptions): CodexThreadLike; +} + +type CodexFactory = (options?: CodexOptions) => CodexClientLike; + +function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): SandboxMode { + switch (writeMode) { + case "allowed": + return "workspace-write"; + case "full_access": + return "danger-full-access"; + case "read_only": + case undefined: + return "read-only"; + } +} + +function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { + return { + workingDirectory: input.workspace, + sandboxMode: sandboxModeFor(input.writeMode), + approvalPolicy: "never", + model: input.model, + }; +} + +export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { + readonly provider = "codex" as const; + readonly backend = "codex-sdk" 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, + backend: this.backend, + 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/server.ts b/src/server.ts index 9b1c0dbe..e5779e4e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,6 +41,7 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; +import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; @@ -230,6 +231,15 @@ const workspaceAgentsFileOutputSchema = z.object({ content: z.string(), }); +const workspaceLocalAgentOutputSchema = z.object({ + name: z.string(), + description: z.string(), + provider: z.string(), + model: z.string().optional(), + mode: z.string().optional(), + permissions: z.record(z.string(), z.enum(["allow", "ask", "deny"])).optional(), +}); + const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); @@ -745,6 +755,7 @@ function createMcpServer( agentsFiles: z.array(workspaceAgentsFileOutputSchema), availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), skills: z.array(workspaceSkillOutputSchema), + agents: z.array(workspaceLocalAgentOutputSchema), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), }, @@ -767,6 +778,7 @@ function createMcpServer( description: skill.description, path: formatPathForPrompt(skill.filePath), })); + const visibleAgents = workspace.agentProfiles.map(summarizeLocalAgentProfile); const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, @@ -793,6 +805,9 @@ function createMcpServer( visibleSkills.length > 0 ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, + visibleAgents.length > 0 + ? `Available local agents: ${visibleAgents.map((agent) => agent.name).join(", ")}` + : undefined, instruction, ].filter(Boolean).join("\n"), }, @@ -817,6 +832,7 @@ function createMcpServer( agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, + agents: visibleAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, }, }, @@ -830,6 +846,7 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, + agents: visibleAgents, skillDiagnostics: workspace.skillDiagnostics, instruction, }, diff --git a/src/user-config.ts b/src/user-config.ts index b8c9a0ba..57492591 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -51,6 +51,10 @@ export function devspaceSkillsDir(env: NodeJS.ProcessEnv = process.env): string return join(devspaceConfigDir(env), "skills"); } +export function devspaceAgentsDir(env: NodeJS.ProcessEnv = process.env): string { + return join(devspaceConfigDir(env), "agents"); +} + export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): DevspaceFiles { const dir = devspaceConfigDir(env); const configPath = join(dir, "config.json"); diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 554a3daa..86bcb47b 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -17,6 +17,22 @@ try { await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); await writeFile(join(root, "AGENTS.md"), "root instructions\n"); + await mkdir(join(root, ".devspace", "agents"), { recursive: true }); + await writeFile( + join(root, ".devspace", "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + "description: Read-only project reviewer.", + "provider: codex", + "permissions:", + " edit: deny", + "---", + "", + "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"); @@ -25,6 +41,7 @@ try { DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_LOCAL_AGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); @@ -40,6 +57,24 @@ try { availableAgentsFiles.map((file) => file.path), [join(root, "nested", "AGENTS.md")], ); + assert.deepEqual( + workspace.agentProfiles.map((profile) => ({ + name: profile.name, + description: profile.description, + provider: profile.provider, + permissions: profile.permissions, + body: profile.body, + })), + [ + { + name: "reviewer", + description: "Read-only project reviewer.", + provider: "codex", + permissions: { edit: "deny" }, + body: "Review only.", + }, + ], + ); const missingWorkspaceRoot = join(root, "missing", "workspace"); const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); diff --git a/src/workspaces.ts b/src/workspaces.ts index 3b7b51e9..673d0823 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -13,6 +13,10 @@ import { type LoadedSkills, type SkillReadResolution, } from "./skills.js"; +import { + loadLocalAgentProfiles, + type LocalAgentProfile, +} from "./local-agent-profiles.js"; export interface LoadedAgentsFile { path: string; @@ -40,6 +44,7 @@ export interface Workspace { worktree?: WorkspaceWorktree; skills: LoadedSkills["skills"]; skillDiagnostics: LoadedSkills["diagnostics"]; + agentProfiles: LocalAgentProfile[]; activatedSkillDirs: Set; } @@ -110,6 +115,7 @@ export class WorkspaceRegistry { } : undefined, ...this.loadSkillsForWorkspace(root), + agentProfiles: [], activatedSkillDirs: new Set(), }; this.store?.touchSession(workspaceId); @@ -200,6 +206,7 @@ export class WorkspaceRegistry { sourceRoot: input.sourceRoot, worktree: input.worktree, ...this.loadSkillsForWorkspace(input.root), + agentProfiles: await loadLocalAgentProfiles(this.config, input.root), activatedSkillDirs: new Set(), }; From 6266abf3c2c5838411c217240e2dba310bdea42c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 04:11:33 +0530 Subject: [PATCH 02/33] feat(agents): add minimal local agents cli --- package.json | 2 +- src/cli.test.ts | 43 +++++- src/cli.ts | 255 +++++++++++++++++++++++++++++++++- src/local-agent-store.test.ts | 39 ++++++ src/local-agent-store.ts | 148 ++++++++++++++++++++ src/process-sessions.test.ts | 5 +- src/process-sessions.ts | 18 ++- src/server.ts | 1 + 8 files changed, 501 insertions(+), 10 deletions(-) create mode 100644 src/local-agent-store.test.ts create mode 100644 src/local-agent-store.ts diff --git a/package.json b/package.json index 1139eebb..4b0cbd88 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.test.ts b/src/cli.test.ts index 96dfd55c..41d400e7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -14,3 +16,42 @@ 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 projectRoot = join(root, "project"); + mkdirSync(join(configDir, "agents"), { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + join(configDir, "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + "description: Read-only reviewer.", + "provider: codex", + "model: gpt-5.4", + "---", + "", + "Review only.", + "", + ].join("\n"), + ); + + const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_WORKSPACE_ROOT: projectRoot, + DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }); + + assert.match(output, /profile reviewer codex gpt-5\.4 - Read-only reviewer\./); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/cli.ts b/src/cli.ts index 8010120d..4843fdf6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,23 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { stdin as input, stdout as output } from "node:process"; -import { resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; +import { loadLocalAgentProfiles, type LocalAgentProfile } from "./local-agent-profiles.js"; +import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-store.js"; +import { + createCodexSdkLocalAgentRuntime, + type LocalAgentRunResult, + type LocalAgentWriteMode, +} from "./local-agent-runtime.js"; import { ensureDevspaceDefaultSkills, generateOwnerToken, @@ -17,7 +29,7 @@ import { } from "./user-config.js"; import { expandHomePath } from "./roots.js"; -type Command = "serve" | "init" | "doctor" | "config" | "help" | "version"; +type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version"; const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; @@ -41,6 +53,9 @@ async function main(argv: string[]): Promise { case "config": runConfigCommand(args); return; + case "agents": + await runAgentsCommand(args); + return; case "help": printHelp(); return; @@ -52,7 +67,7 @@ async function main(argv: string[]): Promise { function normalizeCommand(command: string | undefined): Command { if (!command || command === "serve" || command === "start") return "serve"; - if (command === "init" || command === "doctor" || command === "config") return command; + if (command === "init" || command === "doctor" || command === "config" || command === "agents") return command; if (command === "help" || command === "--help" || command === "-h") return "help"; if (command === "version" || command === "--version" || command === "-v") return "version"; throw new Error(`Unknown command: ${command}`); @@ -273,6 +288,9 @@ function printHelp(): void { " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", + " devspace agents ls List local agent profiles and sessions", + " devspace agents run ", + " devspace agents show ", " devspace -v, --version Print the installed version", "", "For temporary tunnels:", @@ -281,6 +299,237 @@ 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 workspaceRoot = resolveCurrentWorkspaceRoot(); + const profiles = await loadLocalAgentProfiles(config, workspaceRoot); + const store = createLocalAgentStore(config); + const agents = store.list(workspaceRoot); + + if (profiles.length === 0 && agents.length === 0) { + console.log("No local agents configured for this workspace."); + return; + } + + for (const profile of profiles) { + const model = profile.model ? ` ${profile.model}` : ""; + const mode = profile.mode ? ` ${profile.mode}` : ""; + console.log(`profile ${profile.name} ${profile.provider}${model}${mode} - ${profile.description}`); + } + + for (const agent of agents) { + console.log(formatAgentLine(agent)); + } +} + +async function runAgentsRun(args: string[]): Promise { + const [target, ...promptParts] = args; + const prompt = promptParts.join(" ").trim(); + if (!target || !prompt) { + throw new Error('Usage: devspace agents run ""'); + } + + const config = loadConfig(); + const workspaceRoot = resolveCurrentWorkspaceRoot(); + const store = createLocalAgentStore(config); + const existing = store.get(target); + const promptFile = writeAgentPromptFile(prompt); + + if (existing) { + store.update(existing.id, { status: "starting", error: undefined }); + spawnAgentWorker(existing.id, promptFile); + console.log(formatAgentLine({ ...existing, status: "running" })); + return; + } + + const profiles = await loadLocalAgentProfiles(config, workspaceRoot); + const profile = profiles.find((candidate) => candidate.name === target); + if (!profile) { + throw new Error(`Unknown local agent profile or id: ${target}`); + } + + const record = store.create({ + workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + workspaceRoot, + profileName: profile.name, + provider: profile.provider, + model: profile.model, + mode: profile.mode, + backend: "auto", + }); + + 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 local agent 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 local agent 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); + if (!profile) throw new Error(`Local agent profile not found: ${record.profileName}`); + + const prompt = await readFile(promptFile, "utf8"); + const result = await runLocalAgentProfile(profile, record, prompt); + store.update(record.id, { + backend: result.backend, + 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 { + if (profile.provider !== "codex") { + throw new Error(`Provider '${profile.provider}' is not wired yet. Supported provider: codex.`); + } + + const runtime = await createCodexSdkLocalAgentRuntime(); + const body = profile.body.trim(); + const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; + return runtime.run({ + prompt: fullPrompt, + workspace: record.workspaceRoot, + providerSessionId: record.providerSessionId, + writeMode: writeModeForProfile(profile), + model: profile.model, + }); +} + +function writeModeForProfile(profile: LocalAgentProfile): LocalAgentWriteMode { + if (profile.permissions?.edit === "allow") return "allowed"; + return "read_only"; +} + +function spawnAgentWorker(agentId: string, promptFile: string): void { + const child = spawn(process.execPath, [ + 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 formatAgentLine(agent: Pick< + LocalAgentRecord, + "id" | "status" | "profileName" | "provider" | "model" +>): string { + const model = agent.model ? ` ${agent.model}` : ""; + return `${agent.id} ${agent.status} ${agent.profileName} ${agent.provider}${model}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +function printAgentsHelp(): void { + console.log( + [ + "DevSpace agents", + "", + "Usage:", + " devspace agents ls", + " devspace agents run ", + " 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/local-agent-store.test.ts b/src/local-agent-store.test.ts new file mode 100644 index 00000000..762e292c --- /dev/null +++ b/src/local-agent-store.test.ts @@ -0,0 +1,39 @@ +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-")); + +try { + const store = new LocalAgentStore(join(root, "agents.json")); + const created = store.create({ + workspaceId: "ws_1", + workspaceRoot: join(root, "project"), + profileName: "reviewer", + provider: "codex", + model: "gpt-5.4", + }); + + assert.match(created.id, /^agt_[a-f0-9]{8}$/); + assert.equal(created.status, "starting"); + assert.equal(store.get(created.id)?.profileName, "reviewer"); + assert.equal(store.get(created.id.slice(0, 7))?.id, created.id); + + const updated = store.update(created.id, { + status: "idle", + latestResponse: "done", + providerSessionId: "thread_123", + }); + + assert.equal(updated.status, "idle"); + assert.equal(store.get("thread_123")?.id, created.id); + assert.deepEqual( + store.list(join(root, "project")).map((agent) => agent.latestResponse), + ["done"], + ); + assert.deepEqual(store.list(join(root, "other")), []); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts new file mode 100644 index 00000000..c0152d86 --- /dev/null +++ b/src/local-agent-store.ts @@ -0,0 +1,148 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import type { ServerConfig } from "./config.js"; + +export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; +export type LocalAgentBackendName = "auto" | "codex-sdk" | "cli" | "acp"; + +export interface LocalAgentRecord { + id: string; + workspaceId?: string; + workspaceRoot: string; + profileName: string; + provider: string; + model?: string; + mode?: string; + backend: LocalAgentBackendName; + 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; + mode?: string; + backend?: LocalAgentBackendName; +} + +interface LocalAgentStoreData { + agents: LocalAgentRecord[]; +} + +export class LocalAgentStore { + constructor(private readonly filePath: string) {} + + list(workspaceRoot?: string): LocalAgentRecord[] { + const data = this.read(); + const resolvedRoot = workspaceRoot ? resolve(workspaceRoot) : undefined; + return data.agents + .filter((agent) => !resolvedRoot || resolve(agent.workspaceRoot) === resolvedRoot) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + 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, + mode: input.mode, + backend: input.backend ?? "auto", + status: "starting", + createdAt: now, + updatedAt: now, + }; + + this.write((data) => ({ agents: [...data.agents, record] })); + return record; + } + + get(idOrPrefix: string): LocalAgentRecord | undefined { + const agents = this.read().agents; + return resolveRecord(idOrPrefix, agents); + } + + update(id: string, patch: Partial>): LocalAgentRecord { + let updated: LocalAgentRecord | undefined; + this.write((data) => ({ + agents: data.agents.map((agent) => { + if (agent.id !== id) return agent; + updated = { + ...agent, + ...patch, + updatedAt: new Date().toISOString(), + }; + return updated; + }), + })); + + if (!updated) throw new Error(`Unknown local agent id: ${id}`); + return updated; + } + + private read(): LocalAgentStoreData { + if (!existsSync(this.filePath)) return { agents: [] }; + return normalizeStoreData(JSON.parse(readFileSync(this.filePath, "utf8"))); + } + + private write(update: (data: LocalAgentStoreData) => LocalAgentStoreData): void { + const next = update(this.read()); + mkdirSync(dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 }); + renameSync(tempPath, this.filePath); + } +} + +export function createLocalAgentStore(config: ServerConfig): LocalAgentStore { + return new LocalAgentStore(join(config.stateDir, "local-agents.json")); +} + +function normalizeStoreData(value: unknown): LocalAgentStoreData { + if (!value || typeof value !== "object" || Array.isArray(value)) return { agents: [] }; + const agents = (value as { agents?: unknown }).agents; + if (!Array.isArray(agents)) return { agents: [] }; + return { + agents: agents.filter(isLocalAgentRecord), + }; +} + +function isLocalAgentRecord(value: unknown): value is LocalAgentRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Partial; + return ( + typeof record.id === "string" && + typeof record.workspaceRoot === "string" && + typeof record.profileName === "string" && + typeof record.provider === "string" && + typeof record.status === "string" && + typeof record.createdAt === "string" && + typeof record.updatedAt === "string" + ); +} + +function resolveRecord( + idOrPrefix: string, + agents: LocalAgentRecord[], +): LocalAgentRecord | undefined { + const exact = agents.find((agent) => agent.id === idOrPrefix || agent.providerSessionId === idOrPrefix); + if (exact) return exact; + + const matches = agents.filter( + (agent) => + agent.id.startsWith(idOrPrefix) || + (agent.providerSessionId?.startsWith(idOrPrefix) ?? false), + ); + return matches.length === 1 ? matches[0] : undefined; +} diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 346c67cd..b050e790 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -50,12 +50,13 @@ assert.equal(foreground.sessionId, undefined); const environment = await manager.start({ workspaceId: "workspace-a", + workspaceRoot: "/tmp/devspace-workspace-a", cwd: process.cwd(), - command: `${node} -e "console.log([process.env.NO_COLOR, process.env.TERM, process.env.PAGER, process.env.GIT_PAGER, process.env.GH_PAGER, process.env.CODEX_CI].join(','))"`, + command: `${node} -e "console.log([process.env.NO_COLOR, process.env.TERM, process.env.PAGER, process.env.GIT_PAGER, process.env.GH_PAGER, process.env.CODEX_CI, process.env.DEVSPACE_WORKSPACE_ID, process.env.DEVSPACE_WORKSPACE_ROOT].join(','))"`, yieldTimeMs: 2_000, }); assert.equal(environment.running, false); -assert.match(environment.output, /1,dumb,cat,cat,cat,1/); +assert.match(environment.output, /1,dumb,cat,cat,cat,1,workspace-a,\/tmp\/devspace-workspace-a/); const background = await manager.start({ workspaceId: "workspace-a", diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 9884df12..f414df19 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -16,6 +16,7 @@ export interface StartCommandInput { workspaceId: string; command: string; cwd: string; + workspaceRoot?: string; tty?: boolean; columns?: number; rows?: number; @@ -86,7 +87,10 @@ function terminalSize(value: number | undefined, fallback: number): number { return value; } -function processEnvironment(): Record { +function processEnvironment(input?: { + workspaceId?: string; + workspaceRoot?: string; +}): Record { return { ...Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), @@ -99,6 +103,8 @@ function processEnvironment(): Record { CODEX_CI: "1", LANG: process.env.LANG ?? "C.UTF-8", LC_ALL: process.env.LC_ALL ?? "C.UTF-8", + ...(input?.workspaceId ? { DEVSPACE_WORKSPACE_ID: input.workspaceId } : {}), + ...(input?.workspaceRoot ? { DEVSPACE_WORKSPACE_ROOT: input.workspaceRoot } : {}), }; } @@ -321,7 +327,10 @@ export class ProcessSessionManager { const detached = process.platform !== "win32"; const child = spawn(input.command, { cwd: input.cwd, - env: processEnvironment(), + env: processEnvironment({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + }), stdio: "pipe", windowsHide: true, detached, @@ -352,7 +361,10 @@ export class ProcessSessionManager { try { pty = nodePty.spawn(shell.executable, shell.args, { cwd: input.cwd, - env: processEnvironment(), + env: processEnvironment({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + }), name: "xterm-256color", cols: session.columns, rows: session.rows, diff --git a/src/server.ts b/src/server.ts index e5779e4e..676cb40e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -572,6 +572,7 @@ function registerCodexProcessTools( workspaceId, command: cmd, cwd, + workspaceRoot: workspace.root, tty, columns, rows, From 07e7135b621ddd301e53945290932ea067b827f5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 04:13:08 +0530 Subject: [PATCH 03/33] feat(agents): support cli fallback profiles --- src/cli.test.ts | 68 +++++++++++++++++++++++++++++++++++++ src/cli.ts | 58 ++++++++++++++++++++++++++++++- src/local-agent-profiles.ts | 18 ++++++++++ src/local-agent-runtime.ts | 4 +-- 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 41d400e7..409f5444 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -3,6 +3,8 @@ 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"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -20,9 +22,17 @@ for (const flag of ["-v", "--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"); + const harnessPath = join(root, "harness.cjs"); + const promptPath = join(root, "prompt.txt"); mkdirSync(join(configDir, "agents"), { recursive: true }); mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + harnessPath, + "process.stdin.on('data', data => process.stdout.write('cli:' + data.toString().includes('Task:')));\n", + ); + writeFileSync(promptPath, "check this"); writeFileSync( join(configDir, "agents", "reviewer.md"), [ @@ -37,6 +47,21 @@ try { "", ].join("\n"), ); + writeFileSync( + join(configDir, "agents", "legacy.md"), + [ + "---", + "name: legacy", + "description: Legacy CLI agent.", + "provider: legacy", + "backend: cli", + `command: "${process.execPath} ${harnessPath}"`, + "---", + "", + "Legacy body.", + "", + ].join("\n"), + ); const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { cwd: process.cwd(), @@ -45,6 +70,7 @@ try { ...process.env, DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, DEVSPACE_WORKSPACE_ROOT: projectRoot, DEVSPACE_LOCAL_AGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", @@ -52,6 +78,48 @@ try { }); assert.match(output, /profile reviewer codex gpt-5\.4 - Read-only reviewer\./); + + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }); + const store = new LocalAgentStore(join(config.stateDir, "local-agents.json")); + const record = store.create({ + workspaceRoot: projectRoot, + profileName: "legacy", + provider: "legacy", + backend: "cli", + }); + execFileSync( + "node", + [ + "--import", + "tsx", + "src/cli.ts", + "agents", + "__worker", + record.id, + "--prompt-file", + promptPath, + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }, + ); + assert.equal(store.get(record.id)?.status, "idle"); + assert.equal(store.get(record.id)?.latestResponse, "cli:true"); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index 4843fdf6..449f8a50 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -382,7 +382,7 @@ async function runAgentsRun(args: string[]): Promise { provider: profile.provider, model: profile.model, mode: profile.mode, - backend: "auto", + backend: profile.backend ?? "auto", }); spawnAgentWorker(record.id, promptFile); @@ -457,7 +457,12 @@ async function runLocalAgentProfile( record: LocalAgentRecord, prompt: string, ): Promise { + if (profile.backend === "cli") { + return runCliLocalAgentProfile(profile, record, prompt); + } + if (profile.provider !== "codex") { + if (profile.command) return runCliLocalAgentProfile(profile, record, prompt); throw new Error(`Provider '${profile.provider}' is not wired yet. Supported provider: codex.`); } @@ -473,6 +478,57 @@ async function runLocalAgentProfile( }); } +async function runCliLocalAgentProfile( + profile: LocalAgentProfile, + record: LocalAgentRecord, + prompt: string, +): Promise { + if (!profile.command) { + throw new Error(`CLI local agent profile '${profile.name}' is missing command.`); + } + + const body = profile.body.trim(); + const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; + const finalResponse = await runPromptCommand(profile.command, fullPrompt, record.workspaceRoot); + return { + provider: profile.provider, + backend: "cli", + providerSessionId: record.providerSessionId ?? null, + finalResponse, + items: [], + }; +} + +function runPromptCommand(command: string, prompt: string, cwd: string): Promise { + return new Promise((resolveCommand, rejectCommand) => { + const child = spawn(command, { + cwd, + env: process.env, + shell: true, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString("utf8"); + }); + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString("utf8"); + }); + child.on("error", rejectCommand); + child.on("close", (code) => { + if (code && code !== 0) { + rejectCommand(new Error(stderr.trim() || `CLI local agent exited with code ${code}`)); + return; + } + resolveCommand((stdout.trim() || stderr.trim()).trim()); + }); + child.stdin.end(prompt); + }); +} + function writeModeForProfile(profile: LocalAgentProfile): LocalAgentWriteMode { if (profile.permissions?.edit === "allow") return "allowed"; return "read_only"; diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 190ae3c3..0ae49ae3 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -4,11 +4,14 @@ import { basename, join, resolve } from "node:path"; import type { ServerConfig } from "./config.js"; export type AgentPermissionValue = "allow" | "ask" | "deny"; +export type LocalAgentProfileBackend = "auto" | "codex-sdk" | "cli" | "acp"; export interface LocalAgentProfile { name: string; description: string; provider: string; + backend?: LocalAgentProfileBackend; + command?: string; model?: string; mode?: string; permissions?: Record; @@ -33,6 +36,7 @@ interface ParsedFrontmatter { const FRONTMATTER_DELIMITER = "---"; const PERMISSION_VALUES = new Set(["allow", "ask", "deny"]); +const BACKENDS = new Set(["auto", "codex-sdk", "cli", "acp"]); export async function loadLocalAgentProfiles( config: ServerConfig, @@ -187,6 +191,8 @@ function profileFromFrontmatter( name, description, provider, + backend: readBackend(frontmatter, filePath), + command: readString(frontmatter, "command"), model: readString(frontmatter, "model"), mode: readString(frontmatter, "mode"), permissions: readPermissions(frontmatter.permissions, filePath), @@ -196,6 +202,18 @@ function profileFromFrontmatter( }; } +function readBackend( + frontmatter: Record, + filePath: string, +): LocalAgentProfileBackend | undefined { + const backend = readString(frontmatter, "backend"); + if (!backend) return undefined; + if (!BACKENDS.has(backend as LocalAgentProfileBackend)) { + throw new Error(`Local agent profile backend must be auto, codex-sdk, cli, or acp: ${filePath}`); + } + return backend as LocalAgentProfileBackend; +} + function readString(frontmatter: Record, key: string): string | undefined { const value = frontmatter[key]; if (typeof value !== "string") return undefined; diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index b17b0782..e17eac7a 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -18,7 +18,7 @@ export interface LocalAgentRunInput { } export interface LocalAgentRunResult { - provider: "codex"; + provider: string; backend: LocalAgentBackend; providerSessionId: string | null; finalResponse: string; @@ -26,7 +26,7 @@ export interface LocalAgentRunResult { } export interface LocalAgentRuntime { - readonly provider: "codex"; + readonly provider: string; readonly backend: LocalAgentBackend; run(input: LocalAgentRunInput): Promise; } From 465080659ecc43c17305928dfff12a6b38182d6f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 04:17:11 +0530 Subject: [PATCH 04/33] docs(agents): document minimal local agent workflow --- docs/agent-profile-schema.md | 348 +++++++------------------ docs/chatgpt-coding-workflow.md | 18 +- docs/configuration.md | 20 +- docs/gotchas.md | 11 +- examples/agents/claude-implementer.md | 99 +------ examples/agents/codex-explorer.md | 80 +----- examples/agents/codex-worker.md | 95 +------ examples/agents/copilot-reviewer.md | 85 +----- examples/agents/cursor-agent-worker.md | 81 +----- examples/agents/opencode-explorer.md | 82 +----- examples/agents/pi-reviewer.md | 80 +----- skills/local-agent-delegation/SKILL.md | 164 ++++-------- 12 files changed, 265 insertions(+), 898 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 20709160..51061f2a 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -1,83 +1,43 @@ # Local agent profile schema -DevSpace local agent profiles are user-owned markdown files with YAML front matter. -They describe how a local coding-agent CLI can be used as a worker under ChatGPT -supervision. +DevSpace local agent profiles are user-owned markdown files with YAML +frontmatter. They describe *roles* such as reviewer, explorer, or implementer. +Provider configuration describes how DevSpace talks to the underlying harness. -The packaged files in `examples/agents/` are starter templates only. DevSpace does -not currently parse, load, activate, or run local agent profile definitions. A -future profile loader can define the user-owned directory for active profiles. +Profiles are discovered from: + +- `~/.devspace/agents/*.md` +- `.devspace/agents/*.md` + +Packaged files under `examples/agents/` are starter templates only. ## Minimal shape ```md --- schema: devspace-agent/v1 -name: codex-explorer -description: Read-only Codex agent for bounded codebase questions. +name: reviewer +description: Read-only reviewer for bugs, security risks, and missing tests. provider: codex -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: codex - args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: codex - args: - - exec - - resume - - "{externalSessionId}" - - --json - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +backend: auto +model: gpt-5.4 +mode: review +permissions: + edit: deny + bash: deny +disabled: false --- -Use this agent for bounded read-only codebase investigation. +You are a read-only reviewer. Do not edit files. +Focus on correctness, security, test gaps, and maintainability. +Cite files and return concise findings. ``` -## Front matter fields +## Frontmatter fields ### `schema` -Required schema identifier. - -Current value: +Optional schema identifier: ```yaml schema: devspace-agent/v1 @@ -85,22 +45,22 @@ schema: devspace-agent/v1 ### `name` -Stable profile identifier shown to the model and user. - -Use lowercase kebab-case names, for example: +Stable profile identifier shown to the model and accepted by: -```yaml -name: codex-explorer +```bash +devspace agents run "" ``` +Use lowercase kebab-case names. + ### `description` -Short human-readable purpose. This should help the supervising model decide when -the agent is appropriate. +Required short purpose. This is exposed by `open_workspace` and +`devspace agents ls` so the supervising model can choose the right profile. ### `provider` -The local agent family or CLI provider. +Required local agent family or provider id. Examples: @@ -115,228 +75,120 @@ provider: copilot ### `backend` -Execution backend. The near-term templates use CLI-backed agents: +Optional execution backend. ```yaml +backend: auto +backend: codex-sdk +backend: acp backend: cli ``` -Future profiles may support protocol-backed backends such as ACP without changing -the high-level profile name. +`auto` is the normal value. DevSpace resolves the best available adapter for +the provider. CLI fallback profiles can set `backend: cli` and provide +`command`. -## Capabilities +Current support is intentionally narrow: Codex profiles use the Codex SDK, and +other providers should include a `command` until their ACP or SDK adapters are +wired. -Capabilities describe what the worker is allowed or expected to do. +### `command` -```yaml -capabilities: - read: true - write: false - shell: false - background: true - resume: true -``` - -- `read`: the agent can inspect project files. -- `write`: the agent may modify files. -- `shell`: the agent may run shell commands. -- `background`: the agent can be started as a long-running process. -- `resume`: the agent supports follow-up prompts against an existing session. - -These fields are descriptive in the current template-only stage. A future parser -should validate them before rendering an available-agent catalog or exposing -runtime tools. - -## Workspace policy +Optional command string for `backend: cli` fallback profiles. DevSpace passes +the full worker prompt on stdin and captures stdout as the response. ```yaml -workspace: - default: current - isolation: user_decides - writeMode: allowed -``` - -- `default`: default workspace source. Current templates use `current`. -- `isolation`: whether to use the same checkout, a branch, a worktree, or let the - user decide. -- `writeMode`: whether writes are allowed. - -Recommended values: - -```yaml -isolation: none -isolation: user_decides - -writeMode: read_only -writeMode: allowed -``` - -Use read-only profiles for review, lookup, and second opinions. Use write-capable -profiles only when the user explicitly asks a local agent to implement or edit. - -## Actions - -Actions define lifecycle commands and strategies. - -### `actions.start` - -Starts the local agent. - -```yaml -actions: - start: - command: codex - args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" - background: true - output: jsonl -``` - -Use `command` plus `args` arrays. Do not use free-form shell strings. - -Good: - -```yaml -command: codex -args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" -``` - -Avoid: - -```yaml -run: "codex exec --json -C {workspace} {prompt}" +backend: cli +command: "my-agent run --no-color" ``` -Argv arrays are easier to validate, escape, log, review, and migrate to future -backends. - -### `actions.followup` +Prefer built-in provider adapters or ACP when available. -Defines how to continue a previous worker session. +### `model` -Supported strategy names used by the starter templates: +Optional provider model id or alias. ```yaml -strategy: resume_command -strategy: fresh_prompt_with_context +model: gpt-5.4 +model: sonnet ``` -Use `resume_command` when the provider has an explicit resume/session flag. Use -`fresh_prompt_with_context` when follow-up work must include previous summaries, -review findings, and diff context in a new prompt. - -### `actions.read` - -Defines how DevSpace should read worker output. +### `mode` -The starter templates use: +Optional provider or role mode label. ```yaml -read: - strategy: devspace_process_poll +mode: review +mode: implement ``` -### `actions.cancel` +### `permissions` -Defines how DevSpace should interrupt a running worker. - -The starter templates use: +Optional model-facing permission hints. ```yaml -cancel: - strategy: devspace_process_signal - signal: SIGINT +permissions: + edit: deny + bash: deny ``` -Prefer interruption over deleting provider session history. - -### `actions.diff` +Supported values are `allow`, `ask`, and `deny`. DevSpace exposes these hints in +the compact agent catalog. Provider adapters may also map them to native sandbox +or approval settings. -Defines how DevSpace can inspect worker file changes. +### `disabled` -Read-only profiles should use: +Optional boolean. Disabled profiles are not exposed. ```yaml -diff: - strategy: none +disabled: true ``` -Write-capable profiles should use: - -```yaml -diff: - strategy: git_diff -``` +## Markdown body -## Placeholders +The body is the profile prompt prefix DevSpace prepends when launching that +profile. It is not included in `open_workspace` by default. -The examples use placeholders that a future runtime can substitute safely: +Recommended body content: -```text -{workspace} -{prompt} -{externalSessionId} -``` +- When to use this profile. +- What the worker must not do. +- Output format. +- Review or testing expectations. -- `{workspace}`: absolute workspace path selected by DevSpace or the user. -- `{prompt}`: focused worker prompt created by the supervising model. -- `{externalSessionId}`: provider session id returned by a previous agent run. +## Model-facing workflow -## Safety policy +The local-agent skill teaches only: -```yaml -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +```bash +devspace agents ls +devspace agents run "" +devspace agents show ``` -Recommended write-capable profile safety: +`open_workspace` exposes compact profile metadata: -```yaml -safety: - requireExplicitUserIntent: true - allowWrites: true - requireDiffReview: true - requireTestsOrExplanation: true +```json +{ + "name": "reviewer", + "description": "Read-only reviewer for bugs, security risks, and missing tests.", + "provider": "codex", + "model": "gpt-5.4", + "mode": "review", + "permissions": { + "edit": "deny", + "bash": "deny" + } +} ``` -Profiles should make user intent and review requirements explicit. DevSpace should -not silently delegate work to local agents, and the supervising model should not -present worker output as verified until it has reviewed the result. - -## Markdown body - -The markdown body should explain when to use the agent and provide a worker prompt -template. - -Recommended sections: - -- `Use this agent when ...` -- `Good tasks:` -- `Worker prompt style:` -- A final report format that the supervising model can review. - -The body is model-facing guidance. Keep it practical and concise. +The full profile body stays out of the model context until DevSpace launches the +profile. ## Current non-goals -The current examples do not add: - -- local agent profile parsing. -- automatic activation of packaged examples. -- `devspace agents init`. -- generated available-agent catalogs. -- first-class agent runtime tools. -- ACP-backed execution. - -Those can be added in later PRs without changing the template intent. +- 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 runtime used by + `devspace agents`. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 2a734337..0732369b 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -91,9 +91,15 @@ It also keeps compatibility with: - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -Example local coding-agent profiles are packaged under `examples/agents/` for -users who want starter templates. These examples are inert: DevSpace does not -currently parse, load, activate, or run local agent profile definitions. +When local agents are enabled, DevSpace discovers local coding-agent profiles +from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. +`open_workspace` exposes a compact catalog with profile names, descriptions, +providers, modes, models, and permissions so the model can choose a configured +agent without seeing provider-specific launch details. + +Example profiles are packaged under `examples/agents/` for users who want +starter templates. Copy or adapt them into one of the active profile directories +before use. Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. @@ -106,8 +112,10 @@ Skill paths may be outside the workspace. DevSpace only permits reading: - files under a skill directory after that skill's `SKILL.md` has been read Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Set -`DEVSPACE_LOCAL_AGENTS=1` to expose the experimental `local-agent-delegation` -skill. +`DEVSPACE_LOCAL_AGENTS=1` to expose the experimental local agent catalog and +`local-agent-delegation` skill. That skill teaches the minimal +`devspace agents ls`, `devspace agents run`, and `devspace agents show` +workflow. ## Tool Names diff --git a/docs/configuration.md b/docs/configuration.md index 29cf1169..3c96ae49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,7 +99,7 @@ sessions. | Variable | Purpose | | --- | --- | | `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | -| `DEVSPACE_LOCAL_AGENTS` | Set to `1` to expose the local-agent delegation skill. Experimental and disabled by default. | +| `DEVSPACE_LOCAL_AGENTS` | Set to `1` to expose local agent profiles and the local-agent delegation skill. 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. | @@ -115,9 +115,21 @@ It also keeps compatibility with: - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -Starter local coding-agent profile templates are available under -`examples/agents/`. These files are inert examples: DevSpace does not currently -parse, load, activate, or run local agent profile definitions. +When local agents are enabled, DevSpace discovers local coding-agent profiles +from: + +- `~/.devspace/agents/*.md` +- project `.devspace/agents/*.md` + +`open_workspace` returns a compact catalog containing profile names, +descriptions, providers, modes, models, and permissions so the host model can +choose an agent without reading provider-specific launch details. The +`local-agent-delegation` skill teaches the model to use only the minimal +`devspace agents ls`, `devspace agents run`, and `devspace agents show` +workflow. + +Starter profile templates are available under `examples/agents/`. Copy or adapt +them into one of the active profile directories before use. Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. diff --git a/docs/gotchas.md b/docs/gotchas.md index e6874d1b..9fdce973 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -205,9 +205,14 @@ It also checks compatibility and custom paths: - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -Packaged local-agent examples under `examples/agents/` are inert templates only. -DevSpace does not currently parse, load, activate, or run local agent profile -definitions. +When `DEVSPACE_LOCAL_AGENTS=1`, DevSpace loads local coding-agent profiles from +`~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a +compact profile catalog through `open_workspace`. The bundled +`local-agent-delegation` skill keeps the model-facing workflow to +`devspace agents ls`, `devspace agents run`, and `devspace agents show`. + +Packaged local-agent 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. diff --git a/examples/agents/claude-implementer.md b/examples/agents/claude-implementer.md index 235d7e5f..52b027a7 100644 --- a/examples/agents/claude-implementer.md +++ b/examples/agents/claude-implementer.md @@ -1,104 +1,31 @@ --- schema: devspace-agent/v1 name: claude-implementer -description: Claude Code implementation worker for larger edits, refactors, and follow-up loops. +description: Claude Code profile for larger implementation, refactor, and repair tasks. provider: claude -backend: cli - -capabilities: - read: true - write: true - shell: true - background: true - resume: true - -workspace: - default: current - isolation: user_decides - writeMode: allowed - -actions: - start: - command: claude - args: - - -p - - --output-format - - stream-json - - --verbose - - "{prompt}" - background: true - output: stream-json - - followup: - strategy: resume_command - command: claude - args: - - --resume - - "{externalSessionId}" - - -p - - --output-format - - stream-json - - --verbose - - "{prompt}" - - list: - command: claude - args: - - agents - - --json - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: git_diff - -safety: - requireExplicitUserIntent: true - allowWrites: true - requireDiffReview: true - requireTestsOrExplanation: true +backend: auto +model: sonnet +permissions: + edit: allow + bash: allow --- -Use this agent when the task benefits from a stronger implementation worker. - -Good tasks: - -- Multi-file implementation. -- Refactor with clear boundaries. -- Test repair loop. -- Apply detailed review comments. -- Continue an already-started implementation. +You are a local Claude Code implementation worker under supervisor review. -Worker prompt style: - -```text -You are a local Claude Code implementation worker under ChatGPT supervision. - -Goal: - - -Context: - - -Plan: - - -Constraints: - +Use this profile for multi-file implementation, careful refactors, and test +repair loops when the user asked for delegated implementation. Rules: + - Keep changes focused. -- Do not rewrite unrelated code. - Preserve public behavior unless asked. +- Do not rewrite unrelated code. - Run or explain relevant tests. - Return a concise final report. Final report format: + +```text summary: files_changed: tests_run: diff --git a/examples/agents/codex-explorer.md b/examples/agents/codex-explorer.md index 5ca22120..ea2b8aeb 100644 --- a/examples/agents/codex-explorer.md +++ b/examples/agents/codex-explorer.md @@ -1,88 +1,30 @@ --- schema: devspace-agent/v1 name: codex-explorer -description: Read-only Codex agent for bounded codebase questions and architecture exploration. +description: Read-only Codex profile for bounded codebase questions and architecture exploration. provider: codex -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: codex - args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: codex - args: - - exec - - resume - - "{externalSessionId}" - - --json - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +backend: auto +model: gpt-5.4 +permissions: + edit: deny + bash: deny --- -Use this agent when the user wants a bounded read-only investigation, second opinion, -or explanation of a code path. - -Good tasks: - -- Find where a feature is implemented. -- Explain an architecture boundary. -- Review a module without changing files. -- Identify likely files for a future change. - -Worker prompt style: - -```text You are a read-only codebase explorer. -Question: - - -Scope: - +Use this profile for bounded investigation, second opinions, and explanations of +code paths. Rules: + - Do not modify files. - Cite file paths and symbols. - Separate facts from guesses. - Keep the answer concise. Final report format: + +```text answer: evidence: relevant_files: diff --git a/examples/agents/codex-worker.md b/examples/agents/codex-worker.md index 00d89b02..a0c91c8e 100644 --- a/examples/agents/codex-worker.md +++ b/examples/agents/codex-worker.md @@ -1,99 +1,30 @@ --- schema: devspace-agent/v1 name: codex-worker -description: Codex implementation worker for focused, user-approved coding tasks. +description: Codex implementation profile for focused, user-approved coding tasks. provider: codex -backend: cli - -capabilities: - read: true - write: true - shell: true - background: true - resume: true - -workspace: - default: current - isolation: user_decides - writeMode: allowed - -actions: - start: - command: codex - args: - - exec - - --json - - -C - - "{workspace}" - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: codex - args: - - exec - - resume - - "{externalSessionId}" - - --json - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: git_diff - -safety: - requireExplicitUserIntent: true - allowWrites: true - requireDiffReview: true - requireTestsOrExplanation: true +backend: auto +model: gpt-5.4 +permissions: + edit: allow + bash: allow --- -Use this agent when ChatGPT has already planned a focused implementation and the -user wants Codex to execute it locally. - -Good tasks: - -- Implement a small feature from a clear plan. -- Fix a bug with known reproduction steps. -- Add tests for an existing code path. -- Apply review comments. +You are a local implementation worker under supervisor review. -Worker prompt style: - -```text -You are a local implementation worker under ChatGPT supervision. - -Goal: - - -Plan: - - -Constraints: - - -Files to focus: - - -Tests to run: - +Use this profile only for focused tasks with clear acceptance criteria. Rules: + - Keep changes focused. -- Follow existing project style. +- Follow the existing project style. - Do not perform unrelated refactors. - Do not hide failures. -- Return a final report. +- Report tests run and blockers. Final report format: + +```text summary: files_changed: tests_run: diff --git a/examples/agents/copilot-reviewer.md b/examples/agents/copilot-reviewer.md index 640b405a..458a14eb 100644 --- a/examples/agents/copilot-reviewer.md +++ b/examples/agents/copilot-reviewer.md @@ -1,92 +1,29 @@ --- schema: devspace-agent/v1 name: copilot-reviewer -description: GitHub Copilot CLI reviewer for read-only code questions and review passes. +description: GitHub Copilot read-only profile for code questions and review passes. provider: copilot -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: copilot - args: - - --model - - auto - - -p - - "{prompt}" - - --output-format - - json - background: true - output: jsonl - - followup: - strategy: resume_command - command: copilot - args: - - --model - - auto - - -p - - "{prompt}" - - --resume - - "{externalSessionId}" - - --output-format - - json - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +backend: auto +permissions: + edit: deny + bash: deny --- -Use this agent when the user wants a GitHub Copilot-powered second opinion, -review, or codebase answer. - -Good tasks: +You are a read-only Copilot reviewer under supervisor review. -- Review changed files. -- Find likely bug sources. -- Explain repository structure. -- Suggest tests. - -Worker prompt style: - -```text -You are a read-only Copilot reviewer under ChatGPT supervision. - -Question: - - -Scope: - +Use this profile for second opinions, changed-file review, likely bug sources, +and test suggestions. Rules: + - Do not modify files. - Cite exact files and symbols. - Return concise findings. - Separate facts from guesses. Final report format: + +```text answer: findings: evidence: diff --git a/examples/agents/cursor-agent-worker.md b/examples/agents/cursor-agent-worker.md index 89be2b17..46d0e424 100644 --- a/examples/agents/cursor-agent-worker.md +++ b/examples/agents/cursor-agent-worker.md @@ -1,91 +1,32 @@ --- schema: devspace-agent/v1 name: cursor-agent-worker -description: Cursor Agent worker for fast implementation or review using local Cursor CLI. +description: Cursor Agent profile for fast implementation or UI-oriented review. provider: cursor -backend: cli - -capabilities: - read: true - write: true - shell: true - background: true - resume: false - -workspace: - default: current - isolation: user_decides - writeMode: allowed - -actions: - start: - command: cursor-agent - args: - - -p - - --output-format - - stream-json - - --trust - - "{prompt}" - background: true - output: stream-json - - followup: - strategy: fresh_prompt_with_context - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: git_diff - -safety: - requireExplicitUserIntent: true - allowWrites: true - requireDiffReview: true - requireTestsOrExplanation: true +backend: acp +permissions: + edit: allow + bash: allow --- -Use this agent when the user wants Cursor's local agent/model to quickly execute -or inspect a task. - -Good tasks: - -- Fast implementation pass. -- UI/UX-oriented code review. -- Alternative implementation idea. -- Lightweight refactor. +You are a local Cursor Agent worker under supervisor review. -Worker prompt style: - -```text -You are a local Cursor Agent worker under ChatGPT supervision. - -Goal: - - -Context: - - -Plan: - +Use this profile for fast implementation passes, UI-oriented code review, +alternative implementation ideas, and lightweight refactors. Rules: + - Keep changes focused. - Do not make unrelated edits. - Preserve existing style. - Report tests and blockers. Final report format: + +```text summary: files_changed: tests_run: blockers: follow_up_needed: ``` - -Because this profile uses `fresh_prompt_with_context`, include the previous worker -summary and review findings when sending follow-up work. diff --git a/examples/agents/opencode-explorer.md b/examples/agents/opencode-explorer.md index 9a86ce6a..97165c57 100644 --- a/examples/agents/opencode-explorer.md +++ b/examples/agents/opencode-explorer.md @@ -1,91 +1,29 @@ --- schema: devspace-agent/v1 name: opencode-explorer -description: OpenCode read-only explorer for fast codebase lookup and bounded questions. +description: OpenCode read-only profile for fast lookup and bounded codebase questions. provider: opencode -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: opencode - args: - - run - - --format - - json - - --dir - - "{workspace}" - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: opencode - args: - - run - - --format - - json - - --session - - "{externalSessionId}" - - --dir - - "{workspace}" - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +backend: auto +permissions: + edit: deny + bash: deny --- -Use this agent for fast, read-only codebase exploration. - -Good tasks: - -- Find relevant files. -- Explain a subsystem. -- Identify test coverage gaps. -- Compare possible implementation locations. - -Worker prompt style: - -```text You are a read-only OpenCode explorer. -Question: - - -Scope: - +Use this profile for fast codebase exploration, relevant-file discovery, and +small architecture questions. Rules: + - Do not modify files. - Cite exact file paths. - Prefer concise findings. - State uncertainty. Final report format: + +```text answer: evidence: relevant_files: diff --git a/examples/agents/pi-reviewer.md b/examples/agents/pi-reviewer.md index 21e842e9..bd66daf4 100644 --- a/examples/agents/pi-reviewer.md +++ b/examples/agents/pi-reviewer.md @@ -1,87 +1,29 @@ --- schema: devspace-agent/v1 name: pi-reviewer -description: Pi read-only reviewer for quick code review and targeted questions. +description: Pi read-only profile for quick code review and targeted questions. provider: pi -backend: cli - -capabilities: - read: true - write: false - shell: false - background: true - resume: true - -workspace: - default: current - isolation: none - writeMode: read_only - -actions: - start: - command: pi - args: - - -p - - --mode - - json - - "{prompt}" - background: true - output: jsonl - - followup: - strategy: resume_command - command: pi - args: - - -p - - --mode - - json - - --session-id - - "{externalSessionId}" - - "{prompt}" - - read: - strategy: devspace_process_poll - - cancel: - strategy: devspace_process_signal - signal: SIGINT - - diff: - strategy: none - -safety: - requireExplicitUserIntent: true - allowWrites: false - requireReviewBeforeFinal: true +backend: auto +permissions: + edit: deny + bash: deny --- -Use this agent for lightweight review and targeted read-only investigation. - -Good tasks: - -- Review a diff. -- Find possible bugs. -- Explain a small subsystem. -- Check whether tests cover an edge case. - -Worker prompt style: - -```text You are a read-only local code reviewer. -Question: - - -Scope: - +Use this profile for lightweight review, risk checks, and targeted codebase +questions. Rules: + - Do not modify files. - Cite evidence. - Focus on actionable findings. -- Avoid broad rewrites. +- Avoid broad rewrite suggestions. Final report format: + +```text findings: evidence: risk_level: diff --git a/skills/local-agent-delegation/SKILL.md b/skills/local-agent-delegation/SKILL.md index 5af2f9a4..b4312d70 100644 --- a/skills/local-agent-delegation/SKILL.md +++ b/skills/local-agent-delegation/SKILL.md @@ -1,127 +1,84 @@ --- name: local-agent-delegation -description: Delegate coding tasks to user-configured local coding agents such as Codex, Claude Code, OpenCode, Cursor Agent, Pi, or Copilot CLI. +description: Delegate coding tasks to user-configured DevSpace local agents. --- # Local Agent Delegation -Use this skill when the user explicitly asks to delegate work to another coding agent, use a named local agent, get a second opinion, compare implementations, run agents in parallel, or create a subagent-like workflow. +Use this skill when the user explicitly asks to delegate work to another coding +agent, use a named local agent, get a second opinion, compare approaches, or run +a subagent-like workflow. -Do not use local agents silently. Tell the user when another local agent is being used. +Do not use local agents silently. Tell the user when another local agent is +being used. -## Core idea +## Core commands -You are the supervisor. A local coding agent is the worker. - -Your responsibilities are: - -1. Understand the user's goal. -2. Decide whether delegation is useful. -3. Choose the right local agent or CLI. -4. Give the worker a focused prompt. -5. Inspect the result yourself. -6. Review diffs, tests, and risks before telling the user the work is done. - -## When to delegate - -Good delegation requests include: - -- "Ask another agent to look at this." -- "Have Claude/Codex/OpenCode/Pi implement this." -- "Run this in the background." -- "Compare two approaches." -- "Use a local subagent for this." -- "Get a second opinion on the architecture/test gaps/security risk." - -Do not delegate just because the task is coding-related. Use the normal DevSpace tools directly unless the user asks for delegation, another agent's opinion, parallel work, or a named local coding agent. - -## CLI execution guidance - -Prefer structured non-interactive CLI modes when available. - -Examples of useful patterns: +Use only these commands for normal delegation: ```bash -codex exec --json -C "$WORKSPACE" "$PROMPT" -claude -p --output-format stream-json "$PROMPT" -opencode run --format json --dir "$WORKSPACE" "$PROMPT" -cursor-agent -p --output-format stream-json "$PROMPT" -pi -p --mode json "$PROMPT" -copilot -p "$PROMPT" --output-format json +devspace agents ls +devspace agents run "" +devspace agents show ``` -These examples are illustrative. Real implementations should pass workspace and prompt values as separate argv entries without a shell, not concatenate untrusted prompt text into a shell command string. - -Use exact command templates from user-provided instructions when they are available. Do not invent provider-specific flags when the user has already supplied a command shape. - -Packaged files under `examples/agents/` are templates only. DevSpace does not currently parse, load, activate, or run local agent profile definitions. +`ls` shows configured profiles and active agents for the current workspace. -If no command shape exists for a requested agent, use the installed CLI's help output only when needed, then summarize what you found before running it. +`run ""` starts a new agent and prints a DevSpace agent id. -## Background execution +`run ""` sends a follow-up to an existing agent. -When DevSpace exposes long-running process tools, prefer background execution for long tasks. +`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. -Start the local agent process, keep the returned process/session id, and poll output later. +Do not run provider CLIs such as `codex`, `claude`, `opencode`, or `pi` +directly unless you are explicitly debugging DevSpace agent integration. -Use this pattern for long implementations, test repair loops, large reviews, multi-step investigations, and agents that stream JSON or progress logs. +## Choosing a profile -When polling output, summarize useful progress instead of forwarding noisy terminal logs. +Use `devspace agents ls` and choose by profile name, description, provider, and +permissions. -If the worker is clearly stuck, running the wrong task, or burning resources, interrupt the current process or turn. Do not delete provider session history unless the user explicitly asks. +Good delegation targets: -## Follow-up prompts - -When sending a follow-up to the same local agent, include: - -```text -previous_task: -current_status: -review_findings: -requested_changes: -success_criteria: -``` +- `reviewer`: second opinion, bug risk, security risk, test gaps. +- `explorer`: read-only codebase investigation. +- `implementer`: focused implementation when the user asked for delegation. -Prefer the agent profile's resume/session mechanism when available. +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 local agent. -If resume is not available, include the previous worker summary and relevant diff context in a fresh prompt. +## Worker prompts -## Worker prompt templates +Agents start with only the prompt you send plus their configured profile +instructions. Make prompts self-contained. -Use this structure when delegating implementation: +Implementation prompt shape: ```text -You are acting as a local coding worker under ChatGPT supervision. - Goal: Context: -Plan to execute: - +Relevant files: + + +Acceptance criteria: +- Rules: -- Follow the existing project style. - Keep changes focused. - Do not perform unrelated refactors. -- Do not hide failures. -- At the end, return a concise final report. - -Final report format: -summary: -files_changed: -tests_run: -blockers: -follow_up_needed: +- Report blockers clearly. ``` -Use this structure when delegating read-only investigation: +Read-only investigation prompt shape: ```text -You are acting as a read-only local code investigator under ChatGPT supervision. - Question: @@ -132,46 +89,21 @@ Rules: - Do not modify files. - Cite relevant file paths and symbols. - Separate facts from guesses. -- Return a concise answer. - -Final report format: -answer: -evidence: -relevant_files: -confidence: -unknowns: ``` -## After the worker finishes - -Always review the result. - -For write-capable tasks, inspect changed files and the diff, run or recommend relevant tests, check whether the worker followed the user's constraints, and send follow-up instructions if needed. - -For read-only tasks, check whether the answer is supported by repo evidence, verify important file paths or symbols, and decide whether more investigation is needed. +## After the worker responds -Do not assume the worker's summary is correct. +Always review the result before presenting it as verified. -## Reporting back to the user +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. - -Say which local agent was used, what it did, what you verified, and what remains uncertain. - -Good final shape: +Be transparent in the final response: ```text -I delegated the implementation to . It changed . I reviewed the diff and ran . The main result is . Remaining concerns: . +I used . It reported . I verified . Remaining risk: +. ``` -Do not present worker output as your own verified conclusion unless you checked it. - -## Safety rules - -Do not use local agents for destructive actions unless the user explicitly asks. - -Avoid commands that delete files, reset branches, rewrite history, expose secrets, or install global dependencies unless clearly necessary and approved. - -Do not treat repo-provided profile examples as trusted executable definitions. - Never hide that a local agent was used. From b475ea1266b9e3ef47f5b82bfbf9f966d4445e94 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 4 Jul 2026 01:04:19 +0530 Subject: [PATCH 05/33] feat(agents): simplify built-in profile schema --- src/cli.test.ts | 61 +------------------------- src/cli.ts | 70 +---------------------------- src/local-agent-profiles.test.ts | 31 ++++++++----- src/local-agent-profiles.ts | 75 ++++++++++---------------------- src/local-agent-runtime.test.ts | 2 - src/local-agent-runtime.ts | 5 --- src/local-agent-store.ts | 7 --- src/server.ts | 2 - src/workspaces.test.ts | 4 -- 9 files changed, 47 insertions(+), 210 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 409f5444..baecd24f 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -4,7 +4,6 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; -import { LocalAgentStore } from "./local-agent-store.js"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -24,15 +23,8 @@ try { const configDir = join(root, ".devspace"); const stateDir = join(root, ".state"); const projectRoot = join(root, "project"); - const harnessPath = join(root, "harness.cjs"); - const promptPath = join(root, "prompt.txt"); mkdirSync(join(configDir, "agents"), { recursive: true }); mkdirSync(projectRoot, { recursive: true }); - writeFileSync( - harnessPath, - "process.stdin.on('data', data => process.stdout.write('cli:' + data.toString().includes('Task:')));\n", - ); - writeFileSync(promptPath, "check this"); writeFileSync( join(configDir, "agents", "reviewer.md"), [ @@ -47,21 +39,6 @@ try { "", ].join("\n"), ); - writeFileSync( - join(configDir, "agents", "legacy.md"), - [ - "---", - "name: legacy", - "description: Legacy CLI agent.", - "provider: legacy", - "backend: cli", - `command: "${process.execPath} ${harnessPath}"`, - "---", - "", - "Legacy body.", - "", - ].join("\n"), - ); const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { cwd: process.cwd(), @@ -79,47 +56,13 @@ try { assert.match(output, /profile reviewer codex gpt-5\.4 - Read-only reviewer\./); - const config = loadConfig({ + assert.equal(loadConfig({ DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_STATE_DIR: stateDir, DEVSPACE_LOCAL_AGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }); - const store = new LocalAgentStore(join(config.stateDir, "local-agents.json")); - const record = store.create({ - workspaceRoot: projectRoot, - profileName: "legacy", - provider: "legacy", - backend: "cli", - }); - execFileSync( - "node", - [ - "--import", - "tsx", - "src/cli.ts", - "agents", - "__worker", - record.id, - "--prompt-file", - promptPath, - ], - { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, - DEVSPACE_LOCAL_AGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }, - }, - ); - assert.equal(store.get(record.id)?.status, "idle"); - assert.equal(store.get(record.id)?.latestResponse, "cli:true"); + }).localAgents, true); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index 449f8a50..09739429 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,6 @@ import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-stor import { createCodexSdkLocalAgentRuntime, type LocalAgentRunResult, - type LocalAgentWriteMode, } from "./local-agent-runtime.js"; import { ensureDevspaceDefaultSkills, @@ -340,8 +339,7 @@ async function runAgentsList(): Promise { for (const profile of profiles) { const model = profile.model ? ` ${profile.model}` : ""; - const mode = profile.mode ? ` ${profile.mode}` : ""; - console.log(`profile ${profile.name} ${profile.provider}${model}${mode} - ${profile.description}`); + console.log(`profile ${profile.name} ${profile.provider}${model} - ${profile.description}`); } for (const agent of agents) { @@ -381,8 +379,6 @@ async function runAgentsRun(args: string[]): Promise { profileName: profile.name, provider: profile.provider, model: profile.model, - mode: profile.mode, - backend: profile.backend ?? "auto", }); spawnAgentWorker(record.id, promptFile); @@ -438,7 +434,6 @@ async function runAgentsWorker(args: string[]): Promise { const prompt = await readFile(promptFile, "utf8"); const result = await runLocalAgentProfile(profile, record, prompt); store.update(record.id, { - backend: result.backend, providerSessionId: result.providerSessionId ?? undefined, status: "idle", latestResponse: result.finalResponse, @@ -457,12 +452,7 @@ async function runLocalAgentProfile( record: LocalAgentRecord, prompt: string, ): Promise { - if (profile.backend === "cli") { - return runCliLocalAgentProfile(profile, record, prompt); - } - if (profile.provider !== "codex") { - if (profile.command) return runCliLocalAgentProfile(profile, record, prompt); throw new Error(`Provider '${profile.provider}' is not wired yet. Supported provider: codex.`); } @@ -473,67 +463,11 @@ async function runLocalAgentProfile( prompt: fullPrompt, workspace: record.workspaceRoot, providerSessionId: record.providerSessionId, - writeMode: writeModeForProfile(profile), + writeMode: "read_only", model: profile.model, }); } -async function runCliLocalAgentProfile( - profile: LocalAgentProfile, - record: LocalAgentRecord, - prompt: string, -): Promise { - if (!profile.command) { - throw new Error(`CLI local agent profile '${profile.name}' is missing command.`); - } - - const body = profile.body.trim(); - const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; - const finalResponse = await runPromptCommand(profile.command, fullPrompt, record.workspaceRoot); - return { - provider: profile.provider, - backend: "cli", - providerSessionId: record.providerSessionId ?? null, - finalResponse, - items: [], - }; -} - -function runPromptCommand(command: string, prompt: string, cwd: string): Promise { - return new Promise((resolveCommand, rejectCommand) => { - const child = spawn(command, { - cwd, - env: process.env, - shell: true, - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }); - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (data: Buffer) => { - stdout += data.toString("utf8"); - }); - child.stderr.on("data", (data: Buffer) => { - stderr += data.toString("utf8"); - }); - child.on("error", rejectCommand); - child.on("close", (code) => { - if (code && code !== 0) { - rejectCommand(new Error(stderr.trim() || `CLI local agent exited with code ${code}`)); - return; - } - resolveCommand((stdout.trim() || stderr.trim()).trim()); - }); - child.stdin.end(prompt); - }); -} - -function writeModeForProfile(profile: LocalAgentProfile): LocalAgentWriteMode { - if (profile.permissions?.edit === "allow") return "allowed"; - return "read_only"; -} - function spawnAgentWorker(agentId: string, promptFile: string): void { const child = spawn(process.execPath, [ fileURLToPath(import.meta.url), diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 1c3f921c..56908839 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -21,9 +21,6 @@ try { "description: Global reviewer.", "provider: codex", "model: gpt-5.4", - "permissions:", - " edit: deny", - " bash: deny", "---", "", "Global body.", @@ -37,9 +34,7 @@ try { "name: reviewer", "description: Project reviewer.", "provider: claude", - "mode: review", - "permissions:", - " edit: deny", + "model: sonnet", "---", "", "Project body.", @@ -73,17 +68,33 @@ try { assert.equal(profiles[0]?.name, "reviewer"); assert.equal(profiles[0]?.description, "Project reviewer."); assert.equal(profiles[0]?.provider, "claude"); - assert.equal(profiles[0]?.mode, "review"); + assert.equal(profiles[0]?.model, "sonnet"); assert.equal(profiles[0]?.body, "Project body."); assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { name: "reviewer", description: "Project reviewer.", provider: "claude", - model: undefined, - mode: "review", - permissions: { edit: "deny" }, + model: "sonnet", }); + await writeFile( + join(workspaceRoot, ".devspace", "agents", "custom.md"), + [ + "---", + "name: custom", + "description: Unsupported custom agent.", + "provider: custom", + "---", + "", + "Custom body.", + "", + ].join("\n"), + ); + await assert.rejects( + () => loadLocalAgentProfiles(enabledConfig, workspaceRoot), + /provider must be codex, claude, opencode, pi, cursor, or copilot/, + ); + const disabledConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: workspaceRoot, diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 0ae49ae3..6044f6cd 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -3,18 +3,13 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import type { ServerConfig } from "./config.js"; -export type AgentPermissionValue = "allow" | "ask" | "deny"; -export type LocalAgentProfileBackend = "auto" | "codex-sdk" | "cli" | "acp"; +export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot"; export interface LocalAgentProfile { name: string; description: string; - provider: string; - backend?: LocalAgentProfileBackend; - command?: string; + provider: LocalAgentProvider; model?: string; - mode?: string; - permissions?: Record; filePath: string; body: string; disabled: boolean; @@ -23,10 +18,8 @@ export interface LocalAgentProfile { export interface LocalAgentProfileSummary { name: string; description: string; - provider: string; + provider: LocalAgentProvider; model?: string; - mode?: string; - permissions?: Record; } interface ParsedFrontmatter { @@ -35,8 +28,14 @@ interface ParsedFrontmatter { } const FRONTMATTER_DELIMITER = "---"; -const PERMISSION_VALUES = new Set(["allow", "ask", "deny"]); -const BACKENDS = new Set(["auto", "codex-sdk", "cli", "acp"]); +const PROVIDERS = new Set([ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", +]); export async function loadLocalAgentProfiles( config: ServerConfig, @@ -69,8 +68,6 @@ export function summarizeLocalAgentProfile( description: profile.description, provider: profile.provider, model: profile.model, - mode: profile.mode, - permissions: profile.permissions, }; } @@ -179,39 +176,33 @@ function profileFromFrontmatter( ): LocalAgentProfile { const name = readString(frontmatter, "name") ?? basename(filePath, ".md"); const description = readString(frontmatter, "description"); - const provider = readString(frontmatter, "provider"); + const provider = readProvider(frontmatter, filePath); if (!description) { throw new Error(`Local agent profile is missing description: ${filePath}`); } - if (!provider) { - throw new Error(`Local agent profile is missing provider: ${filePath}`); - } return { name, description, provider, - backend: readBackend(frontmatter, filePath), - command: readString(frontmatter, "command"), model: readString(frontmatter, "model"), - mode: readString(frontmatter, "mode"), - permissions: readPermissions(frontmatter.permissions, filePath), filePath, body, disabled: frontmatter.disabled === true, }; } -function readBackend( - frontmatter: Record, - filePath: string, -): LocalAgentProfileBackend | undefined { - const backend = readString(frontmatter, "backend"); - if (!backend) return undefined; - if (!BACKENDS.has(backend as LocalAgentProfileBackend)) { - throw new Error(`Local agent profile backend must be auto, codex-sdk, cli, or acp: ${filePath}`); +function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { + const provider = readString(frontmatter, "provider"); + if (!provider) { + throw new Error(`Local agent profile is missing provider: ${filePath}`); } - return backend as LocalAgentProfileBackend; + if (!PROVIDERS.has(provider as LocalAgentProvider)) { + throw new Error( + `Local agent profile provider must be codex, claude, opencode, pi, cursor, or copilot: ${filePath}`, + ); + } + return provider as LocalAgentProvider; } function readString(frontmatter: Record, key: string): string | undefined { @@ -220,25 +211,3 @@ function readString(frontmatter: Record, key: string): string | const trimmed = value.trim(); return trimmed || undefined; } - -function readPermissions( - value: unknown, - filePath: string, -): Record | undefined { - if (value === undefined) return undefined; - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`Local agent profile permissions must be a map: ${filePath}`); - } - - const permissions: Record = {}; - for (const [key, rawPermission] of Object.entries(value)) { - if (!PERMISSION_VALUES.has(rawPermission as AgentPermissionValue)) { - throw new Error( - `Local agent profile permission '${key}' must be allow, ask, or deny: ${filePath}`, - ); - } - permissions[key] = rawPermission as AgentPermissionValue; - } - - return Object.keys(permissions).length > 0 ? permissions : undefined; -} diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index a3c2001d..c56bb46c 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -47,7 +47,6 @@ const readOnly = await runtime.run({ }); assert.equal(readOnly.provider, "codex"); -assert.equal(readOnly.backend, "codex-sdk"); assert.equal(readOnly.providerSessionId, "new-thread"); assert.equal(readOnly.finalResponse, "response:inspect only"); assert.deepEqual(codex.startThreadInstance.prompts, ["inspect only"]); @@ -95,4 +94,3 @@ assert.deepEqual(codex.resumed, [ const created = await createCodexSdkLocalAgentRuntime(undefined, () => new FakeCodex()); assert.equal(created.provider, "codex"); -assert.equal(created.backend, "codex-sdk"); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index e17eac7a..949e73ff 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -6,7 +6,6 @@ import type { ThreadOptions, } from "@openai/codex-sdk"; -export type LocalAgentBackend = "cli" | "codex-sdk" | "acp"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; export interface LocalAgentRunInput { @@ -19,7 +18,6 @@ export interface LocalAgentRunInput { export interface LocalAgentRunResult { provider: string; - backend: LocalAgentBackend; providerSessionId: string | null; finalResponse: string; items: unknown[]; @@ -27,7 +25,6 @@ export interface LocalAgentRunResult { export interface LocalAgentRuntime { readonly provider: string; - readonly backend: LocalAgentBackend; run(input: LocalAgentRunInput): Promise; } @@ -66,7 +63,6 @@ function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { readonly provider = "codex" as const; - readonly backend = "codex-sdk" as const; private readonly codex: CodexClientLike; constructor(codex: CodexClientLike) { @@ -82,7 +78,6 @@ export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { return { provider: this.provider, - backend: this.backend, providerSessionId: thread.id, finalResponse: turn.finalResponse, items: turn.items, diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index c0152d86..8e4b4a7e 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -4,7 +4,6 @@ import { dirname, join, resolve } from "node:path"; import type { ServerConfig } from "./config.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; -export type LocalAgentBackendName = "auto" | "codex-sdk" | "cli" | "acp"; export interface LocalAgentRecord { id: string; @@ -13,8 +12,6 @@ export interface LocalAgentRecord { profileName: string; provider: string; model?: string; - mode?: string; - backend: LocalAgentBackendName; providerSessionId?: string; status: LocalAgentStatus; latestResponse?: string; @@ -29,8 +26,6 @@ export interface CreateLocalAgentRecordInput { profileName: string; provider: string; model?: string; - mode?: string; - backend?: LocalAgentBackendName; } interface LocalAgentStoreData { @@ -57,8 +52,6 @@ export class LocalAgentStore { profileName: input.profileName, provider: input.provider, model: input.model, - mode: input.mode, - backend: input.backend ?? "auto", status: "starting", createdAt: now, updatedAt: now, diff --git a/src/server.ts b/src/server.ts index 676cb40e..2ec83f09 100644 --- a/src/server.ts +++ b/src/server.ts @@ -236,8 +236,6 @@ const workspaceLocalAgentOutputSchema = z.object({ description: z.string(), provider: z.string(), model: z.string().optional(), - mode: z.string().optional(), - permissions: z.record(z.string(), z.enum(["allow", "ask", "deny"])).optional(), }); const workspaceAvailableAgentsFileOutputSchema = z.object({ diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 86bcb47b..4feb6ea7 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -25,8 +25,6 @@ try { "name: reviewer", "description: Read-only project reviewer.", "provider: codex", - "permissions:", - " edit: deny", "---", "", "Review only.", @@ -62,7 +60,6 @@ try { name: profile.name, description: profile.description, provider: profile.provider, - permissions: profile.permissions, body: profile.body, })), [ @@ -70,7 +67,6 @@ try { name: "reviewer", description: "Read-only project reviewer.", provider: "codex", - permissions: { edit: "deny" }, body: "Review only.", }, ], From 3582b7ed725903c99aa39a6f5ca9b07ec4ed0312 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 4 Jul 2026 01:09:04 +0530 Subject: [PATCH 06/33] feat(agents): route built-in providers through adapters --- package-lock.json | 227 +++++++++++++++++++++++ package.json | 5 +- src/cli.ts | 13 +- src/local-agent-adapters.test.ts | 18 ++ src/local-agent-adapters.ts | 307 +++++++++++++++++++++++++++++++ 5 files changed, 559 insertions(+), 11 deletions(-) create mode 100644 src/local-agent-adapters.test.ts create mode 100644 src/local-agent-adapters.ts diff --git a/package-lock.json b/package-lock.json index 8e9e090d..b6bf551f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,14 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@agentclientprotocol/sdk": "^1.1.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.200", "@clack/prompts": "^1.5.1", "@earendil-works/pi-coding-agent": "^0.80.1", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", "@openai/codex-sdk": "^0.142.5", + "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", @@ -46,6 +49,175 @@ "node-pty": "^1.1.0" } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.1.0.tgz", + "integrity": "sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.200.tgz", + "integrity": "sha512-o13TM3boFIJE4oZdQDFw5TQfiev1sBoxwzKM2QGj/NPtxriGTP0PKNAQsGZvTsiEOIIH5rzPr/H81xVkkAw23g==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.200", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.200", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.200", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.200" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.200.tgz", + "integrity": "sha512-8UzzInVdRPDNIOvrAxYbHHJD/u13WSBx9fvEeuZnsZ6rZh0qnSI1QwU8Due0V2+m+ZnT3cEonmXDvo2ee/icWg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.200.tgz", + "integrity": "sha512-DCwlQoO8HWGuFElE+Q5pYkiBTalXjjMATRAxXyc94fI6m1ZRqyba66dOea+zTmzHPpOb6zSoHYNLiXy7EjNpcg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.200.tgz", + "integrity": "sha512-NAEonp086ZOsf+3o/9Y5JRclO6C4n4ceiSuCpSDV6SSUOLBmCRi7r/PJOoMsIWwMshC6fnnkDKZamTpHjr75eg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.200.tgz", + "integrity": "sha512-ak0l+zpz3dKPjnBegUhOs1Y5xFveEQ1AVqmq6s8Q7qd3vO4SrDPiUOpxRkjkqWyGD8r8w+ezG+unf3U9IZ6DRg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.200.tgz", + "integrity": "sha512-0R/In8G4fZLFFEIA1SqXRRf9mzDGx7roHpMawNdTT1QlG4XftGTlKMxfukt/YcxwzsNPWg4hJSkEDxsb+3J6FA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.200.tgz", + "integrity": "sha512-Sf5TTCO3bc5ty7FX5F19WT3xbtU+f1biYD9+dDJ7YHyYFWuiPlWcnCJ8El8RSwCTuvz3OexJLwCqGHRWOC3eBg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.200.tgz", + "integrity": "sha512-iJx10bdrk3afa/Oq9QHRh2HaINT/xnsm5OrFNNLbix2CoOEY5lA7f0lk/s0OMiWnfXdv5vvtADpgZ5tvUoQykA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.200", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.200.tgz", + "integrity": "sha512-Mka8YDpDIiSJcbrdoBhzX3S0n9DYcoYaEjS7lxwX3GyPi5PvXV4UBuXzj++7ieV/KS4w32Sm3mHQRpeVwnJZ0A==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@clack/core": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz", @@ -2602,6 +2774,15 @@ "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", @@ -2980,6 +3161,13 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3935,6 +4123,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -4309,6 +4504,20 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5502,6 +5711,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5607,6 +5827,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 4b0cbd88..210617f5 100644 --- a/package.json +++ b/package.json @@ -28,18 +28,21 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], "author": "", "license": "MIT", "dependencies": { + "@agentclientprotocol/sdk": "^1.1.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.200", "@clack/prompts": "^1.5.1", "@earendil-works/pi-coding-agent": "^0.80.1", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", "@openai/codex-sdk": "^0.142.5", + "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", diff --git a/src/cli.ts b/src/cli.ts index 09739429..8e8d0572 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,12 +11,10 @@ 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 { loadLocalAgentProfiles, type LocalAgentProfile } from "./local-agent-profiles.js"; import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-store.js"; -import { - createCodexSdkLocalAgentRuntime, - type LocalAgentRunResult, -} from "./local-agent-runtime.js"; +import type { LocalAgentRunResult } from "./local-agent-runtime.js"; import { ensureDevspaceDefaultSkills, generateOwnerToken, @@ -452,14 +450,9 @@ async function runLocalAgentProfile( record: LocalAgentRecord, prompt: string, ): Promise { - if (profile.provider !== "codex") { - throw new Error(`Provider '${profile.provider}' is not wired yet. Supported provider: codex.`); - } - - const runtime = await createCodexSdkLocalAgentRuntime(); const body = profile.body.trim(); const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; - return runtime.run({ + return runLocalAgentProvider(profile.provider, { prompt: fullPrompt, workspace: record.workspaceRoot, providerSessionId: record.providerSessionId, diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts new file mode 100644 index 00000000..ed75ef85 --- /dev/null +++ b/src/local-agent-adapters.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { createLocalAgentAdapter } from "./local-agent-adapters.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"); +} diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts new file mode 100644 index 00000000..60aa984e --- /dev/null +++ b/src/local-agent-adapters.ts @@ -0,0 +1,307 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import type { LocalAgentProvider } from "./local-agent-profiles.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"], +}; + +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 messages = query({ + prompt: input.prompt, + options: { + cwd: input.workspace, + model: input.model, + resume: input.providerSessionId, + permissionMode: "plan", + maxTurns: 1, + }, + }); + + 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") { + finalResponse = record.result; + } + } + + return { + provider: this.provider, + providerSessionId, + finalResponse: finalResponse.trim(), + items, + }; + } +} + +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 client.session.prompt({ + sessionID: sessionId, + directory: input.workspace, + ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), + parts: [{ type: "text", text: input.prompt }], + }, { throwOnError: true }); + const finalResponse = extractText(promptResult); + return { + provider: this.provider, + providerSessionId: sessionId, + finalResponse, + items: [promptResult], + }; + } 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 { 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" }).connectWith(stream, async (context) => { + const session = await context.buildSession(input.workspace).start(); + providerSessionId = session.sessionId; + try { + await session.prompt(input.prompt); + return await session.readText(); + } 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(); + } + } +} + +class PiRpcLocalAgentAdapter implements LocalAgentAdapter { + readonly provider = "pi" as const; + + async run(input: LocalAgentRunInput): Promise { + const child = spawn(process.env.PI_COMMAND ?? "pi", ["--mode", "rpc"], { + cwd: input.workspace, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + assertPipedChild(child); + const rpc = new JsonLineRpc(child); + try { + await rpc.request({ + type: "prompt", + message: input.prompt, + ...(input.model ? { model: input.model } : {}), + ...(input.providerSessionId ? { session: input.providerSessionId } : {}), + }); + const messages = await rpc.request({ type: "get_messages" }); + return { + provider: this.provider, + providerSessionId: input.providerSessionId ?? null, + finalResponse: extractText(messages), + items: [messages], + }; + } finally { + child.kill(); + } + } +} + +class JsonLineRpc { + private readonly pending = new Map void; + reject: (error: Error) => void; + }>(); + private buffer = ""; + private nextId = 1; + private stderr = ""; + + 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 { + 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`); + }); + } + + 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; + const message = JSON.parse(line) as Record; + 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.error) { + pending.reject(new Error(extractText(message.error))); + } else { + pending.resolve(message.result ?? message); + } + } + } + + private failAll(error: Error): void { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } +} + +async function createOpencodeSession(client: unknown, input: LocalAgentRunInput): Promise { + const result = await (client as { + session: { + create(parameters?: unknown, options?: unknown): Promise; + }; + }).session.create({ + directory: input.workspace, + ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), + }, { throwOnError: true }); + const id = (result as { id?: unknown }).id; + if (typeof id !== "string") { + throw new Error("OpenCode did not return a session id."); + } + return id; +} + +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), + }; +} + +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."); + } +} + +function extractText(value: unknown): string { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return String(value ?? ""); + if (Array.isArray(value)) { + return value.map(extractText).filter(Boolean).join("\n").trim(); + } + const record = value as Record; + if (typeof record.text === "string") return record.text; + if (typeof record.content === "string") return record.content; + if (typeof record.result === "string") return record.result; + if (Array.isArray(record.parts)) return extractText(record.parts); + if (Array.isArray(record.messages)) return extractText(record.messages.at(-1)); + if (Array.isArray(record.data)) return extractText(record.data); + return JSON.stringify(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} From 9ca359b27cace22ffc0f2cde6c634d6c49eaad43 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 4 Jul 2026 01:12:51 +0530 Subject: [PATCH 07/33] fix(agents): allow profile runners to edit workspaces --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 8e8d0572..332d5a0c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -456,7 +456,7 @@ async function runLocalAgentProfile( prompt: fullPrompt, workspace: record.workspaceRoot, providerSessionId: record.providerSessionId, - writeMode: "read_only", + writeMode: "allowed", model: profile.model, }); } From e9bf51733ed930b4a7824f50b06b5c23cde3c47d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 4 Jul 2026 01:12:56 +0530 Subject: [PATCH 08/33] docs(agents): align profiles with built-in providers --- .../plan/local-agent-profiles-and-cli-plan.md | 328 +++--------------- docs/agent-profile-schema.md | 92 +---- docs/chatgpt-coding-workflow.md | 4 +- docs/configuration.md | 4 +- examples/agents/claude-implementer.md | 4 - examples/agents/codex-explorer.md | 4 - examples/agents/codex-worker.md | 4 - examples/agents/copilot-reviewer.md | 4 - examples/agents/cursor-agent-worker.md | 4 - examples/agents/opencode-explorer.md | 4 - examples/agents/pi-reviewer.md | 4 - skills/local-agent-delegation/SKILL.md | 7 +- 12 files changed, 77 insertions(+), 386 deletions(-) diff --git a/.agents/plan/local-agent-profiles-and-cli-plan.md b/.agents/plan/local-agent-profiles-and-cli-plan.md index 5e7f90d0..10405440 100644 --- a/.agents/plan/local-agent-profiles-and-cli-plan.md +++ b/.agents/plan/local-agent-profiles-and-cli-plan.md @@ -1,11 +1,13 @@ -# Local Agent Profiles and Minimal CLI Plan +# Local agent profiles and DevSpace agent CLI plan -## Goal +## Decision -Add local coding-agent delegation to DevSpace without forcing the supervising -model to stream or reason over raw provider CLI output. +Local agent profiles describe roles over built-in coding-agent providers. +DevSpace owns provider invocation and lifecycle. Custom CLI-backed agents, +provider action objects, and model-visible backend details are out of scope for +v1. -The model-facing surface should stay small: +The model-facing workflow stays small: ```bash devspace agents ls @@ -13,304 +15,74 @@ devspace agents run "" devspace agents show ``` -Everything richer, including JSON output, explicit workspace ids, logs, stop, -diagnostics, and future MCP tools, can exist as implementation details or -advanced commands but should not be taught in the default skill. +## Profile schema -## Principles +Profiles are discovered from: -- DevSpace owns the stable agent handle. Provider session ids are stored as - metadata and aliases, not exposed as the primary handle. -- Agent profiles describe roles. Provider configuration describes how to launch - or connect to a harness. -- Prefer first-class adapters for popular harnesses. Keep CLI as a fallback for - custom or unsupported local agents. -- Do not infer changed files, tests, or diffs in v1. Show only status and the - agent final response or latest known response. -- Keep raw provider transcripts out of the default model context. Store them for - debugging and expose them only through explicit advanced commands. +- `~/.devspace/agents/*.md` +- project `.devspace/agents/*.md` -## Agent Profiles +Supported frontmatter fields: -Profiles should be markdown files with frontmatter plus an instruction body. -The frontmatter is for discovery and routing. The body is the worker prompt -prefix used internally when DevSpace launches the profile. - -Default profile locations: - -- Global: `~/.devspace/agents/*.md` -- Project: `.devspace/agents/*.md` - -Packaged examples should remain inert templates. - -Minimal profile shape: - -```md ---- +```yaml +schema: devspace-agent/v1 name: reviewer description: Read-only reviewer for bugs, security risks, and missing tests. provider: codex model: gpt-5.4 -mode: review -permissions: - edit: deny - bash: deny 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. ``` -For v1, avoid profile fields such as `workspace.mode`, `writeMode`, -`runtime.maxTurns`, or `output`. They add routing complexity without improving -the model-facing workflow. - -## Provider Configuration - -Provider configuration is separate from profiles. - -Profiles can say: +Supported providers: -```yaml -provider: codex -model: gpt-5.4 -``` +- `codex` +- `claude` +- `opencode` +- `pi` +- `cursor` +- `copilot` -DevSpace resolves the provider backend internally: +Removed from v1 profile schema: -1. SDK or app-server adapter when available. -2. ACP adapter when available. -3. CLI adapter as fallback. +- `backend` +- `command` +- `mode` +- `permissions` +- `actions` -Future custom provider config can support: +## Provider mapping -```json -{ - "agents": { - "providers": { - "my-acp-agent": { - "extends": "acp", - "label": "My ACP Agent", - "command": ["my-agent", "acp"] - }, - "legacy-agent": { - "backend": "cli", - "label": "Legacy Agent", - "command": ["legacy-agent", "run"] - } - } - } -} -``` +DevSpace maps provider ids to native integrations: -The existing CLI-oriented example profiles should be reworked into role -profiles. Command templates should move to provider config or CLI fallback -adapter tests. +- `codex`: Codex SDK +- `claude`: Claude Code SDK +- `opencode`: OpenCode SDK +- `pi`: Pi RPC mode +- `cursor`: ACP +- `copilot`: ACP -## `open_workspace` Exposure +The adapter registry is the internal seam future MCP tools can reuse if we move +from skill plus CLI guidance to first-class MCP agent tools. -When local agents are enabled, `open_workspace` should expose a compact agent -catalog next to skills. +## Model exposure -Structured output should include: +`open_workspace` exposes only compact profile metadata: ```json { - "agents": [ - { - "name": "reviewer", - "description": "Read-only reviewer for bugs, security risks, and missing tests.", - "provider": "codex", - "model": "gpt-5.4", - "mode": "review", - "permissions": { - "edit": "deny", - "bash": "deny" - } - } - ] -} -``` - -Model-readable text should stay terse: - -```text -Available local agents: reviewer, implementer. -Use the local-agent-delegation skill before delegating work. -``` - -Do not include the full profile body in `open_workspace`; the body can be read -or used internally only when the profile is launched. - -## Skill Guidance - -The local-agent-delegation skill should teach only: - -```bash -devspace agents ls -devspace agents run "" -devspace agents show -``` - -Rules for the model: - -- Use local agents only when the user asks for delegation, second opinion, - parallel work, named local agent usage, or a task clearly benefits from a - configured specialist. -- Pick a profile by `description` and `permissions`. -- Do not silently delegate normal coding tasks. -- Use `run ""` to start a new agent. -- Use `run ""` to send a follow-up to an existing agent. -- Use `show ` to get status and the latest/final response. -- Review the worker's answer before presenting it as verified. - -## CLI Behavior - -### `devspace agents ls` - -Lists available profiles and active agents for the current DevSpace workspace. - -Workspace scoping should resolve in this order: - -1. `DEVSPACE_WORKSPACE_ID`, injected by DevSpace process tools when available. -2. Current working directory. -3. Advanced hidden `--workspace-id` flag, not taught in the skill. - -### `devspace agents run ""` - -If the first argument matches an existing DevSpace agent id or provider session -alias, send a follow-up. Otherwise, treat it as a profile name and create a new -agent. - -Default output should be compact text: - -```text -agt_123 running reviewer -``` - -Keep `--json` available for tests, scripts, and future MCP wrappers, but do not -mention it in the skill. - -### `devspace agents show ` - -Shows agent status and response. - -Default behavior: - -- If idle or done, return immediately with the latest response. -- If running, wait up to 15 seconds. -- If it finishes within that window, print the final response. -- If still running, print compact status and tell the model to call `show` - again later. -- If waiting for permission, errored, or stopped, print that state. - -Example while running: - -```text -agt_123 running reviewer -No final response yet. Call `devspace agents show agt_123` again later. -``` - -Example when done: - -```text -agt_123 idle reviewer -The likely issue is in src/foo.ts... -``` - -Hidden advanced options can include `--no-wait`, `--timeout `, -`--json`, and `--logs`, but they should not be part of the default skill. - -## Runtime Storage - -Store a DevSpace-owned session record: - -```ts -interface LocalAgentRecord { - id: string; - workspaceId?: string; - workspaceRoot: string; - profileName: string; - provider: string; - model?: string; - backend: "auto" | "sdk" | "app-server" | "acp" | "cli"; - providerSessionId?: string; - status: "starting" | "running" | "idle" | "error" | "stopped"; - latestResponse?: string; - error?: string; - createdAt: string; - updatedAt: string; + "name": "reviewer", + "description": "Read-only reviewer for bugs, security risks, and missing tests.", + "provider": "codex", + "model": "gpt-5.4" } ``` -Raw event streams and verbose logs should be stored separately and omitted from -default command output. - -## Adapter Plan - -Start with a small provider-neutral interface: - -```ts -interface LocalAgentRuntime { - provider: string; - backend: string; - start(input: StartAgentInput): Promise; - followUp(input: FollowUpAgentInput): Promise; - status?(id: string): Promise; - cancel?(id: string): Promise; -} -``` - -Initial adapters: - -1. Codex SDK/app-server adapter using the existing `local-agent-runtime.ts` - direction. -2. CLI fallback adapter for custom profiles and unsupported harnesses. -3. ACP adapter after the CLI/profile flow is stable. - -Later adapters can cover Claude Code SDK, OpenCode SDK/server, Pi RPC/SDK, and -other harness-specific APIs. - -## Implementation Phases - -### Phase 1: Profile Catalog - -- Add profile loader for global and project profile directories. -- Validate minimal frontmatter. -- Add `agents` to `open_workspace` structured output when local agents are - enabled. -- Update docs and tests. - -### Phase 2: Minimal CLI - -- Add `devspace agents ls`. -- Add local agent records in state storage. -- Add `run` and `show` command skeletons with mocked/runtime-injected adapter - tests. -- Inject `DEVSPACE_WORKSPACE_ID` and `DEVSPACE_WORKSPACE_ROOT` into DevSpace - process tool environments. - -### Phase 3: Runtime Adapters - -- Wire Codex SDK/app-server adapter. -- Wire CLI fallback adapter. -- Keep provider logs out of default output. - -### Phase 4: Skill and Examples - -- Rewrite `local-agent-delegation` skill around the three commands. -- Rework packaged examples into role profiles, not CLI command templates. -- Document provider config and CLI fallback separately. - -### Phase 5: Future MCP Tools - -Add MCP tools only after the CLI/runtime boundary is stable: +The profile body, provider protocol, raw provider transcript, and adapter +details stay outside the default model context. -- `spawn_agent` -- `show_agent` -- `run_agent` -- `list_agents` +## Non-goals -These should call the same runtime implementation as the CLI. +- Custom or arbitrary local agent commands. +- Provider-specific action DSLs. +- Exposing raw provider transcripts by default. +- Tracking changed files or tests from provider output. diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 51061f2a..d933f53b 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -1,8 +1,8 @@ # Local agent profile schema DevSpace local agent profiles are user-owned markdown files with YAML -frontmatter. They describe *roles* such as reviewer, explorer, or implementer. -Provider configuration describes how DevSpace talks to the underlying harness. +frontmatter. They describe roles such as reviewer, explorer, or implementer. +DevSpace owns provider invocation. Profiles are discovered from: @@ -19,12 +19,7 @@ schema: devspace-agent/v1 name: reviewer description: Read-only reviewer for bugs, security risks, and missing tests. provider: codex -backend: auto model: gpt-5.4 -mode: review -permissions: - edit: deny - bash: deny disabled: false --- @@ -51,7 +46,8 @@ Stable profile identifier shown to the model and accepted by: devspace agents run "" ``` -Use lowercase kebab-case names. +Use lowercase kebab-case names. If omitted, DevSpace uses the filename without +`.md`. ### `description` @@ -60,49 +56,26 @@ Required short purpose. This is exposed by `open_workspace` and ### `provider` -Required local agent family or provider id. - -Examples: +Required built-in provider id: ```yaml provider: codex provider: claude provider: opencode -provider: cursor provider: pi +provider: cursor provider: copilot ``` -### `backend` - -Optional execution backend. - -```yaml -backend: auto -backend: codex-sdk -backend: acp -backend: cli -``` +Unsupported or custom providers are rejected. DevSpace maps providers to their +native integration: -`auto` is the normal value. DevSpace resolves the best available adapter for -the provider. CLI fallback profiles can set `backend: cli` and provide -`command`. - -Current support is intentionally narrow: Codex profiles use the Codex SDK, and -other providers should include a `command` until their ACP or SDK adapters are -wired. - -### `command` - -Optional command string for `backend: cli` fallback profiles. DevSpace passes -the full worker prompt on stdin and captures stdout as the response. - -```yaml -backend: cli -command: "my-agent run --no-color" -``` - -Prefer built-in provider adapters or ACP when available. +- `codex`: Codex SDK +- `claude`: Claude Code SDK +- `opencode`: OpenCode SDK +- `pi`: Pi RPC mode +- `cursor`: ACP +- `copilot`: ACP ### `model` @@ -113,29 +86,6 @@ model: gpt-5.4 model: sonnet ``` -### `mode` - -Optional provider or role mode label. - -```yaml -mode: review -mode: implement -``` - -### `permissions` - -Optional model-facing permission hints. - -```yaml -permissions: - edit: deny - bash: deny -``` - -Supported values are `allow`, `ask`, and `deny`. DevSpace exposes these hints in -the compact agent catalog. Provider adapters may also map them to native sandbox -or approval settings. - ### `disabled` Optional boolean. Disabled profiles are not exposed. @@ -152,7 +102,7 @@ profile. It is not included in `open_workspace` by default. Recommended body content: - When to use this profile. -- What the worker must not do. +- Whether the worker should act read-only or may make changes. - Output format. - Review or testing expectations. @@ -173,12 +123,7 @@ devspace agents show "name": "reviewer", "description": "Read-only reviewer for bugs, security risks, and missing tests.", "provider": "codex", - "model": "gpt-5.4", - "mode": "review", - "permissions": { - "edit": "deny", - "bash": "deny" - } + "model": "gpt-5.4" } ``` @@ -187,8 +132,9 @@ 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 runtime used by - `devspace agents`. +- 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 0732369b..81a38db0 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -94,8 +94,8 @@ It also keeps compatibility with: When local agents are enabled, DevSpace discovers local coding-agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. `open_workspace` exposes a compact catalog with profile names, descriptions, -providers, modes, models, and permissions so the model can choose a configured -agent without seeing provider-specific launch details. +providers, and optional models so the model can choose a configured agent +without seeing provider-specific launch details. Example profiles are packaged under `examples/agents/` for users who want starter templates. Copy or adapt them into one of the active profile directories diff --git a/docs/configuration.md b/docs/configuration.md index 3c96ae49..086f2d64 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -122,8 +122,8 @@ from: - project `.devspace/agents/*.md` `open_workspace` returns a compact catalog containing profile names, -descriptions, providers, modes, models, and permissions so the host model can -choose an agent without reading provider-specific launch details. The +descriptions, providers, and optional models so the host model can choose an +agent without reading provider-specific launch details. The `local-agent-delegation` skill teaches the model to use only the minimal `devspace agents ls`, `devspace agents run`, and `devspace agents show` workflow. diff --git a/examples/agents/claude-implementer.md b/examples/agents/claude-implementer.md index 52b027a7..f4f2fb3b 100644 --- a/examples/agents/claude-implementer.md +++ b/examples/agents/claude-implementer.md @@ -3,11 +3,7 @@ schema: devspace-agent/v1 name: claude-implementer description: Claude Code profile for larger implementation, refactor, and repair tasks. provider: claude -backend: auto model: sonnet -permissions: - edit: allow - bash: allow --- You are a local Claude Code implementation worker under supervisor review. diff --git a/examples/agents/codex-explorer.md b/examples/agents/codex-explorer.md index ea2b8aeb..78b1de4a 100644 --- a/examples/agents/codex-explorer.md +++ b/examples/agents/codex-explorer.md @@ -3,11 +3,7 @@ schema: devspace-agent/v1 name: codex-explorer description: Read-only Codex profile for bounded codebase questions and architecture exploration. provider: codex -backend: auto model: gpt-5.4 -permissions: - edit: deny - bash: deny --- You are a read-only codebase explorer. diff --git a/examples/agents/codex-worker.md b/examples/agents/codex-worker.md index a0c91c8e..cbae95c2 100644 --- a/examples/agents/codex-worker.md +++ b/examples/agents/codex-worker.md @@ -3,11 +3,7 @@ schema: devspace-agent/v1 name: codex-worker description: Codex implementation profile for focused, user-approved coding tasks. provider: codex -backend: auto model: gpt-5.4 -permissions: - edit: allow - bash: allow --- You are a local implementation worker under supervisor review. diff --git a/examples/agents/copilot-reviewer.md b/examples/agents/copilot-reviewer.md index 458a14eb..e6ebd176 100644 --- a/examples/agents/copilot-reviewer.md +++ b/examples/agents/copilot-reviewer.md @@ -3,10 +3,6 @@ schema: devspace-agent/v1 name: copilot-reviewer description: GitHub Copilot read-only profile for code questions and review passes. provider: copilot -backend: auto -permissions: - edit: deny - bash: deny --- You are a read-only Copilot reviewer under supervisor review. diff --git a/examples/agents/cursor-agent-worker.md b/examples/agents/cursor-agent-worker.md index 46d0e424..91fffc9c 100644 --- a/examples/agents/cursor-agent-worker.md +++ b/examples/agents/cursor-agent-worker.md @@ -3,10 +3,6 @@ schema: devspace-agent/v1 name: cursor-agent-worker description: Cursor Agent profile for fast implementation or UI-oriented review. provider: cursor -backend: acp -permissions: - edit: allow - bash: allow --- You are a local Cursor Agent worker under supervisor review. diff --git a/examples/agents/opencode-explorer.md b/examples/agents/opencode-explorer.md index 97165c57..beb52865 100644 --- a/examples/agents/opencode-explorer.md +++ b/examples/agents/opencode-explorer.md @@ -3,10 +3,6 @@ schema: devspace-agent/v1 name: opencode-explorer description: OpenCode read-only profile for fast lookup and bounded codebase questions. provider: opencode -backend: auto -permissions: - edit: deny - bash: deny --- You are a read-only OpenCode explorer. diff --git a/examples/agents/pi-reviewer.md b/examples/agents/pi-reviewer.md index bd66daf4..67eded03 100644 --- a/examples/agents/pi-reviewer.md +++ b/examples/agents/pi-reviewer.md @@ -3,10 +3,6 @@ schema: devspace-agent/v1 name: pi-reviewer description: Pi read-only profile for quick code review and targeted questions. provider: pi -backend: auto -permissions: - edit: deny - bash: deny --- You are a read-only local code reviewer. diff --git a/skills/local-agent-delegation/SKILL.md b/skills/local-agent-delegation/SKILL.md index b4312d70..489b6d56 100644 --- a/skills/local-agent-delegation/SKILL.md +++ b/skills/local-agent-delegation/SKILL.md @@ -32,13 +32,14 @@ devspace agents show 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`, or `pi` -directly unless you are explicitly debugging DevSpace agent integration. +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 Use `devspace agents ls` and choose by profile name, description, provider, and -permissions. +model when present. Good delegation targets: From 394cc31f18e259b13f31dcc286fcdf4d6a4e2bc2 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 4 Jul 2026 01:30:36 +0530 Subject: [PATCH 09/33] refactor(subagents): rename local agent feature gate --- .../plan/local-agent-profiles-and-cli-plan.md | 6 ++-- docs/agent-profile-schema.md | 6 ++-- docs/chatgpt-coding-workflow.md | 8 +++--- docs/configuration.md | 8 +++--- docs/gotchas.md | 8 +++--- .../SKILL.md | 14 +++++----- src/cli.test.ts | 6 ++-- src/cli.ts | 18 ++++++------ src/config.test.ts | 22 +++++++-------- src/config.ts | 10 +++---- src/local-agent-profiles.test.ts | 4 +-- src/local-agent-profiles.ts | 12 ++++---- src/local-agent-store.ts | 2 +- src/server.ts | 2 +- src/skills.test.ts | 28 +++++++++---------- src/skills.ts | 16 +++++------ src/user-config.ts | 14 +++++----- src/workspaces.test.ts | 2 +- 18 files changed, 93 insertions(+), 93 deletions(-) rename skills/{local-agent-delegation => subagent-delegation}/SKILL.md (86%) diff --git a/.agents/plan/local-agent-profiles-and-cli-plan.md b/.agents/plan/local-agent-profiles-and-cli-plan.md index 10405440..e05d38a9 100644 --- a/.agents/plan/local-agent-profiles-and-cli-plan.md +++ b/.agents/plan/local-agent-profiles-and-cli-plan.md @@ -1,8 +1,8 @@ -# Local agent profiles and DevSpace agent CLI plan +# Subagent profiles and DevSpace agent CLI plan ## Decision -Local agent profiles describe roles over built-in coding-agent providers. +Subagent profiles describe roles over built-in coding-agent providers. DevSpace owns provider invocation and lifecycle. Custom CLI-backed agents, provider action objects, and model-visible backend details are out of scope for v1. @@ -82,7 +82,7 @@ details stay outside the default model context. ## Non-goals -- Custom or arbitrary local agent commands. +- Custom or arbitrary subagent commands. - Provider-specific action DSLs. - Exposing raw provider transcripts by default. - Tracking changed files or tests from provider output. diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index d933f53b..189ee876 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -1,6 +1,6 @@ -# Local agent profile schema +# Subagent profile schema -DevSpace local agent profiles are user-owned markdown files with YAML +DevSpace agent profiles are user-owned markdown files with YAML frontmatter. They describe roles such as reviewer, explorer, or implementer. DevSpace owns provider invocation. @@ -108,7 +108,7 @@ Recommended body content: ## Model-facing workflow -The local-agent skill teaches only: +The Subagent skill teaches only: ```bash devspace agents ls diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 81a38db0..84959e0b 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -87,11 +87,11 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: -- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -When local agents are enabled, DevSpace discovers local coding-agent profiles +When Subagents are enabled, DevSpace discovers agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. `open_workspace` exposes a compact catalog with profile names, descriptions, providers, and optional models so the model can choose a configured agent @@ -112,8 +112,8 @@ Skill paths may be outside the workspace. DevSpace only permits reading: - files under a skill directory after that skill's `SKILL.md` has been read Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Set -`DEVSPACE_LOCAL_AGENTS=1` to expose the experimental local agent catalog and -`local-agent-delegation` skill. That skill teaches the minimal +`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. diff --git a/docs/configuration.md b/docs/configuration.md index 086f2d64..3e3e44b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,7 +99,7 @@ sessions. | Variable | Purpose | | --- | --- | | `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | -| `DEVSPACE_LOCAL_AGENTS` | Set to `1` to expose local agent profiles and the local-agent delegation skill. Experimental and disabled by default. | +| `DEVSPACE_SUBAGENTS` | Set to `1` to expose configured agent profiles as Subagents. Experimental and disabled by default. | | `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | @@ -111,11 +111,11 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: -- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -When local agents are enabled, DevSpace discovers local coding-agent profiles +When Subagents are enabled, DevSpace discovers agent profiles from: - `~/.devspace/agents/*.md` @@ -124,7 +124,7 @@ from: `open_workspace` returns a compact catalog containing profile names, descriptions, providers, and optional models so the host model can choose an agent without reading provider-specific launch details. The -`local-agent-delegation` skill teaches the model to use only the minimal +`subagent-delegation` skill teaches the model to use only the minimal `devspace agents ls`, `devspace agents run`, and `devspace agents show` workflow. diff --git a/docs/gotchas.md b/docs/gotchas.md index 9fdce973..5b0a72e6 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,17 +201,17 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: -- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -When `DEVSPACE_LOCAL_AGENTS=1`, DevSpace loads local coding-agent profiles from +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 -`local-agent-delegation` skill keeps the model-facing workflow to +`subagent-delegation` skill keeps the model-facing workflow to `devspace agents ls`, `devspace agents run`, and `devspace agents show`. -Packaged local-agent examples under `examples/agents/` are starter templates. +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. diff --git a/skills/local-agent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md similarity index 86% rename from skills/local-agent-delegation/SKILL.md rename to skills/subagent-delegation/SKILL.md index 489b6d56..02f4a9b2 100644 --- a/skills/local-agent-delegation/SKILL.md +++ b/skills/subagent-delegation/SKILL.md @@ -1,15 +1,15 @@ --- -name: local-agent-delegation -description: Delegate coding tasks to user-configured DevSpace local agents. +name: subagent-delegation +description: Delegate coding tasks to user-configured DevSpace subagents. --- -# Local Agent Delegation +# Subagent Delegation Use this skill when the user explicitly asks to delegate work to another coding -agent, use a named local agent, get a second opinion, compare approaches, or run +agent, use a named subagent, get a second opinion, compare approaches, or run a subagent-like workflow. -Do not use local agents silently. Tell the user when another local agent is +Do not use subagents silently. Tell the user when another subagent is being used. ## Core commands @@ -49,7 +49,7 @@ Good delegation targets: 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 local agent. +parallel work, or a named subagent. ## Worker prompts @@ -107,4 +107,4 @@ I used . It reported . I verified . Remaining risk: . ``` -Never hide that a local agent was used. +Never hide that a subagent was used. diff --git a/src/cli.test.ts b/src/cli.test.ts index baecd24f..f331847a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -49,7 +49,7 @@ try { DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_STATE_DIR: stateDir, DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }, }); @@ -60,9 +60,9 @@ try { DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_STATE_DIR: stateDir, - DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }).localAgents, true); + }).subagents, true); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index 332d5a0c..f9716019 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import { ensureDevspaceDefaultSkills, generateOwnerToken, loadDevspaceFiles, - resolveLocalAgentsFlag, + resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, type DevspaceUserConfig, @@ -147,7 +147,7 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, - localAgents: resolveLocalAgentsFlag(files.config), + subagents: resolveSubagentsFlag(files.config), }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -155,7 +155,7 @@ async function runInit({ force }: { force: boolean }): Promise { const configPath = writeDevspaceConfig(config); const authPath = writeDevspaceAuth(auth); - const seededSkillPaths = config.localAgents ? ensureDevspaceDefaultSkills() : []; + const seededSkillPaths = config.subagents ? ensureDevspaceDefaultSkills() : []; const lines = [ `Config: ${configPath}`, @@ -285,7 +285,7 @@ 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 local agent profiles and sessions", + " devspace agents ls List agent profiles and sessions", " devspace agents run ", " devspace agents show ", " devspace -v, --version Print the installed version", @@ -331,7 +331,7 @@ async function runAgentsList(): Promise { const agents = store.list(workspaceRoot); if (profiles.length === 0 && agents.length === 0) { - console.log("No local agents configured for this workspace."); + console.log("No subagents configured for this workspace."); return; } @@ -368,7 +368,7 @@ async function runAgentsRun(args: string[]): Promise { const profiles = await loadLocalAgentProfiles(config, workspaceRoot); const profile = profiles.find((candidate) => candidate.name === target); if (!profile) { - throw new Error(`Unknown local agent profile or id: ${target}`); + throw new Error(`Unknown subagent profile or id: ${target}`); } const record = store.create({ @@ -390,7 +390,7 @@ async function runAgentsShow(args: string[]): Promise { const config = loadConfig(); const store = createLocalAgentStore(config); let record = store.get(id); - if (!record) throw new Error(`Unknown local agent id: ${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) { @@ -421,13 +421,13 @@ async function runAgentsWorker(args: string[]): Promise { const config = loadConfig(); const store = createLocalAgentStore(config); const record = store.get(id); - if (!record) throw new Error(`Unknown local agent id: ${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); - if (!profile) throw new Error(`Local agent profile not found: ${record.profileName}`); + if (!profile) throw new Error(`Subagent profile not found: ${record.profileName}`); const prompt = await readFile(promptFile, "utf8"); const result = await runLocalAgentProfile(profile, record, prompt); diff --git a/src/config.test.ts b/src/config.test.ts index c8f69b38..cbe870d6 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; -import { ensureDevspaceDefaultSkills, resolveLocalAgentsFlag } from "./user-config.js"; +import { ensureDevspaceDefaultSkills, resolveSubagentsFlag } from "./user-config.js"; const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); const baseEnv = { @@ -28,23 +28,23 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, " 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).localAgents, false); +assert.equal(loadConfig(baseEnv).subagents, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_LOCAL_AGENTS: "1" }).localAgents, + loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, true, ); -assert.equal(resolveLocalAgentsFlag({}, {}), undefined); -assert.equal(resolveLocalAgentsFlag({ localAgents: true }, {}), true); -assert.equal(resolveLocalAgentsFlag({ localAgents: true }, { DEVSPACE_LOCAL_AGENTS: "0" }), false); -assert.equal(resolveLocalAgentsFlag({}, { DEVSPACE_LOCAL_AGENTS: "1" }), true); +assert.equal(resolveSubagentsFlag({}, {}), undefined); +assert.equal(resolveSubagentsFlag({ subagents: true }, {}), true); +assert.equal(resolveSubagentsFlag({ subagents: true }, { DEVSPACE_SUBAGENTS: "0" }), false); +assert.equal(resolveSubagentsFlag({}, { DEVSPACE_SUBAGENTS: "1" }), true); const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-")); const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }); -assert.deepEqual(seededSkillPaths, [join(seededConfigDir, "skills", "local-agent-delegation", "SKILL.md")]); +assert.deepEqual(seededSkillPaths, [join(seededConfigDir, "skills", "subagent-delegation", "SKILL.md")]); assert.equal(existsSync(seededSkillPaths[0]), true); -assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: local-agent-delegation/); +assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagent-delegation/); assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []); assert.throws( @@ -169,7 +169,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", - localAgents: true, + subagents: true, }), ); writeFileSync( @@ -183,7 +183,7 @@ const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); -assert.equal(fileConfig.localAgents, true); +assert.equal(fileConfig.subagents, true); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index 83b7bcbd..c168af43 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,7 +27,7 @@ export interface ServerConfig { skillPaths: string[]; devspaceSkillsDir: string; devspaceAgentsDir: string; - localAgents: boolean; + subagents: boolean; agentDir: string; logging: LoggingConfig; } @@ -240,10 +240,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), - localAgents: - env.DEVSPACE_LOCAL_AGENTS === undefined - ? files.config.localAgents === true - : parseBoolean(env.DEVSPACE_LOCAL_AGENTS), + 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/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 56908839..b355c13b 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -59,7 +59,7 @@ try { const enabledConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: workspaceRoot, - DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }); const profiles = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); @@ -98,7 +98,7 @@ try { const disabledConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: workspaceRoot, - DEVSPACE_LOCAL_AGENTS: "0", + DEVSPACE_SUBAGENTS: "0", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }); assert.deepEqual(await loadLocalAgentProfiles(disabledConfig, workspaceRoot), []); diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 6044f6cd..4a3ae687 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -41,7 +41,7 @@ export async function loadLocalAgentProfiles( config: ServerConfig, workspaceRoot: string, ): Promise { - if (!config.localAgents) return []; + if (!config.subagents) return []; const profileDirs = [ config.devspaceAgentsDir, @@ -99,14 +99,14 @@ 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(`Local agent profile is missing frontmatter: ${filePath}`); + 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(`Local agent profile frontmatter is not closed: ${filePath}`); + throw new Error(`Subagent profile frontmatter is not closed: ${filePath}`); } return { @@ -178,7 +178,7 @@ function profileFromFrontmatter( const description = readString(frontmatter, "description"); const provider = readProvider(frontmatter, filePath); if (!description) { - throw new Error(`Local agent profile is missing description: ${filePath}`); + throw new Error(`Subagent profile is missing description: ${filePath}`); } return { @@ -195,11 +195,11 @@ function profileFromFrontmatter( function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { const provider = readString(frontmatter, "provider"); if (!provider) { - throw new Error(`Local agent profile is missing provider: ${filePath}`); + throw new Error(`Subagent profile is missing provider: ${filePath}`); } if (!PROVIDERS.has(provider as LocalAgentProvider)) { throw new Error( - `Local agent profile provider must be codex, claude, opencode, pi, cursor, or copilot: ${filePath}`, + `Subagent profile provider must be codex, claude, opencode, pi, cursor, or copilot: ${filePath}`, ); } return provider as LocalAgentProvider; diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 8e4b4a7e..d1ac7e1d 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -80,7 +80,7 @@ export class LocalAgentStore { }), })); - if (!updated) throw new Error(`Unknown local agent id: ${id}`); + if (!updated) throw new Error(`Unknown subagent id: ${id}`); return updated; } diff --git a/src/server.ts b/src/server.ts index 2ec83f09..8109313f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -805,7 +805,7 @@ function createMcpServer( ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, visibleAgents.length > 0 - ? `Available local agents: ${visibleAgents.map((agent) => agent.name).join(", ")}` + ? `Available subagents: ${visibleAgents.map((agent) => agent.name).join(", ")}` : undefined, instruction, ].filter(Boolean).join("\n"), diff --git a/src/skills.test.ts b/src/skills.test.ts index af396bbc..707dda26 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -31,10 +31,10 @@ try { await mkdir(join(projectClaudeSkills, "claude-project-skill"), { recursive: true }); await mkdir(join(projectRoot, ".pi", "skills", "project-skill"), { recursive: true }); await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); - await mkdir(join(agentDir, "skills", "local-agent-delegation"), { recursive: true }); + await mkdir(join(agentDir, "skills", "subagent-delegation"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); - await mkdir(join(explicitSkills, "local-agent-delegation"), { recursive: true }); + await mkdir(join(explicitSkills, "subagent-delegation"), { recursive: true }); await mkdir(join(devspaceSkills, "devspace-local-skill"), { recursive: true }); await writeFile( @@ -126,25 +126,25 @@ try { ].join("\n"), ); await writeFile( - join(agentDir, "skills", "local-agent-delegation", "SKILL.md"), + join(agentDir, "skills", "subagent-delegation", "SKILL.md"), [ "---", - "name: local-agent-delegation", - "description: Hidden local agent skill winner.", + "name: subagent-delegation", + "description: Hidden subagent skill winner.", "---", "", - "# Local Agent Delegation", + "# Subagent Delegation", ].join("\n"), ); await writeFile( - join(explicitSkills, "local-agent-delegation", "SKILL.md"), + join(explicitSkills, "subagent-delegation", "SKILL.md"), [ "---", - "name: local-agent-delegation", - "description: Hidden local agent skill loser.", + "name: subagent-delegation", + "description: Hidden subagent skill loser.", "---", "", - "# Local Agent Delegation Duplicate", + "# Subagent Delegation Duplicate", ].join("\n"), ); await writeFile( @@ -184,13 +184,13 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "claude-project-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), false); assert.equal(loaded.skills.some((skill) => skill.name === "devspace-local-skill"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "local-agent-delegation"), false); + assert.equal(loaded.skills.some((skill) => skill.name === "subagent-delegation"), false); assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); assert.equal( loaded.diagnostics.some( - (diagnostic) => diagnostic.collision?.name === "local-agent-delegation", + (diagnostic) => diagnostic.collision?.name === "subagent-delegation", ), false, ); @@ -198,13 +198,13 @@ try { const experimentalConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); assert.equal( loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some( - (skill) => skill.name === "local-agent-delegation", + (skill) => skill.name === "subagent-delegation", ), true, ); diff --git a/src/skills.ts b/src/skills.ts index c71b7ef7..c1f146a9 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -21,15 +21,15 @@ export interface SkillReadResolution { isSkillFile: boolean; } -const LOCAL_AGENT_DELEGATION_NAME = "local-agent-delegation"; -const LOCAL_AGENT_DELEGATION_SKILL = join(LOCAL_AGENT_DELEGATION_NAME, "SKILL.md"); +const SUBAGENT_DELEGATION_NAME = "subagent-delegation"; +const SUBAGENT_DELEGATION_SKILL = join(SUBAGENT_DELEGATION_NAME, "SKILL.md"); function bundledSkillsDir(): string { return fileURLToPath(new URL("../skills", import.meta.url)); } -function hasLocalAgentDelegationSkill(skillDir: string): boolean { - return existsSync(join(skillDir, LOCAL_AGENT_DELEGATION_SKILL)); +function hasSubagentDelegationSkill(skillDir: string): boolean { + return existsSync(join(skillDir, SUBAGENT_DELEGATION_SKILL)); } export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] { @@ -39,7 +39,7 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] resolve(cwd, ".agents", "skills"), config.devspaceSkillsDir, join(config.agentDir, "skills"), - config.localAgents && !hasLocalAgentDelegationSkill(config.devspaceSkillsDir) + config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir) ? bundledSkills : undefined, ]; @@ -71,13 +71,13 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk includeDefaults: false, }); - if (config.localAgents) return result; + if (config.subagents) return result; return { - skills: result.skills.filter((skill) => skill.name !== LOCAL_AGENT_DELEGATION_NAME), + skills: result.skills.filter((skill) => skill.name !== SUBAGENT_DELEGATION_NAME), diagnostics: result.diagnostics.filter((diagnostic) => { const collision = diagnostic.collision; - return !(collision?.resourceType === "skill" && collision.name === LOCAL_AGENT_DELEGATION_NAME); + return !(collision?.resourceType === "skill" && collision.name === SUBAGENT_DELEGATION_NAME); }), }; } diff --git a/src/user-config.ts b/src/user-config.ts index 57492591..c8da90b5 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -18,7 +18,7 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; - localAgents?: boolean; + subagents?: boolean; } export interface DevspaceAuthConfig { @@ -98,21 +98,21 @@ export function generateOwnerToken(): string { } export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env): string[] { - const targetPath = join(devspaceSkillsDir(env), "local-agent-delegation", "SKILL.md"); + const targetPath = join(devspaceSkillsDir(env), "subagent-delegation", "SKILL.md"); if (existsSync(targetPath)) return []; - const sourcePath = new URL("../skills/local-agent-delegation/SKILL.md", import.meta.url); + const sourcePath = new URL("../skills/subagent-delegation/SKILL.md", import.meta.url); mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, readFileSync(sourcePath, "utf8"), { mode: 0o644 }); return [targetPath]; } -export function resolveLocalAgentsFlag( - config: Pick, +export function resolveSubagentsFlag( + config: Pick, env: NodeJS.ProcessEnv = process.env, ): boolean | undefined { - if (env.DEVSPACE_LOCAL_AGENTS === undefined) return config.localAgents; - return ["1", "true", "yes", "on"].includes(env.DEVSPACE_LOCAL_AGENTS.toLowerCase()); + if (env.DEVSPACE_SUBAGENTS === undefined) return config.subagents; + return ["1", "true", "yes", "on"].includes(env.DEVSPACE_SUBAGENTS.toLowerCase()); } function readJsonFile(filePath: string): T { diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 4feb6ea7..f283aeda 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -39,7 +39,7 @@ try { DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_LOCAL_AGENTS: "1", + DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); From b349a80c9b533d2954ec9f9c61088aa8435f4299 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 4 Jul 2026 01:53:11 +0530 Subject: [PATCH 10/33] fix(subagents): scope session listing by workspace --- .../plan/local-agent-profiles-and-cli-plan.md | 8 ++++ docs/agent-profile-schema.md | 7 +++- docs/chatgpt-coding-workflow.md | 3 +- docs/configuration.md | 9 +++-- docs/gotchas.md | 2 + skills/subagent-delegation/SKILL.md | 7 ++-- src/cli.test.ts | 38 ++++++++++++++++++- src/cli.ts | 22 +++++------ src/local-agent-store.test.ts | 6 ++- src/local-agent-store.ts | 14 +++++-- 10 files changed, 89 insertions(+), 27 deletions(-) diff --git a/.agents/plan/local-agent-profiles-and-cli-plan.md b/.agents/plan/local-agent-profiles-and-cli-plan.md index e05d38a9..27c82142 100644 --- a/.agents/plan/local-agent-profiles-and-cli-plan.md +++ b/.agents/plan/local-agent-profiles-and-cli-plan.md @@ -15,6 +15,10 @@ devspace agents run "" devspace agents show ``` +Profile discovery happens through the compact catalog returned by +`open_workspace`. `devspace agents ls` lists existing subagent sessions for the +current workspace; it does not list profile definitions. + ## Profile schema Profiles are discovered from: @@ -80,6 +84,10 @@ from skill plus CLI guidance to first-class MCP agent tools. The profile body, provider protocol, raw provider transcript, and adapter details stay outside the default model context. +Shell calls launched through DevSpace receive `DEVSPACE_WORKSPACE_ID` and +`DEVSPACE_WORKSPACE_ROOT`, so `devspace agents ls` can scope itself without the +model passing workspace flags. + ## Non-goals - Custom or arbitrary subagent commands. diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 189ee876..382911b6 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -51,8 +51,8 @@ Use lowercase kebab-case names. If omitted, DevSpace uses the filename without ### `description` -Required short purpose. This is exposed by `open_workspace` and -`devspace agents ls` so the supervising model can choose the right profile. +Required short purpose. This is exposed by `open_workspace` so the supervising +model can choose the right profile. ### `provider` @@ -127,6 +127,9 @@ devspace agents show } ``` +`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. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 84959e0b..4b73c19f 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -115,7 +115,8 @@ 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. +workflow. The catalog comes from `open_workspace`; `devspace agents ls` lists +existing subagent sessions for that workspace. ## Tool Names diff --git a/docs/configuration.md b/docs/configuration.md index 3e3e44b9..81f0bb0d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,10 +123,11 @@ from: `open_workspace` returns a compact catalog containing profile names, descriptions, providers, and optional models so the host model can choose an -agent without reading provider-specific launch details. The -`subagent-delegation` skill teaches the model to use only the minimal -`devspace agents ls`, `devspace agents run`, and `devspace agents show` -workflow. +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. diff --git a/docs/gotchas.md b/docs/gotchas.md index 5b0a72e6..779e37ef 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -210,6 +210,8 @@ When `DEVSPACE_SUBAGENTS=1`, DevSpace loads agent profiles from 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. diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md index 02f4a9b2..bf616054 100644 --- a/skills/subagent-delegation/SKILL.md +++ b/skills/subagent-delegation/SKILL.md @@ -22,7 +22,8 @@ devspace agents run "" devspace agents show ``` -`ls` shows configured profiles and active agents for the current workspace. +`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 agent and prints a DevSpace agent id. @@ -38,8 +39,8 @@ DevSpace agent integration. ## Choosing a profile -Use `devspace agents ls` and choose by profile name, description, provider, and -model when present. +Choose profiles from the compact subagent profile catalog returned by +`open_workspace`. Use the profile name with `devspace agents run`. Good delegation targets: diff --git a/src/cli.test.ts b/src/cli.test.ts index f331847a..12949c28 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -23,6 +23,7 @@ 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( @@ -39,6 +40,38 @@ try { "", ].join("\n"), ); + writeFileSync( + join(stateDir, "local-agents.json"), + JSON.stringify( + { + agents: [ + { + id: "agt_current", + workspaceId: "ws_current", + workspaceRoot: projectRoot, + profileName: "reviewer", + provider: "codex", + model: "gpt-5.4", + status: "idle", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "agt_other", + workspaceId: "ws_other", + workspaceRoot: projectRoot, + profileName: "reviewer", + provider: "codex", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + ], + }, + null, + 2, + ) + "\n", + ); const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { cwd: process.cwd(), @@ -48,13 +81,16 @@ try { 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, /profile reviewer codex gpt-5\.4 - Read-only reviewer\./); + assert.match(output, /agt_current idle reviewer codex gpt-5\.4/); + assert.doesNotMatch(output, /profile reviewer/); + assert.doesNotMatch(output, /agt_other/); assert.equal(loadConfig({ DEVSPACE_CONFIG_DIR: configDir, diff --git a/src/cli.ts b/src/cli.ts index f9716019..efec0ec3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -285,7 +285,7 @@ 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 agent profiles and sessions", + " devspace agents ls List subagent sessions", " devspace agents run ", " devspace agents show ", " devspace -v, --version Print the installed version", @@ -325,21 +325,14 @@ async function runAgentsCommand(args: string[]): Promise { async function runAgentsList(): Promise { const config = loadConfig(); - const workspaceRoot = resolveCurrentWorkspaceRoot(); - const profiles = await loadLocalAgentProfiles(config, workspaceRoot); const store = createLocalAgentStore(config); - const agents = store.list(workspaceRoot); + const agents = store.list(resolveCurrentWorkspaceScope()); - if (profiles.length === 0 && agents.length === 0) { - console.log("No subagents configured for this workspace."); + if (agents.length === 0) { + console.log("No subagent sessions found for this workspace."); return; } - for (const profile of profiles) { - const model = profile.model ? ` ${profile.model}` : ""; - console.log(`profile ${profile.name} ${profile.provider}${model} - ${profile.description}`); - } - for (const agent of agents) { console.log(formatAgentLine(agent)); } @@ -488,6 +481,13 @@ 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" diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index 762e292c..7173e7bb 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -30,10 +30,12 @@ try { assert.equal(updated.status, "idle"); assert.equal(store.get("thread_123")?.id, created.id); assert.deepEqual( - store.list(join(root, "project")).map((agent) => agent.latestResponse), + store.list({ workspaceRoot: join(root, "project") }).map((agent) => agent.latestResponse), ["done"], ); - assert.deepEqual(store.list(join(root, "other")), []); + 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") }), []); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index d1ac7e1d..80863006 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -32,14 +32,22 @@ interface LocalAgentStoreData { agents: LocalAgentRecord[]; } +export interface LocalAgentListScope { + workspaceId?: string; + workspaceRoot?: string; +} + export class LocalAgentStore { constructor(private readonly filePath: string) {} - list(workspaceRoot?: string): LocalAgentRecord[] { + list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { const data = this.read(); - const resolvedRoot = workspaceRoot ? resolve(workspaceRoot) : undefined; + const resolvedRoot = scope.workspaceRoot ? resolve(scope.workspaceRoot) : undefined; return data.agents - .filter((agent) => !resolvedRoot || resolve(agent.workspaceRoot) === resolvedRoot) + .filter((agent) => { + if (scope.workspaceId) return agent.workspaceId === scope.workspaceId; + return !resolvedRoot || resolve(agent.workspaceRoot) === resolvedRoot; + }) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } From 096a1138f4df96f93edabb54b079ea44ba7da268 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 4 Jul 2026 02:12:33 +0530 Subject: [PATCH 11/33] test(subagents): verify response filtering hides tool events --- src/local-agent-adapters.test.ts | 62 +++++++++++++++++++++++++++++++- src/local-agent-adapters.ts | 39 ++++++++++++++++++-- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index ed75ef85..505a6dda 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { createLocalAgentAdapter } from "./local-agent-adapters.js"; +import { + createLocalAgentAdapter, + extractLocalAgentResponseText, +} from "./local-agent-adapters.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; const providers: LocalAgentProvider[] = [ @@ -16,3 +19,60 @@ for (const provider of providers) { assert.equal(adapter.provider, provider); assert.equal(typeof adapter.run, "function"); } + +assert.equal( + extractLocalAgentResponseText({ + messages: [ + { role: "assistant", content: "I will inspect the code." }, + { role: "tool", content: "rg -n secret src" }, + { role: "assistant", content: "Final review only." }, + ], + }), + "Final review only.", +); + +assert.equal( + extractLocalAgentResponseText({ + messages: [ + { type: "assistant", text: "Earlier draft." }, + { + type: "tool_call", + name: "bash", + arguments: { command: "npm test" }, + }, + { + type: "tool_result", + content: "full command output", + }, + { type: "result", result: "Final answer." }, + ], + }), + "Final answer.", +); + +assert.equal( + extractLocalAgentResponseText({ + parts: [ + { type: "text", text: "Visible final text." }, + { type: "tool_use", name: "read", input: { path: "src/foo.ts" } }, + { tool_call_id: "call_1", content: "hidden tool result" }, + ], + }), + "Visible final text.", +); + +assert.equal( + extractLocalAgentResponseText({ + type: "tool_call", + name: "bash", + arguments: { command: "cat src/secret.ts" }, + }), + "", +); + +assert.equal( + extractLocalAgentResponseText({ + unexpected: { nested: "raw provider event" }, + }), + "", +); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 60aa984e..7d55120c 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -280,6 +280,10 @@ function parseOpencodeModel(model: string): { providerID: string; modelID: strin }; } +export function extractLocalAgentResponseText(value: unknown): string { + return extractText(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."); @@ -293,13 +297,44 @@ function extractText(value: unknown): string { return value.map(extractText).filter(Boolean).join("\n").trim(); } const record = value as Record; + if (isToolLikeRecord(record)) return ""; if (typeof record.text === "string") return record.text; if (typeof record.content === "string") return record.content; if (typeof record.result === "string") return record.result; if (Array.isArray(record.parts)) return extractText(record.parts); - if (Array.isArray(record.messages)) return extractText(record.messages.at(-1)); + if (Array.isArray(record.messages)) return extractText(lastAssistantLikeMessage(record.messages)); if (Array.isArray(record.data)) return extractText(record.data); - return JSON.stringify(value); + return ""; +} + +function lastAssistantLikeMessage(messages: unknown[]): unknown { + const candidates = messages.filter((message) => { + if (!message || typeof message !== "object" || Array.isArray(message)) return true; + const record = message as Record; + if (isToolLikeRecord(record)) return false; + const role = typeof record.role === "string" ? record.role : ""; + if (role) return role === "assistant"; + const type = typeof record.type === "string" ? record.type : ""; + return !type || type === "assistant" || type === "message" || type === "result"; + }); + return candidates.at(-1); +} + +function isToolLikeRecord(record: Record): boolean { + const role = typeof record.role === "string" ? record.role.toLowerCase() : ""; + if (role === "tool" || role === "function") return true; + + const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; + if (type.includes("tool") || type.includes("function_call")) return true; + + return ( + "toolCallId" in record || + "tool_call_id" in record || + "toolCalls" in record || + "tool_calls" in record || + "functionCall" in record || + "function_call" in record + ); } function errorMessage(error: unknown): string { From ed4916a402fe27605ce38186aff92dc6e0386b79 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 4 Jul 2026 02:29:00 +0530 Subject: [PATCH 12/33] fix(subagents): align provider response extraction with harness formats --- src/local-agent-adapters.test.ts | 107 +++++++++++++++++-------- src/local-agent-adapters.ts | 130 +++++++++++++++++++++---------- 2 files changed, 161 insertions(+), 76 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 505a6dda..912ebf8e 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { createLocalAgentAdapter, - extractLocalAgentResponseText, + extractOpenCodeFinalResponse, + extractPiFinalResponse, } from "./local-agent-adapters.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; @@ -21,58 +22,98 @@ for (const provider of providers) { } assert.equal( - extractLocalAgentResponseText({ - messages: [ - { role: "assistant", content: "I will inspect the code." }, - { role: "tool", content: "rg -n secret src" }, - { role: "assistant", content: "Final review only." }, + 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 review only.", + "Final OpenCode response.", ); assert.equal( - extractLocalAgentResponseText({ + 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({ messages: [ - { type: "assistant", text: "Earlier draft." }, + { role: "user", content: "Review the change." }, { - type: "tool_call", - name: "bash", - arguments: { command: "npm test" }, + 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." }, + ], }, { - type: "tool_result", - content: "full command output", + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + content: [{ type: "text", text: "tool output" }], }, - { type: "result", result: "Final answer." }, ], }), - "Final answer.", + "Final Pi response.", ); assert.equal( - extractLocalAgentResponseText({ - parts: [ - { type: "text", text: "Visible final text." }, - { type: "tool_use", name: "read", input: { path: "src/foo.ts" } }, - { tool_call_id: "call_1", content: "hidden tool result" }, + 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" }, + ], + }, ], }), - "Visible final text.", -); - -assert.equal( - extractLocalAgentResponseText({ - type: "tool_call", - name: "bash", - arguments: { command: "cat src/secret.ts" }, - }), - "", + "first part\n\nsecond part", ); assert.equal( - extractLocalAgentResponseText({ - unexpected: { nested: "raw provider event" }, + 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 }, + ], }), "", ); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 7d55120c..634ddcd6 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -100,7 +100,7 @@ class OpencodeLocalAgentAdapter implements LocalAgentAdapter { ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), parts: [{ type: "text", text: input.prompt }], }, { throwOnError: true }); - const finalResponse = extractText(promptResult); + const finalResponse = extractOpenCodeFinalResponse(promptResult); return { provider: this.provider, providerSessionId: sessionId, @@ -188,7 +188,7 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { return { provider: this.provider, providerSessionId: input.providerSessionId ?? null, - finalResponse: extractText(messages), + finalResponse: extractPiFinalResponse(messages), items: [messages], }; } finally { @@ -240,7 +240,7 @@ class JsonLineRpc { if (!pending) continue; this.pending.delete(id); if (message.error) { - pending.reject(new Error(extractText(message.error))); + pending.reject(new Error(errorMessage(message.error))); } else { pending.resolve(message.result ?? message); } @@ -281,7 +281,7 @@ function parseOpencodeModel(model: string): { providerID: string; modelID: strin } export function extractLocalAgentResponseText(value: unknown): string { - return extractText(value); + return extractOpenCodeFinalResponse(value) || extractPiFinalResponse(value); } function assertPipedChild(child: ReturnType): asserts child is ChildProcessWithoutNullStreams { @@ -290,51 +290,95 @@ function assertPipedChild(child: ReturnType): asserts child is Chi } } -function extractText(value: unknown): string { - if (typeof value === "string") return value; - if (!value || typeof value !== "object") return String(value ?? ""); - if (Array.isArray(value)) { - return value.map(extractText).filter(Boolean).join("\n").trim(); +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; } - const record = value as Record; - if (isToolLikeRecord(record)) return ""; - if (typeof record.text === "string") return record.text; - if (typeof record.content === "string") return record.content; - if (typeof record.result === "string") return record.result; - if (Array.isArray(record.parts)) return extractText(record.parts); - if (Array.isArray(record.messages)) return extractText(lastAssistantLikeMessage(record.messages)); - if (Array.isArray(record.data)) return extractText(record.data); return ""; } -function lastAssistantLikeMessage(messages: unknown[]): unknown { - const candidates = messages.filter((message) => { - if (!message || typeof message !== "object" || Array.isArray(message)) return true; - const record = message as Record; - if (isToolLikeRecord(record)) return false; - const role = typeof record.role === "string" ? record.role : ""; - if (role) return role === "assistant"; - const type = typeof record.type === "string" ? record.type : ""; - return !type || type === "assistant" || type === "message" || type === "result"; - }); - return candidates.at(-1); +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; + if (role !== "assistant") continue; + const text = extractOpenCodeAssistantMessageText(message); + if (text) return text; + } + return ""; +} + +function extractOpenCodeAssistantMessageText(value: unknown): string { + const message = asRecord(value); + if (!message) return ""; + + 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 isToolLikeRecord(record: Record): boolean { - const role = typeof record.role === "string" ? record.role.toLowerCase() : ""; - if (role === "tool" || role === "function") return true; - - const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; - if (type.includes("tool") || type.includes("function_call")) return true; - - return ( - "toolCallId" in record || - "tool_call_id" in record || - "toolCalls" in record || - "tool_calls" in record || - "functionCall" in record || - "function_call" in record - ); +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; } function errorMessage(error: unknown): string { From 3e8081ce51accd1bd34d54928708ab761f3f40a0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 03:31:43 +0530 Subject: [PATCH 13/33] fix(subagents): clear stale response on follow-up --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index efec0ec3..370f5661 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -352,7 +352,7 @@ async function runAgentsRun(args: string[]): Promise { const promptFile = writeAgentPromptFile(prompt); if (existing) { - store.update(existing.id, { status: "starting", error: undefined }); + store.update(existing.id, { status: "starting", latestResponse: undefined, error: undefined }); spawnAgentWorker(existing.id, promptFile); console.log(formatAgentLine({ ...existing, status: "running" })); return; From 2c95b5fdd4be0c96439644965c46b666cc1ab35f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 03:32:28 +0530 Subject: [PATCH 14/33] fix(subagents): skip invalid profile files --- src/local-agent-profiles.test.ts | 6 ++---- src/local-agent-profiles.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index b355c13b..2d62ee53 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -90,10 +90,8 @@ try { "", ].join("\n"), ); - await assert.rejects( - () => loadLocalAgentProfiles(enabledConfig, workspaceRoot), - /provider must be codex, claude, opencode, pi, cursor, or copilot/, - ); + const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); + assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); const disabledConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir, diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 4a3ae687..bd72b582 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -83,7 +83,11 @@ async function loadProfilesFromDirectory(directory: string): Promise, key: string): string | const trimmed = value.trim(); return trimmed || undefined; } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} From d73307c26dc80f6611927ed79aa629c98c82bfa1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 03:33:43 +0530 Subject: [PATCH 15/33] test(subagents): isolate workspace profile fixtures --- src/workspaces.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index f283aeda..87f6b35a 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -36,6 +36,7 @@ try { await writeFile(join(root, "nested", "file.txt"), "hello\n"); const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), DEVSPACE_AGENT_DIR: agentDir, From 80ee4684b36f8ce7190ef89ec45c77e1469fe6cc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 03:34:31 +0530 Subject: [PATCH 16/33] fix(subagents): parse profile frontmatter with yaml --- package-lock.json | 16 +++++++++ package.json | 1 + src/local-agent-profiles.test.ts | 6 ++-- src/local-agent-profiles.ts | 62 +++++++------------------------- 4 files changed, 32 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index b6bf551f..7ad54ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { @@ -6144,6 +6145,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 210617f5..b13b5a73 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 2d62ee53..4bb0bea0 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -32,7 +32,7 @@ try { [ "---", "name: reviewer", - "description: Project reviewer.", + 'description: "Project reviewer #1."', "provider: claude", "model: sonnet", "---", @@ -66,13 +66,13 @@ try { assert.equal(profiles.length, 1); assert.equal(profiles[0]?.name, "reviewer"); - assert.equal(profiles[0]?.description, "Project reviewer."); + assert.equal(profiles[0]?.description, "Project reviewer #1."); assert.equal(profiles[0]?.provider, "claude"); assert.equal(profiles[0]?.model, "sonnet"); assert.equal(profiles[0]?.body, "Project body."); assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { name: "reviewer", - description: "Project reviewer.", + description: "Project reviewer #1.", provider: "claude", model: "sonnet", }); diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index bd72b582..2bf9bb7c 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -1,6 +1,7 @@ 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"; @@ -114,63 +115,24 @@ function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter } return { - frontmatter: parseSimpleYaml(lines.slice(1, endIndex), filePath), + frontmatter: parseProfileYaml(lines.slice(1, endIndex).join("\n"), filePath), body: lines.slice(endIndex + 1).join("\n").trim(), }; } -function parseSimpleYaml(lines: string[], filePath: string): Record { - const result: Record = {}; - let currentMapKey: string | undefined; - - for (const rawLine of lines) { - const line = rawLine.replace(/\s+#.*$/, ""); - if (!line.trim()) continue; - - const nestedMatch = /^ ([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line); - if (nestedMatch) { - if (!currentMapKey) { - throw new Error(`Unexpected nested frontmatter field in ${filePath}: ${rawLine}`); - } - const current = result[currentMapKey]; - if (!current || typeof current !== "object" || Array.isArray(current)) { - throw new Error(`Invalid nested frontmatter field in ${filePath}: ${rawLine}`); - } - (current as Record)[nestedMatch[1] ?? ""] = parseScalar(nestedMatch[2] ?? ""); - continue; - } - - const topLevelMatch = /^([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line); - if (!topLevelMatch) { - throw new Error(`Unsupported frontmatter line in ${filePath}: ${rawLine}`); - } - - const key = topLevelMatch[1] ?? ""; - const value = topLevelMatch[2] ?? ""; - if (value === "") { - result[key] = {}; - currentMapKey = key; - continue; - } - - result[key] = parseScalar(value); - currentMapKey = undefined; +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)}`); } - return result; -} - -function parseScalar(value: string): unknown { - const trimmed = value.trim(); - if (trimmed === "true") return true; - if (trimmed === "false") return false; - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Subagent profile frontmatter must be a mapping: ${filePath}`); } - return trimmed; + + return parsed as Record; } function profileFromFrontmatter( From 7827f63e1b6bba0360602e147a60b2ae32a158ad Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 03:37:01 +0530 Subject: [PATCH 17/33] refactor(subagents): persist sessions in sqlite --- src/cli.test.ts | 56 ++++---- src/db/migrations.ts | 33 +++++ src/db/schema.ts | 25 ++++ src/local-agent-store.test.ts | 24 +++- src/local-agent-store.ts | 234 +++++++++++++++++++++++----------- src/oauth-store.test.ts | 1 + 6 files changed, 266 insertions(+), 107 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 12949c28..6b636e1d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; +import { LocalAgentStore } from "./local-agent-store.js"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -40,38 +41,27 @@ try { "", ].join("\n"), ); - writeFileSync( - join(stateDir, "local-agents.json"), - JSON.stringify( - { - agents: [ - { - id: "agt_current", - workspaceId: "ws_current", - workspaceRoot: projectRoot, - profileName: "reviewer", - provider: "codex", - model: "gpt-5.4", - status: "idle", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - { - id: "agt_other", - workspaceId: "ws_other", - workspaceRoot: projectRoot, - profileName: "reviewer", - provider: "codex", - status: "running", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:01.000Z", - }, - ], - }, - null, - 2, - ) + "\n", + const store = new LocalAgentStore(stateDir); + const current = store.update( + store.create({ + workspaceId: "ws_current", + workspaceRoot: projectRoot, + profileName: "reviewer", + provider: "codex", + model: "gpt-5.4", + }).id, + { status: "idle" }, + ); + const other = store.update( + store.create({ + workspaceId: "ws_other", + workspaceRoot: projectRoot, + profileName: "reviewer", + provider: "codex", + }).id, + { status: "running" }, ); + store.close(); const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { cwd: process.cwd(), @@ -88,9 +78,9 @@ try { }, }); - assert.match(output, /agt_current idle reviewer codex gpt-5\.4/); + assert.match(output, new RegExp(`${current.id} idle reviewer codex gpt-5\\.4`)); assert.doesNotMatch(output, /profile reviewer/); - assert.doesNotMatch(output, /agt_other/); + assert.doesNotMatch(output, new RegExp(other.id)); assert.equal(loadConfig({ DEVSPACE_CONFIG_DIR: configDir, diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1ce1e1c7..796f72fc 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -17,6 +17,11 @@ const migrations: Migration[] = [ name: "oauth-state", up: migrateOAuthState, }, + { + version: 3, + name: "local-agent-sessions", + up: migrateLocalAgentSessions, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -138,6 +143,34 @@ function migrateOAuthState(sqlite: Database.Database): void { `); } +function migrateLocalAgentSessions(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists local_agent_sessions ( + id text primary key, + workspace_id text, + workspace_root text not null, + profile_name text not null, + provider text not null, + model text, + provider_session_id text, + status text not null, + latest_response text, + error text, + created_at text not null, + updated_at text not null + ); + + create index if not exists local_agent_sessions_workspace_id_idx + on local_agent_sessions(workspace_id, updated_at desc); + + create index if not exists local_agent_sessions_workspace_root_idx + on local_agent_sessions(workspace_root, updated_at desc); + + create index if not exists local_agent_sessions_provider_session_id_idx + on local_agent_sessions(provider_session_id); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 94b3862b..6ade5459 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -73,7 +73,32 @@ export const oauthRefreshTokens = sqliteTable( }, ); +export const localAgentSessions = sqliteTable( + "local_agent_sessions", + { + id: text("id").primaryKey(), + workspaceId: text("workspace_id"), + workspaceRoot: text("workspace_root").notNull(), + profileName: text("profile_name").notNull(), + provider: text("provider").notNull(), + model: text("model"), + providerSessionId: text("provider_session_id"), + status: text("status").notNull(), + latestResponse: text("latest_response"), + error: text("error"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + }, + (table) => [ + index("local_agent_sessions_workspace_id_idx").on(table.workspaceId, table.updatedAt), + index("local_agent_sessions_workspace_root_idx").on(table.workspaceRoot, table.updatedAt), + index("local_agent_sessions_provider_session_id_idx").on(table.providerSessionId), + ], +); + export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; +export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index 7173e7bb..caf3c521 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -5,9 +5,11 @@ 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(join(root, "agents.json")); + const store = new LocalAgentStore(root); + stores.push(store); const created = store.create({ workspaceId: "ws_1", workspaceRoot: join(root, "project"), @@ -29,13 +31,31 @@ try { assert.equal(updated.status, "idle"); assert.equal(store.get("thread_123")?.id, created.id); + assert.equal(store.update(created.id, { latestResponse: undefined }).latestResponse, undefined); assert.deepEqual( store.list({ workspaceRoot: join(root, "project") }).map((agent) => agent.latestResponse), - ["done"], + [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 index 80863006..a2629856 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +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"; @@ -28,27 +28,58 @@ export interface CreateLocalAgentRecordInput { model?: string; } -interface LocalAgentStoreData { - agents: LocalAgentRecord[]; -} - export interface LocalAgentListScope { workspaceId?: string; workspaceRoot?: string; } +interface LocalAgentRow { + id: string; + workspace_id: string | null; + workspace_root: string; + profile_name: string; + provider: string; + model: string | null; + provider_session_id: string | null; + status: string; + latest_response: string | null; + error: string | null; + created_at: string; + updated_at: string; +} + export class LocalAgentStore { - constructor(private readonly filePath: string) {} + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + } list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { - const data = this.read(); - const resolvedRoot = scope.workspaceRoot ? resolve(scope.workspaceRoot) : undefined; - return data.agents - .filter((agent) => { - if (scope.workspaceId) return agent.workspaceId === scope.workspaceId; - return !resolvedRoot || resolve(agent.workspaceRoot) === resolvedRoot; - }) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + 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 { @@ -65,85 +96,144 @@ export class LocalAgentStore { updatedAt: now, }; - this.write((data) => ({ agents: [...data.agents, record] })); + this.database.sqlite + .prepare( + `insert into local_agent_sessions ( + id, + workspace_id, + workspace_root, + profile_name, + provider, + model, + status, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + record.id, + record.workspaceId ?? null, + record.workspaceRoot, + record.profileName, + record.provider, + record.model ?? null, + record.status, + record.createdAt, + record.updatedAt, + ); + return record; } get(idOrPrefix: string): LocalAgentRecord | undefined { - const agents = this.read().agents; - return resolveRecord(idOrPrefix, agents); + 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 { - let updated: LocalAgentRecord | undefined; - this.write((data) => ({ - agents: data.agents.map((agent) => { - if (agent.id !== id) return agent; - updated = { - ...agent, - ...patch, - updatedAt: new Date().toISOString(), - }; - return updated; - }), - })); - - if (!updated) throw new Error(`Unknown subagent id: ${id}`); + const current = this.getById(id); + if (!current) throw new Error(`Unknown subagent id: ${id}`); + + const updated: LocalAgentRecord = { + ...current, + ...patch, + updatedAt: new Date().toISOString(), + }; + + this.database.sqlite + .prepare( + `update local_agent_sessions set + workspace_id = ?, + workspace_root = ?, + profile_name = ?, + provider = ?, + model = ?, + provider_session_id = ?, + status = ?, + latest_response = ?, + error = ?, + updated_at = ? + where id = ?`, + ) + .run( + updated.workspaceId ?? null, + resolve(updated.workspaceRoot), + updated.profileName, + updated.provider, + updated.model ?? null, + updated.providerSessionId ?? null, + updated.status, + updated.latestResponse ?? null, + updated.error ?? null, + updated.updatedAt, + updated.id, + ); + return updated; } - private read(): LocalAgentStoreData { - if (!existsSync(this.filePath)) return { agents: [] }; - return normalizeStoreData(JSON.parse(readFileSync(this.filePath, "utf8"))); + close(): void { + this.database.close(); } - private write(update: (data: LocalAgentStoreData) => LocalAgentStoreData): void { - const next = update(this.read()); - mkdirSync(dirname(this.filePath), { recursive: true }); - const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 }); - renameSync(tempPath, this.filePath); + 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(join(config.stateDir, "local-agents.json")); + return new LocalAgentStore(config.stateDir); } -function normalizeStoreData(value: unknown): LocalAgentStoreData { - if (!value || typeof value !== "object" || Array.isArray(value)) return { agents: [] }; - const agents = (value as { agents?: unknown }).agents; - if (!Array.isArray(agents)) return { agents: [] }; +function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { return { - agents: agents.filter(isLocalAgentRecord), + id: row.id, + workspaceId: row.workspace_id ?? undefined, + workspaceRoot: row.workspace_root, + profileName: row.profile_name, + provider: row.provider, + model: row.model ?? undefined, + providerSessionId: row.provider_session_id ?? undefined, + status: readStatus(row.status), + latestResponse: row.latest_response ?? undefined, + error: row.error ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, }; } -function isLocalAgentRecord(value: unknown): value is LocalAgentRecord { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const record = value as Partial; - return ( - typeof record.id === "string" && - typeof record.workspaceRoot === "string" && - typeof record.profileName === "string" && - typeof record.provider === "string" && - typeof record.status === "string" && - typeof record.createdAt === "string" && - typeof record.updatedAt === "string" - ); +function readStatus(status: string): LocalAgentStatus { + if ( + status === "starting" || + status === "running" || + status === "idle" || + status === "error" || + status === "stopped" + ) { + return status; + } + return "error"; } -function resolveRecord( - idOrPrefix: string, - agents: LocalAgentRecord[], -): LocalAgentRecord | undefined { - const exact = agents.find((agent) => agent.id === idOrPrefix || agent.providerSessionId === idOrPrefix); - if (exact) return exact; - - const matches = agents.filter( - (agent) => - agent.id.startsWith(idOrPrefix) || - (agent.providerSessionId?.startsWith(idOrPrefix) ?? false), - ); - return matches.length === 1 ? matches[0] : undefined; +function escapeLike(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); } diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 2f2a873c..e1c00338 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -43,6 +43,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { assert.deepEqual(migrations, [ { version: 1, name: "workspace-state" }, { version: 2, name: "oauth-state" }, + { version: 3, name: "local-agent-sessions" }, ]); } finally { database.close(); From 65e575704ffe8cfa41fb19d4a7902ef93cbe76c8 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 04:54:57 +0530 Subject: [PATCH 18/33] fix(subagents): harden provider response handling --- src/local-agent-adapters.test.ts | 58 ++++++++---- src/local-agent-adapters.ts | 158 ++++++++++++++++++++++++++----- 2 files changed, 173 insertions(+), 43 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 912ebf8e..273fc328 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -41,6 +41,28 @@ assert.equal( "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: { @@ -70,23 +92,25 @@ assert.equal( assert.equal( extractPiFinalResponse({ - 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" }], - }, - ], + 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.", ); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 634ddcd6..d9dcb524 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -16,6 +16,7 @@ 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, @@ -94,18 +95,18 @@ class OpencodeLocalAgentAdapter implements LocalAgentAdapter { const { client, server } = await createOpencode(); try { const sessionId = input.providerSessionId ?? await createOpencodeSession(client, input); - const promptResult = await client.session.prompt({ - sessionID: sessionId, - directory: input.workspace, - ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), - parts: [{ type: "text", text: input.prompt }], - }, { throwOnError: true }); - const finalResponse = extractOpenCodeFinalResponse(promptResult); + 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], + items: [promptResult, messages], }; } finally { server.close(); @@ -169,7 +170,10 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "pi" as const; async run(input: LocalAgentRunInput): Promise { - const child = spawn(process.env.PI_COMMAND ?? "pi", ["--mode", "rpc"], { + const args = ["--mode", "rpc"]; + if (input.model) args.push("--model", input.model); + if (input.providerSessionId) args.push("--session", input.providerSessionId); + const child = spawn(process.env.PI_COMMAND ?? "pi", args, { cwd: input.workspace, env: process.env, stdio: ["pipe", "pipe", "pipe"], @@ -177,19 +181,21 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { }); assertPipedChild(child); const rpc = new JsonLineRpc(child); + const events: unknown[] = []; + rpc.onEvent((event) => events.push(event)); try { - await rpc.request({ - type: "prompt", - message: input.prompt, - ...(input.model ? { model: input.model } : {}), - ...(input.providerSessionId ? { session: input.providerSessionId } : {}), - }); - const messages = await rpc.request({ type: "get_messages" }); + 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 messages = readArray(agentEnd, "messages") ? agentEnd : await rpc.request({ type: "get_messages" }); + const finalResponse = requireFinalResponse("Pi", extractPiFinalResponse(messages)); return { provider: this.provider, - providerSessionId: input.providerSessionId ?? null, - finalResponse: extractPiFinalResponse(messages), - items: [messages], + providerSessionId, + finalResponse, + items: [...events, messages], }; } finally { child.kill(); @@ -202,6 +208,7 @@ class JsonLineRpc { resolve: (value: unknown) => void; reject: (error: Error) => void; }>(); + private readonly eventSubscribers = new Set<(event: unknown) => void>(); private buffer = ""; private nextId = 1; private stderr = ""; @@ -225,6 +232,26 @@ class JsonLineRpc { }); } + 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 (;;) { @@ -234,15 +261,20 @@ class JsonLineRpc { this.buffer = this.buffer.slice(newline + 1); if (!line) continue; const message = JSON.parse(line) as Record; + 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.error) { - pending.reject(new Error(errorMessage(message.error))); + if (message.success === false || message.error) { + pending.reject(new Error(errorMessage(message.error ?? `Pi RPC request failed: ${message.command ?? id}`))); } else { - pending.resolve(message.result ?? message); + pending.resolve(message.data ?? message.result ?? message); } } } @@ -256,21 +288,65 @@ class JsonLineRpc { } async function createOpencodeSession(client: unknown, input: LocalAgentRunInput): Promise { - const result = await (client as { + const sessionClient = client as { session: { create(parameters?: unknown, options?: unknown): Promise; }; - }).session.create({ + }; + const result = await sessionClient.session.create({ directory: input.workspace, + location: { directory: input.workspace }, ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), }, { throwOnError: true }); - const id = (result as { id?: unknown }).id; + const id = + readNestedString(result, ["id"]) ?? + readNestedString(result, ["data", "id"]) ?? + readNestedString(result, ["session", "id"]) ?? + readNestedString(result, ["data", "session", "id"]); if (typeof id !== "string") { throw new Error("OpenCode did not return a session id."); } return id; } +async function promptOpencodeSession( + client: unknown, + sessionId: string, + input: LocalAgentRunInput, +): Promise { + const session = (client as { + session: { + prompt(parameters?: unknown, options?: unknown): Promise; + }; + }).session; + const promptInput = { + sessionID: sessionId, + directory: input.workspace, + prompt: { parts: [{ type: "text", text: input.prompt }] }, + parts: [{ type: "text", text: input.prompt }], + ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), + }; + return session.prompt(promptInput, { throwOnError: true }); +} + +async function waitForOpencodeSession(client: unknown, sessionId: string): Promise { + const session = (client as { + session?: { wait?: (parameters?: unknown, options?: unknown) => Promise }; + }).session; + if (!session?.wait) return; + await session.wait({ sessionID: sessionId }, { throwOnError: true }); +} + +async function readOpencodeMessages(client: unknown, sessionId: string): Promise { + const session = (client as { + session?: { + messages?: (parameters?: unknown, options?: unknown) => Promise; + }; + }).session; + if (!session?.messages) return undefined; + return session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true }); +} + function parseOpencodeModel(model: string): { providerID: string; modelID: string } { const separator = model.indexOf("/"); if (separator === -1) return { providerID: "opencode", modelID: model }; @@ -317,7 +393,8 @@ function extractLastOpenCodeAssistantMessageText(messages: unknown[]): string { if (!message) continue; const info = asRecord(message.info); const role = typeof info?.role === "string" ? info.role : message.role; - if (role !== "assistant") continue; + const type = typeof message.type === "string" ? message.type : undefined; + if (role !== "assistant" && type !== "assistant") continue; const text = extractOpenCodeAssistantMessageText(message); if (text) return text; } @@ -328,6 +405,19 @@ 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 @@ -381,6 +471,22 @@ function asRecord(value: unknown): Record | 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; +} From 9cb917b7c090632995faa2ec50e09fd12b936808 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 05:02:43 +0530 Subject: [PATCH 19/33] fix(subagents): fallback to pi session history --- src/local-agent-adapters.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index d9dcb524..0019f1c3 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -189,13 +189,16 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { 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 messages = readArray(agentEnd, "messages") ? agentEnd : await rpc.request({ type: "get_messages" }); - const finalResponse = requireFinalResponse("Pi", extractPiFinalResponse(messages)); + const sessionMessages = await rpc.request({ type: "get_messages" }); + const finalResponse = requireFinalResponse( + "Pi", + extractPiFinalResponse(agentEnd) || extractPiFinalResponse(sessionMessages), + ); return { provider: this.provider, providerSessionId, finalResponse, - items: [...events, messages], + items: [...events, sessionMessages], }; } finally { child.kill(); From 2960da7f7a02ecac9b38bab914c77baabac3a4fb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 05:29:34 +0530 Subject: [PATCH 20/33] fix(subagents): recover pi final text from stream --- src/local-agent-adapters.test.ts | 22 ++++++++++++++++++++++ src/local-agent-adapters.ts | 18 +++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 273fc328..af4b347c 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -3,6 +3,7 @@ import { createLocalAgentAdapter, extractOpenCodeFinalResponse, extractPiFinalResponse, + extractPiStreamingText, } from "./local-agent-adapters.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; @@ -141,3 +142,24 @@ assert.equal( }), "", ); + +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.", +); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 0019f1c3..aaed25cc 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -192,7 +192,9 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { const sessionMessages = await rpc.request({ type: "get_messages" }); const finalResponse = requireFinalResponse( "Pi", - extractPiFinalResponse(agentEnd) || extractPiFinalResponse(sessionMessages), + extractPiFinalResponse(agentEnd) || + extractPiFinalResponse(sessionMessages) || + extractPiStreamingText(events), ); return { provider: this.provider, @@ -390,6 +392,20 @@ export function extractPiFinalResponse(value: unknown): string { 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(); +} + function extractLastOpenCodeAssistantMessageText(messages: unknown[]): string { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = asRecord(messages[index]); From b8e72ee2678da1520eea8b4f519c9ef66c23194f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 05:38:22 +0530 Subject: [PATCH 21/33] fix(subagents): preserve node loader for workers --- src/cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli.ts b/src/cli.ts index 370f5661..ee2da369 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -456,6 +456,7 @@ async function runLocalAgentProfile( function spawnAgentWorker(agentId: string, promptFile: string): void { const child = spawn(process.execPath, [ + ...process.execArgv, fileURLToPath(import.meta.url), "agents", "__worker", From 1c9beda27cceda186fb39efb27d4b585e402fbed Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 16:17:59 +0530 Subject: [PATCH 22/33] fix(subagents): update pi rpc dependency --- package-lock.json | 28 ++++++++++++------------- package.json | 2 +- src/local-agent-adapters.test.ts | 16 ++++++++++++++ src/local-agent-adapters.ts | 36 +++++++++++++++++++++++++++----- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7ad54ce0..19172549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@agentclientprotocol/sdk": "^1.1.0", "@anthropic-ai/claude-agent-sdk": "^0.3.200", "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.80.1", + "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", "@openai/codex-sdk": "^0.142.5", @@ -248,15 +248,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.1.tgz", - "integrity": "sha512-tlFNPF5cbQsVOASvgxNmC19+CkncR4DZwa8e+uNf74D0Y2Pruqgqvh8biFzHcwQVqVqagFRQ7QM1yhFX1z+lEw==", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.3.tgz", + "integrity": "sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==", "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.1", - "@earendil-works/pi-ai": "^0.80.1", - "@earendil-works/pi-tui": "^0.80.1", + "@earendil-works/pi-agent-core": "^0.80.3", + "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-tui": "^0.80.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -719,11 +719,11 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.1.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.1", + "@earendil-works/pi-ai": "^0.80.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -733,8 +733,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.1.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -757,8 +757,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.1.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/package.json b/package.json index b13b5a73..e89bd714 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@agentclientprotocol/sdk": "^1.1.0", "@anthropic-ai/claude-agent-sdk": "^0.3.200", "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.80.1", + "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", "@openai/codex-sdk": "^0.142.5", diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index af4b347c..9e2f4dd8 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -3,6 +3,7 @@ import { createLocalAgentAdapter, extractOpenCodeFinalResponse, extractPiFinalResponse, + extractPiProviderError, extractPiStreamingText, } from "./local-agent-adapters.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; @@ -143,6 +144,21 @@ assert.equal( "", ); +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([ { diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index aaed25cc..5fbd5b81 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -190,12 +190,18 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { await rpc.request({ type: "prompt", message: input.prompt }); const agentEnd = await done; const sessionMessages = await rpc.request({ type: "get_messages" }); - const finalResponse = requireFinalResponse( - "Pi", + const finalResponse = extractPiFinalResponse(agentEnd) || - extractPiFinalResponse(sessionMessages) || - extractPiStreamingText(events), - ); + 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, @@ -406,6 +412,26 @@ export function extractPiStreamingText(events: unknown[]): string { .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]); From 86640bce040289e3bbb1854fce8fd1abf992c0c1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 16:28:50 +0530 Subject: [PATCH 23/33] fix(subagents): prefer user pi binary --- src/local-agent-adapters.test.ts | 21 +++++++++++++++++++ src/local-agent-adapters.ts | 35 +++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 9e2f4dd8..25b381fd 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -5,6 +5,7 @@ import { extractPiFinalResponse, extractPiProviderError, extractPiStreamingText, + piCommandEnvironment, } from "./local-agent-adapters.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; @@ -179,3 +180,23 @@ assert.equal( ]), "Final Pi response.", ); + +{ + const devspaceBin = `${process.cwd()}/node_modules/.bin`; + const userBin = "/home/user/.local/bin"; + const env = piCommandEnvironment({ + PATH: [devspaceBin, userBin].join(":"), + }); + + 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(":"), + }); + + assert.equal(env.PATH, `${devspaceBin}:/home/user/.local/bin`); +} diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 5fbd5b81..c6a75574 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,4 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { delimiter, resolve, sep } from "node:path"; import { Readable, Writable } from "node:stream"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { @@ -175,7 +177,7 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { if (input.providerSessionId) args.push("--session", input.providerSessionId); const child = spawn(process.env.PI_COMMAND ?? "pi", args, { cwd: input.workspace, - env: process.env, + env: piCommandEnvironment(process.env), stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); @@ -214,6 +216,37 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { } } +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: path + .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; + } +} + class JsonLineRpc { private readonly pending = new Map void; From ca93955ae58f9b29debe2795639031229ee4197f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 17:01:53 +0530 Subject: [PATCH 24/33] fix(subagents): handle acp provider turns --- src/local-agent-adapters.ts | 49 ++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index c6a75574..296248c5 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -15,7 +15,7 @@ export interface LocalAgentAdapter { } const ACP_COMMANDS: Record<"cursor" | "copilot", [string, ...string[]]> = { - cursor: ["cursor-agent", "--acp"], + cursor: ["cursor-agent", "acp"], copilot: ["copilot", "--acp"], }; const PI_AGENT_TIMEOUT_MS = 120_000; @@ -124,6 +124,7 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { 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, { @@ -144,16 +145,35 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { ); try { let providerSessionId = input.providerSessionId ?? null; - const finalResponse = await client({ name: "DevSpace" }).connectWith(stream, async (context) => { - const session = await context.buildSession(input.workspace).start(); - providerSessionId = session.sessionId; - try { - await session.prompt(input.prompt); - return await session.readText(); - } finally { - session.dispose(); - } - }); + const finalResponse = await client({ name: "DevSpace" }) + .onRequest(methods.client.session.requestPermission, (context) => { + const selected = selectAcpAllowPermissionOption(context.params.options); + return selected + ? { outcome: { outcome: "selected", optionId: selected.optionId } } + : { outcome: { outcome: "cancelled" } }; + }) + .connectWith(stream, async (context) => { + const session = await context.buildSession(input.workspace).start(); + providerSessionId = session.sessionId; + try { + const prompt = session.prompt(input.prompt); + const textParts: string[] = []; + for (;;) { + const message = await session.nextUpdate(); + if (message.kind === "stop") { + await prompt; + return textParts.join("").trim(); + } + + const update = message.update; + if (update.sessionUpdate !== "agent_message_chunk") continue; + const content = update.content; + if (content.type === "text") textParts.push(content.text); + } + } finally { + session.dispose(); + } + }); return { provider: this.provider, providerSessionId, @@ -168,6 +188,13 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { } } +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; From b956e74d3f242d6348e2665af50aabdc367aa9a7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 17:20:31 +0530 Subject: [PATCH 25/33] fix(subagents): align claude sdk launch --- src/local-agent-adapters.test.ts | 17 ++++++++++++++++ src/local-agent-adapters.ts | 33 +++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 25b381fd..fda1e7bb 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { + claudeCommandEnvironment, createLocalAgentAdapter, extractOpenCodeFinalResponse, extractPiFinalResponse, @@ -24,6 +25,22 @@ for (const provider of providers) { assert.equal(typeof adapter.run, "function"); } +{ + const env = claudeCommandEnvironment({ + CLAUDECODE: "1", + CLAUDE_CODE_ENTRYPOINT: "cli", + CLAUDE_CODE_SSE_PORT: "1234", + CLAUDE_AGENT_SDK_VERSION: "test", + PATH: "/usr/bin", + }); + + assert.equal(env.CLAUDECODE, undefined); + assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined); + assert.equal(env.CLAUDE_CODE_SSE_PORT, undefined); + assert.equal(env.CLAUDE_AGENT_SDK_VERSION, undefined); + assert.equal(env.PATH, "/usr/bin"); +} + assert.equal( extractOpenCodeFinalResponse({ data: [ diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 296248c5..1afb92fb 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { delimiter, resolve, sep } from "node:path"; import { Readable, Writable } from "node:stream"; @@ -57,14 +57,17 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { async run(input: LocalAgentRunInput): Promise { const { query } = await import("@anthropic-ai/claude-agent-sdk"); + const claudeExecutable = process.env.CLAUDE_COMMAND ?? resolveExecutable("claude"); const messages = query({ prompt: input.prompt, options: { cwd: input.workspace, model: input.model, resume: input.providerSessionId, - permissionMode: "plan", - maxTurns: 1, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + env: claudeCommandEnvironment(process.env), + ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}), }, }); @@ -89,6 +92,30 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { } } +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; From e9cb5d1bbc943e9aa84fecd3c97202b2c71f7b27 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 17:24:24 +0530 Subject: [PATCH 26/33] test(subagents): use platform path delimiter --- src/local-agent-adapters.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index fda1e7bb..187fed22 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { delimiter } from "node:path"; import { claudeCommandEnvironment, createLocalAgentAdapter, @@ -202,7 +203,7 @@ assert.equal( const devspaceBin = `${process.cwd()}/node_modules/.bin`; const userBin = "/home/user/.local/bin"; const env = piCommandEnvironment({ - PATH: [devspaceBin, userBin].join(":"), + PATH: [devspaceBin, userBin].join(delimiter), }); assert.equal(env.PATH, userBin); @@ -212,8 +213,8 @@ assert.equal( const devspaceBin = `${process.cwd()}/node_modules/.bin`; const env = piCommandEnvironment({ PI_COMMAND: "/custom/pi", - PATH: [devspaceBin, "/home/user/.local/bin"].join(":"), + PATH: [devspaceBin, "/home/user/.local/bin"].join(delimiter), }); - assert.equal(env.PATH, `${devspaceBin}:/home/user/.local/bin`); + assert.equal(env.PATH, [devspaceBin, "/home/user/.local/bin"].join(delimiter)); } From 9a19349f282e5e7460ca762363573c60c288b3df Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 17:39:22 +0530 Subject: [PATCH 27/33] feat(subagents): run built-in providers directly --- package.json | 2 +- src/cli.ts | 73 ++++++++++++++++-------- src/local-agent-profiles.ts | 22 +++++--- src/local-agent-targets.test.ts | 89 ++++++++++++++++++++++++++++++ src/local-agent-targets.ts | 98 +++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 src/local-agent-targets.test.ts create mode 100644 src/local-agent-targets.ts diff --git a/package.json b/package.json index e89bd714..c2db42d5 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index ee2da369..d9fbf8f6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,16 @@ 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 { loadLocalAgentProfiles, type LocalAgentProfile } from "./local-agent-profiles.js"; +import { + isLocalAgentProvider, + loadLocalAgentProfiles, + type LocalAgentProfile, +} from "./local-agent-profiles.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 { @@ -286,7 +295,7 @@ function printHelp(): void { " devspace config get Print persisted config", " devspace config set publicBaseUrl ", " devspace agents ls List subagent sessions", - " devspace agents run ", + " devspace agents run [--model ] ", " devspace agents show ", " devspace -v, --version Print the installed version", "", @@ -339,37 +348,40 @@ async function runAgentsList(): Promise { } async function runAgentsRun(args: string[]): Promise { - const [target, ...promptParts] = args; - const prompt = promptParts.join(" ").trim(); - if (!target || !prompt) { - throw new Error('Usage: devspace agents run ""'); - } + const parsed = parseLocalAgentRunArgs(args); const config = loadConfig(); const workspaceRoot = resolveCurrentWorkspaceRoot(); const store = createLocalAgentStore(config); - const existing = store.get(target); - const promptFile = writeAgentPromptFile(prompt); + const existing = store.get(parsed.target); + const promptFile = writeAgentPromptFile(parsed.prompt); if (existing) { - store.update(existing.id, { status: "starting", latestResponse: undefined, error: undefined }); + store.update(existing.id, { + status: "starting", + model: parsed.model ?? existing.model, + latestResponse: undefined, + error: undefined, + }); spawnAgentWorker(existing.id, promptFile); - console.log(formatAgentLine({ ...existing, status: "running" })); + console.log(formatAgentLine({ ...existing, status: "running", model: parsed.model ?? existing.model })); return; } const profiles = await loadLocalAgentProfiles(config, workspaceRoot); - const profile = profiles.find((candidate) => candidate.name === target); - if (!profile) { - throw new Error(`Unknown subagent profile or id: ${target}`); + const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model); + if (!target) { + throw new Error( + `Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`, + ); } const record = store.create({ workspaceId: process.env.DEVSPACE_WORKSPACE_ID, workspaceRoot, - profileName: profile.name, - provider: profile.provider, - model: profile.model, + profileName: target.name, + provider: target.provider, + model: target.model, }); spawnAgentWorker(record.id, promptFile); @@ -420,10 +432,10 @@ async function runAgentsWorker(args: string[]): Promise { try { const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot); const profile = profiles.find((candidate) => candidate.name === record.profileName); - if (!profile) throw new Error(`Subagent profile not found: ${record.profileName}`); - const prompt = await readFile(promptFile, "utf8"); - const result = await runLocalAgentProfile(profile, record, prompt); + const result = profile + ? await runLocalAgentProfile(profile, record, prompt) + : await runRawLocalAgentProvider(record, prompt); store.update(record.id, { providerSessionId: result.providerSessionId ?? undefined, status: "idle", @@ -450,7 +462,24 @@ async function runLocalAgentProfile( workspace: record.workspaceRoot, providerSessionId: record.providerSessionId, writeMode: "allowed", - model: profile.model, + model: record.model ?? profile.model, + }); +} + +async function runRawLocalAgentProvider( + record: LocalAgentRecord, + prompt: string, +): Promise { + if (record.profileName !== record.provider || !isLocalAgentProvider(record.provider)) { + throw new Error(`Subagent profile not found: ${record.profileName}`); + } + + return runLocalAgentProvider(record.provider, { + prompt, + workspace: record.workspaceRoot, + providerSessionId: record.providerSessionId, + writeMode: "allowed", + model: record.model, }); } @@ -508,7 +537,7 @@ function printAgentsHelp(): void { "", "Usage:", " devspace agents ls", - " devspace agents run ", + " devspace agents run [--model ] ", " devspace agents show ", ].join("\n"), ); diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 2bf9bb7c..3c0f7123 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -6,6 +6,15 @@ 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; @@ -29,14 +38,7 @@ interface ParsedFrontmatter { } const FRONTMATTER_DELIMITER = "---"; -const PROVIDERS = new Set([ - "codex", - "claude", - "opencode", - "pi", - "cursor", - "copilot", -]); +const PROVIDERS = new Set(LOCAL_AGENT_PROVIDERS); export async function loadLocalAgentProfiles( config: ServerConfig, @@ -171,6 +173,10 @@ function readProvider(frontmatter: Record, filePath: string): L 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; diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts new file mode 100644 index 00000000..7dac5cdc --- /dev/null +++ b/src/local-agent-targets.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { + formatAvailableLocalAgentTargets, + parseLocalAgentRunArgs, + resolveLocalAgentTarget, +} from "./local-agent-targets.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; + +const profiles: LocalAgentProfile[] = [ + { + name: "reviewer", + description: "Review changes.", + provider: "codex", + model: "gpt-5-codex", + filePath: "/workspace/.devspace/agents/reviewer.md", + body: "Review carefully.", + disabled: false, + }, + { + name: "claude", + description: "A profile that shadows the raw provider.", + provider: "opencode", + model: "qwen/custom", + filePath: "/workspace/.devspace/agents/claude.md", + body: "Use OpenCode.", + disabled: false, + }, +]; + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "hello", "world"]), { + target: "codex", + prompt: "hello world", + model: undefined, +}); + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model", "gpt-5.1", "hello"]), { + target: "codex", + prompt: "hello", + model: "gpt-5.1", +}); + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model=gpt-5.1", "hello"]), { + target: "codex", + prompt: "hello", + model: "gpt-5.1", +}); + +assert.throws( + () => parseLocalAgentRunArgs(["codex", "--model"]), + /Missing value for --model/, +); + +{ + const target = resolveLocalAgentTarget("reviewer", profiles); + assert.equal(target?.kind, "profile"); + assert.equal(target?.name, "reviewer"); + assert.equal(target?.provider, "codex"); + assert.equal(target?.model, "gpt-5-codex"); +} + +{ + const target = resolveLocalAgentTarget("reviewer", profiles, "gpt-5.2"); + assert.equal(target?.kind, "profile"); + assert.equal(target?.model, "gpt-5.2"); +} + +{ + const target = resolveLocalAgentTarget("opencode", profiles); + assert.equal(target?.kind, "provider"); + assert.equal(target?.name, "opencode"); + assert.equal(target?.provider, "opencode"); + assert.equal(target?.model, undefined); +} + +{ + const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2"); + assert.equal(target?.kind, "provider"); + assert.equal(target?.model, "kimi-k2"); +} + +{ + const target = resolveLocalAgentTarget("claude", profiles); + assert.equal(target?.kind, "profile"); + assert.equal(target?.provider, "opencode"); +} + +assert.equal(resolveLocalAgentTarget("missing", profiles), undefined); +assert.match(formatAvailableLocalAgentTargets(profiles), /profiles: reviewer, claude/); +assert.match(formatAvailableLocalAgentTargets([]), /providers: codex, claude, opencode, pi, cursor, copilot/); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts new file mode 100644 index 00000000..a892d59a --- /dev/null +++ b/src/local-agent-targets.ts @@ -0,0 +1,98 @@ +import { + isLocalAgentProvider, + LOCAL_AGENT_PROVIDERS, + type LocalAgentProfile, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; + +export interface ParsedLocalAgentRunArgs { + target: string; + prompt: string; + model?: string; +} + +export type LocalAgentTarget = + | { + kind: "profile"; + name: string; + provider: LocalAgentProvider; + model?: string; + profile: LocalAgentProfile; + } + | { + kind: "provider"; + name: LocalAgentProvider; + provider: LocalAgentProvider; + model?: string; + }; + +export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs { + const [target, ...rest] = args; + if (!target) { + throw new Error('Usage: devspace agents run [--model ] ""'); + } + + let model: string | undefined; + const promptParts: string[] = []; + for (let index = 0; index < rest.length; index += 1) { + const part = rest[index]; + if (part === "--model") { + const value = rest[index + 1]?.trim(); + if (!value) throw new Error("Missing value for --model."); + model = value; + index += 1; + continue; + } + if (part?.startsWith("--model=")) { + const value = part.slice("--model=".length).trim(); + if (!value) throw new Error("Missing value for --model."); + model = value; + continue; + } + promptParts.push(part ?? ""); + } + + const prompt = promptParts.join(" ").trim(); + if (!prompt) { + throw new Error('Usage: devspace agents run [--model ] ""'); + } + + return { target, prompt, model }; +} + +export function resolveLocalAgentTarget( + target: string, + profiles: LocalAgentProfile[], + modelOverride?: string, +): LocalAgentTarget | undefined { + const profile = profiles.find((candidate) => candidate.name === target); + if (profile) { + return { + kind: "profile", + name: profile.name, + provider: profile.provider, + model: modelOverride ?? profile.model, + profile, + }; + } + + if (isLocalAgentProvider(target)) { + return { + kind: "provider", + name: target, + provider: target, + model: modelOverride, + }; + } + + return undefined; +} + +export function formatAvailableLocalAgentTargets(profiles: LocalAgentProfile[]): string { + const profileNames = profiles.map((profile) => profile.name); + const parts = [ + profileNames.length > 0 ? `profiles: ${profileNames.join(", ")}` : undefined, + `providers: ${LOCAL_AGENT_PROVIDERS.join(", ")}`, + ].filter(Boolean); + return parts.join("; "); +} From 4a48668868a5a53d6bf8076f8d57dd56d9c5d403 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 17:40:24 +0530 Subject: [PATCH 28/33] feat(subagents): advertise built-in providers --- skills/subagent-delegation/SKILL.md | 19 +++++++++++++++--- src/server.ts | 30 +++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md index bf616054..ae2c0725 100644 --- a/skills/subagent-delegation/SKILL.md +++ b/skills/subagent-delegation/SKILL.md @@ -18,14 +18,18 @@ Use only these commands for normal delegation: ```bash devspace agents ls -devspace agents run "" +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 agent and prints a DevSpace agent id. +`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. @@ -40,7 +44,16 @@ 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`. +`open_workspace`. Use the profile name with `devspace agents run`. If no +profile fits and delegation is still appropriate, use a built-in provider name +from `open_workspace`. + +Profiles may declare a model. To override the configured/default provider model +for a run, pass `--model`: + +```bash +devspace agents run --model "" +``` Good delegation targets: diff --git a/src/server.ts b/src/server.ts index 8109313f..c1e7e315 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,7 +41,10 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; -import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import { + LOCAL_AGENT_PROVIDERS, + summarizeLocalAgentProfile, +} from "./local-agent-profiles.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; @@ -209,6 +212,16 @@ function serverInstructions(config: ServerConfig, toolNames: ToolNames): string return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; } + +function formatVisibleAgent(agent: { + name: string; + provider: string; + model?: string; +}): string { + const model = agent.model ? `, model ${agent.model}` : ""; + return `${agent.name} (${agent.provider}${model})`; +} + function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { result: z @@ -238,6 +251,10 @@ const workspaceLocalAgentOutputSchema = z.object({ model: z.string().optional(), }); +const workspaceLocalAgentProviderOutputSchema = z.object({ + name: z.string(), +}); + const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); @@ -754,6 +771,7 @@ function createMcpServer( agentsFiles: z.array(workspaceAgentsFileOutputSchema), availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), skills: z.array(workspaceSkillOutputSchema), + agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), agents: z.array(workspaceLocalAgentOutputSchema), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), @@ -777,6 +795,9 @@ function createMcpServer( description: skill.description, path: formatPathForPrompt(skill.filePath), })); + const visibleAgentProviders = config.subagents + ? LOCAL_AGENT_PROVIDERS.map((name) => ({ name })) + : []; const visibleAgents = workspace.agentProfiles.map(summarizeLocalAgentProfile); const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), @@ -804,8 +825,11 @@ function createMcpServer( visibleSkills.length > 0 ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, + visibleAgentProviders.length > 0 + ? `Available subagent providers: ${visibleAgentProviders.map((provider) => provider.name).join(", ")}` + : undefined, visibleAgents.length > 0 - ? `Available subagents: ${visibleAgents.map((agent) => agent.name).join(", ")}` + ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` : undefined, instruction, ].filter(Boolean).join("\n"), @@ -831,6 +855,7 @@ function createMcpServer( agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, + agentProviders: visibleAgentProviders.length, agents: visibleAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, }, @@ -845,6 +870,7 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, + agentProviders: visibleAgentProviders, agents: visibleAgents, skillDiagnostics: workspace.skillDiagnostics, instruction, From 719762917833f9fd8b83ee6eb54b16d04f2515e6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 17:58:10 +0530 Subject: [PATCH 29/33] feat(subagents): preflight provider availability --- package.json | 2 +- src/cli.ts | 17 +++- src/local-agent-availability.test.ts | 37 +++++++ src/local-agent-availability.ts | 147 +++++++++++++++++++++++++++ src/server.ts | 63 +++++++++--- 5 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 src/local-agent-availability.test.ts create mode 100644 src/local-agent-availability.ts diff --git a/package.json b/package.json index c2db42d5..c0f0b24a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index d9fbf8f6..7c0a01a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,10 @@ import { loadLocalAgentProfiles, type LocalAgentProfile, } from "./local-agent-profiles.js"; +import { + assertLocalAgentProviderAvailable, + formatLocalAgentProviderAvailabilitySummary, +} from "./local-agent-availability.js"; import { formatAvailableLocalAgentTargets, parseLocalAgentRunArgs, @@ -208,7 +212,7 @@ async function serve(): Promise { const { createServer } = await import("./server.js"); const config = loadConfig(); - const { app, close } = createServer(config); + const { app, close, localAgentProviders } = createServer(config); const httpServer = app.listen(config.port, config.host, () => { console.log(`devspace listening on http://${config.host}:${config.port}/mcp`); console.log(`public base url: ${config.publicBaseUrl}`); @@ -219,6 +223,9 @@ async function serve(): Promise { } console.log("auth: Owner password approval required"); console.log(`logging: ${config.logging.level} ${config.logging.format}`); + if (config.subagents) { + console.log(`subagent providers: ${formatLocalAgentProviderAvailabilitySummary(localAgentProviders)}`); + } }); const shutdown = () => { @@ -354,9 +361,13 @@ async function runAgentsRun(args: string[]): Promise { const workspaceRoot = resolveCurrentWorkspaceRoot(); const store = createLocalAgentStore(config); const existing = store.get(parsed.target); - const promptFile = writeAgentPromptFile(parsed.prompt); 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, @@ -375,7 +386,9 @@ async function runAgentsRun(args: string[]): Promise { `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, diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts new file mode 100644 index 00000000..5d56697c --- /dev/null +++ b/src/local-agent-availability.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { + checkLocalAgentProviderAvailability, + formatLocalAgentProviderAvailabilitySummary, + getLocalAgentProviderAvailabilitySnapshot, +} from "./local-agent-availability.js"; + +assert.equal(checkLocalAgentProviderAvailability("codex").available, true); + +{ + const availability = checkLocalAgentProviderAvailability("pi", { + ...process.env, + PI_COMMAND: "/definitely/missing/devspace-pi", + }); + assert.equal(availability.available, false); + assert.match(availability.reason ?? "", /executable not found/); +} + +{ + const snapshot = getLocalAgentProviderAvailabilitySnapshot({ + ...process.env, + PI_COMMAND: "/definitely/missing/devspace-pi", + }); + assert.deepEqual( + snapshot.map((provider) => provider.name), + ["codex", "claude", "opencode", "pi", "cursor", "copilot"], + ); + assert.equal(snapshot.find((provider) => provider.name === "pi")?.available, false); +} + +assert.equal( + formatLocalAgentProviderAvailabilitySummary([ + { name: "codex", available: true }, + { name: "pi", available: false, reason: "pi executable not found" }, + ]), + "available: codex; unavailable: pi (pi executable not found)", +); diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts new file mode 100644 index 00000000..e660f118 --- /dev/null +++ b/src/local-agent-availability.ts @@ -0,0 +1,147 @@ +import { spawnSync } from "node:child_process"; +import { delimiter, resolve, sep } from "node:path"; +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; + + const result = spawnSync(process.platform === "win32" ? "where.exe" : "command", [ + ...(process.platform === "win32" ? [command] : ["-v", command]), + ], { + encoding: "utf8", + env, + shell: process.platform !== "win32", + windowsHide: true, + }); + const executable = result.stdout?.split(/\r?\n/).find((line) => line.trim()); + return executable?.trim() || undefined; +} + +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: path + .split(delimiter) + .filter((entry) => entry && !isDevspaceNodeModulesBin(entry)) + .join(delimiter), + }; +} + +function isDevspaceNodeModulesBin(pathEntry: string): boolean { + const resolvedEntry = resolve(pathEntry); + return resolvedEntry.endsWith(`${sep}node_modules${sep}.bin`) + && resolvedEntry.includes(`${sep}devspace${sep}`); +} diff --git a/src/server.ts b/src/server.ts index c1e7e315..fb317a7b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,10 +41,12 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; +import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; import { - LOCAL_AGENT_PROVIDERS, - summarizeLocalAgentProfile, -} from "./local-agent-profiles.js"; + formatLocalAgentProviderAvailabilitySummary, + getLocalAgentProviderAvailabilitySnapshot, + type LocalAgentProviderAvailability, +} from "./local-agent-availability.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; @@ -71,6 +73,7 @@ const SHELL_TOOL_ANNOTATIONS = { interface RunningServer { app: ReturnType; config: ServerConfig; + localAgentProviders: LocalAgentProviderAvailability[]; close(): void; } @@ -217,9 +220,18 @@ function formatVisibleAgent(agent: { name: string; provider: string; model?: string; + providerAvailable?: boolean; + providerUnavailableReason?: string; }): string { const model = agent.model ? `, model ${agent.model}` : ""; - return `${agent.name} (${agent.provider}${model})`; + const availability = agent.providerAvailable === false + ? `, unavailable: ${agent.providerUnavailableReason ?? "provider unavailable"}` + : ""; + return `${agent.name} (${agent.provider}${model}${availability})`; +} + +function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability): string { + return `${provider.name} (${provider.reason ?? "unavailable"})`; } function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { @@ -249,10 +261,14 @@ const workspaceLocalAgentOutputSchema = z.object({ description: z.string(), provider: z.string(), model: z.string().optional(), + providerAvailable: z.boolean().optional(), + providerUnavailableReason: z.string().optional(), }); const workspaceLocalAgentProviderOutputSchema = z.object({ name: z.string(), + available: z.boolean(), + reason: z.string().optional(), }); const workspaceAvailableAgentsFileOutputSchema = z.object({ @@ -683,6 +699,7 @@ function createMcpServer( workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, + localAgentProviders: LocalAgentProviderAvailability[], ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -795,10 +812,16 @@ function createMcpServer( description: skill.description, path: formatPathForPrompt(skill.filePath), })); - const visibleAgentProviders = config.subagents - ? LOCAL_AGENT_PROVIDERS.map((name) => ({ name })) - : []; - const visibleAgents = workspace.agentProfiles.map(summarizeLocalAgentProfile); + const visibleAgentProviders = config.subagents ? localAgentProviders : []; + const visibleAgents = workspace.agentProfiles.map((profile) => { + const summary = summarizeLocalAgentProfile(profile); + const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider); + return { + ...summary, + providerAvailable: availability?.available, + providerUnavailableReason: availability?.reason, + }; + }); const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, @@ -825,8 +848,11 @@ function createMcpServer( visibleSkills.length > 0 ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, - visibleAgentProviders.length > 0 - ? `Available subagent providers: ${visibleAgentProviders.map((provider) => provider.name).join(", ")}` + 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(", ")}` @@ -1611,6 +1637,9 @@ export function createServer(config = loadConfig()): RunningServer { const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); + const localAgentProviders = config.subagents + ? getLocalAgentProviderAvailabilitySnapshot() + : []; if (config.logging.trustProxy) { app.set("trust proxy", true); @@ -1734,7 +1763,13 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions); + const server = createMcpServer( + config, + workspaces, + reviewCheckpoints, + processSessions, + localAgentProviders, + ); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1757,6 +1792,7 @@ export function createServer(config = loadConfig()): RunningServer { return { app, config, + localAgentProviders, close: () => { if (closed) return; closed = true; @@ -1776,7 +1812,7 @@ async function isMainModule(): Promise { } if (await isMainModule()) { - const { app, config, close } = createServer(); + const { app, config, close, localAgentProviders } = createServer(); const httpServer = app.listen(config.port, config.host, () => { console.log( `devspace listening on http://${config.host}:${config.port}/mcp`, @@ -1787,6 +1823,9 @@ if (await isMainModule()) { console.log(`request logging: ${config.logging.requests ? "enabled" : "disabled"}`); console.log(`asset logging: ${config.logging.assets ? "enabled" : "disabled"}`); console.log(`trust proxy: ${config.logging.trustProxy ? "enabled" : "disabled"}`); + if (config.subagents) { + console.log(`subagent providers: ${formatLocalAgentProviderAvailabilitySummary(localAgentProviders)}`); + } }); const shutdown = () => { From a72d1ed9e0bf1bf12a4542ed34c1b7d9ce83bc98 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 18:47:04 +0530 Subject: [PATCH 30/33] fix(subagents): fail pi rpc on malformed stdout --- src/local-agent-adapters.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 1afb92fb..31d5e89d 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -310,6 +310,7 @@ class JsonLineRpc { 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"))); @@ -322,6 +323,9 @@ class JsonLineRpc { } 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) => { @@ -358,7 +362,14 @@ class JsonLineRpc { const line = this.buffer.slice(0, newline).trim(); this.buffer = this.buffer.slice(newline + 1); if (!line) continue; - const message = JSON.parse(line) as Record; + 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; @@ -378,6 +389,7 @@ class JsonLineRpc { } private failAll(error: Error): void { + this.fatalError = error; for (const pending of this.pending.values()) { pending.reject(error); } From c513a8df597588c940167c20ae9ce16053921f93 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 18:47:44 +0530 Subject: [PATCH 31/33] fix(subagents): reject claude error results --- src/local-agent-adapters.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 31d5e89d..7ad0defc 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -79,19 +79,39 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { 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: finalResponse.trim(), + 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]), From 6c4dfb5cbaaae28cf1dcf32d759f9205fd275591 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 18:48:11 +0530 Subject: [PATCH 32/33] fix(subagents): avoid shell command lookup --- src/local-agent-availability.ts | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index e660f118..4e642174 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -102,16 +102,28 @@ function resolveCommand(command: string, env: NodeJS.ProcessEnv = process.env): const commandHasPath = command.includes("/") || command.includes("\\"); if (commandHasPath) return executableExists(command, env) ? command : undefined; - const result = spawnSync(process.platform === "win32" ? "where.exe" : "command", [ - ...(process.platform === "win32" ? [command] : ["-v", command]), - ], { - encoding: "utf8", - env, - shell: process.platform !== "win32", - windowsHide: true, - }); - const executable = result.stdout?.split(/\r?\n/).find((line) => line.trim()); - return executable?.trim() || 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 { From f50f02a91f70e8423aa6814c46359f472a91140f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 5 Jul 2026 18:49:18 +0530 Subject: [PATCH 33/33] refactor(subagents): share local agent path cleanup --- src/local-agent-adapters.test.ts | 6 ++++++ src/local-agent-adapters.ts | 26 +++----------------------- src/local-agent-availability.ts | 14 +++----------- src/local-agent-path.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 34 deletions(-) create mode 100644 src/local-agent-path.ts diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 187fed22..8dcea38e 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -9,6 +9,7 @@ import { extractPiStreamingText, piCommandEnvironment, } from "./local-agent-adapters.js"; +import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; const providers: LocalAgentProvider[] = [ @@ -202,6 +203,11 @@ assert.equal( { 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), }); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 7ad0defc..267e7f83 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,8 +1,8 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { delimiter, resolve, sep } from "node:path"; +import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { createCodexSdkLocalAgentRuntime, type LocalAgentRunInput, @@ -297,30 +297,10 @@ export function piCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv return { ...env, - PATH: path - .split(delimiter) - .filter((entry) => entry && !isDevspaceNodeModulesBin(entry)) - .join(delimiter), + PATH: removeDevspaceNodeModulesBinFromPath(path), }; } -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; - } -} - class JsonLineRpc { private readonly pending = new Map void; diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 4e642174..747f304f 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; -import { delimiter, resolve, sep } from "node:path"; +import { delimiter, resolve } from "node:path"; +import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, @@ -145,15 +146,6 @@ function piAvailabilityEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (!path) return env; return { ...env, - PATH: path - .split(delimiter) - .filter((entry) => entry && !isDevspaceNodeModulesBin(entry)) - .join(delimiter), + PATH: removeDevspaceNodeModulesBinFromPath(path), }; } - -function isDevspaceNodeModulesBin(pathEntry: string): boolean { - const resolvedEntry = resolve(pathEntry); - return resolvedEntry.endsWith(`${sep}node_modules${sep}.bin`) - && resolvedEntry.includes(`${sep}devspace${sep}`); -} diff --git a/src/local-agent-path.ts b/src/local-agent-path.ts new file mode 100644 index 00000000..c8ff2935 --- /dev/null +++ b/src/local-agent-path.ts @@ -0,0 +1,26 @@ +import { existsSync, readFileSync } from "node:fs"; +import { delimiter, resolve, sep } from "node:path"; + +export function removeDevspaceNodeModulesBinFromPath(pathValue: string): string { + return pathValue + .split(delimiter) + .filter((entry) => entry && !isDevspaceNodeModulesBin(entry)) + .join(delimiter); +} + +function isDevspaceNodeModulesBin(pathEntry: string): boolean { + const resolvedEntry = resolve(pathEntry); + if (!resolvedEntry.endsWith(`${sep}node_modules${sep}.bin`)) { + return false; + } + + const packageJson = resolve(resolvedEntry, "..", "..", "package.json"); + if (!existsSync(packageJson)) return false; + + try { + const packageInfo = JSON.parse(readFileSync(packageJson, "utf8")) as { name?: unknown }; + return packageInfo.name === "@waishnav/devspace"; + } catch { + return false; + } +}