diff --git a/README.md b/README.md index 1d7c84c..65704cb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # @factory/droid-sdk -TypeScript SDK for the [Factory](https://factory.ai) Droid CLI. Provides a high-level API for interacting with Droid as a subprocess, with one-shot prompts, streaming messages, multi-turn sessions, structured output, SDK-backed MCP tools, spec mode, tool controls, initialization metadata, session forking, session discovery, and tool permission handling. +TypeScript SDK for the [Factory](https://factory.ai) Droid CLI. Provides a high-level API for interacting with Droid as a subprocess: one-shot prompts, streaming, multi-turn sessions, structured output, in-process MCP tools, and tool permission control. + +For in-depth documentation, see the [SDK usage guide](./docs/sdk-usage-guide.md). To control a long-running `droid` daemon over WebSocket (multiple concurrent sessions, remote machines), see the [daemon usage guide](./docs/daemon-usage-guide.md). Low-level JSON-RPC protocol types and schemas are available from the `@factory/droid-sdk/protocol` subpath export. ## Requirements @@ -21,24 +23,30 @@ Send a one-shot prompt and get the aggregated result: import { run } from '@factory/droid-sdk'; const result = await run('What files are in the current directory?', { + apiKey: process.env.FACTORY_API_KEY!, cwd: '/my/project', }); console.log(result.text); ``` +`apiKey` is required by the type signature, but its runtime value may be `undefined`: the `!` only satisfies TypeScript. When the value is undefined, the `droid` CLI falls back to its stored login credentials, so `apiKey: process.env.FACTORY_API_KEY!` works on any machine where `droid` is logged in. + Create a session when you want to stream one or more turns: ```ts import { DroidMessageType, createSession } from '@factory/droid-sdk'; -const session = await createSession({ cwd: '/my/project' }); +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: '/my/project', +}); try { for await (const msg of session.stream( 'What files are in the current directory?' )) { - if (msg.type === DroidMessageType.AssistantTextDelta) { + if (msg.type === DroidMessageType.Assistant) { process.stdout.write(msg.text); } if (msg.type === DroidMessageType.Result) { @@ -50,6 +58,29 @@ try { } ``` +By default, `session.stream()` yields complete messages (`assistant`, `user`, `tool_call`, `tool_result`, `hook`, `error`, `result`). To stream token-by-token deltas, pass `includePartialMessages: true`: + +```ts +import { DroidMessageType, createSession } from '@factory/droid-sdk'; + +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: '/my/project', +}); + +try { + for await (const msg of session.stream('Tell me a short joke', { + includePartialMessages: true, + })) { + if (msg.type === DroidMessageType.AssistantTextDelta) { + process.stdout.write(msg.text); + } + } +} finally { + await session.close(); +} +``` + ## Structured Output Request a JSON object that matches a JSON Schema: @@ -58,6 +89,7 @@ Request a JSON object that matches a JSON Schema: import { OutputFormatType, run } from '@factory/droid-sdk'; const result = await run('Pick a favorite number between 1 and 42.', { + apiKey: process.env.FACTORY_API_KEY!, cwd: '/my/project', outputFormat: { type: OutputFormatType.JsonSchema, @@ -75,31 +107,37 @@ const result = await run('Pick a favorite number between 1 and 42.', { }, }); -console.log(result.structuredOutput?.favoriteNumber); +const { favoriteNumber } = result.structuredOutput as { + favoriteNumber: number; +}; +console.log(favoriteNumber); ``` -Structured output is available on `run()` and `session.stream(prompt, options)` through the `outputFormat` message option. `run()` parses the final object into `result.structuredOutput`; streaming callers can read and parse the final assistant message themselves. +Structured output is available on `run()` and `session.stream(prompt, options)` through the `outputFormat` message option. `run()` parses the final object into `result.structuredOutput`. It is typed `unknown`, so cast it (as above) or validate it with a schema library before use. ## Multi-Turn Sessions Use `createSession()` for persistent conversations with multiple turns: ```ts -import { createSession, DroidMessageType } from '@factory/droid-sdk'; +import { DroidMessageType, createSession } from '@factory/droid-sdk'; -const session = await createSession({ cwd: '/my/project' }); +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: '/my/project', +}); console.log(session.sessionId); // Streaming turn for await (const msg of session.stream('List all TypeScript files')) { - if (msg.type === DroidMessageType.AssistantTextDelta) { + if (msg.type === DroidMessageType.Assistant) { process.stdout.write(msg.text); } } // Later turns use the same streaming API for await (const msg of session.stream('Summarize the project')) { - if (msg.type === DroidMessageType.AssistantTextDelta) { + if (msg.type === DroidMessageType.Assistant) { process.stdout.write(msg.text); } } @@ -149,6 +187,7 @@ const sdkTools = createSdkMcpServer({ }); const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, cwd: '/my/project', mcpServers: [sdkTools], permissionHandler: () => ToolConfirmationOutcome.ProceedOnce, @@ -157,7 +196,7 @@ const session = await createSession({ for await (const msg of session.stream( 'Use the favorite_number tool for Ada and tell me the answer.' )) { - if (msg.type === DroidMessageType.AssistantTextDelta) { + if (msg.type === DroidMessageType.Assistant) { process.stdout.write(msg.text); } } @@ -165,6 +204,8 @@ for await (const msg of session.stream( await session.close(); ``` +MCP tool calls request confirmation even at the default autonomy level, so the session supplies a `permissionHandler` to approve them (see [Permission Handling](#permission-handling)). + `createSdkMcpServer()` is managed by the SDK session lifecycle. Use `tool()` with a Zod object shape for typed tool input validation. ## Initialization Metadata @@ -174,7 +215,10 @@ Inspect the raw initialization metadata from `createSession()` and `resumeSessio ```ts import { createSession, resumeSession } from '@factory/droid-sdk'; -const session = await createSession({ cwd: '/my/project' }); +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: '/my/project', +}); console.log(session.sessionId); console.log(session.initResult.settings.modelId); @@ -191,27 +235,27 @@ Start a session directly in spec mode, or enter spec mode later on an existing s ```ts import { - DroidMessageType, createSession, DroidInteractionMode, ReasoningEffort, } from '@factory/droid-sdk'; const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, cwd: '/my/project', interactionMode: DroidInteractionMode.Spec, specModeReasoningEffort: ReasoningEffort.High, - specModeModelId: 'claude-sonnet-4-20250514', + // specModeModelId: '', // optionally override the spec-mode model }); -for await (const msg of session.stream( +for await (const _msg of session.stream( 'Draft a plan for adding integration tests' )) { - if (msg.type === DroidMessageType.AssistantTextDelta) { - process.stdout.write(msg.text); - } + // Consume the turn. The drafted spec is delivered through the + // spec-mode approval flow (a permissionHandler confirmation). } +// Alternatively, switch an existing session into spec mode: await session.enterSpecMode({ specModeReasoningEffort: ReasoningEffort.High, }); @@ -219,36 +263,38 @@ await session.enterSpecMode({ await session.close(); ``` -When handling spec-mode approval, you can approve implementation in the same session with `ToolConfirmationOutcome.ProceedOnce`, or hand off to a fresh session with `ToolConfirmationOutcome.ProceedNewSessionHigh`. +When handling spec-mode approval in a `permissionHandler` (see [Permission Handling](#permission-handling)), you can approve implementation in the same session with `ToolConfirmationOutcome.ProceedOnce`, or hand off to a fresh session with `ToolConfirmationOutcome.ProceedNewSessionHigh`. See [`examples/spec-mode-same-session.ts`](./examples/spec-mode-same-session.ts) and [`examples/spec-mode-new-session.ts`](./examples/spec-mode-new-session.ts) for complete flows. ## Tool Controls -Control which exec tools are available at session start, inspect the current tool catalog, and update tool overrides later: +Override which exec tools are available at session start, inspect the current tool catalog, and update tool overrides later. Tool IDs are the CLI's internal IDs such as `'read-cli'`, `'execute-cli'`, and `'grep_tool_cli'` (not model-facing names like `'Read'`). Call `session.listTools()` to discover them: ```ts import { createSession } from '@factory/droid-sdk'; const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, cwd: '/my/project', - enabledToolIds: ['Read'], - disabledToolIds: ['Execute'], + disabledToolIds: ['execute-cli'], }); const { tools } = await session.listTools(); console.log( tools.map((tool) => ({ - id: tool.llmId, + id: tool.id, allowed: tool.currentlyAllowed, })) ); await session.updateSettings({ - disabledToolIds: ['Read', 'Execute'], + disabledToolIds: ['read-cli', 'execute-cli'], }); await session.close(); ``` +`disabledToolIds` turns the listed tools off. `enabledToolIds` re-enables tools that are off by default; it is applied on top of the default tool set, not as an exclusive allowlist. + ## Forking Sessions Fork the current server-side session and continue from the new session ID: @@ -260,7 +306,10 @@ import { resumeSession, } from '@factory/droid-sdk'; -const session = await createSession({ cwd: '/my/project' }); +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: '/my/project', +}); for await (const _msg of session.stream( 'Remember this phrase: mango sunrise' @@ -272,7 +321,7 @@ const { newSessionId } = await session.forkSession(); const fork = await resumeSession(newSessionId); for await (const msg of fork.stream('What phrase did I ask you to remember?')) { - if (msg.type === DroidMessageType.AssistantTextDelta) { + if (msg.type === DroidMessageType.Assistant) { process.stdout.write(msg.text); } } @@ -283,7 +332,7 @@ await session.close(); ## Listing Sessions -Discover droid sessions saved on disk (mirrors the CLI's `/sessions` command). Reads `~/.factory/sessions/` directly — no droid process is spawned, so this works even when no session is running: +Discover `droid` sessions saved on disk (mirrors the CLI's `/sessions` command). Reads `~/.factory/sessions/` directly — no `droid` process is spawned, so this works even when no session is running: ```ts import { listSessions } from '@factory/droid-sdk'; @@ -322,17 +371,20 @@ Each `SessionMetadata` record includes `id`, `title`, `sessionTitle`, `owner`, ` ## Permission Handling -Handle tool confirmation requests with a custom permission handler: +Handle tool confirmation requests with a custom permission handler. At the default autonomy level most tool calls are auto-approved, so set a stricter `autonomyLevel` (such as `AutonomyLevel.Off`) to route confirmations through your handler: ```ts import { + AutonomyLevel, DroidMessageType, createSession, ToolConfirmationOutcome, } from '@factory/droid-sdk'; const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, cwd: '/my/project', + autonomyLevel: AutonomyLevel.Off, permissionHandler(params) { console.log('Tool permission requested:', params); return ToolConfirmationOutcome.ProceedOnce; @@ -341,7 +393,7 @@ const session = await createSession({ try { for await (const msg of session.stream('Create a hello.txt file')) { - if (msg.type === DroidMessageType.AssistantTextDelta) { + if (msg.type === DroidMessageType.Assistant) { process.stdout.write(msg.text); } } @@ -354,14 +406,16 @@ try { ### Top-Level Functions -| Function | Description | -| ----------------------------- | ---------------------------------------------------------------- | -| `run(prompt, options?)` | One-shot prompt → aggregated `DroidResult` | -| `createSession(options?)` | Create a new multi-turn session → `DroidSession` | -| `resumeSession(id, options?)` | Resume an existing session → `DroidSession` | -| `listSessions(options?)` | List droid sessions saved on disk → `Promise` | -| `createSdkMcpServer(options)` | Create an SDK-managed MCP server for in-process tools | -| `tool(...)` | Define a typed SDK-backed MCP tool | +| Function | Description | +| ----------------------------- | --------------------------------------------------------- | +| `run(prompt, options)` | One-shot prompt → aggregated `DroidResult` | +| `createSession(options)` | Create a new multi-turn session → `DroidSession` | +| `resumeSession(id, options?)` | Resume an existing session → `DroidSession` | +| `listSessions(options?)` | List `droid` sessions saved on disk → `SessionMetadata[]` | +| `createSdkMcpServer(options)` | Create an SDK-managed MCP server for in-process tools | +| `tool(...)` | Define a typed SDK-backed MCP tool | + +The package also exports a daemon-mode surface (`connectDaemon`, `DaemonClient`, `DaemonSession`, `ensureLocalDaemon`, ...) for managing multiple sessions over WebSocket — see the [daemon usage guide](./docs/daemon-usage-guide.md). ### `DroidSession` @@ -378,8 +432,9 @@ Returned by `createSession()` and `resumeSession()`. Key methods: - **`addMcpServer(params)`** / **`removeMcpServer(params)`** / **`toggleMcpServer(params)`** — manage MCP servers - **`listMcpServers()`** / **`listMcpTools()`** / **`authenticateMcpServer(params)`** — inspect and authenticate MCP servers - **`listTools(params?)`** — inspect the exec tool catalog and current allow/deny state +- **`listSkills()`** — list skills available to the session - **`renameSession(params)`** — rename the current session -- **`getRewindInfo(params)`** / **`executeRewind(params)`** — inspect and execute file rewind operations +- **`onNotification(callback, filter?)`** — subscribe to raw session notifications; returns an unsubscribe function - **`sessionId`** — the session ID - **`initResult`** — cached `initialize_session` or `load_session` result @@ -394,8 +449,11 @@ Returned by `run()`: - **`durationMs`** — wall-clock time spent consuming the turn - **`turnCount`** — number of completed turns observed while consuming the stream - **`error`** — first Droid error event from the turn, or `null` -- **`structuredOutput`** — parsed structured JSON object, or `null` +- **`structuredOutput`** — parsed structured JSON object (typed `unknown`), or `null` - **`success`** — `true` when no Droid error event was emitted +- **`isError`** / **`subtype`** — discriminate success (`subtype: 'success'`) from error results (`subtype: 'error_during_execution' | 'error_structured_output'`) +- **`errors`** — error descriptions (error results only) +- **`structuredOutputError`** — structured output parse/validation failure, if any ### `DroidMessage` Types @@ -404,50 +462,63 @@ All messages have a discriminated `type` field: ```ts import { DroidMessageType } from '@factory/droid-sdk'; -if (msg.type === DroidMessageType.AssistantTextDelta) { - process.stdout.write(msg.text); +for await (const msg of session.stream(prompt, { + includePartialMessages: true, +})) { + if (msg.type === DroidMessageType.AssistantTextDelta) { + process.stdout.write(msg.text); + } } ``` -| Type | Description | -| -------------------------- | ----------------------------------------------- | -| `assistant` | Complete assistant message | -| `user` | Complete user message | -| `assistant_text_delta` | Streaming text token from the assistant | -| `assistant_text_complete` | End of an assistant text block | -| `thinking_text_delta` | Streaming reasoning/thinking token | -| `thinking_text_complete` | End of a thinking block | -| `tool_call` | Tool invocation by the assistant | -| `tool_call_delta` | Streaming tool call input | -| `tool_result` | Result from a tool execution | -| `tool_progress` | Progress update during tool execution | -| `hook` | File hook execution event (started or finished) | -| `working_state_changed` | Agent working state transition | -| `token_usage_update` | Updated token usage counters | -| `result` | End-of-turn sentinel with aggregated metadata | -| `session_title_updated` | Session title changed | -| `settings_updated` | Session settings changed | -| `permission_resolved` | Tool permission request resolved | -| `mcp_status_changed` | MCP server status changed | -| `mcp_auth_required` | MCP authentication required | -| `mcp_auth_completed` | MCP authentication completed | -| `error` | Error event from the process | -| `mission_state_changed` | Mission state changed | -| `mission_features_changed` | Mission features changed | -| `mission_progress_entry` | Mission progress log changed | -| `mission_heartbeat` | Mission heartbeat | -| `mission_worker_started` | Mission worker started | -| `mission_worker_completed` | Mission worker completed | +By default, `session.stream()` yields only these message types: + +| Type | Description | +| ------------- | ----------------------------------------------- | +| `assistant` | Complete assistant message | +| `user` | Complete user message | +| `tool_call` | Tool invocation by the assistant | +| `tool_result` | Result from a tool execution | +| `hook` | File hook execution event (started or finished) | +| `error` | Error event from the process | +| `result` | End-of-turn sentinel with aggregated metadata | + +The remaining types are only yielded when streaming with `includePartialMessages: true`: + +| Type | Description | +| -------------------------- | --------------------------------------- | +| `assistant_text_delta` | Streaming text token from the assistant | +| `assistant_text_complete` | End of an assistant text block | +| `thinking_text_delta` | Streaming reasoning/thinking token | +| `thinking_text_complete` | End of a thinking block | +| `tool_call_delta` | Streaming tool call input | +| `tool_progress` | Progress update during tool execution | +| `working_state_changed` | Agent working state transition | +| `token_usage_update` | Updated token usage counters | +| `session_title_updated` | Session title changed | +| `settings_updated` | Session settings changed | +| `permission_resolved` | Tool permission request resolved | +| `mcp_status_changed` | MCP server status changed | +| `mcp_auth_required` | MCP authentication required | +| `mcp_auth_completed` | MCP authentication completed | +| `mission_state_changed` | Mission state changed | +| `mission_features_changed` | Mission features changed | +| `mission_progress_entry` | Mission progress log changed | +| `mission_heartbeat` | Mission heartbeat | +| `mission_worker_started` | Mission worker started | +| `mission_worker_completed` | Mission worker completed | ### Options Session creation options used by `run()` and `createSession()` include: +- **`apiKey`** — Factory API key. Required by the type signature, but the runtime value may be `undefined` (the `!` only satisfies TypeScript); when undefined, the `droid` CLI falls back to its stored login credentials - **`cwd`** — working directory for the session - **`execPath`** — path to `droid` executable (default: `"droid"`) -- **`execArgs`** — extra CLI arguments for the spawned droid process +- **`execArgs`** — extra CLI arguments for the spawned `droid` process - **`env`** — environment variables for the spawned process - **`transport`** — provide a custom transport instead of spawning a process +- **`machineId`** — machine identifier for initialization (default: `"default"`) - **`modelId`** — LLM model identifier - **`autonomyLevel`** — `AutonomyLevel` enum value - **`interactionMode`** — `DroidInteractionMode` enum value @@ -455,35 +526,38 @@ Session creation options used by `run()` and `createSession()` include: - **`specModeModelId`** — override model used in spec mode - **`specModeReasoningEffort`** — override reasoning level used in spec mode - **`mcpServers`** — initial MCP server configurations, including SDK-backed MCP servers from `createSdkMcpServer()` -- **`enabledToolIds`** — explicit exec tool allowlist -- **`disabledToolIds`** — explicit exec tool denylist +- **`enabledToolIds`** — exec tool IDs to enable on top of the default tool set +- **`disabledToolIds`** — exec tool IDs to disable +- **`sessionSource`** — session source label for attribution +- **`tags`** — session tags - **`permissionHandler`** — callback for tool confirmations - **`askUserHandler`** — callback for interactive questions - **`abortSignal`** — standard `AbortSignal` for cancellation -`resumeSession()` accepts the process, transport, handler, `mcpServers`, and `abortSignal` options needed to reconnect to an existing session, but does not accept new-session-only options such as `modelId` or `interactionMode`. `cwd` is intentionally not accepted on resume: the persisted session's working directory is always used. To run in a different directory, create a new session or fork the existing one. +`resumeSession()` accepts the process, transport, handler, `mcpServers`, and `abortSignal` options needed to reconnect to an existing session (`apiKey` is optional on resume), but does not accept new-session-only options such as `modelId` or `interactionMode`. `cwd` is intentionally not accepted on resume: the persisted session's working directory is always used. To run in a different directory, create a new session or fork the existing one. Message APIs (`run()` and `session.stream()`) also accept: - **`images`** — base64 image attachments - **`files`** — document/file attachments - **`outputFormat`** — structured output request, currently `OutputFormatType.JsonSchema` +- **`includePartialMessages`** — `session.stream()` only: also yield partial events such as `assistant_text_delta` (see the message types tables above) - **`abortSignal`** — standard `AbortSignal` for turn cancellation ### `DroidClient` -Low-level JSON-RPC client for advanced use. Provides typed methods for the underlying protocol operations, including `listTools()`, `renameSession()`, `getRewindInfo()`, and `executeRewind()`. Most users should prefer `run()` and `createSession()`. +Low-level JSON-RPC client for advanced use. Provides typed methods for the underlying protocol operations, including `listTools()`, `listSkills()`, and `renameSession()`. Most users should prefer `run()` and `createSession()`. ### Error Types -| Error | Description | -| ---------------------- | -------------------------------------- | -| `ConnectionError` | Failed to connect to the droid process | -| `ProtocolError` | JSON-RPC protocol error | -| `SessionError` | Base session error | -| `SessionNotFoundError` | Session ID not found | -| `TimeoutError` | Request timed out | -| `ProcessExitError` | Droid subprocess exited unexpectedly | +| Error | Description | +| ---------------------- | ---------------------------------------- | +| `ConnectionError` | Failed to connect to the `droid` process | +| `ProtocolError` | JSON-RPC protocol error | +| `SessionError` | Base session error | +| `SessionNotFoundError` | Session ID not found | +| `TimeoutError` | Request timed out | +| `ProcessExitError` | Droid subprocess exited unexpectedly | ## Examples @@ -497,16 +571,17 @@ See the [`examples/`](./examples) directory for runnable examples: - **[`init-metadata.ts`](./examples/init-metadata.ts)** — read initialization and load metadata from session APIs - **[`result-metadata.ts`](./examples/result-metadata.ts)** — inspect `DroidResult` metadata from `run()` - **[`structured-output.ts`](./examples/structured-output.ts)** — request and parse structured output -- **[`droid-dev-structured-output.ts`](./examples/droid-dev-structured-output.ts)** — structured output smoke test with configurable Droid executable +- **[`readme-structured-output.ts`](./examples/readme-structured-output.ts)** — the README structured output snippet, runnable as-is - **[`permission-handler.ts`](./examples/permission-handler.ts)** — custom permission handling - **[`spec-mode-same-session.ts`](./examples/spec-mode-same-session.ts)** — approve a spec and continue in the same session - **[`spec-mode-new-session.ts`](./examples/spec-mode-new-session.ts)** — approve a spec and hand off implementation to a new session - **[`tool-controls.ts`](./examples/tool-controls.ts)** — configure allow/deny lists and inspect tool availability - **[`sdk-mcp-tool.ts`](./examples/sdk-mcp-tool.ts)** — expose SDK-defined tools to Droid through MCP +- **[`hook-execution.ts`](./examples/hook-execution.ts)** — observe file hook execution events - **[`fork-session.ts`](./examples/fork-session.ts)** — fork a session and continue from the new session ID -- **[`list-sessions.ts`](./examples/list-sessions.ts)** — discover droid sessions saved on disk -- **[`test-compact.ts`](./examples/test-compact.ts)** — compact session history -- **[`test-rewind.ts`](./examples/test-rewind.ts)** — use low-level rewind APIs +- **[`list-sessions.ts`](./examples/list-sessions.ts)** — discover `droid` sessions saved on disk +- **[`compact-session.ts`](./examples/compact-session.ts)** — compact session history +- **[`daemon-multi-session.ts`](./examples/daemon-multi-session.ts)** — run multiple sessions against a local `droid` daemon ## License diff --git a/docs/daemon-usage-guide.md b/docs/daemon-usage-guide.md index 5dbec63..d99e7b4 100644 --- a/docs/daemon-usage-guide.md +++ b/docs/daemon-usage-guide.md @@ -2,6 +2,30 @@ The daemon SDK connects to a running `droid daemon` process over WebSocket instead of spawning a new subprocess per session. This enables multiple concurrent sessions over a single connection and is the transport used by Slack, Linear, REST API, and Automations integrations. +## Table of Contents + +- [Getting Started](#getting-started) +- [Daemon vs Exec Mode](#daemon-vs-exec-mode) +- [Connecting to a Daemon](#connecting-to-a-daemon) + - [Connect to Remote Machine](#connect-to-remote-machine) + - [Direct URL](#direct-url) + - [Connection Retries](#connection-retries) +- [Create a Session](#create-a-session) +- [Stream a Response](#stream-a-response) +- [Partial Message Streaming](#partial-message-streaming) +- [Fire-and-Forget with send()](#fire-and-forget-with-send) +- [Multi-turn Session](#multi-turn-session) +- [Resume a Previous Session](#resume-a-previous-session) +- [Concurrent Sessions](#concurrent-sessions) +- [Interrupt a Session](#interrupt-a-session) +- [Permission Handler](#permission-handler) +- [Ask-User Handler](#ask-user-handler) +- [SDK-backed MCP Tools](#sdk-backed-mcp-tools) +- [Raw Notification Subscription](#raw-notification-subscription) +- [Error Handling](#error-handling) +- [Lifecycle Pattern](#lifecycle-pattern) +- [Configuration Reference](#configuration-reference) + ## Getting Started ```bash @@ -10,6 +34,8 @@ npm install @factory/droid-sdk Requires a running `droid daemon` (the SDK will auto-start one locally) and a `FACTORY_API_KEY`. For simpler use cases that don't need concurrent sessions, see the [SDK Usage Guide](./sdk-usage-guide.md) which covers exec mode (`run()`, `createSession()`). +> **`apiKey` is mandatory in daemon mode.** Unlike exec mode (where the `droid` CLI falls back to its stored login credentials when `apiKey` is `undefined`), daemon authentication has **no stored-credential fallback**. You must pass a real `apiKey` (or `token`) to `connectDaemon()`. Passing `apiKey: undefined` fails the handshake with `ConnectionError: Daemon authentication failed: Parse error`. This is the single biggest gotcha when moving from exec mode to daemon mode. + ```ts import { connectDaemon, DroidMessageType } from '@factory/droid-sdk'; @@ -41,9 +67,11 @@ await connection.close(); Use daemon mode when you need multiple concurrent sessions, want to avoid subprocess overhead, or are building a server-side integration. +> **Convention used in this guide:** for brevity, examples call `session.close()` and `connection.close()` directly after a turn. In production, wrap turns in `try`/`finally` (see [Lifecycle Pattern](#lifecycle-pattern)) so sessions and the connection are always closed even if a turn throws. + --- -## Connect to Local Daemon +## Connecting to a Daemon The simplest form -- the SDK spawns a local daemon and authenticates with the provided API key. @@ -60,18 +88,8 @@ const connection = await connectDaemon({ ```ts import { connectDaemon, MachineType } from '@factory/droid-sdk'; -// Ephemeral sandbox (e2b) -const connection = await connectDaemon({ - apiKey: process.env.FACTORY_API_KEY!, - machine: { - type: MachineType.Ephemeral, - sandboxId: 'sandbox-abc123', - workspaceId: 'ws-xyz', - }, -}); - // Computer relay -const connection2 = await connectDaemon({ +const connection = await connectDaemon({ apiKey: process.env.FACTORY_API_KEY!, machine: { type: MachineType.Computer, @@ -80,6 +98,8 @@ const connection2 = await connectDaemon({ }); ``` +The SDK currently supports two machine targets: `MachineType.Local` (the default) and `MachineType.Computer` (a remote computer reached via the Factory relay). Ephemeral/remote sandbox targets are not yet part of the public SDK surface. + ### Direct URL Skip machine-based resolution and connect to a specific WebSocket endpoint. @@ -127,7 +147,7 @@ const session = await connection.createSession({ ## Stream a Response -`stream()` yields complete messages by default: assistant text, tool calls, tool results, and the final result. +By default, `stream()` yields complete messages: assistant text, user messages, tool calls, tool results, hooks, errors, and the final result. ```ts import { DroidMessageType } from '@factory/droid-sdk'; @@ -482,41 +502,41 @@ try { ### `ConnectDaemonOptions` -| Field | Type | Required | Description | -| :------------- | :----------------- | :------- | :--------------------------------------------------------------------- | -| `apiKey` | `string` | **Yes** | Factory API key for authentication. | -| `machine` | `SDKMachineConfig` | No | Machine target. Defaults to local daemon. | -| `url` | `string` | No | Direct WebSocket URL. Overrides machine resolution. | -| `maxRetries` | `number` | No | Retry budget for connect+authenticate cycle. | -| `daemonPort` | `number` | No | WebSocket port override. Default: `37643`. | -| `relayBaseUrl` | `string` | No | Relay URL for computer connections. Default: `wss://relay.factory.ai`. | +| Field | Type | Required | Description | +| :------------- | :----------------- | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiKey` | `string` | **Yes** | Factory API key for authentication. | +| `machine` | `SDKMachineConfig` | No | Machine target. Defaults to local daemon. | +| `url` | `string` | No | Direct WebSocket URL. Overrides machine resolution. | +| `maxRetries` | `number` | No | Retry budget for connect+authenticate cycle. | +| `daemonPort` | `number` | No | WebSocket port override. Default: `37643`. | +| `relayBaseUrl` | `string` | No | Relay URL for computer connections. Default: `wss://relay.factory.ai`. | +| `baseUrl` | `string` | No | Reserved for the Factory API base URL (default: `https://api.factory.ai`). Accepted by the type but not yet used by the current connect flow. | ### `SDKMachineConfig` -| Variant | Fields | Description | -| :---------- | :--------------------------------- | :---------------------------- | -| `Local` | `{ type: MachineType.Local }` | Local daemon on this machine. | -| `Ephemeral` | `{ type, sandboxId, workspaceId }` | e2b sandbox. | -| `Computer` | `{ type, computerId }` | Remote computer via relay. | +| Variant | Fields | Description | +| :--------- | :---------------------------- | :---------------------------- | +| `Local` | `{ type: MachineType.Local }` | Local daemon on this machine. | +| `Computer` | `{ type, computerId }` | Remote computer via relay. | ### `DaemonSessionOptions` -| Field | Type | Description | -| :------------------------ | :----------------------- | :--------------------------------------------- | -| `cwd` | `string` | Working directory for the session. | -| `modelId` | `string` | LLM model identifier. | -| `autonomyLevel` | `AutonomyLevel` | `Off` \| `Low` \| `Medium` \| `High`. | -| `interactionMode` | `DroidInteractionMode` | `Auto` \| `Spec` \| `AGI`. | -| `reasoningEffort` | `ReasoningEffort` | `Off` \| `Low` \| `Medium` \| `High` \| `Max`. | -| `specModeModelId` | `string` | Override model for spec mode. | -| `specModeReasoningEffort` | `ReasoningEffort` | Override reasoning effort for spec mode. | -| `mcpServers` | `DroidMcpServerConfig[]` | MCP server configurations. | -| `enabledToolIds` | `string[]` | Tool allowlist. | -| `disabledToolIds` | `string[]` | Tool denylist. | -| `tags` | `SessionTag[]` | Session tags for categorization. | -| `permissionHandler` | `PermissionHandler` | Tool confirmation callback. | -| `askUserHandler` | `AskUserHandler` | Structured user-input callback. | -| `sessionSource` | `SessionSource` | Attribution metadata. | +| Field | Type | Description | +| :------------------------ | :----------------------- | :------------------------------------------------------------------------------------------------------------ | +| `cwd` | `string` | Working directory for the session. | +| `modelId` | `string` | LLM model identifier. | +| `autonomyLevel` | `AutonomyLevel` | `Off` \| `Low` \| `Medium` \| `High`. | +| `interactionMode` | `DroidInteractionMode` | `Auto` \| `Spec` \| `AGI`. | +| `reasoningEffort` | `ReasoningEffort` | `None` \| `Dynamic` \| `Off` \| `Minimal` \| `Low` \| `Medium` \| `High` \| `ExtraHigh` (`'xhigh'`) \| `Max`. | +| `specModeModelId` | `string` | Override model for spec mode. | +| `specModeReasoningEffort` | `ReasoningEffort` | Override reasoning effort for spec mode. | +| `mcpServers` | `DroidMcpServerConfig[]` | MCP server configurations. | +| `enabledToolIds` | `string[]` | Tool IDs to enable on top of the default set (not an exclusive allowlist). Use real CLI IDs like `read-cli`. | +| `disabledToolIds` | `string[]` | Tool IDs to disable. Use real CLI IDs like `execute-cli`. | +| `tags` | `SessionTag[]` | Session tags for categorization. | +| `permissionHandler` | `PermissionHandler` | Tool confirmation callback. | +| `askUserHandler` | `AskUserHandler` | Structured user-input callback. | +| `sessionSource` | `SessionSource` | Attribution metadata. | ### `DaemonResumeOptions` @@ -536,9 +556,39 @@ try { ### Key Classes -| Class | Description | -| :------------------- | :------------------------------------------------------------------------- | -| `DaemonConnection` | Manages the WebSocket connection. Creates/resumes sessions. | -| `DaemonSession` | A single session. Provides `stream()`, `send()`, `interrupt()`, `close()`. | -| `DaemonClient` | Low-level RPC client. Used internally by `DaemonSession`. | -| `WebSocketTransport` | WebSocket transport with retry and reconnection. | +| Class | Description | +| :------------------- | :---------------------------------------------------------- | +| `DaemonConnection` | Manages the WebSocket connection. Creates/resumes sessions. | +| `DaemonSession` | A single session (see methods below). | +| `DaemonClient` | Low-level RPC client. Used internally by `DaemonSession`. | +| `WebSocketTransport` | WebSocket transport with connect retry. | + +`WebSocketTransport` retries the **initial** connect with exponential backoff; it does not automatically reconnect after an established connection drops. + +#### `DaemonSession` methods + +| Method | Description | +| :------------------------------ | :--------------------------------------------------------------- | +| `sessionId` | Getter for the session's ID. | +| `stream(prompt, options?)` | Run a turn and yield messages (or partial events). | +| `send(prompt, options?)` | Fire-and-forget: dispatch a prompt and return after the ACK. | +| `interrupt()` | Interrupt the current turn server-side. | +| `close()` | Close the session and its client view. | +| `updateSettings(params)` | Change session settings (model, autonomy, reasoning, tools...). | +| `enterSpecMode(params?)` | Switch the session into spec (planning) mode. | +| `compactSession(params?)` | Summarize and remove old messages; returns a new session id. | +| `forkSession()` | Copy the session with context preserved. | +| `getContextBreakdown()` | Inspect current context window usage. | +| `renameSession(params)` | Give the session a human-readable title. | +| `addMcpServer(params)` | Add an external MCP server (user-level settings). | +| `removeMcpServer(params)` | Remove an external MCP server. | +| `toggleMcpServer(params)` | Enable/disable an external MCP server. | +| `listMcpServers()` | List configured MCP servers and their status. | +| `listMcpTools()` | List tools exposed by connected MCP servers. | +| `authenticateMcpServer(params)` | Authenticate an OAuth-style MCP server. | +| `listSkills()` | List skills available in the session. | +| `onNotification(cb, filter?)` | Subscribe to raw protocol notifications; returns an unsubscribe. | + +### Low-level Exports + +For advanced use, the package root also exports the daemon primitives used internally: `ensureLocalDaemon()` (discover or spawn a local daemon) and `resolveWebSocketUrl()` (compute the WebSocket URL from `ConnectDaemonOptions`). The default port (`37643`) and relay URL (`wss://relay.factory.ai`) are the values shown in the [`ConnectDaemonOptions`](#connectdaemonoptions) table. diff --git a/docs/sdk-usage-guide.md b/docs/sdk-usage-guide.md index aabb176..5abd41a 100644 --- a/docs/sdk-usage-guide.md +++ b/docs/sdk-usage-guide.md @@ -1,12 +1,58 @@ # SDK Usage Guide +In-depth guide to `@factory/droid-sdk`, the TypeScript SDK for the Factory Droid CLI. For a quick overview and the public API reference, see the [README](../README.md). This guide is the depth document: it walks through each capability with a runnable example. + +This guide covers **exec mode** (`run()`, `createSession()`), which spawns a `droid` subprocess per session. For WebSocket-based daemon mode with concurrent sessions, see the [Daemon Usage Guide](./daemon-usage-guide.md). + +## Table of Contents + +- [Getting Started](#getting-started) +- [Core Usage](#core-usage) + - [One-shot Run](#one-shot-run) + - [Structured Output](#structured-output) +- [Sessions](#sessions) + - [Multi-turn Session](#multi-turn-session) + - [Resume Session](#resume-session) + - [Fork Session](#fork-session) + - [Compact Session](#compact-session) + - [List Sessions](#list-sessions) + - [Rename Session](#rename-session) +- [Streaming](#streaming) + - [Full Message Streaming](#full-message-streaming) + - [Partial Message Streaming](#partial-message-streaming) + - [Interrupt or Cancel Running Work](#interrupt-or-cancel-running-work) +- [Controlling the Droid](#controlling-the-droid) + - [Autonomy Levels](#autonomy-levels) + - [Enabled/Disabled Tools](#enableddisabled-tools) + - [Permission Handler](#permission-handler) + - [Ask-User Handler](#ask-user-handler) + - [Spec Mode](#spec-mode) + - [Model and Reasoning Effort](#model-and-reasoning-effort) + - [Multimodal Input](#multimodal-input) +- [MCP](#mcp) + - [SDK-backed MCP Tools](#sdk-backed-mcp-tools) + - [MCP Server Management](#mcp-server-management) +- [Observability](#observability) + - [Hook Execution Monitoring](#hook-execution-monitoring) + - [Token Usage Tracking](#token-usage-tracking) + - [Context Stats](#context-stats) + - [List Skills](#list-skills) + - [Raw Notification Subscription](#raw-notification-subscription) +- [Error Handling](#error-handling) +- [Low-level APIs](#low-level-apis) +- [Configuration Reference](#configuration-reference) + +> **Convention used in this guide:** most examples create a session with `apiKey` and `cwd`, run a turn, and then `close()`. The two creation options are repeated for copy-paste convenience; in your own code you typically create one session and reuse it across turns. For brevity, examples call `close()` directly after the turn; in production, wrap turns in `try`/`finally` (as shown in the [README](../README.md)) so the subprocess is always closed even if a turn throws. + +--- + ## Getting Started ```bash npm install @factory/droid-sdk ``` -Requires Node.js 18+ and the `droid` CLI on your PATH. This guide covers **exec mode** (`run()`, `createSession()`), which spawns a subprocess per session. For WebSocket-based daemon mode with concurrent sessions, see the [Daemon Usage Guide](./daemon-usage-guide.md). +Requires Node.js 18+ and the `droid` CLI on your PATH. ```ts import { run } from '@factory/droid-sdk'; @@ -18,9 +64,13 @@ const result = await run('What files are in this directory?', { console.log(result.text); ``` +`apiKey` is required by the type signature, but its runtime value may be `undefined`: the `!` only satisfies TypeScript. When the value is undefined, the `droid` CLI falls back to its stored login credentials, so `apiKey: process.env.FACTORY_API_KEY!` works on any machine where `droid` is logged in. The remaining examples use the same pattern. + --- -## One-shot Run +## Core Usage + +### One-shot Run Send a prompt, get a result, done. The session is created and closed automatically. @@ -34,9 +84,13 @@ const result = await run('What is 2 + 2?', { console.log(result.text); ``` -## Structured Output +`run()` accepts every [`MessageOptions`](#messageoptions) field except `includePartialMessages` (one-shot runs aggregate the whole turn, so there is nothing to stream). See [Structured Output](#structured-output) for an example that uses `outputFormat`. + +The returned `DroidResult` carries more than `text`. See [Error Handling](#error-handling) for how to discriminate success from error results (`isError`, `subtype`, `errors`, `structuredOutputError`). + +### Structured Output -Force the response to match a JSON schema. The validated object is available on `result.structuredOutput`. +Force the response to match a JSON schema. The validated object is available on `result.structuredOutput` (typed `unknown`, so cast it or validate it before use). ```ts import { OutputFormatType, run } from '@factory/droid-sdk'; @@ -57,7 +111,13 @@ const result = await run('Pick a number between 1 and 42.', { console.log((result.structuredOutput as { number: number }).number); ``` -## Multi-turn Session +If the model's output cannot be coerced to the schema, the result becomes an **error result** with `subtype: 'error_structured_output'` and a populated `structuredOutputError` (see [Error Handling](#error-handling)). Always check `result.isError` before reading `structuredOutput`. + +--- + +## Sessions + +### Multi-turn Session Create a session once, then call `stream()` multiple times. Context is preserved across turns. @@ -80,27 +140,125 @@ for await (const msg of session.stream('What word did I say?')) { await session.close(); ``` -## Resume Session +### Resume Session -Reconnect to a previously created session by its ID. +Reconnect to a previously created session by its ID. The `options` argument is optional; when omitted, the CLI uses its stored credentials. ```ts import { resumeSession, DroidMessageType } from '@factory/droid-sdk'; -const session = await resumeSession('existing-session-id', { +const session = await resumeSession('existing-session-id'); + +for await (const msg of session.stream('Continue where we left off.')) { + if (msg.type === DroidMessageType.Assistant) console.log(msg.text); +} + +await session.close(); +``` + +`resumeSession()` always runs in the working directory persisted with the session; it intentionally does not accept a `cwd` option. To run in a different directory, create a new session or fork the existing one. See [`ResumeSessionOptions`](#resumesessionoptions) for the full option set. + +### Fork Session + +Create a copy of the current session with all context preserved. Useful for branching a conversation. + +```ts +import { + createSession, + DroidMessageType, + resumeSession, +} from '@factory/droid-sdk'; + +const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, + cwd: process.cwd(), }); +for await (const msg of session.stream('Remember: the password is "banana".')) { +} -for await (const msg of session.stream('Continue where we left off.')) { +const { newSessionId } = await session.forkSession(); +const fork = await resumeSession(newSessionId); + +for await (const msg of fork.stream('What is the password?')) { if (msg.type === DroidMessageType.Assistant) console.log(msg.text); } +await fork.close(); await session.close(); ``` -## Full Message Streaming +### Compact Session -`stream()` yields complete messages: assistant text, tool calls, tool results, hooks, errors, and the final result. +Summarize and remove old messages to free up context window space. + +```ts +import { createSession, resumeSession } from '@factory/droid-sdk'; + +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: process.cwd(), +}); + +// ... after many turns ... +const result = await session.compactSession({ + // customInstructions: 'Keep the API design decisions verbatim.', +}); +console.log( + `New session: ${result.newSessionId}, removed: ${result.removedCount} messages` +); + +await session.close(); + +// Compaction produces a NEW session id. The original `session` object still +// points at the pre-compaction session id, so to continue with the compacted +// history, resume the new id: +const compacted = await resumeSession(result.newSessionId); +// ... use `compacted` ... +await compacted.close(); +``` + +`compactSession()` optionally takes `customInstructions` to steer what the summary preserves. + +### List Sessions + +Discover saved sessions on disk. Filters to the current project by default (`cwd` defaults to `process.cwd()`, `fetchOutsideCWD` defaults to `false`). No `droid` process is spawned. + +```ts +import { listSessions } from '@factory/droid-sdk'; + +const sessions = await listSessions({ numSessions: 10 }); + +for (const s of sessions) { + console.log(`${s.id}: ${s.sessionTitle ?? '(untitled)'}`); +} +``` + +`ListSessionsOptions`: `cwd` (scope to a directory; ignored when `fetchOutsideCWD` is `true`), `fetchOutsideCWD` (return sessions from every working directory), `numSessions` (cap on results), `sessionsDir` (override the sessions root, default `~/.factory/sessions/`). + +### Rename Session + +Give the current session a human-readable title. + +```ts +import { createSession } from '@factory/droid-sdk'; + +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: process.cwd(), +}); + +await session.renameSession({ title: 'Refactor auth module' }); + +await session.close(); +``` + +--- + +## Streaming + +### Full Message Streaming + +By default, `stream()` yields complete messages: assistant text, user messages, tool calls, tool results, hooks, errors, and the final result. ```ts import { createSession, DroidMessageType } from '@factory/droid-sdk'; @@ -132,9 +290,11 @@ for await (const msg of session.stream( await session.close(); ``` -## Partial Message Streaming +The default stream also yields `user` messages (`DroidMessageType.User`); the example above simply does not handle them. See the README's [DroidMessage Types](../README.md#droidmessage-types) tables for the full type list. -Enable `includePartialMessages` to get token-by-token deltas, thinking blocks, and tool progress as they arrive. +### Partial Message Streaming + +Enable `includePartialMessages` to also get token-by-token deltas, thinking blocks, tool progress, token-usage updates, and more. ```ts import { createSession, DroidMessageType } from '@factory/droid-sdk'; @@ -155,7 +315,7 @@ for await (const msg of session.stream('Explain recursion.', { await session.close(); ``` -## Interrupt or Cancel Running Work +### Interrupt or Cancel Running Work Use `session.interrupt()` to stop the current turn server-side, or pass an `AbortSignal` to cancel from the client. @@ -169,7 +329,9 @@ const session = await createSession({ }); for await (const msg of session.stream('Write a long essay.')) { if (msg.type === DroidMessageType.Assistant) { + // Interrupt once, after the first chunk of output, then stop reading. await session.interrupt(); + break; } } await session.close(); @@ -193,69 +355,55 @@ try { await session2.close(); ``` -## SDK-backed MCP Tools +When you abort via `AbortSignal`, the stream throws a plain `Error` carrying the signal's `reason` (not one of the typed SDK errors). See [Error Handling](#error-handling). -Define custom tools that Droid can call during a session. Tools are served via a local MCP server that the SDK manages automatically. +--- -```ts -import { - createSession, - createSdkMcpServer, - DroidMessageType, - tool, - ToolConfirmationOutcome, -} from '@factory/droid-sdk'; -import { z } from 'zod'; +## Controlling the Droid -const server = createSdkMcpServer({ - name: 'my-tools', - tools: [ - tool( - 'lookup', - 'Look up a user by name', - { name: z.string() }, - ({ name }) => { - return `${name} is user #42.`; - } - ), - ], -}); +### Autonomy Levels + +Control what Droid can do without asking for permission. Set at session creation or change mid-session. + +```ts +import { createSession, AutonomyLevel } from '@factory/droid-sdk'; const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, cwd: process.cwd(), - mcpServers: [server], - permissionHandler: () => ToolConfirmationOutcome.ProceedOnce, + autonomyLevel: AutonomyLevel.High, // Off | Low | Medium | High }); -for await (const msg of session.stream('Look up Alice.')) { - if (msg.type === DroidMessageType.Assistant) console.log(msg.text); -} - +// Change mid-session +await session.updateSettings({ autonomyLevel: AutonomyLevel.Low }); await session.close(); ``` -## Autonomy Levels +### Enabled/Disabled Tools -Control what Droid can do without asking for permission. Set at session creation or change mid-session. +Restrict which exec tools Droid can use. Tool IDs are the CLI's internal IDs (`'read-cli'`, `'execute-cli'`, `'grep_tool_cli'`, `'ls-cli'`, `'glob-search-cli'`, ...), **not** the model-facing names like `'Read'`. Use [`session.listTools()`](#tool-discovery-with-listtools) to discover the exact IDs and their current allow/deny state. ```ts -import { createSession, AutonomyLevel } from '@factory/droid-sdk'; +import { createSession } from '@factory/droid-sdk'; const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, cwd: process.cwd(), - autonomyLevel: AutonomyLevel.High, // Off | Low | Medium | High + disabledToolIds: ['execute-cli'], }); // Change mid-session -await session.updateSettings({ autonomyLevel: AutonomyLevel.Low }); +await session.updateSettings({ + disabledToolIds: ['read-cli', 'execute-cli'], +}); await session.close(); ``` -## Enabled/Disabled Tools +`disabledToolIds` turns the listed tools off. `enabledToolIds` re-enables tools that are off by default; it is applied **on top of** the default tool set, not as an exclusive allowlist. Disabling a tool is the reliable way to restrict capability. + +#### Tool discovery with `listTools()` -Restrict which tools Droid can use. Accepts tool IDs like `'Read'`, `'Execute'`, `'Grep'`. +`session.listTools()` returns the exec tool catalog with each tool's `id`, `llmId`, `displayName`, `category`, `defaultAllowed`, and `currentlyAllowed`. Use it to find IDs and confirm the effect of your overrides. ```ts import { createSession } from '@factory/droid-sdk'; @@ -263,18 +411,22 @@ import { createSession } from '@factory/droid-sdk'; const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, cwd: process.cwd(), - enabledToolIds: ['Read', 'Grep'], - disabledToolIds: ['Execute'], + disabledToolIds: ['execute-cli'], }); -// Change mid-session -await session.updateSettings({ disabledToolIds: ['Read', 'Execute'] }); +const { tools } = await session.listTools(); +for (const t of tools) { + console.log(`${t.id} (${t.llmId}): allowed=${t.currentlyAllowed}`); +} + await session.close(); ``` -## Permission Handler +### Permission Handler + +Programmatically approve or reject tool calls instead of prompting a human. The handler receives full tool details including file paths and commands. -Programmatically approve or reject tool calls instead of prompting a human. Receives full tool details including file paths and commands. +**By default (no handler set), every confirmation request is cancelled.** At higher autonomy levels most tool calls are auto-approved and never reach a handler; to route confirmations through your handler, set a stricter `autonomyLevel` (such as `AutonomyLevel.Off`) or rely on the tools that always require confirmation (such as MCP tools, see below). ```ts import { @@ -297,9 +449,40 @@ await run('Create hello.txt with "Hello, World!"', { }); ``` -## Spec Mode +A handler may return a `ToolConfirmationOutcome` enum value, a plain string outcome, or an object of the form `{ selectedOption, updatedContent? }`. Returning `ToolConfirmationOutcome.Cancel` rejects the call. + +### Ask-User Handler + +Programmatically answer questions that Droid asks the user during execution. + +```ts +import { createSession, DroidMessageType } from '@factory/droid-sdk'; + +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: process.cwd(), + askUserHandler(params) { + return { + cancelled: false, + answers: params.questions.map((q) => ({ + index: q.index, + question: q.question, + answer: q.options[0] ?? 'yes', + })), + }; + }, +}); + +for await (const msg of session.stream('Help me set up this project.')) { + if (msg.type === DroidMessageType.Assistant) console.log(msg.text); +} + +await session.close(); +``` + +### Spec Mode -Start Droid in read-only planning mode. It will research and produce a plan, then request to exit spec mode for implementation. +Start Droid in read-only planning mode. It researches and produces a plan, then requests to exit spec mode for implementation. ```ts import { @@ -328,9 +511,35 @@ for await (const msg of session.stream('Plan a refactor of src/utils.ts')) { await session.close(); ``` -## Multimodal Input +You can also switch an existing session into spec mode with `session.enterSpecMode({ specModeModelId?, specModeReasoningEffort? })`. When approving the exit-spec-mode confirmation, returning `ToolConfirmationOutcome.ProceedOnce` continues implementation in the same session, while `ToolConfirmationOutcome.ProceedNewSessionHigh` hands off to a fresh session. + +### Model and Reasoning Effort -Send images or documents alongside your prompt. Images must be base64-encoded. +Choose which model to use and how much reasoning effort to apply. Configurable at creation or mid-session. + +```ts +import { createSession, ReasoningEffort } from '@factory/droid-sdk'; + +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: process.cwd(), + modelId: 'claude-sonnet-4-20250514', + reasoningEffort: ReasoningEffort.High, +}); + +// Change mid-session +await session.updateSettings({ + reasoningEffort: ReasoningEffort.Low, +}); + +await session.close(); +``` + +For valid model identifiers, see the `ModelID` enum in the [`@factory/droid-sdk/protocol`](#low-level-apis) subpath export. The full `ReasoningEffort` enum is listed in the [Configuration Reference](#createsessionoptions). + +### Multimodal Input + +Send images or documents alongside your prompt. Images must be base64-encoded; `mediaType` must be one of `image/jpeg`, `image/png`, `image/gif`, or `image/webp`. ```ts import { readFileSync } from 'node:fs'; @@ -356,137 +565,150 @@ for await (const msg of session.stream('Describe this image.', { await session.close(); ``` -## Fork Session - -Create a copy of the current session with all context preserved. Useful for branching a conversation. +Documents are passed through the `files` option (`DocumentSource[]`) the same way: ```ts -import { - createSession, - DroidMessageType, - resumeSession, -} from '@factory/droid-sdk'; +import { readFileSync } from 'node:fs'; +import { createSession, DroidMessageType } from '@factory/droid-sdk'; const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, cwd: process.cwd(), }); -for await (const msg of session.stream('Remember: the password is "banana".')) { -} -const { newSessionId } = await session.forkSession(); -const fork = await resumeSession(newSessionId, { - apiKey: process.env.FACTORY_API_KEY!, -}); - -for await (const msg of fork.stream('What is the password?')) { +for await (const msg of session.stream('Summarize this document.', { + files: [ + { + type: 'base64', + data: readFileSync('report.pdf').toString('base64'), + mediaType: 'application/pdf', + }, + ], +})) { if (msg.type === DroidMessageType.Assistant) console.log(msg.text); } -await fork.close(); await session.close(); ``` -## Compact Session - -Summarize and remove old messages to free up context window space. - -```ts -import { createSession } from '@factory/droid-sdk'; - -const session = await createSession({ - apiKey: process.env.FACTORY_API_KEY!, - cwd: process.cwd(), -}); - -// ... after many turns ... -const result = await session.compactSession(); -console.log( - `New session: ${result.newSessionId}, removed: ${result.removedCount} messages` -); +--- -await session.close(); -``` +## MCP -## Rewind +### SDK-backed MCP Tools -Undo to a specific message and optionally restore files to their state at that point. +Define custom tools that Droid can call during a session. Tools are served via a local MCP server that the SDK starts and stops automatically with the session lifecycle. ```ts import { - DroidClient, - ProcessTransport, - AutonomyLevel, + createSession, + createSdkMcpServer, + DroidMessageType, + tool, + ToolConfirmationOutcome, + ToolConfirmationType, } from '@factory/droid-sdk'; +import { z } from 'zod'; -const transport = new ProcessTransport({ - cwd: process.cwd(), - env: { FACTORY_API_KEY: process.env.FACTORY_API_KEY! }, +const server = createSdkMcpServer({ + name: 'my-tools', + tools: [ + tool( + 'lookup', + 'Look up a user by name', + { name: z.string() }, + ({ name }) => { + return `${name} is user #42.`; + } + ), + ], }); -await transport.connect(); -const client = new DroidClient({ transport }); -await client.initializeSession({ - machineId: 'default', +const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, cwd: process.cwd(), - autonomyLevel: AutonomyLevel.High, + mcpServers: [server], + permissionHandler(params) { + const allMcp = params.toolUses.every( + (item) => item.details.type === ToolConfirmationType.McpTool + ); + return allMcp + ? ToolConfirmationOutcome.ProceedOnce + : ToolConfirmationOutcome.Cancel; + }, }); -const messageId = 'target-message-id'; -const info = await client.getRewindInfo({ messageId }); -console.log(`Files available to restore: ${info.availableFiles.length}`); - -const result = await client.executeRewind({ - messageId, - filesToRestore: info.availableFiles, - filesToDelete: [], - forkTitle: 'Rewind checkpoint', -}); -console.log(`Rewound into session: ${result.newSessionId}`); +for await (const msg of session.stream('Look up Alice.')) { + if (msg.type === DroidMessageType.Assistant) console.log(msg.text); +} -await client.close(); +await session.close(); ``` -## List Sessions +**MCP tool calls request confirmation even at the default autonomy level**, so the session must supply a `permissionHandler` (or set a high autonomy level) to approve them. The handler above gates approval on `ToolConfirmationType.McpTool` so it only auto-approves your tools and cancels everything else. -Discover saved sessions on disk. Filters to the current project by default. +`tool()` has two forms: `tool(name, description, zodShape, handler)` for typed input validation (the input is parsed against the Zod object shape), and `tool(name, description, handler)` with no schema for tools that take no structured input. A tool handler may return a string or a full MCP `CallToolResult`. -```ts -import { listSessions } from '@factory/droid-sdk'; +### MCP Server Management -const sessions = await listSessions({ numSessions: 10 }); +Add, remove, toggle, and list external MCP servers from within an active session. -for (const s of sessions) { - console.log(`${s.id}: ${s.sessionTitle ?? '(untitled)'}`); -} -``` - -## Model and Reasoning Effort - -Choose which model to use and how much reasoning effort to apply. Configurable at creation or mid-session. +> **Side effect warning:** `addMcpServer()` persists the server into your **user-level** `droid` settings, so it remains configured in future sessions until removed. `removeMcpServer()` and `toggleMcpServer()` likewise operate on user-level settings and require `settingsLevel: SettingsLevel.User`. The example below adds a server and then removes it in the same script so it leaves no trace. ```ts -import { createSession, ReasoningEffort } from '@factory/droid-sdk'; +import { + createSession, + McpServerType, + SettingsLevel, +} from '@factory/droid-sdk'; const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, cwd: process.cwd(), - modelId: 'claude-sonnet-4-20250514', - reasoningEffort: ReasoningEffort.High, }); -// Change mid-session -await session.updateSettings({ - reasoningEffort: ReasoningEffort.Low, +await session.addMcpServer({ + name: 'my-server', + type: McpServerType.Http, + url: 'https://mcp.example.com/mcp', +}); + +const { servers, summary } = await session.listMcpServers(); +// `summary` is an McpStatusSummary: { total, connected, connecting, failed, disabled? }. +// There is no `summary.status` field; per-server status lives on servers[i].status. +console.log(`Connected: ${summary.connected}/${summary.total}`); +for (const s of servers) { + console.log(`${s.name}: ${s.status}`); +} + +// Toggle a server on/off (user-level settings)... +await session.toggleMcpServer({ + serverName: 'my-server', + enabled: false, + settingsLevel: SettingsLevel.User, +}); + +// ...and remove it to undo the persistent change made by addMcpServer(). +await session.removeMcpServer({ + serverName: 'my-server', + settingsLevel: SettingsLevel.User, }); await session.close(); ``` -## Hook Execution Monitoring +Inspect tools exposed by MCP servers with `session.listMcpTools()`, and authenticate OAuth-style servers with `session.authenticateMcpServer(params)`. + +--- + +## Observability + +### Hook Execution Monitoring Observe file hooks (pre/post tool execution hooks) as they run during a session. +> **Hooks must be configured in your `droid` settings files.** The SDK only _observes_ hook executions reported by the CLI; it does not register hooks. If no hooks are configured for the events your session triggers, the stream contains no `hook` messages and the loop below prints nothing. + ```ts import { createSession, DroidMessageType } from '@factory/droid-sdk'; @@ -508,9 +730,9 @@ for await (const msg of session.stream('Create a new file.')) { await session.close(); ``` -## Ask-User Handler +### Token Usage Tracking -Programmatically answer questions that Droid asks the user during execution. +Monitor token consumption in real time via stream events (requires `includePartialMessages: true`), or read the final totals from the result. ```ts import { createSession, DroidMessageType } from '@factory/droid-sdk'; @@ -518,50 +740,25 @@ import { createSession, DroidMessageType } from '@factory/droid-sdk'; const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, cwd: process.cwd(), - askUserHandler(params) { - return { - cancelled: false, - answers: params.questions.map((q) => ({ - index: q.index, - question: q.question, - answer: q.options[0] ?? 'yes', - })), - }; - }, }); -for await (const msg of session.stream('Help me set up this project.')) { - if (msg.type === DroidMessageType.Assistant) console.log(msg.text); +for await (const msg of session.stream('Summarize this project.', { + includePartialMessages: true, +})) { + if (msg.type === DroidMessageType.TokenUsageUpdate) { + console.log(`Tokens — in: ${msg.inputTokens}, out: ${msg.outputTokens}`); + } + if (msg.type === DroidMessageType.Result && msg.tokenUsage) { + console.log( + `Final — in: ${msg.tokenUsage.inputTokens}, out: ${msg.tokenUsage.outputTokens}` + ); + } } await session.close(); ``` -## MCP Server Management - -Add, remove, toggle, and list MCP servers at runtime within an active session. - -```ts -import { createSession, McpServerType } from '@factory/droid-sdk'; - -const session = await createSession({ - apiKey: process.env.FACTORY_API_KEY!, - cwd: process.cwd(), -}); - -await session.addMcpServer({ - name: 'my-server', - type: McpServerType.Http, - url: 'https://mcp.example.com/mcp', -}); - -const { servers, summary } = await session.listMcpServers(); -console.log(`MCP status: ${summary.status}, servers: ${servers.length}`); - -await session.close(); -``` - -## Context Stats +### Context Stats Query current context window usage to understand how much capacity remains. @@ -583,37 +780,9 @@ console.log( await session.close(); ``` -## Token Usage Tracking +### List Skills -Monitor token consumption in real-time via stream events, or read the final totals from the result. - -```ts -import { createSession, DroidMessageType } from '@factory/droid-sdk'; - -const session = await createSession({ - apiKey: process.env.FACTORY_API_KEY!, - cwd: process.cwd(), -}); - -for await (const msg of session.stream('Summarize this project.', { - includePartialMessages: true, -})) { - if (msg.type === DroidMessageType.TokenUsageUpdate) { - console.log(`Tokens — in: ${msg.inputTokens}, out: ${msg.outputTokens}`); - } - if (msg.type === DroidMessageType.Result && msg.tokenUsage) { - console.log( - `Final — in: ${msg.tokenUsage.inputTokens}, out: ${msg.tokenUsage.outputTokens}` - ); - } -} - -await session.close(); -``` - -## List Skills - -List all available skills in the current session. +List all skills available in the current session. ```ts import { createSession } from '@factory/droid-sdk'; @@ -631,9 +800,9 @@ for (const skill of skills) { await session.close(); ``` -## Raw Notification Subscription +### Raw Notification Subscription -Subscribe to raw protocol notifications for custom event handling beyond the stream API. +Subscribe to raw protocol notifications for custom event handling beyond the stream API. The callback receives the raw JSON-RPC envelope; the payload is at `params.notification`. An optional filter restricts which notification types are delivered. ```ts import { createSession, SessionNotificationType } from '@factory/droid-sdk'; @@ -656,9 +825,43 @@ unsubscribe(); await session.close(); ``` +--- + ## Error Handling -The SDK throws typed errors with structured context. Catch specific error classes to handle different failure modes. +There are two distinct failure surfaces, and most failures are the **first** kind: + +1. **Error results** — the turn completes but reports a problem. `run()` returns (and `stream()` yields) a `DroidResult` with `isError: true`. This is the most common failure mode and does **not** throw. +2. **Thrown exceptions** — connection, protocol, or session-level failures throw typed SDK errors you catch with `try`/`catch`. + +### Handling error results + +Always check `result.isError` before trusting `text` or `structuredOutput`: + +```ts +import { run } from '@factory/droid-sdk'; + +const result = await run('Do something that might fail.', { + apiKey: process.env.FACTORY_API_KEY!, + cwd: process.cwd(), +}); + +if (result.isError) { + // subtype is 'error_during_execution' | 'error_structured_output' + console.error(`Failed (${result.subtype}):`, result.errors); + if (result.structuredOutputError) { + console.error('Structured output error:', result.structuredOutputError); + } +} else { + console.log(result.text); +} +``` + +Result discrimination fields: `success` / `isError` (booleans), `subtype` (`'success' | 'error_during_execution' | 'error_structured_output'`), `errors` (error descriptions, error results only), `error` (the first error event, or `null`), and `structuredOutputError` (structured-output parse/validation failure, if any). The same `result` message arrives as the final `DroidMessageType.Result` event when streaming. + +### Handling thrown errors + +The SDK throws typed errors with structured context. Catch specific classes to handle different failure modes. ```ts import { @@ -670,9 +873,7 @@ import { } from '@factory/droid-sdk'; try { - const session = await resumeSession('nonexistent-id', { - apiKey: process.env.FACTORY_API_KEY!, - }); + const session = await resumeSession('nonexistent-id'); } catch (error) { if (error instanceof SessionNotFoundError) { console.log(`Session not found: ${error.sessionId}`); @@ -686,13 +887,17 @@ try { } ``` +See the [Error Types](#error-types) reference for all classes. Note that aborting a stream via `AbortSignal` throws a plain `Error` (carrying `signal.reason`), not one of these typed errors. + +--- + ## Low-level APIs Most users should use `run()` and `DroidSession`. For direct RPC access, the SDK also exports `ProcessTransport`, `ProtocolEngine`, and `DroidClient`. `DroidClient` exposes additional methods not on `DroidSession`: `killWorkerSession()`, `cancelMcpAuth()`, `clearMcpAuth()`, `submitMcpAuthCode()`, `listMcpRegistry()`, `toggleMcpTool()`, and `submitBugReport()`. -The package also exports its full Zod schema surface from `src/schemas/index.ts` for runtime validation of protocol payloads. +The package also re-exports its full Zod schema surface from the package root for runtime validation of protocol payloads. Low-level JSON-RPC protocol types, enums (including `ModelID`), and schemas are additionally available from the `@factory/droid-sdk/protocol` subpath export and the `protocol` namespace export. Factory REST API helpers (for managing remote computers, machine templates, and remote sessions) are also exported from the package root. --- @@ -700,41 +905,57 @@ The package also exports its full Zod schema surface from `src/schemas/index.ts` ### `CreateSessionOptions` -| Field | Type | Description | -| :------------------------ | :----------------------- | :---------------------------------------------------------- | -| `apiKey` | `string` | **Required.** Factory API key for authentication | -| `cwd` | `string` | Working directory for the session | -| `machineId` | `string` | Machine identifier for initialization | -| `modelId` | `string` | LLM model identifier | -| `autonomyLevel` | `AutonomyLevel` | `Off` \| `Low` \| `Medium` \| `High` | -| `interactionMode` | `DroidInteractionMode` | `Auto` \| `Spec` \| `AGI` | -| `reasoningEffort` | `ReasoningEffort` | `None` \| `Low` \| `Medium` \| `High` \| `Max` (and others) | -| `specModeModelId` | `string` | Override model for spec mode | -| `specModeReasoningEffort` | `ReasoningEffort` | Override reasoning effort for spec mode | -| `mcpServers` | `DroidMcpServerConfig[]` | Initial MCP server configurations | -| `enabledToolIds` | `string[]` | Tool allowlist | -| `disabledToolIds` | `string[]` | Tool denylist | -| `tags` | `SessionTag[]` | Session tags for categorization | -| `sessionSource` | `SessionSource` | Attribution metadata (e.g., integration origin) | -| `permissionHandler` | `PermissionHandler` | Tool confirmation callback | -| `askUserHandler` | `AskUserHandler` | Structured user-input callback | -| `execPath` | `string` | Path to `droid` executable (default: `"droid"`) | -| `execArgs` | `string[]` | Extra CLI arguments for the subprocess | -| `env` | `Record` | Environment variables for the subprocess | -| `transport` | `DroidClientTransport` | Custom transport (skips subprocess spawn) | -| `abortSignal` | `AbortSignal` | Cancellation signal | +| Field | Type | Description | +| :------------------------ | :----------------------- | :----------------------------------------------------------------------------------------------------------- | +| `apiKey` | `string` | **Required by the type.** Runtime value may be `undefined`; the CLI then uses stored credentials | +| `cwd` | `string` | Working directory for the session (default: `"."`) | +| `machineId` | `string` | Machine identifier for initialization (default: `"default"`) | +| `modelId` | `string` | LLM model identifier | +| `autonomyLevel` | `AutonomyLevel` | `Off` \| `Low` \| `Medium` \| `High` | +| `interactionMode` | `DroidInteractionMode` | `Auto` \| `Spec` \| `AGI` | +| `reasoningEffort` | `ReasoningEffort` | `None` \| `Dynamic` \| `Off` \| `Minimal` \| `Low` \| `Medium` \| `High` \| `ExtraHigh` (`'xhigh'`) \| `Max` | +| `specModeModelId` | `string` | Override model for spec mode | +| `specModeReasoningEffort` | `ReasoningEffort` | Override reasoning effort for spec mode | +| `mcpServers` | `DroidMcpServerConfig[]` | Initial MCP server configs, including SDK-backed servers from `createSdkMcpServer()` | +| `enabledToolIds` | `string[]` | Tool IDs to re-enable on top of the default set (not an exclusive allowlist) | +| `disabledToolIds` | `string[]` | Tool IDs to disable | +| `tags` | `SessionTag[]` | Session tags for categorization (the SDK always appends its own SDK tag) | +| `sessionSource` | `SessionSource` | Attribution metadata (e.g., integration origin) | +| `permissionHandler` | `PermissionHandler` | Tool confirmation callback (default behavior with no handler: cancel everything) | +| `askUserHandler` | `AskUserHandler` | Structured user-input callback | +| `execPath` | `string` | Path to `droid` executable (default: `"droid"`) | +| `execArgs` | `string[]` | Extra CLI arguments for the subprocess | +| `env` | `Record` | Environment variables for the subprocess | +| `transport` | `DroidClientTransport` | Custom transport (skips subprocess spawn) | +| `abortSignal` | `AbortSignal` | Cancellation signal | + +### `ResumeSessionOptions` + +Accepted by `resumeSession(sessionId, options?)`. The `options` argument is optional. `resumeSession()` accepts only the subset of options needed to reconnect; new-session-only options such as `modelId` or `interactionMode` are not accepted, and `cwd` is intentionally omitted (the persisted session cwd is authoritative). + +| Field | Type | Description | +| :------------------ | :----------------------- | :-------------------------------------------------------------------- | +| `apiKey` | `string` | **Optional on resume.** When omitted, the CLI uses stored credentials | +| `mcpServers` | `DroidMcpServerConfig[]` | MCP servers to attach to the resumed session | +| `permissionHandler` | `PermissionHandler` | Tool confirmation callback | +| `askUserHandler` | `AskUserHandler` | Structured user-input callback | +| `execPath` | `string` | Path to `droid` executable (default: `"droid"`) | +| `execArgs` | `string[]` | Extra CLI arguments for the subprocess | +| `env` | `Record` | Environment variables for the subprocess | +| `transport` | `DroidClientTransport` | Custom transport (skips subprocess spawn) | +| `abortSignal` | `AbortSignal` | Cancellation signal | ### `MessageOptions` -Accepted by `session.stream()` and `run()`: +Accepted by `session.stream()` and `run()` (`run()` accepts all of these except `includePartialMessages`): -| Field | Type | Description | -| :----------------------- | :-------------------- | :------------------------------------------- | -| `images` | `Base64ImageSource[]` | Base64-encoded image attachments | -| `files` | `DocumentSource[]` | Document/file attachments | -| `outputFormat` | `OutputFormat` | Structured output JSON schema request | -| `includePartialMessages` | `boolean` | Yield token-level deltas and progress events | -| `abortSignal` | `AbortSignal` | Cancellation signal for this turn | +| Field | Type | Description | +| :----------------------- | :-------------------- | :------------------------------------------------------------ | +| `images` | `Base64ImageSource[]` | Base64-encoded image attachments | +| `files` | `DocumentSource[]` | Document/file attachments | +| `outputFormat` | `OutputFormat` | Structured output JSON schema request | +| `includePartialMessages` | `boolean` | `stream()` only: yield token-level deltas and progress events | +| `abortSignal` | `AbortSignal` | Cancellation signal for this turn | ### Error Types @@ -746,3 +967,5 @@ Accepted by `session.stream()` and `run()`: | `SessionNotFoundError` | Requested session does not exist | | `TimeoutError` | RPC timed out | | `ProcessExitError` | Subprocess exited unexpectedly | + +Aborting a turn via `AbortSignal` throws a plain `Error` (carrying `signal.reason`), not one of the typed SDK errors above. diff --git a/examples/abort-session-stream.ts b/examples/abort-session-stream.ts index 6ae03b2..ce15056 100644 --- a/examples/abort-session-stream.ts +++ b/examples/abort-session-stream.ts @@ -1,8 +1,14 @@ /** * Abort a running turn with AbortController. * + * Demonstrates passing an `abortSignal` to `session.stream()` and + * stopping a turn after a timeout. + * * Usage: * npx tsx examples/abort-session-stream.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { createSession } from '@factory/droid-sdk'; diff --git a/examples/test-compact.ts b/examples/compact-session.ts similarity index 88% rename from examples/test-compact.ts rename to examples/compact-session.ts index 814b239..407b506 100644 --- a/examples/test-compact.ts +++ b/examples/compact-session.ts @@ -7,7 +7,10 @@ * and removedCount. * * Usage: - * npx tsx examples/test-compact.ts + * npx tsx examples/compact-session.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { createSession, DroidMessageType } from '@factory/droid-sdk'; @@ -43,7 +46,7 @@ async function main(): Promise { } console.log('=== Compacting session ==='); - const compactResult = await session.compactSession({}); + const compactResult = await session.compactSession(); console.log(`Original session: ${session.sessionId}`); console.log(`New session: ${compactResult.newSessionId}`); console.log(`Removed messages: ${compactResult.removedCount}`); diff --git a/examples/daemon-multi-session.ts b/examples/daemon-multi-session.ts index 1319707..66fdf9a 100644 --- a/examples/daemon-multi-session.ts +++ b/examples/daemon-multi-session.ts @@ -1,61 +1,83 @@ /** - * Manual smoke test for the daemon SDK — multiple concurrent sessions. + * Daemon: multiple concurrent sessions. * - * Spawns a local daemon, creates two sessions in separate /tmp directories, - * and runs them concurrently over a single WebSocket connection. + * Connects to a local daemon, creates two sessions in separate /tmp + * directories, and runs them concurrently over a single WebSocket + * connection. * * Usage: * npx tsx examples/daemon-multi-session.ts + * + * Requirements: droid CLI installed, plus a real FACTORY_API_KEY. + * Daemon authentication has no stored-credential fallback, so this + * example skips itself when the env var is unset. */ import { connectDaemon, DroidMessageType } from '@factory/droid-sdk'; async function main(): Promise { + if (!process.env.FACTORY_API_KEY) { + console.log( + 'FACTORY_API_KEY is not set. Daemon authentication requires a real ' + + 'API key (stored CLI credentials are not used). Skipping.' + ); + return; + } + console.log('Connecting to local daemon...\n'); - const daemon = await connectDaemon({ apiKey: process.env.FACTORY_API_KEY! }); + const daemon = await connectDaemon({ apiKey: process.env.FACTORY_API_KEY }); console.log('Connected!\n'); - const frontend = await daemon.createSession({ - cwd: '/tmp/daemon-test-frontend', - }); - const backend = await daemon.createSession({ - cwd: '/tmp/daemon-test-backend', - }); + try { + const frontend = await daemon.createSession({ + cwd: '/tmp/daemon-test-frontend', + }); + const backend = await daemon.createSession({ + cwd: '/tmp/daemon-test-backend', + }); - console.log('Two sessions created. Running concurrently...\n'); + console.log('Two sessions created. Running concurrently...\n'); - await Promise.all([ - (async () => { - for await (const msg of frontend.stream( - 'Create a file called hello.md with a short greeting message. Just a few lines.' - )) { - if (msg.type === DroidMessageType.Assistant) { - process.stdout.write(`[frontend] ${msg.text}\n`); - } else if (msg.type === DroidMessageType.ToolCall) { - console.log(`[frontend] [tool] ${msg.toolUse.name}`); - } else if (msg.type === DroidMessageType.Result) { - console.log(`[frontend] Done in ${msg.durationMs}ms`); + await Promise.all([ + (async () => { + try { + for await (const msg of frontend.stream( + 'Create a file called hello.md with a short greeting message. Just a few lines.' + )) { + if (msg.type === DroidMessageType.Assistant) { + process.stdout.write(`[frontend] ${msg.text}\n`); + } else if (msg.type === DroidMessageType.ToolCall) { + console.log(`[frontend] [tool] ${msg.toolUse.name}`); + } else if (msg.type === DroidMessageType.Result) { + console.log(`[frontend] Done in ${msg.durationMs}ms`); + } + } + } finally { + await frontend.close(); } - } - await frontend.close(); - })(), - (async () => { - for await (const msg of backend.stream( - 'Create a file called notes.md with 3 random fun facts. Keep it short.' - )) { - if (msg.type === DroidMessageType.Assistant) { - process.stdout.write(`[backend] ${msg.text}\n`); - } else if (msg.type === DroidMessageType.ToolCall) { - console.log(`[backend] [tool] ${msg.toolUse.name}`); - } else if (msg.type === DroidMessageType.Result) { - console.log(`[backend] Done in ${msg.durationMs}ms`); + })(), + (async () => { + try { + for await (const msg of backend.stream( + 'Create a file called notes.md with 3 random fun facts. Keep it short.' + )) { + if (msg.type === DroidMessageType.Assistant) { + process.stdout.write(`[backend] ${msg.text}\n`); + } else if (msg.type === DroidMessageType.ToolCall) { + console.log(`[backend] [tool] ${msg.toolUse.name}`); + } else if (msg.type === DroidMessageType.Result) { + console.log(`[backend] Done in ${msg.durationMs}ms`); + } + } + } finally { + await backend.close(); } - } - await backend.close(); - })(), - ]); + })(), + ]); + } finally { + await daemon.close(); + } - await daemon.close(); console.log( '\nDone. Check /tmp/daemon-test-frontend/hello.md and /tmp/daemon-test-backend/notes.md' ); diff --git a/examples/droid-dev-structured-output.ts b/examples/droid-dev-structured-output.ts deleted file mode 100644 index 7a28582..0000000 --- a/examples/droid-dev-structured-output.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * One-off integration smoke test for JSON output against Droid. - * - * Usage: - * npx tsx examples/droid-dev-structured-output.ts - * DROID_EXEC_PATH=/path/to/droid npx tsx examples/droid-dev-structured-output.ts - */ - -import { run } from '@factory/droid-sdk'; -import { z } from 'zod'; - -const PersonSchema = z.object({ - name: z.literal('Ada Lovelace'), - language: z.literal('TypeScript'), - score: z.number(), -}); - -async function main(): Promise { - const execPath = process.env['DROID_EXEC_PATH'] ?? 'droid'; - - const result = await run( - [ - 'Return a structured object for Ada Lovelace.', - 'Use name "Ada Lovelace", language "TypeScript", and score 99.', - 'Return only valid JSON and do not include markdown fences.', - ].join(' '), - { - apiKey: process.env.FACTORY_API_KEY!, - execPath, - cwd: process.cwd(), - } - ); - - const parsed = PersonSchema.parse(JSON.parse(result.text)); - - console.log(`droid executable: ${execPath}`); - console.log('structured output:', JSON.stringify(parsed, null, 2)); - console.log(`messages received: ${result.messages.length}`); -} - -main().catch((error: unknown) => { - console.error(error); - process.exit(1); -}); diff --git a/examples/fork-session.ts b/examples/fork-session.ts index 846053a..f3c2687 100644 --- a/examples/fork-session.ts +++ b/examples/fork-session.ts @@ -8,6 +8,9 @@ * * Usage: * npx tsx examples/fork-session.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { @@ -58,7 +61,7 @@ async function main(): Promise { } finally { await fork?.close(); await session.close(); - console.log('\nBoth sessions closed.'); + console.log('\nCleaned up sessions.'); } } diff --git a/examples/hook-execution.ts b/examples/hook-execution.ts index 8de8a01..d0263e5 100644 --- a/examples/hook-execution.ts +++ b/examples/hook-execution.ts @@ -3,25 +3,28 @@ * * Demonstrates how to handle hook execution messages in a session stream. * + * IMPORTANT: Hook messages only appear if hooks are configured in your + * droid settings. Without configured hooks the example still runs, but + * no `[Hook ...]` lines are printed. + * * Usage: * npx tsx examples/hook-execution.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ -import { DroidMessageType, createSession } from '../src/index.js'; +import { DroidMessageType, createSession } from '@factory/droid-sdk'; async function main(): Promise { const prompt = 'Run a simple shell command using Execute tool.'; console.log(`Sending prompt: "${prompt}"\n`); - // Note: To actually see hooks, you need to have hooks configured in your droid settings. - const apiKey = process.env.FACTORY_API_KEY; - if (!apiKey) { - console.error('Set FACTORY_API_KEY environment variable.'); - process.exit(1); - } - - const session = await createSession({ apiKey, cwd: process.cwd() }); + const session = await createSession({ + apiKey: process.env.FACTORY_API_KEY!, + cwd: process.cwd(), + }); try { for await (const msg of session.stream(prompt)) { diff --git a/examples/init-metadata.ts b/examples/init-metadata.ts index 6f2d365..72d3d87 100644 --- a/examples/init-metadata.ts +++ b/examples/init-metadata.ts @@ -1,8 +1,14 @@ /** * Initialization metadata example. * + * Demonstrates reading `session.initResult` metadata (model, cwd) from + * created and resumed sessions. + * * Usage: * npx tsx examples/init-metadata.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { createSession, resumeSession } from '@factory/droid-sdk'; @@ -15,10 +21,12 @@ const resumed = await resumeSession(session.sessionId, { apiKey: process.env.FACTORY_API_KEY!, }); -console.log(`created session: ${session.sessionId}`); -console.log(`resumed session: ${resumed.sessionId}`); -console.log(`created model: ${String(session.initResult.settings.modelId)}`); -console.log(`resumed cwd: ${String(resumed.initResult.cwd)}`); - -await resumed.close(); -await session.close(); +try { + console.log(`created session: ${session.sessionId}`); + console.log(`resumed session: ${resumed.sessionId}`); + console.log(`created model: ${String(session.initResult.settings.modelId)}`); + console.log(`resumed cwd: ${String(resumed.initResult.cwd)}`); +} finally { + await resumed.close(); + await session.close(); +} diff --git a/examples/interrupt-session.ts b/examples/interrupt-session.ts index 487e890..c66f6c1 100644 --- a/examples/interrupt-session.ts +++ b/examples/interrupt-session.ts @@ -1,8 +1,15 @@ /** * Interrupt a running turn. * + * Demonstrates `session.interrupt()`: requests the interrupt after the + * fifth streamed text delta. Because the interrupt is asynchronous, a + * few more deltas may arrive before the turn actually stops. + * * Usage: * npx tsx examples/interrupt-session.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { createSession, DroidMessageType } from '@factory/droid-sdk'; @@ -25,6 +32,8 @@ try { deltaCount++; if (deltaCount === 5) { + // Asynchronous: the stream may deliver a few more deltas before + // the interrupt takes effect. await session.interrupt(); } } diff --git a/examples/list-sessions.ts b/examples/list-sessions.ts index c9bc9d4..c817f65 100644 --- a/examples/list-sessions.ts +++ b/examples/list-sessions.ts @@ -1,8 +1,14 @@ /** * List saved sessions example. * + * Demonstrates `listSessions()` to fetch recent sessions for the + * current project. + * * Usage: * npx tsx examples/list-sessions.ts + * + * Requirements: droid CLI installed (reads local session files); no + * credentials needed. */ import { listSessions } from '@factory/droid-sdk'; diff --git a/examples/multi-turn-session.ts b/examples/multi-turn-session.ts index 7927218..cb4ccc5 100644 --- a/examples/multi-turn-session.ts +++ b/examples/multi-turn-session.ts @@ -1,10 +1,21 @@ /** * Multi-turn session example. * + * Demonstrates context retention across turns: the first turn states a + * fact, the second turn asks for it back. Runs in a temporary + * directory so nothing touches your project. + * * Usage: * npx tsx examples/multi-turn-session.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { DroidMessageType, createSession } from '@factory/droid-sdk'; async function streamText( @@ -22,19 +33,27 @@ async function streamText( return text; } +const tempDir = await mkdtemp(join(tmpdir(), 'droid-sdk-multi-turn-')); const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, - cwd: process.cwd(), + cwd: tempDir, }); try { console.log(`Session: ${session.sessionId}\n`); - const first = await streamText(session, 'What is this project?'); + const first = await streamText( + session, + 'My favorite color is teal. Acknowledge in one short sentence.' + ); console.log(first); - const second = await streamText(session, 'What should I test first?'); + const second = await streamText( + session, + 'What is my favorite color? Answer in one short sentence.' + ); console.log('\n', second); } finally { await session.close(); + await rm(tempDir, { recursive: true, force: true }); } diff --git a/examples/permission-handler.ts b/examples/permission-handler.ts index edeb8fa..90aad49 100644 --- a/examples/permission-handler.ts +++ b/examples/permission-handler.ts @@ -1,8 +1,15 @@ /** * Permission handler example. * + * Demonstrates approving or canceling tool calls with a + * `permissionHandler`: only file creation at the expected temp path is + * allowed. + * * Usage: * npx tsx examples/permission-handler.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { mkdtemp, readFile, rm } from 'node:fs/promises'; diff --git a/examples/readme-structured-output.ts b/examples/readme-structured-output.ts index 071cdb9..42ca02d 100644 --- a/examples/readme-structured-output.ts +++ b/examples/readme-structured-output.ts @@ -1,3 +1,16 @@ +/** + * Structured output example from the README. + * + * Demonstrates the `outputFormat` option of `run()` with a JSON schema + * and prints the parsed structured output. Mirrors the README snippet. + * + * Usage: + * npx tsx examples/readme-structured-output.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. + */ + import { OutputFormatType, run } from '@factory/droid-sdk'; type FavoriteNumber = { favoriteNumber: number }; diff --git a/examples/result-metadata.ts b/examples/result-metadata.ts index 61b8dac..50570c0 100644 --- a/examples/result-metadata.ts +++ b/examples/result-metadata.ts @@ -8,6 +8,9 @@ * Usage: * npx tsx examples/result-metadata.ts * npx tsx examples/result-metadata.ts "Reply with metadata details." + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import assert from 'node:assert/strict'; diff --git a/examples/run.ts b/examples/run.ts index 8459988..e7c3403 100644 --- a/examples/run.ts +++ b/examples/run.ts @@ -1,12 +1,15 @@ /** - * Manual smoke test for the one-shot `run()` API. + * One-shot `run()` example. * - * Sends a single prompt, prints the aggregated result, and exits after the - * underlying session has been closed by `run()`. + * Sends a single prompt, prints the aggregated result, and exits after + * the underlying session has been closed by `run()`. * * Usage: * npx tsx examples/run.ts * npx tsx examples/run.ts "What is 2 + 2?" + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { run } from '@factory/droid-sdk'; diff --git a/examples/sdk-mcp-tool.ts b/examples/sdk-mcp-tool.ts index e117215..8607c28 100644 --- a/examples/sdk-mcp-tool.ts +++ b/examples/sdk-mcp-tool.ts @@ -1,13 +1,31 @@ +/** + * SDK-defined MCP tool example. + * + * Demonstrates `createSdkMcpServer()` and `tool()`: registers an + * in-process MCP tool, asks the model to call it, and prints the tool + * call, tool result, and final assistant answer. The permission + * handler approves only MCP tool confirmations, cancels everything + * else, and logs each decision. + * + * Usage: + * npx tsx examples/sdk-mcp-tool.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. Set + * DROID_EXEC_PATH to point at a specific droid executable. + */ + import { DroidMessageType, ToolConfirmationOutcome, + ToolConfirmationType, createSession, createSdkMcpServer, tool, } from '@factory/droid-sdk'; import { z } from 'zod'; -const execPath = process.env['DROID_EXEC_PATH'] ?? 'droid-dev'; +const execPath = process.env['DROID_EXEC_PATH'] ?? 'droid'; const sdkTools = createSdkMcpServer({ name: 'sdk-tools', @@ -26,19 +44,48 @@ const session = await createSession({ execPath, mcpServers: [sdkTools], cwd: process.cwd(), - permissionHandler: () => ToolConfirmationOutcome.ProceedOnce, + permissionHandler(params) { + const allMcp = params.toolUses.every( + (item) => item.details.type === ToolConfirmationType.McpTool + ); + const decision = allMcp ? 'proceed_once' : 'cancel'; + for (const item of params.toolUses) { + console.log(`[Permission] ${item.toolUse.name} -> ${decision}`); + } + return allMcp + ? ToolConfirmationOutcome.ProceedOnce + : ToolConfirmationOutcome.Cancel; + }, }); try { for await (const msg of session.stream( - 'Use the favorite_number tool for Ada and tell me the answer.', - { includePartialMessages: true } + 'Use the favorite_number tool for Ada and tell me the answer.' )) { - if (msg.type === DroidMessageType.AssistantTextDelta) { - process.stdout.write(msg.text); + switch (msg.type) { + case DroidMessageType.ToolCall: + console.log(`[Tool Call] ${msg.toolUse.name}`); + break; + + case DroidMessageType.ToolResult: + console.log( + `[Tool Result] ${msg.toolName}: ${ + msg.isError ? 'Error' : JSON.stringify(msg.content) + }` + ); + break; + + case DroidMessageType.Assistant: + if (msg.text.trim()) { + console.log(`[Assistant] ${msg.text.trim()}`); + } + break; + + case DroidMessageType.Result: + console.log('--- Turn complete ---'); + break; } } - console.log(); } finally { await session.close(); } diff --git a/examples/session-stream.ts b/examples/session-stream.ts index b476693..6fc4541 100644 --- a/examples/session-stream.ts +++ b/examples/session-stream.ts @@ -6,6 +6,9 @@ * * Usage: * npx tsx examples/session-stream.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { DroidMessageType, createSession } from '@factory/droid-sdk'; diff --git a/examples/spec-mode-new-session.ts b/examples/spec-mode-new-session.ts index 41b01a1..39fa802 100644 --- a/examples/spec-mode-new-session.ts +++ b/examples/spec-mode-new-session.ts @@ -1,8 +1,15 @@ /** * Spec mode: approve and hand off implementation to a new session. * + * Demonstrates starting a session in spec (planning) mode and answering + * the ExitSpecMode confirmation with `ProceedNewSessionHigh`, which + * implements the plan in a fresh session. + * * Usage: * npx tsx examples/spec-mode-new-session.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { mkdtemp, readFile, rm } from 'node:fs/promises'; @@ -55,7 +62,15 @@ try { await session.close(); } - console.log(await readFile(outputPath, 'utf8')); + try { + console.log(await readFile(outputPath, 'utf8')); + } catch { + console.error( + `Expected ${outputPath} to exist after the turn, but it was not ` + + 'created.' + ); + process.exitCode = 1; + } } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/examples/spec-mode-same-session.ts b/examples/spec-mode-same-session.ts index 8463734..84194fa 100644 --- a/examples/spec-mode-same-session.ts +++ b/examples/spec-mode-same-session.ts @@ -1,8 +1,16 @@ /** * Spec mode: approve and implement in the same session. * + * Demonstrates starting a session in spec (planning) mode and answering + * the ExitSpecMode confirmation with `ProceedOnce`, which implements + * the plan in the same session. The permission handler approves any + * tool call scoped to the temp directory and logs anything it cancels. + * * Usage: * npx tsx examples/spec-mode-same-session.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { mkdtemp, readFile, rm } from 'node:fs/promises'; @@ -15,11 +23,29 @@ import { ToolConfirmationOutcome, ToolConfirmationType, createSession, + type ToolConfirmationDetails, } from '@factory/droid-sdk'; const tempDir = await mkdtemp(join(tmpdir(), 'droid-sdk-spec-')); const outputPath = join(tempDir, 'hello.txt'); +// Demo heuristic for keeping the example self-contained, not a +// security boundary. +function isScopedToTempDir(details: ToolConfirmationDetails): boolean { + switch (details.type) { + case ToolConfirmationType.ExitSpecMode: + return true; + case ToolConfirmationType.Create: + case ToolConfirmationType.Edit: + case ToolConfirmationType.ApplyPatch: + return details.filePath.startsWith(tempDir); + case ToolConfirmationType.Execute: + return details.fullCommand.includes(tempDir); + default: + return false; + } +} + try { const session = await createSession({ apiKey: process.env.FACTORY_API_KEY!, @@ -27,32 +53,21 @@ try { interactionMode: DroidInteractionMode.Spec, specModeReasoningEffort: ReasoningEffort.High, permissionHandler(params) { - const canExitSpec = params.toolUses.some( - (item) => item.details.type === ToolConfirmationType.ExitSpecMode - ); - const onlyCreatesFile = params.toolUses.every( - (item) => - item.details.type === ToolConfirmationType.Create && - item.details.filePath === outputPath - ); - const onlyEditsTempFile = params.toolUses.every( - (item) => - item.details.type === ToolConfirmationType.ApplyPatch && - item.details.filePath === outputPath - ); - const onlyRunsTempCommand = params.toolUses.every( - (item) => - item.details.type === ToolConfirmationType.Execute && - item.details.fullCommand.includes(outputPath) && - item.details.fullCommand.includes(tempDir) + const rejected = params.toolUses.filter( + (item) => !isScopedToTempDir(item.details) ); - return canExitSpec || - onlyCreatesFile || - onlyEditsTempFile || - onlyRunsTempCommand - ? ToolConfirmationOutcome.ProceedOnce - : ToolConfirmationOutcome.Cancel; + if (rejected.length > 0) { + for (const item of rejected) { + console.log( + `[Permission] Canceling unexpected tool request: ` + + `${item.toolUse.name} (${item.details.type})` + ); + } + return ToolConfirmationOutcome.Cancel; + } + + return ToolConfirmationOutcome.ProceedOnce; }, }); @@ -66,7 +81,15 @@ try { await session.close(); } - console.log(await readFile(outputPath, 'utf8')); + try { + console.log(await readFile(outputPath, 'utf8')); + } catch { + console.error( + `Expected ${outputPath} to exist after the turn, but it was not ` + + 'created. Check the [Permission] log above for canceled tool calls.' + ); + process.exitCode = 1; + } } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/examples/structured-output.ts b/examples/structured-output.ts index 6e9c733..6bbf869 100644 --- a/examples/structured-output.ts +++ b/examples/structured-output.ts @@ -7,6 +7,9 @@ * Usage: * npx tsx examples/structured-output.ts * npx tsx examples/structured-output.ts "Pick a favorite number between 1 and 42" + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import assert from 'node:assert/strict'; diff --git a/examples/test-rewind.ts b/examples/test-rewind.ts deleted file mode 100644 index ac2464e..0000000 --- a/examples/test-rewind.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Low-level rewind API example. - * - * Usage: - * npx tsx examples/test-rewind.ts - */ - -import { randomUUID } from 'node:crypto'; - -import { - AutonomyLevel, - DroidClient, - DroidWorkingState, - ProcessTransport, - SessionNotificationType, -} from '@factory/droid-sdk'; - -function waitForTurn(client: DroidClient): Promise { - return new Promise((resolve) => { - let started = false; - const unsubscribe = client.onNotification((notification) => { - const params = notification['params']; - if (typeof params !== 'object' || params === null) { - return; - } - - const event = (params as Record)['notification']; - if (typeof event !== 'object' || event === null) { - return; - } - - if ( - (event as Record)['type'] !== - SessionNotificationType.DROID_WORKING_STATE_CHANGED - ) { - return; - } - - const newState = (event as Record)['newState']; - started ||= newState !== DroidWorkingState.Idle; - if (started && newState === DroidWorkingState.Idle) { - unsubscribe(); - resolve(); - } - }); - }); -} - -const transport = new ProcessTransport({ cwd: process.cwd() }); -await transport.connect(); - -const client = new DroidClient({ transport }); - -try { - await client.initializeSession({ - machineId: 'default', - cwd: process.cwd(), - autonomyLevel: AutonomyLevel.High, - }); - - const messageId = randomUUID(); - const done = waitForTurn(client); - await client.addUserMessage({ - messageId, - text: 'Say "hello" and nothing else.', - }); - await done; - - try { - const rewindInfo = await client.getRewindInfo({ messageId }); - console.log( - `Files available to restore: ${rewindInfo.availableFiles.length}` - ); - - const rewind = await client.executeRewind({ - messageId, - filesToRestore: [], - filesToDelete: [], - forkTitle: 'Rewind example', - }); - console.log(`Rewound into session: ${rewind.newSessionId}`); - } catch (error) { - console.log(`Rewind unavailable: ${String(error)}`); - } -} finally { - await client.close(); -} diff --git a/examples/tool-controls.ts b/examples/tool-controls.ts index e5e7234..a55b5d7 100644 --- a/examples/tool-controls.ts +++ b/examples/tool-controls.ts @@ -1,8 +1,15 @@ /** * Tool controls example. * + * Demonstrates `enabledToolIds` / `disabledToolIds` session options, + * `session.listTools()`, and disabling tools at runtime with + * `session.updateSettings()`. + * * Usage: * npx tsx examples/tool-controls.ts + * + * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ import { createSession } from '@factory/droid-sdk'; diff --git a/src/helpers.ts b/src/helpers.ts index f221406..ae8514b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -267,8 +267,19 @@ export interface TransportCreationOptions extends Pick< transport?: DroidClientTransport; } +/** + * Internal variant of {@link TransportCreationOptions} where `apiKey` may be + * omitted (resume path); the CLI then falls back to its stored credentials. + */ +export type OptionalApiKeyTransportCreationOptions = Omit< + TransportCreationOptions, + 'apiKey' +> & { + apiKey?: string; +}; + export async function createTransport( - options: TransportCreationOptions + options: OptionalApiKeyTransportCreationOptions ): Promise { if (options.transport) { return options.transport; @@ -278,7 +289,12 @@ export async function createTransport( execPath: options.execPath, execArgs: options.execArgs, cwd: options.cwd, - env: { ...options.env, FACTORY_API_KEY: options.apiKey }, + // Spreading FACTORY_API_KEY: undefined would make spawn() delete an + // ambient FACTORY_API_KEY, so only override when a key was provided. + env: + options.apiKey !== undefined + ? { ...options.env, FACTORY_API_KEY: options.apiKey } + : options.env, }; const processTransport = new ProcessTransport(transportOptions); await processTransport.connect(); @@ -303,7 +319,7 @@ export function setupClientHandlers( } interface ClientCreationOptions - extends TransportCreationOptions, HandlerOptions {} + extends OptionalApiKeyTransportCreationOptions, HandlerOptions {} export async function createConfiguredClient( options: ClientCreationOptions diff --git a/src/protocol/cli.ts b/src/protocol/cli.ts index 50322ba..b1f6e6a 100644 --- a/src/protocol/cli.ts +++ b/src/protocol/cli.ts @@ -110,7 +110,9 @@ const PermissionResolvedNotificationSchema = z.object({ type: z.literal(SessionNotificationType.PERMISSION_RESOLVED), requestId: z.string(), toolUseIds: z.array(z.string()), // Array to match batched permission requests - selectedOption: z.nativeEnum(ToolConfirmationOutcome), + // Accept values beyond ToolConfirmationOutcome so newer CLI versions can + // resolve permissions with options the SDK does not know about yet. + selectedOption: z.union([z.nativeEnum(ToolConfirmationOutcome), z.string()]), }); const SettingsUpdatedNotificationSchema = z.object({ @@ -526,7 +528,12 @@ export const AskUserResponseSchema = z.union([ export const RequestPermissionResultSchema = z .object({ - selectedOption: z.nativeEnum(ToolConfirmationOutcome), + // Accept values beyond ToolConfirmationOutcome so handlers can echo back + // options offered by newer CLI versions. + selectedOption: z.union([ + z.nativeEnum(ToolConfirmationOutcome), + z.string(), + ]), comment: z.string().optional(), editedSpecContent: z.string().optional(), }) diff --git a/src/protocol/selectable-list-item.ts b/src/protocol/selectable-list-item.ts index 1dcc83c..15859de 100644 --- a/src/protocol/selectable-list-item.ts +++ b/src/protocol/selectable-list-item.ts @@ -10,7 +10,9 @@ import { ToolConfirmationOutcome } from './enums.js'; */ export const ToolConfirmationListItemSchema = z.object({ label: z.string(), - value: z.nativeEnum(ToolConfirmationOutcome), + // Accept values beyond ToolConfirmationOutcome so newer CLI versions can + // offer options the SDK does not know about yet. + value: z.union([z.nativeEnum(ToolConfirmationOutcome), z.string()]), }); export type ToolConfirmationListItem = z.infer< diff --git a/src/schemas/mcp.ts b/src/schemas/mcp.ts index 6296935..08aa2f5 100644 --- a/src/schemas/mcp.ts +++ b/src/schemas/mcp.ts @@ -106,7 +106,9 @@ export type McpToolInfo = z.infer; export const ToolConfirmationListItemSchema = z .object({ label: z.string(), - value: z.nativeEnum(ToolConfirmationOutcome), + // Accept values beyond ToolConfirmationOutcome so newer CLI versions can + // offer options the SDK does not know about yet. + value: z.union([z.nativeEnum(ToolConfirmationOutcome), z.string()]), }) .passthrough(); diff --git a/src/schemas/server.ts b/src/schemas/server.ts index 92febdb..d729523 100644 --- a/src/schemas/server.ts +++ b/src/schemas/server.ts @@ -160,7 +160,12 @@ export const PermissionResolvedNotificationSchema = z type: z.literal(SessionNotificationType.PERMISSION_RESOLVED), requestId: z.string(), toolUseIds: z.array(z.string()), - selectedOption: z.nativeEnum(ToolConfirmationOutcome), + // Accept values beyond ToolConfirmationOutcome so newer CLI versions can + // resolve permissions with options the SDK does not know about yet. + selectedOption: z.union([ + z.nativeEnum(ToolConfirmationOutcome), + z.string(), + ]), }) .passthrough(); @@ -726,7 +731,10 @@ export type RequestPermissionRequest = z.infer< export type RequestPermissionSelection = | ToolConfirmationOutcome - | `${ToolConfirmationOutcome}`; + | `${ToolConfirmationOutcome}` + // Preserve autocomplete for known outcomes while allowing handlers to echo + // back any option value offered by newer CLI versions. + | (string & {}); export type RequestPermissionHandlerResult = | RequestPermissionSelection @@ -738,7 +746,12 @@ export type RequestPermissionHandlerResult = /** Result for droid.request_permission response. */ export const RequestPermissionResultSchema = z .object({ - selectedOption: z.nativeEnum(ToolConfirmationOutcome), + // Accept values beyond ToolConfirmationOutcome so handlers can echo back + // options offered by newer CLI versions. + selectedOption: z.union([ + z.nativeEnum(ToolConfirmationOutcome), + z.string(), + ]), comment: z.string().optional(), }) .strict(); diff --git a/src/session.ts b/src/session.ts index 23d7876..693bf43 100644 --- a/src/session.ts +++ b/src/session.ts @@ -64,7 +64,6 @@ export interface CreateSessionOptions export interface ResumeSessionOptions extends Pick< CreateSessionOptions, - | 'apiKey' | 'execPath' | 'execArgs' | 'env' @@ -73,6 +72,11 @@ export interface ResumeSessionOptions extends Pick< | 'transport' | 'abortSignal' > { + /** + * Optional on resume: when omitted, the droid CLI falls back to its stored + * credentials. + */ + apiKey?: CreateSessionOptions['apiKey']; mcpServers?: DroidMcpServerConfig[]; } @@ -328,6 +332,9 @@ export async function createSession( /** * Resumes an existing Droid session. * + * The `options` argument is optional. When `apiKey` is omitted, the droid CLI + * falls back to its stored credentials. + * * The resumed session always runs in the working directory that was persisted * with the session at creation time. `resumeSession()` intentionally does not * accept a `cwd` option: the persisted session cwd is authoritative. The @@ -340,7 +347,7 @@ export async function createSession( */ export async function resumeSession( sessionId: string, - options: ResumeSessionOptions + options: ResumeSessionOptions = {} ): Promise { const { client } = await createConfiguredClient(options); let sdkMcpServers: Awaited> | undefined; diff --git a/src/stream.ts b/src/stream.ts index 60d31bc..60c0dfe 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -153,7 +153,9 @@ export interface PermissionResolved { readonly type: 'permission_resolved'; readonly requestId: string; readonly toolUseIds: string[]; - readonly selectedOption: ToolConfirmationOutcome; + // Known outcomes keep autocomplete, but newer CLI versions may resolve + // permissions with option values the SDK does not know about yet. + readonly selectedOption: ToolConfirmationOutcome | (string & {}); } export interface SettingsUpdated { diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 5c10b83..398e46a 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -259,6 +259,54 @@ describe('createTransport', () => { expect(result).toStrictEqual(customTransport); }); + + describe('FACTORY_API_KEY environment handling', () => { + /** Spawn `node -e` that reports its FACTORY_API_KEY, return the value. */ + async function spawnAndReadApiKey(options: { + apiKey?: string; + }): Promise { + const script = ` + console.log( + JSON.stringify({ key: process.env.FACTORY_API_KEY ?? null }) + ); + setTimeout(() => {}, 10000); + `; + const transport = await createTransport({ + ...options, + execPath: 'node', + execArgs: ['-e', script], + }); + + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timeout waiting for child env report')); + }, 5_000); + transport.onMessage((msg) => { + clearTimeout(timer); + resolve((msg as { key: string | null }).key); + }); + }); + } finally { + await transport.close(); + } + } + + it('overrides FACTORY_API_KEY when apiKey is provided', async () => { + const key = await spawnAndReadApiKey({ apiKey: 'explicit-key' }); + expect(key).toBe('explicit-key'); + }); + + it('preserves ambient FACTORY_API_KEY when apiKey is omitted', async () => { + vi.stubEnv('FACTORY_API_KEY', 'ambient-key'); + try { + const key = await spawnAndReadApiKey({}); + expect(key).toBe('ambient-key'); + } finally { + vi.unstubAllEnvs(); + } + }); + }); }); describe('setupClientHandlers', () => { diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 891f2a2..4f3c98b 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -22,6 +22,7 @@ import { ServerRequestHandlerType, ToolConfirmationOutcome, } from '../src/schemas/index.js'; +import type { RequestPermissionRequestParams } from '../src/schemas/server.js'; import { InMemoryTransport, makeErrorResponse, @@ -410,6 +411,67 @@ describe('ProtocolEngine', () => { selectedOption: ToolConfirmationOutcome.ProceedNewSessionHigh, }); }); + + it('handles option values outside ToolConfirmationOutcome', async () => { + const handler = vi.fn((params: RequestPermissionRequestParams) => { + const always = params.options.find( + (option) => option.value === 'proceed_always_tools' + ); + return always ? always.value : ToolConfirmationOutcome.Cancel; + }); + engine.setPermissionHandler(handler); + + transport.injectMessage( + makeServerRequest('perm-7', DroidClientMethod.REQUEST_PERMISSION, { + toolUses: [], + options: [ + { label: 'Always allow tool', value: 'proceed_always_tools' }, + { label: 'Cancel', value: ToolConfirmationOutcome.Cancel }, + ], + }) + ); + + await vi.waitFor(() => { + expect(transport.sentMessages).toHaveLength(1); + }); + + expect(handler).toHaveBeenCalledOnce(); + + const response = transport.sentMessages[0] as Record; + expect(response['type']).toBe('response'); + expect(response['error']).toBeUndefined(); + expect(response['result']).toEqual({ + selectedOption: 'proceed_always_tools', + }); + }); + + it('handles object results with unknown option values', async () => { + engine.setPermissionHandler(() => ({ + selectedOption: 'proceed_always_tools', + comment: 'ok', + })); + + transport.injectMessage( + makeServerRequest('perm-8', DroidClientMethod.REQUEST_PERMISSION, { + toolUses: [], + options: [ + { label: 'Always allow tool', value: 'proceed_always_tools' }, + ], + }) + ); + + await vi.waitFor(() => { + expect(transport.sentMessages).toHaveLength(1); + }); + + const response = transport.sentMessages[0] as Record; + expect(response['type']).toBe('response'); + expect(response['error']).toBeUndefined(); + expect(response['result']).toEqual({ + selectedOption: 'proceed_always_tools', + comment: 'ok', + }); + }); }); describe('ask-user requests', () => { diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index 18acecc..b17b870 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -646,6 +646,13 @@ describe('MCP schemas', () => { 'proceed_once' ); }); + + it('ToolConfirmationListItemSchema accepts unknown outcome values', () => { + const item = { label: 'Always allow tool', value: 'proceed_always_tools' }; + expect(ToolConfirmationListItemSchema.parse(item).value).toBe( + 'proceed_always_tools' + ); + }); }); describe('mission schemas', () => { @@ -1120,6 +1127,18 @@ describe('server notification schemas', () => { ); }); + it('PermissionResolvedNotificationSchema accepts unknown outcome values', () => { + const n = { + type: 'permission_resolved', + requestId: 'req-1', + toolUseIds: ['tu-1'], + selectedOption: 'proceed_always_tools', + }; + expect(PermissionResolvedNotificationSchema.parse(n).selectedOption).toBe( + 'proceed_always_tools' + ); + }); + it('SettingsUpdatedNotificationSchema parses valid notification', () => { const n = { type: 'settings_updated', @@ -1309,6 +1328,18 @@ describe('server→client request schemas', () => { expect(result.options).toHaveLength(1); }); + it('RequestPermissionRequestParamsSchema accepts unknown option values', () => { + const params = { + toolUses: [], + options: [ + { label: 'Always allow tool', value: 'proceed_always_tools' }, + { label: 'Cancel', value: 'cancel' }, + ], + }; + const result = RequestPermissionRequestParamsSchema.parse(params); + expect(result.options[0]?.value).toBe('proceed_always_tools'); + }); + it('RequestPermissionResultSchema parses valid result', () => { const result = { selectedOption: 'proceed_once', @@ -1341,9 +1372,16 @@ describe('server→client request schemas', () => { } }); - it('RequestPermissionResultSchema rejects invalid outcome', () => { + it('RequestPermissionResultSchema accepts unknown outcome values', () => { + const parsed = RequestPermissionResultSchema.parse({ + selectedOption: 'proceed_always_tools', + }); + expect(parsed.selectedOption).toBe('proceed_always_tools'); + }); + + it('RequestPermissionResultSchema rejects non-string outcome', () => { expect(() => - RequestPermissionResultSchema.parse({ selectedOption: 'invalid' }) + RequestPermissionResultSchema.parse({ selectedOption: 42 }) ).toThrow(); }); diff --git a/tests/session.test.ts b/tests/session.test.ts index 913bf7b..3c4acd7 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -331,6 +331,32 @@ describe('resumeSession()', () => { }); }); + describe('optional options', () => { + it('accepts a single argument at the type level', () => { + // Compile-time check: calling without options must typecheck. Not + // invoked, since a real call without a transport would spawn the CLI. + const oneArg: (sessionId: string) => Promise = + resumeSession; + expect(oneArg).toBe(resumeSession); + }); + + it('resumes without apiKey, falling back to stored credentials', async () => { + const transport = new InMemoryTransport(); + await transport.connect(); + + setupLoadResponder(transport, 'sess-resume-no-key'); + + const session = await resumeSession('sess-resume-no-key', { + transport, + }); + + expect(session).toBeInstanceOf(DroidSession); + expect(session.sessionId).toBe('sess-resume-no-key'); + + await session.close(); + }); + }); + describe('VAL-API-014: resumeSession with invalid ID throws SessionNotFoundError', () => { it('throws SessionNotFoundError for non-existent session', async () => { const transport = new InMemoryTransport();