Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ lists existing subagent sessions for the current workspace, scoped by the
workspace environment injected into shell commands. The `subagent-delegation`
skill teaches the model to use only the minimal `devspace agents ls`,
`devspace agents run`, and `devspace agents show` workflow.
`devspace agents run` submits work to the running DevSpace server so subagent
execution can reuse long-lived provider resources. Start `devspace serve` with
`DEVSPACE_SUBAGENTS=1` before using the agent commands directly from another
terminal.

Starter profile templates are available under `examples/agents/`. Copy or adapt
them into one of the active profile directories before use.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/local-agent-control.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
170 changes: 11 additions & 159 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,19 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { stdin as input, stdout as output } from "node:process";
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import * as prompts from "@clack/prompts";
import { getShellConfig } from "@earendil-works/pi-coding-agent";
import { satisfies } from "semver";
import { loadConfig } from "./config.js";
import { runLocalAgentProvider } from "./local-agent-adapters.js";
import {
isLocalAgentProvider,
loadLocalAgentProfiles,
type LocalAgentProfile,
} from "./local-agent-profiles.js";
import {
assertLocalAgentProviderAvailable,
formatLocalAgentProviderAvailabilitySummary,
} from "./local-agent-availability.js";
import {
formatAvailableLocalAgentTargets,
parseLocalAgentRunArgs,
resolveLocalAgentTarget,
} from "./local-agent-targets.js";
import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-store.js";
import type { LocalAgentRunResult } from "./local-agent-runtime.js";
import { requestLocalAgentRun } from "./local-agent-control.js";
import {
ensureDevspaceDefaultSkills,
generateOwnerToken,
Expand Down Expand Up @@ -213,7 +199,8 @@ async function serve(): Promise<void> {

const { createServer } = await import("./server.js");
const config = loadConfig();
const { app, close, localAgentProviders } = createServer(config);
const { app, close, localAgentProviders, startAgentControl } = createServer(config);
await startAgentControl();
const httpServer = app.listen(config.port, config.host, () => {
console.log(`devspace listening on http://${config.host}:${config.port}/mcp`);
console.log(`public base url: ${config.publicBaseUrl}`);
Expand Down Expand Up @@ -333,9 +320,6 @@ async function runAgentsCommand(args: string[]): Promise<void> {
case "show":
await runAgentsShow(rest);
return;
case "__worker":
await runAgentsWorker(rest);
return;
case undefined:
case "help":
case "--help":
Expand Down Expand Up @@ -364,56 +348,16 @@ async function runAgentsList(): Promise<void> {

async function runAgentsRun(args: string[]): Promise<void> {
const parsed = parseLocalAgentRunArgs(args);

const config = loadConfig();
const workspaceRoot = resolveCurrentWorkspaceRoot();
const store = createLocalAgentStore(config);
const existing = store.get(parsed.target);

if (existing) {
if (!isLocalAgentProvider(existing.provider)) {
throw new Error(`Unknown subagent provider for existing session: ${existing.provider}`);
}
assertLocalAgentProviderAvailable(existing.provider);
const promptFile = writeAgentPromptFile(parsed.prompt);
store.update(existing.id, {
status: "starting",
model: parsed.model ?? existing.model,
thinking: parsed.thinking ?? existing.thinking,
latestResponse: undefined,
error: undefined,
});
spawnAgentWorker(existing.id, promptFile);
console.log(formatAgentLine({
...existing,
status: "running",
model: parsed.model ?? existing.model,
thinking: parsed.thinking ?? existing.thinking,
}));
return;
}

const profiles = await loadLocalAgentProfiles(config, workspaceRoot);
const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model, parsed.thinking);
if (!target) {
throw new Error(
`Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`,
);
}
assertLocalAgentProviderAvailable(target.provider);

const promptFile = writeAgentPromptFile(parsed.prompt);
const record = store.create({
const record = await requestLocalAgentRun(config, {
workspaceId: process.env.DEVSPACE_WORKSPACE_ID,
workspaceRoot,
profileName: target.name,
provider: target.provider,
model: target.model,
thinking: target.thinking,
workspaceRoot: resolveCurrentWorkspaceRoot(),
target: parsed.target,
prompt: parsed.prompt,
model: parsed.model,
thinking: parsed.thinking,
});

spawnAgentWorker(record.id, promptFile);
console.log(formatAgentLine({ ...record, status: "running" }));
console.log(formatAgentLine(record));
}

async function runAgentsShow(args: string[]): Promise<void> {
Expand Down Expand Up @@ -445,98 +389,6 @@ async function runAgentsShow(args: string[]): Promise<void> {
}
}

async function runAgentsWorker(args: string[]): Promise<void> {
const [id, promptFileFlag, promptFile] = args;
if (!id || promptFileFlag !== "--prompt-file" || !promptFile) {
throw new Error("Usage: devspace agents __worker <id> --prompt-file <path>");
}

const config = loadConfig();
const store = createLocalAgentStore(config);
const record = store.get(id);
if (!record) throw new Error(`Unknown subagent id: ${id}`);

store.update(record.id, { status: "running", error: undefined });
try {
const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot);
const profile = profiles.find((candidate) => candidate.name === record.profileName);
const prompt = await readFile(promptFile, "utf8");
const result = profile
? await runLocalAgentProfile(profile, record, prompt)
: await runRawLocalAgentProvider(record, prompt);
store.update(record.id, {
providerSessionId: result.providerSessionId ?? undefined,
status: "idle",
latestResponse: result.finalResponse,
error: undefined,
});
} catch (error) {
store.update(record.id, {
status: "error",
error: error instanceof Error ? error.message : String(error),
});
}
}

async function runLocalAgentProfile(
profile: LocalAgentProfile,
record: LocalAgentRecord,
prompt: string,
): Promise<LocalAgentRunResult> {
const body = profile.body.trim();
const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt;
return runLocalAgentProvider(profile.provider, {
prompt: fullPrompt,
workspace: record.workspaceRoot,
providerSessionId: record.providerSessionId,
writeMode: "allowed",
model: record.model ?? profile.model,
thinking: record.thinking ?? profile.thinking,
});
}

async function runRawLocalAgentProvider(
record: LocalAgentRecord,
prompt: string,
): Promise<LocalAgentRunResult> {
if (record.profileName !== record.provider || !isLocalAgentProvider(record.provider)) {
throw new Error(`Subagent profile not found: ${record.profileName}`);
}

return runLocalAgentProvider(record.provider, {
prompt,
workspace: record.workspaceRoot,
providerSessionId: record.providerSessionId,
writeMode: "allowed",
model: record.model,
thinking: record.thinking,
});
}

function spawnAgentWorker(agentId: string, promptFile: string): void {
const child = spawn(process.execPath, [
...process.execArgv,
fileURLToPath(import.meta.url),
"agents",
"__worker",
agentId,
"--prompt-file",
promptFile,
], {
detached: true,
stdio: "ignore",
env: process.env,
});
child.unref();
}

function writeAgentPromptFile(prompt: string): string {
const directory = mkdtempSync(join(tmpdir(), "devspace-agent-prompt-"));
const filePath = join(directory, "prompt.txt");
writeFileSync(filePath, prompt, { mode: 0o600 });
return filePath;
}

function resolveCurrentWorkspaceRoot(): string {
return resolve(process.env.DEVSPACE_WORKSPACE_ROOT || process.cwd());
}
Expand Down
82 changes: 82 additions & 0 deletions src/local-agent-control.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfig } from "./config.js";
import {
LocalAgentControlServer,
localAgentControlAddress,
requestLocalAgentRun,
type LocalAgentCommandHandler,
} from "./local-agent-control.js";
import type { LocalAgentRunCommand } from "./local-agent-manager.js";
import type { LocalAgentRecord } from "./local-agent-store.js";

const root = mkdtempSync(join(tmpdir(), "devspace-local-agent-control-test-"));
const config = loadConfig({
...process.env,
DEVSPACE_CONFIG_DIR: join(root, "config"),
DEVSPACE_STATE_DIR: join(root, "state"),
DEVSPACE_WORKTREE_ROOT: join(root, "worktrees"),
DEVSPACE_ALLOWED_ROOTS: root,
DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-long-enough",
});
let received: LocalAgentRunCommand | undefined;
const expected: LocalAgentRecord = {
id: "agt_control",
workspaceId: "ws_control",
workspaceRoot: root,
profileName: "codex",
provider: "codex",
status: "running",
model: undefined,
thinking: undefined,
providerSessionId: undefined,
latestResponse: undefined,
error: undefined,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const handler: LocalAgentCommandHandler = {
async enqueue(command) {
received = command;
return expected;
},
};
const server = new LocalAgentControlServer(config, handler);

try {
await server.start();
const record = await requestLocalAgentRun(config, {
workspaceId: "ws_control",
workspaceRoot: root,
target: "codex",
prompt: "inspect",
model: "gpt-test",
thinking: "high",
});

assert.deepEqual(received, {
workspaceId: "ws_control",
workspaceRoot: root,
target: "codex",
prompt: "inspect",
model: "gpt-test",
thinking: "high",
});
assert.deepEqual(record, expected);

const idleSocket = createConnection(localAgentControlAddress(config.stateDir));
await new Promise<void>((resolve, reject) => {
idleSocket.once("connect", resolve);
idleSocket.once("error", reject);
});
const socketClosed = new Promise<void>((resolve) => idleSocket.once("close", () => resolve()));
await server.close();
await socketClosed;
assert.equal(idleSocket.destroyed, true);
} finally {
await server.close();
rmSync(root, { recursive: true, force: true });
}
Loading
Loading