From ccf25e154259751ba7a02e1a30c23ead345d670d Mon Sep 17 00:00:00 2001 From: User Date: Fri, 12 Jun 2026 15:38:33 -0700 Subject: [PATCH 01/10] fix: accept unknown permission option values and make resumeSession options optional Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/helpers.ts | 15 ++++++++-- src/protocol/cli.ts | 11 ++++++-- src/protocol/selectable-list-item.ts | 4 ++- src/schemas/mcp.ts | 4 ++- src/schemas/server.ts | 19 +++++++++++-- src/session.ts | 11 ++++++-- src/stream.ts | 4 ++- src/transport.ts | 2 +- src/types.ts | 2 +- tests/protocol.test.ts | 34 ++++++++++++++++++++++ tests/schemas.test.ts | 42 ++++++++++++++++++++++++++-- tests/session.test.ts | 26 +++++++++++++++++ 12 files changed, 158 insertions(+), 16 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index f221406..9e161e8 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; @@ -303,7 +314,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/src/transport.ts b/src/transport.ts index f3b9443..87b520f 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -24,7 +24,7 @@ export class ProcessTransport implements DroidClientTransport { private readonly execPath: string; private readonly execArgs: string[]; private readonly cwd: string | undefined; - private readonly env: Record | undefined; + private readonly env: Record | undefined; private readonly gracePeriodMs: number; private childProcess: ChildProcess | null = null; diff --git a/src/types.ts b/src/types.ts index d74b1f0..dac631e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,7 +23,7 @@ export interface ProcessTransportOptions { cwd?: string; - env?: Record; + env?: Record; gracePeriod?: number; } diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 891f2a2..42dbc25 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,39 @@ 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', + }); + }); }); 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(); From 8140fa4fe03997bffc898bbde469a5d35c21991f Mon Sep 17 00:00:00 2001 From: User Date: Fri, 12 Jun 2026 16:13:41 -0700 Subject: [PATCH 02/10] fix: preserve ambient FACTORY_API_KEY when resuming without apiKey Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/helpers.ts | 7 +++++- src/transport.ts | 2 +- src/types.ts | 2 +- tests/helpers.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++ tests/protocol.test.ts | 28 ++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 9e161e8..ae8514b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -289,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(); diff --git a/src/transport.ts b/src/transport.ts index 87b520f..f3b9443 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -24,7 +24,7 @@ export class ProcessTransport implements DroidClientTransport { private readonly execPath: string; private readonly execArgs: string[]; private readonly cwd: string | undefined; - private readonly env: Record | undefined; + private readonly env: Record | undefined; private readonly gracePeriodMs: number; private childProcess: ChildProcess | null = null; diff --git a/src/types.ts b/src/types.ts index dac631e..d74b1f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,7 +23,7 @@ export interface ProcessTransportOptions { cwd?: string; - env?: Record; + env?: Record; gracePeriod?: number; } 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 42dbc25..4f3c98b 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -444,6 +444,34 @@ describe('ProtocolEngine', () => { 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', () => { From 68550b3684ba653693603738551a421c678ddfe6 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 12 Jun 2026 16:30:44 -0700 Subject: [PATCH 03/10] docs(examples): polish examples for accuracy and consistency Standardize user-facing headers (description, usage, requirements) across all examples; rename test-compact.ts to compact-session.ts; remove test-rewind.ts (rewind RPC unavailable in exec mode) and the duplicate droid-dev-structured-output.ts; add a graceful FACTORY_API_KEY skip to the daemon example; make sdk-mcp-tool.ts print tool calls/results with droid as the default executable; harden the spec-mode-same-session permission handler with cancel logging and a clear failure message; use cheap conversational prompts in a temp dir for multi-turn-session.ts. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- examples/abort-session-stream.ts | 6 ++ .../{test-compact.ts => compact-session.ts} | 5 +- examples/daemon-multi-session.ts | 21 ++++- examples/droid-dev-structured-output.ts | 44 ---------- examples/fork-session.ts | 3 + examples/hook-execution.ts | 21 +++-- examples/init-metadata.ts | 22 +++-- examples/interrupt-session.ts | 9 ++ examples/list-sessions.ts | 6 ++ examples/multi-turn-session.ts | 25 +++++- examples/permission-handler.ts | 7 ++ examples/readme-structured-output.ts | 13 +++ examples/result-metadata.ts | 3 + examples/run.ts | 9 +- examples/sdk-mcp-tool.ts | 53 +++++++++-- examples/session-stream.ts | 3 + examples/spec-mode-new-session.ts | 7 ++ examples/spec-mode-same-session.ts | 71 +++++++++------ examples/structured-output.ts | 3 + examples/test-rewind.ts | 87 ------------------- examples/tool-controls.ts | 7 ++ 21 files changed, 235 insertions(+), 190 deletions(-) rename examples/{test-compact.ts => compact-session.ts} (91%) delete mode 100644 examples/droid-dev-structured-output.ts delete mode 100644 examples/test-rewind.ts 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 91% rename from examples/test-compact.ts rename to examples/compact-session.ts index 814b239..f5567a9 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'; diff --git a/examples/daemon-multi-session.ts b/examples/daemon-multi-session.ts index 1319707..aedc9a8 100644 --- a/examples/daemon-multi-session.ts +++ b/examples/daemon-multi-session.ts @@ -1,18 +1,31 @@ /** - * 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({ 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..78456ad 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 { 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..6e535ed 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 and logged in. FACTORY_API_KEY is + * optional; stored CLI credentials are used when it is unset. */ 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..1f6ba55 100644 --- a/examples/sdk-mcp-tool.ts +++ b/examples/sdk-mcp-tool.ts @@ -1,3 +1,19 @@ +/** + * 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 the MCP tool call and logs anything else it sees. + * + * 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, @@ -7,7 +23,7 @@ import { } 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 +42,42 @@ const session = await createSession({ execPath, mcpServers: [sdkTools], cwd: process.cwd(), - permissionHandler: () => ToolConfirmationOutcome.ProceedOnce, + permissionHandler(params) { + for (const item of params.toolUses) { + console.log(`[Permission] ${item.toolUse.name} -> proceed_once`); + } + return ToolConfirmationOutcome.ProceedOnce; + }, }); 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..aaafca7 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'; diff --git a/examples/spec-mode-same-session.ts b/examples/spec-mode-same-session.ts index 8463734..2d9f98c 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,27 @@ 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'); +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 +51,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 allApproved = params.toolUses.every((item) => + isScopedToTempDir(item.details) ); - return canExitSpec || - onlyCreatesFile || - onlyEditsTempFile || - onlyRunsTempCommand - ? ToolConfirmationOutcome.ProceedOnce - : ToolConfirmationOutcome.Cancel; + if (!allApproved) { + for (const item of params.toolUses) { + console.log( + `[Permission] Canceling unexpected tool request: ` + + `${item.toolUse.name} (${item.details.type})` + ); + } + return ToolConfirmationOutcome.Cancel; + } + + return ToolConfirmationOutcome.ProceedOnce; }, }); @@ -66,7 +79,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'; From c41cb23102c64f0c3ba67f0e23564972d39b858b Mon Sep 17 00:00:00 2001 From: User Date: Fri, 12 Jun 2026 16:49:24 -0700 Subject: [PATCH 04/10] docs(examples): address review feedback on permission handlers and cleanup Make the sdk-mcp-tool permission handler selective (approve only McpTool confirmations, cancel and log otherwise); log only the rejected items in spec-mode-same-session and note the scoping check is a demo heuristic; add a clear readFile failure path to spec-mode-new-session; guard daemon and session cleanup with try/finally in daemon-multi-session; correct the list-sessions requirements note; drop the redundant compactSession argument; reword the fork-session cleanup message. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- examples/compact-session.ts | 2 +- examples/daemon-multi-session.ts | 81 +++++++++++++++++------------- examples/fork-session.ts | 2 +- examples/list-sessions.ts | 4 +- examples/sdk-mcp-tool.ts | 14 ++++-- examples/spec-mode-new-session.ts | 10 +++- examples/spec-mode-same-session.ts | 10 ++-- 7 files changed, 75 insertions(+), 48 deletions(-) diff --git a/examples/compact-session.ts b/examples/compact-session.ts index f5567a9..407b506 100644 --- a/examples/compact-session.ts +++ b/examples/compact-session.ts @@ -46,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 aedc9a8..66fdf9a 100644 --- a/examples/daemon-multi-session.ts +++ b/examples/daemon-multi-session.ts @@ -28,47 +28,56 @@ async function main(): Promise { 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/fork-session.ts b/examples/fork-session.ts index 78456ad..f3c2687 100644 --- a/examples/fork-session.ts +++ b/examples/fork-session.ts @@ -61,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/list-sessions.ts b/examples/list-sessions.ts index 6e535ed..c817f65 100644 --- a/examples/list-sessions.ts +++ b/examples/list-sessions.ts @@ -7,8 +7,8 @@ * Usage: * npx tsx examples/list-sessions.ts * - * Requirements: droid CLI installed and logged in. FACTORY_API_KEY is - * optional; stored CLI credentials are used when it is unset. + * Requirements: droid CLI installed (reads local session files); no + * credentials needed. */ import { listSessions } from '@factory/droid-sdk'; diff --git a/examples/sdk-mcp-tool.ts b/examples/sdk-mcp-tool.ts index 1f6ba55..8607c28 100644 --- a/examples/sdk-mcp-tool.ts +++ b/examples/sdk-mcp-tool.ts @@ -4,7 +4,8 @@ * 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 the MCP tool call and logs anything else it sees. + * handler approves only MCP tool confirmations, cancels everything + * else, and logs each decision. * * Usage: * npx tsx examples/sdk-mcp-tool.ts @@ -17,6 +18,7 @@ import { DroidMessageType, ToolConfirmationOutcome, + ToolConfirmationType, createSession, createSdkMcpServer, tool, @@ -43,10 +45,16 @@ const session = await createSession({ mcpServers: [sdkTools], cwd: process.cwd(), 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} -> proceed_once`); + console.log(`[Permission] ${item.toolUse.name} -> ${decision}`); } - return ToolConfirmationOutcome.ProceedOnce; + return allMcp + ? ToolConfirmationOutcome.ProceedOnce + : ToolConfirmationOutcome.Cancel; }, }); diff --git a/examples/spec-mode-new-session.ts b/examples/spec-mode-new-session.ts index aaafca7..39fa802 100644 --- a/examples/spec-mode-new-session.ts +++ b/examples/spec-mode-new-session.ts @@ -62,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 2d9f98c..84194fa 100644 --- a/examples/spec-mode-same-session.ts +++ b/examples/spec-mode-same-session.ts @@ -29,6 +29,8 @@ import { 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: @@ -51,12 +53,12 @@ try { interactionMode: DroidInteractionMode.Spec, specModeReasoningEffort: ReasoningEffort.High, permissionHandler(params) { - const allApproved = params.toolUses.every((item) => - isScopedToTempDir(item.details) + const rejected = params.toolUses.filter( + (item) => !isScopedToTempDir(item.details) ); - if (!allApproved) { - for (const item of params.toolUses) { + if (rejected.length > 0) { + for (const item of rejected) { console.log( `[Permission] Canceling unexpected tool request: ` + `${item.toolUse.name} (${item.details.type})` From cc38c576d5f86099c6014dd8de91ce4da5c43e01 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 12 Jun 2026 17:05:57 -0700 Subject: [PATCH 05/10] docs: fix README accuracy against verified SDK behavior Add required apiKey to all examples, fix streaming examples to match default stream messages, document includePartialMessages, use real CLI tool IDs in tool controls, trigger the permission handler with autonomyLevel Off, type-safe structured output access, correct API table arity, expand DroidResult/DroidSession references, remove rewind, and refresh the examples list and doc links. All 14 code blocks typechecked and live-verified against droid CLI. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 208 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 140 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 1d7c84c..1b25325 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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. +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 - **Node.js 18+** @@ -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 a required option. When `FACTORY_API_KEY` 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); } } @@ -174,7 +213,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,25 +233,24 @@ 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). } await session.enterSpecMode({ @@ -219,36 +260,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`, 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 +303,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 +318,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); } } @@ -322,17 +368,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 +390,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); } } @@ -356,13 +405,15 @@ try { | Function | Description | | ----------------------------- | ---------------------------------------------------------------- | -| `run(prompt, options?)` | One-shot prompt → aggregated `DroidResult` | -| `createSession(options?)` | Create a new multi-turn session → `DroidSession` | +| `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 | +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` Returned by `createSession()` and `resumeSession()`. Key methods: @@ -378,8 +429,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 +446,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 +459,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 | +A 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). Pass `process.env.FACTORY_API_KEY!`; when the variable is 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 - **`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,24 +523,27 @@ 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 @@ -497,16 +568,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 +- **[`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 From 83e32f3d2aab2b310dff313021d54b8ebf9ac2d4 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 12 Jun 2026 17:18:13 -0700 Subject: [PATCH 06/10] docs: clarify apiKey semantics, MCP permission context, and spec mode flows in README Explain that apiKey's ! only satisfies TypeScript and the droid CLI falls back to stored credentials at runtime, note why the MCP example needs a permissionHandler, and mark enterSpecMode() as the alternative flow. Also trim the intro feature list, add permission-handling anchors, code-format the droid binary name, align the functions table return types, and tighten phrasing. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 57 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 1b25325..65704cb 100644 --- a/README.md +++ b/README.md @@ -1,8 +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. +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 @@ -30,7 +30,7 @@ const result = await run('What files are in the current directory?', { console.log(result.text); ``` -`apiKey` is a required option. When `FACTORY_API_KEY` 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. +`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: @@ -204,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 @@ -253,6 +255,7 @@ for await (const _msg of session.stream( // spec-mode approval flow (a permissionHandler confirmation). } +// Alternatively, switch an existing session into spec mode: await session.enterSpecMode({ specModeReasoningEffort: ReasoningEffort.High, }); @@ -260,11 +263,11 @@ await session.enterSpecMode({ await session.close(); ``` -When handling spec-mode approval in a `permissionHandler`, 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. +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 -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: +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'; @@ -329,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'; @@ -403,14 +406,14 @@ 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). @@ -468,7 +471,7 @@ for await (const msg of session.stream(prompt, { } ``` -A default `session.stream()` yields only these message types: +By default, `session.stream()` yields only these message types: | Type | Description | | ------------- | ----------------------------------------------- | @@ -509,10 +512,10 @@ The remaining types are only yielded when streaming with `includePartialMessages Session creation options used by `run()` and `createSession()` include: -- **`apiKey`** — Factory API key (required). Pass `process.env.FACTORY_API_KEY!`; when the variable is undefined, the droid CLI falls back to its stored login credentials +- **`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"`) @@ -547,14 +550,14 @@ Low-level JSON-RPC client for advanced use. Provides typed methods for the under ### 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 @@ -576,9 +579,9 @@ See the [`examples/`](./examples) directory for runnable examples: - **[`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 +- **[`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 +- **[`daemon-multi-session.ts`](./examples/daemon-multi-session.ts)** — run multiple sessions against a local `droid` daemon ## License From 1dbc11d39b4e83a9a17de91f7634765ff0dc4a4f Mon Sep 17 00:00:00 2001 From: User Date: Sat, 13 Jun 2026 17:08:55 -0700 Subject: [PATCH 07/10] docs: rewrite SDK usage guide for accuracy and structure Reorganize the flat section list into grouped H2 sections with a table of contents (Getting Started, Core Usage, Sessions, Streaming, Controlling the Droid, MCP, Observability, Error Handling, Low-level APIs, Configuration Reference). Fix verified inaccuracies against droid CLI 0.146.0: gate the SDK-backed MCP tools permissionHandler on ToolConfirmationType.McpTool (now works after the SDK schema fix); use real exec tool IDs (read-cli, execute-cli, grep_tool_cli, ...) and document session.listTools() for discovery; correct MCP status reporting to summary.connected/summary.total and servers[i].status; document addMcpServer user-level persistence plus remove/toggle with SettingsLevel.User. Add error-result handling (isError, subtype, errors, structuredOutputError), permissionHandler default (cancel everything), hooks-must-be-configured note, complete ReasoningEffort enum, reference defaults, ResumeSessionOptions table, files/DocumentSource example, rename/listTools/enterSpecMode coverage, and a low-level/protocol/REST pointer. Remove the Rewind section (RPC unverifiable on current CLI). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/sdk-usage-guide.md | 719 ++++++++++++++++++++++++++-------------- 1 file changed, 470 insertions(+), 249 deletions(-) diff --git a/docs/sdk-usage-guide.md b/docs/sdk-usage-guide.md index aabb176..d7ed9fd 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. + +--- + ## 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. @@ -193,69 +353,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 +409,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 +447,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 +509,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 +563,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 +728,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 +738,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 +778,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 +798,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 +823,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 +871,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 +885,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 +903,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 +965,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. From feb68094836670b36f53a2e122f5852903bb7a7c Mon Sep 17 00:00:00 2001 From: User Date: Sat, 13 Jun 2026 17:13:24 -0700 Subject: [PATCH 08/10] docs: note try/finally pattern and fix interrupt example in SDK guide Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/sdk-usage-guide.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/sdk-usage-guide.md b/docs/sdk-usage-guide.md index d7ed9fd..5abd41a 100644 --- a/docs/sdk-usage-guide.md +++ b/docs/sdk-usage-guide.md @@ -42,7 +42,7 @@ This guide covers **exec mode** (`run()`, `createSession()`), which spawns a `dr - [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. +> **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. --- @@ -329,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(); From f0f2640107c74f07b464e8faf514133fe27e944e Mon Sep 17 00:00:00 2001 From: User Date: Sat, 13 Jun 2026 17:15:46 -0700 Subject: [PATCH 09/10] docs: fix daemon guide accuracy (remove Ephemeral, expand DaemonSession, add baseUrl) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/daemon-usage-guide.md | 146 +++++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 48 deletions(-) diff --git a/docs/daemon-usage-guide.md b/docs/daemon-usage-guide.md index 5dbec63..254e746 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 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. | ### `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), `resolveWebSocketUrl()` (compute the WebSocket URL from `ConnectDaemonOptions`), and the constants `DEFAULT_DAEMON_PORT` (`37643`) and `DEFAULT_RELAY_BASE_URL` (`wss://relay.factory.ai`). From 1903365c4acaf2323b1f95fdac1e163b6f3f2b5d Mon Sep 17 00:00:00 2001 From: User Date: Sat, 13 Jun 2026 17:21:47 -0700 Subject: [PATCH 10/10] docs: correct daemon low-level exports claim and align tool-id wording Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/daemon-usage-guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/daemon-usage-guide.md b/docs/daemon-usage-guide.md index 254e746..d99e7b4 100644 --- a/docs/daemon-usage-guide.md +++ b/docs/daemon-usage-guide.md @@ -531,8 +531,8 @@ try { | `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. | +| `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. | @@ -591,4 +591,4 @@ try { ### Low-level Exports -For advanced use, the package root also exports the daemon primitives used internally: `ensureLocalDaemon()` (discover or spawn a local daemon), `resolveWebSocketUrl()` (compute the WebSocket URL from `ConnectDaemonOptions`), and the constants `DEFAULT_DAEMON_PORT` (`37643`) and `DEFAULT_RELAY_BASE_URL` (`wss://relay.factory.ai`). +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.