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
2 changes: 1 addition & 1 deletion docs/agent-profile-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Unsupported or custom providers are rejected. DevSpace maps providers to their
native integration:

- `codex`: the user's Codex CLI through `codex app-server`
- `claude`: Claude Code SDK
- `claude`: warm Claude Code SDK streaming session
- `opencode`: OpenCode SDK
- `pi`: embedded Pi `AgentSession` runtime
- `cursor`: ACP
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-codex/runtime.test.ts && tsx src/local-agent-pi/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/local-agent-runtime-pool.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-codex/runtime.test.ts && tsx src/local-agent-pi/runtime.test.ts && tsx src/local-agent-claude/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/local-agent-runtime-pool.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
91 changes: 61 additions & 30 deletions src/local-agent-acp/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,21 @@ import {

const STDERR_LIMIT = 32_000;
const SHUTDOWN_GRACE_MS = 2_000;
const ACP_SESSION_IDLE_MS = 5 * 60 * 1_000;

type AcpProvider = "cursor" | "copilot";

interface PendingAcpTurn {
textParts: string[];
}

interface AcpSessionEntry extends AcpSessionConfigState {
active: boolean;
lastUsedAt: number;
}

export class AcpHarnessRuntime implements HarnessRuntime {
private readonly sessions = new Map<string, AcpSessionConfigState>();
private readonly sessions = new Map<string, AcpSessionEntry>();
private readonly pendingTurns = new Map<string, PendingAcpTurn>();
private closed = false;

Expand All @@ -49,33 +55,38 @@ export class AcpHarnessRuntime implements HarnessRuntime {
if (this.pendingTurns.has(session.sessionId)) {
throw new Error(`${this.provider} ACP session ${session.sessionId} already has a turn in progress.`);
}

if (input.model) {
await this.connection.agent.request("session/set_config_option", {
...resolveAcpModelConfigUpdate(session, input.model, this.provider),
});
}
if (input.thinking) {
await this.connection.agent.request("session/set_config_option", {
...resolveAcpThinkingConfigUpdate(session, input.thinking, this.provider),
});
}

const pending: PendingAcpTurn = { textParts: [] };
this.pendingTurns.set(session.sessionId, pending);
session.active = true;
try {
await this.connection.agent.request("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: input.prompt }],
});
return {
provider: this.provider,
providerSessionId: session.sessionId,
finalResponse: pending.textParts.join("").trim(),
items: [],
};
if (input.model) {
await this.connection.agent.request("session/set_config_option", {
...resolveAcpModelConfigUpdate(session, input.model, this.provider),
});
}
if (input.thinking) {
await this.connection.agent.request("session/set_config_option", {
...resolveAcpThinkingConfigUpdate(session, input.thinking, this.provider),
});
}

const pending: PendingAcpTurn = { textParts: [] };
this.pendingTurns.set(session.sessionId, pending);
try {
await this.connection.agent.request("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: input.prompt }],
});
return {
provider: this.provider,
providerSessionId: session.sessionId,
finalResponse: pending.textParts.join("").trim(),
items: [],
};
} finally {
this.pendingTurns.delete(session.sessionId);
}
} finally {
this.pendingTurns.delete(session.sessionId);
session.active = false;
session.lastUsedAt = Date.now();
}
} catch (error) {
const detail = this.stderr().trim();
Expand All @@ -100,6 +111,19 @@ export class AcpHarnessRuntime implements HarnessRuntime {
&& this.child.signalCode === null;
}

async reapIdleSessions(now: number): Promise<void> {
if (!this.initializeResponse.agentCapabilities?.sessionCapabilities?.close) return;
for (const [sessionId, session] of this.sessions) {
if (session.active || now - session.lastUsedAt < ACP_SESSION_IDLE_MS) continue;
try {
await this.connection.agent.request("session/close", { sessionId });
this.sessions.delete(sessionId);
} catch {
// The connection remains useful; retry this session on a later reap.
}
}
}

async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
Expand All @@ -118,22 +142,25 @@ export class AcpHarnessRuntime implements HarnessRuntime {
}
}

private async ensureSession(input: LocalAgentRunInput): Promise<AcpSessionConfigState> {
private async ensureSession(input: LocalAgentRunInput): Promise<AcpSessionEntry> {
if (input.providerSessionId) {
const existing = this.sessions.get(input.providerSessionId);
if (existing) return existing;
const resumed = await this.resumeSession(input.providerSessionId, input.workspace);
this.sessions.set(input.providerSessionId, resumed);
return resumed;
const entry = withActivity(resumed);
this.sessions.set(input.providerSessionId, entry);
return entry;
}

const response = await this.connection.agent.request("session/new", {
cwd: input.workspace,
mcpServers: [],
}) as NewSessionResponse;
const session: AcpSessionConfigState = {
const session: AcpSessionEntry = {
sessionId: response.sessionId,
configOptions: response.configOptions,
active: false,
lastUsedAt: Date.now(),
};
this.sessions.set(session.sessionId, session);
return session;
Expand Down Expand Up @@ -163,6 +190,10 @@ export class AcpHarnessRuntime implements HarnessRuntime {
}
}

function withActivity(session: AcpSessionConfigState): AcpSessionEntry {
return { ...session, active: false, lastUsedAt: Date.now() };
}

export function createAcpHarnessDriver(
provider: AcpProvider,
command: readonly [string, ...string[]],
Expand Down
85 changes: 10 additions & 75 deletions src/local-agent-adapters.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk";
import type { LocalAgentProvider } from "./local-agent-profiles.js";
import type { LocalAgentRunInput, LocalAgentRunResult } from "./local-agent-runtime.js";
import type { HarnessDriver, HarnessRuntime } from "./local-agent-runtime-pool.js";
import { createCodexHarnessDriver } from "./local-agent-codex/runtime.js";
import { createAcpHarnessDriver } from "./local-agent-acp/runtime.js";
import { createPiHarnessDriver } from "./local-agent-pi/runtime.js";
import {
claudeCommandEnvironment,
createClaudeHarnessDriver,
} from "./local-agent-claude/runtime.js";
export { claudeCommandEnvironment } from "./local-agent-claude/runtime.js";
export {
resolveAcpModelConfigUpdate,
resolveAcpThinkingConfigUpdate,
Expand Down Expand Up @@ -62,86 +64,19 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter {
readonly provider = "claude" as const;

async run(input: LocalAgentRunInput): Promise<LocalAgentRunResult> {
const { query } = await import("@anthropic-ai/claude-agent-sdk");
const claudeExecutable = process.env.CLAUDE_COMMAND ?? resolveExecutable("claude");
const messages = query({
prompt: input.prompt,
options: {
cwd: input.workspace,
model: input.model,
...(input.thinking ? { thinking: { type: "adaptive" } as const, effort: input.thinking as EffortLevel } : {}),
resume: input.providerSessionId,
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
env: claudeCommandEnvironment(process.env),
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
},
});

let providerSessionId = input.providerSessionId ?? null;
let finalResponse = "";
const items: unknown[] = [];
for await (const message of messages) {
items.push(message);
const record = message as Record<string, unknown>;
if (typeof record.session_id === "string") providerSessionId = record.session_id;
if (record.type === "result" && typeof record.result === "string") {
const resultError = claudeResultError(record);
if (resultError) throw new Error(resultError);
finalResponse = record.result;
}
const runtime = await createClaudeHarnessDriver().createRuntime(input);
try {
return await runtime.run({ ...input, agentId: input.agentId ?? "direct" });
} finally {
await runtime.close();
}

finalResponse = requireFinalResponse("Claude", finalResponse);
return {
provider: this.provider,
providerSessionId,
finalResponse,
items,
};
}
}

function claudeResultError(record: Record<string, unknown>): string | undefined {
const subtype = typeof record.subtype === "string" ? record.subtype : undefined;
const isError = record.is_error === true || subtype?.startsWith("error");
if (!isError) return undefined;
const message =
directString(record.error) ??
directString(record.message) ??
directString(record.result) ??
subtype ??
"Claude returned an error result.";
return `Claude returned an error result: ${message}`;
}

function directString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function resolveExecutable(command: string): string | undefined {
const result = spawnSync(process.platform === "win32" ? "where.exe" : "command", [
...(process.platform === "win32" ? [command] : ["-v", command]),
], {
encoding: "utf8",
shell: process.platform !== "win32",
});
const executable = result.stdout?.split(/\r?\n/).find((line) => line.trim());
return executable?.trim() || undefined;
}

export function claudeCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env };
for (const key of [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SSE_PORT",
"CLAUDE_AGENT_SDK_VERSION",
]) {
delete next[key];
}
return next;
}

class OpencodeLocalAgentAdapter implements LocalAgentAdapter {
readonly provider = "opencode" as const;
Expand Down
114 changes: 114 additions & 0 deletions src/local-agent-claude/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import {
ClaudeWarmRuntime,
type ClaudeQueryLike,
} from "./runtime.js";

class FakeClaudeQuery implements ClaudeQueryLike {
readonly models: Array<string | undefined> = [];
closed = false;
private readonly prompts: AsyncIterator<SDKUserMessage>;
private turn = 0;

constructor(
prompts: AsyncIterable<SDKUserMessage>,
readonly sessionId: string,
) {
this.prompts = prompts[Symbol.asyncIterator]();
}

async next(): Promise<IteratorResult<SDKMessage, void>> {
const prompt = await this.prompts.next();
if (prompt.done) return { done: true, value: undefined };
this.turn += 1;
const content = prompt.value.message.content;
const text = typeof content === "string" ? content : JSON.stringify(content);
return {
done: false,
value: {
type: "result",
subtype: "success",
is_error: false,
result: `response:${text}`,
session_id: this.sessionId,
} as SDKMessage,
};
}

async setModel(model?: string): Promise<void> {
this.models.push(model);
}

close(): void {
this.closed = true;
}
}

const queries: FakeClaudeQuery[] = [];
const resumes: Array<string | undefined> = [];
const efforts: Array<string | undefined> = [];
const runtime = new ClaudeWarmRuntime((input, prompts, resume) => {
resumes.push(resume);
efforts.push(input.thinking);
const query = new FakeClaudeQuery(prompts, resume ?? `claude_${queries.length + 1}`);
queries.push(query);
return query;
});

try {
const first = await runtime.run({
agentId: "agt_claude",
workspace: "/tmp/project",
prompt: "first",
model: "sonnet",
thinking: "high",
});
const second = await runtime.run({
agentId: "agt_claude",
workspace: "/tmp/project",
prompt: "second",
providerSessionId: first.providerSessionId ?? undefined,
model: "opus",
thinking: "high",
});

assert.equal(queries.length, 1, "same agent and effort should reuse one live Claude query");
assert.equal(first.providerSessionId, "claude_1");
assert.equal(second.providerSessionId, "claude_1");
assert.equal(first.finalResponse, "response:first");
assert.equal(second.finalResponse, "response:second");
assert.deepEqual(queries[0]?.models, ["opus"], "model changes should use the live query control channel");

const changedEffort = await runtime.run({
agentId: "agt_claude",
workspace: "/tmp/project",
prompt: "third",
providerSessionId: second.providerSessionId ?? undefined,
model: "opus",
thinking: "xhigh",
});

assert.equal(queries.length, 2, "session-start-only effort changes should replace only the live query");
assert.equal(queries[0]?.closed, true);
assert.deepEqual(resumes, [undefined, "claude_1"]);
assert.deepEqual(efforts, ["high", "xhigh"]);
assert.equal(changedEffort.providerSessionId, "claude_1");
assert.equal(changedEffort.finalResponse, "response:third");

await assert.rejects(
runtime.run({
agentId: "agt_claude",
workspace: "/tmp/other-project",
prompt: "wrong workspace",
providerSessionId: changedEffort.providerSessionId ?? undefined,
thinking: "xhigh",
}),
/belongs to workspace \/tmp\/project, not \/tmp\/other-project/,
);
assert.equal(queries.length, 2, "workspace mismatch must not create or reuse a query in another cwd");
} finally {
await runtime.close();
}

assert.equal(queries.at(-1)?.closed, true);
Loading
Loading