From 03f68709cbbd35486919420815cf89036033947c Mon Sep 17 00:00:00 2001 From: starlove7 Date: Wed, 9 Sep 2026 04:30:42 +0000 Subject: [PATCH 01/13] feat: Add antigravity acp provider --- README.md | 22 ++++++++++++++++++++ docs/agent-profile-schema.md | 11 +++++----- docs/configuration.md | 2 +- schema/v1/devspace.schema.json | 3 ++- src/local-agent-acp.test.ts | 2 ++ src/local-agent-acp.ts | 10 +++++++-- src/local-agent-adapters.ts | 1 + src/local-agent-availability.test.ts | 6 ++++++ src/local-agent-availability.ts | 6 ++++++ src/local-agent-errors.ts | 1 + src/local-agent-profiles.ts | 5 +++-- src/ui/assets/provider-logos/antigravity.svg | 1 + src/ui/icons.ts | 4 ++++ 13 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 src/ui/assets/provider-logos/antigravity.svg diff --git a/README.md b/README.md index fed1d2049..0e717f4fa 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,30 @@ DevSpace gives ChatGPT tools to: - use isolated Git worktrees for parallel coding sessions - follow project instructions from `AGENTS.md` and `CLAUDE.md` - discover local agent skills from your skill folders +- run bounded subagents (`devspace agents run`) across multiple providers - show tool cards and optional change summaries in ChatGPT Apps-compatible hosts +### Subagent Providers + +DevSpace supports delegating tasks to local coding subagents: + +- **Codex**: `codex app-server` +- **Claude**: Claude Code SDK +- **OpenCode**: OpenCode SDK +- **Pi**: Embedded Pi Coding Agent SDK +- **Cursor**: ACP (`cursor-agent acp`) +- **GitHub Copilot**: ACP (`copilot --acp`) +- **Grok**: ACP (`grok agent stdio`) +- **Google Antigravity**: ACP (`agy_acp_server`) + +#### Google Antigravity Setup + +Antigravity operates via the Agent Client Protocol (ACP). Because the standard `agy` CLI is designed for terminal interactions, ACP communication requires the official `agy_acp_server` binary from the [ACP Registry](https://github.com/agentclientprotocol/registry): + +1. **Download**: Obtain the `agy_acp_server` distribution archive for your platform (Linux, macOS, Windows) from the [ACP Registry manifest](https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json). +2. **Install**: Extract the binary and place it in your `PATH` as `agy_acp_server` (e.g., in `~/.local/bin/`), or set the `ANTIGRAVITY_COMMAND` environment variable to the executable path. +3. **Authentication**: Set up your authentication method in `~/.gemini/antigravity-acp/settings.json` (e.g., `{"auth": {"type": "oauth-personal"}}` for personal Google OAuth, or Gemini/Vertex API key). + ## Mental Model DevSpace is remote access to selected local folders. diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 5ede01ad1..e0e328fef 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -74,6 +74,7 @@ provider: pi provider: cursor provider: copilot provider: grok +provider: antigravity ``` Unsupported or custom providers are rejected. DevSpace maps providers to their @@ -86,12 +87,12 @@ native integration: - `cursor`: ACP - `copilot`: ACP - `grok`: Grok Build ACP (`grok agent stdio`) +- `antigravity`: Google Antigravity ACP (`agy_acp_server`) -Codex is resolved from the user's environment rather than bundled with -DevSpace. Run `codex login` normally before using it; set `CODEX_COMMAND` when -the executable is not on the normal PATH. OpenCode, Cursor, and Copilot -runtimes are started and reused by the daemon internally, while Pi is embedded -through its Node SDK. +Codex and Antigravity ACP servers are resolved from the user's environment rather than bundled with +DevSpace. Set `ANTIGRAVITY_COMMAND` when the `agy_acp_server` executable is not on the normal PATH. +OpenCode, Cursor, Copilot, and Antigravity runtimes are started and reused by the daemon internally, +while Pi is embedded through its Node SDK. ### `model` diff --git a/docs/configuration.md b/docs/configuration.md index 2ed489c97..d25e24ab6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -160,7 +160,7 @@ available in the current workspace. Provider executable discovery remains process-scoped. The supported overrides are `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, -`COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. DevSpace does not +`COPILOT_COMMAND`, `GROK_COMMAND`, `GROK_AGENT_PROFILE`, and `ANTIGRAVITY_COMMAND`. DevSpace does not persist provider credentials. ## Native artifact download diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index 4c5fb62e5..85fa77a4c 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -188,7 +188,8 @@ "pi", "cursor", "copilot", - "grok" + "grok", + "antigravity" ] }, "enabled": { diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 39227fa66..bfe42dc72 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -285,6 +285,7 @@ assert.deepEqual(acpCommandArgs("copilot", { ...cachedContext, writeMode: "read_ assert.deepEqual(acpCommandArgs("copilot", { ...cachedContext, writeMode: "full_access" }), [ "--acp", "--no-sandbox", "--allow-all", "-C", resolvedProject, ]); +assert.deepEqual(acpCommandArgs("antigravity", cachedContext), []); const missingCommandDriver = new AcpLocalAgentDriver( "cursor", @@ -336,6 +337,7 @@ if (process.platform !== "win32") { await writeFile(candidate, `#!/bin/sh\ntouch '${marker}'\nexit 0\n`, { mode: 0o700 }); await chmod(candidate, 0o700); assert.equal(resolveAcpCommand("cursor", { PATH: commandRoot }), candidate); + assert.equal(resolveAcpCommand("antigravity", { ANTIGRAVITY_COMMAND: candidate }), candidate); assert.equal(existsSync(marker), false, "ACP command discovery must not execute PATH candidates"); } finally { await rm(commandRoot, { recursive: true, force: true }); diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 043939cf6..0b36ee27c 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -29,7 +29,7 @@ import type { LocalAgentWriteMode, } from "./local-agent-runtime.js"; -export type AcpProvider = "cursor" | "copilot" | "grok"; +export type AcpProvider = "cursor" | "copilot" | "grok" | "antigravity"; const MAX_ACP_QUEUE_ITEMS = 10_000; const MAX_ACP_STDERR_BYTES = 32 * 1024; @@ -44,6 +44,7 @@ const ACP_COMMANDS: Record = { cursor: ["cursor-agent", "acp"], copilot: ["copilot", "--acp"], grok: ["grok", "agent", "stdio"], + antigravity: ["agy_acp_server"], }; interface AcpConnectionLike { @@ -615,7 +616,9 @@ export function resolveAcpCommand( ? env.CURSOR_COMMAND : provider === "copilot" ? env.COPILOT_COMMAND - : env.GROK_COMMAND; + : provider === "grok" + ? env.GROK_COMMAND + : env.ANTIGRAVITY_COMMAND ?? env.AGY_ACP_COMMAND; const command = configured ?? ACP_COMMANDS[provider][0]; if (command.includes("/") || command.includes("\\")) return executableExists(command) ? command : undefined; const path = env.PATH; @@ -641,6 +644,9 @@ export function acpCommandArgs( env: NodeJS.ProcessEnv = process.env, ): string[] { const writeMode = context.writeMode ?? "allowed"; + if (provider === "antigravity") { + return []; + } if (provider === "cursor") { return [ "acp", diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 03a5cc40c..3f06f3a10 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -43,6 +43,7 @@ export function createLocalAgentDrivers( new AcpLocalAgentDriver("cursor", options.env), new AcpLocalAgentDriver("copilot", options.env), new AcpLocalAgentDriver("grok", options.env), + new AcpLocalAgentDriver("antigravity", options.env), ]; } diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 7e0ebc1ce..96dc00816 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -4,9 +4,15 @@ import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availab const snapshot = getLocalAgentProviderAvailabilitySnapshot({ ...process.env, CODEX_COMMAND: "/definitely/missing/devspace-codex", + ANTIGRAVITY_COMMAND: "/definitely/missing/devspace-antigravity", }); assert.deepEqual(snapshot.find((provider) => provider.name === "codex"), { name: "codex", available: false, reason: "/definitely/missing/devspace-codex executable not found", }); +assert.deepEqual(snapshot.find((provider) => provider.name === "antigravity"), { + name: "antigravity", + available: false, + reason: "/definitely/missing/devspace-antigravity executable not found", +}); diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 3a67b98f7..956140893 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -37,6 +37,12 @@ function checkLocalAgentProviderAvailability( return commandAvailability(provider, env.COPILOT_COMMAND ?? "copilot", env); case "grok": return commandAvailability(provider, env.GROK_COMMAND ?? "grok", env); + case "antigravity": + return commandAvailability( + provider, + env.ANTIGRAVITY_COMMAND ?? env.AGY_ACP_COMMAND ?? "agy_acp_server", + env, + ); } } diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index 0df50b867..dfaa7932a 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -451,6 +451,7 @@ function displayProvider(provider: LocalAgentProvider): string { case "cursor": return "Cursor"; case "copilot": return "Copilot"; case "grok": return "Grok"; + case "antigravity": return "Antigravity"; } } diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index ad99a225c..2608c5d6c 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -4,7 +4,7 @@ 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" | "grok"; +export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot" | "grok" | "antigravity"; export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ "codex", @@ -14,6 +14,7 @@ export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ "cursor", "copilot", "grok", + "antigravity", ]; export interface LocalAgentProfile { @@ -161,7 +162,7 @@ function readProvider(frontmatter: Record, filePath: string): L } if (!PROVIDERS.has(provider as LocalAgentProvider)) { throw new Error( - `Subagent profile provider must be codex, claude, opencode, pi, cursor, copilot, or grok: ${filePath}`, + `Subagent profile provider must be codex, claude, opencode, pi, cursor, copilot, grok, or antigravity: ${filePath}`, ); } return provider as LocalAgentProvider; diff --git a/src/ui/assets/provider-logos/antigravity.svg b/src/ui/assets/provider-logos/antigravity.svg new file mode 100644 index 000000000..3ed10ab65 --- /dev/null +++ b/src/ui/assets/provider-logos/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 5d489e358..cc7ed2f7d 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -62,6 +62,10 @@ const providerLogos = { light: new URL("./assets/provider-logos/pi-on-light.svg", import.meta.url).href, dark: new URL("./assets/provider-logos/pi-on-dark.svg", import.meta.url).href, }, + antigravity: { + light: new URL("./assets/provider-logos/antigravity.svg", import.meta.url).href, + dark: new URL("./assets/provider-logos/antigravity.svg", import.meta.url).href, + }, } as const; export type ProviderLogoTheme = "light" | "dark"; From ddd99f21591877f4b5c75772c59162cba67c9c2d Mon Sep 17 00:00:00 2001 From: starlove7 Date: Wed, 9 Sep 2026 12:44:13 +0000 Subject: [PATCH 02/13] fix(acp): resolve antigravity command per platform and update acp docs --- README.md | 1 + docs/agent-profile-schema.md | 3 +- docs/configuration.md | 2 +- src/local-agent-acp.test.ts | 115 ++++++++++++++++++++++++++++++++++- src/local-agent-acp.ts | 11 +++- 5 files changed, 125 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0e717f4fa..5245e1e36 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ Antigravity operates via the Agent Client Protocol (ACP). Because the standard ` 1. **Download**: Obtain the `agy_acp_server` distribution archive for your platform (Linux, macOS, Windows) from the [ACP Registry manifest](https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json). 2. **Install**: Extract the binary and place it in your `PATH` as `agy_acp_server` (e.g., in `~/.local/bin/`), or set the `ANTIGRAVITY_COMMAND` environment variable to the executable path. 3. **Authentication**: Set up your authentication method in `~/.gemini/antigravity-acp/settings.json` (e.g., `{"auth": {"type": "oauth-personal"}}` for personal Google OAuth, or Gemini/Vertex API key). +4. **Restart Daemon**: Environment variable lookups are process-scoped. After newly setting or updating `PATH` or `ANTIGRAVITY_COMMAND`, restart the DevSpace daemon (`devspace agents daemon stop` or restart `devspace serve`) so that changes are reflected in the process environment. ## Mental Model diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index e0e328fef..9fecf4993 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -89,8 +89,9 @@ native integration: - `grok`: Grok Build ACP (`grok agent stdio`) - `antigravity`: Google Antigravity ACP (`agy_acp_server`) -Codex and Antigravity ACP servers are resolved from the user's environment rather than bundled with +Codex app-server and Antigravity ACP server are resolved from the user's environment rather than bundled with DevSpace. Set `ANTIGRAVITY_COMMAND` when the `agy_acp_server` executable is not on the normal PATH. +Because environment variable lookups and executable discovery are process-scoped, if you set or modify `PATH` or `ANTIGRAVITY_COMMAND` while DevSpace is running, restart the DevSpace daemon (`devspace agents daemon stop` or restart `devspace serve`) so that changes take effect. OpenCode, Cursor, Copilot, and Antigravity runtimes are started and reused by the daemon internally, while Pi is embedded through its Node SDK. diff --git a/docs/configuration.md b/docs/configuration.md index d25e24ab6..858d945fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,7 +161,7 @@ available in the current workspace. Provider executable discovery remains process-scoped. The supported overrides are `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, `GROK_COMMAND`, `GROK_AGENT_PROFILE`, and `ANTIGRAVITY_COMMAND`. DevSpace does not -persist provider credentials. +persist provider credentials. If you set or modify these environment variables or `PATH` while DevSpace is running, restart the daemon (`devspace agents daemon stop` or restart `devspace serve`) so that the running process picks up the changes. ## Native artifact download diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index bfe42dc72..3919fbc7a 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, relative, resolve } from "node:path"; import { AcpLocalAgentDriver, AcpRuntime, @@ -285,7 +285,10 @@ assert.deepEqual(acpCommandArgs("copilot", { ...cachedContext, writeMode: "read_ assert.deepEqual(acpCommandArgs("copilot", { ...cachedContext, writeMode: "full_access" }), [ "--acp", "--no-sandbox", "--allow-all", "-C", resolvedProject, ]); -assert.deepEqual(acpCommandArgs("antigravity", cachedContext), []); +assert.deepEqual( + acpCommandArgs("antigravity", cachedContext), + process.platform === "linux" ? ["--uid="] : [], +); const missingCommandDriver = new AcpLocalAgentDriver( "cursor", @@ -327,6 +330,59 @@ if (process.platform === "win32") { } finally { await rm(shimRoot, { recursive: true, force: true }); } + + const daemonDir = process.cwd(); + const relTestDir = await mkdtemp(join(daemonDir, ".tmp-devspace-acp-rel-test-")); + const relCandidate = join(relTestDir, "agy_acp_server.cmd"); + const relMarker = join(tmpdir(), `devspace-acp-marker-${Date.now()}.txt`); + const relWorkspaceRoot = await mkdtemp(join(tmpdir(), "devspace-acp-diff-workspace-")); + try { + await writeFile(relCandidate, `@ECHO OFF\r\ncd > "${relMarker}"\r\nexit /b 0\r\n`); + const relPathWithDot = `.\\${relative(daemonDir, relCandidate)}`; + const relPathForwardSlash = `./${relative(daemonDir, relCandidate).replace(/\\/g, "/")}`; + + assert.equal( + resolveAcpCommand("antigravity", { ANTIGRAVITY_COMMAND: relPathWithDot }), + relCandidate, + "resolveAcpCommand must normalize ANTIGRAVITY_COMMAND with .\\ to an absolute path", + ); + assert.equal( + resolveAcpCommand("antigravity", { AGY_ACP_COMMAND: relPathWithDot }), + relCandidate, + "resolveAcpCommand must normalize AGY_ACP_COMMAND with .\\ to an absolute path", + ); + assert.equal( + resolveAcpCommand("antigravity", { ANTIGRAVITY_COMMAND: relPathForwardSlash }), + relCandidate, + "resolveAcpCommand must normalize ANTIGRAVITY_COMMAND with forward slashes to an absolute path", + ); + assert.equal( + resolveAcpCommand("antigravity", { ANTIGRAVITY_COMMAND: ".\\non\\existent\\agy_acp_server.exe" }), + undefined, + "resolveAcpCommand must return undefined for non-existent relative commands", + ); + + const driver = new AcpLocalAgentDriver("antigravity", { + ...process.env, + ANTIGRAVITY_COMMAND: relPathWithDot, + }); + const runtimeResult = await driver.createRuntime({ + ...cachedContext, + provider: "antigravity", + workspaceRoot: relWorkspaceRoot, + }); + assert.equal(runtimeResult.isErr(), true); + if (runtimeResult.isErr()) { + assert.equal(runtimeResult.error.code, "PROVIDER_PROTOCOL_ERROR"); + } + assert.equal(existsSync(relMarker), true, "mock executable must have been executed despite different workspaceRoot"); + const executedCwd = (await readFile(relMarker, "utf8")).trim(); + assert.equal(executedCwd, resolve(relWorkspaceRoot), "executable must run in the specified workspaceRoot"); + } finally { + await rm(relTestDir, { recursive: true, force: true }); + await rm(relWorkspaceRoot, { recursive: true, force: true }); + await rm(relMarker, { force: true }); + } } if (process.platform !== "win32") { @@ -342,6 +398,61 @@ if (process.platform !== "win32") { } finally { await rm(commandRoot, { recursive: true, force: true }); } + + const daemonDir = process.cwd(); + const relTestDir = await mkdtemp(join(daemonDir, ".tmp-devspace-acp-rel-test-")); + const relCandidate = join(relTestDir, "agy_acp_server.par"); + const relMarker = join(tmpdir(), `devspace-acp-marker-${Date.now()}.txt`); + const workspaceRoot = await mkdtemp(join(tmpdir(), "devspace-acp-diff-workspace-")); + try { + await writeFile(relCandidate, `#!/bin/sh\npwd > '${relMarker}'\nexit 0\n`, { mode: 0o700 }); + await chmod(relCandidate, 0o700); + + const relPathWithDot = `./${relative(daemonDir, relCandidate)}`; + const relPathWithoutDot = relative(daemonDir, relCandidate); + + assert.equal( + resolveAcpCommand("antigravity", { ANTIGRAVITY_COMMAND: relPathWithDot }), + relCandidate, + "resolveAcpCommand must normalize ANTIGRAVITY_COMMAND with ./ to an absolute path", + ); + assert.equal( + resolveAcpCommand("antigravity", { AGY_ACP_COMMAND: relPathWithDot }), + relCandidate, + "resolveAcpCommand must normalize AGY_ACP_COMMAND with ./ to an absolute path", + ); + assert.equal( + resolveAcpCommand("antigravity", { ANTIGRAVITY_COMMAND: relPathWithoutDot }), + relCandidate, + "resolveAcpCommand must normalize relative ANTIGRAVITY_COMMAND without leading ./ to an absolute path", + ); + assert.equal( + resolveAcpCommand("antigravity", { ANTIGRAVITY_COMMAND: "./non/existent/agy_acp_server.par" }), + undefined, + "resolveAcpCommand must return undefined for non-existent relative commands", + ); + + const driver = new AcpLocalAgentDriver("antigravity", { + ...process.env, + ANTIGRAVITY_COMMAND: relPathWithDot, + }); + const runtimeResult = await driver.createRuntime({ + ...cachedContext, + provider: "antigravity", + workspaceRoot, + }); + assert.equal(runtimeResult.isErr(), true); + if (runtimeResult.isErr()) { + assert.equal(runtimeResult.error.code, "PROVIDER_PROTOCOL_ERROR"); + } + assert.equal(existsSync(relMarker), true, "mock executable must have been executed despite different workspaceRoot"); + const executedCwd = (await readFile(relMarker, "utf8")).trim(); + assert.equal(executedCwd, resolve(workspaceRoot), "executable must run in the specified workspaceRoot"); + } finally { + await rm(relTestDir, { recursive: true, force: true }); + await rm(workspaceRoot, { recursive: true, force: true }); + await rm(relMarker, { force: true }); + } } const grokRequests: Array<{ method: string; params?: unknown }> = []; diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 0b36ee27c..b4d989b43 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -44,7 +44,9 @@ const ACP_COMMANDS: Record = { cursor: ["cursor-agent", "acp"], copilot: ["copilot", "--acp"], grok: ["grok", "agent", "stdio"], - antigravity: ["agy_acp_server"], + antigravity: process.platform === "win32" + ? ["agy_acp_server.exe"] + : ["agy_acp_server.par"], }; interface AcpConnectionLike { @@ -620,7 +622,10 @@ export function resolveAcpCommand( ? env.GROK_COMMAND : env.ANTIGRAVITY_COMMAND ?? env.AGY_ACP_COMMAND; const command = configured ?? ACP_COMMANDS[provider][0]; - if (command.includes("/") || command.includes("\\")) return executableExists(command) ? command : undefined; + if (command.includes("/") || command.includes("\\")) { + const candidate = resolve(command); + return executableExists(candidate) ? candidate : undefined; + } const path = env.PATH; if (!path) return undefined; const extensions = process.platform === "win32" @@ -645,7 +650,7 @@ export function acpCommandArgs( ): string[] { const writeMode = context.writeMode ?? "allowed"; if (provider === "antigravity") { - return []; + return process.platform === "linux" ? ["--uid="] : []; } if (provider === "cursor") { return [ From 6fe1b79fa1af96f6c034c616a061e99528462ff1 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:46:18 +0530 Subject: [PATCH 03/13] feat(agents): emit compact XML fragments by default --- src/cli.test.ts | 35 +++++++++++++++- src/cli.ts | 71 ++++++++++++++++++++++----------- src/local-agent-presentation.ts | 57 +++++++++++++++++++------- 3 files changed, 124 insertions(+), 39 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 26d41d0ae..06747c171 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -150,7 +150,10 @@ try { }, }); - assert.equal(output.trim(), `${current.id} completed reviewer`); + assert.equal( + output.trim(), + ``, + ); const { stdout: jsonOutput } = await execFileAsync( "node", @@ -217,6 +220,31 @@ try { assert.equal(payload.error.retryable, false); assert.equal(payload.error.target, "missing"); + let xmlCommandFailure: unknown; + try { + await execFileAsync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "run", "missing", "inspect"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + } catch (error) { + xmlCommandFailure = error; + } + assert.ok(xmlCommandFailure, "XML CLI errors should exit non-zero"); + assert.equal( + (xmlCommandFailure as { stderr?: string }).stderr, + 'Unknown subagent profile or provider: missing.\n', + ); + await assert.rejects( execFileAsync( "node", @@ -243,7 +271,10 @@ try { }, ), (error: unknown) => { - assert.match((error as { stderr?: string }).stderr ?? "", /Unknown option: --unknown/); + assert.equal( + (error as { stderr?: string }).stderr, + 'Unknown option: --unknown. Use -- before prompt text that starts with a dash.\n', + ); return true; }, ); diff --git a/src/cli.ts b/src/cli.ts index 4fa06a650..518282045 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,7 @@ import { import { createLocalAgentClient } from "./local-agent-client.js"; import { toAgentErrorPayload, type LocalAgentError } from "./local-agent-errors.js"; import { + formatAgentCommandError, formatAgentObservation, formatAgentReceipt, formatAgentSummary, @@ -521,19 +522,19 @@ async function runAgentsCommand(args: string[]): Promise { switch (subcommand) { case "ls": case "list": - await runAgentsList(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsList(commandArgs, json)); return; case "run": - await runAgentsRun(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsRun(commandArgs, json)); return; case "continue": - await runAgentsContinue(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsContinue(commandArgs, json)); return; case "show": - await runAgentsShow(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsShow(commandArgs, json)); return; case "targets": - await runAgentsTargets(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; case "daemon": await runAgentsDaemon(commandArgs, json); @@ -545,7 +546,7 @@ async function runAgentsCommand(args: string[]): Promise { printAgentsHelp(); return; default: - throw new Error(`Unknown agents command: ${subcommand}`); + writeAgentWorkflowError(`Unknown agents command: ${subcommand}`, json); } } @@ -561,7 +562,7 @@ async function runAgentsTargets(args: string[], json: boolean): Promise { const catalog = buildLocalAgentCatalog(config.subagents, profiles, providers); const output = presentAgentTargetCatalog(catalog); if (json) printJson(output); - else console.log(formatAgentTargetCatalog(output)); + else printAgentXml(formatAgentTargetCatalog(output)); } async function runAgentsList(args: string[], json: boolean): Promise { @@ -569,7 +570,7 @@ async function runAgentsList(args: string[], json: boolean): Promise { const config = loadConfig(); const client = createLocalAgentClient(config); const result = await client.list(resolveCliWorkspaceContext(config.allowedRoots)); - const agents = presentAgentResult(result, json); + const agents = presentAgentWorkflowResult(result, json); if (!agents) return; const summaries = agents.map(presentAgentSummary); @@ -578,14 +579,7 @@ async function runAgentsList(args: string[], json: boolean): Promise { return; } - if (agents.length === 0) { - console.log("No subagent sessions found for this workspace."); - return; - } - - for (const summary of summaries) { - console.log(formatAgentSummary(summary)); - } + printAgentXml(summaries.map(formatAgentSummary).join("\n")); } async function runAgentsRun(args: string[], json: boolean): Promise { @@ -601,14 +595,14 @@ async function runAgentsRun(args: string[], json: boolean): Promise { model: parsed.model, effort: parsed.effort, }); - const record = presentAgentResult(result, json); + const record = presentAgentWorkflowResult(result, json); if (!record) return; const receipt = presentAgentReceipt(record); if (json) { printJson(receipt); return; } - console.log(formatAgentReceipt(receipt)); + printAgentXml(formatAgentReceipt(receipt)); } async function runAgentsContinue(args: string[], json: boolean): Promise { @@ -620,14 +614,14 @@ async function runAgentsContinue(args: string[], json: boolean): Promise { model: parsed.model, effort: parsed.effort, }, scope); - const record = presentAgentResult(result, json); + const record = presentAgentWorkflowResult(result, json); if (!record) return; const receipt = presentAgentReceipt(record); if (json) { printJson(receipt); return; } - console.log(formatAgentReceipt(receipt)); + printAgentXml(formatAgentReceipt(receipt)); } async function runAgentsShow(args: string[], json: boolean): Promise { @@ -638,20 +632,20 @@ async function runAgentsShow(args: string[], json: boolean): Promise { const client = createLocalAgentClient(config); const scope = resolveCliWorkspaceContext(config.allowedRoots); const initial = await client.get(id, scope); - let record = presentAgentResult(initial, json); + let record = presentAgentWorkflowResult(initial, json); if (!record) return; const deadline = Date.now() + 15_000; while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { await sleep(500); - const refreshed = presentAgentResult(await client.get(id, scope), json); + const refreshed = presentAgentWorkflowResult(await client.get(id, scope), json); if (!refreshed) return; record = refreshed; } const observation = presentAgentObservation(record); if (json) printJson(observation); - else console.log(formatAgentObservation(observation)); + else printAgentXml(formatAgentObservation(observation)); } async function runAgentsDaemon(args: string[], json: boolean): Promise { @@ -717,6 +711,37 @@ function presentAgentResult( throw new Error(result.error.message); } +function presentAgentWorkflowResult( + result: BetterResult, + json: boolean, +): T | undefined { + if (result.isOk()) return result.value; + const error = toAgentErrorPayload(result.error); + if (json) printJson({ error }); + else console.error(formatAgentCommandError(error)); + process.exitCode = 1; + return undefined; +} + +async function runAgentWorkflowCommand(json: boolean, command: () => Promise): Promise { + try { + await command(); + } catch (error) { + writeAgentWorkflowError(error instanceof Error ? error.message : String(error), json); + } +} + +function writeAgentWorkflowError(message: string, json: boolean): void { + const error = { code: "AGENT_COMMAND_ERROR", message, retryable: false }; + if (json) printJson({ error }); + else console.error(formatAgentCommandError(error)); + process.exitCode = 1; +} + +function printAgentXml(fragment: string): void { + if (fragment) console.log(fragment); +} + function printJson(value: unknown): void { console.log(JSON.stringify(value)); } diff --git a/src/local-agent-presentation.ts b/src/local-agent-presentation.ts index 21916afa5..9def4d39f 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -38,6 +38,13 @@ export interface AgentFailureOutput { retryable: boolean; } +export interface AgentCommandErrorOutput { + code: string; + message: string; + retryable?: boolean; + agentId?: string; +} + export type AgentObservationOutput = | { id: string; status: "running" } | { id: string; status: "completed"; response?: string } @@ -98,37 +105,59 @@ export function presentAgentObservation(record: LocalAgentRecord): AgentObservat } export function formatAgentTargetCatalog(catalog: AgentTargetCatalogOutput): string { - if (catalog.targets.length === 0) return "No usable subagent targets."; return catalog.targets.map((target) => { - const settings = [ - target.model ? `model=${target.model}` : undefined, - target.effort ? `effort=${target.effort}` : undefined, - ].filter(Boolean).join(" "); + const settings = xmlAttributes({ model: target.model, effort: target.effort }); if (target.kind === "provider") { - return `${target.name} [provider]${settings ? ` ${settings}` : ""}`; + return ``; } - return `${target.name} [profile, ${target.provider}]${settings ? ` ${settings}` : ""} - ${target.description}`; + return `${escapeXmlText(target.description)}`; }).join("\n"); } export function formatAgentReceipt(receipt: AgentReceiptOutput): string { - return `${receipt.id} ${receipt.status}`; + return ``; } export function formatAgentSummary(summary: AgentSummaryOutput): string { - return `${formatAgentReceipt(summary)} ${summary.target}`; + return ``; } export function formatAgentObservation(observation: AgentObservationOutput): string { - const line = formatAgentReceipt(observation); if (observation.status === "completed" && observation.response !== undefined) { - return `${line}\n\n${observation.response}`; + return `${escapeXmlText(observation.response)}`; } if ((observation.status === "failed" || observation.status === "stopped") && observation.error) { - const retryable = observation.error.retryable ? " [retryable]" : ""; - return `${line} ${observation.error.code}: ${observation.error.message}${retryable}`; + return `${escapeXmlText(observation.error.message)}`; } - return line; + return formatAgentReceipt(observation); +} + +export function formatAgentCommandError(error: AgentCommandErrorOutput): string { + const agentId = error.agentId ? ` agent-id="${escapeXmlAttribute(error.agentId)}"` : ""; + return `${escapeXmlText(error.message)}`; +} + +function xmlAttributes(values: Record): string { + return Object.entries(values) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .map(([name, value]) => ` ${name}="${escapeXmlAttribute(value)}"`) + .join(""); +} + +function escapeXmlAttribute(value: string): string { + return escapeXml(value).replaceAll('"', """).replaceAll("'", "'"); +} + +function escapeXmlText(value: string): string { + return escapeXml(value); +} + +function escapeXml(value: string): string { + return value + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "\uFFFD") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); } function presentAgentStatus(status: LocalAgentStatus): AgentCommandStatus { From ba8168821c78af1ed5995acd922b5f1158f7eebf Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:51:30 +0530 Subject: [PATCH 04/13] feat(agents): persist internal turn history --- src/db/migrations.ts | 29 +++++ src/local-agent-manager.test.ts | 18 ++- src/local-agent-manager.ts | 47 ++++--- src/local-agent-store.test.ts | 68 +++++++++- src/local-agent-store.ts | 217 ++++++++++++++++++++++++++++++-- src/oauth-store.test.ts | 1 + 6 files changed, 343 insertions(+), 37 deletions(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index ce6e66062..8bb1f7e4f 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -42,6 +42,11 @@ const migrations: Migration[] = [ name: "workspace-recovery-state", up: migrateWorkspaceRecoveryState, }, + { + version: 8, + name: "local-agent-turns", + up: migrateLocalAgentTurns, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -260,6 +265,30 @@ function migrateWorkspaceRecoveryState(sqlite: Database.Database): void { addColumnIfMissing(sqlite, "workspace_sessions", "recovery_kind", "text"); } +function migrateLocalAgentTurns(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists local_agent_turns ( + id integer primary key autoincrement, + agent_id text not null, + prompt text not null, + status text not null, + response text, + error text, + error_code text, + error_retryable text, + created_at text not null, + completed_at text, + foreign key (agent_id) references local_agent_sessions(id) on delete cascade + ); + + create index if not exists local_agent_turns_agent_id_idx + on local_agent_turns(agent_id, id desc); + + create index if not exists local_agent_turns_status_idx + on local_agent_turns(status); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index a3b50d202..7398454fd 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -122,7 +122,8 @@ const stale = store.create({ profileName: "reviewer", provider: "codex", }); -store.update(stale.id, { status: "running", latestResponse: "previous response" }); +const staleTurn = store.beginTurn(stale.id, { prompt: "interrupted turn" }); +store.update(stale.id, { latestResponse: "previous response" }); const manager = new LocalAgentManager({ store, @@ -223,6 +224,8 @@ assert.equal(getRecord(stale.id).latestResponse, "previous response"); assert.equal(getRecord(stale.id).error, "DevSpace restarted while this agent turn was running."); assert.equal(getRecord(stale.id).errorCode, "DAEMON_UNAVAILABLE"); assert.equal(getRecord(stale.id).errorRetryable, true); +assert.equal(store.getTurnById(staleTurn.turn.id)?.status, "failed"); +assert.equal(store.getTurnById(staleTurn.turn.id)?.errorCode, "DAEMON_UNAVAILABLE"); const first = unwrap(await manager.start({ target: "reviewer", @@ -245,6 +248,10 @@ runtimes.get(first.id)!.release(); await waitFor(() => getRecord(first.id).status === "idle"); assert.equal(getRecord(first.id).providerSessionId, "thread_test"); assert.match(getRecord(first.id).latestResponse ?? "", /Task:\nhold/); +assert.deepEqual( + store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })), + [{ prompt: "hold", status: "completed" }], +); const continued = unwrap(await manager.continue(first.id, "continue", { model: "gpt-run", @@ -254,6 +261,13 @@ assert.equal(continued.status, "running"); await waitFor(() => getRecord(first.id).status === "idle"); assert.equal(getRecord(first.id).model, "gpt-run"); assert.equal(getRecord(first.id).effort, "high"); +assert.deepEqual( + store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })), + [ + { prompt: "hold", status: "completed" }, + { prompt: "continue", status: "completed" }, + ], +); const second = unwrap(await manager.start({ target: "reviewer", @@ -275,6 +289,8 @@ await waitFor(() => getRecord(failed.id).status === "error"); assert.equal(getRecord(failed.id).error, "provider failed"); assert.equal(getRecord(failed.id).errorCode, "PROVIDER_EXECUTION_ERROR"); assert.equal(getRecord(failed.id).errorRetryable, false); +assert.equal(store.getLatestTurn(failed.id)?.status, "failed"); +assert.equal(store.getLatestTurn(failed.id)?.error, "provider failed"); const recovered = unwrap(await manager.continue(failed.id, "recovered", {}, scope)); assert.equal(recovered.status, "running", "provider Err releases active-turn ownership"); await waitFor(() => getRecord(failed.id).status === "idle"); diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index dbd80d86b..ef8a3c720 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -246,28 +246,25 @@ export class LocalAgentManager { })); } - const updated = this.store.updateResult(record.id, { - status: "running", + const begun = this.store.beginTurnResult(record.id, { + prompt, model: overrides.model ?? record.model, effort: overrides.effort ?? record.effort, - latestResponse: undefined, - error: undefined, - errorCode: undefined, - errorRetryable: undefined, }); - if (updated.isErr()) return updated; + if (begun.isErr()) return begun; // Defer invocation until after the tracking entry is visible. This keeps // cleanup correct even if runTurn later gains a synchronous completion path. const turn = Promise.resolve().then(() => ( - this.runTurn(updated.value, prompt, overrides, workspaceId) + this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) )); this.activeTurns.set(record.id, turn); void turn.catch(() => undefined); - return updated; + return Result.ok(begun.value.agent); } private async runTurn( record: LocalAgentRecord, + turnId: number, prompt: string, overrides: RunOverrides, workspaceId?: string, @@ -281,7 +278,7 @@ export class LocalAgentManager { try { const authorized = this.authorizeWorkspace(record.workspaceRoot, workspaceId, "run"); if (authorized.isErr()) { - this.persistRunError(record, authorized.error, startedAt); + this.persistRunError(record, turnId, authorized.error, startedAt); return; } const workspaceRoot = authorized.value; @@ -290,22 +287,22 @@ export class LocalAgentManager { : { ...record, workspaceRoot }; const profiles = await this.loadProfilesResult(workspaceRoot, record.profileName); if (profiles.isErr()) { - this.persistRunError(record, profiles.error, startedAt); + this.persistRunError(record, turnId, profiles.error, startedAt); return; } const profile = this.profileForRecordResult(record, profiles.value); if (profile.isErr()) { - this.persistRunError(record, profile.error, startedAt); + this.persistRunError(record, turnId, profile.error, startedAt); return; } const input = this.buildRunInputResult(authorizedRecord, profile.value, prompt, overrides); if (input.isErr()) { - this.persistRunError(record, input.error, startedAt); + this.persistRunError(record, turnId, input.error, startedAt); return; } const driver = this.driverResult(record.provider, "run", record.id); if (driver.isErr()) { - this.persistRunError(record, driver.error, startedAt); + this.persistRunError(record, turnId, driver.error, startedAt); return; } const context: LocalAgentRuntimeContext = { @@ -329,20 +326,17 @@ export class LocalAgentManager { }; const result = await this.pool.run(driver.value, context, input.value, callbacks); if (result.isErr()) { - this.persistRunError(record, result.error, startedAt); + this.persistRunError(record, turnId, result.error, startedAt); return; } const runResult = result.value; const current = this.store.getByIdResult(record.id); if (current.isErr()) throw current.error; if (!current.value) return; - const updated = this.store.updateResult(record.id, { + const updated = this.store.finishTurnResult(record.id, turnId, { providerSessionId: runResult.providerSessionId ?? current.value.providerSessionId, - status: "idle", - latestResponse: runResult.finalResponse, - error: undefined, - errorCode: undefined, - errorRetryable: undefined, + status: "completed", + response: runResult.finalResponse, }); if (updated.isErr()) throw updated.error; this.log("info", "agent_run_completed", { @@ -353,11 +347,11 @@ export class LocalAgentManager { }); } catch (error) { if (isLocalAgentError(error)) { - this.persistRunError(record, error, startedAt); + this.persistRunError(record, turnId, error, startedAt); return; } - const persisted = this.store.updateResult(record.id, { - status: "error", + const persisted = this.store.finishTurnResult(record.id, turnId, { + status: "failed", error: "Unexpected internal subagent failure.", errorCode: "AGENT_INTERNAL_ERROR", errorRetryable: false, @@ -379,11 +373,12 @@ export class LocalAgentManager { private persistRunError( record: LocalAgentRecord, + turnId: number, error: LocalAgentError, startedAt: number, ): void { - const persisted = this.store.updateResult(record.id, { - status: "error", + const persisted = this.store.finishTurnResult(record.id, turnId, { + status: "failed", error: error.message, errorCode: error.code, errorRetryable: error.retryable, diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index 829940f92..7f4d9440f 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -54,7 +54,66 @@ try { assert.deepEqual(store.list({ workspaceId: "ws_1" }).map((agent) => agent.id), [created.id]); assert.deepEqual(store.list({ workspaceId: "ws_other" }), []); assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "other") }), []); -assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); + assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); + + const begun = store.beginTurn(created.id, { + prompt: "Review the current changes.", + model: updated.model, + effort: updated.effort, + }); + assert.equal(begun.agent.status, "running"); + assert.equal(begun.turn.agentId, created.id); + assert.equal(begun.turn.prompt, "Review the current changes."); + assert.equal(begun.turn.status, "running"); + assert.equal(begun.turn.completedAt, undefined); + + const completed = store.finishTurn(created.id, begun.turn.id, { + status: "completed", + response: "No issues found.", + providerSessionId: "thread_456", + }); + assert.equal(completed.status, "idle"); + assert.equal(completed.latestResponse, "No issues found."); + assert.equal(completed.providerSessionId, "thread_456"); + const completedTurn = store.getLatestTurn(created.id); + assert.equal(completedTurn?.id, begun.turn.id); + assert.equal(completedTurn?.status, "completed"); + assert.equal(completedTurn?.response, "No issues found."); + assert.ok(completedTurn?.completedAt); + + const failing = store.beginTurn(created.id, { + prompt: "Retry the review.", + model: completed.model, + effort: completed.effort, + }); + store.finishTurn(created.id, failing.turn.id, { + status: "failed", + error: "Provider disconnected.", + errorCode: "PROVIDER_EXECUTION_ERROR", + errorRetryable: true, + }); + assert.deepEqual( + store.listTurns(created.id).map((turn) => ({ + prompt: turn.prompt, + status: turn.status, + response: turn.response, + errorCode: turn.errorCode, + })), + [ + { + prompt: "Review the current changes.", + status: "completed", + response: "No issues found.", + errorCode: undefined, + }, + { + prompt: "Retry the review.", + status: "failed", + response: undefined, + errorCode: "PROVIDER_EXECUTION_ERROR", + }, + ], + ); const otherStore = new LocalAgentStore(root); stores.push(otherStore); @@ -69,6 +128,7 @@ assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); store.list({ workspaceId: "ws_1" }).map((agent) => agent.id).sort(), [created.id, createdFromOtherStore.id].sort(), ); + assert.equal(otherStore.listTurns(created.id).length, 2); const legacyStateDir = join(root, "legacy-state"); mkdirSync(legacyStateDir, { recursive: true }); @@ -137,6 +197,12 @@ assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); assert.equal(reloadedRecord?.error, "old error"); assert.equal(reloadedRecord?.errorCode, "DAEMON_TIMEOUT"); assert.equal(reloadedRecord?.errorRetryable, true); + const legacyTurn = upgradedStore.beginTurn("agt_legacy", { + prompt: "Continue after upgrade.", + model: reloadedRecord?.model, + effort: reloadedRecord?.effort, + }); + assert.equal(legacyTurn.turn.status, "running"); } finally { for (const store of stores) { store.close(); diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 74bf875d5..f3fa93c3f 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -5,6 +5,7 @@ import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { AgentStoreError, isProgrammerDefect } from "./local-agent-errors.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; +export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; export interface LocalAgentRecord { id: string; @@ -33,6 +34,35 @@ export interface CreateLocalAgentRecordInput { effort?: string; } +export interface LocalAgentTurnRecord { + id: number; + agentId: string; + prompt: string; + status: LocalAgentTurnStatus; + response?: string; + error?: string; + errorCode?: string; + errorRetryable?: boolean; + createdAt: string; + completedAt?: string; +} + +export interface BeginLocalAgentTurnInput { + prompt: string; + model?: string; + effort?: string; +} + +export type FinishLocalAgentTurnInput = + | { status: "completed"; response?: string; providerSessionId?: string } + | { status: "failed"; error: string; errorCode: string; errorRetryable: boolean } + | { status: "stopped"; error?: string; errorCode?: string; errorRetryable?: boolean }; + +export interface BegunLocalAgentTurn { + agent: LocalAgentRecord; + turn: LocalAgentTurnRecord; +} + export interface LocalAgentWorkspaceScope { workspaceId?: string; workspaceRoot: string; @@ -61,6 +91,19 @@ interface LocalAgentRow { updated_at: string; } +interface LocalAgentTurnRow { + id: number; + agent_id: string; + prompt: string; + status: string; + response: string | null; + error: string | null; + error_code: string | null; + error_retryable: string | null; + created_at: string; + completed_at: string | null; +} + export class LocalAgentStore { private readonly database: DatabaseHandle; @@ -235,16 +278,150 @@ export class LocalAgentStore { return storeResult("update", () => this.update(id, patch)); } + beginTurn(agentId: string, input: BeginLocalAgentTurnInput): BegunLocalAgentTurn { + return this.database.sqlite.transaction(() => { + const agent = this.update(agentId, { + status: "running", + model: input.model, + effort: input.effort, + latestResponse: undefined, + error: undefined, + errorCode: undefined, + errorRetryable: undefined, + }); + const result = this.database.sqlite + .prepare( + `insert into local_agent_turns ( + agent_id, + prompt, + status, + created_at + ) values (?, ?, 'running', ?)`, + ) + .run(agentId, input.prompt, agent.updatedAt); + const turn = this.getTurnById(Number(result.lastInsertRowid)); + if (!turn) throw new Error(`Unable to load the new turn for subagent ${agentId}.`); + return { agent, turn }; + }).immediate(); + } + + beginTurnResult( + agentId: string, + input: BeginLocalAgentTurnInput, + ): BetterResult { + return storeResult("begin_turn", () => this.beginTurn(agentId, input)); + } + + finishTurn( + agentId: string, + turnId: number, + completion: FinishLocalAgentTurnInput, + ): LocalAgentRecord { + return this.database.sqlite.transaction(() => { + const turn = this.getTurnById(turnId); + if (!turn || turn.agentId !== agentId) { + throw new Error(`Unknown turn ${turnId} for subagent ${agentId}.`); + } + if (turn.status !== "running") { + throw new Error(`Turn ${turnId} for subagent ${agentId} is already ${turn.status}.`); + } + const currentAgent = this.getById(agentId); + if (!currentAgent) throw new Error(`Unknown subagent id: ${agentId}`); + + const completedAt = new Date().toISOString(); + this.database.sqlite + .prepare( + `update local_agent_turns set + status = ?, + response = ?, + error = ?, + error_code = ?, + error_retryable = ?, + completed_at = ? + where id = ? and agent_id = ?`, + ) + .run( + completion.status, + completion.status === "completed" ? completion.response ?? null : null, + completion.status === "completed" ? null : completion.error ?? null, + completion.status === "completed" ? null : completion.errorCode ?? null, + completion.status === "completed" || completion.errorRetryable === undefined + ? null + : String(completion.errorRetryable), + completedAt, + turnId, + agentId, + ); + + if (completion.status === "completed") { + return this.update(agentId, { + providerSessionId: completion.providerSessionId ?? currentAgent.providerSessionId, + status: "idle", + latestResponse: completion.response, + error: undefined, + errorCode: undefined, + errorRetryable: undefined, + }); + } + return this.update(agentId, { + status: completion.status === "failed" ? "error" : "stopped", + latestResponse: undefined, + error: completion.error, + errorCode: completion.errorCode, + errorRetryable: completion.errorRetryable, + }); + }).immediate(); + } + + finishTurnResult( + agentId: string, + turnId: number, + completion: FinishLocalAgentTurnInput, + ): BetterResult { + return storeResult("finish_turn", () => this.finishTurn(agentId, turnId, completion)); + } + + getTurnById(turnId: number): LocalAgentTurnRecord | undefined { + const row = this.database.sqlite + .prepare("select * from local_agent_turns where id = ? limit 1") + .get(turnId) as LocalAgentTurnRow | undefined; + return row ? rowToLocalAgentTurnRecord(row) : undefined; + } + + getLatestTurn(agentId: string): LocalAgentTurnRecord | undefined { + const row = this.database.sqlite + .prepare("select * from local_agent_turns where agent_id = ? order by id desc limit 1") + .get(agentId) as LocalAgentTurnRow | undefined; + return row ? rowToLocalAgentTurnRecord(row) : undefined; + } + + listTurns(agentId: string): LocalAgentTurnRecord[] { + const rows = this.database.sqlite + .prepare("select * from local_agent_turns where agent_id = ? order by id asc") + .all(agentId) as LocalAgentTurnRow[]; + return rows.map(rowToLocalAgentTurnRecord); + } + reconcileActiveRuns(message = "DevSpace restarted while this agent turn was running."): number { - const now = new Date().toISOString(); - const result = this.database.sqlite - .prepare( - `update local_agent_sessions - set status = 'error', error = ?, error_code = 'DAEMON_UNAVAILABLE', error_retryable = 'true', updated_at = ? - where status in ('starting', 'running')`, - ) - .run(message, now); - return Number(result.changes); + return this.database.sqlite.transaction(() => { + const now = new Date().toISOString(); + this.database.sqlite + .prepare( + `update local_agent_turns + set status = 'failed', error = ?, error_code = 'DAEMON_UNAVAILABLE', + error_retryable = 'true', completed_at = ? + where status = 'running'`, + ) + .run(message, now); + const result = this.database.sqlite + .prepare( + `update local_agent_sessions + set status = 'error', error = ?, error_code = 'DAEMON_UNAVAILABLE', error_retryable = 'true', updated_at = ? + where status in ('starting', 'running')`, + ) + .run(message, now); + return Number(result.changes); + }).immediate(); } reconcileActiveRunsResult( @@ -283,6 +460,28 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { }; } +function rowToLocalAgentTurnRecord(row: LocalAgentTurnRow): LocalAgentTurnRecord { + return { + id: row.id, + agentId: row.agent_id, + prompt: row.prompt, + status: readTurnStatus(row.status), + response: row.response ?? undefined, + error: row.error ?? undefined, + errorCode: row.error_code ?? undefined, + errorRetryable: readOptionalBoolean(row.error_retryable), + createdAt: row.created_at, + completedAt: row.completed_at ?? undefined, + }; +} + +function readTurnStatus(status: string): LocalAgentTurnStatus { + if (status === "running" || status === "completed" || status === "failed" || status === "stopped") { + return status; + } + throw new Error(`Invalid stored local agent turn status: ${status}`); +} + function readOptionalBoolean(value: string | null): boolean | undefined { if (value === "true") return true; if (value === "false") return false; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index d10e51087..90a8ade25 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -51,6 +51,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 5, name: "local-agent-structured-errors" }, { version: 6, name: "local-agent-effort-rename" }, { version: 7, name: "workspace-recovery-state" }, + { version: 8, name: "local-agent-turns" }, ]); } finally { database.close(); From 9d74d83b6e5d8ca94be90dbc7f4e8fe11b6c010c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:01:14 +0530 Subject: [PATCH 05/13] fix(agents): prevent overlapping persisted turns --- src/local-agent-store.test.ts | 9 +++++++++ src/local-agent-store.ts | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index 7f4d9440f..e29bd319b 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -66,6 +66,15 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot assert.equal(begun.turn.prompt, "Review the current changes."); assert.equal(begun.turn.status, "running"); assert.equal(begun.turn.completedAt, undefined); + assert.throws( + () => store.beginTurn(created.id, { + prompt: "Start overlapping work.", + model: begun.agent.model, + effort: begun.agent.effort, + }), + /already has a running turn/, + ); + assert.equal(store.listTurns(created.id).length, 1); const completed = store.finishTurn(created.id, begun.turn.id, { status: "completed", diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index f3fa93c3f..bdb014a66 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -280,6 +280,11 @@ export class LocalAgentStore { beginTurn(agentId: string, input: BeginLocalAgentTurnInput): BegunLocalAgentTurn { return this.database.sqlite.transaction(() => { + const current = this.getById(agentId); + if (!current) throw new Error(`Unknown subagent id: ${agentId}`); + if (current.status === "running") { + throw new Error(`Subagent ${agentId} already has a running turn.`); + } const agent = this.update(agentId, { status: "running", model: input.model, From 354ebeb34efc72a632e481e7c46fb0b4e1041a73 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:00:00 +0530 Subject: [PATCH 06/13] feat(agents): add event-driven multi-agent wait --- src/cli.test.ts | 81 ++++++++++- src/cli.ts | 66 +++++++-- src/local-agent-client.ts | 46 +++++-- src/local-agent-daemon-lifecycle.ts | 2 +- src/local-agent-daemon-protocol.test.ts | 48 ++++++- src/local-agent-daemon-protocol.ts | 91 +++++++++++++ src/local-agent-daemon.test.ts | 63 ++++++++- src/local-agent-daemon.ts | 29 +++- src/local-agent-manager.test.ts | 70 ++++++++++ src/local-agent-manager.ts | 171 +++++++++++++++++++++++- src/local-agent-presentation.ts | 5 +- src/local-agent-store.ts | 12 ++ 12 files changed, 633 insertions(+), 51 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 06747c171..aa83f2da9 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -8,7 +8,10 @@ import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { loadConfig } from "./config.js"; -import { localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js"; +import { + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + localAgentDaemonPaths, +} from "./local-agent-daemon-lifecycle.js"; import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js"; import { LocalAgentStore } from "./local-agent-store.js"; import { writeTestDevspaceConfig } from "./test-support/config.test.js"; @@ -100,7 +103,7 @@ try { if (request.method === "agent.start") { socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error: { code: "UNKNOWN_TARGET", @@ -113,10 +116,17 @@ try { } const result = request.method === "agent.list" ? [current] + : request.method === "agent.get" + ? current + : request.method === "agent.wait" + ? [ + { id: current.id, status: "completed", response: "Review complete." }, + { id: other.id, status: "running", wait: "timeout" }, + ] : request.method === "hello" ? { state: "ready", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, pid: process.pid, endpoint: daemonSocket, startedAt: "now", @@ -127,7 +137,7 @@ try { : null; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result, })); @@ -192,6 +202,69 @@ try { const directList = [...daemonRequests].reverse().find((request) => request.method === "agent.list"); assert.deepEqual(directList?.params, { workspaceRoot: realpathSync.native(projectRoot) }); + const { stdout: showOutput } = await execFileAsync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "show", current.id], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + assert.equal( + showOutput, + `Review complete.\n`, + ); + assert.equal( + daemonRequests.filter((request) => request.method === "agent.get").length, + 1, + "show must be an immediate snapshot", + ); + + const { stdout: waitOutput } = await execFileAsync( + "node", + [ + "--import", + "tsx", + "src/cli.ts", + "agents", + "wait", + current.id, + other.id, + "--timeout", + "0", + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + assert.equal( + waitOutput, + [ + `Review complete.`, + ``, + "", + ].join("\n"), + ); + const waitRequest = daemonRequests.find((request) => request.method === "agent.wait"); + assert.deepEqual(waitRequest?.params, { + ids: [current.id, other.id], + scope: { workspaceId: "ws_current", workspaceRoot: realpathSync.native(projectRoot) }, + timeoutMs: 0, + }); + let commandFailure: unknown; try { await execFileAsync( diff --git a/src/cli.ts b/src/cli.ts index 518282045..30d0a3d83 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -533,6 +533,9 @@ async function runAgentsCommand(args: string[]): Promise { case "show": await runAgentWorkflowCommand(json, () => runAgentsShow(commandArgs, json)); return; + case "wait": + await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json)); + return; case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; @@ -632,22 +635,62 @@ async function runAgentsShow(args: string[], json: boolean): Promise { const client = createLocalAgentClient(config); const scope = resolveCliWorkspaceContext(config.allowedRoots); const initial = await client.get(id, scope); - let record = presentAgentWorkflowResult(initial, json); + const record = presentAgentWorkflowResult(initial, json); if (!record) return; - const deadline = Date.now() + 15_000; - while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { - await sleep(500); - const refreshed = presentAgentWorkflowResult(await client.get(id, scope), json); - if (!refreshed) return; - record = refreshed; - } - const observation = presentAgentObservation(record); if (json) printJson(observation); else printAgentXml(formatAgentObservation(observation)); } +async function runAgentsWait(args: string[], json: boolean): Promise { + const { ids, timeoutMs } = parseAgentsWaitArgs(args); + const config = loadConfig(); + const client = createLocalAgentClient(config); + const scope = resolveCliWorkspaceContext(config.allowedRoots); + const results = presentAgentWorkflowResult(await client.wait(ids, scope, timeoutMs), json); + if (!results) return; + if (json) { + printJson(results); + return; + } + printAgentXml(results.map(formatAgentObservation).join("\n")); +} + +function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } { + const ids: string[] = []; + let timeoutMs: number | undefined; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (argument === "--timeout") { + timeoutMs = parseAgentWaitTimeout(args[index + 1]); + index += 1; + continue; + } + if (argument.startsWith("--timeout=")) { + timeoutMs = parseAgentWaitTimeout(argument.slice("--timeout=".length)); + continue; + } + if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}.`); + ids.push(argument); + } + if (ids.length === 0) { + throw new Error("Usage: devspace agents wait ... [--timeout ] [--json]"); + } + return { ids, ...(timeoutMs === undefined ? {} : { timeoutMs }) }; +} + +function parseAgentWaitTimeout(value: string | undefined): number { + if (!value || !/^\d+$/.test(value)) { + throw new Error("Agent wait timeout must be a non-negative integer number of seconds."); + } + const timeoutMs = Number(value) * 1_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs > 2_147_483_647) { + throw new Error("Agent wait timeout is too large."); + } + return timeoutMs; +} + async function runAgentsDaemon(args: string[], json: boolean): Promise { const [subcommand, ...extra] = args; if (extra.length > 0) throw new Error("Usage: devspace agents daemon [--json]"); @@ -746,10 +789,6 @@ function printJson(value: unknown): void { console.log(JSON.stringify(value)); } -function sleep(ms: number): Promise { - return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); -} - function printAgentsHelp(): void { console.log( [ @@ -760,6 +799,7 @@ function printAgentsHelp(): void { " devspace agents run [--model ] [--effort ] [--json] ", " devspace agents continue [--model ] [--effort ] [--json] ", " devspace agents show [--json]", + " devspace agents wait ... [--timeout ] [--json]", " devspace agents targets [--json]", " devspace agents daemon [--json]", ].join("\n"), diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 01f8c1cdc..5d31464b6 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -22,6 +22,7 @@ import { import { decodeAgentRecord, decodeAgentRecordList, + decodeAgentWaitResults, decodeDaemonLogs, decodeDaemonStatus, decodeLocalAgentDaemonResponse, @@ -45,6 +46,8 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentWaitError, + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -60,6 +63,7 @@ type RequestError = : M extends "agent.continue" ? AgentContinueError | AgentDaemonError : M extends "agent.get" ? AgentLookupError | AgentDaemonError : M extends "agent.list" ? AgentListError | AgentDaemonError + : M extends "agent.wait" ? AgentWaitError | AgentDaemonError : AgentDaemonError; export interface LocalAgentClientOptions { @@ -134,6 +138,22 @@ export class LocalAgentClient { return decodeRequestResult(result, "agent.list", decodeAgentRecordList); } + async wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + ): Promise> { + const transportTimeoutMs = timeoutMs === undefined + ? null + : Math.min(2_147_483_647, timeoutMs + this.requestTimeoutMs); + const result = await this.request("agent.wait", { + ids: [...agentIds], + scope, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }, transportTimeoutMs); + return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); + } + async status(): Promise> { const result = await this.requestExisting("daemon.status", {}); return decodeRequestResult(result, "daemon.status", decodeDaemonStatus); @@ -308,6 +328,7 @@ export class LocalAgentClient { private async request( method: M, params: Extract['params'], + timeoutMs: number | null = this.requestTimeoutMs, ): Promise>> { const ready = await this.ensureReady(); if (ready.isErr()) return ready as BetterResult>; @@ -319,7 +340,7 @@ export class LocalAgentClient { authToken: authToken.value, method, params, - } as LocalAgentDaemonRequest, this.requestTimeoutMs); + } as LocalAgentDaemonRequest, timeoutMs ?? undefined); if (response.isErr()) return response as BetterResult>; if (!response.value.ok) { const error = decodeRemoteError(response.value.error, method); @@ -459,20 +480,22 @@ export function resolveDaemonEntrypoint(): string { async function sendRequest( endpoint: string, request: LocalAgentDaemonRequest, - timeoutMs: number, + timeoutMs?: number, ): Promise> { return new Promise((resolve) => { const socket = createConnection(endpoint); let buffer = ""; let settled = false; - const timer = setTimeout(() => { - finish(Result.err(new AgentDaemonTimeoutError({ - code: "DAEMON_TIMEOUT", - operation: request.method, - retryable: true, - message: "Timed out waiting for the local agent daemon.", - })), true); - }, timeoutMs); + const timer = timeoutMs === undefined + ? undefined + : setTimeout(() => { + finish(Result.err(new AgentDaemonTimeoutError({ + code: "DAEMON_TIMEOUT", + operation: request.method, + retryable: true, + message: "Timed out waiting for the local agent daemon.", + })), true); + }, timeoutMs); const finish = ( result: BetterResult, @@ -480,7 +503,7 @@ async function sendRequest( ) => { if (settled) return; settled = true; - clearTimeout(timer); + if (timer) clearTimeout(timer); if (destroy) socket.destroy(); resolve(result); }; @@ -599,6 +622,7 @@ function isRequestError( || category === "conflict" || category === "store"; case "agent.get": + case "agent.wait": return category === "target" || category === "scope" || category === "store"; case "agent.list": return category === "scope" || category === "store"; diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index df0b81b95..250bb2319 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 3; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 4; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 708180987..b5fb282c1 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -1,15 +1,17 @@ import assert from "node:assert/strict"; import { decodeAgentRecord, + decodeAgentWaitResults, decodeLocalAgentDaemonRequest, decodeLocalAgentDaemonResponse, encodeLocalAgentDaemonResponse, LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; +import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; const request = decodeLocalAgentDaemonRequest({ requestId: "req_1", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -26,7 +28,7 @@ assert.equal(request.params.writeMode, "read_only"); const whitespaceRequest = decodeLocalAgentDaemonRequest({ requestId: "req_whitespace", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -41,7 +43,7 @@ assert.equal(whitespaceRequest.params.prompt, " keep prompt whitespace \n"); const directRequest = decodeLocalAgentDaemonRequest({ requestId: "req_direct", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -56,7 +58,7 @@ assert.equal(directRequest.params.workspaceId, undefined); assert.throws( () => decodeLocalAgentDaemonRequest({ requestId: "req_2", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { target: "reviewer", prompt: "" }, @@ -83,7 +85,7 @@ assert.equal(directRecord.workspaceId, undefined); const response = decodeLocalAgentDaemonResponse({ requestId: "req_1", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: record, }); @@ -91,7 +93,7 @@ assert.equal(response.ok, true); const errorResponse = decodeLocalAgentDaemonResponse(JSON.parse(encodeLocalAgentDaemonResponse({ requestId: "req_error", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error: { code: "PROVIDER_UNAVAILABLE", @@ -126,3 +128,37 @@ const failedRecord = decodeAgentRecord({ }); assert.equal(failedRecord.errorCode, "DAEMON_TIMEOUT"); assert.equal(failedRecord.errorRetryable, true); + +const waitRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_wait", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "agent.wait", + params: { + ids: ["agt_one", "agt_two"], + scope: { workspaceId: "ws_test", workspaceRoot: "/tmp/project" }, + timeoutMs: 5_000, + }, +}); +assert.equal(waitRequest.method, "agent.wait"); +if (waitRequest.method !== "agent.wait") throw new Error("expected agent.wait request"); +assert.deepEqual(waitRequest.params.ids, ["agt_one", "agt_two"]); +assert.equal(waitRequest.params.timeoutMs, 5_000); + +assert.deepEqual(decodeAgentWaitResults([ + { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_two", status: "running", wait: "timeout" }, + { + id: "agt_three", + status: "failed", + error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true }, + }, +]), [ + { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_two", status: "running", wait: "timeout" }, + { + id: "agt_three", + status: "failed", + error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true }, + }, +]); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index bf9bf8e65..402b94bc7 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -4,6 +4,7 @@ import type { LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import type { + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -16,6 +17,7 @@ export type LocalAgentDaemonMethod = | "agent.continue" | "agent.get" | "agent.list" + | "agent.wait" | "daemon.status" | "daemon.stop" | "daemon.logs"; @@ -26,6 +28,11 @@ export type LocalAgentDaemonRequest = | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides }> | AgentDaemonRequestBase<"agent.get", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"agent.list", LocalAgentWorkspaceScope> + | AgentDaemonRequestBase<"agent.wait", { + ids: string[]; + scope: LocalAgentWorkspaceScope; + timeoutMs?: number; + }> | AgentDaemonRequestBase<"daemon.status", Record> | AgentDaemonRequestBase<"daemon.stop", Record> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; @@ -133,6 +140,14 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR method, params: decodeListScope(params), } as LocalAgentDaemonRequest; + case "agent.wait": + return { + requestId, + protocolVersion, + authToken, + method, + params: decodeWaitParams(params), + } as LocalAgentDaemonRequest; case "daemon.logs": return { requestId, @@ -202,6 +217,40 @@ export function decodeAgentRecordList(value: unknown): LocalAgentRecord[] { return value.map(decodeAgentRecord); } +export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { + if (!Array.isArray(value)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid agent wait results."); + } + return value.map((entry): LocalAgentWaitResult => { + const record = asRecord(entry); + const id = requiredString(record?.id, "id"); + const status = requiredString(record?.status, "status"); + switch (status) { + case "running": { + const wait = optionalString(record?.wait); + if (wait !== undefined && wait !== "timeout") { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait state."); + } + return { id, status, ...(wait ? { wait } : {}) }; + } + case "completed": { + const response = optionalContentString(record?.response); + return { id, status, ...(response === undefined ? {} : { response }) }; + } + case "failed": + return { id, status, error: decodeWaitError(record?.error) }; + case "stopped": + return { + id, + status, + ...(record?.error === undefined ? {} : { error: decodeWaitError(record.error) }), + }; + default: + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait result status."); + } + }); +} + export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { const record = asRecord(value); const state = requiredString(record?.state, "state"); @@ -282,6 +331,48 @@ function decodeListScope(value: unknown): LocalAgentWorkspaceScope { return decodeWorkspaceScope(value); } +function decodeWaitParams(value: unknown): { + ids: string[]; + scope: LocalAgentWorkspaceScope; + timeoutMs?: number; +} { + const record = asRecord(value); + if (!record) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Agent wait options must be an object."); + } + const ids = record?.ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "At least one subagent id is required."); + } + const timeoutMs = record.timeoutMs; + if ( + timeoutMs !== undefined + && (typeof timeoutMs !== "number" + || !Number.isSafeInteger(timeoutMs) + || timeoutMs < 0 + || timeoutMs > 2_147_483_647) + ) { + throw new LocalAgentDaemonProtocolError( + "INVALID_PARAMS", + "Wait timeout must be an integer between 0 and 2147483647 milliseconds.", + ); + } + return { + ids: ids.map((id, index) => requiredString(id, `ids[${index}]`)), + scope: decodeWorkspaceScope(record.scope), + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }; +} + +function decodeWaitError(value: unknown): { code: string; message: string; retryable: boolean } { + const record = asRecord(value); + return { + code: requiredString(record?.code, "error.code"), + message: requiredContentString(record?.message, "error.message"), + retryable: optionalBoolean(record?.retryable) ?? false, + }; +} + function decodeLogsParams(value: unknown): { lines?: number } { if (value === undefined) return {}; const record = asRecord(value); diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 6ea66e652..4d196ef3b 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -40,6 +40,9 @@ class FakeManager implements LocalAgentDaemonManager { runtimeCount = 0; closed = false; lastInput?: StartLocalAgentInput; + blockWaitUntilAbort = false; + waitStarted = false; + waitAborted = false; async start(input: StartLocalAgentInput) { this.lastInput = input; @@ -63,6 +66,21 @@ class FakeManager implements LocalAgentDaemonManager { return Result.ok([record]); } + async wait(agentIds: readonly string[], _scope: unknown, _timeoutMs?: number, signal?: AbortSignal) { + this.waitStarted = true; + if (this.blockWaitUntilAbort) { + await new Promise((resolveAbort) => { + const onAbort = () => { + this.waitAborted = true; + resolveAbort(); + }; + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + return Result.ok(agentIds.map((id) => ({ id, status: "running" as const }))); + } + async evictIdle(): Promise {} async close(): Promise { @@ -135,6 +153,9 @@ try { const recordScope = { workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot }; assert.equal(unwrap(await client.get(record.id, recordScope)).id, record.id); assert.equal(unwrap(await client.list(recordScope))[0]?.id, record.id); + assert.deepEqual(unwrap(await client.wait([record.id], recordScope, 0)), [ + { id: record.id, status: "running" }, + ]); assert.equal(unwrap(await client.status()).state, "ready"); unwrap(await client.stop()); @@ -247,7 +268,7 @@ const legacyServer = createNetServer((socket) => { ok: false, error: { code: "DAEMON_PROTOCOL_MISMATCH", - message: "Unsupported daemon protocol version 3; expected 1.", + message: `Unsupported daemon protocol version ${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}; expected 1.`, retryable: false, }, })); @@ -301,10 +322,17 @@ const upgradeClient = new LocalAgentClient({ }, }); try { - assert.equal(unwrap(await upgradeClient.ensureReady()).protocolVersion, 3); + assert.equal( + unwrap(await upgradeClient.ensureReady()).protocolVersion, + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + ); assert.equal(replacementSpawns, 1); assert.equal(spawnedBeforeLegacyLockReleased, false); - assert.deepEqual(legacyMethods.slice(0, 3), ["hello:3", "hello:1", "daemon.stop:1"]); + assert.deepEqual(legacyMethods.slice(0, 3), [ + `hello:${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}`, + "hello:1", + "daemon.stop:1", + ]); } finally { legacyLock.release(); await replacementDaemon.close(); @@ -397,11 +425,11 @@ const timeoutServer = createNetServer((socket) => { if (request.method !== "hello") return; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: { state: "ready", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, pid: process.pid, endpoint: timeoutPaths.endpoint, startedAt: "now", @@ -443,7 +471,7 @@ const invalidServer = createNetServer((socket) => { if (!buffer.includes("\n")) return; socket.end(encodeLocalAgentDaemonResponse({ requestId: "wrong_request_id", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: {}, })); @@ -487,6 +515,27 @@ const socketDaemon = new LocalAgentDaemon({ try { await socketDaemon.start(); + socketManager.blockWaitUntilAbort = true; + const waitSocket = createConnection(socketDaemon.paths.endpoint); + await new Promise((resolveConnect, rejectConnect) => { + waitSocket.once("error", rejectConnect); + waitSocket.once("connect", resolveConnect); + }); + waitSocket.write(JSON.stringify({ + requestId: "disconnect-wait", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: ensureLocalAgentDaemonSecret(socketDaemon.paths), + method: "agent.wait", + params: { + ids: [record.id], + scope: { workspaceId: record.workspaceId, workspaceRoot: record.workspaceRoot }, + }, + }) + "\n"); + await waitFor(() => socketManager.waitStarted); + waitSocket.destroy(); + await waitFor(() => socketManager.waitAborted); + socketManager.blockWaitUntilAbort = false; + const timedOutRequest = await sendRawRequest(socketDaemon.paths.endpoint); assert.equal(timedOutRequest.ok, false); if (!timedOutRequest.ok) { @@ -497,7 +546,7 @@ try { const unauthorized = await sendRawRequest(socketDaemon.paths.endpoint, JSON.stringify({ requestId: "unauthorized", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "wrong-secret", method: "hello", params: {}, diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index dfd3a1499..75a3b6dd4 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -37,6 +37,8 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentWaitError, + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -53,6 +55,12 @@ export interface LocalAgentDaemonManager { continue(agentId: string, prompt: string, overrides: RunOverrides | undefined, scope: LocalAgentWorkspaceScope): Promise>; get(agentId: string, scope: LocalAgentWorkspaceScope): Result; list(scope: LocalAgentWorkspaceScope): Result; + wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise>; evictIdle(now?: number): Promise; close(): Promise; readonly activeTurnCount: number; @@ -210,6 +218,7 @@ export class LocalAgentDaemon { private handleConnection(socket: Socket): void { this.sockets.add(socket); + const disconnected = new AbortController(); socket.setEncoding("utf8"); let buffer = ""; let handled = false; @@ -243,14 +252,17 @@ export class LocalAgentDaemon { handled = true; clearTimeout(requestTimer); const line = buffer.slice(0, newline); - void this.handleLine(socket, line); + void this.handleLine(socket, line, disconnected.signal); }); socket.on("error", () => undefined); - socket.on("close", () => this.sockets.delete(socket)); + socket.on("close", () => { + disconnected.abort(); + this.sockets.delete(socket); + }); socket.on("error", () => clearTimeout(requestTimer)); } - private async handleLine(socket: Socket, line: string): Promise { + private async handleLine(socket: Socket, line: string, signal: AbortSignal): Promise { let requestId = ""; try { let parsed: unknown; @@ -261,7 +273,7 @@ export class LocalAgentDaemon { } requestId = readRequestId(parsed); const request = decodeLocalAgentDaemonRequest(parsed); - const response = await this.dispatch(request); + const response = await this.dispatch(request, signal); socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -274,7 +286,7 @@ export class LocalAgentDaemon { } } - private async dispatch(request: LocalAgentDaemonRequest): Promise { + private async dispatch(request: LocalAgentDaemonRequest, signal: AbortSignal): Promise { if (request.protocolVersion !== LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) { throw new LocalAgentDaemonProtocolError( "PROTOCOL_MISMATCH", @@ -307,6 +319,13 @@ export class LocalAgentDaemon { return unwrapManagerResult(this.manager.get(request.params.id, request.params.scope)); case "agent.list": return unwrapManagerResult(this.manager.list(request.params)); + case "agent.wait": + return unwrapManagerResult(await this.manager.wait( + request.params.ids, + request.params.scope, + request.params.timeoutMs, + signal, + )); case "daemon.status": return this.status(); case "daemon.stop": diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 7398454fd..0df247006 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -304,6 +304,76 @@ const earlyFailure = unwrap(await manager.start({ await waitFor(() => getRecord(earlyFailure.id).status === "error"); assert.equal(getRecord(earlyFailure.id).providerSessionId, "thread_early"); +const waitingOne = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold wait one", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +const waitingTwo = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold wait two", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(waitingOne.id)?.inputs.length === 1); +await waitFor(() => runtimes.get(waitingTwo.id)?.inputs.length === 1); +let multiWaitSettled = false; +const multiWait = manager.wait([waitingOne.id, waitingTwo.id, waitingOne.id], scope) + .then((result) => { + multiWaitSettled = true; + return result; + }); +runtimes.get(waitingOne.id)!.release(); +await waitFor(() => getRecord(waitingOne.id).status === "idle"); +assert.equal(multiWaitSettled, false, "multi-agent wait must remain pending until every turn finishes"); +runtimes.get(waitingTwo.id)!.release(); +assert.deepEqual(unwrap(await multiWait).map((result) => ({ id: result.id, status: result.status })), [ + { id: waitingOne.id, status: "completed" }, + { id: waitingTwo.id, status: "completed" }, +]); + +const timedWaitAgent = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold timed wait", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(timedWaitAgent.id)?.inputs.length === 1); +assert.deepEqual(unwrap(await manager.wait([earlyFailure.id, timedWaitAgent.id], scope, 5)), [ + { + id: earlyFailure.id, + status: "failed", + error: { + code: "PROVIDER_EXECUTION_ERROR", + message: "provider failed after session creation", + retryable: false, + }, + }, + { id: timedWaitAgent.id, status: "running", wait: "timeout" }, +]); +runtimes.get(timedWaitAgent.id)!.release(); +await waitFor(() => getRecord(timedWaitAgent.id).status === "idle"); + +const cancelledWaitAgent = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold cancelled wait", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(cancelledWaitAgent.id)?.inputs.length === 1); +const waitAbort = new AbortController(); +const cancelledWait = manager.wait([cancelledWaitAgent.id], scope, undefined, waitAbort.signal); +waitAbort.abort(); +assert.deepEqual(unwrap(await cancelledWait), [{ id: cancelledWaitAgent.id, status: "running" }]); +assert.equal(getRecord(cancelledWaitAgent.id).status, "running", "cancelling a waiter must not stop its turn"); +runtimes.get(cancelledWaitAgent.id)!.release(); +await waitFor(() => getRecord(cancelledWaitAgent.id).status === "idle"); + +const invalidWait = await manager.wait([waitingOne.id, "agt_missing"], scope, 5); +assert.equal(invalidWait.isErr(), true); +if (invalidWait.isErr()) assert.equal(invalidWait.error.code, "AGENT_NOT_FOUND"); + const wrongWorkspace = await manager.continue( first.id, "wrong workspace", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index ef8a3c720..30fe1e1f4 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -20,6 +20,7 @@ import { import { type LocalAgentRecord, type LocalAgentStore, + type LocalAgentTurnRecord, type LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import { @@ -71,6 +72,18 @@ export type AgentStartError = AgentTargetError | AgentScopeError | AgentConflict export type AgentContinueError = AgentStartError; export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; export type AgentListError = AgentScopeError | AgentStoreError; +export type AgentWaitError = AgentLookupError; + +export type LocalAgentWaitResult = + | { id: string; status: "running"; wait?: "timeout" } + | { id: string; status: "completed"; response?: string } + | { id: string; status: "failed"; error: { code: string; message: string; retryable: boolean } } + | { id: string; status: "stopped"; error?: { code: string; message: string; retryable: boolean } }; + +interface ActiveLocalAgentTurn { + turnId: number; + completion: Promise; +} /** * Owns one durable DevSpace agent's turn lifecycle. Provider runtimes remain @@ -86,7 +99,7 @@ export class LocalAgentManager { private readonly allowedRoots?: readonly string[]; private readonly logger?: LocalAgentManagerLogger; private readonly subagents: SubagentsConfig; - private readonly activeTurns = new Map>(); + private readonly activeTurns = new Map(); private accepting = true; private closePromise?: Promise; @@ -199,10 +212,57 @@ export class LocalAgentManager { )); } + async wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise> { + const captures: Array<{ agent: LocalAgentRecord; turn?: LocalAgentTurnRecord }> = []; + for (const agentId of unique(agentIds)) { + const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + const turn = this.store.getLatestTurnResult(agentId); + if (turn.isErr()) return turn; + captures.push({ agent: agent.value, turn: turn.value }); + } + + const pending: Promise[] = []; + for (const capture of captures) { + if (capture.turn?.status !== "running") continue; + const active = this.activeTurns.get(capture.agent.id); + if (active?.turnId !== capture.turn.id) { + return Result.err(new AgentStoreError( + "wait", + new Error(`Turn ${capture.turn.id} is not active.`), + `Running turn state is unavailable for subagent ${capture.agent.id}.`, + )); + } + pending.push(active.completion); + } + + const timedOut = pending.length > 0 + ? await waitForTurns(pending, timeoutMs, signal) + : false; + const results: LocalAgentWaitResult[] = []; + for (const capture of captures) { + if (!capture.turn) { + results.push(waitResultFromAgent(capture.agent, timedOut)); + continue; + } + const turn = this.store.getTurnByIdResult(capture.turn.id); + if (turn.isErr()) return turn; + results.push(turn.value + ? waitResultFromTurn(turn.value, timedOut) + : waitResultFromAgent(capture.agent, timedOut)); + } + return Result.ok(results); + } + async close(): Promise { if (this.closePromise) return this.closePromise; this.accepting = false; - const turns = Array.from(this.activeTurns.values()); + const turns = Array.from(this.activeTurns.values(), (turn) => turn.completion); this.closePromise = (async () => { // Closing pooled runtimes is what interrupts provider turns. Waiting for // those turns first can strand a provider process indefinitely. @@ -257,7 +317,7 @@ export class LocalAgentManager { const turn = Promise.resolve().then(() => ( this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) )); - this.activeTurns.set(record.id, turn); + this.activeTurns.set(record.id, { turnId: begun.value.turn.id, completion: turn }); void turn.catch(() => undefined); return Result.ok(begun.value.agent); } @@ -604,3 +664,108 @@ function agentNotFound(agentId: string): AgentTargetError { message: `Unknown subagent id: ${agentId}.`, }); } + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +async function waitForTurns( + turns: readonly Promise[], + timeoutMs: number | undefined, + signal: AbortSignal | undefined, +): Promise { + let timer: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + const timeout = timeoutMs === undefined + ? undefined + : new Promise<"timeout">((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout("timeout"), timeoutMs); + }); + const aborted = signal + ? new Promise<"aborted">((resolveAbort) => { + onAbort = () => resolveAbort("aborted"); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }) + : undefined; + try { + const result = await Promise.race([ + Promise.allSettled(turns).then(() => "completed" as const), + ...(timeout ? [timeout] : []), + ...(aborted ? [aborted] : []), + ]); + return result === "timeout"; + } finally { + if (timer) clearTimeout(timer); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); + } +} + +function waitResultFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): LocalAgentWaitResult { + switch (turn.status) { + case "running": + return { id: turn.agentId, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; + case "completed": + return { + id: turn.agentId, + status: "completed", + ...(turn.response === undefined ? {} : { response: turn.response }), + }; + case "failed": + return { id: turn.agentId, status: "failed", error: turnFailure(turn) }; + case "stopped": + return { + id: turn.agentId, + status: "stopped", + ...(hasTurnFailure(turn) ? { error: turnFailure(turn) } : {}), + }; + } +} + +function waitResultFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAgentWaitResult { + switch (agent.status) { + case "starting": + case "running": + return { id: agent.id, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; + case "idle": + return { + id: agent.id, + status: "completed", + ...(agent.latestResponse === undefined ? {} : { response: agent.latestResponse }), + }; + case "error": + return { + id: agent.id, + status: "failed", + error: { + code: agent.errorCode ?? "AGENT_FAILED", + message: agent.error ?? "Subagent failed without an error message.", + retryable: agent.errorRetryable ?? false, + }, + }; + case "stopped": + return { + id: agent.id, + status: "stopped", + ...(agent.error || agent.errorCode || agent.errorRetryable !== undefined + ? { error: { + code: agent.errorCode ?? "AGENT_STOPPED", + message: agent.error ?? "Subagent stopped.", + retryable: agent.errorRetryable ?? false, + } } + : {}), + }; + } +} + +function hasTurnFailure(turn: LocalAgentTurnRecord): boolean { + return turn.error !== undefined || turn.errorCode !== undefined || turn.errorRetryable !== undefined; +} + +function turnFailure(turn: LocalAgentTurnRecord): { code: string; message: string; retryable: boolean } { + return { + code: turn.errorCode ?? "AGENT_FAILED", + message: turn.error ?? "Subagent failed without an error message.", + retryable: turn.errorRetryable ?? false, + }; +} diff --git a/src/local-agent-presentation.ts b/src/local-agent-presentation.ts index 9def4d39f..f5ff569ec 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -46,7 +46,7 @@ export interface AgentCommandErrorOutput { } export type AgentObservationOutput = - | { id: string; status: "running" } + | { id: string; status: "running"; wait?: "timeout" } | { id: string; status: "completed"; response?: string } | { id: string; status: "failed"; error: AgentFailureOutput } | { id: string; status: "stopped"; error?: AgentFailureOutput }; @@ -123,6 +123,9 @@ export function formatAgentSummary(summary: AgentSummaryOutput): string { } export function formatAgentObservation(observation: AgentObservationOutput): string { + if (observation.status === "running" && observation.wait) { + return ``; + } if (observation.status === "completed" && observation.response !== undefined) { return `${escapeXmlText(observation.response)}`; } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index bdb014a66..3aab64da6 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -393,6 +393,12 @@ export class LocalAgentStore { return row ? rowToLocalAgentTurnRecord(row) : undefined; } + getTurnByIdResult( + turnId: number, + ): BetterResult { + return storeResult("get_turn", () => this.getTurnById(turnId)); + } + getLatestTurn(agentId: string): LocalAgentTurnRecord | undefined { const row = this.database.sqlite .prepare("select * from local_agent_turns where agent_id = ? order by id desc limit 1") @@ -400,6 +406,12 @@ export class LocalAgentStore { return row ? rowToLocalAgentTurnRecord(row) : undefined; } + getLatestTurnResult( + agentId: string, + ): BetterResult { + return storeResult("get_latest_turn", () => this.getLatestTurn(agentId)); + } + listTurns(agentId: string): LocalAgentTurnRecord[] { const rows = this.database.sqlite .prepare("select * from local_agent_turns where agent_id = ? order by id asc") From b148538e5cce11e59d6d3423913bf1d20389f537 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:01:58 +0530 Subject: [PATCH 07/13] fix(agents): preserve empty wait responses --- src/local-agent-daemon-protocol.test.ts | 4 ++++ src/local-agent-daemon-protocol.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index b5fb282c1..24acaf316 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -147,6 +147,8 @@ assert.equal(waitRequest.params.timeoutMs, 5_000); assert.deepEqual(decodeAgentWaitResults([ { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_empty", status: "completed", response: "" }, + { id: "agt_whitespace", status: "completed", response: " \n" }, { id: "agt_two", status: "running", wait: "timeout" }, { id: "agt_three", @@ -155,6 +157,8 @@ assert.deepEqual(decodeAgentWaitResults([ }, ]), [ { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_empty", status: "completed", response: "" }, + { id: "agt_whitespace", status: "completed", response: " \n" }, { id: "agt_two", status: "running", wait: "timeout" }, { id: "agt_three", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 402b94bc7..bf87a4714 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -234,7 +234,7 @@ export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { return { id, status, ...(wait ? { wait } : {}) }; } case "completed": { - const response = optionalContentString(record?.response); + const response = typeof record?.response === "string" ? record.response : undefined; return { id, status, ...(response === undefined ? {} : { response }) }; } case "failed": From 9d44110d6b6dcc7ee103531b535e393e02d2a2ee Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:06:58 +0530 Subject: [PATCH 08/13] feat(agents): configure provider launch environment --- schema/v1/devspace.schema.json | 15 +++++++ src/cli.ts | 9 +++-- src/local-agent-adapters.ts | 14 ++++++- src/local-agent-availability.test.ts | 36 +++++++++++++++++ src/local-agent-availability.ts | 28 ++++++++++---- src/local-agent-claude.test.ts | 38 ++++++++++++++++++ src/local-agent-config.test.ts | 58 +++++++++++++++++++++++++++- src/local-agent-config.ts | 45 ++++++++++++++++++++- src/local-agent-daemon-main.ts | 2 +- src/onboarding.test.ts | 18 ++++++++- src/server.ts | 4 +- 11 files changed, 246 insertions(+), 21 deletions(-) diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index 85fa77a4c..6de069c35 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -202,6 +202,21 @@ "effort": { "type": "string", "minLength": 1 + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "additionalProperties": { + "type": "string" + } } }, "required": [ diff --git a/src/cli.ts b/src/cli.ts index 30d0a3d83..775b5a1d2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -219,7 +219,10 @@ async function runInit({ force }: { force: boolean }): Promise { } const currentSubagents = files.config.subagents; - const availability = getLocalAgentProviderAvailabilitySnapshot(); + const availability = getLocalAgentProviderAvailabilitySnapshot( + process.env, + currentSubagents, + ); const configuredProviders = currentSubagents.providers .filter((provider) => provider.enabled) .map((provider) => provider.id); @@ -405,7 +408,7 @@ async function runDoctor(): Promise { console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); const providers = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); console.log(`Subagents: ${config.subagents.enabled ? "enabled" : "disabled"}`); console.log(`Subagent providers: ${formatLocalAgentProviderStatusSummary(providers)}`); @@ -560,7 +563,7 @@ async function runAgentsTargets(args: string[], json: boolean): Promise { const profiles = await loadLocalAgentProfiles(config, scope.workspaceRoot); const providers = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const catalog = buildLocalAgentCatalog(config.subagents, profiles, providers); const output = presentAgentTargetCatalog(catalog); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 3f06f3a10..13d40810f 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,3 +1,8 @@ +import { + localAgentProviderEnvironment, + type SubagentsConfig, +} from "./local-agent-config.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { AcpLocalAgentDriver, resolveAcpCommand, @@ -27,6 +32,7 @@ export type LocalAgentAdapter = LocalAgentDriver; export interface LocalAgentDriverOptions { env?: NodeJS.ProcessEnv; + subagents?: SubagentsConfig; claudeQueryFactory?: ClaudeQueryFactory; opencodeFactory?: OpencodeFactory; piSessionFactory?: PiSessionFactory; @@ -35,9 +41,13 @@ export interface LocalAgentDriverOptions { export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { + const env = options.env ?? process.env; + const providerEnv = (provider: LocalAgentProvider) => options.subagents + ? localAgentProviderEnvironment(options.subagents, provider, env) + : env; return [ - new CodexLocalAgentDriver(options.env), - new ClaudeLocalAgentDriver(options.claudeQueryFactory, options.env), + new CodexLocalAgentDriver(providerEnv("codex")), + new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")), new OpencodeLocalAgentDriver(options.opencodeFactory), new PiLocalAgentDriver(options.piSessionFactory), new AcpLocalAgentDriver("cursor", options.env), diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 96dc00816..8fd0067b4 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; const snapshot = getLocalAgentProviderAvailabilitySnapshot({ @@ -16,3 +19,36 @@ assert.deepEqual(snapshot.find((provider) => provider.name === "antigravity"), { available: false, reason: "/definitely/missing/devspace-antigravity executable not found", }); + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-provider-command-")); + const executable = join(directory, "codex-wrapper"); + try { + writeFileSync(executable, "#!/bin/sh\nexit 0\n"); + chmodSync(executable, 0o700); + const availability = getLocalAgentProviderAvailabilitySnapshot( + { + ...process.env, + CODEX_COMMAND: "/definitely/missing/devspace-codex", + OPENAI_API_KEY: "must-not-appear", + }, + { + enabled: true, + providers: [{ + id: "codex", + enabled: true, + command: executable, + env: { OPENAI_API_KEY: "configured-secret", EMPTY_VALUE: "" }, + }], + }, + ).find((provider) => provider.name === "codex"); + assert.deepEqual(availability, { + name: "codex", + available: true, + note: "available", + }); + assert.doesNotMatch(JSON.stringify(availability), /configured-secret|must-not-appear/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 956140893..ad8c06d9f 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -4,6 +4,10 @@ import { LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; +import { + localAgentProviderEnvironment, + type SubagentsConfig, +} from "./local-agent-config.js"; export interface LocalAgentProviderAvailability { name: LocalAgentProvider; @@ -14,43 +18,51 @@ export interface LocalAgentProviderAvailability { export function getLocalAgentProviderAvailabilitySnapshot( env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): LocalAgentProviderAvailability[] { - return LOCAL_AGENT_PROVIDERS.map((provider) => checkLocalAgentProviderAvailability(provider, env)); + return LOCAL_AGENT_PROVIDERS.map((provider) => ( + checkLocalAgentProviderAvailability(provider, env, config) + )); } function checkLocalAgentProviderAvailability( provider: LocalAgentProvider, env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): LocalAgentProviderAvailability { + const providerEnv = config ? localAgentProviderEnvironment(config, provider, env) : env; switch (provider) { case "codex": - return codexAvailability(env); + return codexAvailability(providerEnv); case "claude": - return packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); + return providerEnv.CLAUDE_COMMAND + ? commandAvailability(provider, providerEnv.CLAUDE_COMMAND, providerEnv) + : packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); case "opencode": return packageAvailability(provider, "@opencode-ai/sdk/v2"); case "pi": return packageAvailability(provider, "@earendil-works/pi-coding-agent"); case "cursor": - return commandAvailability(provider, env.CURSOR_COMMAND ?? "cursor-agent", env); + return commandAvailability(provider, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); case "copilot": - return commandAvailability(provider, env.COPILOT_COMMAND ?? "copilot", env); + return commandAvailability(provider, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); case "grok": - return commandAvailability(provider, env.GROK_COMMAND ?? "grok", env); + return commandAvailability(provider, providerEnv.GROK_COMMAND ?? "grok", providerEnv); case "antigravity": return commandAvailability( provider, env.ANTIGRAVITY_COMMAND ?? env.AGY_ACP_COMMAND ?? "agy_acp_server", env, - ); + ); } } export function assertLocalAgentProviderAvailable( provider: LocalAgentProvider, env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): void { - const availability = checkLocalAgentProviderAvailability(provider, env); + const availability = checkLocalAgentProviderAvailability(provider, env, config); if (availability.available) return; throw new Error( `${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`, diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index e8b3d506b..14e364005 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -5,6 +5,8 @@ import { type ClaudeQueryLike, type ClaudeUserMessage, } from "./local-agent-claude.js"; +import { createLocalAgentDrivers } from "./local-agent-adapters.js"; +import { subagentsConfigSchema } from "./local-agent-config.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { @@ -230,3 +232,39 @@ await assert.rejects( TypeError, "programmer defects must not be reclassified as provider failures", ); + +let configuredOptions: Record | undefined; +const configuredDriver = createLocalAgentDrivers({ + env: { + PATH: "/usr/bin", + CLAUDE_COMMAND: "/usr/bin/claude", + ANTHROPIC_API_KEY: "inherited", + INHERITED: "yes", + }, + subagents: subagentsConfigSchema.parse({ + enabled: true, + providers: [{ + id: "claude", + enabled: true, + command: "/opt/bin/claude-wrapper", + env: { ANTHROPIC_API_KEY: "configured", EMPTY_VALUE: "" }, + }], + }), + claudeQueryFactory: ({ prompt, options }) => { + configuredOptions = options; + return new FakeClaudeQuery(prompt); + }, +}).find((driver) => driver.provider === "claude"); +assert.ok(configuredDriver); +const configuredRuntime = await configuredDriver.createRuntime(context); +assert.equal(configuredRuntime.isOk(), true); +if (configuredRuntime.isErr()) throw configuredRuntime.error; +assert.equal(configuredOptions?.pathToClaudeCodeExecutable, "/opt/bin/claude-wrapper"); +assert.deepEqual(configuredOptions?.env, { + PATH: "/usr/bin", + CLAUDE_COMMAND: "/opt/bin/claude-wrapper", + ANTHROPIC_API_KEY: "configured", + INHERITED: "yes", + EMPTY_VALUE: "", +}); +await configuredRuntime.value.close(); diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 665d96f2d..0ea2b0f85 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { isSubagentProviderEnabled, + localAgentProviderEnvironment, subagentProviderConfig, subagentsConfigSchema, } from "./local-agent-config.js"; @@ -8,7 +9,14 @@ import { const config = subagentsConfigSchema.parse({ enabled: true, providers: [ - { id: "codex", enabled: true, model: " gpt-5.4 ", effort: " high " }, + { + id: "codex", + enabled: true, + model: " gpt-5.4 ", + effort: " high ", + command: " /opt/bin/codex-wrapper ", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: false, model: "sonnet" }, ], }); @@ -16,7 +24,14 @@ assert.deepEqual(config, { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-5.4", effort: "high" }, + { + id: "codex", + enabled: true, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: false, model: "sonnet" }, ], }); @@ -29,6 +44,22 @@ assert.equal( "preload", ); +const inherited = { + CODEX_COMMAND: "/usr/bin/codex", + OPENAI_API_KEY: "inherited", + UNCHANGED: "yes", +}; +assert.deepEqual(localAgentProviderEnvironment(config, "codex", inherited), { + CODEX_COMMAND: "/opt/bin/codex-wrapper", + OPENAI_API_KEY: "configured", + EMPTY_VALUE: "", + UNCHANGED: "yes", +}); +assert.deepEqual(inherited, { + CODEX_COMMAND: "/usr/bin/codex", + OPENAI_API_KEY: "inherited", + UNCHANGED: "yes", +}); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, @@ -50,3 +81,26 @@ assert.throws( }), /Too small/, ); +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "codex", enabled: true, command: " " }], + }), + /non-whitespace character/, +); +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "codex", enabled: true, env: { "INVALID-NAME": "value" } }], + }), + /Invalid environment variable name/, +); +for (const id of ["opencode", "pi"] as const) { + assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id, enabled: true, command: "/opt/bin/agent" }], + }), + new RegExp(`${id} is embedded and does not support command or env configuration`), + ); +} diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 9b889eafc..d024e600d 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -4,12 +4,30 @@ import { type LocalAgentProvider, } from "./local-agent-profiles.js"; +const environmentSchema = z.record( + z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Invalid environment variable name"), + z.string(), +); + const providerSchema = z.object({ id: z.enum(LOCAL_AGENT_PROVIDERS as [LocalAgentProvider, ...LocalAgentProvider[]]), enabled: z.boolean(), model: z.string().trim().min(1).optional(), effort: z.string().trim().min(1).optional(), -}).strict(); + command: z.string() + .regex(/\S/, "Command must contain a non-whitespace character") + .trim() + .min(1) + .optional(), + env: environmentSchema.optional(), +}).strict().superRefine((value, context) => { + if ((value.id === "opencode" || value.id === "pi") && (value.command || value.env)) { + context.addIssue({ + code: "custom", + message: `${value.id} is embedded and does not support command or env configuration.`, + }); + } +}); export const subagentsConfigSchema = z.object({ enabled: z.boolean(), @@ -51,3 +69,28 @@ export function isSubagentProviderEnabled( ): boolean { return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; } + +export function localAgentProviderEnvironment( + config: SubagentsConfig, + provider: LocalAgentProvider, + inherited: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const providerConfig = subagentProviderConfig(config, provider); + const env = { ...inherited, ...providerConfig?.env }; + const commandVariable = providerCommandVariable(provider); + if (commandVariable && providerConfig?.command) env[commandVariable] = providerConfig.command; + return env; +} + +export function providerCommandVariable(provider: LocalAgentProvider): string | undefined { + switch (provider) { + case "codex": return "CODEX_COMMAND"; + case "claude": return "CLAUDE_COMMAND"; + case "cursor": return "CURSOR_COMMAND"; + case "copilot": return "COPILOT_COMMAND"; + case "grok": return "GROK_COMMAND"; + case "opencode": + case "pi": + return undefined; + } +} diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index b1e0e09da..0121441df 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -22,7 +22,7 @@ const log = ( const store = new LocalAgentStore(paths.stateDir); const manager = new LocalAgentManager({ store, - drivers: createLocalAgentDrivers(), + drivers: createLocalAgentDrivers({ subagents: config.subagents }), pool: new LocalAgentRuntimePool({ logger: log }), loadProfiles: (workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot, { includeDisabled: true }), agentDir: config.agentDir, diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index ba3c0a224..9a5d02e8c 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -32,7 +32,14 @@ const configured = { enabled: true, instructions: "preload" as const, providers: [ - { id: "codex" as const, enabled: true, model: "gpt-5.4", effort: "high" }, + { + id: "codex" as const, + enabled: true, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude" as const, enabled: true, model: "sonnet" }, ], }; @@ -42,7 +49,14 @@ assert.deepEqual( enabled: true, instructions: "preload", providers: [ - { id: "codex", enabled: false, model: "gpt-5.4", effort: "high" }, + { + id: "codex", + enabled: false, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: true, model: "sonnet" }, ], }, diff --git a/src/server.ts b/src/server.ts index 744d765d3..b39bbc1ba 100644 --- a/src/server.ts +++ b/src/server.ts @@ -813,11 +813,11 @@ export function createServer( const toolActivities = new ToolActivityTracker(); const localAgentProviders = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const resolveLocalAgentProviders = () => buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const modernToolSurface = getToolSurface(config.toolMode); const bindModernMcpSurface = compileMcpRegistrationSurface((target) => { From 9113983ea8d411ecf0da8fbe76428c3ade9e1fde Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:03:18 +0530 Subject: [PATCH 09/13] fix(agents): reject directory provider commands --- src/local-agent-availability.test.ts | 12 ++++++++++++ src/local-agent-availability.ts | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 8fd0067b4..83cb3ef78 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -19,11 +19,23 @@ assert.deepEqual(snapshot.find((provider) => provider.name === "antigravity"), { available: false, reason: "/definitely/missing/devspace-antigravity executable not found", }); +assert.equal( + getLocalAgentProviderAvailabilitySnapshot({ ...process.env, CODEX_COMMAND: "" }) + .find((provider) => provider.name === "codex")?.available, + false, +); { const directory = mkdtempSync(join(tmpdir(), "devspace-provider-command-")); const executable = join(directory, "codex-wrapper"); try { + assert.equal( + getLocalAgentProviderAvailabilitySnapshot({ + ...process.env, + CODEX_COMMAND: directory, + }).find((provider) => provider.name === "codex")?.available, + false, + ); writeFileSync(executable, "#!/bin/sh\nexit 0\n"); chmodSync(executable, 0o700); const availability = getLocalAgentProviderAvailabilitySnapshot( diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index ad8c06d9f..f90cae121 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,4 +1,4 @@ -import { accessSync, constants } from "node:fs"; +import { accessSync, constants, statSync } from "node:fs"; import { delimiter, resolve } from "node:path"; import { LOCAL_AGENT_PROVIDERS, @@ -109,6 +109,7 @@ function commandAvailability( } function resolveCommand(command: string, env: NodeJS.ProcessEnv): string | undefined { + if (!command) return undefined; if (command.includes("/") || command.includes("\\")) { return executableExists(command) ? command : undefined; } @@ -131,7 +132,7 @@ function executableExists(command: string): boolean { const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK; try { accessSync(command, mode); - return true; + return statSync(command).isFile(); } catch { return false; } From 62f8493e62461435602be861fadca01b7fe276cd Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:34:57 +0530 Subject: [PATCH 10/13] fix(agents): refresh daemon provider configuration --- src/cli.test.ts | 19 +-- src/local-agent-client.ts | 77 +++++++++-- src/local-agent-config.test.ts | 28 ++++ src/local-agent-config.ts | 23 ++++ src/local-agent-daemon-lifecycle.ts | 2 +- src/local-agent-daemon-main.ts | 2 + src/local-agent-daemon-protocol.test.ts | 48 +++++++ src/local-agent-daemon-protocol.ts | 48 ++++++- src/local-agent-daemon.test.ts | 173 ++++++++++++++++++++++-- src/local-agent-daemon.ts | 42 +++++- src/local-agent-errors.ts | 14 ++ 11 files changed, 438 insertions(+), 38 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index aa83f2da9..3a622eb19 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -125,14 +125,17 @@ try { ] : request.method === "hello" ? { - state: "ready", - protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, - pid: process.pid, - endpoint: daemonSocket, - startedAt: "now", - activeTurns: 0, - runtimeCount: 0, - clientConnections: 1, + status: { + state: "ready", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + pid: process.pid, + endpoint: daemonSocket, + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }, + configMatches: true, } : null; socket.end(encodeLocalAgentDaemonResponse({ diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 5d31464b6..4e6367d9c 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { matchError, Result, type Result as BetterResult } from "better-result"; import type { ServerConfig } from "./config.js"; import { + AgentDaemonConfigChangedError, AgentDaemonInvalidRequestError, AgentDaemonInvalidResponseError, AgentDaemonProtocolMismatchError, @@ -23,6 +24,7 @@ import { decodeAgentRecord, decodeAgentRecordList, decodeAgentWaitResults, + decodeDaemonHello, decodeDaemonLogs, decodeDaemonStatus, decodeLocalAgentDaemonResponse, @@ -33,6 +35,7 @@ import { type LocalAgentDaemonResponse, type LocalAgentDaemonStatus, } from "./local-agent-daemon-protocol.js"; +import { localAgentProviderConfigRevision } from "./local-agent-config.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ensureLocalAgentDaemonSecret, @@ -68,6 +71,7 @@ type RequestError = export interface LocalAgentClientOptions { stateDir: string; + configRevision: string; configDir?: string; startupTimeoutMs?: number; requestTimeoutMs?: number; @@ -78,6 +82,7 @@ export interface LocalAgentClientOptions { export class LocalAgentClient { private readonly stateDir: string; private readonly paths: LocalAgentDaemonPaths; + private readonly configRevision: string; private readonly endpoint: string; private readonly startupTimeoutMs: number; private readonly requestTimeoutMs: number; @@ -86,6 +91,7 @@ export class LocalAgentClient { constructor(options: LocalAgentClientOptions) { this.stateDir = options.stateDir; + this.configRevision = options.configRevision; this.paths = localAgentDaemonPaths(options.stateDir); this.endpoint = options.endpoint ?? this.paths.endpoint; this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; @@ -226,6 +232,7 @@ export class LocalAgentClient { authToken: authToken.value, method: "hello", params: {}, + configRevision: this.configRevision, }, this.requestTimeoutMs); if (response.isErr()) { if ( @@ -253,8 +260,28 @@ export class LocalAgentClient { } return error.code === "DAEMON_UNAVAILABLE" ? Result.ok(undefined) : Result.err(error); } - const decoded = decodeValue(response.value.result, "hello", decodeDaemonStatus); - return decoded.map((status) => status.state === "ready" ? status : undefined); + const decoded = decodeValue(response.value.result, "hello", decodeDaemonHello); + if (decoded.isErr()) return decoded; + if (!decoded.value.configMatches) { + return this.replaceIdleChangedDaemon(authToken.value, decoded.value.status); + } + return Result.ok(decoded.value.status.state === "ready" ? decoded.value.status : undefined); + } + + private async replaceIdleChangedDaemon( + authToken: string, + status: LocalAgentDaemonStatus, + ): Promise> { + const changed = new AgentDaemonConfigChangedError({ + code: "DAEMON_CONFIG_CHANGED", + operation: "startup", + retryable: true, + message: status.activeTurns > 0 + ? "The local agent daemon is running active turns with an older provider configuration. Retry after they finish." + : "The local agent daemon is using an older provider configuration.", + }); + if (status.activeTurns > 0) return Result.err(changed); + return this.stopIdleDaemon(authToken, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, status, changed); } private async replaceIdleOlderDaemon( @@ -268,6 +295,7 @@ export class LocalAgentClient { authToken, method: "hello", params: {}, + configRevision: this.configRevision, }, this.requestTimeoutMs); if (statusResponse.isErr() || !statusResponse.value.ok) return Result.err(mismatch); const status = decodeValue(statusResponse.value.result, "hello", decodeDaemonStatus); @@ -282,14 +310,27 @@ export class LocalAgentClient { })); } + return this.stopIdleDaemon(authToken, protocolVersion, status.value, mismatch); + } + + private async stopIdleDaemon( + authToken: string, + protocolVersion: number, + status: LocalAgentDaemonStatus, + cause: AgentDaemonProtocolMismatchError | AgentDaemonConfigChangedError, + ): Promise> { const stopResponse = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion, authToken, method: "daemon.stop", - params: {}, + // Older daemons do not support atomic idle replacement. Their existing + // best-effort upgrade path remains available through the legacy shape. + params: protocolVersion === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION + ? { ifIdle: true } + : {}, }, this.requestTimeoutMs); - if (stopResponse.isErr() || !stopResponse.value.ok) return Result.err(mismatch); + if (stopResponse.isErr() || !stopResponse.value.ok) return Result.err(cause); const deadline = Date.now() + this.startupTimeoutMs; while (Date.now() < deadline) { @@ -300,28 +341,33 @@ export class LocalAgentClient { authToken, method: "hello", params: {}, + configRevision: this.configRevision, }, Math.min(this.requestTimeoutMs, 250)); if (probe.isErr() && probe.error.code === "DAEMON_UNAVAILABLE") { - if (!existsSync(this.paths.lockPath) || !isProcessAlive(status.value.pid)) { + if (!existsSync(this.paths.lockPath) || !isProcessAlive(status.pid)) { return Result.ok(undefined); } continue; } - if ( - probe.isOk() - && probe.value.protocolVersion >= LOCAL_AGENT_DAEMON_PROTOCOL_VERSION - ) { + if (probe.isOk() && probe.value.protocolVersion > protocolVersion) { // Another client completed the replacement while this client was // waiting for the old endpoint to disappear. return this.tryHello(); } + if (probe.isOk() && probe.value.ok && protocolVersion === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) { + const hello = decodeValue(probe.value.result, "hello", decodeDaemonHello); + if (hello.isErr()) return hello; + if (hello.value.configMatches && hello.value.status.state === "ready") { + return Result.ok(hello.value.status); + } + } } return Result.err(new AgentDaemonStartupError({ code: "DAEMON_STARTUP_FAILURE", operation: "startup", retryable: true, - cause: mismatch, - message: "The older local agent daemon did not stop in time for the upgrade.", + cause, + message: "The local agent daemon did not stop in time for replacement.", })); } @@ -430,9 +476,13 @@ export class LocalAgentClient { } export function createLocalAgentClient( - config: Pick, + config: Pick, ): LocalAgentClient { - return new LocalAgentClient({ configDir: config.configDir, stateDir: config.stateDir }); + return new LocalAgentClient({ + configDir: config.configDir, + stateDir: config.stateDir, + configRevision: localAgentProviderConfigRevision(config.subagents), + }); } export function spawnLocalAgentDaemon( @@ -607,6 +657,7 @@ function isRequestError( AgentDaemonStartupError: () => "daemon" as const, AgentDaemonTimeoutError: () => "daemon" as const, AgentDaemonProtocolMismatchError: () => "daemon" as const, + AgentDaemonConfigChangedError: () => "daemon" as const, AgentDaemonUnauthorizedError: () => "daemon" as const, AgentDaemonInvalidRequestError: () => "daemon" as const, AgentDaemonInvalidResponseError: () => "daemon" as const, diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 0ea2b0f85..5641066f7 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { isSubagentProviderEnabled, + localAgentProviderConfigRevision, localAgentProviderEnvironment, subagentProviderConfig, subagentsConfigSchema, @@ -60,6 +61,33 @@ assert.deepEqual(inherited, { OPENAI_API_KEY: "inherited", UNCHANGED: "yes", }); +assert.equal( + localAgentProviderConfigRevision(config), + localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + enabled: true, + providers: [ + { id: "claude", enabled: false, model: "sonnet" }, + { + id: "codex", + enabled: true, + effort: "high", + model: "gpt-5.4", + command: "/opt/bin/codex-wrapper", + env: { EMPTY_VALUE: "", OPENAI_API_KEY: "configured" }, + }, + ], + })), + "provider and environment key order must not restart the daemon", +); +assert.notEqual( + localAgentProviderConfigRevision(config), + localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + ...config, + providers: config.providers.map((provider) => provider.id === "codex" + ? { ...provider, command: "/opt/bin/another-wrapper" } + : provider), + })), +); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index d024e600d..cf6c32df1 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { LOCAL_AGENT_PROVIDERS, @@ -94,3 +95,25 @@ export function providerCommandVariable(provider: LocalAgentProvider): string | return undefined; } } + +export function localAgentProviderConfigRevision(config: SubagentsConfig): string { + const providers = [...config.providers] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((provider) => ({ + id: provider.id, + enabled: provider.enabled, + ...(provider.model ? { model: provider.model } : {}), + ...(provider.effort ? { effort: provider.effort } : {}), + ...(provider.command ? { command: provider.command } : {}), + ...(provider.env && Object.keys(provider.env).length > 0 + ? { + env: Object.fromEntries( + Object.entries(provider.env).sort(([left], [right]) => left.localeCompare(right)), + ), + } + : {}), + })); + return createHash("sha256") + .update(JSON.stringify({ enabled: config.enabled, providers })) + .digest("hex"); +} diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 250bb2319..5ac32b93b 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 4; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 5; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index 0121441df..51a9fc477 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -10,6 +10,7 @@ import { import { LocalAgentManager } from "./local-agent-manager.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; +import { localAgentProviderConfigRevision } from "./local-agent-config.js"; const config = loadConfig(); const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; @@ -33,6 +34,7 @@ const manager = new LocalAgentManager({ const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, manager, + configRevision: localAgentProviderConfigRevision(config.subagents), onLockAcquired: () => { const reconciled = manager.reconcileActiveRuns(); if (reconciled.isErr()) throw reconciled.error; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 24acaf316..95d750451 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { decodeAgentRecord, decodeAgentWaitResults, + decodeDaemonHello, decodeLocalAgentDaemonRequest, decodeLocalAgentDaemonResponse, encodeLocalAgentDaemonResponse, @@ -55,6 +56,53 @@ const directRequest = decodeLocalAgentDaemonRequest({ if (directRequest.method !== "agent.start") throw new Error("expected agent.start request"); assert.equal(directRequest.params.workspaceId, undefined); +const helloRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_hello", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "hello", + params: {}, + configRevision: "provider-config-revision", +}); +assert.equal(helloRequest.method, "hello"); +if (helloRequest.method !== "hello") throw new Error("expected hello request"); +assert.equal(helloRequest.configRevision, "provider-config-revision"); +const conditionalStop = decodeLocalAgentDaemonRequest({ + requestId: "req_stop", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "daemon.stop", + params: { ifIdle: true }, +}); +assert.equal(conditionalStop.method, "daemon.stop"); +if (conditionalStop.method !== "daemon.stop") throw new Error("expected daemon.stop request"); +assert.equal(conditionalStop.params.ifIdle, true); +assert.deepEqual(decodeDaemonHello({ + status: { + state: "ready", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + pid: 123, + endpoint: "/tmp/agentd.sock", + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }, + configMatches: false, +}), { + status: { + state: "ready", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + pid: 123, + endpoint: "/tmp/agentd.sock", + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }, + configMatches: false, +}); + assert.throws( () => decodeLocalAgentDaemonRequest({ requestId: "req_2", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index bf87a4714..a19571e37 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -23,7 +23,7 @@ export type LocalAgentDaemonMethod = | "daemon.logs"; export type LocalAgentDaemonRequest = - | AgentDaemonRequestBase<"hello", Record> + | (AgentDaemonRequestBase<"hello", Record> & { configRevision?: string }) | AgentDaemonRequestBase<"agent.start", StartLocalAgentInput> | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides }> | AgentDaemonRequestBase<"agent.get", { id: string; scope: LocalAgentWorkspaceScope }> @@ -34,7 +34,7 @@ export type LocalAgentDaemonRequest = timeoutMs?: number; }> | AgentDaemonRequestBase<"daemon.status", Record> - | AgentDaemonRequestBase<"daemon.stop", Record> + | AgentDaemonRequestBase<"daemon.stop", { ifIdle?: boolean }> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; interface AgentDaemonRequestBase< @@ -59,6 +59,11 @@ export interface LocalAgentDaemonStatus { clientConnections: number; } +export interface LocalAgentDaemonHello { + status: LocalAgentDaemonStatus; + configMatches: boolean; +} + export interface LocalAgentDaemonErrorPayload { code: string; message: string; @@ -102,9 +107,24 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR switch (method) { case "hello": + return { + requestId, + protocolVersion, + authToken, + method, + params: decodeEmptyParams(params), + configRevision: optionalString(record?.configRevision), + }; case "daemon.status": - case "daemon.stop": return { requestId, protocolVersion, authToken, method, params: decodeEmptyParams(params) } as LocalAgentDaemonRequest; + case "daemon.stop": + return { + requestId, + protocolVersion, + authToken, + method, + params: decodeStopParams(params), + }; case "agent.start": return { requestId, @@ -269,6 +289,14 @@ export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { }; } +export function decodeDaemonHello(value: unknown): LocalAgentDaemonHello { + const record = asRecord(value); + return { + status: decodeDaemonStatus(record?.status), + configMatches: requiredBoolean(record?.configMatches, "configMatches"), + }; +} + export function decodeDaemonLogs(value: unknown): string { if (typeof value !== "string") throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid logs."); return value; @@ -331,6 +359,13 @@ function decodeListScope(value: unknown): LocalAgentWorkspaceScope { return decodeWorkspaceScope(value); } +function decodeStopParams(value: unknown): { ifIdle?: boolean } { + const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Daemon stop options must be an object."); + const ifIdle = optionalBoolean(record.ifIdle); + return ifIdle === undefined ? {} : { ifIdle }; +} + function decodeWaitParams(value: unknown): { ids: string[]; scope: LocalAgentWorkspaceScope; @@ -414,6 +449,13 @@ function requiredInteger(value: unknown, field: string): number { return value; } +function requiredBoolean(value: unknown, field: string): boolean { + if (typeof value !== "boolean") { + throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); + } + return value; +} + function optionalString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 4d196ef3b..466cf066e 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -24,6 +24,7 @@ import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.j import type { LocalAgentRecord } from "./local-agent-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agentd-test-")); +const CONFIG_REVISION = "test-provider-config"; const record: LocalAgentRecord = { id: "agt_test", workspaceId: "ws_test", @@ -41,14 +42,27 @@ class FakeManager implements LocalAgentDaemonManager { closed = false; lastInput?: StartLocalAgentInput; blockWaitUntilAbort = false; + blockStartUntilRelease = false; + startStarted = false; waitStarted = false; waitAborted = false; + private releaseStart?: () => void; async start(input: StartLocalAgentInput) { this.lastInput = input; + this.startStarted = true; + if (this.blockStartUntilRelease) { + await new Promise((resolveStart) => { this.releaseStart = resolveStart; }); + this.activeTurnCount = 1; + } return Result.ok(record); } + releaseBlockedStart(): void { + this.releaseStart?.(); + this.releaseStart = undefined; + } + async continue( _agentId: string, _prompt: string, @@ -92,11 +106,13 @@ class FakeManager implements LocalAgentDaemonManager { const manager = new FakeManager(); const daemon = new LocalAgentDaemon({ stateDir: join(root, "state"), + configRevision: CONFIG_REVISION, manager, idleShutdownMs: 60_000, }); const client = new LocalAgentClient({ stateDir: join(root, "state"), + configRevision: CONFIG_REVISION, startupTimeoutMs: 2_000, requestTimeoutMs: 2_000, spawnDaemon: () => { void daemon.start(); }, @@ -106,6 +122,7 @@ const missingDaemonStateDir = join(root, "missing-daemon-state"); let diagnosticSpawnCount = 0; const missingDaemonClient = new LocalAgentClient({ stateDir: missingDaemonStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 50, requestTimeoutMs: 50, spawnDaemon: () => { diagnosticSpawnCount += 1; }, @@ -169,12 +186,14 @@ const idleManager = new FakeManager(); idleManager.activeTurnCount = 0; const idleDaemon = new LocalAgentDaemon({ stateDir: idleStateDir, + configRevision: CONFIG_REVISION, manager: idleManager, idleShutdownMs: 200, idleCheckIntervalMs: 10, }); const idleClient = new LocalAgentClient({ stateDir: idleStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 2_000, requestTimeoutMs: 2_000, spawnDaemon: () => { void idleDaemon.start(); }, @@ -193,11 +212,13 @@ const ownerManager = new FakeManager(); const competingManager = new FakeManager(); const ownerDaemon = new LocalAgentDaemon({ stateDir: ownershipStateDir, + configRevision: CONFIG_REVISION, manager: ownerManager, idleShutdownMs: 60_000, }); const competingDaemon = new LocalAgentDaemon({ stateDir: ownershipStateDir, + configRevision: CONFIG_REVISION, manager: competingManager, idleShutdownMs: 60_000, }); @@ -223,6 +244,7 @@ try { assert.equal(readFileSync(ownerDaemon.paths.pidPath, "utf8"), pidBefore); const ownerClient = new LocalAgentClient({ stateDir: ownershipStateDir, + configRevision: CONFIG_REVISION, spawnDaemon: () => { throw new Error("the winning daemon should already be reachable"); }, }); assert.equal(unwrap(await ownerClient.status()).pid, process.pid); @@ -233,6 +255,7 @@ try { const startupFailureClient = new LocalAgentClient({ stateDir: join(root, "startup-failure-state"), + configRevision: CONFIG_REVISION, startupTimeoutMs: 20, requestTimeoutMs: 10, spawnDaemon: () => { throw new Error("spawn failed"); }, @@ -241,6 +264,125 @@ const startupFailure = await startupFailureClient.ensureReady(); assert.equal(startupFailure.isErr(), true); if (startupFailure.isErr()) assert.equal(startupFailure.error.code, "DAEMON_STARTUP_FAILURE"); +// Keep Unix socket paths below macOS's short sockaddr_un path limit. +const staleIdleStateDir = join(root, "si"); +const staleIdleManager = new FakeManager(); +staleIdleManager.activeTurnCount = 0; +const staleIdleDaemon = new LocalAgentDaemon({ + stateDir: staleIdleStateDir, + configRevision: "old-provider-config", + manager: staleIdleManager, + idleShutdownMs: 60_000, +}); +const currentManager = new FakeManager(); +currentManager.activeTurnCount = 0; +const currentDaemon = new LocalAgentDaemon({ + stateDir: staleIdleStateDir, + configRevision: CONFIG_REVISION, + manager: currentManager, + idleShutdownMs: 60_000, +}); +let currentDaemonSpawns = 0; +const staleIdleClient = new LocalAgentClient({ + stateDir: staleIdleStateDir, + configRevision: CONFIG_REVISION, + startupTimeoutMs: 2_000, + requestTimeoutMs: 500, + spawnDaemon: () => { + currentDaemonSpawns += 1; + void currentDaemon.start(); + }, +}); +try { + await staleIdleDaemon.start(); + assert.equal(unwrap(await staleIdleClient.ensureReady()).state, "ready"); + assert.equal(staleIdleManager.closed, true); + assert.equal(currentDaemonSpawns, 1); +} finally { + await staleIdleDaemon.close(); + await currentDaemon.close(); +} + +const staleActiveStateDir = join(root, "sa"); +const staleActiveManager = new FakeManager(); +const staleActiveDaemon = new LocalAgentDaemon({ + stateDir: staleActiveStateDir, + configRevision: "old-provider-config", + manager: staleActiveManager, + idleShutdownMs: 60_000, +}); +let staleActiveSpawns = 0; +const staleActiveClient = new LocalAgentClient({ + stateDir: staleActiveStateDir, + configRevision: CONFIG_REVISION, + startupTimeoutMs: 500, + requestTimeoutMs: 500, + spawnDaemon: () => { staleActiveSpawns += 1; }, +}); +try { + await staleActiveDaemon.start(); + const changed = await staleActiveClient.ensureReady(); + assert.equal(changed.isErr(), true); + if (changed.isErr()) { + assert.equal(changed.error.code, "DAEMON_CONFIG_CHANGED"); + assert.equal(changed.error.retryable, true); + } + assert.equal(staleActiveSpawns, 0); + assert.equal(staleActiveManager.closed, false); + assert.deepEqual(Object.keys(unwrap(await staleActiveClient.status())).sort(), [ + "activeTurns", + "clientConnections", + "endpoint", + "pid", + "protocolVersion", + "runtimeCount", + "startedAt", + "state", + ]); +} finally { + await staleActiveDaemon.close(); +} + +const configRaceStateDir = join(root, "sr"); +const configRaceManager = new FakeManager(); +configRaceManager.activeTurnCount = 0; +configRaceManager.blockStartUntilRelease = true; +const configRaceDaemon = new LocalAgentDaemon({ + stateDir: configRaceStateDir, + configRevision: "old-provider-config", + manager: configRaceManager, + idleShutdownMs: 60_000, +}); +const matchingRaceClient = new LocalAgentClient({ + stateDir: configRaceStateDir, + configRevision: "old-provider-config", + spawnDaemon: () => { throw new Error("the existing daemon should be used"); }, +}); +const changedRaceClient = new LocalAgentClient({ + stateDir: configRaceStateDir, + configRevision: CONFIG_REVISION, + spawnDaemon: () => { throw new Error("a busy daemon must not be replaced"); }, +}); +try { + await configRaceDaemon.start(); + const starting = matchingRaceClient.run({ + target: "reviewer", + prompt: "race with replacement", + workspaceId: record.workspaceId, + workspaceRoot: record.workspaceRoot, + }); + await waitFor(() => configRaceManager.startStarted); + const changed = await changedRaceClient.ensureReady(); + assert.equal(changed.isErr(), true); + if (changed.isErr()) assert.equal(changed.error.code, "DAEMON_CONFIG_CHANGED"); + assert.equal(configRaceManager.closed, false); + configRaceManager.releaseBlockedStart(); + unwrap(await starting); +} finally { + configRaceManager.releaseBlockedStart(); + await configRaceDaemon.close(); +} + const upgradeStateDir = join(root, "upgrade-state"); await mkdir(upgradeStateDir, { recursive: true }); const upgradePaths = localAgentDaemonPaths(upgradeStateDir); @@ -306,6 +448,7 @@ const replacementManager = new FakeManager(); replacementManager.activeTurnCount = 0; const replacementDaemon = new LocalAgentDaemon({ stateDir: upgradeStateDir, + configRevision: CONFIG_REVISION, manager: replacementManager, idleShutdownMs: 60_000, }); @@ -313,6 +456,7 @@ let replacementSpawns = 0; let spawnedBeforeLegacyLockReleased = false; const upgradeClient = new LocalAgentClient({ stateDir: upgradeStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 2_000, requestTimeoutMs: 500, spawnDaemon: () => { @@ -368,20 +512,24 @@ const replacementRaceServer = createNetServer((socket) => { })); return; } + const status = { + state: request.method === "daemon.stop" ? "stopping" as const : "ready" as const, + protocolVersion: replacementRaceProtocol, + pid: process.pid, + endpoint: replacementRacePaths.endpoint, + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: replacementRaceProtocol, ok: true, - result: { - state: request.method === "daemon.stop" ? "stopping" : "ready", - protocolVersion: replacementRaceProtocol, - pid: process.pid, - endpoint: replacementRacePaths.endpoint, - startedAt: "now", - activeTurns: 0, - runtimeCount: 0, - clientConnections: 1, - }, + result: request.method === "hello" + && replacementRaceProtocol === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION + ? { status, configMatches: true } + : status, }), () => { if (request.method === "daemon.stop") { replacementRaceProtocol = LOCAL_AGENT_DAEMON_PROTOCOL_VERSION; @@ -395,6 +543,7 @@ await new Promise((resolveListen, rejectListen) => { }); const replacementRaceClient = new LocalAgentClient({ stateDir: replacementRaceStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 500, requestTimeoutMs: 100, spawnDaemon: () => { @@ -447,6 +596,7 @@ await new Promise((resolveListen, rejectListen) => { try { const timeoutClient = new LocalAgentClient({ stateDir: timeoutStateDir, + configRevision: CONFIG_REVISION, endpoint: timeoutPaths.endpoint, requestTimeoutMs: 20, spawnDaemon: () => { throw new Error("existing daemon should be used"); }, @@ -484,6 +634,7 @@ await new Promise((resolveListen, rejectListen) => { try { const invalidClient = new LocalAgentClient({ stateDir: invalidStateDir, + configRevision: CONFIG_REVISION, endpoint: invalidPaths.endpoint, requestTimeoutMs: 50, spawnDaemon: () => { throw new Error("existing daemon should be used"); }, @@ -507,6 +658,7 @@ const socketManager = new FakeManager(); socketManager.activeTurnCount = 0; const socketDaemon = new LocalAgentDaemon({ stateDir: socketStateDir, + configRevision: CONFIG_REVISION, manager: socketManager, requestReadTimeoutMs: 30, shutdownTimeoutMs: 100, @@ -550,6 +702,7 @@ try { authToken: "wrong-secret", method: "hello", params: {}, + configRevision: CONFIG_REVISION, }) + "\n"); assert.equal(unauthorized.ok, false); if (!unauthorized.ok) assert.equal(unauthorized.error.code, "DAEMON_UNAUTHORIZED"); diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index 75a3b6dd4..aac96e9d9 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -70,6 +70,7 @@ export interface LocalAgentDaemonManager { export interface LocalAgentDaemonOptions { stateDir: string; manager: LocalAgentDaemonManager; + configRevision: string; idleShutdownMs?: number; idleCheckIntervalMs?: number; requestReadTimeoutMs?: number; @@ -83,6 +84,7 @@ export interface LocalAgentDaemonOptions { export class LocalAgentDaemon { readonly paths: LocalAgentDaemonPaths; private readonly manager: LocalAgentDaemonManager; + private readonly configRevision: string; private readonly lock: LocalAgentDaemonLock; private readonly idleShutdownMs: number; private readonly idleCheckIntervalMs: number; @@ -99,12 +101,14 @@ export class LocalAgentDaemon { private startedAt?: string; private accepting = false; private stopping = false; + private activeTurnRequests = 0; private authToken?: string; private ownsLock = false; constructor(options: LocalAgentDaemonOptions) { this.paths = options.paths ?? localAgentDaemonPaths(options.stateDir); this.manager = options.manager; + this.configRevision = options.configRevision; this.lock = new LocalAgentDaemonLock(this.paths); this.idleShutdownMs = options.idleShutdownMs ?? DEFAULT_DAEMON_IDLE_SHUTDOWN_MS; this.idleCheckIntervalMs = options.idleCheckIntervalMs ?? DEFAULT_IDLE_CHECK_INTERVAL_MS; @@ -294,6 +298,12 @@ export class LocalAgentDaemon { ); } this.assertAuthenticated(request.authToken); + if (request.method === "hello" && !request.configRevision) { + throw new LocalAgentDaemonProtocolError( + "INVALID_REQUEST", + "Daemon hello requires a provider configuration revision.", + ); + } if (!this.accepting && request.method !== "hello" && request.method !== "daemon.status") { throw new AgentDaemonUnavailableError({ code: "DAEMON_UNAVAILABLE", @@ -305,11 +315,14 @@ export class LocalAgentDaemon { switch (request.method) { case "hello": - return this.status(); + return { + status: this.status(), + configMatches: request.configRevision === this.configRevision, + }; case "agent.start": - return unwrapManagerResult(await this.manager.start(request.params)); + return this.runTurnRequest(() => this.manager.start(request.params)); case "agent.continue": - return unwrapManagerResult(await this.manager.continue( + return this.runTurnRequest(() => this.manager.continue( request.params.id, request.params.prompt, request.params.overrides, @@ -329,6 +342,18 @@ export class LocalAgentDaemon { case "daemon.status": return this.status(); case "daemon.stop": + if (request.params.ifIdle) { + this.accepting = false; + if (this.activeTurnRequests > 0 || this.manager.activeTurnCount > 0) { + this.accepting = true; + throw new AgentDaemonUnavailableError({ + code: "DAEMON_UNAVAILABLE", + operation: "daemon.stop", + retryable: true, + message: "Local agent daemon became busy before it could be replaced.", + }); + } + } this.stopping = true; this.accepting = false; return this.status(); @@ -337,6 +362,17 @@ export class LocalAgentDaemon { } } + private async runTurnRequest( + operation: () => Promise>, + ): Promise { + this.activeTurnRequests += 1; + try { + return unwrapManagerResult(await operation()); + } finally { + this.activeTurnRequests -= 1; + } + } + private writeError(socket: Socket, requestId: string, error: LocalAgentDaemonErrorPayload): void { socket.end(encodeLocalAgentDaemonResponse({ requestId, diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index dfaa7932a..2941c81c8 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -103,6 +103,10 @@ export class AgentDaemonProtocolMismatchError extends TaggedError( "AgentDaemonProtocolMismatchError", )() {} +export class AgentDaemonConfigChangedError extends TaggedError( + "AgentDaemonConfigChangedError", +)() {} + export class AgentDaemonUnauthorizedError extends TaggedError( "AgentDaemonUnauthorizedError", )() {} @@ -124,6 +128,7 @@ export type AgentDaemonError = | AgentDaemonStartupError | AgentDaemonTimeoutError | AgentDaemonProtocolMismatchError + | AgentDaemonConfigChangedError | AgentDaemonUnauthorizedError | AgentDaemonInvalidRequestError | AgentDaemonInvalidResponseError @@ -179,6 +184,7 @@ export function isAgentDaemonError(error: unknown): error is AgentDaemonError { || AgentDaemonStartupError.is(error) || AgentDaemonTimeoutError.is(error) || AgentDaemonProtocolMismatchError.is(error) + || AgentDaemonConfigChangedError.is(error) || AgentDaemonUnauthorizedError.is(error) || AgentDaemonInvalidRequestError.is(error) || AgentDaemonInvalidResponseError.is(error) @@ -207,6 +213,7 @@ export function toAgentErrorPayload(error: LocalAgentError): AgentErrorPayload { AgentDaemonStartupError: daemonErrorPayload, AgentDaemonTimeoutError: daemonErrorPayload, AgentDaemonProtocolMismatchError: daemonErrorPayload, + AgentDaemonConfigChangedError: daemonErrorPayload, AgentDaemonUnauthorizedError: daemonErrorPayload, AgentDaemonInvalidRequestError: daemonErrorPayload, AgentDaemonInvalidResponseError: daemonErrorPayload, @@ -315,6 +322,13 @@ export function agentErrorFromPayload(payload: { retryable, message: payload.message, }); + case "DAEMON_CONFIG_CHANGED": + return new AgentDaemonConfigChangedError({ + code: payload.code, + operation: payload.operation ?? "hello", + retryable, + message: payload.message, + }); case "DAEMON_UNAUTHORIZED": return new AgentDaemonUnauthorizedError({ code: payload.code, From a217455c54edcc1ccbbc474358d7d99cfe1e34ce Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:39:34 +0530 Subject: [PATCH 11/13] docs(agents): teach XML and multi-agent wait --- docs/agent-profile-schema.md | 36 ++++++++++++++++++---- docs/chatgpt-coding-workflow.md | 3 +- docs/configuration.md | 32 +++++++++++++++++--- docs/gotchas.md | 2 +- docs/local-agent-daemon.md | 29 +++++++++++------- docs/setup.md | 2 +- skills/subagents/SKILL.md | 53 +++++++++++++++++++-------------- src/cli.ts | 2 ++ 8 files changed, 114 insertions(+), 45 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 9fecf4993..fce66bc8c 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -147,16 +147,40 @@ Recommended body content: ## Model-facing workflow -The Subagent skill teaches only: +The Subagent skill uses the default compact XML fragments: ```bash -devspace agents ls --json -devspace agents targets --json -devspace agents run "" --json -devspace agents continue "" --json -devspace agents show --json +devspace agents targets +devspace agents ls +devspace agents run "" +devspace agents continue "" +devspace agents show +devspace agents wait ... ``` +The commands do not add a document-level wrapper. `targets`, `ls`, and `wait` +print one fragment per item and print nothing for an empty list. This keeps the +model-facing result small: + +```xml + +Read-only code review. + +Review complete. +Provider disconnected. +Subagent not found. +``` + +`show` returns an immediate snapshot. `wait` accepts one or more agent IDs and +waits for all of their current work. It does not stream fragments as individual +agents finish. With `--timeout `, it returns each unique agent in +first-seen order and marks unfinished work with `status="running" +wait="timeout"`. + +`--json` remains available for scripts that need it, but the bundled skill does +not request it. Internal turn records, prompts, provider session IDs, workspace +paths, and timestamps are absent from both output formats. + `open_workspace` exposes compact profile metadata: ```json diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 7d53fa397..584834364 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -150,7 +150,8 @@ the managed `subagents` skill for a separate read only when the model decides delegation would help. Set it to `preload` to include those instructions in the initial `open_workspace` result instead. The skill teaches the minimal `devspace agents targets`, `devspace agents ls`, `devspace agents run`, -`devspace agents continue`, and `devspace agents show` workflow. The catalog +`devspace agents continue`, `devspace agents show`, and `devspace agents wait` +workflow. The catalog comes from `open_workspace`; `devspace agents ls` lists existing subagent sessions for that workspace. diff --git a/docs/configuration.md b/docs/configuration.md index 858d945fc..abb42ae07 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -133,6 +133,11 @@ Subagent providers are explicit. Omitted providers are disabled: "enabled": true, "model": "gpt-5.4", "effort": "high", + "command": "/opt/devspace/bin/codex-wrapper", + "env": { + "CODEX_HOME": "/home/alice/.codex-work", + "OPENAI_BASE_URL": "https://api.example.com/v1", + }, }, { "id": "claude", @@ -158,10 +163,29 @@ Profiles are loaded from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. `devspace agents targets` prints the configured targets available in the current workspace. -Provider executable discovery remains process-scoped. The supported overrides -are `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, -`COPILOT_COMMAND`, `GROK_COMMAND`, `GROK_AGENT_PROFILE`, and `ANTIGRAVITY_COMMAND`. DevSpace does not -persist provider credentials. If you set or modify these environment variables or `PATH` while DevSpace is running, restart the daemon (`devspace agents daemon stop` or restart `devspace serve`) so that the running process picks up the changes. +`command` names one executable. DevSpace does not split shell arguments, so use +a wrapper executable when startup needs fixed arguments. `env` maps environment +variable names to literal string values and preserves empty strings. DevSpace +does not expand `$NAME` references in these values. + +Codex, Claude, Cursor, Copilot, Grok, and Antigravity accept `command` and `env`. +OpenCode and Pi are embedded, so their provider entries reject both fields. The +daemon inherits its startup environment, then overlays the provider's `env`. An +explicit `command` wins over both the inherited command override and a command +override placed in `env`. + +Existing process-level overrides remain supported: `CODEX_COMMAND`, +`CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, +`GROK_COMMAND`, `GROK_AGENT_PROFILE`, and `ANTIGRAVITY_COMMAND`. Provider +configuration takes precedence where the same value is set in both places. If +you set or modify these environment variables or `PATH` while DevSpace is +running, restart the daemon (`devspace agents daemon stop` or restart +`devspace serve`) so that the running process picks up the changes. + +DevSpace writes `config.jsonc` with mode `0600`, but provider environment values +are still plain text on disk. Keep the file out of version control. Leave +credentials in the process environment if you do not want DevSpace to persist +them. ## Native artifact download diff --git a/docs/gotchas.md b/docs/gotchas.md index a43a59d68..2f8bb8e97 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -232,7 +232,7 @@ its managed `subagents` skill to `~/.devspace/skills/subagents/SKILL.md` and uses that copy instead of a package-manager path. The skill keeps the model-facing workflow to `devspace agents targets`, `devspace agents ls`, `devspace agents run`, -`devspace agents continue`, and `devspace agents show`. +`devspace agents continue`, `devspace agents show`, and `devspace agents wait`. Those commands automatically manage the internal local agent daemon; `devspace serve` is not a prerequisite. `devspace agents ls` lists existing subagent sessions, not profile diff --git a/docs/local-agent-daemon.md b/docs/local-agent-daemon.md index e1313b2db..f940cfa21 100644 --- a/docs/local-agent-daemon.md +++ b/docs/local-agent-daemon.md @@ -5,7 +5,7 @@ by the MCP server and not by an individual CLI invocation. The daemon is an internal implementation detail: the normal workflow remains: ```text -devspace agents run/continue/show/ls +devspace agents targets/run/continue/show/wait/ls │ ▼ devspace-agentd @@ -58,15 +58,24 @@ devspace agents daemon stop devspace agents daemon logs ``` -Agent commands accept `--json` when a machine-readable response is needed. -They emit one compact JSON value. `run` and `continue` return only the logical -agent ID and status, `ls` returns session summaries, and `show` returns the -response or structured failure for one agent. Internal workspace paths, -provider session IDs, timestamps, and prior responses are not included in list -or receipt output. Immediate failures are emitted as -`{ error: { code, message, retryable, ... } }` with a non-zero exit code. -Successful `daemon status` and `daemon stop` output the daemon status object, -and successful `daemon logs` output is `{ "logs": "" }`. +The client and daemon compare an internal revision of the provider +configuration. A client replaces an idle daemon when that configuration has +changed. It never stops a daemon with active work; the client returns the +retryable `DAEMON_CONFIG_CHANGED` error until that work finishes. The revision +is not included in status, logs, or agent command output. + +Model-facing agent commands emit compact XML fragments by default. Lists use +one fragment per item without a root wrapper, and empty lists print nothing. +`run` and `continue` return only the logical agent ID and status. `show` returns +an immediate snapshot. `wait` blocks for one or more agents and can return a +complete ordered snapshot at a caller-supplied timeout. It does not stream +individual completions. + +Internal turns, prompts, workspace paths, provider session IDs, timestamps, and +prior responses are not included. Immediate failures use an `` fragment +and a non-zero exit code. `--json` remains available for compatibility and +scripts. Daemon diagnostic commands keep their existing text and JSON output; +they do not use the model-facing XML format. Agent identity is explicit at the client boundary. `agents run` starts a new logical agent from a profile or provider; `agents continue ` continues an diff --git a/docs/setup.md b/docs/setup.md index b7c044d3b..9d1f7e97b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -65,7 +65,7 @@ npx skills add Waishnav/devspace --skill subagents --global ``` The Skills CLI asks which installed Coding Agents should receive the skill. -The skill uses `devspace agents targets`, `run`, `continue`, `show`, and `ls`. +The skill uses `devspace agents targets`, `run`, `continue`, `show`, `wait`, and `ls`. These commands do not require `devspace serve`. This Coding Agent installation is separate from ChatGPT MCP usage. For MCP diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 95ca378ed..ed137cf6f 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -7,49 +7,58 @@ description: Delegate focused coding, research, review, or verification work to Subagents are optional. Use the normal workspace tools for routine work; delegate only when a separate worker materially helps through independent context, specialization, or follow-up. -Use the DevSpace CLI through the shell or process tool. Run commands from the project the subagent should work on. +Run the DevSpace CLI through the shell or process tool from the project the subagent should use. Agent commands print compact XML fragments by default. Read that output directly. Do not add `--json`. ## Choose a target Discover usable targets instead of guessing names: ```bash -devspace agents targets --json +devspace agents targets ``` -Configured profiles include a description and may define provider, model, effort, and task instructions. Choose a matching profile when one fits. Use a provider target when no profile fits or a specific provider is needed. - -Usually rely on the target's configured model and effort. Pass `--model` or `--effort` only with a value supported by that provider. DevSpace passes these values through without translating them between providers. +Each line is a `` or `description` fragment. Prefer a matching profile. Use a provider target when no profile fits or the task needs a specific provider. Keep the configured model and effort unless the task requires a supported override. ## Start work -Give the subagent a self-contained brief. Include the objective, relevant paths, constraints, decisions it needs from the current conversation, and the expected result. The subagent receives the brief and its profile instructions, not the parent conversation. +Give the subagent a self-contained brief with the objective, relevant paths, constraints, context it cannot infer, and the expected result. The subagent receives this brief and its profile instructions, not the parent conversation. ```bash -devspace agents run "" --json -devspace agents run --model --effort "" --json +devspace agents run "" +devspace agents run --model --effort "" ``` -The result contains a DevSpace agent `id` and its current status. Execution continues independently, so retain the ID for later inspection or follow-up. +The command returns an `` receipt. Keep the DevSpace agent ID for inspection, waiting, or follow-up. + +## Wait or inspect -## Inspect and continue +Use `wait` when work must finish before you proceed. One call can wait for several agents: ```bash -devspace agents show --json -devspace agents continue "" --json -devspace agents ls --json +devspace agents wait +devspace agents wait +devspace agents wait --timeout 60 ``` -- `show` waits briefly for active work, then returns the current status and any - available response or error. -- `continue` gives the same subagent another turn with its existing provider - session and context. -- `ls` returns sessions belonging to the current project. +Without `--timeout`, the command waits until every named agent's current work finishes. A timeout returns one fragment per unique agent in first-seen order; unfinished work has `status="running" wait="timeout"`. Completed output is the element text. Failures include `code` and `retryable` attributes. The command does not stream partial results. + +Use `show` for an immediate snapshot. Do not poll it when `wait` can express the dependency. + +```bash +devspace agents show +devspace agents ls +``` -Run `devspace agents show --json` again later while the status is `running`. -`completed` includes the response. `failed` includes a structured error, and -`stopped` is terminal without a successful response. Continue an agent when its -existing context is useful; start another agent for unrelated work. +`ls` lists agents for the current project. Empty `targets` and `ls` results print nothing. + +## Continue related work + +Continue an agent when its existing provider context helps. Start another agent for unrelated work. + +```bash +devspace agents continue "" +devspace agents wait +``` ## Good uses diff --git a/src/cli.ts b/src/cli.ts index 775b5a1d2..28632996b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -488,10 +488,12 @@ function printHelp(): void { " devspace config set publicBaseUrl ", " devspace worktrees prune Prune managed worktrees unused for 3 days", " devspace show-changes [--json]", + " devspace agents targets [--json] List usable subagent providers and profiles", " devspace agents ls List subagent sessions", " devspace agents run [--model ] [--effort ] ", " devspace agents continue [--model ] [--effort ] ", " devspace agents show ", + " devspace agents wait ... [--timeout ] [--json]", " devspace agents daemon ", " devspace -v, --version Print the installed version", "", From f47a08e27815bc66c4b05e8aa97f0e72e27976eb Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:05:49 +0530 Subject: [PATCH 12/13] docs(agents): clarify target and JSON help --- skills/subagents/SKILL.md | 2 +- src/cli.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index ed137cf6f..5ef4f5bb7 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -17,7 +17,7 @@ Discover usable targets instead of guessing names: devspace agents targets ``` -Each line is a `` or `description` fragment. Prefer a matching profile. Use a provider target when no profile fits or the task needs a specific provider. Keep the configured model and effort unless the task requires a supported override. +Each line is a `` or `description` fragment. Pass the profile or provider `name` as ``. Prefer a matching profile. Use a provider target when no profile fits or the task needs a specific provider. Keep the configured model and effort unless the task requires a supported override. ## Start work diff --git a/src/cli.ts b/src/cli.ts index 28632996b..418350d83 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -492,7 +492,7 @@ function printHelp(): void { " devspace agents ls List subagent sessions", " devspace agents run [--model ] [--effort ] ", " devspace agents continue [--model ] [--effort ] ", - " devspace agents show ", + " devspace agents show [--json]", " devspace agents wait ... [--timeout ] [--json]", " devspace agents daemon ", " devspace -v, --version Print the installed version", From 8cf6b51ea76c9a4ae402db5ff4bb5e2e4103eaff Mon Sep 17 00:00:00 2001 From: starlove7 Date: Wed, 9 Sep 2026 13:14:36 +0000 Subject: [PATCH 13/13] fix(tests): add missing instructions field in subagentsConfig mock --- src/local-agent-availability.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 83cb3ef78..7a5bb0b67 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -46,6 +46,7 @@ assert.equal( }, { enabled: true, + instructions: "on-demand", providers: [{ id: "codex", enabled: true,