From c154c398d2a653230667bac804c113657468b98d Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Tue, 30 Jun 2026 16:49:46 -0400 Subject: [PATCH 01/13] feat(cloud-agent): add event-driven agent host package - Add @mieweb/cloud-agent with hostAgent() API - Session DO for queue-driven turns, suspend/resume, alarms - Storage module for sessions, events, messages, activity_events, summaries - Add @mieweb/cloud-agent-cli with message-first dispatcher - Support --call (streaming) and -txt/--put (fire-and-forget) modes - Update README with new packages Co-authored-by: Cursor --- packages/README.md | 2 + packages/cloud-agent-cli/bin/agent-cli.js | 16 + packages/cloud-agent-cli/package.json | 37 +++ packages/cloud-agent-cli/src/client.ts | 230 ++++++++++++++ packages/cloud-agent-cli/src/index.ts | 28 ++ packages/cloud-agent-cli/src/parse.test.ts | 135 ++++++++ packages/cloud-agent-cli/src/parse.ts | 136 ++++++++ packages/cloud-agent-cli/src/run.ts | 156 +++++++++ packages/cloud-agent-cli/src/types.ts | 50 +++ packages/cloud-agent/package.json | 40 +++ packages/cloud-agent/src/host.ts | 178 +++++++++++ packages/cloud-agent/src/index.ts | 59 ++++ packages/cloud-agent/src/session.ts | 297 +++++++++++++++++ packages/cloud-agent/src/storage.test.ts | 172 ++++++++++ packages/cloud-agent/src/storage.ts | 350 +++++++++++++++++++++ packages/cloud-agent/src/types.ts | 244 ++++++++++++++ 16 files changed, 2130 insertions(+) create mode 100755 packages/cloud-agent-cli/bin/agent-cli.js create mode 100644 packages/cloud-agent-cli/package.json create mode 100644 packages/cloud-agent-cli/src/client.ts create mode 100644 packages/cloud-agent-cli/src/index.ts create mode 100644 packages/cloud-agent-cli/src/parse.test.ts create mode 100644 packages/cloud-agent-cli/src/parse.ts create mode 100644 packages/cloud-agent-cli/src/run.ts create mode 100644 packages/cloud-agent-cli/src/types.ts create mode 100644 packages/cloud-agent/package.json create mode 100644 packages/cloud-agent/src/host.ts create mode 100644 packages/cloud-agent/src/index.ts create mode 100644 packages/cloud-agent/src/session.ts create mode 100644 packages/cloud-agent/src/storage.test.ts create mode 100644 packages/cloud-agent/src/storage.ts create mode 100644 packages/cloud-agent/src/types.ts diff --git a/packages/README.md b/packages/README.md index ec7c4cc..3f81871 100644 --- a/packages/README.md +++ b/packages/README.md @@ -20,6 +20,8 @@ implement the same Cloudflare-shaped contract. | [`cloud-workers`](cloud-workers) | The `DurableObject` base. Backs the **`mieweb:workers`** virtual import: re-exports `cloudflare:workers` on Cloudflare (workerd export condition), pure-JS base everywhere else. | | [`cloud`](cloud) | Umbrella entry. Re-exports the contracts + `DurableObject` from one stable import surface (`@mieweb/cloud`). | | [`cloud-local`](cloud-local) | Local/Node **adapters** (the POC): D1→SQLite, R2→filesystem, KV→in-memory, Queues→in-process, Durable Objects→in-process registry. Vectorize/Workers AI surface explicit `UnsupportedBindingError`. Includes the Node **host harness** that runs the unchanged worker handler and a migration runner. | +| [`cloud-agent`](cloud-agent) | Event-driven **agent host**. Binds an agent definition + `AgentRuntime` to a Durable Object with queue-driven turns, suspend/resume, and alarms. Provides `hostAgent()` which returns DO class + worker wiring helpers. | +| [`cloud-agent-cli`](cloud-agent-cli) | Message-first **CLI dispatcher** for `cloud-agent`. Agent identity from `basename(argv[0])`. Agent-specific packages (`jerry`, `lisa`, etc.) wrap this with their config. | | [`cli`](cli) | The **`mieweb`** CLI. On the `cloudflare` target it delegates verbatim to `wrangler`; on other targets it drives the matching adapter. | ## How it wires into the app diff --git a/packages/cloud-agent-cli/bin/agent-cli.js b/packages/cloud-agent-cli/bin/agent-cli.js new file mode 100755 index 0000000..34ca9cb --- /dev/null +++ b/packages/cloud-agent-cli/bin/agent-cli.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** + * Generic agent CLI dispatcher. + * Agent name is determined by basename(argv[0]). + */ + +import { run } from '../src/index.js'; +import { basename } from 'path'; + +const agent = basename(process.argv[1]).replace(/\.(js|mjs|ts)$/, ''); + +run({ + agent, + baseUrl: process.env.JERRY_URL ?? 'http://127.0.0.1:8787', + version: '0.1.0', +}); diff --git a/packages/cloud-agent-cli/package.json b/packages/cloud-agent-cli/package.json new file mode 100644 index 0000000..88e1a52 --- /dev/null +++ b/packages/cloud-agent-cli/package.json @@ -0,0 +1,37 @@ +{ + "name": "@mieweb/cloud-agent-cli", + "version": "0.1.0", + "description": "Message-first CLI dispatcher for @mieweb/cloud-agent. Agent identity is determined by basename(argv[0]) (busybox/git multicall pattern).", + "type": "module", + "license": "MIT", + "author": "MIEWEB", + "homepage": "https://github.com/mieweb/cloud#readme", + "bugs": "https://github.com/mieweb/cloud/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/mieweb/cloud.git", + "directory": "packages/cloud-agent-cli" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "bin": { + "agent-cli": "./bin/agent-cli.js" + }, + "files": [ + "src", + "bin" + ], + "scripts": { + "test": "node --import tsx --test src/**/*.test.ts" + }, + "devDependencies": { + "@types/node": "^20.0.0" + } +} diff --git a/packages/cloud-agent-cli/src/client.ts b/packages/cloud-agent-cli/src/client.ts new file mode 100644 index 0000000..0283c70 --- /dev/null +++ b/packages/cloud-agent-cli/src/client.ts @@ -0,0 +1,230 @@ +/** + * HTTP client for agent communication. + * Supports streaming (SSE) and fire-and-forget modes. + */ + +import type { StreamEvent } from "./types.js"; + +interface RequestOptions { + profile?: unknown; + cwd?: string; +} + +/** + * Send a message and stream the response. + * Uses Server-Sent Events for real-time streaming. + */ +export async function* streamCall( + baseUrl: string, + sessionId: string, + message: string, + options: RequestOptions +): AsyncGenerator { + const url = `${baseUrl}/v1/sessions/${sessionId}/messages`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream, application/json", + }, + body: JSON.stringify({ + message, + profile: options.profile, + context: { cwd: options.cwd }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + let errorMessage: string; + try { + const json = JSON.parse(text); + errorMessage = json.error ?? text; + } catch { + errorMessage = text; + } + yield { type: "error", message: errorMessage }; + return; + } + + const contentType = response.headers.get("content-type") ?? ""; + + // Handle SSE streaming + if (contentType.includes("text/event-stream")) { + yield* parseSSE(response); + return; + } + + // Handle JSON response (non-streaming) + const json = await response.json() as { + ok?: boolean; + message?: string; + status?: string; + suspended?: boolean; + error?: string; + }; + + if (json.error) { + yield { type: "error", message: json.error }; + return; + } + + yield { type: "start" }; + + if (json.message) { + yield { type: "text", text: json.message }; + } + + if (json.suspended) { + yield { + type: "suspended", + reason: json.status ?? "waiting_for_user", + message: json.message, + }; + } else { + yield { type: "finish", finishReason: "stop" }; + } +} + +/** + * Parse Server-Sent Events from a response. + */ +async function* parseSSE(response: Response): AsyncGenerator { + const reader = response.body?.getReader(); + if (!reader) { + yield { type: "error", message: "No response body" }; + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse complete SSE events + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + if (data === "[DONE]") { + yield { type: "finish", finishReason: "stop" }; + return; + } + + try { + const event = JSON.parse(data); + yield normalizeEvent(event); + } catch { + // Ignore malformed JSON + } + } + } + } + } finally { + reader.releaseLock(); + } +} + +/** + * Normalize server event to StreamEvent. + */ +function normalizeEvent(event: unknown): StreamEvent { + if (typeof event !== "object" || event === null) { + return { type: "error", message: "Invalid event" }; + } + + const e = event as Record; + + switch (e.type) { + case "start": + return { type: "start" }; + case "text-delta": + case "text": + return { type: "text", text: String(e.text ?? "") }; + case "tool-call": + return { + type: "tool-call", + toolName: String(e.toolName ?? ""), + input: e.input, + }; + case "tool-result": + return { + type: "tool-result", + toolName: String(e.toolName ?? ""), + output: e.output, + }; + case "finish": + return { type: "finish", finishReason: String(e.finishReason ?? "stop") }; + case "error": + return { type: "error", message: String(e.message ?? "Unknown error") }; + case "suspend": + case "suspended": + return { + type: "suspended", + reason: String(e.reason ?? "waiting_for_user"), + message: e.message as string | undefined, + }; + default: + return { type: "error", message: `Unknown event type: ${e.type}` }; + } +} + +/** + * Send a message and return immediately (fire-and-forget). + * The agent processes the message asynchronously. + */ +export async function fireAndForget( + baseUrl: string, + sessionId: string, + message: string, + options: RequestOptions +): Promise<{ eventId: string; status: string }> { + const url = `${baseUrl}/v1/sessions/${sessionId}/enqueue`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message, + profile: options.profile, + context: { cwd: options.cwd }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Request failed: ${text}`); + } + + const json = await response.json() as { eventId: string; status: string }; + return json; +} + +/** + * Get session status. + */ +export async function getStatus( + baseUrl: string, + sessionId: string +): Promise<{ status: string; continuation?: unknown }> { + const url = `${baseUrl}/v1/sessions/${sessionId}/status`; + + const response = await fetch(url); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Request failed: ${text}`); + } + + return response.json() as Promise<{ status: string; continuation?: unknown }>; +} diff --git a/packages/cloud-agent-cli/src/index.ts b/packages/cloud-agent-cli/src/index.ts new file mode 100644 index 0000000..49a9d64 --- /dev/null +++ b/packages/cloud-agent-cli/src/index.ts @@ -0,0 +1,28 @@ +/** + * @mieweb/cloud-agent-cli — Message-first CLI dispatcher for @mieweb/cloud-agent. + * + * Agent identity is determined by basename(argv[0]) (busybox/git multicall pattern). + * Agent-specific packages wrap this with their config. + * + * @example + * ```ts + * // packages/cli/bin/jerry.js + * import { run } from '@mieweb/cloud-agent-cli'; + * + * run({ + * agent: 'jerry', + * baseUrl: process.env.JERRY_URL ?? 'http://127.0.0.1:8787', + * version: '0.1.0', + * }); + * ``` + */ + +export { run } from "./run.js"; +export { parseArgs } from "./parse.js"; +export { streamCall, fireAndForget, getStatus } from "./client.js"; +export type { + CliConfig, + ParsedCommand, + CliOptions, + StreamEvent, +} from "./types.js"; diff --git a/packages/cloud-agent-cli/src/parse.test.ts b/packages/cloud-agent-cli/src/parse.test.ts new file mode 100644 index 0000000..2cd89d2 --- /dev/null +++ b/packages/cloud-agent-cli/src/parse.test.ts @@ -0,0 +1,135 @@ +/** + * Tests for CLI argument parsing. + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { parseArgs } from "./parse.js"; + +describe("parseArgs", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env.JERRY_SESSION; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("message-first behavior", () => { + it("treats non-flag args as a message for --call", () => { + const { command } = parseArgs(["hello", "world"]); + assert.strictEqual(command.type, "call"); + if (command.type === "call") { + assert.strictEqual(command.message, "hello world"); + } + }); + + it("joins all args with spaces", () => { + const { command } = parseArgs(["summarize", "my", "last", "2", "hours"]); + assert.strictEqual(command.type, "call"); + if (command.type === "call") { + assert.strictEqual(command.message, "summarize my last 2 hours"); + } + }); + + it("returns help when no args", () => { + const { command } = parseArgs([]); + assert.strictEqual(command.type, "help"); + }); + }); + + describe("flags", () => { + it("parses --help", () => { + const { command } = parseArgs(["--help"]); + assert.strictEqual(command.type, "help"); + }); + + it("parses -h", () => { + const { command } = parseArgs(["-h"]); + assert.strictEqual(command.type, "help"); + }); + + it("parses --version", () => { + const { command } = parseArgs(["--version"]); + assert.strictEqual(command.type, "version"); + }); + + it("parses -v", () => { + const { command } = parseArgs(["-v"]); + assert.strictEqual(command.type, "version"); + }); + + it("parses -txt with message", () => { + const { command } = parseArgs(["-txt", "quick", "note"]); + assert.strictEqual(command.type, "put"); + if (command.type === "put") { + assert.strictEqual(command.message, "quick note"); + } + }); + + it("parses --put with message", () => { + const { command } = parseArgs(["--put", "enqueue", "this"]); + assert.strictEqual(command.type, "put"); + if (command.type === "put") { + assert.strictEqual(command.message, "enqueue this"); + } + }); + + it("parses --debug with message", () => { + const { command, options } = parseArgs(["--debug", "test", "message"]); + assert.strictEqual(command.type, "debug"); + assert.strictEqual(options.debug, true); + if (command.type === "debug") { + assert.strictEqual(command.message, "test message"); + } + }); + + it("parses --config", () => { + const { command } = parseArgs(["--config"]); + assert.strictEqual(command.type, "config"); + }); + + it("parses --report", () => { + const { command } = parseArgs(["--report", "daily"]); + assert.strictEqual(command.type, "report"); + if (command.type === "report") { + assert.deepStrictEqual(command.args, ["daily"]); + } + }); + }); + + describe("session handling", () => { + it("uses JERRY_SESSION from env", () => { + process.env.JERRY_SESSION = "test-session-123"; + const { command, options } = parseArgs(["hello"]); + assert.strictEqual(options.sessionId, "test-session-123"); + }); + + it("parses --session flag", () => { + const { command, options } = parseArgs(["--session", "my-session", "hello", "world"]); + assert.strictEqual(options.sessionId, "my-session"); + assert.strictEqual(command.type, "call"); + if (command.type === "call") { + assert.strictEqual(command.message, "hello world"); + } + }); + }); + + describe("edge cases", () => { + it("treats unknown flags as message content", () => { + const { command } = parseArgs(["--unknown", "flag"]); + assert.strictEqual(command.type, "call"); + if (command.type === "call") { + assert.strictEqual(command.message, "--unknown flag"); + } + }); + + it("returns help for -txt with no message", () => { + const { command } = parseArgs(["-txt"]); + assert.strictEqual(command.type, "help"); + }); + }); +}); diff --git a/packages/cloud-agent-cli/src/parse.ts b/packages/cloud-agent-cli/src/parse.ts new file mode 100644 index 0000000..1b74d6f --- /dev/null +++ b/packages/cloud-agent-cli/src/parse.ts @@ -0,0 +1,136 @@ +/** + * CLI argument parsing. + * Message-first: all args are joined as a message unless the first arg is a flag. + */ + +import type { ParsedCommand, CliOptions } from "./types.js"; + +/** + * Parse CLI arguments into a command and options. + */ +export function parseArgs(args: string[]): { + command: ParsedCommand; + options: CliOptions; +} { + const options: CliOptions = { + sessionId: process.env.JERRY_SESSION, + cwd: process.cwd(), + }; + + if (args.length === 0) { + return { command: { type: "help" }, options }; + } + + const first = args[0]; + + // Check for flags + if (first.startsWith("-")) { + return parseFlag(first, args.slice(1), options); + } + + // Default: --call with message + const message = args.join(" "); + return { + command: { type: "call", message, sessionId: options.sessionId }, + options, + }; +} + +/** + * Parse a flag-based command. + */ +function parseFlag( + flag: string, + rest: string[], + options: CliOptions +): { command: ParsedCommand; options: CliOptions } { + switch (flag) { + case "-h": + case "--help": + return { command: { type: "help" }, options }; + + case "-v": + case "--version": + return { command: { type: "version" }, options }; + + case "-txt": + case "--put": + if (rest.length === 0) { + return { command: { type: "help" }, options }; + } + return { + command: { + type: "put", + message: rest.join(" "), + sessionId: options.sessionId, + }, + options, + }; + + case "--call": + if (rest.length === 0) { + return { command: { type: "help" }, options }; + } + return { + command: { + type: "call", + message: rest.join(" "), + sessionId: options.sessionId, + }, + options, + }; + + case "-d": + case "--debug": + if (rest.length === 0) { + return { command: { type: "help" }, options }; + } + return { + command: { + type: "debug", + message: rest.join(" "), + sessionId: options.sessionId, + }, + options: { ...options, debug: true }, + }; + + case "--report": + return { + command: { type: "report", args: rest }, + options, + }; + + case "--config": + return { + command: { type: "config", args: rest }, + options, + }; + + case "-s": + case "--session": + if (rest.length === 0) { + return { command: { type: "help" }, options }; + } + options.sessionId = rest[0]; + if (rest.length === 1) { + return { command: { type: "help" }, options }; + } + // Parse remaining args as message + const message = rest.slice(1).join(" "); + return { + command: { type: "call", message, sessionId: options.sessionId }, + options, + }; + + default: + // Unknown flag, treat as message + return { + command: { + type: "call", + message: [flag, ...rest].join(" "), + sessionId: options.sessionId, + }, + options, + }; + } +} diff --git a/packages/cloud-agent-cli/src/run.ts b/packages/cloud-agent-cli/src/run.ts new file mode 100644 index 0000000..8f9e60d --- /dev/null +++ b/packages/cloud-agent-cli/src/run.ts @@ -0,0 +1,156 @@ +/** + * CLI run function for message-first agent interaction. + */ + +import type { CliConfig, CliOptions } from "./types.js"; +import { parseArgs } from "./parse.js"; +import { streamCall, fireAndForget } from "./client.js"; + +const DEFAULT_BASE_URL = "http://127.0.0.1:8787"; + +/** + * Run the CLI with the given config. + * Agent-specific wrappers call this with their config. + */ +export async function run(config: CliConfig): Promise { + const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; + const { command, options } = parseArgs(process.argv.slice(2)); + + switch (command.type) { + case "help": + printHelp(config.agent); + break; + + case "version": + console.log(`${config.agent} ${config.version ?? "0.0.0"}`); + break; + + case "call": + await handleCall(baseUrl, config, command.message, options); + break; + + case "put": + await handlePut(baseUrl, config, command.message, options); + break; + + case "debug": + options.debug = true; + await handleCall(baseUrl, config, command.message, options); + break; + + case "report": + console.log("Report mode not yet implemented"); + break; + + case "config": + console.log("Config:", JSON.stringify(config, null, 2)); + break; + } +} + +/** + * Handle --call (default): send message, stream reply. + */ +async function handleCall( + baseUrl: string, + config: CliConfig, + message: string, + options: CliOptions +): Promise { + const sessionId = options.sessionId ?? generateSessionId(); + + if (options.debug) { + console.error(`[debug] agent=${config.agent} session=${sessionId}`); + console.error(`[debug] baseUrl=${baseUrl}`); + console.error(`[debug] cwd=${options.cwd ?? process.cwd()}`); + } + + try { + for await (const event of streamCall(baseUrl, sessionId, message, { + profile: config.profile, + cwd: options.cwd ?? process.cwd(), + })) { + if (event.type === "text") { + process.stdout.write(event.text); + } else if (event.type === "tool-call" && options.debug) { + console.error(`[tool] ${event.toolName}(${JSON.stringify(event.input)})`); + } else if (event.type === "tool-result" && options.debug) { + console.error(`[tool-result] ${event.toolName}: ${JSON.stringify(event.output)}`); + } else if (event.type === "error") { + console.error(`\nError: ${event.message}`); + process.exitCode = 1; + } else if (event.type === "suspended") { + console.log(`\n[${event.reason}] ${event.message ?? ""}`); + console.log(`Session: ${sessionId}`); + } else if (event.type === "finish") { + if (options.debug) { + console.error(`\n[finish] reason=${event.finishReason}`); + } + } + } + console.log(); // Final newline + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exitCode = 1; + } +} + +/** + * Handle -txt/--put: enqueue message, return immediately. + */ +async function handlePut( + baseUrl: string, + config: CliConfig, + message: string, + options: CliOptions +): Promise { + const sessionId = options.sessionId ?? generateSessionId(); + + try { + const result = await fireAndForget(baseUrl, sessionId, message, { + profile: config.profile, + cwd: options.cwd ?? process.cwd(), + }); + console.log(`Queued: session=${sessionId} eventId=${result.eventId}`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exitCode = 1; + } +} + +/** + * Generate a session ID (for new conversations). + */ +function generateSessionId(): string { + return `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Print help message. + */ +function printHelp(agent: string): void { + console.log(` +${agent} — message-first CLI + +Usage: + ${agent} Send message and stream reply (default --call) + ${agent} -txt Enqueue message, return immediately + ${agent} --put Same as -txt + ${agent} --help Show this help + ${agent} --version Show version + ${agent} --debug Send with debug output + ${agent} --config Show current config + +Arguments are joined with spaces, so quotes are only needed to preserve +multiple spaces or escape shell metacharacters. + +Examples: + ${agent} summarize my last 2 hours + ${agent} I just made this PR + ${agent} -txt quick note for the day + +Environment: + JERRY_URL Override base URL (default: http://127.0.0.1:8787) + JERRY_SESSION Override session ID +`.trim()); +} diff --git a/packages/cloud-agent-cli/src/types.ts b/packages/cloud-agent-cli/src/types.ts new file mode 100644 index 0000000..f0ad66c --- /dev/null +++ b/packages/cloud-agent-cli/src/types.ts @@ -0,0 +1,50 @@ +/** + * @mieweb/cloud-agent-cli type definitions + */ + +/** + * CLI configuration passed to run(). + */ +export interface CliConfig { + /** Agent name (used for routing and display) */ + agent: string; + /** Base URL for the agent server (default: http://127.0.0.1:8787) */ + baseUrl?: string; + /** Privacy profile to send with requests */ + profile?: unknown; + /** Version string for --version */ + version?: string; +} + +/** + * Parsed command from CLI arguments. + */ +export type ParsedCommand = + | { type: "call"; message: string; sessionId?: string } + | { type: "put"; message: string; sessionId?: string } + | { type: "help" } + | { type: "version" } + | { type: "debug"; message: string; sessionId?: string } + | { type: "report"; args: string[] } + | { type: "config"; args: string[] }; + +/** + * Options extracted from CLI flags. + */ +export interface CliOptions { + sessionId?: string; + debug?: boolean; + cwd?: string; +} + +/** + * Streaming response event from the agent. + */ +export type StreamEvent = + | { type: "start" } + | { type: "text"; text: string } + | { type: "tool-call"; toolName: string; input: unknown } + | { type: "tool-result"; toolName: string; output: unknown } + | { type: "finish"; finishReason: string } + | { type: "error"; message: string } + | { type: "suspended"; reason: string; message?: string }; diff --git a/packages/cloud-agent/package.json b/packages/cloud-agent/package.json new file mode 100644 index 0000000..45df0e1 --- /dev/null +++ b/packages/cloud-agent/package.json @@ -0,0 +1,40 @@ +{ + "name": "@mieweb/cloud-agent", + "version": "0.1.0", + "description": "Event-driven agent host for @mieweb/cloud. Binds an agent definition + AgentRuntime to a Durable Object with queue-driven turns, suspend/resume, and alarms.", + "type": "module", + "license": "MIT", + "author": "MIEWEB", + "homepage": "https://github.com/mieweb/cloud#readme", + "bugs": "https://github.com/mieweb/cloud/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/mieweb/cloud.git", + "directory": "packages/cloud-agent" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + } + }, + "files": [ + "src" + ], + "scripts": { + "test": "node --import tsx --test src/**/*.test.ts" + }, + "dependencies": { + "@mieweb/cloud-types": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0" + } +} diff --git a/packages/cloud-agent/src/host.ts b/packages/cloud-agent/src/host.ts new file mode 100644 index 0000000..659a2b5 --- /dev/null +++ b/packages/cloud-agent/src/host.ts @@ -0,0 +1,178 @@ +/** + * hostAgent() — binds an agent definition + AgentRuntime to a Durable Object + * with queue-driven turns, suspend/resume, and alarms. + */ + +import type { + HostAgentConfig, + HostAgentResult, + HostEnv, + TurnJob, +} from "./types.js"; +import { createSessionClass } from "./session.js"; +import { initSchema, insertActivityEvent } from "./storage.js"; + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); + +/** + * Extract session ID from request. + * Looks for :id in path /v1/sessions/:id/... or X-Session-Id header. + */ +function extractSessionId(request: Request): string | null { + const url = new URL(request.url); + const match = url.pathname.match(/\/v1\/sessions\/([^/]+)/); + if (match) return match[1]; + return request.headers.get("X-Session-Id"); +} + +/** + * Extract user ID from request headers. + */ +function extractUserId(request: Request): string | undefined { + return request.headers.get("X-User-Id") ?? undefined; +} + +/** + * hostAgent() creates the wiring for an event-driven agent. + * + * Returns: + * - SessionClass: The DO class to export from the worker + * - handleFetch: Routes fetch requests to the appropriate DO + * - handleQueue: Processes queue messages by forwarding to DOs + * - handleScheduled: Optional cron handler + */ +export function hostAgent(config: HostAgentConfig): HostAgentResult { + const { agent, createRuntime } = config; + + const SessionClass = createSessionClass(agent, createRuntime); + + async function handleFetch( + request: Request, + env: HostEnv + ): Promise { + const url = new URL(request.url); + const path = url.pathname; + + await initSchema(env.DB); + + if (path === "/health") { + return json({ ok: true, agent: agent.name }); + } + + if (path === "/v1/events" && request.method === "POST") { + const events = await request.json() as Array<{ + source: string; + payload: unknown; + occurredAt: string; + }>; + + const ids: string[] = []; + for (const event of events) { + const id = await insertActivityEvent( + env.DB, + event.source, + event.payload, + event.occurredAt + ); + ids.push(id); + } + + return json({ ok: true, count: ids.length, ids }); + } + + const sessionId = extractSessionId(request); + if (!sessionId) { + return json({ error: "Missing session ID" }, 400); + } + + const id = env.SESSION.idFromName(sessionId); + const stub = env.SESSION.get(id); + + if (path.endsWith("/messages") && request.method === "POST") { + const body = await request.json() as { message: string; profile?: unknown }; + const userId = extractUserId(request); + + const doRequest = new Request(`${url.origin}/message`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message: body.message, + userId, + profile: body.profile, + }), + }); + + return stub.fetch(doRequest); + } + + if (path.endsWith("/enqueue") && request.method === "POST") { + const body = await request.json() as { message: string }; + + const eventId = crypto.randomUUID(); + await env.JOBS.send({ + sessionId, + eventId, + message: body.message, + }); + + return json({ ok: true, sessionId, eventId, status: "queued" }); + } + + if (path.endsWith("/status")) { + const doRequest = new Request(`${url.origin}/status`); + return stub.fetch(doRequest); + } + + return json({ error: "Not found", path }, 404); + } + + async function handleQueue( + batch: { messages: Array<{ body: TurnJob; ack: () => void }> }, + env: HostEnv + ): Promise { + for (const message of batch.messages) { + const job = message.body; + + try { + const id = env.SESSION.idFromName(job.sessionId); + const stub = env.SESSION.get(id); + + const response = await stub.fetch( + new Request("http://internal/turn", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(job), + }) + ); + + if (!response.ok) { + const error = await response.text(); + console.error(`Turn failed for session ${job.sessionId}:`, error); + } + + message.ack(); + } catch (err) { + console.error(`Queue processing error for session ${job.sessionId}:`, err); + message.ack(); + } + } + } + + async function handleScheduled( + event: { cron: string }, + _env: HostEnv + ): Promise { + console.log(`Scheduled event: ${event.cron}`); + } + + return { + SessionClass, + handleFetch, + handleQueue, + handleScheduled, + }; +} diff --git a/packages/cloud-agent/src/index.ts b/packages/cloud-agent/src/index.ts new file mode 100644 index 0000000..5d32208 --- /dev/null +++ b/packages/cloud-agent/src/index.ts @@ -0,0 +1,59 @@ +/** + * @mieweb/cloud-agent — Event-driven agent host for @mieweb/cloud. + * + * Binds an agent definition + AgentRuntime to a Durable Object with + * queue-driven turns, suspend/resume, and alarms. + * + * @example + * ```ts + * import { hostAgent } from '@mieweb/cloud-agent'; + * + * const { SessionClass, handleFetch, handleQueue } = hostAgent({ + * agent: { name: 'jerry', instructions: '...', tools: [...] }, + * createRuntime: (profile) => resolveRuntime(profile), + * store: { db: env.DB }, + * }); + * + * export { SessionClass as AgentSession }; + * export default { + * fetch: (req, env) => handleFetch(req, env), + * queue: (batch, env) => handleQueue(batch, env), + * }; + * ``` + */ + +export { hostAgent } from "./host.js"; +export { createSessionClass } from "./session.js"; +export { + initSchema, + getOrCreateSession, + updateSessionStatus, + insertEvent, + getSessionEvents, + insertMessage, + getSessionMessages, + insertActivityEvent, + getActivityEvents, + insertSummary, +} from "./storage.js"; +export type { + SessionStatus, + EventType, + LifecycleEvent, + MessageRole, + ConversationMessage, + ContinuationState, + Session, + TurnJob, + AgentDefinition, + AgentRuntime, + TurnInput, + RuntimeEvent, + HostStore, + Trigger, + HostEnv, + HostAgentConfig, + ToolContext, + HostAgentResult, + AgentSessionDO, +} from "./types.js"; diff --git a/packages/cloud-agent/src/session.ts b/packages/cloud-agent/src/session.ts new file mode 100644 index 0000000..9aa2fa9 --- /dev/null +++ b/packages/cloud-agent/src/session.ts @@ -0,0 +1,297 @@ +/** + * AgentSession Durable Object. + * Handles the agent turn lifecycle: queue-driven turns, suspend/resume, alarms. + */ + +import type { CloudStatefulState } from "@mieweb/cloud-types"; +import type { + HostEnv, + AgentDefinition, + AgentRuntime, + TurnJob, + ContinuationState, + ToolContext, +} from "./types.js"; +import { + getOrCreateSession, + updateSessionStatus, + insertEvent, + insertMessage, + getSessionMessages, + initSchema, +} from "./storage.js"; + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); + +/** + * Creates an AgentSession DO class bound to the given agent definition and runtime factory. + */ +export function createSessionClass( + agent: AgentDefinition, + createRuntime: (profile?: unknown) => AgentRuntime +) { + return class AgentSession { + private state: CloudStatefulState; + private env: HostEnv; + private turnInProgress = false; + private suspendReason: "waiting_for_user" | "waiting_for_approval" | null = null; + private suspendMessage: string | null = null; + + constructor(state: CloudStatefulState, env: HostEnv) { + this.state = state; + this.env = env; + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + const sessionId = this.state.id.toString(); + + try { + await initSchema(this.env.DB); + + if (url.pathname === "/status") { + return this.handleStatus(sessionId); + } + + if (url.pathname === "/message" && request.method === "POST") { + return this.handleMessage(request, sessionId); + } + + if (url.pathname === "/turn" && request.method === "POST") { + return this.handleTurn(request, sessionId); + } + + if (url.pathname === "/alarm" && request.method === "POST") { + return this.handleAlarmTrigger(request, sessionId); + } + + return json({ error: "not found", path: url.pathname }, 404); + } catch (err) { + console.error("AgentSession error:", err); + return json( + { error: String(err instanceof Error ? err.message : err) }, + 500 + ); + } + } + + /** + * Alarm handler - fires when a scheduled wake occurs. + */ + async alarm(): Promise { + const sessionId = this.state.id.toString(); + const payload = await this.state.storage.get("alarm_payload"); + await this.state.storage.delete("alarm_payload"); + + await insertEvent(this.env.DB, sessionId, "scheduled_wake", payload); + + await this.env.JOBS.send({ + sessionId, + eventId: crypto.randomUUID(), + scheduledPayload: payload, + }); + } + + /** + * Get session status. + */ + private async handleStatus(sessionId: string): Promise { + const session = await getOrCreateSession(this.env.DB, sessionId); + return json({ + sessionId, + status: session.status, + continuation: session.continuation, + }); + } + + /** + * Handle incoming message - record event and enqueue turn. + */ + private async handleMessage( + request: Request, + sessionId: string + ): Promise { + const { message, userId, profile: _profile } = await request.json() as { + message: string; + userId?: string; + profile?: unknown; + }; + + const session = await getOrCreateSession(this.env.DB, sessionId, userId); + + if (session.status === "running") { + return json( + { error: "Turn already in progress", status: session.status }, + 409 + ); + } + + const eventId = await insertEvent(this.env.DB, sessionId, "user_message", { + message, + }); + await insertMessage(this.env.DB, sessionId, "user", message); + + const isResume = + session.status === "waiting_for_user" || + session.status === "waiting_for_approval"; + + if (isResume) { + await insertEvent(this.env.DB, sessionId, "resumed"); + } + + await this.env.JOBS.send({ + sessionId, + eventId, + message, + isResume, + }); + + await updateSessionStatus(this.env.DB, sessionId, "running"); + + return json({ + ok: true, + sessionId, + eventId, + status: "queued", + wasResume: isResume, + }); + } + + /** + * Handle a turn job (called from queue consumer via fetch). + */ + private async handleTurn( + request: Request, + sessionId: string + ): Promise { + if (this.turnInProgress) { + return json({ error: "Turn already in progress" }, 409); + } + + this.turnInProgress = true; + this.suspendReason = null; + this.suspendMessage = null; + + try { + // Read job from request (used for context, e.g. scheduledPayload) + const job = await request.json() as TurnJob; + void job; // Used in future for scheduledPayload handling + // Ensure session exists (also validates sessionId) + await getOrCreateSession(this.env.DB, sessionId); + + const messages = await getSessionMessages(this.env.DB, sessionId); + const coreMessages = messages.map((m) => ({ + role: m.role, + content: m.content, + })); + + const runtime = createRuntime(); + + // Tool context for tools to access bindings and control flow + // TODO: Pass this to tools when they're implemented + const toolContext: ToolContext = { + sessionId, + db: this.env.DB, + vectors: this.env.VECTORS, + bucket: this.env.BUCKET, + scheduleWake: async (at, payload) => { + const when = typeof at === "string" ? new Date(at) : at; + await this.state.storage.put("alarm_payload", payload); + await this.state.storage.setAlarm(when); + }, + suspendForUser: (message) => { + this.suspendReason = "waiting_for_user"; + this.suspendMessage = message; + }, + suspendForApproval: (message) => { + this.suspendReason = "waiting_for_approval"; + this.suspendMessage = message; + }, + }; + void toolContext; // Will be passed to tools when implemented + + let assistantContent = ""; + let finishReason = "stop"; + + for await (const event of runtime.runTurn({ + messages: coreMessages, + tools: agent.tools, + system: agent.instructions, + maxSteps: 10, + })) { + if (event.type === "text-delta") { + assistantContent += event.text; + } else if (event.type === "finish") { + finishReason = event.finishReason; + } else if (event.type === "suspend") { + this.suspendReason = event.reason; + this.suspendMessage = event.message ?? null; + } else if (event.type === "error") { + await insertEvent(this.env.DB, sessionId, "error", { + message: event.message, + }); + await updateSessionStatus(this.env.DB, sessionId, "idle"); + return json({ error: event.message }, 500); + } + } + + if (this.suspendReason) { + const continuation: ContinuationState = { + pendingMessage: this.suspendMessage ?? undefined, + suspendedAt: new Date().toISOString(), + reason: this.suspendReason, + }; + + await insertMessage(this.env.DB, sessionId, "assistant", assistantContent || this.suspendMessage); + await insertEvent(this.env.DB, sessionId, this.suspendReason, { + message: this.suspendMessage, + }); + await updateSessionStatus(this.env.DB, sessionId, this.suspendReason, continuation); + + return json({ + ok: true, + sessionId, + status: this.suspendReason, + message: assistantContent || this.suspendMessage, + suspended: true, + }); + } + + if (assistantContent) { + await insertMessage(this.env.DB, sessionId, "assistant", assistantContent); + await insertEvent(this.env.DB, sessionId, "agent_message", { + content: assistantContent, + finishReason, + }); + } + + await updateSessionStatus(this.env.DB, sessionId, "idle"); + + return json({ + ok: true, + sessionId, + status: "idle", + message: assistantContent, + finishReason, + }); + } finally { + this.turnInProgress = false; + } + } + + /** + * Handle alarm trigger (for internal routing). + */ + private async handleAlarmTrigger( + _request: Request, + sessionId: string + ): Promise { + await this.alarm(); + return json({ ok: true, sessionId, alarm: "triggered" }); + } + }; +} diff --git a/packages/cloud-agent/src/storage.test.ts b/packages/cloud-agent/src/storage.test.ts new file mode 100644 index 0000000..d4deeaf --- /dev/null +++ b/packages/cloud-agent/src/storage.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for cloud-agent storage operations. + * Uses a mock CloudDatabase for testing. + */ + +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; + +/** + * Mock CloudDatabase implementation for testing. + * Stores data in memory. + */ +class MockDatabase { + private tables: Map = new Map(); + private execStatements: string[] = []; + + async exec(sql: string): Promise { + this.execStatements.push(sql); + const createTableMatches = sql.matchAll(/CREATE TABLE IF NOT EXISTS (\w+)/g); + for (const match of createTableMatches) { + if (!this.tables.has(match[1])) { + this.tables.set(match[1], []); + } + } + } + + prepare(sql: string) { + const db = this; + let boundValues: unknown[] = []; + + return { + bind(...values: unknown[]) { + boundValues = values; + return this; + }, + async run() { + const insertMatch = sql.match(/INSERT INTO (\w+)/i); + if (insertMatch) { + const table = insertMatch[1]; + const rows = db.tables.get(table) ?? []; + rows.push({ values: boundValues }); + db.tables.set(table, rows); + return { meta: { last_row_id: rows.length } }; + } + return {}; + }, + async first(): Promise { + const selectMatch = sql.match(/SELECT .* FROM (\w+) WHERE id = \?/i); + if (selectMatch) { + const table = selectMatch[1]; + const rows = db.tables.get(table) ?? []; + const row = rows.find((r: any) => r.values?.[0] === boundValues[0]); + if (row) { + return row as T; + } + } + return null; + }, + async all(): Promise<{ results: T[] }> { + const selectMatch = sql.match(/SELECT .* FROM (\w+)/i); + if (selectMatch) { + const table = selectMatch[1]; + const rows = db.tables.get(table) ?? []; + return { results: rows as T[] }; + } + return { results: [] }; + }, + }; + } + + getExecStatements() { + return this.execStatements; + } + + getTable(name: string) { + return this.tables.get(name); + } +} + +import { + initSchema, + getOrCreateSession, + updateSessionStatus, + insertEvent, + insertMessage, + getSessionMessages, + insertActivityEvent, + insertSummary, +} from "./storage.js"; + +describe("storage", () => { + let db: MockDatabase; + + beforeEach(() => { + db = new MockDatabase(); + }); + + describe("initSchema", () => { + it("creates all required tables", async () => { + await initSchema(db as any); + const statements = db.getExecStatements(); + assert.ok(statements.length > 0); + assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS sessions")); + assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS events")); + assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS messages")); + assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS activity_events")); + assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS summaries")); + }); + }); + + describe("getOrCreateSession", () => { + it("creates a new session when none exists", async () => { + await initSchema(db as any); + const session = await getOrCreateSession(db as any, "test-session-1", "user-1"); + assert.strictEqual(session.id, "test-session-1"); + assert.strictEqual(session.userId, "user-1"); + assert.strictEqual(session.status, "idle"); + assert.ok(session.conversationId); + }); + }); + + describe("insertEvent", () => { + it("inserts a lifecycle event", async () => { + await initSchema(db as any); + const id = await insertEvent(db as any, "session-1", "user_message", { text: "hello" }); + assert.ok(id); + const events = db.getTable("events"); + assert.ok(events && events.length > 0); + }); + }); + + describe("insertMessage", () => { + it("inserts a conversation message", async () => { + await initSchema(db as any); + const id = await insertMessage(db as any, "session-1", "user", "Hello world"); + assert.ok(id); + const messages = db.getTable("messages"); + assert.ok(messages && messages.length > 0); + }); + }); + + describe("insertActivityEvent", () => { + it("inserts an activity event from collector", async () => { + await initSchema(db as any); + const id = await insertActivityEvent( + db as any, + "aw", + { bucket: "aw-watcher-window" }, + "2024-01-01T12:00:00Z" + ); + assert.ok(id); + const events = db.getTable("activity_events"); + assert.ok(events && events.length > 0); + }); + }); + + describe("insertSummary", () => { + it("inserts an activity summary", async () => { + await initSchema(db as any); + const id = await insertSummary( + db as any, + "session-1", + "2024-01-01T10:00:00Z", + "2024-01-01T12:00:00Z", + { totalMinutes: 120 } + ); + assert.ok(id); + const summaries = db.getTable("summaries"); + assert.ok(summaries && summaries.length > 0); + }); + }); +}); diff --git a/packages/cloud-agent/src/storage.ts b/packages/cloud-agent/src/storage.ts new file mode 100644 index 0000000..a0bcf65 --- /dev/null +++ b/packages/cloud-agent/src/storage.ts @@ -0,0 +1,350 @@ +/** + * Storage operations for sessions, events, messages, and summaries. + * All operations use CloudDatabase (D1-compatible). + */ + +import type { CloudDatabase } from "@mieweb/cloud-types"; +import type { + Session, + SessionStatus, + LifecycleEvent, + ConversationMessage, + ContinuationState, + EventType, + MessageRole, +} from "./types.js"; + +function generateId(): string { + return crypto.randomUUID(); +} + +function nowISO(): string { + return new Date().toISOString(); +} + +/** + * Initialize the schema. Idempotent - safe to call on every request. + */ +export async function initSchema(db: CloudDatabase): Promise { + await db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT, + status TEXT NOT NULL DEFAULT 'idle', + conversation_id TEXT, + continuation TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS activity_events ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + payload TEXT, + occurred_at TEXT NOT NULL, + ingested_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS summaries ( + id TEXT PRIMARY KEY, + session_id TEXT, + range_start TEXT NOT NULL, + range_end TEXT NOT NULL, + summary TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id); + CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); + CREATE INDEX IF NOT EXISTS idx_activity_events_occurred ON activity_events(occurred_at); + `); +} + +/** + * Get or create a session by ID. + */ +export async function getOrCreateSession( + db: CloudDatabase, + sessionId: string, + userId?: string +): Promise { + const existing = await db + .prepare("SELECT * FROM sessions WHERE id = ?") + .bind(sessionId) + .first<{ + id: string; + user_id: string | null; + status: string; + conversation_id: string | null; + continuation: string | null; + created_at: string; + updated_at: string; + }>(); + + if (existing) { + return { + id: existing.id, + userId: existing.user_id ?? undefined, + status: existing.status as SessionStatus, + conversationId: existing.conversation_id ?? undefined, + continuation: existing.continuation + ? JSON.parse(existing.continuation) + : undefined, + createdAt: existing.created_at, + updatedAt: existing.updated_at, + }; + } + + const now = nowISO(); + const conversationId = generateId(); + await db + .prepare( + `INSERT INTO sessions (id, user_id, status, conversation_id, created_at, updated_at) + VALUES (?, ?, 'idle', ?, ?, ?)` + ) + .bind(sessionId, userId ?? null, conversationId, now, now) + .run(); + + return { + id: sessionId, + userId, + status: "idle", + conversationId, + createdAt: now, + updatedAt: now, + }; +} + +/** + * Update session status and optionally continuation state. + */ +export async function updateSessionStatus( + db: CloudDatabase, + sessionId: string, + status: SessionStatus, + continuation?: ContinuationState | null +): Promise { + const now = nowISO(); + await db + .prepare( + `UPDATE sessions + SET status = ?, continuation = ?, updated_at = ? + WHERE id = ?` + ) + .bind( + status, + continuation ? JSON.stringify(continuation) : null, + now, + sessionId + ) + .run(); +} + +/** + * Insert a lifecycle event. + */ +export async function insertEvent( + db: CloudDatabase, + sessionId: string, + type: EventType, + payload?: unknown +): Promise { + const id = generateId(); + const now = nowISO(); + await db + .prepare( + `INSERT INTO events (id, session_id, type, payload, created_at) + VALUES (?, ?, ?, ?, ?)` + ) + .bind(id, sessionId, type, payload ? JSON.stringify(payload) : null, now) + .run(); + return id; +} + +interface EventRow { + id: string; + session_id: string; + type: string; + payload: string | null; + created_at: string; +} + +/** + * Get recent events for a session. + */ +export async function getSessionEvents( + db: CloudDatabase, + sessionId: string, + limit = 100 +): Promise { + const rows = await db + .prepare( + `SELECT id, session_id, type, payload, created_at + FROM events + WHERE session_id = ? + ORDER BY created_at DESC + LIMIT ?` + ) + .bind(sessionId, limit) + .all(); + + return (rows.results ?? []).map((row: EventRow) => ({ + id: row.id, + sessionId: row.session_id, + type: row.type as EventType, + payload: row.payload ? JSON.parse(row.payload) : null, + createdAt: row.created_at, + })); +} + +/** + * Insert a conversation message. + */ +export async function insertMessage( + db: CloudDatabase, + sessionId: string, + role: MessageRole, + content: unknown +): Promise { + const id = generateId(); + const now = nowISO(); + await db + .prepare( + `INSERT INTO messages (id, session_id, role, content, created_at) + VALUES (?, ?, ?, ?, ?)` + ) + .bind(id, sessionId, role, JSON.stringify(content), now) + .run(); + return id; +} + +interface MessageRow { + id: string; + session_id: string; + role: string; + content: string | null; + created_at: string; +} + +/** + * Get conversation messages for a session. + */ +export async function getSessionMessages( + db: CloudDatabase, + sessionId: string +): Promise { + const rows = await db + .prepare( + `SELECT id, session_id, role, content, created_at + FROM messages + WHERE session_id = ? + ORDER BY created_at ASC` + ) + .bind(sessionId) + .all(); + + return (rows.results ?? []).map((row: MessageRow) => ({ + id: row.id, + sessionId: row.session_id, + role: row.role as MessageRole, + content: row.content ? JSON.parse(row.content) : null, + createdAt: row.created_at, + })); +} + +/** + * Insert an activity event from collector. + */ +export async function insertActivityEvent( + db: CloudDatabase, + source: string, + payload: unknown, + occurredAt: string +): Promise { + const id = generateId(); + const now = nowISO(); + await db + .prepare( + `INSERT INTO activity_events (id, source, payload, occurred_at, ingested_at) + VALUES (?, ?, ?, ?, ?)` + ) + .bind(id, source, JSON.stringify(payload), occurredAt, now) + .run(); + return id; +} + +interface ActivityEventRow { + id: string; + source: string; + payload: string | null; + occurred_at: string; +} + +/** + * Get activity events in a time range. + */ +export async function getActivityEvents( + db: CloudDatabase, + start: string, + end: string, + source?: string +): Promise> { + const query = source + ? `SELECT id, source, payload, occurred_at + FROM activity_events + WHERE occurred_at >= ? AND occurred_at <= ? AND source = ? + ORDER BY occurred_at ASC` + : `SELECT id, source, payload, occurred_at + FROM activity_events + WHERE occurred_at >= ? AND occurred_at <= ? + ORDER BY occurred_at ASC`; + + const rows = source + ? await db.prepare(query).bind(start, end, source).all() + : await db.prepare(query).bind(start, end).all(); + + return (rows.results ?? []).map((row: ActivityEventRow) => ({ + id: row.id, + source: row.source, + payload: row.payload ? JSON.parse(row.payload) : null, + occurredAt: row.occurred_at, + })); +} + +/** + * Insert a summary. + */ +export async function insertSummary( + db: CloudDatabase, + sessionId: string | null, + rangeStart: string, + rangeEnd: string, + summary: unknown +): Promise { + const id = generateId(); + const now = nowISO(); + await db + .prepare( + `INSERT INTO summaries (id, session_id, range_start, range_end, summary, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind(id, sessionId, rangeStart, rangeEnd, JSON.stringify(summary), now) + .run(); + return id; +} diff --git a/packages/cloud-agent/src/types.ts b/packages/cloud-agent/src/types.ts new file mode 100644 index 0000000..e64e75a --- /dev/null +++ b/packages/cloud-agent/src/types.ts @@ -0,0 +1,244 @@ +/** + * @mieweb/cloud-agent type definitions + * + * Types for the event-driven agent host that binds an agent definition + + * AgentRuntime to a Durable Object with queue-driven turns, suspend/resume, + * and alarms. + */ + +import type { + CloudDatabase, + CloudQueue, + CloudStatefulNamespace, + CloudStatefulState, + CloudVectorIndex, + CloudBucket, +} from "@mieweb/cloud-types"; + +/** + * Session status states. + * - `idle`: No turn in progress, ready for new messages + * - `running`: Turn currently executing + * - `waiting_for_user`: Agent asked a question, waiting for user response + * - `waiting_for_approval`: Tool needs human approval + * - `scheduled`: Alarm scheduled for future wake + */ +export type SessionStatus = + | "idle" + | "running" + | "waiting_for_user" + | "waiting_for_approval" + | "scheduled"; + +/** + * Event types in the lifecycle log. + */ +export type EventType = + | "user_message" + | "agent_message" + | "external" + | "scheduled_wake" + | "waiting_for_user" + | "waiting_for_approval" + | "resumed" + | "error"; + +/** + * A lifecycle event in the event log. + */ +export interface LifecycleEvent { + id: string; + sessionId: string; + type: EventType; + payload: unknown; + createdAt: string; +} + +/** + * Conversation message role. + */ +export type MessageRole = "user" | "assistant" | "system" | "tool"; + +/** + * A message in the conversation history. + */ +export interface ConversationMessage { + id: string; + sessionId: string; + role: MessageRole; + content: unknown; + createdAt: string; +} + +/** + * Continuation state for suspend/resume. + * Persisted when the agent suspends waiting for user input or approval. + */ +export interface ContinuationState { + /** The pending question or approval request */ + pendingMessage?: string; + /** Partial tool call state if suspended mid-tool */ + partialToolState?: unknown; + /** Timestamp when suspension occurred */ + suspendedAt: string; + /** Reason for suspension */ + reason: "waiting_for_user" | "waiting_for_approval"; +} + +/** + * Session record in the database. + */ +export interface Session { + id: string; + userId?: string; + status: SessionStatus; + conversationId?: string; + continuation?: ContinuationState; + createdAt: string; + updatedAt: string; +} + +/** + * Turn job queued for processing. + */ +export interface TurnJob { + sessionId: string; + eventId: string; + message?: string; + isResume?: boolean; + scheduledPayload?: unknown; +} + +/** + * Agent definition: instructions and tools. + */ +export interface AgentDefinition { + /** Agent name (used for routing) */ + name: string; + /** System instructions / persona */ + instructions: string; + /** Tools available to the agent (will be passed to runtime) */ + tools?: unknown; +} + +/** + * Minimal AgentRuntime interface expected by the host. + * Matches the AgentRuntime port from @mieweb/jerry-agent-runtime. + */ +export interface AgentRuntime { + /** Execute a turn and yield events */ + runTurn(input: TurnInput): AsyncIterable; +} + +/** + * Input for a single turn of the agent runtime. + */ +export interface TurnInput { + messages: Array<{ role: string; content: unknown }>; + tools?: unknown; + system?: string; + maxSteps?: number; +} + +/** + * Events emitted during a turn. + */ +export type RuntimeEvent = + | { type: "start" } + | { type: "text-delta"; text: string } + | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown } + | { + type: "tool-result"; + toolCallId: string; + toolName: string; + output: unknown; + } + | { type: "finish"; finishReason: string; usage?: unknown } + | { type: "error"; message: string; cause?: unknown } + | { type: "suspend"; reason: "waiting_for_user" | "waiting_for_approval"; message?: string }; + +/** + * Store bindings required by the host. + */ +export interface HostStore { + db: CloudDatabase; + vectors?: CloudVectorIndex; + bucket?: CloudBucket; +} + +/** + * Trigger definition for the host. + */ +export type Trigger = + | { type: "fetch"; path: string; method?: string } + | { type: "queue"; topic?: string } + | { type: "scheduled"; cron: string }; + +/** + * Environment bindings expected by the host. + */ +export interface HostEnv { + DB: CloudDatabase; + JOBS: CloudQueue; + SESSION: CloudStatefulNamespace; + VECTORS?: CloudVectorIndex; + BUCKET?: CloudBucket; +} + +/** + * Host configuration for hostAgent(). + */ +export interface HostAgentConfig { + /** Agent definition (name, instructions, tools) */ + agent: AgentDefinition; + /** Factory function to create runtime for a turn (receives profile from request) */ + createRuntime: (profile?: unknown) => AgentRuntime; + /** Store bindings (built from env) */ + store: HostStore; + /** Trigger definitions (optional, defaults to standard routes) */ + triggers?: Trigger[]; +} + +/** + * Tool context passed to tools during execution. + */ +export interface ToolContext { + sessionId: string; + db: CloudDatabase; + vectors?: CloudVectorIndex; + bucket?: CloudBucket; + /** Schedule a future wake-up */ + scheduleWake: (at: Date | string, payload?: unknown) => Promise; + /** Suspend the turn waiting for user input */ + suspendForUser: (message: string) => void; + /** Suspend the turn waiting for approval */ + suspendForApproval: (message: string) => void; +} + +/** + * Result from hostAgent() - wiring helpers for the worker. + */ +export interface HostAgentResult { + /** The DO class to export */ + SessionClass: new (state: CloudStatefulState, env: HostEnv) => AgentSessionDO; + /** Handle a fetch request (routes to DO) */ + handleFetch: ( + request: Request, + env: HostEnv + ) => Promise; + /** Handle a queue batch (forwards to DO) */ + handleQueue: ( + batch: { messages: Array<{ body: TurnJob; ack: () => void }> }, + env: HostEnv + ) => Promise; + /** Handle scheduled events (optional) */ + handleScheduled?: (event: { cron: string }, env: HostEnv) => Promise; +} + +/** + * The AgentSession Durable Object interface. + */ +export interface AgentSessionDO { + fetch(request: Request): Promise; + alarm?(): Promise; +} From ecb8aa768625be05dda2388fa18b3da90aaf9414 Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Tue, 30 Jun 2026 17:58:11 -0400 Subject: [PATCH 02/13] feat(cloud-agent): run --call turns synchronously and inject tools via createTools POST /messages now executes the turn inline instead of only enqueueing, and hostAgent accepts an optional createTools(ctx) factory so agents like Jerry can bind runtime tools with DB/vector/alarm context. Co-authored-by: Cursor --- packages/cloud-agent/src/host.ts | 4 +-- packages/cloud-agent/src/session.ts | 40 ++++++++++++++--------------- packages/cloud-agent/src/types.ts | 7 +++++ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/packages/cloud-agent/src/host.ts b/packages/cloud-agent/src/host.ts index 659a2b5..361407e 100644 --- a/packages/cloud-agent/src/host.ts +++ b/packages/cloud-agent/src/host.ts @@ -46,9 +46,9 @@ function extractUserId(request: Request): string | undefined { * - handleScheduled: Optional cron handler */ export function hostAgent(config: HostAgentConfig): HostAgentResult { - const { agent, createRuntime } = config; + const { agent, createRuntime, createTools } = config; - const SessionClass = createSessionClass(agent, createRuntime); + const SessionClass = createSessionClass(agent, createRuntime, createTools); async function handleFetch( request: Request, diff --git a/packages/cloud-agent/src/session.ts b/packages/cloud-agent/src/session.ts index 9aa2fa9..e7683b3 100644 --- a/packages/cloud-agent/src/session.ts +++ b/packages/cloud-agent/src/session.ts @@ -32,7 +32,8 @@ const json = (data: unknown, status = 200) => */ export function createSessionClass( agent: AgentDefinition, - createRuntime: (profile?: unknown) => AgentRuntime + createRuntime: (profile?: unknown) => AgentRuntime, + createTools?: (ctx: ToolContext) => unknown ) { return class AgentSession { private state: CloudStatefulState; @@ -115,7 +116,7 @@ export function createSessionClass( request: Request, sessionId: string ): Promise { - const { message, userId, profile: _profile } = await request.json() as { + const { message, userId, profile } = await request.json() as { message: string; userId?: string; profile?: unknown; @@ -143,22 +144,22 @@ export function createSessionClass( await insertEvent(this.env.DB, sessionId, "resumed"); } - await this.env.JOBS.send({ - sessionId, - eventId, - message, - isResume, - }); - await updateSessionStatus(this.env.DB, sessionId, "running"); - return json({ - ok: true, - sessionId, - eventId, - status: "queued", - wasResume: isResume, + // Synchronous turn for --call (POST /messages). Async enqueue uses /enqueue. + const turnRequest = new Request("http://internal/turn", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionId, + eventId, + message, + isResume, + profile, + } satisfies TurnJob), }); + + return this.handleTurn(turnRequest, sessionId); } /** @@ -189,10 +190,8 @@ export function createSessionClass( content: m.content, })); - const runtime = createRuntime(); + const runtime = createRuntime(job.profile); - // Tool context for tools to access bindings and control flow - // TODO: Pass this to tools when they're implemented const toolContext: ToolContext = { sessionId, db: this.env.DB, @@ -212,14 +211,15 @@ export function createSessionClass( this.suspendMessage = message; }, }; - void toolContext; // Will be passed to tools when implemented + + const tools = createTools?.(toolContext) ?? agent.tools; let assistantContent = ""; let finishReason = "stop"; for await (const event of runtime.runTurn({ messages: coreMessages, - tools: agent.tools, + tools, system: agent.instructions, maxSteps: 10, })) { diff --git a/packages/cloud-agent/src/types.ts b/packages/cloud-agent/src/types.ts index e64e75a..1f214e3 100644 --- a/packages/cloud-agent/src/types.ts +++ b/packages/cloud-agent/src/types.ts @@ -107,6 +107,8 @@ export interface TurnJob { message?: string; isResume?: boolean; scheduledPayload?: unknown; + /** Privacy profile override from the request (runtime, model, egress). */ + profile?: unknown; } /** @@ -193,6 +195,11 @@ export interface HostAgentConfig { agent: AgentDefinition; /** Factory function to create runtime for a turn (receives profile from request) */ createRuntime: (profile?: unknown) => AgentRuntime; + /** + * Build tools for a turn from host bindings (DB, vectors, alarms, …). + * When omitted, static `agent.tools` is used. + */ + createTools?: (ctx: ToolContext) => unknown; /** Store bindings (built from env) */ store: HostStore; /** Trigger definitions (optional, defaults to standard routes) */ From 054795c3983356f4f42ce28bd63d0db75476a26c Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Wed, 1 Jul 2026 16:16:13 -0400 Subject: [PATCH 03/13] chore: update pnpm-lock.yaml to match package.json Add lockfile entries for @types/node@^20.0.0 in cloud-agent and cloud-agent-cli so CI pnpm install --frozen-lockfile succeeds. Co-authored-by: Cursor --- pnpm-lock.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cacc103..43bbc4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,22 @@ importers: specifier: workspace:* version: link:../cloud-workers + packages/cloud-agent: + dependencies: + '@mieweb/cloud-types': + specifier: workspace:* + version: link:../cloud-types + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + + packages/cloud-agent-cli: + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + packages/cloud-local: dependencies: '@hono/node-server': @@ -393,6 +409,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.19.20': resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==} @@ -1530,6 +1549,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@types/node@22.19.20': dependencies: undici-types: 6.21.0 From ed365460562b8ce73e0ad6d5c7bf329b22416d23 Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Wed, 1 Jul 2026 16:17:39 -0400 Subject: [PATCH 04/13] fix: add tsx devDependency for cloud-agent test scripts Tests use `node --import tsx` but tsx was not declared, causing CI unit job failures on a clean install. Co-authored-by: Cursor --- packages/cloud-agent-cli/package.json | 3 +- packages/cloud-agent/package.json | 3 +- pnpm-lock.yaml | 293 ++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 2 deletions(-) diff --git a/packages/cloud-agent-cli/package.json b/packages/cloud-agent-cli/package.json index 88e1a52..4e3ae26 100644 --- a/packages/cloud-agent-cli/package.json +++ b/packages/cloud-agent-cli/package.json @@ -32,6 +32,7 @@ "test": "node --import tsx --test src/**/*.test.ts" }, "devDependencies": { - "@types/node": "^20.0.0" + "@types/node": "^20.0.0", + "tsx": "^4.19.0" } } diff --git a/packages/cloud-agent/package.json b/packages/cloud-agent/package.json index 45df0e1..991f91d 100644 --- a/packages/cloud-agent/package.json +++ b/packages/cloud-agent/package.json @@ -35,6 +35,7 @@ "@mieweb/cloud-types": "workspace:*" }, "devDependencies": { - "@types/node": "^20.0.0" + "@types/node": "^20.0.0", + "tsx": "^4.19.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43bbc4f..f147ca5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,12 +51,18 @@ importers: '@types/node': specifier: ^20.0.0 version: 20.19.43 + tsx: + specifier: ^4.19.0 + version: 4.22.4 packages/cloud-agent-cli: devDependencies: '@types/node': specifier: ^20.0.0 version: 20.19.43 + tsx: + specifier: ^4.19.0 + version: 4.22.4 packages/cloud-local: dependencies: @@ -277,6 +283,162 @@ packages: '@cloudflare/workers-types@4.20260607.1': resolution: {integrity: sha512-TSiusluJ8+5esTMYwxGFuT1SNU/PRzPmt9VMsmAlzjIK0mhc24Zsc1bbGEVH5qyMZ8hrdRtrAPdt2+T8Vph2+Q==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -524,6 +686,11 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -580,6 +747,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -929,6 +1101,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -1381,6 +1558,84 @@ snapshots: '@cloudflare/workers-types@4.20260607.1': {} + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@hono/node-server@1.19.14(hono@4.12.23)': dependencies: hono: 4.12.23 @@ -1667,6 +1922,35 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + esprima@4.0.1: {} expand-template@2.0.3: @@ -1738,6 +2022,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.3: + optional: true + github-from-package@0.0.0: optional: true @@ -2094,6 +2381,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 From f3d3790ba0b5e9999d0f15a483a96fb576a41b7f Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Thu, 2 Jul 2026 11:55:43 -0400 Subject: [PATCH 05/13] fix(cloud-agent): address Copilot PR review on async turns - Forward profile and userId through the /enqueue path into TurnJob so async turns honor the per-request privacy profile. - Persist the enqueued user message and status transitions inside handleTurn so queued turns actually consume the new message; slim handleMessage to delegate and avoid double-inserts. - Reset session status to idle (and log an error event) when a turn throws, preventing sessions from getting stuck in "running". - Retry failed/exception queue jobs instead of ack-ing them so transient failures are not dropped permanently. Co-authored-by: Cursor --- packages/cloud-agent/src/host.ts | 12 +++-- packages/cloud-agent/src/session.ts | 73 ++++++++++++++++------------- packages/cloud-agent/src/types.ts | 4 +- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/packages/cloud-agent/src/host.ts b/packages/cloud-agent/src/host.ts index 361407e..f8c3d67 100644 --- a/packages/cloud-agent/src/host.ts +++ b/packages/cloud-agent/src/host.ts @@ -9,6 +9,7 @@ import type { HostEnv, TurnJob, } from "./types.js"; +import type { CloudMessageBatch } from "@mieweb/cloud-types"; import { createSessionClass } from "./session.js"; import { initSchema, insertActivityEvent } from "./storage.js"; @@ -110,13 +111,16 @@ export function hostAgent(config: HostAgentConfig): HostAgentResult { } if (path.endsWith("/enqueue") && request.method === "POST") { - const body = await request.json() as { message: string }; + const body = await request.json() as { message: string; profile?: unknown }; + const userId = extractUserId(request); const eventId = crypto.randomUUID(); await env.JOBS.send({ sessionId, eventId, message: body.message, + userId, + profile: body.profile, }); return json({ ok: true, sessionId, eventId, status: "queued" }); @@ -131,7 +135,7 @@ export function hostAgent(config: HostAgentConfig): HostAgentResult { } async function handleQueue( - batch: { messages: Array<{ body: TurnJob; ack: () => void }> }, + batch: CloudMessageBatch, env: HostEnv ): Promise { for (const message of batch.messages) { @@ -152,12 +156,14 @@ export function hostAgent(config: HostAgentConfig): HostAgentResult { if (!response.ok) { const error = await response.text(); console.error(`Turn failed for session ${job.sessionId}:`, error); + message.retry(); + continue; } message.ack(); } catch (err) { console.error(`Queue processing error for session ${job.sessionId}:`, err); - message.ack(); + message.retry(); } } } diff --git a/packages/cloud-agent/src/session.ts b/packages/cloud-agent/src/session.ts index e7683b3..4db4bdc 100644 --- a/packages/cloud-agent/src/session.ts +++ b/packages/cloud-agent/src/session.ts @@ -110,7 +110,7 @@ export function createSessionClass( } /** - * Handle incoming message - record event and enqueue turn. + * Handle incoming message - delegate to handleTurn for persistence and execution. */ private async handleMessage( request: Request, @@ -122,39 +122,14 @@ export function createSessionClass( profile?: unknown; }; - const session = await getOrCreateSession(this.env.DB, sessionId, userId); - - if (session.status === "running") { - return json( - { error: "Turn already in progress", status: session.status }, - 409 - ); - } - - const eventId = await insertEvent(this.env.DB, sessionId, "user_message", { - message, - }); - await insertMessage(this.env.DB, sessionId, "user", message); - - const isResume = - session.status === "waiting_for_user" || - session.status === "waiting_for_approval"; - - if (isResume) { - await insertEvent(this.env.DB, sessionId, "resumed"); - } - - await updateSessionStatus(this.env.DB, sessionId, "running"); - - // Synchronous turn for --call (POST /messages). Async enqueue uses /enqueue. const turnRequest = new Request("http://internal/turn", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, - eventId, + eventId: crypto.randomUUID(), message, - isResume, + userId, profile, } satisfies TurnJob), }); @@ -178,11 +153,36 @@ export function createSessionClass( this.suspendMessage = null; try { - // Read job from request (used for context, e.g. scheduledPayload) const job = await request.json() as TurnJob; - void job; // Used in future for scheduledPayload handling - // Ensure session exists (also validates sessionId) - await getOrCreateSession(this.env.DB, sessionId); + const session = await getOrCreateSession( + this.env.DB, + sessionId, + job.userId + ); + + if (session.status === "running") { + return json( + { error: "Turn already in progress", status: session.status }, + 409 + ); + } + + if (job.message) { + const isResume = + session.status === "waiting_for_user" || + session.status === "waiting_for_approval"; + + await insertEvent(this.env.DB, sessionId, "user_message", { + message: job.message, + }); + await insertMessage(this.env.DB, sessionId, "user", job.message); + + if (isResume) { + await insertEvent(this.env.DB, sessionId, "resumed"); + } + } + + await updateSessionStatus(this.env.DB, sessionId, "running"); const messages = await getSessionMessages(this.env.DB, sessionId); const coreMessages = messages.map((m) => ({ @@ -278,6 +278,15 @@ export function createSessionClass( message: assistantContent, finishReason, }); + } catch (err) { + await insertEvent(this.env.DB, sessionId, "error", { + message: String(err instanceof Error ? err.message : err), + }); + await updateSessionStatus(this.env.DB, sessionId, "idle"); + return json( + { error: String(err instanceof Error ? err.message : err) }, + 500 + ); } finally { this.turnInProgress = false; } diff --git a/packages/cloud-agent/src/types.ts b/packages/cloud-agent/src/types.ts index 1f214e3..1acec20 100644 --- a/packages/cloud-agent/src/types.ts +++ b/packages/cloud-agent/src/types.ts @@ -8,6 +8,7 @@ import type { CloudDatabase, + CloudMessageBatch, CloudQueue, CloudStatefulNamespace, CloudStatefulState, @@ -105,6 +106,7 @@ export interface TurnJob { sessionId: string; eventId: string; message?: string; + userId?: string; isResume?: boolean; scheduledPayload?: unknown; /** Privacy profile override from the request (runtime, model, egress). */ @@ -235,7 +237,7 @@ export interface HostAgentResult { ) => Promise; /** Handle a queue batch (forwards to DO) */ handleQueue: ( - batch: { messages: Array<{ body: TurnJob; ack: () => void }> }, + batch: CloudMessageBatch, env: HostEnv ) => Promise; /** Handle scheduled events (optional) */ From f68bd1dcf81397f15c44f0977b53dbea9e85169a Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Thu, 2 Jul 2026 12:08:48 -0400 Subject: [PATCH 06/13] fix(cloud-agent): harden alarm enqueue and event payload persistence - alarm(): create the scheduled_wake event first and reuse its eventId for the queued wake job, and only delete alarm_payload after JOBS.send() succeeds so a failed enqueue can be retried. - insertEvent(): only store NULL when payload is undefined, so falsy payloads (0, false, "") are persisted instead of silently dropped. Co-authored-by: Cursor --- packages/cloud-agent/src/session.ts | 12 +++++++++--- packages/cloud-agent/src/storage.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/cloud-agent/src/session.ts b/packages/cloud-agent/src/session.ts index 4db4bdc..2a6b99d 100644 --- a/packages/cloud-agent/src/session.ts +++ b/packages/cloud-agent/src/session.ts @@ -86,15 +86,21 @@ export function createSessionClass( async alarm(): Promise { const sessionId = this.state.id.toString(); const payload = await this.state.storage.get("alarm_payload"); - await this.state.storage.delete("alarm_payload"); - await insertEvent(this.env.DB, sessionId, "scheduled_wake", payload); + const eventId = await insertEvent( + this.env.DB, + sessionId, + "scheduled_wake", + payload + ); await this.env.JOBS.send({ sessionId, - eventId: crypto.randomUUID(), + eventId, scheduledPayload: payload, }); + + await this.state.storage.delete("alarm_payload"); } /** diff --git a/packages/cloud-agent/src/storage.ts b/packages/cloud-agent/src/storage.ts index a0bcf65..8160143 100644 --- a/packages/cloud-agent/src/storage.ts +++ b/packages/cloud-agent/src/storage.ts @@ -172,7 +172,7 @@ export async function insertEvent( `INSERT INTO events (id, session_id, type, payload, created_at) VALUES (?, ?, ?, ?, ?)` ) - .bind(id, sessionId, type, payload ? JSON.stringify(payload) : null, now) + .bind(id, sessionId, type, payload === undefined ? null : JSON.stringify(payload), now) .run(); return id; } From e371f4007a180fa826878811235f32cea3af6b4d Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Tue, 11 Aug 2026 12:39:01 -0400 Subject: [PATCH 07/13] refactor(cloud-agent): remove agent-specific naming from the host layer The host and CLI hard-coded JERRY_URL / JERRY_SESSION, which tied a generic platform package to one consumer. Derive the environment namespace from the agent name instead (`jerry` -> JERRY_*, `assistant` -> ASSISTANT_*) with a shared AGENT_* fallback, so any agent gets its own namespace and none are named in the package. - Add envPrefix()/readEnv() and thread the agent name through parseArgs/run - Resolve baseUrl from the agent namespace when a wrapper omits it - Interpolate the env var names in --help instead of printing them literally - Neutralize the @example blocks and the AgentRuntime port reference Behavior is unchanged for existing consumers: an agent named `jerry` still resolves JERRY_URL and JERRY_SESSION, and its --help output is identical. Co-authored-by: Cursor --- packages/cloud-agent-cli/bin/agent-cli.js | 1 - packages/cloud-agent-cli/src/env.test.ts | 58 ++++++++++++++++++++++ packages/cloud-agent-cli/src/env.ts | 37 ++++++++++++++ packages/cloud-agent-cli/src/index.ts | 9 ++-- packages/cloud-agent-cli/src/parse.test.ts | 22 ++++++-- packages/cloud-agent-cli/src/parse.ts | 9 +++- packages/cloud-agent-cli/src/run.ts | 17 +++++-- packages/cloud-agent/src/index.ts | 2 +- packages/cloud-agent/src/types.ts | 3 +- 9 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 packages/cloud-agent-cli/src/env.test.ts create mode 100644 packages/cloud-agent-cli/src/env.ts diff --git a/packages/cloud-agent-cli/bin/agent-cli.js b/packages/cloud-agent-cli/bin/agent-cli.js index 34ca9cb..d350885 100755 --- a/packages/cloud-agent-cli/bin/agent-cli.js +++ b/packages/cloud-agent-cli/bin/agent-cli.js @@ -11,6 +11,5 @@ const agent = basename(process.argv[1]).replace(/\.(js|mjs|ts)$/, ''); run({ agent, - baseUrl: process.env.JERRY_URL ?? 'http://127.0.0.1:8787', version: '0.1.0', }); diff --git a/packages/cloud-agent-cli/src/env.test.ts b/packages/cloud-agent-cli/src/env.test.ts new file mode 100644 index 0000000..e3c7bf7 --- /dev/null +++ b/packages/cloud-agent-cli/src/env.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for agent-scoped environment variable resolution. + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { envPrefix, readEnv } from "./env.js"; + +describe("envPrefix", () => { + it("upper-cases a simple agent name", () => { + assert.strictEqual(envPrefix("assistant"), "ASSISTANT"); + }); + + it("normalizes separators to underscores", () => { + assert.strictEqual(envPrefix("my-agent"), "MY_AGENT"); + assert.strictEqual(envPrefix("my.agent v2"), "MY_AGENT_V2"); + }); + + it("falls back to AGENT for empty or unusable names", () => { + assert.strictEqual(envPrefix(undefined), "AGENT"); + assert.strictEqual(envPrefix(""), "AGENT"); + assert.strictEqual(envPrefix("---"), "AGENT"); + }); +}); + +describe("readEnv", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env.AGENT_URL; + delete process.env.ASSISTANT_URL; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("returns undefined when nothing is set", () => { + assert.strictEqual(readEnv("assistant", "URL"), undefined); + }); + + it("prefers the agent-scoped variable over the shared one", () => { + process.env.AGENT_URL = "http://shared"; + process.env.ASSISTANT_URL = "http://scoped"; + assert.strictEqual(readEnv("assistant", "URL"), "http://scoped"); + }); + + it("falls back to the shared variable", () => { + process.env.AGENT_URL = "http://shared"; + assert.strictEqual(readEnv("assistant", "URL"), "http://shared"); + }); + + it("reads the shared variable when no agent name is given", () => { + process.env.AGENT_URL = "http://shared"; + assert.strictEqual(readEnv(undefined, "URL"), "http://shared"); + }); +}); diff --git a/packages/cloud-agent-cli/src/env.ts b/packages/cloud-agent-cli/src/env.ts new file mode 100644 index 0000000..da4ad1e --- /dev/null +++ b/packages/cloud-agent-cli/src/env.ts @@ -0,0 +1,37 @@ +/** + * Environment variable naming for agent CLIs. + * + * Each agent reads from its own namespace derived from its name, so several + * agents can coexist in one shell without colliding. `AGENT_*` is the shared + * fallback for the generic dispatcher and for agents whose name does not + * produce a usable prefix. + */ + +const FALLBACK_PREFIX = "AGENT"; + +/** + * Derive the environment variable prefix for an agent name. + * `"assistant"` yields `"ASSISTANT"`, `"my-agent"` yields `"MY_AGENT"`. + */ +export function envPrefix(agent?: string): string { + const normalized = (agent ?? "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return normalized || FALLBACK_PREFIX; +} + +/** + * Read an agent-scoped variable, falling back to the shared `AGENT_*` name. + */ +export function readEnv( + agent: string | undefined, + suffix: string +): string | undefined { + const prefix = envPrefix(agent); + if (prefix !== FALLBACK_PREFIX) { + const scoped = process.env[`${prefix}_${suffix}`]; + if (scoped) return scoped; + } + return process.env[`${FALLBACK_PREFIX}_${suffix}`]; +} diff --git a/packages/cloud-agent-cli/src/index.ts b/packages/cloud-agent-cli/src/index.ts index 49a9d64..c15745c 100644 --- a/packages/cloud-agent-cli/src/index.ts +++ b/packages/cloud-agent-cli/src/index.ts @@ -4,14 +4,16 @@ * Agent identity is determined by basename(argv[0]) (busybox/git multicall pattern). * Agent-specific packages wrap this with their config. * + * Configuration not passed explicitly is read from the agent's environment + * namespace (`assistant` reads `ASSISTANT_URL`), falling back to `AGENT_URL`. + * * @example * ```ts - * // packages/cli/bin/jerry.js + * // packages/cli/bin/assistant.js * import { run } from '@mieweb/cloud-agent-cli'; * * run({ - * agent: 'jerry', - * baseUrl: process.env.JERRY_URL ?? 'http://127.0.0.1:8787', + * agent: 'assistant', * version: '0.1.0', * }); * ``` @@ -19,6 +21,7 @@ export { run } from "./run.js"; export { parseArgs } from "./parse.js"; +export { envPrefix, readEnv } from "./env.js"; export { streamCall, fireAndForget, getStatus } from "./client.js"; export type { CliConfig, diff --git a/packages/cloud-agent-cli/src/parse.test.ts b/packages/cloud-agent-cli/src/parse.test.ts index 2cd89d2..c0e22bf 100644 --- a/packages/cloud-agent-cli/src/parse.test.ts +++ b/packages/cloud-agent-cli/src/parse.test.ts @@ -11,7 +11,8 @@ describe("parseArgs", () => { beforeEach(() => { originalEnv = { ...process.env }; - delete process.env.JERRY_SESSION; + delete process.env.AGENT_SESSION; + delete process.env.ASSISTANT_SESSION; }); afterEach(() => { @@ -102,12 +103,25 @@ describe("parseArgs", () => { }); describe("session handling", () => { - it("uses JERRY_SESSION from env", () => { - process.env.JERRY_SESSION = "test-session-123"; - const { command, options } = parseArgs(["hello"]); + it("uses AGENT_SESSION from env", () => { + process.env.AGENT_SESSION = "test-session-123"; + const { options } = parseArgs(["hello"]); assert.strictEqual(options.sessionId, "test-session-123"); }); + it("prefers the agent-scoped session variable", () => { + process.env.AGENT_SESSION = "shared"; + process.env.ASSISTANT_SESSION = "scoped"; + const { options } = parseArgs(["hello"], "assistant"); + assert.strictEqual(options.sessionId, "scoped"); + }); + + it("falls back to AGENT_SESSION when the scoped variable is unset", () => { + process.env.AGENT_SESSION = "shared"; + const { options } = parseArgs(["hello"], "assistant"); + assert.strictEqual(options.sessionId, "shared"); + }); + it("parses --session flag", () => { const { command, options } = parseArgs(["--session", "my-session", "hello", "world"]); assert.strictEqual(options.sessionId, "my-session"); diff --git a/packages/cloud-agent-cli/src/parse.ts b/packages/cloud-agent-cli/src/parse.ts index 1b74d6f..2f953a5 100644 --- a/packages/cloud-agent-cli/src/parse.ts +++ b/packages/cloud-agent-cli/src/parse.ts @@ -4,16 +4,21 @@ */ import type { ParsedCommand, CliOptions } from "./types.js"; +import { readEnv } from "./env.js"; /** * Parse CLI arguments into a command and options. + * `agent` scopes the session environment variable to that agent's namespace. */ -export function parseArgs(args: string[]): { +export function parseArgs( + args: string[], + agent?: string +): { command: ParsedCommand; options: CliOptions; } { const options: CliOptions = { - sessionId: process.env.JERRY_SESSION, + sessionId: readEnv(agent, "SESSION"), cwd: process.cwd(), }; diff --git a/packages/cloud-agent-cli/src/run.ts b/packages/cloud-agent-cli/src/run.ts index 8f9e60d..6017b58 100644 --- a/packages/cloud-agent-cli/src/run.ts +++ b/packages/cloud-agent-cli/src/run.ts @@ -5,6 +5,7 @@ import type { CliConfig, CliOptions } from "./types.js"; import { parseArgs } from "./parse.js"; import { streamCall, fireAndForget } from "./client.js"; +import { envPrefix, readEnv } from "./env.js"; const DEFAULT_BASE_URL = "http://127.0.0.1:8787"; @@ -13,8 +14,9 @@ const DEFAULT_BASE_URL = "http://127.0.0.1:8787"; * Agent-specific wrappers call this with their config. */ export async function run(config: CliConfig): Promise { - const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; - const { command, options } = parseArgs(process.argv.slice(2)); + const baseUrl = + config.baseUrl ?? readEnv(config.agent, "URL") ?? DEFAULT_BASE_URL; + const { command, options } = parseArgs(process.argv.slice(2), config.agent); switch (command.type) { case "help": @@ -129,6 +131,11 @@ function generateSessionId(): string { * Print help message. */ function printHelp(agent: string): void { + const prefix = envPrefix(agent); + const urlVar = `${prefix}_URL`; + const sessionVar = `${prefix}_SESSION`; + const pad = (name: string) => + name.padEnd(Math.max(urlVar.length, sessionVar.length) + 3); console.log(` ${agent} — message-first CLI @@ -149,8 +156,8 @@ Examples: ${agent} I just made this PR ${agent} -txt quick note for the day -Environment: - JERRY_URL Override base URL (default: http://127.0.0.1:8787) - JERRY_SESSION Override session ID +Environment (falls back to AGENT_URL / AGENT_SESSION): + ${pad(urlVar)}Override base URL (default: ${DEFAULT_BASE_URL}) + ${pad(sessionVar)}Override session ID `.trim()); } diff --git a/packages/cloud-agent/src/index.ts b/packages/cloud-agent/src/index.ts index 5d32208..281fc1a 100644 --- a/packages/cloud-agent/src/index.ts +++ b/packages/cloud-agent/src/index.ts @@ -9,7 +9,7 @@ * import { hostAgent } from '@mieweb/cloud-agent'; * * const { SessionClass, handleFetch, handleQueue } = hostAgent({ - * agent: { name: 'jerry', instructions: '...', tools: [...] }, + * agent: { name: 'assistant', instructions: '...', tools: [...] }, * createRuntime: (profile) => resolveRuntime(profile), * store: { db: env.DB }, * }); diff --git a/packages/cloud-agent/src/types.ts b/packages/cloud-agent/src/types.ts index 1acec20..ef3e0d3 100644 --- a/packages/cloud-agent/src/types.ts +++ b/packages/cloud-agent/src/types.ts @@ -127,7 +127,8 @@ export interface AgentDefinition { /** * Minimal AgentRuntime interface expected by the host. - * Matches the AgentRuntime port from @mieweb/jerry-agent-runtime. + * The consuming agent package supplies the implementation, which is what keeps + * model and provider selection out of the host. */ export interface AgentRuntime { /** Execute a turn and yield events */ From 46c5a4bb63c8b64d0984d85ef1be9ef37403813d Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Tue, 11 Aug 2026 13:26:42 -0400 Subject: [PATCH 08/13] fix(cloud-agent): apply schema via prepared statements so initSchema works on D1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1's exec() splits input on newlines and requires each line to be a complete statement, so the multi-line CREATE TABLE block failed with D1_EXEC_ERROR on workerd. initSchema() runs on every request, so nothing worked on Cloudflare: health, session status, turns, and queue consumption all returned 500. Local (better-sqlite3 db.exec) and mieweb (libSQL executeMultiple) both accept multi-line SQL, which is why conformance passed on those targets and missed it. Type checking could not catch it either, since exec(string) is a valid call. Apply each statement through prepare().run() instead — the one path all three adapters implement, with no line restrictions. - Split the schema into one statement per table/index - Make the storage test mock reject multi-line exec() the way D1 does, so the old code would now fail the suite - Add a Miniflare smoke harness under smoke/ that boots hostAgent() on workerd with real D1, Queues, and Durable Object bindings Verified on wrangler dev: health, DO status, D1 event write, synchronous turn, and queue producer -> consumer -> DO all pass. Co-authored-by: Cursor --- .gitignore | 1 + packages/cloud-agent/smoke/README.md | 25 +++++++++ packages/cloud-agent/smoke/worker.ts | 32 ++++++++++++ packages/cloud-agent/smoke/wrangler.jsonc | 26 +++++++++ packages/cloud-agent/src/storage.test.ts | 64 ++++++++++++++++++----- packages/cloud-agent/src/storage.ts | 48 +++++++++-------- 6 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 packages/cloud-agent/smoke/README.md create mode 100644 packages/cloud-agent/smoke/worker.ts create mode 100644 packages/cloud-agent/smoke/wrangler.jsonc diff --git a/.gitignore b/.gitignore index 1e673d9..a6b4339 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .DS_Store *.log .mieweb/ +.wrangler/ diff --git a/packages/cloud-agent/smoke/README.md b/packages/cloud-agent/smoke/README.md new file mode 100644 index 0000000..5ce51ff --- /dev/null +++ b/packages/cloud-agent/smoke/README.md @@ -0,0 +1,25 @@ +# Workers runtime smoke test + +Boots `hostAgent()` on workerd via Miniflare with real D1, Queues, and Durable +Object bindings, using a stub runtime so no model provider is needed. + +This exists because the local (better-sqlite3) and mieweb (libSQL) backends are +more permissive than D1 in places. Anything that passes conformance on those +two can still fail on Cloudflare — `initSchema()` did. + +```sh +npx wrangler dev -c wrangler.jsonc --port 8799 --local +``` + +Then, against `http://localhost:8799`: + +| Request | Exercises | +|---------|-----------| +| `GET /health` | worker boot, D1 schema init | +| `GET /v1/sessions/smoke-1/status` | Durable Object + D1 read | +| `POST /v1/events` | D1 write | +| `POST /v1/sessions/smoke-1/messages` | synchronous turn through the DO | +| `POST /v1/sessions/smoke-2/enqueue` | Queues producer → consumer → DO | + +Vectorize and AI bindings are omitted because Miniflare does not emulate them; +`hostAgent()` treats both as optional. diff --git a/packages/cloud-agent/smoke/worker.ts b/packages/cloud-agent/smoke/worker.ts new file mode 100644 index 0000000..b612177 --- /dev/null +++ b/packages/cloud-agent/smoke/worker.ts @@ -0,0 +1,32 @@ +/** + * Minimal worker that mounts @mieweb/cloud-agent on the Workers runtime. + * + * Uses a stub runtime so the smoke test exercises the host (Durable Object, + * D1 storage, Queues) without needing a model provider. + */ + +import { hostAgent } from "../src/index.js"; +import type { AgentRuntime, RuntimeEvent, HostEnv, TurnJob } from "../src/types.js"; +import type { CloudMessageBatch } from "@mieweb/cloud-types"; + +const stubRuntime: AgentRuntime = { + async *runTurn(): AsyncIterable { + yield { type: "start" }; + yield { type: "text-delta", text: "cloud-agent running on workerd" }; + yield { type: "finish", finishReason: "stop" }; + }, +}; + +const host = hostAgent({ + agent: { name: "smoke", instructions: "smoke test agent" }, + createRuntime: () => stubRuntime, + store: { db: null as never }, +}); + +export const AgentSession = host.SessionClass; + +export default { + fetch: (request: Request, env: HostEnv) => host.handleFetch(request, env), + queue: (batch: CloudMessageBatch, env: HostEnv) => + host.handleQueue(batch, env), +}; diff --git a/packages/cloud-agent/smoke/wrangler.jsonc b/packages/cloud-agent/smoke/wrangler.jsonc new file mode 100644 index 0000000..3ee621b --- /dev/null +++ b/packages/cloud-agent/smoke/wrangler.jsonc @@ -0,0 +1,26 @@ +{ + // Miniflare/workerd smoke config for @mieweb/cloud-agent. + // Bindings mirror what hostAgent() requires: D1, Queues, Durable Objects. + // Vectorize and AI are omitted because Miniflare does not emulate them. + "name": "cloud-agent-smoke", + "main": "worker.ts", + "compatibility_date": "2025-01-01", + + "d1_databases": [ + { + "binding": "DB", + "database_name": "cloud-agent-smoke", + "database_id": "cloud-agent-smoke-local" + } + ], + + "queues": { + "producers": [{ "binding": "JOBS", "queue": "cloud-agent-smoke-turns" }], + "consumers": [{ "queue": "cloud-agent-smoke-turns", "max_batch_size": 1 }] + }, + + "durable_objects": { + "bindings": [{ "name": "SESSION", "class_name": "AgentSession" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["AgentSession"] }] +} diff --git a/packages/cloud-agent/src/storage.test.ts b/packages/cloud-agent/src/storage.test.ts index d4deeaf..3de4252 100644 --- a/packages/cloud-agent/src/storage.test.ts +++ b/packages/cloud-agent/src/storage.test.ts @@ -12,14 +12,19 @@ import assert from "node:assert"; */ class MockDatabase { private tables: Map = new Map(); - private execStatements: string[] = []; + private ddlStatements: string[] = []; + /** + * Mirrors D1: exec() rejects statements that span multiple lines, so the + * mock refuses them too and the schema path has to use prepare()/run(). + */ async exec(sql: string): Promise { - this.execStatements.push(sql); - const createTableMatches = sql.matchAll(/CREATE TABLE IF NOT EXISTS (\w+)/g); - for (const match of createTableMatches) { - if (!this.tables.has(match[1])) { - this.tables.set(match[1], []); + for (const line of sql.split("\n")) { + const trimmed = line.trim(); + if (trimmed && !trimmed.endsWith(";")) { + throw new Error( + `D1_EXEC_ERROR: Error in line 1: ${trimmed}: incomplete input` + ); } } } @@ -34,6 +39,16 @@ class MockDatabase { return this; }, async run() { + const ddlMatch = sql.match(/^\s*CREATE (TABLE|INDEX)/i); + if (ddlMatch) { + db.ddlStatements.push(sql); + const tableMatch = sql.match(/CREATE TABLE IF NOT EXISTS (\w+)/i); + if (tableMatch && !db.tables.has(tableMatch[1])) { + db.tables.set(tableMatch[1], []); + } + return {}; + } + const insertMatch = sql.match(/INSERT INTO (\w+)/i); if (insertMatch) { const table = insertMatch[1]; @@ -68,8 +83,8 @@ class MockDatabase { }; } - getExecStatements() { - return this.execStatements; + getDdlStatements() { + return this.ddlStatements; } getTable(name: string) { @@ -98,13 +113,34 @@ describe("storage", () => { describe("initSchema", () => { it("creates all required tables", async () => { await initSchema(db as any); - const statements = db.getExecStatements(); + const statements = db.getDdlStatements(); assert.ok(statements.length > 0); - assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS sessions")); - assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS events")); - assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS messages")); - assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS activity_events")); - assert.ok(statements[0].includes("CREATE TABLE IF NOT EXISTS summaries")); + for (const table of [ + "sessions", + "events", + "messages", + "activity_events", + "summaries", + ]) { + assert.ok( + statements.some((s) => + s.includes(`CREATE TABLE IF NOT EXISTS ${table}`) + ), + `missing CREATE TABLE for ${table}` + ); + } + }); + + it("issues one statement per table so D1 exec() line limits cannot bite", async () => { + await initSchema(db as any); + for (const statement of db.getDdlStatements()) { + const bodies = statement.match(/CREATE (TABLE|INDEX)/gi) ?? []; + assert.strictEqual( + bodies.length, + 1, + `expected a single statement, got: ${statement}` + ); + } }); }); diff --git a/packages/cloud-agent/src/storage.ts b/packages/cloud-agent/src/storage.ts index 8160143..8f55f79 100644 --- a/packages/cloud-agent/src/storage.ts +++ b/packages/cloud-agent/src/storage.ts @@ -23,11 +23,14 @@ function nowISO(): string { } /** - * Initialize the schema. Idempotent - safe to call on every request. + * Schema statements, one per entry. + * + * D1's exec() splits on newlines and rejects statements that span lines, so the + * schema is applied as individual prepared statements instead. prepare()/run() + * is the one path every backend implements. */ -export async function initSchema(db: CloudDatabase): Promise { - await db.exec(` - CREATE TABLE IF NOT EXISTS sessions ( +const SCHEMA_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT, status TEXT NOT NULL DEFAULT 'idle', @@ -35,45 +38,48 @@ export async function initSchema(db: CloudDatabase): Promise { continuation TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS events ( + )`, + `CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, type TEXT NOT NULL, payload TEXT, created_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS messages ( + )`, + `CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, created_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS activity_events ( + )`, + `CREATE TABLE IF NOT EXISTS activity_events ( id TEXT PRIMARY KEY, source TEXT NOT NULL, payload TEXT, occurred_at TEXT NOT NULL, ingested_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS summaries ( + )`, + `CREATE TABLE IF NOT EXISTS summaries ( id TEXT PRIMARY KEY, session_id TEXT, range_start TEXT NOT NULL, range_end TEXT NOT NULL, summary TEXT, created_at TEXT NOT NULL - ); + )`, + `CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id)`, + `CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)`, + `CREATE INDEX IF NOT EXISTS idx_activity_events_occurred ON activity_events(occurred_at)`, +]; - CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id); - CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); - CREATE INDEX IF NOT EXISTS idx_activity_events_occurred ON activity_events(occurred_at); - `); +/** + * Initialize the schema. Idempotent - safe to call on every request. + */ +export async function initSchema(db: CloudDatabase): Promise { + for (const statement of SCHEMA_STATEMENTS) { + await db.prepare(statement).run(); + } } /** From 6fd9969ffb41a6f3af203348321cfa7223cb8495 Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Tue, 11 Aug 2026 15:20:30 -0400 Subject: [PATCH 09/13] docs(chats): add Cursor review transcripts for cloud-agent PR Add two exported Cursor chat transcripts under chats/ so reviewers of PR #1 can follow the exploration and reasoning behind the cloud-agent and cloud-agent-cli packages. - chat2.md: latest update overview (package walkthrough + PR evaluation) - chat3.md: forked continuation of the same overview --- chats/chat2.md | 330 +++++++++++++++++++++++++++++++ chats/chat3.md | 527 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 857 insertions(+) create mode 100644 chats/chat2.md create mode 100644 chats/chat3.md diff --git a/chats/chat2.md b/chats/chat2.md new file mode 100644 index 0000000..ec52519 --- /dev/null +++ b/chats/chat2.md @@ -0,0 +1,330 @@ +# Latest update overview +_Exported on 8/11/2026 at 15:17:08 EDT from Cursor (3.14.7)_ + +--- + +**User** + +explain me the lastest update +everything and every file. + + +--- + +**Cursor** + +I'll read through all the new files in `vendor/cloud` (the latest, untracked additions per git status) and explain what they do. + +Now the CLI package files. + +Here's the full picture. The latest update adds two brand-new vendored packages under `vendor/cloud/packages/` — a server-side agent host (`@mieweb/cloud-agent`) and a client CLI (`@mieweb/cloud-agent-cli`) — plus an updated `pnpm-lock.yaml`. Together they form the runtime that Jerry's own CLI (`packages/cli/src/run.ts`) now talks to. Everything is untracked, so this is all new code not yet committed. + +## Package 1: `@mieweb/cloud-agent` — the server side + +This is an event-driven "agent host" that runs an AI agent inside a Cloudflare-style Durable Object, with queue-driven turns, suspend/resume, and scheduled alarms. It deliberately knows nothing about which LLM/model is used — the consumer supplies an `AgentRuntime`. + +**`src/types.ts` (255 lines)** — All the type definitions: +- `SessionStatus`: a session is `idle`, `running`, `waiting_for_user`, `waiting_for_approval`, or `scheduled`. +- `EventType` / `LifecycleEvent`: an append-only audit log of everything that happens (user messages, agent replies, wakes, errors, suspensions). +- `ContinuationState`: what gets persisted when the agent pauses mid-conversation waiting for a human (the pending question, timestamp, reason). +- `AgentRuntime` / `RuntimeEvent`: the pluggable LLM interface — `runTurn()` yields streaming events (`text-delta`, `tool-call`, `tool-result`, `finish`, `error`, `suspend`). +- `ToolContext`: what tools get at execution time — the DB, vector index, bucket, plus three superpowers: `scheduleWake()` (set a future alarm), `suspendForUser()`, and `suspendForApproval()`. +- `HostAgentConfig` / `HostAgentResult`: the input/output of the main `hostAgent()` entry point. + +**`src/storage.ts` (357 lines)** — the file you have open. All persistence, written against a D1-compatible `CloudDatabase`. It defines five tables: +- `sessions` — one row per conversation, with status and the serialized `continuation` JSON (the columns you highlighted on lines 36–38 are exactly the suspend/resume state: current status, a conversation ID, and the frozen continuation blob). +- `events` — the lifecycle log. +- `messages` — the chat history replayed to the model each turn. +- `activity_events` — activity data ingested from external collectors (e.g. ActivityWatch, judging by the test). +- `summaries` — time-ranged activity summaries. + +One notable design decision documented at the top: D1's `exec()` rejects multi-line statements, so the schema is applied as individual `prepare().run()` statements (`SCHEMA_STATEMENTS` array) instead of one big `exec()`. `initSchema()` is idempotent and called on every request. The rest is straightforward CRUD helpers (`getOrCreateSession`, `updateSessionStatus`, `insertEvent`, `insertMessage`, etc.) with JSON serialization of payloads. + +**`src/session.ts` (321 lines)** — the Durable Object itself. `createSessionClass()` returns an `AgentSession` class whose `fetch()` routes four internal paths: `/status`, `/message`, `/turn`, and `/alarm`. The heart is `handleTurn()`: +1. Rejects concurrent turns (409 if one is in progress). +2. Persists the incoming user message; if the session was suspended, logs a `resumed` event. +3. Marks the session `running`, loads full message history, builds the `ToolContext`, and streams the runtime's `runTurn()` — accumulating text, tracking which tools were called, and watching for `suspend` events. +4. On suspend: saves a `ContinuationState` and sets the session status to the suspend reason. On normal finish: saves the assistant message and returns to `idle`. Errors are logged as events and the session is reset to `idle`. + +The `alarm()` handler implements scheduled wakes: when a tool called `scheduleWake()`, the DO alarm fires later, logs a `scheduled_wake` event, and enqueues a new turn job. + +**`src/host.ts` (185 lines)** — the worker-level wiring. `hostAgent(config)` returns: +- `handleFetch`: the public HTTP API — `/health`, `POST /v1/events` (bulk activity-event ingestion from collectors), `POST /v1/sessions/:id/messages` (synchronous turn, forwarded to the DO), `POST /v1/sessions/:id/enqueue` (fire-and-forget via queue), and `GET .../status`. +- `handleQueue`: the queue consumer — forwards each `TurnJob` to the right DO, acking on success and retrying on failure. +- `handleScheduled`: a cron stub (just logs for now). + +**`src/index.ts`** — the public export surface, with a usage example in the docblock. + +**`src/storage.test.ts`** — tests using an in-memory `MockDatabase` that deliberately mimics D1's "exec rejects multi-line statements" behavior, verifying the schema is applied as one statement per table. (This file was created after the git-status snapshot, which is why it doesn't appear there.) + +**`package.json`** — publishes as `@mieweb/cloud-agent` v0.1.0, TypeScript-source-only exports, sole dependency is `@mieweb/cloud-types` (the workspace package providing the `Cloud*` abstractions like `CloudDatabase` and `CloudQueue`). + +## Package 2: `@mieweb/cloud-agent-cli` — the client side + +A "message-first" terminal client: anything you type that isn't a flag becomes the message, so `jerry summarize my last 2 hours` just works without quotes. + +**`src/parse.ts` + `src/parse.test.ts`** — argument parsing. Non-flag args are joined with spaces into a `call` command. Flags: `--help`/`-h`, `--version`/`-v`, `-txt`/`--put` (enqueue and return immediately), `--call`, `--debug`/`-d`, `--report` (stubbed), `--config`, `--session`/`-s`. Unknown flags are treated as message text rather than errors. The tests cover all of this plus session-variable precedence. + +**`src/env.ts` + `src/env.test.ts`** — per-agent environment namespacing. An agent named `assistant` reads `ASSISTANT_URL`/`ASSISTANT_SESSION` first, falling back to the shared `AGENT_URL`/`AGENT_SESSION`, so multiple agent CLIs can coexist in one shell. + +**`src/client.ts` (250 lines)** — the HTTP client matching the server routes above. `streamCall()` POSTs to `/v1/sessions/:id/messages` and handles both Server-Sent Events streaming and plain JSON responses, normalizing everything into a common `StreamEvent` type. `fireAndForget()` hits `/enqueue`; `getStatus()` hits `/status`. + +**`src/run.ts` (184 lines)** — the main loop. Resolves the base URL (config → env → `http://127.0.0.1:8787` default), dispatches the parsed command, streams the reply to stdout, and finishes with a footer showing the session ID and the tools used. It also warns if the model hit its output token limit (`finishReason === "length"`). + +**`src/types.ts`, `src/index.ts`, `bin/agent-cli.js`, `package.json`** — types, exports, and a generic `agent-cli` binary that derives the agent's name from its own filename (the busybox/git multicall pattern — symlink it as `foo` and it becomes the `foo` agent). + +## How it ties into Jerry + +Jerry's own CLI entry point is now a thin wrapper over this vendored package: + +```8:9:packages/cli/src/run.ts +import { run as agentRun } from "@mieweb/cloud-agent-cli"; +import { loadConfig } from "./profile.js"; +``` + +It loads the repo `.env`, handles the Jerry-specific `mcp` subcommand and `--approve` flag, then delegates to `agentRun({ agent: "jerry", ... })`. So the flow end to end is: **`jerry ` → cloud-agent-cli → HTTP → cloud-agent worker → queue/Durable Object → your `AgentRuntime` (the LLM) → streamed back to your terminal**, with the whole conversation, event log, and activity data persisted in the five SQLite/D1 tables from `storage.ts`. + +--- + +**User** + +https://github.com/mieweb/cloud/pull/1/ + +evaluate the most important parts of this PR and what parts do you think are unncessary + +--- + +**Cursor** + +I'll read the uploaded document and pull the PR details from GitHub. + +I've gone through the PR ([mieweb/cloud#1](https://github.com/mieweb/cloud/pull/1), ~2,700 additions across 23 files) alongside the actual code. Here's my evaluation. + +## Verdict in one line + +The core of this PR — the turn lifecycle in the Durable Object, the D1 schema fix, and the `createTools` injection point — is solid and genuinely load-bearing. The parts I'd trim are mostly speculative surface: stubbed CLI commands, Jerry-specific tables in a supposedly generic package, and a couple of dead exports. + +## The most important parts + +**1. The D1 prepared-statements fix (`46c5a4b`) is the single most valuable commit.** Before it, `initSchema()` used multi-line `exec()`, which D1 rejects — so literally every endpoint returned 500 on Cloudflare, and the local/libSQL conformance suites were structurally incapable of catching it. The fix itself is small, but the accompanying work is what makes it important: the test mock now rejects multi-line `exec()` the way D1 does (so the regression can't come back), and the `smoke/` Miniflare harness boots the host on real workerd with D1, Queues, and DO bindings. That harness is the only thing in the repo that tests the actual deploy target. + +**2. The turn lifecycle in `session.ts`.** This is the heart of the package: single-turn-at-a-time enforcement, message persistence, suspend/resume with `ContinuationState`, and — critically — the error paths hardened in `f3d3790`: sessions are reset to `idle` when a turn throws (instead of being stuck in `running` forever), and failed queue jobs are retried instead of acked away. Those two fixes prevent the two worst production failure modes of this design: permanently wedged sessions and silently dropped messages. + +**3. The `createTools(ctx)` factory and the `AgentRuntime` seam.** This is the architectural decision that makes the package worth having as a platform layer at all: model/provider selection and tool implementations stay in the consumer (Jerry), while the host provides `scheduleWake`, `suspendForUser`, and `suspendForApproval` through `ToolContext`. Without this, the package would just be Jerry's app code wearing a generic name. + +**4. The env-namespacing refactor (`e371f40`).** Removing hard-coded `JERRY_URL`/`JERRY_SESSION` and deriving the prefix from the agent name is what actually earns the "platform package" label. Small diff, right call, and behavior-preserving for the existing consumer. + +**5. The alarm-ordering fix (`f68bd1d`).** Creating the `scheduled_wake` event before enqueueing and only deleting `alarm_payload` after `JOBS.send()` succeeds is a subtle correctness fix — without it, a failed enqueue permanently loses the scheduled wake. + +## What I think is unnecessary + +**The `--report` command** is a stub that prints "Report mode not yet implemented." Dead surface area in a v0.1.0 — I'd delete the flag, its `ParsedCommand` variant, and its parse test until there's an implementation. Shipping a documented flag that does nothing is worse than not having it. + +**The `activity_events` and `summaries` tables don't belong in a generic agent host.** They exist for Jerry's activity-collector use case (the test literally uses an ActivityWatch payload), and the `POST /v1/events` bulk-ingestion route in `host.ts` exists only to feed them. Notably, `insertSummary` is exported but nothing in the host or session ever reads summaries back — there's no `getSummaries` at all. This is exactly the kind of consumer-specific code the `e371f40` commit removed from the env layer, still living in the storage layer. I'd move these into Jerry's app package (the `createTools` seam already gives tools direct `db` access, so nothing structural blocks that). + +**`getStatus` in `client.ts` is a dead export** — the CLI has no `--status` command that uses it. Either add the command (it'd be genuinely useful given the suspend/resume design — how else does a user know a session is `waiting_for_approval`?) or drop the function. + +**The `/alarm` POST route and `handleAlarmTrigger` in `session.ts`** duplicate what the platform-invoked `alarm()` already does. It reads like a manual-testing convenience; the smoke harness makes it redundant. + +**`handleScheduled`** just logs the cron string. It's harmless since the interface marks it optional, but returning a stub from `hostAgent()` implies functionality that doesn't exist. I'd omit it until there's a real cron use case. + +## Borderline, worth flagging rather than removing + +- **Two suspend mechanisms**: a runtime can suspend via the `RuntimeEvent` `suspend` event *or* tools can call `suspendForUser`/`suspendForApproval` on the context. Both write to the same DO fields, so it works, but one mechanism would be simpler and the docs don't say which is canonical. +- **Unknown flags become message text** (`jerry --sesion abc hi` sends the literal string `--sesion abc hi` to the LLM). Defensible for a message-first CLI, but it silently swallows typos of real flags. An "unknown flag" warning on stderr would cost nothing. +- **`initSchema()` runs on every request** — five `CREATE TABLE IF NOT EXISTS` plus three index statements per fetch, in both the worker and the DO. Idempotent but wasteful; a once-per-isolate guard would be a one-line improvement. +- **`profile?: unknown`** everywhere — already acknowledged in the PR thread as a known gap. Agreed it's fine to defer, but it's the loosest part of the wire contract. +- The **Drizzle suggestion from the reviewer was correctly declined** — for five tables and a dozen queries, an ORM would add dependency and migration overhead with no payoff. The written rationale in the thread is exactly right. + +Net: I'd merge this after deleting `--report`, the dead `getStatus`/`insertSummary` surface, and ideally relocating the activity/summary tables to Jerry — none of which changes the architecture, just tightens what v0.1.0 claims to support. + +--- + +**User** + +@vendor/cloud/packages/cloud-agent/src/storage.ts:41-44 is these in @vendor/cloud/packages/cloud-agent/src/storage.ts +highlight the lines for me + + + +--- + +**Cursor** + +Yes — those exact lines are in the file. They sit inside the `SCHEMA_STATEMENTS` array (lines 32–74): line 41 is the closing of the `sessions` table statement, and lines 42–44 are the start of the `events` table statement. Here they are (click the reference to jump to them in the editor): + +```41:44:vendor/cloud/packages/cloud-agent/src/storage.ts + )`, + `CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, +``` + +For context, the full `events` statement they open runs through line 48: + +```42:48:vendor/cloud/packages/cloud-agent/src/storage.ts + `CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT, + created_at TEXT NOT NULL + )`, +``` + +I can't control your editor's visual highlighting directly, but the code references above are clickable and will take you straight to those lines in `storage.ts`. + +--- + +**User** + +list all the file diff in @vendor/cloud/packages/cloud-agent/ +and why they were done? + +--- + +**Cursor** + +Let me check the git state of `vendor/cloud` — it's a submodule, so the diffs live in its own repo. + +Now the per-commit file changes so I can tie each diff to its reason. + +First, the state: the working tree in `vendor/cloud` is clean, so there are no uncommitted diffs. Everything in `packages/cloud-agent/` is committed work on the branch `jerry/toolsused-passthrough`, which sits 8 commits (plus 2 merges) ahead of `main`. The total diff vs `main` is **10 files, +1,505 lines, 0 deletions** — the package didn't exist on `main` at all. + +## The full diff vs `main`, file by file + +| File | Lines | Why it exists | +|---|---|---| +| `src/types.ts` | +254 | The contract for everything: session statuses, lifecycle events, `ContinuationState` for suspend/resume, the pluggable `AgentRuntime` interface, `ToolContext`, and the `hostAgent()` config/result types. | +| `src/storage.ts` | +356 | Persistence layer over a D1-compatible database — five tables (sessions, events, messages, activity_events, summaries) plus CRUD helpers. | +| `src/session.ts` | +320 | The Durable Object that owns one conversation: runs turns, streams runtime events, persists messages, and handles suspend/resume and alarms. | +| `src/host.ts` | +184 | Worker-level wiring: HTTP routes (`/health`, `/v1/events`, `/v1/sessions/:id/...`), queue consumer, and cron stub — everything `hostAgent()` returns. | +| `src/index.ts` | +59 | Public export surface of the package. | +| `src/storage.test.ts` | +208 | Tests with an in-memory mock database, including one that mimics D1's multi-line `exec()` rejection. | +| `package.json` | +41 | Package metadata for `@mieweb/cloud-agent` v0.1.0. | +| `smoke/worker.ts`, `smoke/wrangler.jsonc`, `smoke/README.md` | +83 | Miniflare smoke harness that boots the host on real workerd with D1/Queues/DO bindings. | + +## Why each commit was made, in order + +1. **`c154c39` — initial package.** Created the six `src/` files and `package.json`. This is the Phase 1 foundation for Jerry: an "event shell" that binds an agent definition and runtime to a Durable Object so conversations survive restarts and can be driven by a queue. + +2. **`ecb8aa7` — synchronous `--call` turns + `createTools` injection** (touched `host.ts`, `session.ts`, `types.ts`). Originally `POST /messages` only enqueued; you couldn't get an answer back in one request, which made the CLI's streaming `--call` mode useless. This made `/messages` execute the turn inline (keeping `/enqueue` for async), and added the `createTools(ctx)` factory so Jerry can build tools per-turn with DB/vector/alarm access instead of only static tools. + +3. **`ed36546` — add `tsx` devDependency** (`package.json`). Pure CI fix: tests run via `node --import tsx` but `tsx` wasn't declared, so clean installs failed the unit job. + +4. **`f3d3790` — fixes from the Copilot PR review** (`host.ts`, `session.ts`, `types.ts`). Four real bugs on the async path: the `/enqueue` route dropped `profile` and `userId` from the `TurnJob`; queued turns never actually persisted the new user message (that logic lived only in `handleMessage`, so it moved into `handleTurn`); a thrown turn left the session stuck in `running` forever (now reset to `idle` with an error event); and failed queue jobs were acked, silently dropping messages (now retried). + +5. **`f68bd1d` — harden alarms and event payloads** (`session.ts`, `storage.ts`). Two correctness fixes: `alarm()` now logs the `scheduled_wake` event first and only deletes `alarm_payload` after `JOBS.send()` succeeds, so a failed enqueue can be retried instead of losing the wake; and `insertEvent()` now only stores `NULL` for `undefined`, so falsy payloads like `0`, `false`, `""` are persisted instead of silently discarded. + +6. **`676c760` — return `toolsUsed` on turn replies** (`session.ts`). The reason this branch is named `jerry/toolsused-passthrough`: the turn loop now collects unique tool names as they're called and includes them in the JSON response, so the CLI can print its `tools: …` footer. + +7. **`e371f40` — remove agent-specific naming** (`index.ts`, `types.ts` here; the bulk was in `cloud-agent-cli`). The generic platform package hard-coded its one consumer — `@example` blocks and doc comments referenced Jerry, and the CLI read `JERRY_URL`/`JERRY_SESSION`. In this package the change was doc-only neutralization; the CLI side got the derived `envPrefix()` mechanism. + +8. **`46c5a4b` — schema via prepared statements** (`storage.ts`, `storage.test.ts`, new `smoke/`). The most important fix: D1's `exec()` splits on newlines and rejects multi-line statements, so `initSchema()` threw `D1_EXEC_ERROR` on every request — the package was completely broken on Cloudflare, while local SQLite and libSQL happily accepted the same SQL and hid the bug. The schema became the `SCHEMA_STATEMENTS` array applied one `prepare().run()` at a time (that's exactly the code around your cursor at lines 32–83), the test mock was changed to reject multi-line `exec()` the way D1 does so the old code would now fail the suite, and the `smoke/` harness was added so workerd is actually exercised going forward. + +The two merge commits (`ca1d1d3`, `436b045`) just brought `feature/cloud-agent` — the branch behind [PR #1](https://github.com/mieweb/cloud/pull/1) — into this working branch; they carry no changes of their own. + +--- + +**User** + +@vendor/cloud/packages/cloud-agent/src/storage.ts:25-76 is it specific to how jerry collects data + +--- + +**Cursor** + +Mostly no — but two of the five tables are, and I flagged this earlier as one of the questionable parts of the PR. + +**Generic (not Jerry-specific):** `sessions`, `events`, and `messages` (lines 33–55) plus their two indexes. Any agent built on this host needs them — they're what the turn lifecycle in `session.ts` reads and writes: session status and suspend/resume state, the lifecycle audit log, and the chat history replayed to the model each turn. Nothing about them assumes Jerry. + +**Jerry-shaped:** `activity_events` (lines 56–62) and `summaries` (lines 63–70). These exist for Jerry's activity-collection use case: + +- `activity_events` stores raw events pushed by an external collector via the `POST /v1/events` endpoint in `host.ts`. The columns give it away — `source`, `occurred_at` vs `ingested_at` — and the test in `storage.test.ts` uses an ActivityWatch payload (`source: "aw"`, `bucket: "aw-watcher-window"`). That's Jerry's "summarize my last 2 hours" feature. +- `summaries` stores time-ranged rollups of that activity (`range_start`/`range_end`, and the test writes `{ totalMinutes: 120 }`). Notably, the host itself never reads it back — `insertSummary` exists but there's no `getSummaries`. Only Jerry's tools would use it. + +So the schema isn't *specific to how* Jerry collects data in a technical sense — the tables are deliberately generic (`source` is just a string, `payload` is opaque JSON, so any collector could feed them). But their *reason for existing* is Jerry's activity-tracking feature, and no other hypothetical agent on this platform would need them. That's why the cleaner design would be moving those two tables and their three helpers (`insertActivityEvent`, `getActivityEvents`, `insertSummary`), plus the `/v1/events` route, into Jerry's app package — the `createTools(ctx)` factory already hands tools direct database access, so Jerry could own that schema without the platform package knowing about it. + +--- + +**User** + +List the files Cloud agent Cli package introduces and what issue they addressing? Is it necessary? + +--- + +**Cursor** + +The `@mieweb/cloud-agent-cli` package introduces 10 files (~870 lines). Here's each one, the problem it solves, and my take on whether it earns its place. + +## The files + +**`src/run.ts` (184 lines)** — The main entry point. Solves the core UX problem: how does a user talk to an agent from the terminal? It resolves the server URL (config → env var → `http://127.0.0.1:8787`), dispatches the parsed command, streams the reply to stdout as it arrives, and prints a footer with the session ID and tools used. It also warns when the model hit its output token limit. **Necessary** — this is the package's reason to exist. + +**`src/parse.ts` (141 lines)** — Argument parsing with a "message-first" philosophy: `jerry summarize my day` works without quotes because all non-flag args are joined into the message. Solves the friction of having to quote every message in a chat-style CLI. **Necessary**, though it carries two flags I'd cut: `--report` (parsed here, but `run.ts` just prints "not yet implemented") and arguably `--config` (dumps raw config JSON — a debugging convenience at best). + +**`src/client.ts` (250 lines)** — The HTTP client matching the server's routes. Solves the transport problem, and specifically the dual-protocol one: the server may answer with Server-Sent Events (streaming) or plain JSON, and this normalizes both into one `StreamEvent` type so `run.ts` doesn't care which it got. Also provides `fireAndForget()` for the `/enqueue` path. **Mostly necessary** — the SSE parser and normalizer are real work. The exception is `getStatus()`, which nothing calls; it's a dead export until someone adds a `--status` command. + +**`src/env.ts` (38 lines)** — Derives per-agent environment variable namespaces: an agent named `jerry` reads `JERRY_URL`/`JERRY_SESSION`, falling back to shared `AGENT_*`. Solves a real coupling bug: earlier versions hard-coded `JERRY_URL` inside this supposedly generic package, and this file (added in commit `e371f40`) is what removed the last functional "Jerry" reference. It also lets multiple agent CLIs coexist in one shell. **Necessary** — small and does exactly one job. + +**`src/types.ts` (51 lines)** — `CliConfig`, `ParsedCommand`, `CliOptions`, `StreamEvent`. Just the shared type contract between the other four files. **Necessary** by construction. + +**`src/index.ts` (32 lines)** — Public exports. Solves the wrapper use case: Jerry's own CLI (`packages/cli/src/run.ts` in the jerry repo) imports `run` from here and just supplies `{ agent: "jerry", baseUrl, profile }`. **Necessary.** + +**`bin/agent-cli.js` (15 lines)** — A generic executable that derives the agent name from its own filename (the busybox/git multicall pattern — symlink it as `foo` and it becomes the `foo` agent's CLI). Solves "try any agent without writing a wrapper package." **Nice-to-have, not necessary** — Jerry doesn't use it (it has its own wrapper), but at 15 lines it's cheap and useful for testing. + +**`src/parse.test.ts` (150 lines) + `src/env.test.ts` (58 lines)** — Tests covering flag parsing, message joining, edge cases (unknown flags become message text, `-txt` with no message shows help), and the scoped-vs-fallback env precedence. **Necessary** — parsing and env resolution are exactly the kind of logic that silently regresses. + +**`package.json`** — Package metadata, the `agent-cli` bin registration, and the test script. Necessary boilerplate. + +## Is the package as a whole necessary? + +Yes, as a separate package — the split is the right call. The alternative would be baking a CLI into each agent (Jerry would own ~870 lines of terminal code unrelated to its actual job) or putting it in `cloud-agent` (forcing the server-side package to carry Node-specific stdout/process code, which would break its zero-Node-imports property that matters for running on workerd). Keeping it separate means Jerry's CLI wrapper is 56 lines, and the next agent's wrapper will be about the same. + +Within the package, the genuinely unnecessary bits are small and all in the "surface area" category: the `--report` stub, the unused `getStatus()`, and debatably `--config`. Everything else is either load-bearing or a cheap convenience. + +--- + +**User** + +http://127.0.0.1:8787 why this specific URL? is it jerry related? +Also this separate CLI package helps jerry only? and not built as a generic cloud agent CLI for any agent? + +--- + +**Cursor** + +Let me quickly check how Jerry's wrapper resolves its URL, to give you the complete picture. + +Good questions — both have clean answers. + +## Why `http://127.0.0.1:8787`? + +It's not Jerry-related — **8787 is the default port of `wrangler dev`**, Cloudflare's local development server (Miniflare/workerd uses the same default). Since the agent host (`@mieweb/cloud-agent`) is built to run as a Cloudflare-style Worker, the natural local workflow is: run `pnpm dev` (which starts wrangler on `127.0.0.1:8787`), then point the CLI at it. So the default just means "talk to the agent worker you're running locally right now" with zero configuration. + +The default appears in two places, and both are ecosystem-derived rather than Jerry-derived: + +```10:10:vendor/cloud/packages/cloud-agent-cli/src/run.ts +const DEFAULT_BASE_URL = "http://127.0.0.1:8787"; +``` + +```27:27:packages/cli/src/profile.ts +const DEFAULT_URL = "http://127.0.0.1:8787"; +``` + +For anything other than local dev, it gets overridden — the generic CLI reads `_URL`/`AGENT_URL` env vars, and Jerry's wrapper additionally supports config files (`.jerry.json`, `~/.config/jerry/config.json`) and `JERRY_URL`, all taking precedence over the default. + +## Is the CLI package Jerry-only? + +No — it's deliberately built as a generic CLI for *any* agent on this platform, and Jerry just happens to be its first (and currently only) consumer. Three design choices make that concrete: + +1. **Agent identity is a parameter, not a constant.** Everything flows from `config.agent`: `run({ agent: "jerry", ... })` produces the Jerry CLI; `run({ agent: "assistant", ... })` would produce an entirely different agent's CLI with its own help text and defaults. + +2. **Env vars are namespaced per agent.** `env.ts` derives the prefix from the name — `jerry` reads `JERRY_URL`/`JERRY_SESSION`, `my-agent` reads `MY_AGENT_URL` — with shared `AGENT_*` as fallback, so several agent CLIs can coexist in one shell. + +3. **The multicall binary.** `bin/agent-cli.js` takes the agent name from its own filename, so symlinking it as `foo` gives you a working `foo` CLI without writing any wrapper code at all. + +It's worth noting this genericity was *earned*, not there from day one: the original commits hard-coded `JERRY_URL`/`JERRY_SESSION` inside the package, and the `e371f40` refactor ("remove agent-specific naming from the host layer") is what replaced those with the derived-prefix mechanism. After that commit, `rg -i jerry` in the package returns nothing. + +The relationship is: **the CLI package is the generic engine; Jerry's `packages/cli` is a thin 56-line skin over it** — adding only Jerry-specific concerns like `.jerry.json` config files, the privacy-profile resolution you see in `profile.ts` (runtime/model/API-key selection for Ollama vs Ozwell vs BYO-cloud), the `--approve` flag, and the `jerry mcp` subcommand. A second agent tomorrow would write its own equally thin wrapper and reuse everything else. + diff --git a/chats/chat3.md b/chats/chat3.md new file mode 100644 index 0000000..9a20a1d --- /dev/null +++ b/chats/chat3.md @@ -0,0 +1,527 @@ +# (Fork) Latest update overview +_Exported on 8/11/2026 at 15:17:45 EDT from Cursor (3.14.7)_ + +--- + +**User** + +explain me the lastest update +everything and every file. + + +--- + +**Cursor** + +I'll read through all the new files in `vendor/cloud` (the latest, untracked additions per git status) and explain what they do. + +Now the CLI package files. + +Here's the full picture. The latest update adds two brand-new vendored packages under `vendor/cloud/packages/` — a server-side agent host (`@mieweb/cloud-agent`) and a client CLI (`@mieweb/cloud-agent-cli`) — plus an updated `pnpm-lock.yaml`. Together they form the runtime that Jerry's own CLI (`packages/cli/src/run.ts`) now talks to. Everything is untracked, so this is all new code not yet committed. + +## Package 1: `@mieweb/cloud-agent` — the server side + +This is an event-driven "agent host" that runs an AI agent inside a Cloudflare-style Durable Object, with queue-driven turns, suspend/resume, and scheduled alarms. It deliberately knows nothing about which LLM/model is used — the consumer supplies an `AgentRuntime`. + +**`src/types.ts` (255 lines)** — All the type definitions: +- `SessionStatus`: a session is `idle`, `running`, `waiting_for_user`, `waiting_for_approval`, or `scheduled`. +- `EventType` / `LifecycleEvent`: an append-only audit log of everything that happens (user messages, agent replies, wakes, errors, suspensions). +- `ContinuationState`: what gets persisted when the agent pauses mid-conversation waiting for a human (the pending question, timestamp, reason). +- `AgentRuntime` / `RuntimeEvent`: the pluggable LLM interface — `runTurn()` yields streaming events (`text-delta`, `tool-call`, `tool-result`, `finish`, `error`, `suspend`). +- `ToolContext`: what tools get at execution time — the DB, vector index, bucket, plus three superpowers: `scheduleWake()` (set a future alarm), `suspendForUser()`, and `suspendForApproval()`. +- `HostAgentConfig` / `HostAgentResult`: the input/output of the main `hostAgent()` entry point. + +**`src/storage.ts` (357 lines)** — the file you have open. All persistence, written against a D1-compatible `CloudDatabase`. It defines five tables: +- `sessions` — one row per conversation, with status and the serialized `continuation` JSON (the columns you highlighted on lines 36–38 are exactly the suspend/resume state: current status, a conversation ID, and the frozen continuation blob). +- `events` — the lifecycle log. +- `messages` — the chat history replayed to the model each turn. +- `activity_events` — activity data ingested from external collectors (e.g. ActivityWatch, judging by the test). +- `summaries` — time-ranged activity summaries. + +One notable design decision documented at the top: D1's `exec()` rejects multi-line statements, so the schema is applied as individual `prepare().run()` statements (`SCHEMA_STATEMENTS` array) instead of one big `exec()`. `initSchema()` is idempotent and called on every request. The rest is straightforward CRUD helpers (`getOrCreateSession`, `updateSessionStatus`, `insertEvent`, `insertMessage`, etc.) with JSON serialization of payloads. + +**`src/session.ts` (321 lines)** — the Durable Object itself. `createSessionClass()` returns an `AgentSession` class whose `fetch()` routes four internal paths: `/status`, `/message`, `/turn`, and `/alarm`. The heart is `handleTurn()`: +1. Rejects concurrent turns (409 if one is in progress). +2. Persists the incoming user message; if the session was suspended, logs a `resumed` event. +3. Marks the session `running`, loads full message history, builds the `ToolContext`, and streams the runtime's `runTurn()` — accumulating text, tracking which tools were called, and watching for `suspend` events. +4. On suspend: saves a `ContinuationState` and sets the session status to the suspend reason. On normal finish: saves the assistant message and returns to `idle`. Errors are logged as events and the session is reset to `idle`. + +The `alarm()` handler implements scheduled wakes: when a tool called `scheduleWake()`, the DO alarm fires later, logs a `scheduled_wake` event, and enqueues a new turn job. + +**`src/host.ts` (185 lines)** — the worker-level wiring. `hostAgent(config)` returns: +- `handleFetch`: the public HTTP API — `/health`, `POST /v1/events` (bulk activity-event ingestion from collectors), `POST /v1/sessions/:id/messages` (synchronous turn, forwarded to the DO), `POST /v1/sessions/:id/enqueue` (fire-and-forget via queue), and `GET .../status`. +- `handleQueue`: the queue consumer — forwards each `TurnJob` to the right DO, acking on success and retrying on failure. +- `handleScheduled`: a cron stub (just logs for now). + +**`src/index.ts`** — the public export surface, with a usage example in the docblock. + +**`src/storage.test.ts`** — tests using an in-memory `MockDatabase` that deliberately mimics D1's "exec rejects multi-line statements" behavior, verifying the schema is applied as one statement per table. (This file was created after the git-status snapshot, which is why it doesn't appear there.) + +**`package.json`** — publishes as `@mieweb/cloud-agent` v0.1.0, TypeScript-source-only exports, sole dependency is `@mieweb/cloud-types` (the workspace package providing the `Cloud*` abstractions like `CloudDatabase` and `CloudQueue`). + +## Package 2: `@mieweb/cloud-agent-cli` — the client side + +A "message-first" terminal client: anything you type that isn't a flag becomes the message, so `jerry summarize my last 2 hours` just works without quotes. + +**`src/parse.ts` + `src/parse.test.ts`** — argument parsing. Non-flag args are joined with spaces into a `call` command. Flags: `--help`/`-h`, `--version`/`-v`, `-txt`/`--put` (enqueue and return immediately), `--call`, `--debug`/`-d`, `--report` (stubbed), `--config`, `--session`/`-s`. Unknown flags are treated as message text rather than errors. The tests cover all of this plus session-variable precedence. + +**`src/env.ts` + `src/env.test.ts`** — per-agent environment namespacing. An agent named `assistant` reads `ASSISTANT_URL`/`ASSISTANT_SESSION` first, falling back to the shared `AGENT_URL`/`AGENT_SESSION`, so multiple agent CLIs can coexist in one shell. + +**`src/client.ts` (250 lines)** — the HTTP client matching the server routes above. `streamCall()` POSTs to `/v1/sessions/:id/messages` and handles both Server-Sent Events streaming and plain JSON responses, normalizing everything into a common `StreamEvent` type. `fireAndForget()` hits `/enqueue`; `getStatus()` hits `/status`. + +**`src/run.ts` (184 lines)** — the main loop. Resolves the base URL (config → env → `http://127.0.0.1:8787` default), dispatches the parsed command, streams the reply to stdout, and finishes with a footer showing the session ID and the tools used. It also warns if the model hit its output token limit (`finishReason === "length"`). + +**`src/types.ts`, `src/index.ts`, `bin/agent-cli.js`, `package.json`** — types, exports, and a generic `agent-cli` binary that derives the agent's name from its own filename (the busybox/git multicall pattern — symlink it as `foo` and it becomes the `foo` agent). + +## How it ties into Jerry + +Jerry's own CLI entry point is now a thin wrapper over this vendored package: + +```8:9:packages/cli/src/run.ts +import { run as agentRun } from "@mieweb/cloud-agent-cli"; +import { loadConfig } from "./profile.js"; +``` + +It loads the repo `.env`, handles the Jerry-specific `mcp` subcommand and `--approve` flag, then delegates to `agentRun({ agent: "jerry", ... })`. So the flow end to end is: **`jerry ` → cloud-agent-cli → HTTP → cloud-agent worker → queue/Durable Object → your `AgentRuntime` (the LLM) → streamed back to your terminal**, with the whole conversation, event log, and activity data persisted in the five SQLite/D1 tables from `storage.ts`. + +--- + +**User** + +https://github.com/mieweb/cloud/pull/1/ + +evaluate the most important parts of this PR and what parts do you think are unncessary + +--- + +**Cursor** + +I'll read the uploaded document and pull the PR details from GitHub. + +I've gone through the PR ([mieweb/cloud#1](https://github.com/mieweb/cloud/pull/1), ~2,700 additions across 23 files) alongside the actual code. Here's my evaluation. + +## Verdict in one line + +The core of this PR — the turn lifecycle in the Durable Object, the D1 schema fix, and the `createTools` injection point — is solid and genuinely load-bearing. The parts I'd trim are mostly speculative surface: stubbed CLI commands, Jerry-specific tables in a supposedly generic package, and a couple of dead exports. + +## The most important parts + +**1. The D1 prepared-statements fix (`46c5a4b`) is the single most valuable commit.** Before it, `initSchema()` used multi-line `exec()`, which D1 rejects — so literally every endpoint returned 500 on Cloudflare, and the local/libSQL conformance suites were structurally incapable of catching it. The fix itself is small, but the accompanying work is what makes it important: the test mock now rejects multi-line `exec()` the way D1 does (so the regression can't come back), and the `smoke/` Miniflare harness boots the host on real workerd with D1, Queues, and DO bindings. That harness is the only thing in the repo that tests the actual deploy target. + +**2. The turn lifecycle in `session.ts`.** This is the heart of the package: single-turn-at-a-time enforcement, message persistence, suspend/resume with `ContinuationState`, and — critically — the error paths hardened in `f3d3790`: sessions are reset to `idle` when a turn throws (instead of being stuck in `running` forever), and failed queue jobs are retried instead of acked away. Those two fixes prevent the two worst production failure modes of this design: permanently wedged sessions and silently dropped messages. + +**3. The `createTools(ctx)` factory and the `AgentRuntime` seam.** This is the architectural decision that makes the package worth having as a platform layer at all: model/provider selection and tool implementations stay in the consumer (Jerry), while the host provides `scheduleWake`, `suspendForUser`, and `suspendForApproval` through `ToolContext`. Without this, the package would just be Jerry's app code wearing a generic name. + +**4. The env-namespacing refactor (`e371f40`).** Removing hard-coded `JERRY_URL`/`JERRY_SESSION` and deriving the prefix from the agent name is what actually earns the "platform package" label. Small diff, right call, and behavior-preserving for the existing consumer. + +**5. The alarm-ordering fix (`f68bd1d`).** Creating the `scheduled_wake` event before enqueueing and only deleting `alarm_payload` after `JOBS.send()` succeeds is a subtle correctness fix — without it, a failed enqueue permanently loses the scheduled wake. + +## What I think is unnecessary + +**The `--report` command** is a stub that prints "Report mode not yet implemented." Dead surface area in a v0.1.0 — I'd delete the flag, its `ParsedCommand` variant, and its parse test until there's an implementation. Shipping a documented flag that does nothing is worse than not having it. + +**The `activity_events` and `summaries` tables don't belong in a generic agent host.** They exist for Jerry's activity-collector use case (the test literally uses an ActivityWatch payload), and the `POST /v1/events` bulk-ingestion route in `host.ts` exists only to feed them. Notably, `insertSummary` is exported but nothing in the host or session ever reads summaries back — there's no `getSummaries` at all. This is exactly the kind of consumer-specific code the `e371f40` commit removed from the env layer, still living in the storage layer. I'd move these into Jerry's app package (the `createTools` seam already gives tools direct `db` access, so nothing structural blocks that). + +**`getStatus` in `client.ts` is a dead export** — the CLI has no `--status` command that uses it. Either add the command (it'd be genuinely useful given the suspend/resume design — how else does a user know a session is `waiting_for_approval`?) or drop the function. + +**The `/alarm` POST route and `handleAlarmTrigger` in `session.ts`** duplicate what the platform-invoked `alarm()` already does. It reads like a manual-testing convenience; the smoke harness makes it redundant. + +**`handleScheduled`** just logs the cron string. It's harmless since the interface marks it optional, but returning a stub from `hostAgent()` implies functionality that doesn't exist. I'd omit it until there's a real cron use case. + +## Borderline, worth flagging rather than removing + +- **Two suspend mechanisms**: a runtime can suspend via the `RuntimeEvent` `suspend` event *or* tools can call `suspendForUser`/`suspendForApproval` on the context. Both write to the same DO fields, so it works, but one mechanism would be simpler and the docs don't say which is canonical. +- **Unknown flags become message text** (`jerry --sesion abc hi` sends the literal string `--sesion abc hi` to the LLM). Defensible for a message-first CLI, but it silently swallows typos of real flags. An "unknown flag" warning on stderr would cost nothing. +- **`initSchema()` runs on every request** — five `CREATE TABLE IF NOT EXISTS` plus three index statements per fetch, in both the worker and the DO. Idempotent but wasteful; a once-per-isolate guard would be a one-line improvement. +- **`profile?: unknown`** everywhere — already acknowledged in the PR thread as a known gap. Agreed it's fine to defer, but it's the loosest part of the wire contract. +- The **Drizzle suggestion from the reviewer was correctly declined** — for five tables and a dozen queries, an ORM would add dependency and migration overhead with no payoff. The written rationale in the thread is exactly right. + +Net: I'd merge this after deleting `--report`, the dead `getStatus`/`insertSummary` surface, and ideally relocating the activity/summary tables to Jerry — none of which changes the architecture, just tightens what v0.1.0 claims to support. + +--- + +**User** + +@vendor/cloud/packages/cloud-agent/src/storage.ts:41-44 is these in @vendor/cloud/packages/cloud-agent/src/storage.ts +highlight the lines for me + + + +--- + +**Cursor** + +Yes — those exact lines are in the file. They sit inside the `SCHEMA_STATEMENTS` array (lines 32–74): line 41 is the closing of the `sessions` table statement, and lines 42–44 are the start of the `events` table statement. Here they are (click the reference to jump to them in the editor): + +```41:44:vendor/cloud/packages/cloud-agent/src/storage.ts + )`, + `CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, +``` + +For context, the full `events` statement they open runs through line 48: + +```42:48:vendor/cloud/packages/cloud-agent/src/storage.ts + `CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT, + created_at TEXT NOT NULL + )`, +``` + +I can't control your editor's visual highlighting directly, but the code references above are clickable and will take you straight to those lines in `storage.ts`. + +--- + +**User** + +list all the file diff in @vendor/cloud/packages/cloud-agent/ +and why they were done? + +--- + +**Cursor** + +Let me check the git state of `vendor/cloud` — it's a submodule, so the diffs live in its own repo. + +Now the per-commit file changes so I can tie each diff to its reason. + +First, the state: the working tree in `vendor/cloud` is clean, so there are no uncommitted diffs. Everything in `packages/cloud-agent/` is committed work on the branch `jerry/toolsused-passthrough`, which sits 8 commits (plus 2 merges) ahead of `main`. The total diff vs `main` is **10 files, +1,505 lines, 0 deletions** — the package didn't exist on `main` at all. + +## The full diff vs `main`, file by file + +| File | Lines | Why it exists | +|---|---|---| +| `src/types.ts` | +254 | The contract for everything: session statuses, lifecycle events, `ContinuationState` for suspend/resume, the pluggable `AgentRuntime` interface, `ToolContext`, and the `hostAgent()` config/result types. | +| `src/storage.ts` | +356 | Persistence layer over a D1-compatible database — five tables (sessions, events, messages, activity_events, summaries) plus CRUD helpers. | +| `src/session.ts` | +320 | The Durable Object that owns one conversation: runs turns, streams runtime events, persists messages, and handles suspend/resume and alarms. | +| `src/host.ts` | +184 | Worker-level wiring: HTTP routes (`/health`, `/v1/events`, `/v1/sessions/:id/...`), queue consumer, and cron stub — everything `hostAgent()` returns. | +| `src/index.ts` | +59 | Public export surface of the package. | +| `src/storage.test.ts` | +208 | Tests with an in-memory mock database, including one that mimics D1's multi-line `exec()` rejection. | +| `package.json` | +41 | Package metadata for `@mieweb/cloud-agent` v0.1.0. | +| `smoke/worker.ts`, `smoke/wrangler.jsonc`, `smoke/README.md` | +83 | Miniflare smoke harness that boots the host on real workerd with D1/Queues/DO bindings. | + +## Why each commit was made, in order + +1. **`c154c39` — initial package.** Created the six `src/` files and `package.json`. This is the Phase 1 foundation for Jerry: an "event shell" that binds an agent definition and runtime to a Durable Object so conversations survive restarts and can be driven by a queue. + +2. **`ecb8aa7` — synchronous `--call` turns + `createTools` injection** (touched `host.ts`, `session.ts`, `types.ts`). Originally `POST /messages` only enqueued; you couldn't get an answer back in one request, which made the CLI's streaming `--call` mode useless. This made `/messages` execute the turn inline (keeping `/enqueue` for async), and added the `createTools(ctx)` factory so Jerry can build tools per-turn with DB/vector/alarm access instead of only static tools. + +3. **`ed36546` — add `tsx` devDependency** (`package.json`). Pure CI fix: tests run via `node --import tsx` but `tsx` wasn't declared, so clean installs failed the unit job. + +4. **`f3d3790` — fixes from the Copilot PR review** (`host.ts`, `session.ts`, `types.ts`). Four real bugs on the async path: the `/enqueue` route dropped `profile` and `userId` from the `TurnJob`; queued turns never actually persisted the new user message (that logic lived only in `handleMessage`, so it moved into `handleTurn`); a thrown turn left the session stuck in `running` forever (now reset to `idle` with an error event); and failed queue jobs were acked, silently dropping messages (now retried). + +5. **`f68bd1d` — harden alarms and event payloads** (`session.ts`, `storage.ts`). Two correctness fixes: `alarm()` now logs the `scheduled_wake` event first and only deletes `alarm_payload` after `JOBS.send()` succeeds, so a failed enqueue can be retried instead of losing the wake; and `insertEvent()` now only stores `NULL` for `undefined`, so falsy payloads like `0`, `false`, `""` are persisted instead of silently discarded. + +6. **`676c760` — return `toolsUsed` on turn replies** (`session.ts`). The reason this branch is named `jerry/toolsused-passthrough`: the turn loop now collects unique tool names as they're called and includes them in the JSON response, so the CLI can print its `tools: …` footer. + +7. **`e371f40` — remove agent-specific naming** (`index.ts`, `types.ts` here; the bulk was in `cloud-agent-cli`). The generic platform package hard-coded its one consumer — `@example` blocks and doc comments referenced Jerry, and the CLI read `JERRY_URL`/`JERRY_SESSION`. In this package the change was doc-only neutralization; the CLI side got the derived `envPrefix()` mechanism. + +8. **`46c5a4b` — schema via prepared statements** (`storage.ts`, `storage.test.ts`, new `smoke/`). The most important fix: D1's `exec()` splits on newlines and rejects multi-line statements, so `initSchema()` threw `D1_EXEC_ERROR` on every request — the package was completely broken on Cloudflare, while local SQLite and libSQL happily accepted the same SQL and hid the bug. The schema became the `SCHEMA_STATEMENTS` array applied one `prepare().run()` at a time (that's exactly the code around your cursor at lines 32–83), the test mock was changed to reject multi-line `exec()` the way D1 does so the old code would now fail the suite, and the `smoke/` harness was added so workerd is actually exercised going forward. + +The two merge commits (`ca1d1d3`, `436b045`) just brought `feature/cloud-agent` — the branch behind [PR #1](https://github.com/mieweb/cloud/pull/1) — into this working branch; they carry no changes of their own. + +--- + +**User** + +@vendor/cloud/packages/cloud-agent/src/storage.ts:25-76 is it specific to how jerry collects data + +--- + +**Cursor** + +Mostly no — but two of the five tables are, and I flagged this earlier as one of the questionable parts of the PR. + +**Generic (not Jerry-specific):** `sessions`, `events`, and `messages` (lines 33–55) plus their two indexes. Any agent built on this host needs them — they're what the turn lifecycle in `session.ts` reads and writes: session status and suspend/resume state, the lifecycle audit log, and the chat history replayed to the model each turn. Nothing about them assumes Jerry. + +**Jerry-shaped:** `activity_events` (lines 56–62) and `summaries` (lines 63–70). These exist for Jerry's activity-collection use case: + +- `activity_events` stores raw events pushed by an external collector via the `POST /v1/events` endpoint in `host.ts`. The columns give it away — `source`, `occurred_at` vs `ingested_at` — and the test in `storage.test.ts` uses an ActivityWatch payload (`source: "aw"`, `bucket: "aw-watcher-window"`). That's Jerry's "summarize my last 2 hours" feature. +- `summaries` stores time-ranged rollups of that activity (`range_start`/`range_end`, and the test writes `{ totalMinutes: 120 }`). Notably, the host itself never reads it back — `insertSummary` exists but there's no `getSummaries`. Only Jerry's tools would use it. + +So the schema isn't *specific to how* Jerry collects data in a technical sense — the tables are deliberately generic (`source` is just a string, `payload` is opaque JSON, so any collector could feed them). But their *reason for existing* is Jerry's activity-tracking feature, and no other hypothetical agent on this platform would need them. That's why the cleaner design would be moving those two tables and their three helpers (`insertActivityEvent`, `getActivityEvents`, `insertSummary`), plus the `/v1/events` route, into Jerry's app package — the `createTools(ctx)` factory already hands tools direct database access, so Jerry could own that schema without the platform package knowing about it. + +--- + +**User** + +List the files Cloud agent Cli package introduces and what issue they addressing? Is it necessary? + +--- + +**Cursor** + +The `@mieweb/cloud-agent-cli` package introduces 10 files (~870 lines). Here's each one, the problem it solves, and my take on whether it earns its place. + +## The files + +**`src/run.ts` (184 lines)** — The main entry point. Solves the core UX problem: how does a user talk to an agent from the terminal? It resolves the server URL (config → env var → `http://127.0.0.1:8787`), dispatches the parsed command, streams the reply to stdout as it arrives, and prints a footer with the session ID and tools used. It also warns when the model hit its output token limit. **Necessary** — this is the package's reason to exist. + +**`src/parse.ts` (141 lines)** — Argument parsing with a "message-first" philosophy: `jerry summarize my day` works without quotes because all non-flag args are joined into the message. Solves the friction of having to quote every message in a chat-style CLI. **Necessary**, though it carries two flags I'd cut: `--report` (parsed here, but `run.ts` just prints "not yet implemented") and arguably `--config` (dumps raw config JSON — a debugging convenience at best). + +**`src/client.ts` (250 lines)** — The HTTP client matching the server's routes. Solves the transport problem, and specifically the dual-protocol one: the server may answer with Server-Sent Events (streaming) or plain JSON, and this normalizes both into one `StreamEvent` type so `run.ts` doesn't care which it got. Also provides `fireAndForget()` for the `/enqueue` path. **Mostly necessary** — the SSE parser and normalizer are real work. The exception is `getStatus()`, which nothing calls; it's a dead export until someone adds a `--status` command. + +**`src/env.ts` (38 lines)** — Derives per-agent environment variable namespaces: an agent named `jerry` reads `JERRY_URL`/`JERRY_SESSION`, falling back to shared `AGENT_*`. Solves a real coupling bug: earlier versions hard-coded `JERRY_URL` inside this supposedly generic package, and this file (added in commit `e371f40`) is what removed the last functional "Jerry" reference. It also lets multiple agent CLIs coexist in one shell. **Necessary** — small and does exactly one job. + +**`src/types.ts` (51 lines)** — `CliConfig`, `ParsedCommand`, `CliOptions`, `StreamEvent`. Just the shared type contract between the other four files. **Necessary** by construction. + +**`src/index.ts` (32 lines)** — Public exports. Solves the wrapper use case: Jerry's own CLI (`packages/cli/src/run.ts` in the jerry repo) imports `run` from here and just supplies `{ agent: "jerry", baseUrl, profile }`. **Necessary.** + +**`bin/agent-cli.js` (15 lines)** — A generic executable that derives the agent name from its own filename (the busybox/git multicall pattern — symlink it as `foo` and it becomes the `foo` agent's CLI). Solves "try any agent without writing a wrapper package." **Nice-to-have, not necessary** — Jerry doesn't use it (it has its own wrapper), but at 15 lines it's cheap and useful for testing. + +**`src/parse.test.ts` (150 lines) + `src/env.test.ts` (58 lines)** — Tests covering flag parsing, message joining, edge cases (unknown flags become message text, `-txt` with no message shows help), and the scoped-vs-fallback env precedence. **Necessary** — parsing and env resolution are exactly the kind of logic that silently regresses. + +**`package.json`** — Package metadata, the `agent-cli` bin registration, and the test script. Necessary boilerplate. + +## Is the package as a whole necessary? + +Yes, as a separate package — the split is the right call. The alternative would be baking a CLI into each agent (Jerry would own ~870 lines of terminal code unrelated to its actual job) or putting it in `cloud-agent` (forcing the server-side package to carry Node-specific stdout/process code, which would break its zero-Node-imports property that matters for running on workerd). Keeping it separate means Jerry's CLI wrapper is 56 lines, and the next agent's wrapper will be about the same. + +Within the package, the genuinely unnecessary bits are small and all in the "surface area" category: the `--report` stub, the unused `getStatus()`, and debatably `--config`. Everything else is either load-bearing or a cheap convenience. + +--- + +**User** + +http://127.0.0.1:8787 why this specific URL? is it jerry related? +Also this separate CLI package helps jerry only? and not built as a generic cloud agent CLI for any agent? + +--- + +**Cursor** + +Let me quickly check how Jerry's wrapper resolves its URL, to give you the complete picture. + +Good questions — both have clean answers. + +## Why `http://127.0.0.1:8787`? + +It's not Jerry-related — **8787 is the default port of `wrangler dev`**, Cloudflare's local development server (Miniflare/workerd uses the same default). Since the agent host (`@mieweb/cloud-agent`) is built to run as a Cloudflare-style Worker, the natural local workflow is: run `pnpm dev` (which starts wrangler on `127.0.0.1:8787`), then point the CLI at it. So the default just means "talk to the agent worker you're running locally right now" with zero configuration. + +The default appears in two places, and both are ecosystem-derived rather than Jerry-derived: + +```10:10:vendor/cloud/packages/cloud-agent-cli/src/run.ts +const DEFAULT_BASE_URL = "http://127.0.0.1:8787"; +``` + +```27:27:packages/cli/src/profile.ts +const DEFAULT_URL = "http://127.0.0.1:8787"; +``` + +For anything other than local dev, it gets overridden — the generic CLI reads `_URL`/`AGENT_URL` env vars, and Jerry's wrapper additionally supports config files (`.jerry.json`, `~/.config/jerry/config.json`) and `JERRY_URL`, all taking precedence over the default. + +## Is the CLI package Jerry-only? + +No — it's deliberately built as a generic CLI for *any* agent on this platform, and Jerry just happens to be its first (and currently only) consumer. Three design choices make that concrete: + +1. **Agent identity is a parameter, not a constant.** Everything flows from `config.agent`: `run({ agent: "jerry", ... })` produces the Jerry CLI; `run({ agent: "assistant", ... })` would produce an entirely different agent's CLI with its own help text and defaults. + +2. **Env vars are namespaced per agent.** `env.ts` derives the prefix from the name — `jerry` reads `JERRY_URL`/`JERRY_SESSION`, `my-agent` reads `MY_AGENT_URL` — with shared `AGENT_*` as fallback, so several agent CLIs can coexist in one shell. + +3. **The multicall binary.** `bin/agent-cli.js` takes the agent name from its own filename, so symlinking it as `foo` gives you a working `foo` CLI without writing any wrapper code at all. + +It's worth noting this genericity was *earned*, not there from day one: the original commits hard-coded `JERRY_URL`/`JERRY_SESSION` inside the package, and the `e371f40` refactor ("remove agent-specific naming from the host layer") is what replaced those with the derived-prefix mechanism. After that commit, `rg -i jerry` in the package returns nothing. + +The relationship is: **the CLI package is the generic engine; Jerry's `packages/cli` is a thin 56-line skin over it** — adding only Jerry-specific concerns like `.jerry.json` config files, the privacy-profile resolution you see in `profile.ts` (runtime/model/API-key selection for Ollama vs Ozwell vs BYO-cloud), the `--approve` flag, and the `jerry mcp` subcommand. A second agent tomorrow would write its own equally thin wrapper and reuse everything else. + +--- + +**User** + +Is this a good PR? How will you evaluate it? + +--- + +**Cursor** + +Yes — on balance this is a good PR, and I'd say a notably good one for an early-stage repo. Let me show you how I'd evaluate it, because the framework matters more than the verdict. + +## My evaluation framework + +For a PR that introduces a new platform package, I'd judge it on six dimensions: **scope discipline** (does it do one coherent thing?), **architectural fit** (does it belong in this repo, at this layer?), **correctness under failure** (not just the happy path), **testing against reality** (does it test what will actually run in production?), **review responsiveness** (what happened between first push and now?), and **restraint** (what it wisely *didn't* do). + +## How this PR scores + +**Scope discipline — good.** One coherent deliverable: an event-driven agent host plus its terminal client, ~2,700 lines. The two packages are genuinely one feature (server and client of the same protocol), so bundling them is right. It didn't sneak in unrelated refactors. + +**Architectural fit — very good, and it improved during review.** The key decision — model/provider selection stays out of the host via the `AgentRuntime` and `createTools` seams — is what makes this a platform package rather than Jerry's app code in disguise. And when the abstraction leaked (hard-coded `JERRY_URL`), it got fixed properly with derived env namespacing rather than a rename. The one remaining leak is the `activity_events`/`summaries` tables, which are Jerry's use case living in a generic package — my main criticism, but a movable one, not a structural flaw. + +**Correctness under failure — good, but only after iteration.** The initial commits had the classic first-draft gaps: sessions stuck in `running` after a crash, failed queue jobs acked and lost, alarms deleted before enqueue succeeded, falsy payloads dropped. All four were fixed in `f3d3790` and `f68bd1d`. First drafts having these bugs is normal; what distinguishes a good PR is that they were found and fixed *before* merge rather than in production. + +**Testing against reality — this is where the PR is genuinely impressive.** The `initSchema` saga is the highlight: the code passed unit tests, passed local SQLite, passed the full libSQL/Valkey/MinIO conformance suite — and was still 100% broken on Cloudflare, because D1's `exec()` rejects multi-line SQL and no test exercised real workerd. The author didn't just patch it; they made the mock reject multi-line `exec()` the way D1 does (so the old code would now *fail* the suite), added a Miniflare smoke harness with real D1/Queue/DO bindings, and wrote up why every other test layer was structurally incapable of catching it. That's the difference between "fixed a bug" and "closed the class of bug." + +**Review responsiveness — excellent, arguably the PR's best quality.** Every reviewer thread got a substantive response: the Drizzle ORM suggestion was evaluated and declined with a correct rationale (five tables don't justify an ORM, and if it comes it should be a platform concern), the Copilot findings were fixed in dedicated commits with explanatory messages, and the vague "something's wrong with Cloudflare interop" concern was run to ground until it produced the D1 bug. The PR comments read like an audit trail — someone joining the project could reconstruct every decision from the thread. + +**Restraint — mixed.** Good calls: no ORM, no premature `ModelPolicy` typing (explicitly deferred with reasoning), `profile` kept opaque until the wire contract stabilizes. Weaker calls: the `--report` stub, the unused `getStatus()` export, `handleScheduled` that only logs, and the Jerry-specific tables — all small, but they're speculative surface a v0.1.0 shouldn't claim. + +## Verdict + +I'd approve it with minor requested changes (delete the stubs, file an issue to relocate the activity tables). What makes it good isn't the initial code — it's the trajectory: 8 commits that went from a working prototype to something hardened against crash states, queue failures, and a target-platform incompatibility that three layers of testing had missed, with every fix documented well enough to be teachable. The honest caveat: it's still v0.1.0 with one consumer, no auth story, and a synchronous `/messages` path that will need timeouts and streaming-over-HTTP work before real production load. But as a Phase 1 foundation, it's the right shape with the right seams. + +To be precise: there's no entire file that's a stub — the stubs are small sections inside otherwise-needed files. Here's exactly where each one lives. + +**1. The `--report` stub** (the main one). It's spread across three files in `cloud-agent-cli`: + +The dead end in `run.ts` — the command does nothing: + +```43:45:vendor/cloud/packages/cloud-agent-cli/src/run.ts + case "report": + console.log("Report mode not yet implemented"); + break; +``` + +The parser branch that feeds it: + +```102:106:vendor/cloud/packages/cloud-agent-cli/src/parse.ts + case "--report": + return { + command: { type: "report", args: rest }, + options, + }; +``` + +And its variant in the command type: + +```28:28:vendor/cloud/packages/cloud-agent-cli/src/types.ts + | { type: "report"; args: string[] } +``` + +Removing it also means dropping the `--report` line from the help text in `run.ts` (line 168) and its test in `parse.test.ts` (lines 96–102). + +**2. The `handleScheduled` stub** in the server package — a cron handler that only logs: + +```171:176:vendor/cloud/packages/cloud-agent/src/host.ts + async function handleScheduled( + event: { cron: string }, + _env: HostEnv + ): Promise { + console.log(`Scheduled event: ${event.cron}`); + } +``` + +**3. The dead export `getStatus`** — fully implemented but nothing calls it, since the CLI has no `--status` command: + +```235:249:vendor/cloud/packages/cloud-agent-cli/src/client.ts +export async function getStatus( + baseUrl: string, + sessionId: string +): Promise<{ status: string; continuation?: unknown }> { + const url = `${baseUrl}/v1/sessions/${sessionId}/status`; + + const response = await fetch(url); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Request failed: ${text}`); + } + + return response.json() as Promise<{ status: string; continuation?: unknown }>; +} +``` + +For this one I'd actually lean the other way — wire up a `--status` command instead of deleting it, since the suspend/resume design gives users a real reason to check whether a session is `waiting_for_approval`. + +One caveat before you delete anything: these live in `vendor/cloud`, which is the submodule behind the upstream [PR #1](https://github.com/mieweb/cloud/pull/1) — so the removal belongs in that repo/branch, not as a local edit inside the Jerry checkout. Want me to make those changes on the `vendor/cloud` branch? + +--- + +**User** + +For this one I'd actually lean the other way — wire up a --status command instead of deleting it, since the suspend/resume design gives users a real reason to check whether a session is waiting_for_approval. + +explain me this option. Point me to the related files. + +--- + +**Cursor** + +The idea is simple: the entire server side of a "check session status" feature already exists and works — only the last inch (a CLI flag) is missing. Here's the full chain, from what exists to what's missing. + +## Why users would need it + +The agent host is built around suspend/resume: a turn can pause with status `waiting_for_user` (agent asked a question) or `waiting_for_approval` (a tool needs a human sign-off), and the session sits frozen until someone sends a follow-up message. The statuses are defined here: + +```27:32:vendor/cloud/packages/cloud-agent/src/types.ts +export type SessionStatus = + | "idle" + | "running" + | "waiting_for_user" + | "waiting_for_approval" + | "scheduled"; +``` + +Now picture the async workflow the CLI itself encourages: you run `jerry -txt do the thing` (fire-and-forget enqueue) and close your terminal. The agent runs the turn later, hits a tool that calls `suspendForApproval("May I delete these files?")`, and parks. **You have no way to find that out.** The suspend question was only printed in the synchronous `--call` flow; in the enqueue flow it just sits in the database. A `jerry --status` command is how you'd discover "your agent is blocked waiting on you, and here's what it asked." + +## What already exists (the whole server path) + +**1. The persisted state.** When a turn suspends, `session.ts` saves a `ContinuationState` — the pending question, when it suspended, and why — into the `continuation` column of the `sessions` table: + +```254:265:vendor/cloud/packages/cloud-agent/src/session.ts + if (this.suspendReason) { + const continuation: ContinuationState = { + pendingMessage: this.suspendMessage ?? undefined, + suspendedAt: new Date().toISOString(), + reason: this.suspendReason, + }; + + await insertMessage(this.env.DB, sessionId, "assistant", assistantContent || this.suspendMessage); + await insertEvent(this.env.DB, sessionId, this.suspendReason, { + message: this.suspendMessage, + }); + await updateSessionStatus(this.env.DB, sessionId, this.suspendReason, continuation); +``` + +**2. The Durable Object handler** that reads it back: + +```109:116:vendor/cloud/packages/cloud-agent/src/session.ts + private async handleStatus(sessionId: string): Promise { + const session = await getOrCreateSession(this.env.DB, sessionId); + return json({ + sessionId, + status: session.status, + continuation: session.continuation, + }); + } +``` + +**3. The public HTTP route** in the worker that forwards to it: + +```129:132:vendor/cloud/packages/cloud-agent/src/host.ts + if (path.endsWith("/status")) { + const doRequest = new Request(`${url.origin}/status`); + return stub.fetch(doRequest); + } +``` + +**4. The client function** — this is the "dead export" in the file you have open. It calls `GET /v1/sessions/:id/status` and returns the status plus continuation, but nothing in the CLI invokes it: + +```235:249:vendor/cloud/packages/cloud-agent-cli/src/client.ts +export async function getStatus( + baseUrl: string, + sessionId: string +): Promise<{ status: string; continuation?: unknown }> { + const url = `${baseUrl}/v1/sessions/${sessionId}/status`; +``` + +## What's missing (the CLI inch) + +Three small edits in `cloud-agent-cli`, mirroring how `--put` is wired: + +- **`src/types.ts`** — add a variant to `ParsedCommand`, e.g. `{ type: "status"; sessionId?: string }` (next to the existing variants at lines 22–29). +- **`src/parse.ts`** — add a `case "--status":` in `parseFlag()` (alongside `--put` at line 61) that picks up the session ID from the arg or from the `JERRY_SESSION`/`AGENT_SESSION` env var that `parseArgs` already reads. +- **`src/run.ts`** — add a `case "status":` to the dispatch `switch` (lines 21–50) that calls the existing `getStatus()` and prints something like: + +```text +session: session-1754935000-ab3k2f +status: waiting_for_approval +asked: "May I delete these files?" (suspended 12m ago) +``` + +The user experience then closes the loop: `--status` tells you the agent is waiting and what it asked, and sending any normal message to that session resumes it (that's the `isResume` path in `handleTurn`, `session.ts` lines 176–189). + +That's why I'd wire it up rather than delete `getStatus`: deletion removes ~15 dead lines, but wiring it costs ~25 lines and turns an already-built, already-tested server feature into something users can actually reach. Happy to implement it on the `vendor/cloud` branch if you want it. + From b7d7706242511b16253ead065e50485dc0e29a88 Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Mon, 3 Aug 2026 15:20:27 -0400 Subject: [PATCH 10/13] feat(cloud-agent): return toolsUsed on turn JSON replies Accumulate unique tool-call names during handleTurn and thread them through the CLI client's finish/suspended events so Jerry's REPL can render a post-answer tools footer without SSE. Co-authored-by: Cursor --- packages/cloud-agent-cli/src/client.ts | 18 ++++++++++++++++-- packages/cloud-agent-cli/src/types.ts | 4 ++-- packages/cloud-agent/src/session.ts | 8 ++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/cloud-agent-cli/src/client.ts b/packages/cloud-agent-cli/src/client.ts index 0283c70..bee38cc 100644 --- a/packages/cloud-agent-cli/src/client.ts +++ b/packages/cloud-agent-cli/src/client.ts @@ -63,6 +63,7 @@ export async function* streamCall( status?: string; suspended?: boolean; error?: string; + toolsUsed?: string[]; }; if (json.error) { @@ -76,14 +77,17 @@ export async function* streamCall( yield { type: "text", text: json.message }; } + const toolsUsed = json.toolsUsed?.length ? json.toolsUsed : undefined; + if (json.suspended) { yield { type: "suspended", reason: json.status ?? "waiting_for_user", message: json.message, + toolsUsed, }; } else { - yield { type: "finish", finishReason: "stop" }; + yield { type: "finish", finishReason: "stop", toolsUsed }; } } @@ -133,6 +137,11 @@ async function* parseSSE(response: Response): AsyncGenerator { } } +function toToolNames(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.length === 0) return undefined; + return value.map((name) => String(name)); +} + /** * Normalize server event to StreamEvent. */ @@ -162,7 +171,11 @@ function normalizeEvent(event: unknown): StreamEvent { output: e.output, }; case "finish": - return { type: "finish", finishReason: String(e.finishReason ?? "stop") }; + return { + type: "finish", + finishReason: String(e.finishReason ?? "stop"), + toolsUsed: toToolNames(e.toolsUsed), + }; case "error": return { type: "error", message: String(e.message ?? "Unknown error") }; case "suspend": @@ -171,6 +184,7 @@ function normalizeEvent(event: unknown): StreamEvent { type: "suspended", reason: String(e.reason ?? "waiting_for_user"), message: e.message as string | undefined, + toolsUsed: toToolNames(e.toolsUsed), }; default: return { type: "error", message: `Unknown event type: ${e.type}` }; diff --git a/packages/cloud-agent-cli/src/types.ts b/packages/cloud-agent-cli/src/types.ts index f0ad66c..b934823 100644 --- a/packages/cloud-agent-cli/src/types.ts +++ b/packages/cloud-agent-cli/src/types.ts @@ -45,6 +45,6 @@ export type StreamEvent = | { type: "text"; text: string } | { type: "tool-call"; toolName: string; input: unknown } | { type: "tool-result"; toolName: string; output: unknown } - | { type: "finish"; finishReason: string } + | { type: "finish"; finishReason: string; toolsUsed?: string[] } | { type: "error"; message: string } - | { type: "suspended"; reason: string; message?: string }; + | { type: "suspended"; reason: string; message?: string; toolsUsed?: string[] }; diff --git a/packages/cloud-agent/src/session.ts b/packages/cloud-agent/src/session.ts index 2a6b99d..e0a7210 100644 --- a/packages/cloud-agent/src/session.ts +++ b/packages/cloud-agent/src/session.ts @@ -222,6 +222,8 @@ export function createSessionClass( let assistantContent = ""; let finishReason = "stop"; + /** Tool names invoked this turn, unique, in order of first call. */ + const toolsUsed: string[] = []; for await (const event of runtime.runTurn({ messages: coreMessages, @@ -231,6 +233,10 @@ export function createSessionClass( })) { if (event.type === "text-delta") { assistantContent += event.text; + } else if (event.type === "tool-call") { + if (!toolsUsed.includes(event.toolName)) { + toolsUsed.push(event.toolName); + } } else if (event.type === "finish") { finishReason = event.finishReason; } else if (event.type === "suspend") { @@ -264,6 +270,7 @@ export function createSessionClass( status: this.suspendReason, message: assistantContent || this.suspendMessage, suspended: true, + toolsUsed, }); } @@ -283,6 +290,7 @@ export function createSessionClass( status: "idle", message: assistantContent, finishReason, + toolsUsed, }); } catch (err) { await insertEvent(this.env.DB, sessionId, "error", { From 365132d717b79c2e9ce5189c4c885c4709798a35 Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Thu, 6 Aug 2026 13:05:25 -0400 Subject: [PATCH 11/13] feat(cloud-agent-cli): print toolsUsed footer after one-shot replies Surface which tools ran at the end of --call turns so scripted usage matches the REPL audit trail. Co-authored-by: Cursor --- packages/cloud-agent-cli/src/run.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cloud-agent-cli/src/run.ts b/packages/cloud-agent-cli/src/run.ts index 6017b58..b8dda24 100644 --- a/packages/cloud-agent-cli/src/run.ts +++ b/packages/cloud-agent-cli/src/run.ts @@ -60,6 +60,11 @@ async function handleCall( options: CliOptions ): Promise { const sessionId = options.sessionId ?? generateSessionId(); + const toolsUsed: string[] = []; + + const noteTool = (name: string | undefined) => { + if (name && !toolsUsed.includes(name)) toolsUsed.push(name); + }; if (options.debug) { console.error(`[debug] agent=${config.agent} session=${sessionId}`); @@ -74,23 +79,31 @@ async function handleCall( })) { if (event.type === "text") { process.stdout.write(event.text); - } else if (event.type === "tool-call" && options.debug) { - console.error(`[tool] ${event.toolName}(${JSON.stringify(event.input)})`); + } else if (event.type === "tool-call") { + noteTool(event.toolName); + if (options.debug) { + console.error(`[tool] ${event.toolName}(${JSON.stringify(event.input)})`); + } } else if (event.type === "tool-result" && options.debug) { console.error(`[tool-result] ${event.toolName}: ${JSON.stringify(event.output)}`); } else if (event.type === "error") { console.error(`\nError: ${event.message}`); process.exitCode = 1; } else if (event.type === "suspended") { + event.toolsUsed?.forEach(noteTool); console.log(`\n[${event.reason}] ${event.message ?? ""}`); console.log(`Session: ${sessionId}`); } else if (event.type === "finish") { + event.toolsUsed?.forEach(noteTool); if (options.debug) { console.error(`\n[finish] reason=${event.finishReason}`); } } } - console.log(); // Final newline + console.log(); // Final newline after the reply + if (toolsUsed.length > 0) { + console.log(`tools: ${toolsUsed.join(" · ")}`); + } } catch (err) { console.error(`Error: ${err instanceof Error ? err.message : err}`); process.exitCode = 1; From 44b0ecd92f05467a357197705ffd4bf39390194d Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Thu, 6 Aug 2026 14:24:23 -0400 Subject: [PATCH 12/13] fix(cloud-agent-cli): pass through finishReason and warn on length truncations JSON one-shot replies were hard-coding finishReason=stop, hiding output token-limit cuts. Surface the real reason and print a truncation notice. Co-authored-by: Cursor --- packages/cloud-agent-cli/src/client.ts | 7 ++++++- packages/cloud-agent-cli/src/run.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cloud-agent-cli/src/client.ts b/packages/cloud-agent-cli/src/client.ts index bee38cc..f48968b 100644 --- a/packages/cloud-agent-cli/src/client.ts +++ b/packages/cloud-agent-cli/src/client.ts @@ -64,6 +64,7 @@ export async function* streamCall( suspended?: boolean; error?: string; toolsUsed?: string[]; + finishReason?: string; }; if (json.error) { @@ -87,7 +88,11 @@ export async function* streamCall( toolsUsed, }; } else { - yield { type: "finish", finishReason: "stop", toolsUsed }; + yield { + type: "finish", + finishReason: json.finishReason ?? "stop", + toolsUsed, + }; } } diff --git a/packages/cloud-agent-cli/src/run.ts b/packages/cloud-agent-cli/src/run.ts index b8dda24..f77fa25 100644 --- a/packages/cloud-agent-cli/src/run.ts +++ b/packages/cloud-agent-cli/src/run.ts @@ -61,6 +61,7 @@ async function handleCall( ): Promise { const sessionId = options.sessionId ?? generateSessionId(); const toolsUsed: string[] = []; + let finishReason: string | undefined; const noteTool = (name: string | undefined) => { if (name && !toolsUsed.includes(name)) toolsUsed.push(name); @@ -94,6 +95,7 @@ async function handleCall( console.log(`\n[${event.reason}] ${event.message ?? ""}`); console.log(`Session: ${sessionId}`); } else if (event.type === "finish") { + finishReason = event.finishReason; event.toolsUsed?.forEach(noteTool); if (options.debug) { console.error(`\n[finish] reason=${event.finishReason}`); @@ -101,6 +103,11 @@ async function handleCall( } } console.log(); // Final newline after the reply + if (finishReason === "length") { + console.error( + "[truncated] model hit the output token limit — ask to continue" + ); + } if (toolsUsed.length > 0) { console.log(`tools: ${toolsUsed.join(" · ")}`); } From 9d6c6907826ff6c7d892b60b7324986ab4c484e4 Mon Sep 17 00:00:00 2001 From: Shubhdeep Sarkar Date: Wed, 12 Aug 2026 15:01:45 -0400 Subject: [PATCH 13/13] docs: add MCP v2 plan and Cursor MCP review transcript Co-authored-by: Cursor --- .../chat4 - cloudflare_mcp_wrapper_traces.md | 258 ++++++++++++++++++ docs/plan_mcp_v2.md | 194 +++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 chats/chat4 - cloudflare_mcp_wrapper_traces.md create mode 100644 docs/plan_mcp_v2.md diff --git a/chats/chat4 - cloudflare_mcp_wrapper_traces.md b/chats/chat4 - cloudflare_mcp_wrapper_traces.md new file mode 100644 index 0000000..4c73cef --- /dev/null +++ b/chats/chat4 - cloudflare_mcp_wrapper_traces.md @@ -0,0 +1,258 @@ +# (2) Cloudflare MCP wrapper traces +_Exported on 8/12/2026 at 14:59:56 EDT from Cursor (3.14.7)_ + +--- + +**User** + +@packages/cloud-agent do you see any traces of cloudflare MCP wrapper in this agentMCP or createMcpHandler in PR#1 with all it's latest commits? + + +--- + +**Cursor** + +I'll look at the `cloud-agent` package locally and check PR #1 on GitHub for any Cloudflare MCP wrapper usage like `agentMCP` or `createMcpHandler`. + +The local tree has no matches, but let me verify against the PR's actual head diff and check for any broader MCP references. + +No — there are no traces of Cloudflare's MCP wrapper anywhere in `packages/cloud-agent` on PR [#1](../../pull/1), and your local checkout is exactly at the PR head (`44b0ecd`, in sync with `origin/feature/cloud-agent`), so this reflects all latest commits. + +Specifically, I checked for `McpAgent` (the class Cloudflare's Agents SDK exposes, sometimes referred to as agent MCP), `createMcpHandler`, imports from the `agents` package, and anything `@modelcontextprotocol`-related across: + +- The `packages/cloud-agent/src` source — no matches at all. +- `package.json` and `wrangler.jsonc` for the package — no `agents` or MCP dependencies/bindings. +- The full diff of PR #1 across all 12 commits — the only "mcp" hits are two lines in the committed chat transcripts (`chats/`), and those just describe Jerry's own `jerry mcp` CLI subcommand in `packages/cli`, not a Cloudflare MCP server wrapper. + +So `cloud-agent` is a plain Worker + Durable Object host with its own HTTP/queue/D1 plumbing; nothing in the PR wires it up as an MCP server. If your `docs/plan_mcp_v2.md` work assumes an existing `createMcpHandler` integration to build on, that would be net-new code. + +--- + +**User** + +So to bring the a MCP handler in the cloud vendor we need to introduce it from the start? I am talking about MCP spec v2. Don't look at MCP v2 plan if there any. Give me a fresh answer + +--- + +**Cursor** + +Let me quickly ground this in the actual `cloud-agent` code so the answer reflects your real architecture, not guesswork. + +No — you don't need to have designed it in from the start. MCP v2 is deliberately stateless, and that makes it purely additive to what `cloud-agent` already is. Here's the fresh picture, grounded in your code and Cloudflare's current (Aug 2026) story. + +**What MCP v2 changed on Cloudflare.** The old `McpAgent` class — the thing that required a dedicated Durable Object and protocol sessions — is now deprecated and feature-frozen. The current approach is `createMcpHandler` from `agents/mcp/server`: you give it a factory that returns a fresh `McpServer` (from `@modelcontextprotocol/server`, the SDK v2 package) per request, and it hands you back a plain fetch handler. No Durable Object, no session transport, no bindings in `wrangler.jsonc` beyond what you already have. The same `/mcp` route also serves legacy 2025 Streamable HTTP clients by default (see the [changelog](https://developers.cloudflare.com/changelog/post/2026-07-27-agents-sdk-v0.20.0-mcp-sdk-v2/) and [Cloudflare's MCP v2 post](https://blog.cloudflare.com/mcp-v2/)). + +**Why your architecture absorbs this cleanly.** `hostAgent()` returns a `handleFetch` that the consuming worker's entry composes — so mounting MCP is just routing `/mcp` before you delegate: + +```typescript +import { createMcpHandler } from "agents/mcp/server"; + +const { SessionClass, handleFetch, handleQueue } = hostAgent(config); +const mcpHandler = createMcpHandler(createServer); // new McpServer per request + +export default { + fetch(request, env, ctx) { + const { pathname } = new URL(request.url); + if (pathname === "/mcp") return mcpHandler(request, env, ctx); + return handleFetch(request, env); + }, + queue: handleQueue, +}; +``` + +Nothing inside `session.ts`, `host.ts`, or `storage.ts` has to change. And your turn model happens to fit well: `handleTurn` runs a turn synchronously within a single fetch and returns JSON, which is exactly the shape a stateless MCP tool call wants — an MCP `send_message` tool can call the existing `/message` path on the DO and return the reply. + +**What does deserve upfront thought (the real "from the start" items):** + +- **Session identity.** MCP v2 has no protocol sessions, so "which conversation" must travel as a tool argument (e.g. `sessionId` passed to the tool, mapped to `idFromName` like `host.ts` already does). That's a tool-schema decision, not an architecture rebuild. +- **Auth.** Your current endpoints trust `X-User-Id` headers. An MCP endpoint exposed to third-party clients needs real auth (Workers OAuth Provider is the blessed pairing). This is the one thing that's genuinely harder to bolt on late. +- **No shared state in the handler.** The factory must create a new `McpServer` per request — a security requirement since MCP SDK 1.26, not just style. +- **Stateful MCP features** (server-pushed requests, standalone streams, replay) are the only things that would pull you back toward Durable Objects. If you don't need them — and a "call the agent, get a reply" tool surface doesn't — stay stateless. + +So the honest answer: introducing MCP now versus at the start costs you almost nothing structurally. The PR #1 architecture (composable fetch handler, synchronous turns, DO-per-session addressed by name) is coincidentally the right substrate for a stateless MCP v2 lane. The only debt you'd be paying off is auth. + +--- + +**User** + +What's the current status, does the Cloud-agent has CF's agent contract? or Cloud-agent has no MCP support? + +--- + +**Cursor** + +Let me re-verify the current state of the package so this reflects any changes since we last looked. + +Neither — `cloud-agent` is a hand-rolled Durable Object host with no Cloudflare Agents SDK contract and no MCP support of any kind. Current state of the package (your working tree, which is at the PR #1 head): + +**No CF Agents SDK contract.** The only dependency is your own `@mieweb/cloud-types` — the `agents` npm package isn't installed at all. The `AgentSession` class in `session.ts` is a plain class implementing raw Durable Object semantics (`fetch()`, `alarm()`, `state.storage`) by hand: + +```38:48:packages/cloud-agent/src/session.ts + return class AgentSession { + private state: CloudStatefulState; + private env: HostEnv; + private turnInProgress = false; + private suspendReason: "waiting_for_user" | "waiting_for_approval" | null = null; + private suspendMessage: string | null = null; + + constructor(state: CloudStatefulState, env: HostEnv) { + this.state = state; + this.env = env; + } +``` + +Nothing extends Cloudflare's `Agent` or `McpAgent`, and it doesn't use any SDK features (no `this.setState`, `this.schedule`, SQL-in-DO, WebSocket hibernation helpers, RPC-callable methods). Instead the package defines its **own** contract: `AgentDefinition` + `AgentRuntime` + `hostAgent()`, sitting directly on platform primitives — Durable Object, Queue (`env.JOBS`), and D1 (`env.DB`) — abstracted behind your `CloudStatefulState`/`CloudMessageBatch` types. + +**No MCP support, in either direction.** There's no MCP server surface (no `createMcpHandler`, no `@modelcontextprotocol/*` packages, no `/mcp` route in `host.ts` — its router only knows `/health`, `/v1/events`, `/v1/sessions/:id/{messages,enqueue,status}`), and no MCP client either (the agent's tools come from `agent.tools` or the `createTools(ctx)` factory; there's no mechanism to connect out to an MCP server and import its tools). + +So the status is: a fully independent agent host that parallels what Cloudflare's Agents SDK does (sessions, scheduling via alarms, suspend/resume) but shares no code or contract with it. MCP — whether exposing this agent as an MCP server or letting it consume MCP tools — would be entirely new surface area, which is presumably what your `plan_mcp_v2.md` is scoping. + +--- + +**User** + +So what does the cloud-agent brings to the table? + +--- + +**Cursor** + +Let me look at the remaining files (`types.ts`, `storage.ts`, `index.ts`) so I can give you a complete picture of what the package actually provides. + +It's the reusable "agent host" layer — everything between an HTTP request and your LLM call that would otherwise be rewritten per agent. Concretely, `hostAgent()` takes just two things from the consumer (an `AgentDefinition` — name, instructions, tools — and a `createRuntime` factory) and hands back a fully wired worker: DO class, fetch router, and queue consumer. What that buys you: + +**1. A durable turn lifecycle.** The `AgentSession` DO serializes turns per session (DO single-threading plus a `turnInProgress` guard plus a DB status check, so a busy session returns 409 instead of interleaving), and supports three entry paths: synchronous (`/v1/sessions/:id/messages` — blocks and returns the reply), asynchronous (`/enqueue` — goes through the `JOBS` queue with ack/retry semantics), and time-triggered (a tool calls `scheduleWake()`, which sets a DO alarm that re-enqueues a turn later). Suspend/resume is first-class: a tool or the runtime can suspend for user input or approval, a `ContinuationState` is persisted, and the next message resumes the session. + +**2. Persistence and auditability out of the box.** Five D1 tables (`sessions`, `messages`, `events`, `activity_events`, `summaries`) with idempotent schema init. Every turn writes a lifecycle event log (user_message, agent_message, suspensions, resumes, errors), so you get conversation history plus an audit trail without designing any of it. Because it's central D1 rather than per-DO embedded storage, you can query across all sessions — the DO itself holds almost nothing (just a pending alarm payload) and acts mainly as the per-session lock and timer. + +**3. Model/provider neutrality.** The host never imports an AI SDK. It consumes an `AgentRuntime` — anything with `runTurn()` yielding a small event vocabulary (`text-delta`, `tool-call`, `finish`, `suspend`, `error`). That's what keeps Ollama vs. Ozwell vs. BYO-cloud selection (the privacy-profile logic) out of the host: the profile rides in on the request and is handed to `createRuntime(profile)` per turn. + +**4. Vendor portability.** It's typed entirely against your own `@mieweb/cloud-types` (`CloudDatabase`, `CloudQueue`, `CloudStatefulNamespace`) instead of Cloudflare's types — which is precisely what lets `cloud-local`'s `host.mjs` run the same host on plain Node with local shims. That's the deliberate trade against adopting Cloudflare's Agents SDK: you give up its freebies (WebSocket streaming, React hooks, built-in MCP support) and in exchange the agent host isn't married to Cloudflare. + +**5. Host-provided tool capabilities.** `createTools(ctx)` gives tools a `ToolContext` carrying the platform bindings (DB, vectors, bucket) plus the lifecycle verbs (`scheduleWake`, `suspendForUser`, `suspendForApproval`) — so tools can be written against a stable interface with no knowledge of DOs or queues. + +The one-line summary: Jerry (or any future agent) writes instructions, tools, and a runtime; `cloud-agent` supplies sessions, ordering, durability, scheduling, suspend/resume, and the HTTP surface. In the MCP conversation specifically, it's the substrate an MCP lane would sit on top of — the missing piece is only the `/mcp` route and auth, not any of this machinery. + +--- + +**User** + +So how does the cloud-agent contacts the MCP when it needs? using the Host? @packages/cloud-agent/src/host.ts + +--- + +**Cursor** + +It doesn't — today there is no path, in `host.ts` or anywhere else in the package, by which `cloud-agent` contacts an MCP server. Nothing in the host does outbound MCP; let me show you what actually happens and where MCP would plug in if you add it. + +**What `host.ts` actually does.** It's purely inbound plumbing: it routes HTTP requests and queue messages to the right Durable Object stub. The only "contacting" it does is `stub.fetch()` to your own DO. Tools never pass through it at all — they're wired at construction time: + +```49:52:packages/cloud-agent/src/host.ts +export function hostAgent(config: HostAgentConfig): HostAgentResult { + const { agent, createRuntime, createTools } = config; + + const SessionClass = createSessionClass(agent, createRuntime, createTools); +``` + +**Where tools actually come from.** Inside the DO, per turn, `session.ts` builds a `ToolContext` (db, vectors, bucket, scheduleWake, suspend hooks) and asks the consumer for tools: + +```221:221:packages/cloud-agent/src/session.ts + const tools = createTools?.(toolContext) ?? agent.tools; +``` + +The host treats the result as an opaque `unknown` and hands it straight to `runtime.runTurn({ messages, tools, ... })`. It has no idea whether a tool is a local function or a proxy to something remote. + +**So if the agent needs MCP tools, that's the consumer's job, not the host's.** The design already gives you the seam: the consuming package (Jerry's worker entry, where it calls `hostAgent()`) would create an MCP client connection — e.g. via `@modelcontextprotocol/client` or the AI SDK's MCP client — list the remote tools, and return them from `createTools` (or bake the connection into its `AgentRuntime`). The host would carry them through unchanged. MCP-as-client is invisible to `cloud-agent` by construction, same as model choice is. + +**One real friction point if you go that route.** MCP tool discovery is async (connect, `tools/list`), but `createTools` is currently called synchronously — line 221 doesn't `await`, and the type is `(ctx: ToolContext) => unknown`. So today you'd have to do MCP connection inside the runtime's `runTurn` (which is async) rather than in `createTools`. If you'd rather keep it in `createTools`, it's a two-line change: make the signature `(ctx) => unknown | Promise` and `await` the call in `session.ts`. Also remember each turn runs in a Worker isolate, so the natural lifecycle is connect → use → close within the turn; there's no long-lived MCP connection to hold anywhere. + +The mirror-image direction — something contacting *cloud-agent over* MCP (the server side, `createMcpHandler` mounted next to `handleFetch`) — is equally absent today, as we established earlier. Host stays MCP-ignorant in both directions; the plan doc's job is deciding which of those two lanes (client, server, or both) you actually want to build. + +--- + +**User** + +verify cloud-agent existing contract with CF's agent +what do we have and what we dont? + +--- + +**Cursor** + +This is a "compare our contract against Cloudflare's `Agent` class" question. I've already verified our side in code; let me confirm the current (Aug 2026) surface of Cloudflare's Agents SDK so the comparison is accurate, not from memory. + +Verified — the two contracts share zero code (the `agents` package isn't even a dependency; the only import is your own `@mieweb/cloud-types`), but they solve overlapping problems. Here's the honest capability map, grounded in the current code and Cloudflare's current (v0.20-era) `Agent` API. + +## What we have — and its CF equivalent + +| Capability | cloud-agent | CF `Agent` equivalent | +|---|---|---| +| Session = addressable DO | `idFromName(sessionId)` in `host.ts` | `getAgentByName` / `routeAgentRequest` | +| Turn serialization | `turnInProgress` flag + DB status check → 409 | DO single-threading, fibers | +| Scheduling | `scheduleWake()` → one DO alarm | `schedule()`, `scheduleEvery()`, cron, listable | +| Persistence | Central D1: 5 shared tables | Embedded per-agent SQLite (`this.sql`, `this.state`) | +| Async work | Workers Queue (`JOBS`) with ack/retry | `this.queue()` (internal, per-agent) | +| Suspend/resume | First-class: `ContinuationState`, status machine | `waitForApproval()` / fiber stash | +| HTTP entry | `/v1/sessions/:id/{messages,enqueue,status}` | `onRequest()` + routing helpers | + +Three of those rows deserve elaboration because the semantics differ meaningfully: + +- **Persistence is architecturally opposite.** CF puts SQLite *inside* each DO — zero-latency but siloed per agent. We put everything in central D1 — queryable across all sessions (audit, analytics, the `activity_events` ingestion feed), at the cost of D1 round-trips per turn. +- **Our scheduling is a single slot.** `scheduleWake` writes one `alarm_payload` and calls `setAlarm` — a second call overwrites the first. CF multiplexes many named/cron schedules over the same alarm primitive. If an agent ever needs two pending wakes, we have a real gap. +- **Our queue is cross-worker with delivery guarantees** (retry on failed turn in `handleQueue`); CF's `queue()` is an in-agent convenience. Ours is arguably stronger for ingestion-driven agents. + +**Plus two things CF's base `Agent` doesn't give you at all:** a turn engine contract (`AgentRuntime`/`RuntimeEvent` — CF's base class has no LLM loop; that only comes with higher layers like `AIChatAgent`/Think, which lock you into their shape), and portability — everything is typed against `CloudStatefulState`/`CloudDatabase`/`CloudQueue`, which is what lets `cloud-local/host.mjs` run the same host on plain Node. That second one is the whole reason this package exists instead of `extends Agent`. + +## What we don't have (CF gives these free) + +- **Streaming and realtime:** no WebSockets (`onConnect`/`onMessage`/hibernation), no SSE — our `/messages` blocks and returns one JSON blob; `text-delta` events are accumulated, never streamed out. +- **Client sync:** no `setState` broadcast, no `useAgent`/`useAgentChat` React hooks, no client SDK. +- **RPC:** no `@callable()` typed methods; our DO speaks only hand-rolled `fetch` routes. +- **MCP, both directions:** no MCP client (`addMcpServer()`) and no MCP server (`createMcpHandler`) — confirmed absent earlier in this session. +- **Durable execution:** no fibers (`runFiber`/`keepAlive`), no Workflows integration — a crashed turn just errors and resets to `idle`; only the suspend path persists mid-turn state, and `partialToolState` is declared in types but nothing writes it yet. +- **The 2026 accessories:** sub-agents/facets, agents-as-tools, skills registry, session compaction, email (`onEmail`), Chat SDK/Think, observability diagnostics channels. + +## Bottom line + +We've independently rebuilt roughly the "headless backend" quarter of CF's contract — sessions, ordering, durability, scheduling, suspend/resume — and added three things CF doesn't have (portability, central queryable storage, a runtime abstraction that keeps privacy-profile/model choice out of the host). Everything client-facing (streaming, state sync, RPC) and everything ecosystem-flavored (MCP, sub-agents, skills) is absent. The strategic question your MCP plan sits inside: each CF feature we want, we either re-implement on our contract (as was done for scheduling and queues) or adopt piecemeal where it's decoupled — and `createMcpHandler` happens to be the one CF feature that's just a fetch handler, usable without buying the `Agent` class at all. + +--- + +**User** + +@README.md check if the new updates in PR #1 follows the same build methods for the new cloud-agent that older packages followed? + +--- + +**Cursor** + +I'll read `packages/README.md` and compare the build setup of `cloud-agent` (and any other packages PR #1 touched) against the older packages. + +Now let me compare the build/packaging setup of the two new packages against the older ones. + +Let me check the CI workflows and root `tsconfig.json` to see whether the new packages are wired into the same typecheck/test/release flow. + +Mostly yes — the two new packages copy the repo's established "no-build, ship-TypeScript-source" method faithfully — but I found one real break: `cloud-agent-cli`'s `bin` shim doesn't actually run under plain Node, which none of the older packages suffer from. + +**The repo's build method, for context.** There is no build step anywhere: every `@mieweb/*` package publishes raw source (`files: ["src"]`, `exports` pointing at `./src/*.ts` or `.mjs`), typechecking happens once at the root (`tsc --noEmit` over all of `packages/`), tests run via `pnpm -r --if-present test`, and releases go through changesets. CI is a thin wrapper over those same commands. + +**Where PR #1 conforms:** + +- `cloud-agent`'s `package.json` is structurally identical to `cloud-types`/`cloud`: same metadata block, `type: module`, version `0.1.0`, `publishConfig.access: public`, `files: ["src"]`, TS-source `exports` with a `types` condition (including the `./types` subpath, same shape `cloud-types` uses), and `workspace:*` for internal deps. No build script — correct, per house style. +- Both packages are picked up by the existing pipelines with zero CI changes: root `tsconfig.json` includes all of `packages/`, and their `test` scripts ride `pnpm -r test`. I ran the full workspace test suite just now and it passes end to end. +- The `smoke/` worker in `cloud-agent` keeps its own `wrangler.jsonc` (bindings as source of truth, matching conventions) and is excluded from the npm tarball since `files` only lists `src`. + +**Minor divergences (acceptable, worth knowing):** + +- These are the first packages to test TypeScript directly — `node --import tsx --test` — where `cloud-local` tests plain `.mjs` with bare `node --test`. Same runner, new `tsx` devDependency; consistent in spirit with shipping TS source. +- They pin `@types/node: ^20` while the root uses `^22` and `engines.node >=22`. Harmless today, but drift. +- No changeset file ships with the PR. Not blocking: `changeset publish` publishes any package whose version isn't on npm yet, so the new `0.1.0`s would still go out on the next release run. + +**The actual break — `cloud-agent-cli`'s `bin`:** + +```7:7:packages/cloud-agent-cli/bin/agent-cli.js +import { run } from '../src/index.js'; +``` + +`src/` contains only `.ts` files, and Node does not rewrite `.js` specifiers to `.ts` — only `tsx`-style loaders do. I verified: `node packages/cloud-agent-cli/bin/agent-cli.js` fails immediately with `ERR_MODULE_NOT_FOUND` for `src/index.js`. Compare the older `cli` package, whose `bin` points at a genuinely runnable `./src/index.mjs`. As published, anyone running `agent-cli` from an npm install gets a crash; it only works when a consumer (like Jerry's wrapper) launches it through `tsx`. So it nominally follows the source-shipping pattern but violates the property that pattern relied on — that shipped entry points are executable as-is. The smallest fix consistent with house style is either making the shim `.mjs`-clean (have `bin` re-exec through `tsx`, declared as a real dependency) or converting `cloud-agent-cli`'s entry chain to `.mjs` the way `cli` and `cloud-local` did. + diff --git a/docs/plan_mcp_v2.md b/docs/plan_mcp_v2.md new file mode 100644 index 0000000..334cd8a --- /dev/null +++ b/docs/plan_mcp_v2.md @@ -0,0 +1,194 @@ +# Plan: MCP v2 handler in the `@mieweb/cloud` vendor layer + +**Goal:** any agent hosted with `hostAgent()` (`@mieweb/cloud-agent`) can be exposed as an +MCP server compliant with the MCP v2 specification (2026-07-28, stateless), on every +target the portability layer supports — Cloudflare Workers as the reference +implementation and the `cloud-local` Node host with the same code. + +Non-goal (for now): stateful MCP features — server-pushed requests, standalone streams, +event replay. MCP v2 is stateless-first and our tool surface doesn't need them. + +## Where we start from + +- `hostAgent()` returns `{ SessionClass, handleFetch, handleQueue, handleScheduled }`; + the consuming worker composes these in its `fetch`/`queue` exports. An MCP endpoint is + one more route composed *before* `handleFetch` — no changes to the turn lifecycle, + Durable Object, or storage schema are required. +- Turns already run synchronously inside a single `fetch` to the session DO and return + JSON (`{ message, status, toolsUsed, finishReason }`). That is exactly the shape a + stateless MCP tool call wants. +- Sessions are addressed by name (`SESSION.idFromName(sessionId)`), so "which + conversation" can travel as a plain tool argument — no MCP protocol session needed. +- On the Cloudflare side, `McpAgent` (Durable-Object-based) is **deprecated and + feature-frozen**. The current primitive is a stateless handler built from a + per-request MCP server factory (SDK v2, `@modelcontextprotocol/server`), which is + fetch-shaped and web-standards based. See the + [Cloudflare MCP v2 announcement](https://blog.cloudflare.com/mcp-v2/) and the + [Agents SDK v0.20 changelog](https://developers.cloudflare.com/changelog/post/2026-07-27-agents-sdk-v0.20.0-mcp-sdk-v2/). + +## Target architecture + +```mermaid +graph TB + Client["Any MCP v2 client
(Claude, Cursor, other agents)"] + Entry["Worker entry (per agent app)
routes /mcp before handleFetch"] + McpHandler["createAgentMcpHandler()
new module in @mieweb/cloud-agent
fresh McpServer per request"] + HostFetch["hostAgent().handleFetch
existing /v1/sessions routes"] + SessionDO["AgentSession Durable Object
sync turn, suspend/resume, alarms"] + Storage["D1 / SQLite
sessions, messages, events"] + LocalHost["cloud-local Node host
runs the same worker entry"] + + Client -->|"POST /mcp (streamable HTTP)"| Entry + Entry --> McpHandler + McpHandler -->|"tools call the same paths
handleFetch already serves"| HostFetch + HostFetch --> SessionDO + SessionDO --> Storage + LocalHost -.->|"same entry, Node adapters"| Entry + + classDef vendor fill:#e8f0fe,stroke:#4285f4 + classDef existing fill:#e6f4ea,stroke:#34a853 + classDef external fill:#fef7e0,stroke:#f9ab00 + class McpHandler vendor + class HostFetch,SessionDO,Storage,LocalHost existing + class Client,Entry external +``` + +The handler is **generic over the hosted agent**: it takes the same +`HostAgentConfig`/`HostAgentResult` wiring every agent already produces, so Jerry, Lisa, +or any future agent gets an MCP endpoint by composing one route — no agent-specific code +in the vendor layer. + +## Decision points + +### D1 — Which MCP SDK the vendor layer depends on + +| Option | Pros | Cons | +| ------ | ---- | ---- | +| **`@modelcontextprotocol/server` (SDK v2) directly — recommended** | Vendor-neutral, web-standards transport, works identically under Workers and the Node host; keeps Cloudflare-only deps out of the portability layer | We own the small amount of glue (routing, CORS, legacy-lane behavior) that Cloudflare's wrapper provides | +| Cloudflare `agents/mcp/server` (`createMcpHandler`) | Workers-focused defaults (CORS, host restrictions, legacy 2025-client compatibility) for free | Adds the `agents` SDK as a dependency of the portability layer; behavior under the `cloud-local` Node host is unverified; couples the vendor contract to Cloudflare tooling — against the layer's organizing principle | + +**Recommendation:** raw SDK v2. Cloudflare remains zero-overhead (the SDK is +fetch-shaped), and the same code runs under `cloud-local`. Revisit only if we end up +reimplementing a large share of the wrapper. +**Decide by:** end of Phase 0 — the spike validates the raw SDK on both targets. + +### D2 — Where the handler lives + +**Recommendation:** a new `src/mcp.ts` module inside `@mieweb/cloud-agent`, exported as +`createAgentMcpHandler()`. Smallest viable change, and the MCP surface is agent-hosting +logic, so `cloud-agent` is its natural anchor. Extract to a separate +`cloud-agent-mcp` package later only if the dependency footprint bothers non-MCP +consumers. + +### D3 — Tool surface exposed over MCP + +**Recommendation:** start with a session-oriented surface that wraps the routes +`handleFetch` already serves, rather than re-exposing the agent's internal tools: + +- `send_message(sessionId, message, userId?)` → runs a turn, returns the reply, + `status`, and `toolsUsed` +- `get_session_status(sessionId)` → current status + continuation (pending question / + approval request) +- `resume_session(sessionId, message)` → answers a `waiting_for_user` / + `waiting_for_approval` suspension (same code path as `send_message`; exists as a + distinct tool so clients discover the suspend/resume contract) + +Re-exposing the agent's internal tools directly over MCP is a different product +(tool-server, not agent-server) and can be a later, separate addition. + +### D4 — Authentication model + +The current API trusts an `X-User-Id` header — acceptable for internal use, not for an +MCP endpoint reachable by arbitrary clients. + +- **Phase 2 (minimum):** static bearer token from an env binding, enforced in the MCP + handler. Portable to every target; the Node host reads the same env. +- **Later:** OAuth (Workers OAuth Provider on Cloudflare) if MCP clients outside our + control need to onboard. This is the piece that is Cloudflare-specific, so it must sit + *outside* the portable handler as middleware. + +**Decide by:** Phase 2. Blocking question: who are the first non-Jerry MCP clients, and +are they all first-party? + +### D5 — Legacy (2025 Streamable HTTP) client compatibility + +MCP v2 servers can accept stateless requests from 2025-spec clients on the same +endpoint. **Recommendation:** support the legacy stateless lane from the start (it is +close to free with SDK v2) and document that protocol-session features of the old spec +are intentionally rejected. + +## Possible complications + +1. **Long turns vs. request timeouts.** A turn runs up to `maxSteps: 10` LLM calls + synchronously inside one fetch. Slow models can exceed what an MCP client (or an + intermediary) tolerates. Mitigation: document expected latency; if it becomes real, + add an `enqueue`-based async tool pair (`start_turn` + `get_result`) — the queue + route already exists. +2. **Suspend/resume semantics.** `waiting_for_user` / `waiting_for_approval` are + first-class here but foreign to MCP clients. The tool output must make the state + machine explicit (`status` + pending message) so a generic client knows to call + `resume_session`. Get this schema right early; it is the public contract. +3. **Per-request server instance is a security requirement, not a style choice.** + Sharing an `McpServer`/transport across requests leaks responses between clients + (fixed in MCP SDK ≥ 1.26). The factory pattern must be enforced in `createAgentMcpHandler`. +4. **Concurrent turns return 409.** "Turn already in progress" must map to a clean MCP + tool error with retry guidance, not a generic failure. +5. **Node-host parity.** The MCP SDK v2 is web-standards based and should run under the + `tsx`-loaded Node host, but this is exactly the "documented edge of the POC" + (module-eval-time globals). The Phase 0 spike must prove it before we build on it. +6. **Streaming is discarded today.** `handleTurn` accumulates `text-delta` events and + returns one JSON body. Fine for MCP JSON responses; if clients want incremental + output later, that is new work in the session layer, not the MCP layer. + +## Current obstacles + +- **PR [#1](../../pull/1) is still open.** This work builds directly on + `hostAgent()`; land it on top of `feature/cloud-agent` or wait for the merge. + Per the PR philosophy, MCP support should be its own PR regardless. +- **No authentication exists anywhere in the host layer** (trusted `X-User-Id`). D4 is + not optional for an exposed endpoint. +- **No MCP dependency in the workspace yet** — `@modelcontextprotocol/server` must be + added to `cloud-agent` and the pnpm lockfile updated (the repo builds through + `scripts/`, and lockfile drift already bit PR #1 once). +- **`initSchema` runs on every request**; adding MCP traffic multiplies calls to it. It + is idempotent, but worth a cheap "already initialized" guard while we are in the area. + +## Checklist + +### Phase 0 — Spike: prove the transport on both targets + +- [ ] Add `@modelcontextprotocol/server` (SDK v2) to `packages/cloud-agent`; update the pnpm lockfile +- [ ] Hand-wire a throwaway `/mcp` route in `packages/test-app/worker/index.mjs` with one echo tool (fresh server per request) +- [ ] Verify with an MCP v2 client against `wrangler dev` (Cloudflare lane) +- [ ] Verify the identical entry under the `cloud-local` Node host; note any module-eval-time global issues +- [ ] Confirm a 2025 stateless client is served by the same route (D5) +- [ ] Close D1 (raw SDK vs Cloudflare wrapper) with the spike's evidence + +### Phase 1 — Generic handler in the vendor layer + +- [ ] Create `packages/cloud-agent/src/mcp.ts` with `createAgentMcpHandler(config)` returning a fetch-shaped handler; export from `index.ts` +- [ ] Implement the session tool surface (D3): `send_message`, `get_session_status`, `resume_session` — internally calling the same logic `handleFetch` routes to +- [ ] Enforce the per-request `McpServer` factory in the API shape (complication 3) +- [ ] Map host errors to MCP tool errors, including the 409 turn-in-progress case (complication 4) +- [ ] Make suspend/resume explicit in tool output schemas: `status`, `pendingMessage`, `suspended` (complication 2) +- [ ] Unit tests alongside `storage.test.ts`; run through `./scripts/test.sh` + +### Phase 2 — AuthN/AuthZ and hardening + +- [ ] Close D4; implement bearer-token auth from an env binding in the MCP handler +- [ ] Reject unauthenticated requests before any tool executes; never fall back to trusting `X-User-Id` on the MCP path +- [ ] Add origin/host restrictions appropriate for each target (Workers config on Cloudflare; handler check on Node) +- [ ] Decide and document `userId` propagation from auth context into `TurnJob` + +### Phase 3 — Portability parity and tests + +- [ ] Extend `packages/cloud-agent/smoke` to exercise `/mcp` end-to-end on the Node host +- [ ] Add a test-app harness case: MCP client → `/mcp` → real turn → reply (both targets) +- [ ] Guard `initSchema` re-runs if profiling shows it matters + +### Phase 4 — Adoption, docs, release + +- [ ] Wire `/mcp` into Jerry's worker entry (one route added, nothing else) +- [ ] Prove genericity: wire the same handler into a second agent (test-app agent is enough) +- [ ] Document the MCP surface in `packages/cloud-agent/README` (tool schemas, auth, suspend/resume contract); cross-reference from `packages/README.md` +- [ ] Changeset for `@mieweb/cloud-agent`; open the PR separate from PR #1