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)
This commit is contained in:
exe.dev user
2026-07-29 12:18:14 +00:00
parent 138d195e4c
commit e859245867
25 changed files with 14804 additions and 10 deletions
+12 -3
View File
@@ -6,16 +6,25 @@ import {
import actionsRegistry from "../../.generated/actions-registry.js";
const INITIAL_TOOL_NAMES = ["view-screen", "navigate", "hello"];
const INITIAL_TOOL_NAMES = [
"view-screen",
"navigate",
"hello",
"list-stocks",
"get-stock",
"add-stock",
"update-stock",
"delete-stock",
];
export default createAgentChatPlugin({
appId: "app",
actions: loadActionsFromStaticRegistry(actionsRegistry),
initialToolNames: INITIAL_TOOL_NAMES,
resolveOrgId: async (event) => (await getOrgContext(event)).orgId,
systemPrompt: `You are the Chat app agent.
systemPrompt: `You are the Chat app agent for a minimal stock listing app.
This is a minimal chat-first Agent-Native app. The chat is the product surface, and actions are the contract shared by chat, UI, HTTP, MCP, A2A, and CLI.
The chat is the product surface, and actions are the contract shared by chat, UI, HTTP, MCP, A2A, and CLI. The app tracks a per-user list of stock tickers (symbol, company name, exchange, sector, price, change percent, market cap, watchlisted) at the /stocks page, backed by the list-stocks, get-stock, add-stock, update-stock, and delete-stock actions.
Use actions as the source of truth. Start by inspecting the current screen when context matters. When the user asks to extend this app, keep the change small and agent-native: add or update actions, expose useful UI, and keep application state/navigation visible to the agent.`,
});
+847
View File
@@ -0,0 +1,847 @@
// 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,
),
});
}
+77
View File
@@ -0,0 +1,77 @@
import {
ensureAdditiveColumns,
getDbExec,
runMigrations,
} from "@agent-native/core/db";
import * as schema from "../db/schema.js";
const runStocksMigrations = runMigrations(
[
{
version: 1,
name: "stocks-table",
sql: `CREATE TABLE IF NOT EXISTS stocks (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
company_name TEXT NOT NULL,
exchange TEXT NOT NULL DEFAULT 'NASDAQ',
sector TEXT,
currency TEXT NOT NULL DEFAULT 'USD',
price REAL NOT NULL DEFAULT 0,
change_percent REAL NOT NULL DEFAULT 0,
market_cap INTEGER,
watchlisted BOOLEAN NOT NULL DEFAULT FALSE,
owner_email TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
},
{
version: 2,
name: "stocks-owner-symbol-unique-index",
sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_stocks_owner_symbol
ON stocks (owner_email, symbol)`,
},
{
version: 3,
name: "stocks-owner-watchlisted-index",
sql: `CREATE INDEX IF NOT EXISTS idx_stocks_owner_watchlisted
ON stocks (owner_email, watchlisted)`,
},
],
{ table: "stocks_migrations" },
);
function isDrizzleTable(value: unknown): value is object {
return (
!!value &&
typeof value === "object" &&
Object.getOwnPropertySymbols(value).some((s) =>
s.toString().includes("drizzle"),
)
);
}
const schemaTables = Object.values(schema).filter(isDrizzleTable);
export default async (nitroApp: any): Promise<void> => {
await runStocksMigrations(nitroApp);
try {
const summary = await ensureAdditiveColumns({
db: getDbExec(),
tables: schemaTables,
});
if (summary.errors.length > 0) {
console.warn(
"[db] ensureAdditiveColumns completed with errors:",
summary.errors,
);
}
} catch (err) {
console.warn(
"[db] ensureAdditiveColumns failed (non-fatal):",
err instanceof Error ? err.message : err,
);
}
};