- Add stocks table (server/db) with list/get/add/update/delete actions, scoped per user, backing both the chat agent and the /stocks UI via useActionQuery/useActionMutation - Add mobile-first /stocks page (shadcn table/cards, watchlist, search) and wire it into the sidebar, navigation state, and view-screen context - Install the platform's Claude Agent SDK harness (server/plugins, server/routes/api/claude-agent) so the app agent runs on the platform's own Claude subscription instead of the Builder.io gateway; fix the vendored turn.post.ts route to resolve the session and wrap it in runWithRequestContext, since custom /api/ routes aren't auto-wrapped in request context the way /_agent-native/actions/* are - Add shadcn table/badge/dialog/select components (Tabler icons, not lucide)
848 lines
28 KiB
TypeScript
848 lines
28 KiB
TypeScript
// GENERATED — do not edit here.
|
|
//
|
|
// Source: https://github.com/sharpshooter90/agent-native.git
|
|
// branch privatecloud/claude-agent-sdk-harness @ db5f84e9c596549d73164cb1c5f4aae009af1465
|
|
// packages/core/src/agent/harness/claude-agent-sdk-adapter.ts
|
|
// Regenerate: node scripts/vendor-claude-harness.mjs <path-to-agent-native>
|
|
//
|
|
// Why this file exists: apps install @agent-native/core from npm, which does not
|
|
// carry this harness yet. Registering it here through the framework's public
|
|
// registry gives the app the same capability without forking the installed
|
|
// package. Delete this file once a published core ships `claude-agent-sdk`.
|
|
|
|
/**
|
|
* Claude Agent SDK harness adapter.
|
|
*
|
|
* Runs Claude Code through `@anthropic-ai/claude-agent-sdk` as an embedded
|
|
* harness: the SDK owns its loop, native file/shell tools, compaction, and
|
|
* session state, while Agent-Native drives it as a session and streams its
|
|
* events into the normal transcript.
|
|
*
|
|
* Two things distinguish this from `acp:claude-code`, which reaches the same
|
|
* runtime through the Zed ACP bridge:
|
|
*
|
|
* - The SDK is the first-party surface, so session resume is the SDK's own
|
|
* `session_id` (not an ACP `loadSession` capability that may be absent),
|
|
* and host tools are bridged as an in-process MCP server rather than being
|
|
* unavailable.
|
|
* - Auth is whatever the spawned `claude` binary already has. The child
|
|
* inherits the host environment, so a host that runs on a Claude
|
|
* subscription token (`CLAUDE_CODE_OAUTH_TOKEN`) — rather than a metered
|
|
* `ANTHROPIC_API_KEY` — can reuse it here. This adapter never reads those
|
|
* variables itself; a host that needs to inject credentials passes them
|
|
* through `env`, which is layered onto the inherited environment.
|
|
*
|
|
* The SDK package is an optional dependency loaded lazily through
|
|
* `installPackage`, so apps that never use this harness do not pay for it.
|
|
* Everything beyond the thin query/session glue is pure mapping logic between
|
|
* SDK messages and {@link AgentHarnessEvent}s.
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import {
|
|
registerAgentHarness,
|
|
type AgentHarnessAdapter,
|
|
type AgentHarnessApproval,
|
|
type AgentHarnessCapabilities,
|
|
type AgentHarnessCreateSessionOptions,
|
|
type AgentHarnessEvent,
|
|
type AgentHarnessMessage,
|
|
type AgentHarnessPermissionMode,
|
|
type AgentHarnessSession,
|
|
type AgentHarnessTurnInput,
|
|
} from "@agent-native/core/agent/harness";
|
|
|
|
export const CLAUDE_AGENT_SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
|
|
|
|
/** MCP server name the host tools from `createSession.tools` are mounted under. */
|
|
export const HOST_TOOL_MCP_SERVER = "agent-native";
|
|
|
|
/** Claude Code tools that only observe; safe under every permission mode. */
|
|
const READ_ONLY_TOOLS = new Set([
|
|
"Glob",
|
|
"Grep",
|
|
"NotebookRead",
|
|
"Read",
|
|
"Task",
|
|
"TodoWrite",
|
|
"WebFetch",
|
|
"WebSearch",
|
|
]);
|
|
|
|
/** Claude Code tools that mutate workspace files; gated by `allow-edits`. */
|
|
const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "NotebookEdit", "Write"]);
|
|
|
|
/** Input keys the edit tools use to name their target file. */
|
|
const FILE_PATH_KEYS = ["file_path", "notebook_path", "path"] as const;
|
|
|
|
export interface ClaudeAgentSdkHarnessAdapterOptions {
|
|
label?: string;
|
|
description?: string;
|
|
/** Model id passed to the SDK. Omit to use the CLI's configured default. */
|
|
model?: string;
|
|
/** Default workspace directory when a session does not supply `cwd`. */
|
|
cwd?: string;
|
|
permissionMode?: AgentHarnessPermissionMode;
|
|
/**
|
|
* Extra environment for the spawned `claude` process, layered onto the
|
|
* inherited host environment. Use this to hand the child host-resolved
|
|
* credentials instead of reading them in this module.
|
|
*/
|
|
env?: Record<string, string>;
|
|
/** Path to the `claude` executable when it is not the packaged one. */
|
|
pathToClaudeCodeExecutable?: string;
|
|
/**
|
|
* Which on-disk settings the SDK loads (CLAUDE.md, slash commands, MCP
|
|
* config). The SDK default is none; pass `["project"]` to let an app's own
|
|
* repo settings shape the agent.
|
|
*/
|
|
settingSources?: Array<"user" | "project" | "local">;
|
|
/** Extra `query()` options, merged last except where noted below. */
|
|
queryOptions?: Record<string, unknown>;
|
|
/**
|
|
* @internal Test seam: supply the SDK module instead of importing it.
|
|
*/
|
|
loadClaudeAgentSdk?: () => Promise<unknown>;
|
|
}
|
|
|
|
/** Options this adapter owns; a host overriding them would break the contract. */
|
|
const RESERVED_QUERY_OPTIONS = [
|
|
"abortController",
|
|
"canUseTool",
|
|
"includePartialMessages",
|
|
"resume",
|
|
] as const;
|
|
|
|
export interface ClaudeAgentSdkResumeState {
|
|
sessionId: string;
|
|
cwd?: string;
|
|
}
|
|
|
|
/** Per-turn correlation between tool_use ids and the call they started. */
|
|
export interface ClaudeAgentSdkTurnState {
|
|
toolCalls: Map<string, { name: string; input: unknown }>;
|
|
}
|
|
|
|
export function createClaudeAgentSdkTurnState(): ClaudeAgentSdkTurnState {
|
|
return { toolCalls: new Map() };
|
|
}
|
|
|
|
const dynamicImport = new Function("specifier", "return import(specifier)") as (
|
|
specifier: string,
|
|
) => Promise<any>;
|
|
|
|
export function createClaudeAgentSdkHarnessAdapter(
|
|
options: ClaudeAgentSdkHarnessAdapterOptions = {},
|
|
): AgentHarnessAdapter {
|
|
assertNoReservedQueryOptions(options.queryOptions);
|
|
const capabilities: AgentHarnessCapabilities = {
|
|
// The SDK runs the agent in a real working directory with its own file and
|
|
// shell tools; it is not sandboxed by Agent-Native.
|
|
sandbox: false,
|
|
resumable: true,
|
|
approvals: true,
|
|
hostTools: true,
|
|
fileEvents: true,
|
|
};
|
|
return {
|
|
name: "claude-agent-sdk",
|
|
label: options.label ?? "Claude Code (Agent SDK)",
|
|
description:
|
|
options.description ??
|
|
"Runs Claude Code through the first-party Claude Agent SDK, reusing the host's existing Claude credentials.",
|
|
installPackage: CLAUDE_AGENT_SDK_PACKAGE,
|
|
capabilities,
|
|
async createSession(sessionOptions) {
|
|
const sdk = (await (options.loadClaudeAgentSdk
|
|
? options.loadClaudeAgentSdk()
|
|
: dynamicImport(CLAUDE_AGENT_SDK_PACKAGE))) as any;
|
|
if (typeof sdk?.query !== "function") {
|
|
throw new Error(
|
|
`[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE} did not expose query().`,
|
|
);
|
|
}
|
|
return new ClaudeAgentSdkHarnessSession(sdk, options, sessionOptions);
|
|
},
|
|
};
|
|
}
|
|
|
|
function assertNoReservedQueryOptions(
|
|
queryOptions: Record<string, unknown> | undefined,
|
|
): void {
|
|
if (!queryOptions) return;
|
|
const reserved = RESERVED_QUERY_OPTIONS.filter((key) => key in queryOptions);
|
|
if (reserved.length === 0) return;
|
|
throw new Error(
|
|
`[agent-harness] queryOptions.${reserved.join(", queryOptions.")} ${
|
|
reserved.length === 1 ? "is" : "are"
|
|
} owned by the Claude Agent SDK harness (streaming, approvals, resume, and cancellation depend on them). Remove ${reserved.length === 1 ? "it" : "them"}.`,
|
|
);
|
|
}
|
|
|
|
class ClaudeAgentSdkHarnessSession implements AgentHarnessSession {
|
|
readonly id: string;
|
|
|
|
private readonly cwd?: string;
|
|
private readonly permissionMode: AgentHarnessPermissionMode;
|
|
private readonly pendingPermissions = new Map<
|
|
string,
|
|
{
|
|
resolve: (
|
|
result: { behavior: "allow" } | { behavior: "deny"; message: string },
|
|
) => void;
|
|
}
|
|
>();
|
|
private queue: AsyncEventQueue<AgentHarnessEvent> | null = null;
|
|
private activeQuery: {
|
|
interrupt?: () => Promise<unknown>;
|
|
return?: (value?: unknown) => Promise<unknown>;
|
|
} | null = null;
|
|
private approvalCounter = 0;
|
|
private sessionId: string;
|
|
private stopped = false;
|
|
|
|
constructor(
|
|
private readonly sdk: any,
|
|
private readonly adapterOptions: ClaudeAgentSdkHarnessAdapterOptions,
|
|
private readonly sessionOptions: AgentHarnessCreateSessionOptions,
|
|
) {
|
|
const resume = sessionOptions.resumeState as
|
|
| ClaudeAgentSdkResumeState
|
|
| undefined
|
|
| null;
|
|
this.cwd = sessionOptions.cwd ?? resume?.cwd ?? adapterOptions.cwd;
|
|
this.permissionMode =
|
|
sessionOptions.permissionMode ??
|
|
adapterOptions.permissionMode ??
|
|
"allow-reads";
|
|
// Empty until the SDK reports its own session id on the first turn. The
|
|
// resume state is the only id that can actually resume a conversation, so
|
|
// never substitute a synthetic one for it — and never let two fresh
|
|
// sessions share a harness id, which would collide in the session store.
|
|
this.sessionId = resume?.sessionId ?? "";
|
|
this.id =
|
|
this.sessionId ||
|
|
sessionOptions.sessionId ||
|
|
`claude-agent-sdk-${randomUUID()}`;
|
|
}
|
|
|
|
async *streamTurn(
|
|
input: AgentHarnessTurnInput,
|
|
): AsyncIterable<AgentHarnessEvent> {
|
|
if (this.stopped) {
|
|
yield {
|
|
type: "error",
|
|
error: "[agent-harness] This Claude Agent SDK session was stopped.",
|
|
};
|
|
return;
|
|
}
|
|
const queue = new AsyncEventQueue<AgentHarnessEvent>();
|
|
this.queue = queue;
|
|
const state = createClaudeAgentSdkTurnState();
|
|
const abortController = new AbortController();
|
|
const abort = input.abortSignal ?? this.sessionOptions.signal;
|
|
const onAbort = () => abortController.abort();
|
|
if (abort) {
|
|
if (abort.aborted) onAbort();
|
|
else abort.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
|
|
const query = this.sdk.query({
|
|
prompt: promptFromTurnInput(input),
|
|
options: this.buildQueryOptions(abortController),
|
|
});
|
|
this.activeQuery = query;
|
|
|
|
void (async () => {
|
|
try {
|
|
for await (const message of query) {
|
|
if (typeof message?.session_id === "string" && message.session_id) {
|
|
this.sessionId = message.session_id;
|
|
}
|
|
for (const event of claudeAgentSdkMessageToEvents(message, state)) {
|
|
queue.push(event);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
queue.push({
|
|
type: "error",
|
|
error: `[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
});
|
|
} finally {
|
|
if (abort) abort.removeEventListener("abort", onAbort);
|
|
this.denyAllPending(
|
|
"The Claude Agent SDK turn ended before this approval was answered.",
|
|
);
|
|
this.activeQuery = null;
|
|
this.queue = null;
|
|
queue.close();
|
|
}
|
|
})();
|
|
|
|
yield* queue;
|
|
}
|
|
|
|
async approve(approval: AgentHarnessApproval): Promise<void> {
|
|
const pending = this.pendingPermissions.get(approval.id);
|
|
if (!pending) return;
|
|
this.pendingPermissions.delete(approval.id);
|
|
pending.resolve(
|
|
approval.approved
|
|
? { behavior: "allow" }
|
|
: {
|
|
behavior: "deny",
|
|
message: approval.message ?? "Denied by the user.",
|
|
},
|
|
);
|
|
}
|
|
|
|
async detach(): Promise<unknown> {
|
|
// A detached session is resumed by id on the next turn, so there is no
|
|
// process to keep alive between turns.
|
|
await this.destroy();
|
|
if (!this.sessionId) return undefined;
|
|
const state: ClaudeAgentSdkResumeState = { sessionId: this.sessionId };
|
|
if (this.cwd) state.cwd = this.cwd;
|
|
return state;
|
|
}
|
|
|
|
async stop(): Promise<unknown> {
|
|
this.stopped = true;
|
|
return this.detach();
|
|
}
|
|
|
|
async destroy(): Promise<void> {
|
|
const query = this.activeQuery;
|
|
this.activeQuery = null;
|
|
this.denyAllPending("The Claude Agent SDK session was stopped.");
|
|
if (!query) return;
|
|
try {
|
|
await query.interrupt?.();
|
|
} catch {
|
|
// The turn may already be finished; fall through to closing the stream.
|
|
}
|
|
try {
|
|
await query.return?.(undefined);
|
|
} catch {
|
|
// Same: an already-closed generator is not an error here.
|
|
}
|
|
}
|
|
|
|
private buildQueryOptions(
|
|
abortController: AbortController,
|
|
): Record<string, unknown> {
|
|
const {
|
|
env,
|
|
model,
|
|
pathToClaudeCodeExecutable,
|
|
queryOptions,
|
|
settingSources,
|
|
} = this.adapterOptions;
|
|
return {
|
|
...(queryOptions ?? {}),
|
|
...(this.cwd ? { cwd: this.cwd } : {}),
|
|
...(model ? { model } : {}),
|
|
...(settingSources ? { settingSources } : {}),
|
|
...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
|
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
...(this.sessionOptions.instructions
|
|
? {
|
|
systemPrompt: {
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
append: this.sessionOptions.instructions,
|
|
},
|
|
}
|
|
: {}),
|
|
...(this.sessionId ? { resume: this.sessionId } : {}),
|
|
...this.hostToolOptions(queryOptions),
|
|
// Text and thinking reach the transcript as deltas; without partial
|
|
// messages a turn would stream nothing until it finished.
|
|
includePartialMessages: true,
|
|
abortController,
|
|
canUseTool: this.createCanUseTool(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Bridge the host tools handed to `createSession` into the SDK as an
|
|
* in-process MCP server. The SDK exposes them to the agent as
|
|
* `mcp__agent-native__<tool>`, which the permission mapping treats as risky
|
|
* — a host action is app state, not a workspace read.
|
|
*/
|
|
private hostToolOptions(
|
|
queryOptions: Record<string, unknown> | undefined,
|
|
): Record<string, unknown> {
|
|
const tools = this.sessionOptions.tools;
|
|
if (!tools || Object.keys(tools).length === 0) return {};
|
|
const server = createHostToolMcpServer(this.sdk, tools);
|
|
const existing = queryOptions?.mcpServers;
|
|
if (existing !== undefined && !isPlainRecord(existing)) {
|
|
throw new Error(
|
|
"[agent-harness] queryOptions.mcpServers must be a record of server configs when host tools are supplied.",
|
|
);
|
|
}
|
|
if (isPlainRecord(existing) && HOST_TOOL_MCP_SERVER in existing) {
|
|
throw new Error(
|
|
`[agent-harness] queryOptions.mcpServers.${HOST_TOOL_MCP_SERVER} collides with the host-tool server this harness mounts. Rename it.`,
|
|
);
|
|
}
|
|
return {
|
|
mcpServers: {
|
|
...(isPlainRecord(existing) ? existing : {}),
|
|
[HOST_TOOL_MCP_SERVER]: server,
|
|
},
|
|
};
|
|
}
|
|
|
|
private createCanUseTool() {
|
|
return async (
|
|
toolName: string,
|
|
input: Record<string, unknown>,
|
|
): Promise<
|
|
| { behavior: "allow"; updatedInput: Record<string, unknown> }
|
|
| { behavior: "deny"; message: string }
|
|
> => {
|
|
if (
|
|
claudeAgentSdkPermissionDecision(toolName, this.permissionMode) ===
|
|
"allow"
|
|
) {
|
|
return { behavior: "allow", updatedInput: input };
|
|
}
|
|
const queue = this.queue;
|
|
if (!queue) {
|
|
// No live turn to prompt on: deny rather than hang the agent.
|
|
return {
|
|
behavior: "deny",
|
|
message: "No approval surface is attached to this session.",
|
|
};
|
|
}
|
|
const id = `claude-agent-sdk-approval-${++this.approvalCounter}`;
|
|
const decision = new Promise<
|
|
{ behavior: "allow" } | { behavior: "deny"; message: string }
|
|
>((resolve) => {
|
|
this.pendingPermissions.set(id, { resolve });
|
|
});
|
|
queue.push({
|
|
type: "approval-request",
|
|
id,
|
|
tool: toolName,
|
|
message: `Approve ${toolName}?`,
|
|
input,
|
|
});
|
|
const resolved = await decision;
|
|
return resolved.behavior === "allow"
|
|
? { behavior: "allow", updatedInput: input }
|
|
: resolved;
|
|
};
|
|
}
|
|
|
|
private denyAllPending(message: string): void {
|
|
for (const [id, pending] of this.pendingPermissions) {
|
|
this.pendingPermissions.delete(id);
|
|
pending.resolve({ behavior: "deny", message });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A host tool as `createSession.tools` carries it: the AI SDK tool shape, whose
|
|
* `inputSchema` is a Zod object (what `defineAction` already holds).
|
|
*/
|
|
interface HostTool {
|
|
description?: string;
|
|
inputSchema: unknown;
|
|
execute: (input: Record<string, unknown>) => unknown;
|
|
}
|
|
|
|
function createHostToolMcpServer(
|
|
sdk: any,
|
|
tools: Record<string, unknown>,
|
|
): unknown {
|
|
if (
|
|
typeof sdk?.createSdkMcpServer !== "function" ||
|
|
typeof sdk?.tool !== "function"
|
|
) {
|
|
throw new Error(
|
|
`[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE} did not expose createSdkMcpServer()/tool(), so host tools cannot be bridged.`,
|
|
);
|
|
}
|
|
const definitions = Object.entries(tools).map(([name, definition]) =>
|
|
sdk.tool(
|
|
name,
|
|
hostToolDescription(name, definition),
|
|
hostToolInputShape(name, definition),
|
|
async (args: Record<string, unknown>) => {
|
|
const result = await (definition as HostTool).execute(args);
|
|
return {
|
|
content: [{ type: "text", text: stringifyHostToolResult(result) }],
|
|
};
|
|
},
|
|
),
|
|
);
|
|
return sdk.createSdkMcpServer({
|
|
name: HOST_TOOL_MCP_SERVER,
|
|
tools: definitions,
|
|
});
|
|
}
|
|
|
|
function hostToolDescription(name: string, definition: unknown): string {
|
|
const description = (definition as HostTool)?.description;
|
|
return typeof description === "string" && description
|
|
? description
|
|
: `Agent-Native host tool: ${name}`;
|
|
}
|
|
|
|
/**
|
|
* The SDK builds its tool schema from a Zod raw shape. `defineAction` inputs
|
|
* are Zod objects, so unwrap one level; anything else is rejected by name
|
|
* rather than silently registered with an empty schema, which would leave the
|
|
* agent calling the tool with no arguments.
|
|
*/
|
|
function hostToolInputShape(
|
|
name: string,
|
|
definition: unknown,
|
|
): Record<string, unknown> {
|
|
const tool = definition as HostTool | undefined;
|
|
if (typeof tool?.execute !== "function") {
|
|
throw new Error(
|
|
`[agent-harness] Host tool "${name}" has no execute() function.`,
|
|
);
|
|
}
|
|
const schema = tool.inputSchema as { shape?: unknown } | undefined;
|
|
if (isPlainRecord(schema?.shape)) return schema.shape;
|
|
if (isPlainRecord(schema)) return schema;
|
|
throw new Error(
|
|
`[agent-harness] Host tool "${name}" needs a Zod object inputSchema (or a raw Zod shape); the Claude Agent SDK cannot register a tool without one.`,
|
|
);
|
|
}
|
|
|
|
function stringifyHostToolResult(result: unknown): string {
|
|
if (typeof result === "string") return result;
|
|
if (result === undefined) return "";
|
|
try {
|
|
return JSON.stringify(result);
|
|
} catch {
|
|
return String(result);
|
|
}
|
|
}
|
|
|
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function promptFromTurnInput(input: AgentHarnessTurnInput): string {
|
|
if (input.prompt) return input.prompt;
|
|
const messages = input.messages ?? [];
|
|
const text = messages
|
|
.map((message) => harnessMessageToText(message))
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
if (!text) {
|
|
throw new Error(
|
|
"[agent-harness] The Claude Agent SDK harness needs a prompt or at least one message with text content.",
|
|
);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function harnessMessageToText(message: AgentHarnessMessage): string {
|
|
if (typeof message.content === "string") return message.content;
|
|
return message.content
|
|
.map((part) => {
|
|
if (typeof part === "string") return part;
|
|
const record = part as { type?: unknown; text?: unknown };
|
|
return record?.type === "text" && typeof record.text === "string"
|
|
? record.text
|
|
: "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Map an Agent-Native permission mode onto a decision for a Claude Code tool
|
|
* call. Reads always run; workspace edits run under `allow-edits`; shell, MCP,
|
|
* and unknown tools prompt unless `allow-all`.
|
|
*/
|
|
export function claudeAgentSdkPermissionDecision(
|
|
toolName: string,
|
|
mode: AgentHarnessPermissionMode,
|
|
): "allow" | "prompt" {
|
|
if (mode === "allow-all") return "allow";
|
|
if (READ_ONLY_TOOLS.has(toolName)) return "allow";
|
|
if (mode === "allow-edits" && EDIT_TOOLS.has(toolName)) return "allow";
|
|
return "prompt";
|
|
}
|
|
|
|
/**
|
|
* Translate one SDK message into harness events. Tool results arrive on a
|
|
* later message than the call that produced them, so `state` carries the
|
|
* tool_use id → name/input correlation across a turn.
|
|
*/
|
|
export function claudeAgentSdkMessageToEvents(
|
|
message: any,
|
|
state: ClaudeAgentSdkTurnState,
|
|
): AgentHarnessEvent[] {
|
|
switch (message?.type) {
|
|
case "stream_event":
|
|
return streamEventToEvents(message.event);
|
|
case "assistant":
|
|
return assistantMessageToEvents(message, state);
|
|
case "user":
|
|
return userMessageToEvents(message, state);
|
|
case "result":
|
|
return resultMessageToEvents(message);
|
|
case "tool_progress":
|
|
return typeof message.tool_name === "string"
|
|
? [
|
|
{
|
|
type: "activity",
|
|
label: `${message.tool_name} is still running`,
|
|
tool: message.tool_name,
|
|
},
|
|
]
|
|
: [];
|
|
case "system":
|
|
return systemMessageToEvents(message);
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function streamEventToEvents(event: any): AgentHarnessEvent[] {
|
|
if (event?.type !== "content_block_delta") return [];
|
|
const delta = event.delta;
|
|
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
return delta.text ? [{ type: "text-delta", text: delta.text }] : [];
|
|
}
|
|
if (delta?.type === "thinking_delta" && typeof delta.thinking === "string") {
|
|
return delta.thinking
|
|
? [{ type: "thinking-delta", text: delta.thinking }]
|
|
: [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function assistantMessageToEvents(
|
|
message: any,
|
|
state: ClaudeAgentSdkTurnState,
|
|
): AgentHarnessEvent[] {
|
|
// Text and thinking already streamed as deltas; only tool calls are new here.
|
|
const blocks = Array.isArray(message?.message?.content)
|
|
? message.message.content
|
|
: [];
|
|
const events: AgentHarnessEvent[] = [];
|
|
for (const block of blocks) {
|
|
if (block?.type !== "tool_use" || typeof block.id !== "string") continue;
|
|
const name = typeof block.name === "string" ? block.name : "tool";
|
|
state.toolCalls.set(block.id, { name, input: block.input });
|
|
events.push({
|
|
type: "tool-start",
|
|
id: block.id,
|
|
name,
|
|
input: block.input,
|
|
});
|
|
}
|
|
return events;
|
|
}
|
|
|
|
function userMessageToEvents(
|
|
message: any,
|
|
state: ClaudeAgentSdkTurnState,
|
|
): AgentHarnessEvent[] {
|
|
const blocks = Array.isArray(message?.message?.content)
|
|
? message.message.content
|
|
: [];
|
|
const events: AgentHarnessEvent[] = [];
|
|
for (const block of blocks) {
|
|
if (block?.type !== "tool_result") continue;
|
|
const id =
|
|
typeof block.tool_use_id === "string" ? block.tool_use_id : undefined;
|
|
const call = id ? state.toolCalls.get(id) : undefined;
|
|
if (id) state.toolCalls.delete(id);
|
|
const name = call?.name ?? "tool";
|
|
events.push({
|
|
type: "tool-done",
|
|
...(id ? { id } : {}),
|
|
name,
|
|
...(call ? { input: call.input } : {}),
|
|
result: block.content,
|
|
});
|
|
if (block.is_error) continue;
|
|
const fileChange = fileChangeEvent(name, call?.input);
|
|
if (fileChange) events.push(fileChange);
|
|
}
|
|
return events;
|
|
}
|
|
|
|
function fileChangeEvent(
|
|
toolName: string,
|
|
input: unknown,
|
|
): AgentHarnessEvent | null {
|
|
if (!EDIT_TOOLS.has(toolName)) return null;
|
|
const record = (input ?? {}) as Record<string, unknown>;
|
|
const key = FILE_PATH_KEYS.find(
|
|
(candidate) => typeof record[candidate] === "string",
|
|
);
|
|
if (!key) return null;
|
|
return {
|
|
type: "file-change",
|
|
path: record[key] as string,
|
|
// Write creates or overwrites and the result does not say which; the edit
|
|
// tools always target an existing file.
|
|
operation: toolName === "Write" ? "unknown" : "update",
|
|
};
|
|
}
|
|
|
|
function resultMessageToEvents(message: any): AgentHarnessEvent[] {
|
|
const events: AgentHarnessEvent[] = [];
|
|
const usage = message?.usage;
|
|
const inputTokens = numberOrUndefined(usage?.input_tokens);
|
|
const outputTokens = numberOrUndefined(usage?.output_tokens);
|
|
const costUsd = numberOrUndefined(message?.total_cost_usd);
|
|
if (
|
|
inputTokens !== undefined ||
|
|
outputTokens !== undefined ||
|
|
costUsd !== undefined
|
|
) {
|
|
events.push({
|
|
type: "usage",
|
|
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
...(inputTokens !== undefined && outputTokens !== undefined
|
|
? { totalTokens: inputTokens + outputTokens }
|
|
: {}),
|
|
...(costUsd !== undefined ? { costCents: costUsd * 100 } : {}),
|
|
});
|
|
}
|
|
if (message?.is_error || message?.subtype !== "success") {
|
|
const errors = Array.isArray(message?.errors) ? message.errors : [];
|
|
events.push({
|
|
type: "error",
|
|
error:
|
|
errors
|
|
.filter((entry: unknown) => typeof entry === "string")
|
|
.join("; ") ||
|
|
`Claude Agent SDK run ended: ${message?.subtype ?? "unknown error"}`,
|
|
...(typeof message?.subtype === "string"
|
|
? { code: message.subtype }
|
|
: {}),
|
|
});
|
|
return events;
|
|
}
|
|
events.push({
|
|
type: "done",
|
|
...(typeof message?.stop_reason === "string"
|
|
? { reason: message.stop_reason }
|
|
: {}),
|
|
});
|
|
return events;
|
|
}
|
|
|
|
function systemMessageToEvents(message: any): AgentHarnessEvent[] {
|
|
if (message?.subtype === "compact_boundary") {
|
|
const trigger = message?.compact_metadata?.trigger;
|
|
return [
|
|
{
|
|
type: "compaction",
|
|
...(typeof trigger === "string"
|
|
? { summary: `Compacted conversation (${trigger})` }
|
|
: {}),
|
|
},
|
|
];
|
|
}
|
|
if (message?.subtype === "permission_denied") {
|
|
const tool =
|
|
typeof message?.tool_name === "string" ? message.tool_name : "tool";
|
|
return [{ type: "activity", label: `Denied ${tool}`, tool }];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function numberOrUndefined(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value)
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
// --- inlined from packages/core/src/agent/harness/async-event-queue.ts -------
|
|
|
|
/** Minimal async queue bridging callback-driven harness updates to an async iterable. */
|
|
class AsyncEventQueue<T> implements AsyncIterable<T> {
|
|
private readonly values: T[] = [];
|
|
private readonly resolvers: Array<(result: IteratorResult<T>) => void> = [];
|
|
private closed = false;
|
|
|
|
push(value: T): void {
|
|
if (this.closed) return;
|
|
const resolver = this.resolvers.shift();
|
|
if (resolver) resolver({ value, done: false });
|
|
else this.values.push(value);
|
|
}
|
|
|
|
close(): void {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
let resolver: ((result: IteratorResult<T>) => void) | undefined;
|
|
while ((resolver = this.resolvers.shift())) {
|
|
resolver({ value: undefined as never, done: true });
|
|
}
|
|
}
|
|
|
|
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
return {
|
|
next: (): Promise<IteratorResult<T>> => {
|
|
if (this.values.length > 0) {
|
|
return Promise.resolve({
|
|
value: this.values.shift() as T,
|
|
done: false,
|
|
});
|
|
}
|
|
if (this.closed) {
|
|
return Promise.resolve({ value: undefined as never, done: true });
|
|
}
|
|
return new Promise<IteratorResult<T>>((resolve) => {
|
|
this.resolvers.push(resolve);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// --- registration -----------------------------------------------------------
|
|
|
|
/**
|
|
* Startup plugin: make `claude-agent-sdk` resolvable by name, so anything that
|
|
* calls `resolveAgentHarness("claude-agent-sdk")` gets this adapter.
|
|
*
|
|
* Auth comes from the process environment the app runs under. On a privatecloud
|
|
* VM that is CLAUDE_CODE_OAUTH_TOKEN, delivered to the systemd unit through
|
|
* EnvironmentFile=%h/.config/privatecloud/agent.env — a login shell's .bashrc
|
|
* export does NOT reach a service, so without that line the harness starts and
|
|
* then fails on its first turn.
|
|
*/
|
|
export default function claudeAgentSdkHarnessPlugin(): void {
|
|
registerAgentHarness({
|
|
name: "claude-agent-sdk",
|
|
label: "Claude Code (Agent SDK)",
|
|
description:
|
|
"Runs Claude Code through the first-party Claude Agent SDK on this app's own Claude credentials.",
|
|
installPackage: CLAUDE_AGENT_SDK_PACKAGE,
|
|
capabilities: {
|
|
sandbox: false,
|
|
resumable: true,
|
|
approvals: true,
|
|
hostTools: true,
|
|
fileEvents: true,
|
|
},
|
|
create: (config) =>
|
|
createClaudeAgentSdkHarnessAdapter(
|
|
(config ?? {}) as ClaudeAgentSdkHarnessAdapterOptions,
|
|
),
|
|
});
|
|
}
|