From 62aa43db8b2bdf0008fea24dfaa00fb5d123371c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 03:12:32 +0530 Subject: [PATCH 01/41] feat(skills): bundle local agent delegation skill --- skills/local-agent-delegation/SKILL.md | 187 +++++++++++++++++++++++++ src/cli.ts | 3 + src/config.test.ts | 11 +- src/config.ts | 4 +- src/skills.test.ts | 15 ++ src/skills.ts | 21 ++- src/user-config.ts | 16 ++- 7 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 skills/local-agent-delegation/SKILL.md diff --git a/skills/local-agent-delegation/SKILL.md b/skills/local-agent-delegation/SKILL.md new file mode 100644 index 00000000..c50173c7 --- /dev/null +++ b/skills/local-agent-delegation/SKILL.md @@ -0,0 +1,187 @@ +--- +name: local-agent-delegation +description: Delegate coding tasks to user-configured local coding agents such as Codex, Claude Code, OpenCode, Cursor Agent, Pi, or Copilot CLI. +--- + +# Local Agent Delegation + +Use this skill when the user explicitly asks to delegate work to another coding agent, use a named local agent, get a second opinion, compare implementations, run agents in parallel, or create a subagent-like workflow. + +Do not use local agents silently. Tell the user when another local agent is being used. + +## Core idea + +You are the supervisor. A local coding agent is the worker. + +Your responsibilities are: + +1. Understand the user's goal. +2. Decide whether delegation is useful. +3. Choose the right local agent or CLI. +4. Give the worker a focused prompt. +5. Inspect the result yourself. +6. Review diffs, tests, and risks before telling the user the work is done. + +## When to delegate + +Good delegation requests include: + +- "Ask another agent to look at this." +- "Have Claude/Codex/OpenCode/Pi implement this." +- "Run this in the background." +- "Compare two approaches." +- "Use a local subagent for this." +- "Get a second opinion on the architecture/test gaps/security risk." + +Do not delegate just because the task is coding-related. Use the normal DevSpace tools directly unless the user asks for delegation, another agent's opinion, parallel work, or a named local coding agent. + +## CLI execution guidance + +Prefer structured non-interactive CLI modes when available. + +Examples of useful patterns: + +```bash +codex exec --json -C "$WORKSPACE" "$PROMPT" +claude -p --output-format stream-json "$PROMPT" +opencode run --format json --dir "$WORKSPACE" "$PROMPT" +cursor-agent -p --output-format stream-json "$PROMPT" +pi -p --mode json "$PROMPT" +copilot -p "$PROMPT" --output-format json +``` + +Use exact command templates from user-configured DevSpace agent profiles when they are available. Do not invent provider-specific flags when a profile already defines them. + +Configured local agent profiles may live in: + +```text +~/.devspace/agents/*.md +``` + +Treat those profiles as user-approved local worker definitions. If no profile exists for a requested agent, use the installed CLI's help output only when needed, then summarize what you found before running it. + +## Background execution + +When DevSpace exposes long-running process tools, prefer background execution for long tasks. + +Start the local agent process, keep the returned process/session id, and poll output later. + +Use this pattern for long implementations, test repair loops, large reviews, multi-step investigations, and agents that stream JSON or progress logs. + +When polling output, summarize useful progress instead of forwarding noisy terminal logs. + +If the worker is clearly stuck, running the wrong task, or burning resources, interrupt the current process or turn. Do not delete provider session history unless the user explicitly asks. + +## Follow-up prompts + +When sending a follow-up to the same local agent, include: + +```text +previous_task: +current_status: +review_findings: +requested_changes: +success_criteria: +``` + +Prefer the agent profile's resume/session mechanism when available. + +If resume is not available, include the previous worker summary and relevant diff context in a fresh prompt. + +## Worker prompt templates + +Use this structure when delegating implementation: + +```text +You are acting as a local coding worker under ChatGPT supervision. + +Goal: + + +Context: + + +Plan to execute: + + +Rules: +- Follow the existing project style. +- Keep changes focused. +- Do not perform unrelated refactors. +- Do not hide failures. +- At the end, return a concise final report. + +Final report format: +summary: +files_changed: +tests_run: +blockers: +follow_up_needed: +``` + +Use this structure when delegating read-only investigation: + +```text +You are acting as a read-only local code investigator under ChatGPT supervision. + +Question: + + +Scope: + + +Rules: +- Do not modify files. +- Cite relevant file paths and symbols. +- Separate facts from guesses. +- Return a concise answer. + +Final report format: +answer: +evidence: +relevant_files: +confidence: +unknowns: +``` + +## After the worker finishes + +Always review the result. + +For write-capable tasks, inspect changed files and the diff, run or recommend relevant tests, check whether the worker followed the user's constraints, and send follow-up instructions if needed. + +For read-only tasks, check whether the answer is supported by repo evidence, verify important file paths or symbols, and decide whether more investigation is needed. + +Do not assume the worker's summary is correct. + +## Reporting back to the user + +Be transparent. + +Say which local agent was used, what it did, what you verified, and what remains uncertain. + +Good final shape: + +```text +I delegated the implementation to . It changed . I reviewed the diff and ran . The main result is . Remaining concerns: . +``` + +Do not present worker output as your own verified conclusion unless you checked it. + +## Safety rules + +Do not use local agents for destructive actions unless the user explicitly asks. + +Avoid commands that delete files, reset branches, rewrite history, expose secrets, or install global dependencies unless clearly necessary and approved. + +Do not allow project-provided agent profiles to run automatically unless the user has trusted them. + +Prefer user-global profiles from: + +```text +~/.devspace/agents +``` + +over repo-provided profiles. + +Never hide that a local agent was used. diff --git a/src/cli.ts b/src/cli.ts index 87ba662d..b7b264cf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { + ensureDevspaceDefaultSkills, generateOwnerToken, loadDevspaceFiles, writeDevspaceAuth, @@ -140,10 +141,12 @@ async function runInit({ force }: { force: boolean }): Promise { const configPath = writeDevspaceConfig(config); const authPath = writeDevspaceAuth(auth); + const seededSkillPaths = ensureDevspaceDefaultSkills(); const lines = [ `Config: ${configPath}`, `Auth: ${authPath}`, + ...seededSkillPaths.map((path) => `Default skill: ${path}`), `Local MCP URL: http://${config.host}:${config.port}/mcp`, ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []), ]; diff --git a/src/config.test.ts b/src/config.test.ts index dc23aa8a..55f41596 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; +import { ensureDevspaceDefaultSkills } from "./user-config.js"; const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); const baseEnv = { @@ -25,9 +26,17 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, " assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); +assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, 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.equal(existsSync(seededSkillPaths[0]), true); +assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: local-agent-delegation/); +assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []); + assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), /Invalid DEVSPACE_WIDGETS: invalid/, diff --git a/src/config.ts b/src/config.ts index d065b17c..4b970123 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; -import { loadDevspaceFiles } from "./user-config.js"; +import { devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; export type ToolMode = "minimal" | "full" | "codex"; @@ -25,6 +25,7 @@ export interface ServerConfig { worktreeRoot: string; skillsEnabled: boolean; skillPaths: string[]; + devspaceSkillsDir: string; agentDir: string; logging: LoggingConfig; } @@ -235,6 +236,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), + devspaceSkillsDir: devspaceSkillsDir(env), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), logging: parseLoggingConfig(env), }; diff --git a/src/skills.test.ts b/src/skills.test.ts index 16a49c8a..c4bfe231 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -20,6 +20,7 @@ try { const projectRoot = join(root, "project"); const agentDir = join(root, "agent"); const explicitSkills = join(root, "explicit-skills"); + const devspaceSkills = join(root, ".devspace", "skills"); const globalAgentsSkills = join(root, ".agents", "skills"); const projectAgentsSkills = join(projectRoot, ".agents", "skills"); const globalClaudeSkills = join(root, ".claude", "skills"); @@ -32,6 +33,7 @@ try { await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); + await mkdir(join(devspaceSkills, "devspace-local-skill"), { recursive: true }); await writeFile( join(globalAgentsSkills, "agent-global-skill", "SKILL.md"), @@ -88,6 +90,17 @@ try { "# Project Skill", ].join("\n"), ); + await writeFile( + join(devspaceSkills, "devspace-local-skill", "SKILL.md"), + [ + "---", + "name: devspace-local-skill", + "description: DevSpace local skill description.", + "---", + "", + "# DevSpace Local Skill", + ].join("\n"), + ); await writeFile( join(agentDir, "skills", "global-skill", "SKILL.md"), [ @@ -146,6 +159,8 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "claude-global-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "claude-project-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), false); + assert.equal(loaded.skills.some((skill) => skill.name === "devspace-local-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "local-agent-delegation"), true); 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); diff --git a/src/skills.ts b/src/skills.ts index e3da62ff..9ad49e5e 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { loadSkills, type Skill, @@ -20,12 +21,28 @@ export interface SkillReadResolution { isSkillFile: boolean; } +const LOCAL_AGENT_DELEGATION_SKILL = join("local-agent-delegation", "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)); +} + export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] { - const defaultPaths = [ + const bundledSkills = bundledSkillsDir(); + const defaultPathCandidates = [ join(homedir(), ".agents", "skills"), resolve(cwd, ".agents", "skills"), + config.devspaceSkillsDir, join(config.agentDir, "skills"), - ].filter((path) => existsSync(path)); + hasLocalAgentDelegationSkill(config.devspaceSkillsDir) ? undefined : bundledSkills, + ]; + const defaultPaths = defaultPathCandidates.filter( + (path): path is string => path !== undefined && existsSync(path), + ); const seen = new Set(); return [...defaultPaths, ...config.skillPaths] diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c519..dcf718c2 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -6,7 +6,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; export interface DevspaceUserConfig { @@ -46,6 +46,10 @@ export function devspaceAuthPath(env: NodeJS.ProcessEnv = process.env): string { return join(devspaceConfigDir(env), "auth.json"); } +export function devspaceSkillsDir(env: NodeJS.ProcessEnv = process.env): string { + return join(devspaceConfigDir(env), "skills"); +} + export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): DevspaceFiles { const dir = devspaceConfigDir(env); const configPath = join(dir, "config.json"); @@ -88,6 +92,16 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } +export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env): string[] { + const targetPath = join(devspaceSkillsDir(env), "local-agent-delegation", "SKILL.md"); + if (existsSync(targetPath)) return []; + + const sourcePath = new URL("../skills/local-agent-delegation/SKILL.md", import.meta.url); + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, readFileSync(sourcePath, "utf8"), { mode: 0o644 }); + return [targetPath]; +} + function readJsonFile(filePath: string): T { try { return JSON.parse(readFileSync(filePath, "utf8")) as T; From 67fa1127fcd53f93d1d5b9f74b8424899f35419d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 03:12:36 +0530 Subject: [PATCH 02/41] docs(agents): add local agent profile examples --- docs/agent-profile-schema.md | 349 +++++++++++++++++++++++++ examples/agents/claude-implementer.md | 106 ++++++++ examples/agents/codex-explorer.md | 91 +++++++ examples/agents/codex-worker.md | 102 ++++++++ examples/agents/copilot-reviewer.md | 92 +++++++ examples/agents/cursor-agent-worker.md | 90 +++++++ examples/agents/opencode-explorer.md | 94 +++++++ examples/agents/pi-reviewer.md | 90 +++++++ 8 files changed, 1014 insertions(+) create mode 100644 docs/agent-profile-schema.md create mode 100644 examples/agents/claude-implementer.md create mode 100644 examples/agents/codex-explorer.md create mode 100644 examples/agents/codex-worker.md create mode 100644 examples/agents/copilot-reviewer.md create mode 100644 examples/agents/cursor-agent-worker.md create mode 100644 examples/agents/opencode-explorer.md create mode 100644 examples/agents/pi-reviewer.md diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md new file mode 100644 index 00000000..dcaa3c75 --- /dev/null +++ b/docs/agent-profile-schema.md @@ -0,0 +1,349 @@ +# 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. + +Profiles are intended to live in: + +```text +~/.devspace/agents/*.md +``` + +The packaged files in `examples/agents/` are starter templates only. DevSpace does +not automatically activate them, copy them into `~/.devspace/agents`, or run their +commands. Users should copy, review, and edit a template before treating it as an +active local worker definition. + +## Minimal shape + +```md +--- +schema: devspace-agent/v1 +name: codex-explorer +description: Read-only Codex agent for bounded codebase questions. +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 +--- + +Use this agent for bounded read-only codebase investigation. +``` + +## Front matter fields + +### `schema` + +Required schema identifier. + +Current value: + +```yaml +schema: devspace-agent/v1 +``` + +### `name` + +Stable profile identifier shown to the model and user. + +Use lowercase kebab-case names, for example: + +```yaml +name: codex-explorer +``` + +### `description` + +Short human-readable purpose. This should help the supervising model decide when +the agent is appropriate. + +### `provider` + +The local agent family or CLI provider. + +Examples: + +```yaml +provider: codex +provider: claude +provider: opencode +provider: cursor +provider: pi +provider: copilot +``` + +### `backend` + +Execution backend. The near-term templates use CLI-backed agents: + +```yaml +backend: cli +``` + +Future profiles may support protocol-backed backends such as ACP without changing +the high-level profile name. + +## Capabilities + +Capabilities describe what the worker is allowed or expected to do. + +```yaml +capabilities: + read: true + write: false + shell: false + background: true + resume: true +``` + +- `read`: the agent can inspect project files. +- `write`: the agent may modify files. +- `shell`: the agent may run shell commands. +- `background`: the agent can be started as a long-running process. +- `resume`: the agent supports follow-up prompts against an existing session. + +These fields are descriptive in the current template-only stage. A future parser +should validate them before rendering an available-agent catalog or exposing +runtime tools. + +## Workspace policy + +```yaml +workspace: + default: current + isolation: user_decides + writeMode: allowed +``` + +- `default`: default workspace source. Current templates use `current`. +- `isolation`: whether to use the same checkout, a branch, a worktree, or let the + user decide. +- `writeMode`: whether writes are allowed. + +Recommended values: + +```yaml +isolation: none +isolation: user_decides + +writeMode: read_only +writeMode: allowed +``` + +Use read-only profiles for review, lookup, and second opinions. Use write-capable +profiles only when the user explicitly asks a local agent to implement or edit. + +## Actions + +Actions define lifecycle commands and strategies. + +### `actions.start` + +Starts the local agent. + +```yaml +actions: + start: + command: codex + args: + - exec + - --json + - -C + - "{workspace}" + - "{prompt}" + background: true + output: jsonl +``` + +Use `command` plus `args` arrays. Do not use free-form shell strings. + +Good: + +```yaml +command: codex +args: + - exec + - --json + - -C + - "{workspace}" + - "{prompt}" +``` + +Avoid: + +```yaml +run: "codex exec --json -C {workspace} {prompt}" +``` + +Argv arrays are easier to validate, escape, log, review, and migrate to future +backends. + +### `actions.followup` + +Defines how to continue a previous worker session. + +Supported strategy names used by the starter templates: + +```yaml +strategy: resume_command +strategy: fresh_prompt_with_context +``` + +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. + +The starter templates use: + +```yaml +read: + strategy: devspace_process_poll +``` + +### `actions.cancel` + +Defines how DevSpace should interrupt a running worker. + +The starter templates use: + +```yaml +cancel: + strategy: devspace_process_signal + signal: SIGINT +``` + +Prefer interruption over deleting provider session history. + +### `actions.diff` + +Defines how DevSpace can inspect worker file changes. + +Read-only profiles should use: + +```yaml +diff: + strategy: none +``` + +Write-capable profiles should use: + +```yaml +diff: + strategy: git_diff +``` + +## Placeholders + +The examples use placeholders that a future runtime can substitute safely: + +```text +{workspace} +{prompt} +{externalSessionId} +``` + +- `{workspace}`: absolute workspace path selected by DevSpace or the user. +- `{prompt}`: focused worker prompt created by the supervising model. +- `{externalSessionId}`: provider session id returned by a previous agent run. + +## Safety policy + +```yaml +safety: + requireExplicitUserIntent: true + allowWrites: false + requireReviewBeforeFinal: true +``` + +Recommended write-capable profile safety: + +```yaml +safety: + requireExplicitUserIntent: true + allowWrites: true + requireDiffReview: true + requireTestsOrExplanation: true +``` + +Profiles should make user intent and review requirements explicit. DevSpace should +not silently delegate work to local agents, and the supervising model should not +present worker output as verified until it has reviewed the result. + +## Markdown body + +The markdown body should explain when to use the agent and provide a worker prompt +template. + +Recommended sections: + +- `Use this agent when ...` +- `Good tasks:` +- `Worker prompt style:` +- A final report format that the supervising model can review. + +The body is model-facing guidance. Keep it practical and concise. + +## Current non-goals + +The current examples do not add: + +- `.devspace/agents` 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. diff --git a/examples/agents/claude-implementer.md b/examples/agents/claude-implementer.md new file mode 100644 index 00000000..58f91ed0 --- /dev/null +++ b/examples/agents/claude-implementer.md @@ -0,0 +1,106 @@ +--- +schema: devspace-agent/v1 +name: claude-implementer +description: Claude Code implementation worker for larger edits, refactors, and follow-up loops. +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 + - "{prompt}" + background: true + output: stream-json + + followup: + strategy: resume_command + command: claude + args: + - --resume + - "{externalSessionId}" + - -p + - --output-format + - stream-json + - "{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 +--- + +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. + +Worker prompt style: + +```text +You are a local Claude Code implementation worker under ChatGPT supervision. + +Goal: + + +Context: + + +Plan: + + +Constraints: + + +Rules: +- Keep changes focused. +- Do not rewrite unrelated code. +- Preserve public behavior unless asked. +- Run or explain relevant tests. +- Return a concise final report. + +Final report format: +summary: +files_changed: +tests_run: +risks: +blockers: +follow_up_needed: +``` diff --git a/examples/agents/codex-explorer.md b/examples/agents/codex-explorer.md new file mode 100644 index 00000000..5ca22120 --- /dev/null +++ b/examples/agents/codex-explorer.md @@ -0,0 +1,91 @@ +--- +schema: devspace-agent/v1 +name: codex-explorer +description: Read-only Codex agent 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 +--- + +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: + + +Rules: +- Do not modify files. +- Cite file paths and symbols. +- Separate facts from guesses. +- Keep the answer concise. + +Final report format: +answer: +evidence: +relevant_files: +confidence: +unknowns: +``` diff --git a/examples/agents/codex-worker.md b/examples/agents/codex-worker.md new file mode 100644 index 00000000..00d89b02 --- /dev/null +++ b/examples/agents/codex-worker.md @@ -0,0 +1,102 @@ +--- +schema: devspace-agent/v1 +name: codex-worker +description: Codex implementation worker 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 +--- + +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. + +Worker prompt style: + +```text +You are a local implementation worker under ChatGPT supervision. + +Goal: + + +Plan: + + +Constraints: + + +Files to focus: + + +Tests to run: + + +Rules: +- Keep changes focused. +- Follow existing project style. +- Do not perform unrelated refactors. +- Do not hide failures. +- Return a final report. + +Final report format: +summary: +files_changed: +tests_run: +blockers: +follow_up_needed: +``` diff --git a/examples/agents/copilot-reviewer.md b/examples/agents/copilot-reviewer.md new file mode 100644 index 00000000..f3b66074 --- /dev/null +++ b/examples/agents/copilot-reviewer.md @@ -0,0 +1,92 @@ +--- +schema: devspace-agent/v1 +name: copilot-reviewer +description: GitHub Copilot CLI reviewer for read-only 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: + - -p + - "{prompt}" + - --output-format + - json + background: true + output: json + + followup: + strategy: resume_command + command: copilot + args: + - -p + - "{prompt}" + - --resume + - "{externalSessionId}" + - --output-format + - json + + read: + strategy: devspace_process_poll + + cancel: + strategy: devspace_process_signal + signal: SIGINT + + diff: + strategy: none + +safety: + requireExplicitUserIntent: true + allowWrites: false + requireReviewBeforeFinal: true +--- + +Use this agent when the user wants a GitHub Copilot-powered second opinion, +review, or codebase answer. + +Good tasks: + +- 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: + + +Rules: +- Do not modify files. +- Cite exact files and symbols. +- Return concise findings. +- Separate facts from guesses. + +Final report format: +answer: +findings: +evidence: +relevant_files: +confidence: +unknowns: +``` diff --git a/examples/agents/cursor-agent-worker.md b/examples/agents/cursor-agent-worker.md new file mode 100644 index 00000000..4dffef2a --- /dev/null +++ b/examples/agents/cursor-agent-worker.md @@ -0,0 +1,90 @@ +--- +schema: devspace-agent/v1 +name: cursor-agent-worker +description: Cursor Agent worker for fast implementation or review using local Cursor CLI. +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 + - "{prompt}" + background: true + output: stream-json + + followup: + strategy: fresh_prompt_with_context + + read: + strategy: devspace_process_poll + + cancel: + strategy: devspace_process_signal + signal: SIGINT + + diff: + strategy: git_diff + +safety: + requireExplicitUserIntent: true + allowWrites: true + requireDiffReview: true + requireTestsOrExplanation: true +--- + +Use this agent when the user wants Cursor's local agent/model to quickly execute +or inspect a task. + +Good tasks: + +- Fast implementation pass. +- UI/UX-oriented code review. +- Alternative implementation idea. +- Lightweight refactor. + +Worker prompt style: + +```text +You are a local Cursor Agent worker under ChatGPT supervision. + +Goal: + + +Context: + + +Plan: + + +Rules: +- Keep changes focused. +- Do not make unrelated edits. +- Preserve existing style. +- Report tests and blockers. + +Final report format: +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 new file mode 100644 index 00000000..93c7a846 --- /dev/null +++ b/examples/agents/opencode-explorer.md @@ -0,0 +1,94 @@ +--- +schema: devspace-agent/v1 +name: opencode-explorer +description: OpenCode read-only explorer for fast codebase lookup and bounded 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: json + + followup: + strategy: resume_command + command: opencode + args: + - run + - --format + - json + - --session + - "{externalSessionId}" + - --dir + - "{workspace}" + - "{prompt}" + + read: + strategy: devspace_process_poll + + cancel: + strategy: devspace_process_signal + signal: SIGINT + + diff: + strategy: none + +safety: + requireExplicitUserIntent: true + allowWrites: false + requireReviewBeforeFinal: true +--- + +Use this agent for fast, read-only codebase exploration. + +Good tasks: + +- Find relevant files. +- Explain a subsystem. +- Identify test coverage gaps. +- Compare possible implementation locations. + +Worker prompt style: + +```text +You are a read-only OpenCode explorer. + +Question: + + +Scope: + + +Rules: +- Do not modify files. +- Cite exact file paths. +- Prefer concise findings. +- State uncertainty. + +Final report format: +answer: +evidence: +relevant_files: +confidence: +unknowns: +``` diff --git a/examples/agents/pi-reviewer.md b/examples/agents/pi-reviewer.md new file mode 100644 index 00000000..27510c27 --- /dev/null +++ b/examples/agents/pi-reviewer.md @@ -0,0 +1,90 @@ +--- +schema: devspace-agent/v1 +name: pi-reviewer +description: Pi read-only reviewer 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: json + + followup: + strategy: resume_command + command: pi + args: + - -p + - --mode + - json + - --session-id + - "{externalSessionId}" + - "{prompt}" + + read: + strategy: devspace_process_poll + + cancel: + strategy: devspace_process_signal + signal: SIGINT + + diff: + strategy: none + +safety: + requireExplicitUserIntent: true + allowWrites: false + requireReviewBeforeFinal: true +--- + +Use this agent for lightweight review and targeted read-only investigation. + +Good tasks: + +- Review a diff. +- Find possible bugs. +- Explain a small subsystem. +- Check whether tests cover an edge case. + +Worker prompt style: + +```text +You are a read-only local code reviewer. + +Question: + + +Scope: + + +Rules: +- Do not modify files. +- Cite evidence. +- Focus on actionable findings. +- Avoid broad rewrites. + +Final report format: +findings: +evidence: +risk_level: +recommended_next_steps: +unknowns: +``` From 5fe69575df60a7099604c4226057c1384d3fe869 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 03:12:42 +0530 Subject: [PATCH 03/41] docs(agents): surface packaged agent templates --- docs/chatgpt-coding-workflow.md | 6 ++++++ docs/configuration.md | 6 ++++++ docs/gotchas.md | 6 ++++++ package.json | 2 ++ 4 files changed, 20 insertions(+) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 13269f52..593987a8 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -83,12 +83,18 @@ DevSpace discovers standard Agent Skills from: - `~/.agents/skills` - project `.agents/skills` +- `~/.devspace/skills` It also keeps compatibility with: +- the bundled `local-agent-delegation` skill, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` +Example local coding-agent profiles are packaged under `examples/agents/` for +users who want starter templates for `~/.devspace/agents/*.md`. These examples +are not activated automatically. + Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. When `open_workspace` returns matching skills, the model should read the diff --git a/docs/configuration.md b/docs/configuration.md index 32293631..2a97376e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,12 +106,18 @@ DevSpace discovers standard Agent Skills from: - `~/.agents/skills` - project `.agents/skills` +- `~/.devspace/skills` It also keeps compatibility with: +- the bundled `local-agent-delegation` skill, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` +Starter local coding-agent profile templates are available under +`examples/agents/`. Users can copy them into `~/.devspace/agents/` and edit them +for their local CLIs. DevSpace does not activate packaged examples automatically. + Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. Example: diff --git a/docs/gotchas.md b/docs/gotchas.md index c6ef1d2e..bb584b28 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -197,12 +197,18 @@ DevSpace looks in standard Agent Skills locations: - `~/.agents/skills` - project `.agents/skills` +- `~/.devspace/skills` It also checks compatibility and custom paths: +- the bundled `local-agent-delegation` skill, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` +Packaged local-agent examples under `examples/agents/` are templates only. Copy +and review a profile before using it from `~/.devspace/agents/`; repo-provided or +packaged examples should not become runnable worker definitions silently. + Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. If a skill appears in `open_workspace`, the model must read that skill's diff --git a/package.json b/package.json index aabd4926..4a8d6057 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "files": [ "dist", "docs", + "examples", "scripts", + "skills", "README.md" ], "publishConfig": { From 172d8bb6594deefac8f65df917fd7161e094644f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 12:36:03 +0530 Subject: [PATCH 04/41] feat(skills): gate local agent delegation experiment --- docs/agent-profile-schema.md | 13 +++---------- docs/chatgpt-coding-workflow.md | 10 ++++++---- docs/configuration.md | 7 ++++--- docs/gotchas.md | 8 ++++---- skills/local-agent-delegation/SKILL.md | 20 ++++---------------- src/cli.ts | 3 ++- src/config.test.ts | 7 +++++++ src/config.ts | 5 +++++ src/skills.test.ts | 16 +++++++++++++++- src/skills.ts | 13 +++++++++++-- src/user-config.ts | 1 + 11 files changed, 62 insertions(+), 41 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index dcaa3c75..20709160 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -4,16 +4,9 @@ DevSpace local agent profiles are user-owned markdown files with YAML front matt They describe how a local coding-agent CLI can be used as a worker under ChatGPT supervision. -Profiles are intended to live in: - -```text -~/.devspace/agents/*.md -``` - The packaged files in `examples/agents/` are starter templates only. DevSpace does -not automatically activate them, copy them into `~/.devspace/agents`, or run their -commands. Users should copy, review, and edit a template before treating it as an -active local worker definition. +not currently parse, load, activate, or run local agent profile definitions. A +future profile loader can define the user-owned directory for active profiles. ## Minimal shape @@ -339,7 +332,7 @@ The body is model-facing guidance. Keep it practical and concise. The current examples do not add: -- `.devspace/agents` parsing. +- local agent profile parsing. - automatic activation of packaged examples. - `devspace agents init`. - generated available-agent catalogs. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 593987a8..2a734337 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -87,13 +87,13 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: -- the bundled `local-agent-delegation` skill, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` Example local coding-agent profiles are packaged under `examples/agents/` for -users who want starter templates for `~/.devspace/agents/*.md`. These examples -are not activated automatically. +users who want starter templates. These examples are inert: DevSpace does not +currently parse, load, activate, or run local agent profile definitions. Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. @@ -105,7 +105,9 @@ Skill paths may be outside the workspace. DevSpace only permits reading: - advertised `SKILL.md` files - files under a skill directory after that skill's `SKILL.md` has been read -Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. +Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Set +`DEVSPACE_LOCAL_AGENTS=1` to expose the experimental `local-agent-delegation` +skill. ## Tool Names diff --git a/docs/configuration.md b/docs/configuration.md index 2a97376e..29cf1169 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,6 +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_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | @@ -110,13 +111,13 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: -- the bundled `local-agent-delegation` skill, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` Starter local coding-agent profile templates are available under -`examples/agents/`. Users can copy them into `~/.devspace/agents/` and edit them -for their local CLIs. DevSpace does not activate packaged examples automatically. +`examples/agents/`. These files are inert examples: DevSpace does not currently +parse, load, activate, or run local agent profile definitions. 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 bb584b28..e6874d1b 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,13 +201,13 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: -- the bundled `local-agent-delegation` skill, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists +- the bundled `local-agent-delegation` skill when `DEVSPACE_LOCAL_AGENTS=1`, unless `~/.devspace/skills/local-agent-delegation/SKILL.md` exists - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -Packaged local-agent examples under `examples/agents/` are templates only. Copy -and review a profile before using it from `~/.devspace/agents/`; repo-provided or -packaged examples should not become runnable worker definitions silently. +Packaged local-agent examples under `examples/agents/` are inert templates only. +DevSpace does not currently parse, load, activate, or run local agent profile +definitions. 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/local-agent-delegation/SKILL.md index c50173c7..4324bcbf 100644 --- a/skills/local-agent-delegation/SKILL.md +++ b/skills/local-agent-delegation/SKILL.md @@ -50,15 +50,11 @@ pi -p --mode json "$PROMPT" copilot -p "$PROMPT" --output-format json ``` -Use exact command templates from user-configured DevSpace agent profiles when they are available. Do not invent provider-specific flags when a profile already defines them. +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. -Configured local agent profiles may live in: +Packaged files under `examples/agents/` are templates only. DevSpace does not currently parse, load, activate, or run local agent profile definitions. -```text -~/.devspace/agents/*.md -``` - -Treat those profiles as user-approved local worker definitions. If no profile exists for a requested agent, use the installed CLI's help output only when needed, then summarize what you found before running it. +If no command shape exists for a requested agent, use the installed CLI's help output only when needed, then summarize what you found before running it. ## Background execution @@ -174,14 +170,6 @@ 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 allow project-provided agent profiles to run automatically unless the user has trusted them. - -Prefer user-global profiles from: - -```text -~/.devspace/agents -``` - -over repo-provided profiles. +Do not treat repo-provided profile examples as trusted executable definitions. Never hide that a local agent was used. diff --git a/src/cli.ts b/src/cli.ts index b7b264cf..c1081e81 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -134,6 +134,7 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, + localAgents: files.config.localAgents, }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -141,7 +142,7 @@ async function runInit({ force }: { force: boolean }): Promise { const configPath = writeDevspaceConfig(config); const authPath = writeDevspaceAuth(auth); - const seededSkillPaths = ensureDevspaceDefaultSkills(); + const seededSkillPaths = config.localAgents ? ensureDevspaceDefaultSkills() : []; const lines = [ `Config: ${configPath}`, diff --git a/src/config.test.ts b/src/config.test.ts index 55f41596..09d82444 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -27,8 +27,13 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, " assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); +assert.equal(loadConfig(baseEnv).localAgents, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_LOCAL_AGENTS: "1" }).localAgents, + true, +); const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-")); const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }); @@ -159,6 +164,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", + localAgents: true, }), ); writeFileSync( @@ -172,6 +178,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.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index 4b970123..2fb7ec9a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,7 @@ export interface ServerConfig { skillsEnabled: boolean; skillPaths: string[]; devspaceSkillsDir: string; + localAgents: boolean; agentDir: string; logging: LoggingConfig; } @@ -237,6 +238,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), + localAgents: + env.DEVSPACE_LOCAL_AGENTS === undefined + ? files.config.localAgents === true + : parseBoolean(env.DEVSPACE_LOCAL_AGENTS), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), logging: parseLoggingConfig(env), }; diff --git a/src/skills.test.ts b/src/skills.test.ts index c4bfe231..86b7c402 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -160,11 +160,25 @@ 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"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "local-agent-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); + const experimentalConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_LOCAL_AGENTS: "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", + ), + true, + ); + const duplicateConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, diff --git a/src/skills.ts b/src/skills.ts index 9ad49e5e..61678e6c 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -38,7 +38,9 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] resolve(cwd, ".agents", "skills"), config.devspaceSkillsDir, join(config.agentDir, "skills"), - hasLocalAgentDelegationSkill(config.devspaceSkillsDir) ? undefined : bundledSkills, + config.localAgents && !hasLocalAgentDelegationSkill(config.devspaceSkillsDir) + ? bundledSkills + : undefined, ]; const defaultPaths = defaultPathCandidates.filter( (path): path is string => path !== undefined && existsSync(path), @@ -61,12 +63,19 @@ function resolveSkillPath(path: string, cwd: string): string { export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; - return loadSkills({ + const result = loadSkills({ cwd, agentDir: config.agentDir, skillPaths: effectiveSkillPaths(config, cwd), includeDefaults: false, }); + + if (config.localAgents) return result; + + return { + skills: result.skills.filter((skill) => skill.name !== "local-agent-delegation"), + diagnostics: result.diagnostics, + }; } export function resolveSkillReadPath( diff --git a/src/user-config.ts b/src/user-config.ts index dcf718c2..96ca116a 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -18,6 +18,7 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; + localAgents?: boolean; } export interface DevspaceAuthConfig { From 5c36d0fd7fb7df8e8b5f6bbbb6e779de74e56caa Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 17:01:50 +0530 Subject: [PATCH 05/41] fix(config): honor local agents env during init --- src/cli.ts | 3 ++- src/config.test.ts | 6 +++++- src/user-config.ts | 8 ++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index c1081e81..8010120d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { ensureDevspaceDefaultSkills, generateOwnerToken, loadDevspaceFiles, + resolveLocalAgentsFlag, writeDevspaceAuth, writeDevspaceConfig, type DevspaceUserConfig, @@ -134,7 +135,7 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, - localAgents: files.config.localAgents, + localAgents: resolveLocalAgentsFlag(files.config), }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), diff --git a/src/config.test.ts b/src/config.test.ts index 09d82444..e6054f00 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 } from "./user-config.js"; +import { ensureDevspaceDefaultSkills, resolveLocalAgentsFlag } from "./user-config.js"; const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); const baseEnv = { @@ -34,6 +34,10 @@ assert.equal( loadConfig({ ...baseEnv, DEVSPACE_LOCAL_AGENTS: "1" }).localAgents, 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); const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-")); const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }); diff --git a/src/user-config.ts b/src/user-config.ts index 96ca116a..b8c9a0ba 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -103,6 +103,14 @@ export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env return [targetPath]; } +export function resolveLocalAgentsFlag( + 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()); +} + function readJsonFile(filePath: string): T { try { return JSON.parse(readFileSync(filePath, "utf8")) as T; From afd669498d56ea320070a4b6bdd630ea4cadadf3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 17:02:53 +0530 Subject: [PATCH 06/41] fix(skills): hide local agent diagnostics when disabled --- src/skills.test.ts | 30 ++++++++++++++++++++++++++++++ src/skills.ts | 10 +++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/skills.test.ts b/src/skills.test.ts index 86b7c402..af396bbc 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -31,8 +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(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); + await mkdir(join(explicitSkills, "local-agent-delegation"), { recursive: true }); await mkdir(join(devspaceSkills, "devspace-local-skill"), { recursive: true }); await writeFile( @@ -123,6 +125,28 @@ try { "# Duplicate Skill", ].join("\n"), ); + await writeFile( + join(agentDir, "skills", "local-agent-delegation", "SKILL.md"), + [ + "---", + "name: local-agent-delegation", + "description: Hidden local agent skill winner.", + "---", + "", + "# Local Agent Delegation", + ].join("\n"), + ); + await writeFile( + join(explicitSkills, "local-agent-delegation", "SKILL.md"), + [ + "---", + "name: local-agent-delegation", + "description: Hidden local agent skill loser.", + "---", + "", + "# Local Agent Delegation Duplicate", + ].join("\n"), + ); await writeFile( join(explicitSkills, "disabled", "SKILL.md"), [ @@ -164,6 +188,12 @@ try { 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", + ), + false, + ); const experimentalConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, diff --git a/src/skills.ts b/src/skills.ts index 61678e6c..c71b7ef7 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -21,7 +21,8 @@ export interface SkillReadResolution { isSkillFile: boolean; } -const LOCAL_AGENT_DELEGATION_SKILL = join("local-agent-delegation", "SKILL.md"); +const LOCAL_AGENT_DELEGATION_NAME = "local-agent-delegation"; +const LOCAL_AGENT_DELEGATION_SKILL = join(LOCAL_AGENT_DELEGATION_NAME, "SKILL.md"); function bundledSkillsDir(): string { return fileURLToPath(new URL("../skills", import.meta.url)); @@ -73,8 +74,11 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk if (config.localAgents) return result; return { - skills: result.skills.filter((skill) => skill.name !== "local-agent-delegation"), - diagnostics: result.diagnostics, + skills: result.skills.filter((skill) => skill.name !== LOCAL_AGENT_DELEGATION_NAME), + diagnostics: result.diagnostics.filter((diagnostic) => { + const collision = diagnostic.collision; + return !(collision?.resourceType === "skill" && collision.name === LOCAL_AGENT_DELEGATION_NAME); + }), }; } From 89beea99fa10665b1943302f7518baac7496edf1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 17:03:07 +0530 Subject: [PATCH 07/41] docs(skills): note safe local agent argv handling --- skills/local-agent-delegation/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/local-agent-delegation/SKILL.md b/skills/local-agent-delegation/SKILL.md index 4324bcbf..5af2f9a4 100644 --- a/skills/local-agent-delegation/SKILL.md +++ b/skills/local-agent-delegation/SKILL.md @@ -50,6 +50,8 @@ pi -p --mode json "$PROMPT" copilot -p "$PROMPT" --output-format json ``` +These examples are illustrative. Real implementations should pass workspace and prompt values as separate argv entries without a shell, not concatenate untrusted prompt text into a shell command string. + Use exact command templates from user-provided instructions when they are available. Do not invent provider-specific flags when the user has already supplied a command shape. Packaged files under `examples/agents/` are templates only. DevSpace does not currently parse, load, activate, or run local agent profile definitions. From 4c436061a37369d499f6b31ad15dd0515641604a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 18:05:59 +0530 Subject: [PATCH 08/41] fix(agents): correct local agent example commands --- examples/agents/claude-implementer.md | 2 ++ examples/agents/copilot-reviewer.md | 6 +++++- examples/agents/cursor-agent-worker.md | 1 + examples/agents/opencode-explorer.md | 2 +- examples/agents/pi-reviewer.md | 2 +- 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/examples/agents/claude-implementer.md b/examples/agents/claude-implementer.md index 58f91ed0..235d7e5f 100644 --- a/examples/agents/claude-implementer.md +++ b/examples/agents/claude-implementer.md @@ -24,6 +24,7 @@ actions: - -p - --output-format - stream-json + - --verbose - "{prompt}" background: true output: stream-json @@ -37,6 +38,7 @@ actions: - -p - --output-format - stream-json + - --verbose - "{prompt}" list: diff --git a/examples/agents/copilot-reviewer.md b/examples/agents/copilot-reviewer.md index f3b66074..640b405a 100644 --- a/examples/agents/copilot-reviewer.md +++ b/examples/agents/copilot-reviewer.md @@ -21,17 +21,21 @@ actions: start: command: copilot args: + - --model + - auto - -p - "{prompt}" - --output-format - json background: true - output: json + output: jsonl followup: strategy: resume_command command: copilot args: + - --model + - auto - -p - "{prompt}" - --resume diff --git a/examples/agents/cursor-agent-worker.md b/examples/agents/cursor-agent-worker.md index 4dffef2a..89be2b17 100644 --- a/examples/agents/cursor-agent-worker.md +++ b/examples/agents/cursor-agent-worker.md @@ -24,6 +24,7 @@ actions: - -p - --output-format - stream-json + - --trust - "{prompt}" background: true output: stream-json diff --git a/examples/agents/opencode-explorer.md b/examples/agents/opencode-explorer.md index 93c7a846..9a86ce6a 100644 --- a/examples/agents/opencode-explorer.md +++ b/examples/agents/opencode-explorer.md @@ -28,7 +28,7 @@ actions: - "{workspace}" - "{prompt}" background: true - output: json + output: jsonl followup: strategy: resume_command diff --git a/examples/agents/pi-reviewer.md b/examples/agents/pi-reviewer.md index 27510c27..21e842e9 100644 --- a/examples/agents/pi-reviewer.md +++ b/examples/agents/pi-reviewer.md @@ -26,7 +26,7 @@ actions: - json - "{prompt}" background: true - output: json + output: jsonl followup: strategy: resume_command From e582d3af15d0817248747c8b970e7dc69c16cbf9 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 04:08:16 +0530 Subject: [PATCH 09/41] 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 10/41] 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 11/41] 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 12/41] 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 13/41] 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 14/41] 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 15/41] 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 16/41] 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 17/41] 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 18/41] 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 19/41] 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 20/41] 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 21/41] 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 22/41] 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 23/41] 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 24/41] 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 25/41] 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 26/41] 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 27/41] 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 28/41] 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 29/41] 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 30/41] 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 31/41] 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 32/41] 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 33/41] 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 34/41] 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 35/41] 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 36/41] 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 37/41] 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 38/41] 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 39/41] 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 40/41] 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 41/41] 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; + } +}