Files
stock-listing-app/server/plugins/claude-agent-sdk-harness.ts
T
exe.dev user e859245867 Add minimal stock listing app: actions, Postgres schema, stocks page, Claude Agent SDK harness
- 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)
2026-07-29 12:18:14 +00:00

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,
),
});
}