diff --git a/src/index.ts b/src/index.ts index 3b9fc54..c0ce038 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,12 @@ export type { PermissionHandler, } from './protocol.js'; +// Namespace export for the source-of-truth protocol schemas under src/protocol/. +// Exposed as a namespace to avoid colliding with the flat exports above from +// the legacy `./protocol.js` file. Consumers access these as +// `protocol.DaemonRequestSchema`, `protocol.daemon.…`, `protocol.usage.…`, etc. +export * as protocol from './protocol/index.js'; + export { DroidClient } from './client.js'; export type { ClientAskUserHandler, diff --git a/src/protocol/automations-enums.ts b/src/protocol/automations-enums.ts new file mode 100644 index 0000000..318db8f --- /dev/null +++ b/src/protocol/automations-enums.ts @@ -0,0 +1,50 @@ +/** Named schedule cadences */ +export enum AutomationScheduleCadence { + Daily = 'daily', + Weekly = 'weekly', + Monthly = 'monthly', +} + +/** Types of validation issues */ +export enum AutomationValidationIssueType { + MissingFile = 'missing_file', + InvalidFrontmatter = 'invalid_frontmatter', + InvalidSchedule = 'invalid_schedule', + ParseError = 'parse_error', +} + +// ============================================================================= +// Control-plane enums +// ============================================================================= + +/** + * Control-plane error codes. + * + * These codes enable deterministic error handling across CLI/desktop surfaces. + */ +export enum AutomationErrorCode { + /** Automation with the given ID was not found */ + NotFound = 'AUTOMATION_NOT_FOUND', + /** Automation already exists with the given ID */ + AlreadyExists = 'AUTOMATION_ALREADY_EXISTS', + /** Invalid automation ID format */ + InvalidId = 'INVALID_AUTOMATION_ID', + /** Invalid configuration (e.g., invalid schedule) */ + InvalidConfig = 'INVALID_AUTOMATION_CONFIG', + /** Automation is already in the requested state */ + AlreadyInState = 'ALREADY_IN_STATE', + /** A run is already in progress for this automation */ + RunInProgress = 'RUN_IN_PROGRESS', + /** Heartbeat execution failed */ + ExecutionFailed = 'EXECUTION_FAILED', + /** General internal error */ + InternalError = 'INTERNAL_ERROR', +} + +/** Privacy level for an automation */ +export enum AutomationPrivacyLevel { + /** Only visible to the creator */ + Private = 'private', + /** Visible to all members of the organization */ + Organization = 'organization', +} diff --git a/src/protocol/automations.ts b/src/protocol/automations.ts new file mode 100644 index 0000000..c59c2d7 --- /dev/null +++ b/src/protocol/automations.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { AutomationPrivacyLevel } from './automations-enums.js'; + +export const AutomationCreatedBySchema = z.object({ + name: z.string(), + email: z.string().optional(), + avatarUrl: z.string().optional(), +}); + +export type AutomationCreatedBy = z.infer; + +/** + * Schema for the on-disk YAML metadata in HEARTBEAT.md frontmatter. + * + * This is the flat representation stored between `---` delimiters. + * Differs from AutomationConfig in that `schedule` is a plain string + * (e.g. "daily", "0 9 * * 1-5") rather than the runtime + * `{ cadence: string }` shape. + */ +export const AutomationsHeartbeatSchema = z.object({ + id: z.string().uuid().optional(), + name: z.string(), + description: z.string().optional(), + schedule: z.string(), + model: z.string().optional(), + tags: z.array(z.string()).optional(), + paused: z.boolean().optional(), + privacyLevel: z.nativeEnum(AutomationPrivacyLevel).optional(), + createdBy: AutomationCreatedBySchema.optional(), + forkedFrom: z.string().optional(), +}); + +export type AutomationsHeartbeat = z.infer; diff --git a/src/protocol/constants.ts b/src/protocol/constants.ts index be64c25..dac3651 100644 --- a/src/protocol/constants.ts +++ b/src/protocol/constants.ts @@ -15,6 +15,28 @@ export const FACTORY_PROTOCOL_VERSION = '1.62.0' as const; export const JSONRPC_VERSION = '2.0' as const; +/** + * Sentinel ModelID value for the Factory routing model. Faithful copy of + * packages/common/src/llm/constants.ts. + */ +export const FACTORY_ROUTER_MODEL_ID = 'factory-router' as const; + +/** + * Sentinel value used by CompactionModelSchema to mean "use the session's + * current model for compaction". Faithful copy of + * packages/common/src/settings/constants.ts. + */ +export const CURRENT_COMPACTION_MODEL = 'current-model'; + +// Faithful copy of packages/common/src/settings/constants.ts router-rule +// guardrails. Used by ManagedSettingsBaseSchema and FactoryRouterRuleSchema. + +/** Sized comparably to a trimmed user message in the classifier context budget. */ +export const FACTORY_ROUTER_GUIDANCE_MAX_LENGTH = 2000; +export const FACTORY_ROUTER_RULES_MAX_COUNT = 20; +export const FACTORY_ROUTER_RULE_WHEN_MAX_LENGTH = 300; +export const FACTORY_ROUTER_RULE_GUIDANCE_MAX_LENGTH = 600; + const seconds = (value: number) => value * 1_000; const minutes = (value: number) => seconds(value * 60); const hours = (value: number) => minutes(value * 60); diff --git a/src/protocol/daemon/automations-enums.ts b/src/protocol/daemon/automations-enums.ts new file mode 100644 index 0000000..ebac448 --- /dev/null +++ b/src/protocol/daemon/automations-enums.ts @@ -0,0 +1,4 @@ +// Re-export from the hoisted top-level automations enums module so daemon +// consumers continue to import `AutomationPrivacyLevel` from this path while +// the canonical definition lives at `src/protocol/automations-enums.ts`. +export * from '../automations-enums.js'; diff --git a/src/protocol/daemon/automations.ts b/src/protocol/daemon/automations.ts new file mode 100644 index 0000000..38c9bf9 --- /dev/null +++ b/src/protocol/daemon/automations.ts @@ -0,0 +1,301 @@ +import z from 'zod'; + +import { JsonRpcBaseRequestSchema } from '../json-rpc.js'; +import { AutomationPrivacyLevel } from './automations-enums.js'; +import { DaemonDroidMethod } from './enums.js'; + +// LIST_AUTOMATIONS - discover automations from the filesystem +const DaemonListAutomationsRequestParamsSchema = z.object({ + basePath: z.string().optional(), +}); + +export const DaemonListAutomationsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_AUTOMATIONS), + params: DaemonListAutomationsRequestParamsSchema, + }); + +export const AutomationEntrySchema = z.object({ + /** Directory name slug (e.g. "health-check"). Kept as `id` because protocol rules forbid renaming required fields. */ + id: z.string(), + /** Stable UUID from HEARTBEAT.md frontmatter, used for backend/Firestore sync. */ + uuid: z.string().optional(), + name: z.string(), + description: z.string().optional(), + status: z.string(), + schedule: z.string().optional(), + model: z.string().optional(), + tags: z.array(z.string()).optional(), + nextRunAt: z.string().optional(), + lastRunAt: z.string().optional(), + isValid: z.boolean(), + path: z.string(), + privacyLevel: z.string().optional(), + createdBy: z + .object({ + name: z.string(), + email: z.string().optional(), + avatarUrl: z.string().optional(), + }) + .optional(), + forkedFrom: z.string().optional(), + /** Present when the automation targets a remote computer. */ + computerId: z.string().optional(), +}); + +export const DaemonListAutomationsResultSchema = z.object({ + automations: z.array(AutomationEntrySchema), +}); + +// RUN_AUTOMATION - resolve automation metadata so frontend can create a session. +// For local automations: reads HEARTBEAT.md from filesystem. +// For computer automations: fetches from backend API when computerId is present. +const DaemonRunAutomationRequestParamsSchema = z.object({ + automationId: z.string(), + /** Directory name of the automation (e.g. "health-check"). Preferred over automationId. */ + automationDirName: z.string().optional(), + basePath: z.string().optional(), + /** When set, fetches the automation from the backend API instead of local filesystem. */ + computerId: z.string().optional(), +}); + +export const DaemonRunAutomationRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.RUN_AUTOMATION), + params: DaemonRunAutomationRequestParamsSchema, + } +); + +export const DaemonRunAutomationResultSchema = z.object({ + prompt: z.string(), + automationName: z.string(), + /** UUID from HEARTBEAT.md frontmatter (falls back to directory name if no UUID) */ + automationId: z.string(), + cwd: z.string(), + model: z.string().optional(), + /** Present when the automation targets a remote computer. */ + computerId: z.string().optional(), +}); + +// PAUSE_AUTOMATION +const DaemonPauseAutomationRequestParamsSchema = z.object({ + automationId: z.string(), + automationDirName: z.string().optional(), + basePath: z.string().optional(), +}); + +export const DaemonPauseAutomationRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.PAUSE_AUTOMATION), + params: DaemonPauseAutomationRequestParamsSchema, + }); + +export const DaemonPauseAutomationResultSchema = z.object({ + success: z.boolean(), + automationId: z.string(), + status: z.string(), + error: z.string().optional(), +}); + +// RESUME_AUTOMATION +const DaemonResumeAutomationRequestParamsSchema = z.object({ + automationId: z.string(), + automationDirName: z.string().optional(), + basePath: z.string().optional(), +}); + +export const DaemonResumeAutomationRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.RESUME_AUTOMATION), + params: DaemonResumeAutomationRequestParamsSchema, + }); + +export const DaemonResumeAutomationResultSchema = z.object({ + success: z.boolean(), + automationId: z.string(), + status: z.string(), + error: z.string().optional(), +}); + +// GET_AUTOMATION_HISTORY +const DaemonGetAutomationHistoryRequestParamsSchema = z.object({ + automationId: z.string(), + automationDirName: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + basePath: z.string().optional(), +}); + +export const DaemonGetAutomationHistoryRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GET_AUTOMATION_HISTORY), + params: DaemonGetAutomationHistoryRequestParamsSchema, + }); + +const AutomationRunRecordSchema = z.object({ + runId: z.string(), + automationId: z.string(), + status: z.string(), + startedAt: z.string(), + completedAt: z.string().optional(), + durationMs: z.number().optional(), + errorMessage: z.string().optional(), + isRetry: z.boolean().optional(), + originalRunId: z.string().optional(), +}); + +export const DaemonGetAutomationHistoryResultSchema = z.object({ + automationId: z.string(), + runs: z.array(AutomationRunRecordSchema), + totalCount: z.number(), +}); + +// GET_AUTOMATION_VISUAL +const DaemonGetAutomationVisualRequestParamsSchema = z.object({ + automationId: z.string(), + automationDirName: z.string().optional(), + basePath: z.string().optional(), + sessionId: z.string().optional(), +}); + +export const DaemonGetAutomationVisualRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GET_AUTOMATION_VISUAL), + params: DaemonGetAutomationVisualRequestParamsSchema, + }); + +export const DaemonGetAutomationVisualResultSchema = z.object({ + automationId: z.string(), + exists: z.boolean(), + content: z.string().optional(), + isStale: z.boolean().optional(), + s3Url: z.string().optional(), +}); + +// CREATE_AUTOMATION +const DaemonCreateAutomationRequestParamsSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + schedule: z.string(), + basePath: z.string().optional(), +}); + +export const DaemonCreateAutomationRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.CREATE_AUTOMATION), + params: DaemonCreateAutomationRequestParamsSchema, + }); + +export const DaemonCreateAutomationResultSchema = z.object({ + success: z.boolean(), + automationId: z.string().optional(), + error: z.string().optional(), +}); + +// UPDATE_AUTOMATION_MODEL - update the model field in HEARTBEAT.md frontmatter +const DaemonUpdateAutomationModelRequestParamsSchema = z.object({ + automationId: z.string(), + automationDirName: z.string().optional(), + model: z.string().nullable(), + basePath: z.string().optional(), +}); + +export const DaemonUpdateAutomationModelRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.UPDATE_AUTOMATION_MODEL), + params: DaemonUpdateAutomationModelRequestParamsSchema, + }); + +export const DaemonUpdateAutomationModelResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); + +// UPDATE_AUTOMATION_PRIVACY - update the privacyLevel field in HEARTBEAT.md frontmatter +const DaemonUpdateAutomationPrivacyRequestParamsSchema = z.object({ + automationId: z.string(), + automationDirName: z.string().optional(), + privacyLevel: z.nativeEnum(AutomationPrivacyLevel), + createdBy: z + .object({ + name: z.string(), + email: z.string().optional(), + avatarUrl: z.string().optional(), + }) + .optional(), + basePath: z.string().optional(), +}); + +export const DaemonUpdateAutomationPrivacyRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.UPDATE_AUTOMATION_PRIVACY), + params: DaemonUpdateAutomationPrivacyRequestParamsSchema, + }); + +export const DaemonUpdateAutomationPrivacyResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); + +// RENAME_AUTOMATION - update the name field in HEARTBEAT.md frontmatter +const DaemonRenameAutomationRequestParamsSchema = z.object({ + automationId: z.string(), + automationDirName: z.string().optional(), + newName: z.string(), + basePath: z.string().optional(), +}); + +export const DaemonRenameAutomationRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.RENAME_AUTOMATION), + params: DaemonRenameAutomationRequestParamsSchema, + }); + +export const DaemonRenameAutomationResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); + +// DELETE_AUTOMATION - remove an automation directory +const DaemonDeleteAutomationRequestParamsSchema = z.object({ + automationId: z.string(), + automationDirName: z.string().optional(), + basePath: z.string().optional(), +}); + +export const DaemonDeleteAutomationRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.DELETE_AUTOMATION), + params: DaemonDeleteAutomationRequestParamsSchema, + }); + +export const DaemonDeleteAutomationResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); + +// FORK_AUTOMATION - fork a shared automation into the local filesystem +const DaemonForkAutomationRequestParamsSchema = z.object({ + automationId: z.string(), + name: z.string(), + description: z.string().optional(), + schedule: z.string(), + tags: z.array(z.string()).optional(), + model: z.string().optional(), + prompt: z.string(), + forkedFrom: z.string(), + localDirName: z.string(), +}); + +export const DaemonForkAutomationRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.FORK_AUTOMATION), + params: DaemonForkAutomationRequestParamsSchema, + }); + +export const DaemonForkAutomationResultSchema = z.object({ + success: z.boolean(), + automationId: z.string().optional(), + error: z.string().optional(), +}); diff --git a/src/protocol/daemon/bug-report.ts b/src/protocol/daemon/bug-report.ts new file mode 100644 index 0000000..448457b --- /dev/null +++ b/src/protocol/daemon/bug-report.ts @@ -0,0 +1,21 @@ +import z from 'zod'; + +import { JsonRpcBaseRequestSchema } from '../json-rpc.js'; +import { DaemonDroidMethod } from './enums.js'; + +// SUBMIT_BUG_REPORT - create and upload a bug report +const DaemonSubmitBugReportRequestParamsSchema = z.object({ + sessionId: z.string(), + userComment: z.string(), + clientLogs: z.string().optional(), +}); + +export const DaemonSubmitBugReportRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.SUBMIT_BUG_REPORT), + params: DaemonSubmitBugReportRequestParamsSchema, + }); + +export const DaemonSubmitBugReportResultSchema = z.object({ + bugReportId: z.string(), +}); diff --git a/src/protocol/daemon/connection.ts b/src/protocol/daemon/connection.ts new file mode 100644 index 0000000..b43621d --- /dev/null +++ b/src/protocol/daemon/connection.ts @@ -0,0 +1,127 @@ +import z from 'zod'; + +import { Platform } from '../enums.js'; +import { + JsonRpcBaseRequestSchema, + JsonRpcBaseResponseSuccessSchema, + JsonRpcBaseResponseFailureSchema, + JsonRpcBaseNotificationSchema, +} from '../json-rpc.js'; +import { DaemonConnectionMethod, DaemonConnectionEvent } from './enums.js'; + +/** + * Client-supplied metadata attached to the authenticate handshake. + * Extensible: new fields can be added here without changing the + * transport protocol. The daemon stores these on the connection + * context and uses them for span attribution. + */ +export const DaemonConnectionMetadataSchema = z.object({ + tracing: z + .object({ + /** Human interaction surface (e.g., 'web', 'desktop', 'cli_tui'). Absent for infrastructure callers. */ + app: z.string().optional(), + /** Client-relative machine classification: local | ephemeral | computer. */ + machineType: z.string().optional(), + /** Computer provider: byom | e2b. */ + machineProvider: z.string().optional(), + /** Transport kind: ws_localhost | ws_direct | ws_relay. */ + daemonTransport: z.string().optional(), + }) + .optional(), +}); + +// Connection-level authentication schemas +export const DaemonAuthenticateRequestParamsSchema = z + .object({ + /** WorkOS JWT access token */ + token: z.string().optional(), + /** Factory API key (fk-*) */ + apiKey: z.string().optional(), + /** Frontend-generated connection ID for trace correlation */ + connectionId: z.string().optional(), + /** Caller identifier for debugging (e.g., 'frontend', 'backend-v0-sessions', 'cli') */ + caller: z.string(), + /** Client-supplied metadata for tracing and diagnostics. */ + metadata: DaemonConnectionMetadataSchema.optional(), + }) + .refine((data) => data.token || data.apiKey, { + message: 'Either token or apiKey must be provided', + }); + +export const DaemonAuthenticateRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonConnectionMethod.AUTHENTICATE), + params: DaemonAuthenticateRequestParamsSchema, +}); + +export const DaemonAuthenticateResultSchema = z.object({ + userId: z.string(), + orgId: z.string(), +}); + +export const DaemonAuthenticateResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonAuthenticateResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonLogoutRequestParamsSchema = z.object({}).strict(); + +export const DaemonLogoutRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonConnectionMethod.LOGOUT), + params: DaemonLogoutRequestParamsSchema, +}); + +export const DaemonLogoutResultSchema = z.object({ + accepted: z.literal(true), +}); + +export const DaemonLogoutResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonLogoutResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// Connection status notification schemas +export const DaemonConnectionStatusNotificationParamsSchema = z.object({ + isDroidCLIInPath: z.boolean(), + droidCLIVersion: z.string().optional(), + homedir: z.string(), + platform: z.nativeEnum(Platform), +}); + +export const DaemonConnectionStatusNotificationSchema = + JsonRpcBaseNotificationSchema.extend({ + method: z.literal(DaemonConnectionEvent.CONNECTION_STATUS), + params: DaemonConnectionStatusNotificationParamsSchema, + }); + +// Inferred types +export type DaemonConnectionMetadata = z.infer< + typeof DaemonConnectionMetadataSchema +>; +export type DaemonAuthenticateRequestParams = z.infer< + typeof DaemonAuthenticateRequestParamsSchema +>; +export type DaemonAuthenticateRequest = z.infer< + typeof DaemonAuthenticateRequestSchema +>; +export type DaemonAuthenticateResult = z.infer< + typeof DaemonAuthenticateResultSchema +>; +export type DaemonAuthenticateResponse = z.infer< + typeof DaemonAuthenticateResponseSchema +>; +export type DaemonLogoutRequestParams = z.infer< + typeof DaemonLogoutRequestParamsSchema +>; +export type DaemonLogoutRequest = z.infer; +export type DaemonLogoutResult = z.infer; +export type DaemonLogoutResponse = z.infer; +export type DaemonConnectionStatusNotificationParams = z.infer< + typeof DaemonConnectionStatusNotificationParamsSchema +>; +export type DaemonConnectionStatusNotification = z.infer< + typeof DaemonConnectionStatusNotificationSchema +>; diff --git a/src/protocol/daemon/droid.ts b/src/protocol/daemon/droid.ts new file mode 100644 index 0000000..56be50f --- /dev/null +++ b/src/protocol/daemon/droid.ts @@ -0,0 +1,1288 @@ +import z from 'zod'; + +import { + AskUserRequestParamsSchema, + AskUserResultSchema, + RequestPermissionRequestParamsSchema, + RequestPermissionResultSchema, + SessionNotificationSchemaList, +} from '../cli.js'; +import { + AddUserMessageRequestParamsSchema, + AddUserMessageResultSchema, + GetContextBreakdownResultSchema, + InitializeSessionRequestParamsSchema, + InitializeSessionResultSchema, + InterruptSessionRequestParamsSchema, + InterruptSessionResultSchema, + KillWorkerSessionRequestParamsSchema, + KillWorkerSessionResultSchema, + LoadSessionRequestParamsSchema, + LoadSessionResultSchema, + ResolveQueuedUserMessageRequestParamsSchema, + ResolveQueuedUserMessageResultSchema, + UpdateSessionSettingsRequestParamsSchema, + UpdateSessionSettingsResultSchema, + ValidateWorkingDirectoryResultSchema, +} from '../client.js'; +import { DroidWorkingState, MissionState } from '../enums.js'; +import { MessageRoleNoSystem } from '../enums.js'; +import { HostIdSchema } from '../host.js'; +import { + CommandAckSchema, + JsonRpcBaseNotificationSchema, + JsonRpcBaseRequestSchema, + JsonRpcBaseResponseSuccessSchema, + JsonRpcBaseResponseFailureSchema, +} from '../json-rpc.js'; +import { + StartLoopRequestParamsSchema, + StartLoopResultSchema, + StopLoopRequestParamsSchema, + StopLoopResultSchema, + GetLoopStatusRequestParamsSchema, + GetLoopStatusResultSchema, + RunLoopNowRequestParamsSchema, + RunLoopNowResultSchema, +} from '../loop.js'; +import { FactoryDroidMessageSchema } from '../messages.js'; +import { SessionTagSchema } from '../session.js'; +import { + DaemonListAutomationsRequestSchema, + DaemonRunAutomationRequestSchema, + DaemonPauseAutomationRequestSchema, + DaemonResumeAutomationRequestSchema, + DaemonGetAutomationHistoryRequestSchema, + DaemonGetAutomationVisualRequestSchema, + DaemonCreateAutomationRequestSchema, + DaemonUpdateAutomationModelRequestSchema, + DaemonUpdateAutomationPrivacyRequestSchema, + DaemonRenameAutomationRequestSchema, + DaemonDeleteAutomationRequestSchema, + DaemonForkAutomationRequestSchema, +} from './automations.js'; +import { DaemonSubmitBugReportRequestSchema } from './bug-report.js'; +import { + DaemonDroidEvent, + DaemonDroidMethod, + DaemonGetGitDiffUnavailableReason, + DaemonSpecificNotificationType, + SessionSearchDocKind, +} from './enums.js'; +import { + DaemonInstallSshKeyRequestSchema, + DaemonTriggerUpdateRequestSchema, +} from './management.js'; +import { + DaemonGetMcpConfigRequestSchema, + DaemonUpdateMcpConfigRequestSchema, + DaemonToggleMcpServerRequestSchema, + DaemonAuthenticateMcpServerRequestSchema, + DaemonCancelMcpAuthRequestSchema, + DaemonClearMcpAuthRequestSchema, + DaemonAddMcpServerRequestSchema, + DaemonRemoveMcpServerRequestSchema, + DaemonListMcpRegistryRequestSchema, + DaemonListMcpToolsRequestSchema, + DaemonListMcpServersRequestSchema, + DaemonToggleMcpToolRequestSchema, + DaemonSubmitMcpAuthCodeRequestSchema, +} from './mcp.js'; +import { + DaemonListAvailablePluginsRequestSchema, + DaemonListInstalledPluginsRequestSchema, + DaemonInstallPluginRequestSchema, + DaemonUninstallPluginRequestSchema, + DaemonUpdatePluginRequestSchema, + DaemonListMarketplacesRequestSchema, + DaemonAddMarketplaceRequestSchema, + DaemonRemoveMarketplaceRequestSchema, + DaemonUpdateMarketplaceRequestSchema, +} from './plugins.js'; +import { + DaemonGetDefaultSettingsRequestSchema, + DaemonUpdateSessionDefaultsRequestSchema, +} from './settings.js'; +import { DaemonListSkillsRequestSchema } from './skills.js'; +import { + TerminalDataNotificationSchema, + TerminalExitNotificationSchema, +} from './terminal.js'; + +// Daemon request params schemas - extend client schemas with sessionId and token where needed + +export const DaemonCommandAckResultSchema = CommandAckSchema; + +const createResponseSchema = (resultSchema: T) => + z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: resultSchema, + }), + JsonRpcBaseResponseFailureSchema, + ]); + +const createAckCompatibleResponseSchema = ( + resultSchema: T +) => + z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: z.union([DaemonCommandAckResultSchema, resultSchema]), + }), + JsonRpcBaseResponseFailureSchema, + ]); + +export const DaemonInitializeSessionRequestParamsSchema = + InitializeSessionRequestParamsSchema.extend({ + token: z.string(), + inactivityTimeoutMs: z.number().int().positive().optional(), + disableInactivityTimeout: z.boolean().optional(), + runtimeSettingsPath: z.string().optional(), + }); + +/** Session spawn options captured for loadSession replay. */ +export const DaemonLoadSessionSpawnOptionsSchema = z.object({ + disableInactivityTimeout: z.boolean().optional(), + skipPermissionsUnsafe: z.boolean().optional(), + runtimeSettingsPath: z.string().optional(), +}); + +export const DaemonLoadSessionRequestParamsSchema = + LoadSessionRequestParamsSchema.merge( + DaemonLoadSessionSpawnOptionsSchema + ).extend({ + token: z.string(), + }); + +export const DaemonAddUserMessageRequestParamsSchema = + AddUserMessageRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonResolveQueuedUserMessageRequestParamsSchema = + ResolveQueuedUserMessageRequestParamsSchema.and( + z.object({ + sessionId: z.string(), + }) + ); + +export const DaemonInterruptSessionRequestParamsSchema = + InterruptSessionRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonCloseSessionRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonSessionListFilterSchema = z.object({ + missionSessions: z.boolean().optional(), + includeBtwForks: z.boolean().optional(), +}); + +export const DaemonListOpenedSessionsRequestParamsSchema = z.object({ + filter: DaemonSessionListFilterSchema.optional(), +}); + +export const DaemonListAvailableSessionsFilterSchema = + DaemonSessionListFilterSchema.extend({ + missionStates: z.array(z.nativeEnum(MissionState)).optional(), + }); + +export const DaemonListAvailableSessionsRequestParamsSchema = z.object({ + limit: z.number().min(1).max(100).default(50), // Default 50 + endBefore: z.number().optional(), // Unix epoch seconds cursor + includeMissionMetadata: z.boolean().optional(), + filter: DaemonListAvailableSessionsFilterSchema.optional(), +}); + +export const DaemonGetSessionMessagesRequestParamsSchema = z.object({ + sessionId: z.string(), + limit: z.number().min(1).max(100).default(20), + cursor: z.string().optional(), + role: z.nativeEnum(MessageRoleNoSystem).optional(), +}); + +export const DaemonUpdateSessionSettingsRequestParamsSchema = + UpdateSessionSettingsRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonStartLoopRequestParamsSchema = + StartLoopRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonStopLoopRequestParamsSchema = + StopLoopRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonGetLoopStatusRequestParamsSchema = + GetLoopStatusRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonRunLoopNowRequestParamsSchema = + RunLoopNowRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonGetUserInfoRequestParamsSchema = z.object({}); + +export const DaemonValidateWorkingDirectoryRequestParamsSchema = z.object({ + workingDirectory: z.string(), +}); + +export const DaemonInitializeSessionRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.INITIALIZE_SESSION), + params: DaemonInitializeSessionRequestParamsSchema, + }); + +export const DaemonLoadSessionRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LOAD_SESSION), + params: DaemonLoadSessionRequestParamsSchema, +}); + +export const DaemonAddUserMessageRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.ADD_USER_MESSAGE), + params: DaemonAddUserMessageRequestParamsSchema, + }); + +export const DaemonResolveQueuedUserMessageRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.RESOLVE_QUEUED_USER_MESSAGE), + params: DaemonResolveQueuedUserMessageRequestParamsSchema, + }); + +export const DaemonInterruptSessionRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.INTERRUPT_SESSION), + params: DaemonInterruptSessionRequestParamsSchema, + }); + +export const DaemonCloseSessionRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.CLOSE_SESSION), + params: DaemonCloseSessionRequestParamsSchema, +}); + +export const DaemonCloseSessionResultSchema = z.object({}); + +export const DaemonKillWorkerSessionRequestParamsSchema = z + .object({ + /** The orchestrator session ID (where the mission is running) */ + sessionId: z.string(), + }) + .extend(KillWorkerSessionRequestParamsSchema.shape); + +export const DaemonKillWorkerSessionRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.KILL_WORKER_SESSION), + params: DaemonKillWorkerSessionRequestParamsSchema, + }); + +export const DaemonKillWorkerSessionResultSchema = + KillWorkerSessionResultSchema; + +export const DaemonListOpenedSessionsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_OPENED_SESSIONS), + params: DaemonListOpenedSessionsRequestParamsSchema, + }); + +export const DaemonListAvailableSessionsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_AVAILABLE_SESSIONS), + params: DaemonListAvailableSessionsRequestParamsSchema, + }); + +export const DaemonGetSessionMessagesRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GET_SESSION_MESSAGES), + params: DaemonGetSessionMessagesRequestParamsSchema, + }); + +export const DaemonUpdateSessionSettingsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.UPDATE_SESSION_SETTINGS), + params: DaemonUpdateSessionSettingsRequestParamsSchema, + }); + +export const DaemonStartLoopRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.START_LOOP), + params: DaemonStartLoopRequestParamsSchema, +}); + +export const DaemonStopLoopRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.STOP_LOOP), + params: DaemonStopLoopRequestParamsSchema, +}); + +export const DaemonGetLoopStatusRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.GET_LOOP_STATUS), + params: DaemonGetLoopStatusRequestParamsSchema, + } +); + +export const DaemonRunLoopNowRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.RUN_LOOP_NOW), + params: DaemonRunLoopNowRequestParamsSchema, +}); + +export const DaemonValidateWorkingDirectoryRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.VALIDATE_WORKING_DIRECTORY), + params: DaemonValidateWorkingDirectoryRequestParamsSchema, + }); + +export const DaemonInitializeSessionResultSchema = + InitializeSessionResultSchema; + +export const DaemonLoadSessionResultSchema = LoadSessionResultSchema; + +export const DaemonAddUserMessageResultSchema = AddUserMessageResultSchema; + +export const DaemonResolveQueuedUserMessageResultSchema = + ResolveQueuedUserMessageResultSchema; + +export const DaemonInterruptSessionResultSchema = InterruptSessionResultSchema; + +export const DaemonOpenedSessionInfoSchema = z.object({ + sessionId: z.string(), + hostId: HostIdSchema.optional(), + updatedAt: z.number(), // Unix epoch seconds (in-memory updatedAt timestamp) + workingState: z.nativeEnum(DroidWorkingState), + cwd: z.string().optional(), // Working directory for the session (may be missing for older sessions) + repoRoot: z.string().optional(), // Git repo root for `cwd`; for worktree cwds this points at the main repo path. Used to group worktree sessions under their parent project in the sidebar. + messagesCount: z.number().optional(), // Number of messages in session + callingSessionId: z.string().optional(), // Parent session ID for child/worker sessions + callingToolUseId: z.string().optional(), // Parent tool use ID that spawned this child session + tags: z.array(SessionTagSchema).optional(), // Session tags for categorization +}); + +export const DaemonAvailableSessionInfoSchema = z.object({ + sessionId: z.string(), + hostId: HostIdSchema.optional(), + updatedAt: z.number(), // Unix epoch seconds (file mtime) + title: z.string().optional(), // May be missing on parse errors + cwd: z.string().optional(), // Working directory for the session (may be missing for older sessions) + repoRoot: z.string().optional(), // See DaemonOpenedSessionInfoSchema.repoRoot. + messagesCount: z.number().optional(), // Number of messages in session + archivedAt: z.string().optional(), // ISO timestamp when archived - presence indicates archived status + callingSessionId: z.string().optional(), // Parent session ID for child/worker sessions (from JSONL summary `callingSessionId` field) + callingToolUseId: z.string().optional(), // Parent tool use ID that spawned this child session (from JSONL summary) + tags: z.array(SessionTagSchema).optional(), // Session tags for categorization + mission: z + .object({ + state: z.nativeEnum(MissionState).nullable(), + title: z.string().nullable(), + workingDirectory: z.string().nullable(), + createdAt: z.string().nullable(), + elapsedMs: z.number().nullable(), + completedFeatures: z.number().nullable(), + totalFeatures: z.number().nullable(), + }) + .optional(), +}); + +export const DaemonListOpenedSessionsResultSchema = z.object({ + sessions: z.array(DaemonOpenedSessionInfoSchema), +}); + +export const DaemonListAvailableSessionsResultSchema = z.object({ + sessions: z.array(DaemonAvailableSessionInfoSchema), + hasMore: z.boolean(), + nextCursor: z.number().optional(), +}); + +export const DaemonGetSessionMessagesResultSchema = z.object({ + messages: z.array(FactoryDroidMessageSchema), + hasMore: z.boolean(), + nextCursor: z.string().optional(), +}); + +export const DaemonUpdateSessionSettingsResultSchema = + UpdateSessionSettingsResultSchema; + +export const DaemonStartLoopResultSchema = StartLoopResultSchema; + +export const DaemonStopLoopResultSchema = StopLoopResultSchema; + +export const DaemonGetLoopStatusResultSchema = GetLoopStatusResultSchema; + +export const DaemonRunLoopNowResultSchema = RunLoopNowResultSchema; + +export const DaemonGetUserInfoResultSchema = z.object({ + userId: z.string(), + orgId: z.string(), +}); + +export const DaemonValidateWorkingDirectoryResultSchema = + ValidateWorkingDirectoryResultSchema; + +// Permission request schemas - extend client schemas with sessionId +export const DaemonRequestPermissionParamsSchema = + RequestPermissionRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonRequestPermissionResultSchema = + RequestPermissionResultSchema.and(z.object({ sessionId: z.string() })); + +// AskUser request schemas - extend droid schemas with sessionId +export const DaemonAskUserParamsSchema = AskUserRequestParamsSchema.extend({ + sessionId: z.string(), +}); + +export const DaemonAskUserResultSchema = AskUserResultSchema.extend({ + sessionId: z.string(), +}); + +// ============================================================ +// List Files +// ============================================================ + +export const DaemonListFilesRequestParamsSchema = z.object({ + sessionId: z.string(), + showHidden: z.boolean().optional().default(false), +}); + +export const DaemonListFilesResultSchema = z.object({ + files: z.array(z.string()), +}); + +export const DaemonListFilesRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_FILES), + params: DaemonListFilesRequestParamsSchema, +}); + +export const DaemonListFilesResponseSchema = + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonListFilesResultSchema, + }); + +// ============================================================ +// Search Files (Fuzzy Search) +// ============================================================ + +export const DaemonSearchFilesRequestParamsSchema = z.object({ + sessionId: z.string(), + query: z.string(), + maxResults: z.number().optional().default(60), + showHidden: z.boolean().optional().default(false), +}); + +export const DaemonSearchFilesResultSchema = z.object({ + files: z.array(z.string()), + totalFiles: z.number(), +}); + +export const DaemonSearchFilesRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.SEARCH_FILES), + params: DaemonSearchFilesRequestParamsSchema, +}); + +export const DaemonSearchFilesResponseSchema = + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonSearchFilesResultSchema, + }); + +// ============================================================ +// Search Sessions (Full-text search across session history) +// ============================================================ + +const SessionSearchDocKindSchema = z.nativeEnum(SessionSearchDocKind); + +export const DaemonSearchSessionsRequestParamsSchema = z.object({ + query: z.string(), + kind: z.union([SessionSearchDocKindSchema, z.literal('all')]).optional(), + limitSessions: z.number().optional(), + limitHitsPerSession: z.number().optional(), + contextChars: z.number().optional(), +}); + +export const DaemonSearchSessionsHitSchema = z.object({ + docId: z.string(), + kind: SessionSearchDocKindSchema, + score: z.number().optional(), + toolName: z.string().optional(), + messageRole: z.enum(['user', 'assistant']).optional(), + snippets: z.array(z.string()), +}); + +export const DaemonSearchSessionsSessionResultSchema = z.object({ + sessionId: z.string(), + title: z.string().optional(), + updatedAt: z.number().optional(), // Unix timestamp in milliseconds for sorting + hits: z.array(DaemonSearchSessionsHitSchema), + totals: z + .object({ + byKind: z.record(z.number()).optional(), + toolUse: z.record(z.number()).optional(), + toolResult: z.record(z.number()).optional(), + }) + .optional(), +}); + +export const DaemonSearchSessionsResultSchema = z.object({ + query: z.string(), + sessions: z.array(DaemonSearchSessionsSessionResultSchema), +}); + +export const DaemonSearchSessionsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.SEARCH_SESSIONS), + params: DaemonSearchSessionsRequestParamsSchema, + }); + +export const DaemonSearchSessionsResponseSchema = + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonSearchSessionsResultSchema, + }); + +// ============================================================ +// Archive Session +// ============================================================ + +export const DaemonArchiveSessionRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonArchiveSessionResultSchema = z.object({ + success: z.boolean(), + archivedAt: z.string(), // ISO timestamp +}); + +export const DaemonArchiveSessionRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.ARCHIVE_SESSION), + params: DaemonArchiveSessionRequestParamsSchema, + }); + +export const DaemonArchiveSessionResponseSchema = createResponseSchema( + DaemonArchiveSessionResultSchema +); + +// ============================================================ +// Unarchive Session +// ============================================================ + +export const DaemonUnarchiveSessionRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonUnarchiveSessionResultSchema = z.object({ + success: z.boolean(), +}); + +export const DaemonUnarchiveSessionRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.UNARCHIVE_SESSION), + params: DaemonUnarchiveSessionRequestParamsSchema, + }); + +export const DaemonUnarchiveSessionResponseSchema = createResponseSchema( + DaemonUnarchiveSessionResultSchema +); + +// ============================================================ +// Rename Session +// ============================================================ + +export const DaemonRenameSessionRequestParamsSchema = z.object({ + sessionId: z.string(), + title: z.string(), +}); + +export const DaemonRenameSessionResultSchema = z.object({ + success: z.boolean(), +}); + +export const DaemonRenameSessionRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.RENAME_SESSION), + params: DaemonRenameSessionRequestParamsSchema, + } +); + +export const DaemonRenameSessionResponseSchema = + createAckCompatibleResponseSchema(DaemonRenameSessionResultSchema); + +// ============================================================ +// Get Git Diff +// ============================================================ + +export const DaemonGetGitDiffRequestParamsSchema = z.object({ + sessionId: z.string(), + baseBranch: z.string().optional(), +}); + +export const DaemonGetGitDiffFileSchema = z.object({ + path: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.string(), +}); + +export const DaemonGetGitDiffCommitSchema = z.object({ + hash: z.string(), + message: z.string(), +}); + +const DaemonGetGitDiffUnavailableReasonSchema = z + .nativeEnum(DaemonGetGitDiffUnavailableReason) + .catch(DaemonGetGitDiffUnavailableReason.Unknown); + +export const DaemonGetGitDiffDataSchema = z.object({ + diff: z.string(), + branch: z.string(), + baseBranch: z.string(), + files: z.array(DaemonGetGitDiffFileSchema), + totalAdditions: z.number(), + totalDeletions: z.number(), + remoteUrl: z.string().nullable(), + commits: z.array(DaemonGetGitDiffCommitSchema), + unstagedDiff: z.string().default(''), + unstagedFiles: z.array(DaemonGetGitDiffFileSchema).default([]), + unstagedTotalAdditions: z.number().default(0), + unstagedTotalDeletions: z.number().default(0), +}); + +export const DaemonGetGitDiffSuccessResultSchema = z.object({ + success: z.literal(true), + data: DaemonGetGitDiffDataSchema, +}); + +export const DaemonGetGitDiffUnavailableResultSchema = z.object({ + success: z.literal(false), + unavailableReason: DaemonGetGitDiffUnavailableReasonSchema, + unavailableMessage: z.string(), +}); + +export const DaemonGetGitDiffResultSchema = z.preprocess( + (value) => { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + !('success' in value) + ) { + const legacyResult = DaemonGetGitDiffDataSchema.safeParse(value); + if (legacyResult.success) { + return { success: true, data: legacyResult.data }; + } + } + + return value; + }, + z.discriminatedUnion('success', [ + DaemonGetGitDiffSuccessResultSchema, + DaemonGetGitDiffUnavailableResultSchema, + ]) +); + +export const DaemonGetGitDiffRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GET_GIT_DIFF), + params: DaemonGetGitDiffRequestParamsSchema, +}); + +export const DaemonGetGitDiffResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGetGitDiffResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// ============================================================ +// Git Push +// ============================================================ + +export const DaemonGitPushRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonGitPushResultSchema = z.object({ + success: z.boolean(), +}); + +export const DaemonGitPushRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GIT_PUSH), + params: DaemonGitPushRequestParamsSchema, +}); + +export const DaemonGitPushResponseSchema = createResponseSchema( + DaemonGitPushResultSchema +); + +// ============================================================ +// Git Commit +// ============================================================ + +export const DaemonGitCommitRequestParamsSchema = z.object({ + sessionId: z.string(), + message: z.string(), +}); + +export const DaemonGitCommitResultSchema = z.object({ + success: z.boolean(), +}); + +export const DaemonGitCommitRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GIT_COMMIT), + params: DaemonGitCommitRequestParamsSchema, +}); + +export const DaemonGitCommitResponseSchema = createResponseSchema( + DaemonGitCommitResultSchema +); + +// ============================================================ +// Create PR +// ============================================================ + +export const DaemonCreatePRRequestParamsSchema = z.object({ + sessionId: z.string(), + title: z.string(), + body: z.string().optional(), + baseBranch: z.string(), + draft: z.boolean().optional(), + linkedTicketIds: z.array(z.string()).optional(), + linkedTicketUrls: z.array(z.string()).optional(), + jiraIssueKeys: z.array(z.string()).optional(), + linearIssueIds: z.array(z.string()).optional(), +}); + +export const DaemonCreatePRResultSchema = z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), + draft: z.boolean(), +}); + +export const DaemonCreatePRRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.CREATE_PR), + params: DaemonCreatePRRequestParamsSchema, +}); + +export const DaemonCreatePRResponseSchema = createResponseSchema( + DaemonCreatePRResultSchema +); + +// ============================================================ +// Semantic Diff Cache +// ============================================================ + +export const DaemonGetSemanticDiffCacheRequestParamsSchema = z.object({ + currentBranch: z.string(), + baseBranch: z.string(), +}); + +export const DaemonGetSemanticDiffCacheResultSchema = z.object({ + content: z.string().nullable(), + commitHash: z.string().nullable(), + truncated: z.boolean(), +}); + +export const DaemonGetSemanticDiffCacheRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GET_SEMANTIC_DIFF_CACHE), + params: DaemonGetSemanticDiffCacheRequestParamsSchema, + }); + +export const DaemonGetSemanticDiffCacheResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGetSemanticDiffCacheResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonSaveSemanticDiffCacheRequestParamsSchema = z.object({ + currentBranch: z.string(), + baseBranch: z.string(), + commitHash: z.string(), + content: z.string(), + truncated: z.boolean(), +}); + +export const DaemonSaveSemanticDiffCacheResultSchema = z.object({ + success: z.boolean(), +}); + +export const DaemonSaveSemanticDiffCacheRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.SAVE_SEMANTIC_DIFF_CACHE), + params: DaemonSaveSemanticDiffCacheRequestParamsSchema, + }); + +export const DaemonSaveSemanticDiffCacheResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonSaveSemanticDiffCacheResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// ============================================================ +// Generate Semantic Diff (Agent Flow) +// ============================================================ + +export const DaemonGenerateSemanticDiffRequestParamsSchema = z.object({ + sessionId: z.string(), + diff: z.string(), + baseBranch: z.string(), + currentBranch: z.string(), + commitHash: z.string().optional(), + modelId: z.string().optional(), + unstagedDiff: z.string().optional(), +}); + +export const DaemonGenerateSemanticDiffResultSchema = z.object({ + content: z.string(), + truncated: z.boolean(), + sessionId: z.string(), +}); + +export const DaemonGenerateSemanticDiffRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GENERATE_SEMANTIC_DIFF), + params: DaemonGenerateSemanticDiffRequestParamsSchema, + }); + +export const DaemonGenerateSemanticDiffResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGenerateSemanticDiffResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// Get Proxy Token +// ============================================================ + +export const DaemonGetProxyTokenResultSchema = z.object({ + token: z.string(), +}); + +export const DaemonGetProxyTokenRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.GET_PROXY_TOKEN), + } +); + +export const DaemonGetProxyTokenResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGetProxyTokenResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// Get Workspace File Content +// ============================================================ + +const DaemonGetWorkspaceFileContentRequestParamsSchema = z.object({ + sessionId: z.string(), + filePath: z.string(), +}); + +export const DaemonGetWorkspaceFileContentResultSchema = z.object({ + content: z.string(), + byteLength: z.number(), +}); + +export const DaemonGetWorkspaceFileContentRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GET_WORKSPACE_FILE_CONTENT), + params: DaemonGetWorkspaceFileContentRequestParamsSchema, + }); + +export const DaemonGetWorkspaceFileContentResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGetWorkspaceFileContentResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// Rewind schemas + +const DaemonGetRewindInfoRequestParamsSchema = z.object({ + sessionId: z.string(), + messageId: z.string(), +}); + +const DaemonRewindFileSnapshotSchema = z.object({ + filePath: z.string(), + contentHash: z.string(), + size: z.number(), +}); + +const DaemonRewindFileCreationSchema = z.object({ + filePath: z.string(), +}); + +const DaemonRewindEvictedFileSchema = z.object({ + filePath: z.string(), + reason: z.string(), +}); + +export const DaemonGetRewindInfoResultSchema = z.object({ + availableFiles: z.array(DaemonRewindFileSnapshotSchema), + createdFiles: z.array(DaemonRewindFileCreationSchema), + evictedFiles: z.array(DaemonRewindEvictedFileSchema), +}); + +export const DaemonGetRewindInfoRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.GET_REWIND_INFO), + params: DaemonGetRewindInfoRequestParamsSchema, + } +); + +export const DaemonGetRewindInfoResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGetRewindInfoResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +const DaemonExecuteRewindRequestParamsSchema = z.object({ + sessionId: z.string(), + messageId: z.string(), + filesToRestore: z.array(DaemonRewindFileSnapshotSchema), + filesToDelete: z.array(DaemonRewindFileCreationSchema), + forkTitle: z.string(), +}); + +export const DaemonExecuteRewindResultSchema = z.object({ + newSessionId: z.string(), + restoredCount: z.number(), + deletedCount: z.number(), + failedRestoreCount: z.number(), + failedDeleteCount: z.number(), +}); + +export const DaemonExecuteRewindRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.EXECUTE_REWIND), + params: DaemonExecuteRewindRequestParamsSchema, + } +); + +export const DaemonExecuteRewindResponseSchema = createResponseSchema( + DaemonExecuteRewindResultSchema +); + +// ============================================================ +// Compact Session +// ============================================================ + +export const DaemonCompactSessionRequestParamsSchema = z.object({ + sessionId: z.string(), + customInstructions: z.string().optional(), +}); + +export const DaemonCompactSessionResultSchema = z.object({ + newSessionId: z.string(), + removedCount: z.number(), +}); + +export const DaemonCompactSessionRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.COMPACT_SESSION), + params: DaemonCompactSessionRequestParamsSchema, + }); + +export const DaemonCompactSessionResponseSchema = createResponseSchema( + DaemonCompactSessionResultSchema +); + +// Fork Session +// ============================================================ + +export const DaemonForkSessionRequestParamsSchema = z.object({ + sessionId: z.string(), + title: z.string().optional(), + tags: z + .array( + z.object({ name: z.string(), metadata: z.record(z.string()).optional() }) + ) + .optional(), +}); + +export const DaemonForkSessionResultSchema = z.object({ + newSessionId: z.string(), +}); + +export const DaemonForkSessionRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.FORK_SESSION), + params: DaemonForkSessionRequestParamsSchema, +}); + +export const DaemonForkSessionResponseSchema = createResponseSchema( + DaemonForkSessionResultSchema +); + +// Warmup Cache +// ============================================================ + +export const DaemonWarmupCacheRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonWarmupCacheRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.WARMUP_CACHE), + params: DaemonWarmupCacheRequestParamsSchema, +}); + +const DaemonWarmupCacheResultSchema = z.object({}); + +export const DaemonWarmupCacheResponseSchema = createResponseSchema( + DaemonWarmupCacheResultSchema +); + +// ============================================================ +// Get Context Breakdown +// ============================================================ + +// The daemon RPC is a passthrough — reuse the droid SDK result shape directly. +export const DaemonGetContextBreakdownResultSchema = + GetContextBreakdownResultSchema; + +const DaemonGetContextBreakdownRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonGetContextBreakdownRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GET_CONTEXT_BREAKDOWN), + params: DaemonGetContextBreakdownRequestParamsSchema, + }); + +export const DaemonGetContextBreakdownResponseSchema = createResponseSchema( + DaemonGetContextBreakdownResultSchema +); + +// Union of all daemon request types (discriminated by method) +export const DaemonRequestSchema = z.discriminatedUnion('method', [ + DaemonInitializeSessionRequestSchema, + DaemonLoadSessionRequestSchema, + DaemonAddUserMessageRequestSchema, + DaemonResolveQueuedUserMessageRequestSchema, + DaemonInterruptSessionRequestSchema, + DaemonCloseSessionRequestSchema, + DaemonKillWorkerSessionRequestSchema, + DaemonListOpenedSessionsRequestSchema, + DaemonListAvailableSessionsRequestSchema, + DaemonGetSessionMessagesRequestSchema, + DaemonUpdateSessionSettingsRequestSchema, + DaemonStartLoopRequestSchema, + DaemonStopLoopRequestSchema, + DaemonGetLoopStatusRequestSchema, + DaemonRunLoopNowRequestSchema, + DaemonGetDefaultSettingsRequestSchema, + DaemonUpdateSessionDefaultsRequestSchema, + DaemonValidateWorkingDirectoryRequestSchema, + DaemonListFilesRequestSchema, + DaemonSearchFilesRequestSchema, + DaemonGetMcpConfigRequestSchema, + DaemonUpdateMcpConfigRequestSchema, + DaemonToggleMcpServerRequestSchema, + DaemonAuthenticateMcpServerRequestSchema, + DaemonCancelMcpAuthRequestSchema, + DaemonClearMcpAuthRequestSchema, + DaemonAddMcpServerRequestSchema, + DaemonRemoveMcpServerRequestSchema, + DaemonListMcpRegistryRequestSchema, + DaemonListMcpToolsRequestSchema, + DaemonListMcpServersRequestSchema, + DaemonToggleMcpToolRequestSchema, + DaemonSubmitMcpAuthCodeRequestSchema, + DaemonSearchSessionsRequestSchema, + DaemonArchiveSessionRequestSchema, + DaemonUnarchiveSessionRequestSchema, + DaemonRenameSessionRequestSchema, + DaemonListSkillsRequestSchema, + DaemonListAvailablePluginsRequestSchema, + DaemonListInstalledPluginsRequestSchema, + DaemonInstallPluginRequestSchema, + DaemonUninstallPluginRequestSchema, + DaemonUpdatePluginRequestSchema, + DaemonListMarketplacesRequestSchema, + DaemonAddMarketplaceRequestSchema, + DaemonRemoveMarketplaceRequestSchema, + DaemonUpdateMarketplaceRequestSchema, + DaemonSubmitBugReportRequestSchema, + DaemonListAutomationsRequestSchema, + DaemonRunAutomationRequestSchema, + DaemonPauseAutomationRequestSchema, + DaemonResumeAutomationRequestSchema, + DaemonGetAutomationHistoryRequestSchema, + DaemonGetAutomationVisualRequestSchema, + DaemonCreateAutomationRequestSchema, + DaemonUpdateAutomationModelRequestSchema, + DaemonUpdateAutomationPrivacyRequestSchema, + DaemonRenameAutomationRequestSchema, + DaemonDeleteAutomationRequestSchema, + DaemonForkAutomationRequestSchema, + DaemonGetGitDiffRequestSchema, + DaemonGitPushRequestSchema, + DaemonGitCommitRequestSchema, + DaemonCreatePRRequestSchema, + DaemonGetSemanticDiffCacheRequestSchema, + DaemonSaveSemanticDiffCacheRequestSchema, + DaemonGenerateSemanticDiffRequestSchema, + DaemonGetProxyTokenRequestSchema, + DaemonGetWorkspaceFileContentRequestSchema, + DaemonTriggerUpdateRequestSchema, + DaemonInstallSshKeyRequestSchema, + DaemonGetRewindInfoRequestSchema, + DaemonExecuteRewindRequestSchema, + DaemonCompactSessionRequestSchema, + DaemonForkSessionRequestSchema, + DaemonWarmupCacheRequestSchema, + DaemonGetContextBreakdownRequestSchema, +]); + +// Daemon response schemas +export const DaemonInitializeSessionResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonInitializeSessionResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonLoadSessionResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonLoadSessionResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonAddUserMessageResponseSchema = + createAckCompatibleResponseSchema(DaemonAddUserMessageResultSchema); + +export const DaemonResolveQueuedUserMessageResponseSchema = + createAckCompatibleResponseSchema(DaemonResolveQueuedUserMessageResultSchema); + +export const DaemonInterruptSessionResponseSchema = createResponseSchema( + DaemonInterruptSessionResultSchema +); + +export const DaemonCloseSessionResponseSchema = createResponseSchema( + DaemonCloseSessionResultSchema +); + +export const DaemonListOpenedSessionsResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonListOpenedSessionsResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonListAvailableSessionsResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonListAvailableSessionsResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonGetSessionMessagesResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGetSessionMessagesResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonUpdateSessionSettingsResponseSchema = + createAckCompatibleResponseSchema(DaemonUpdateSessionSettingsResultSchema); + +export const DaemonStartLoopResponseSchema = createAckCompatibleResponseSchema( + DaemonStartLoopResultSchema +); + +export const DaemonStopLoopResponseSchema = createAckCompatibleResponseSchema( + DaemonStopLoopResultSchema +); + +export const DaemonGetLoopStatusResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGetLoopStatusResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonRunLoopNowResponseSchema = createAckCompatibleResponseSchema( + DaemonRunLoopNowResultSchema +); + +export const DaemonValidateWorkingDirectoryResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonValidateWorkingDirectoryResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// Session inactivity params - daemon-level lifecycle event +export const SessionInactivityNotificationSchema = z.object({ + type: z.literal(DaemonSpecificNotificationType.SESSION_INACTIVITY), + message: z.string(), + timestamp: z.number(), + timeoutSeconds: z.number(), +}); + +// Session process exit params - daemon-level lifecycle event +const SessionProcessExitedNotificationSchema = z.object({ + type: z.literal(DaemonSpecificNotificationType.SESSION_PROCESS_EXITED), + message: z.string(), + timestamp: z.number(), +}); + +const SessionClosedNotificationSchema = z.object({ + type: z.literal(DaemonSpecificNotificationType.SESSION_CLOSED), + timestamp: z.number(), +}); + +// Session unsubscribed params - daemon-level notification when another client connects +export const SessionUnsubscribedNotificationSchema = z.object({ + type: z.literal(DaemonSpecificNotificationType.SESSION_UNSUBSCRIBED), + message: z.string(), +}); + +export const ChildSessionAvailableNotificationSchema = z.object({ + type: z.literal(DaemonSpecificNotificationType.CHILD_SESSION_AVAILABLE), + childSessionId: z.string(), + timestamp: z.number(), +}); + +// Daemon session notification schema - extends SDK schema with sessionId +export const DaemonSessionNotificationParamsSchema = z.object({ + sessionId: z.string(), + notification: z.discriminatedUnion('type', [ + ...SessionNotificationSchemaList, + SessionInactivityNotificationSchema, + SessionProcessExitedNotificationSchema, + SessionClosedNotificationSchema, + SessionUnsubscribedNotificationSchema, + ChildSessionAvailableNotificationSchema, + TerminalDataNotificationSchema, + TerminalExitNotificationSchema, + ]), +}); + +export const DaemonSessionNotificationSchema = + JsonRpcBaseNotificationSchema.extend({ + method: z.literal(DaemonDroidEvent.SESSION_NOTIFICATION), + params: DaemonSessionNotificationParamsSchema, + }); + +// Permission request schema - daemon-level request for tool permissions +export const DaemonRequestPermissionSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidEvent.REQUEST_PERMISSION), + params: DaemonRequestPermissionParamsSchema, +}); + +// AskUser request schema - daemon-level request for user questionnaire answers +export const DaemonAskUserSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidEvent.ASK_USER), + params: DaemonAskUserParamsSchema, +}); diff --git a/src/protocol/daemon/enums.ts b/src/protocol/daemon/enums.ts new file mode 100644 index 0000000..c27f321 --- /dev/null +++ b/src/protocol/daemon/enums.ts @@ -0,0 +1,247 @@ +// Connection-level methods (shared across all daemon connections) +export enum DaemonConnectionMethod { + AUTHENTICATE = 'daemon.authenticate', + LOGOUT = 'daemon.logout', +} + +// Connection-level events (notifications from server to client) +export enum DaemonConnectionEvent { + CONNECTION_STATUS = 'daemon.connection_status', +} + +/** + * Connection state for daemon WebSocket connections. + * + * This enum contains two categories of states: + * + * **WebSocket connection states** (actual transport-level states): + * - `Disconnected` - No active WebSocket connection + * - `Connecting` - WebSocket handshake in progress + * - `Connected` - WebSocket is open and ready + * - `Closing` - WebSocket is closing + * + * **UI/UX display states** (for user feedback, not actual WS state): + * - `LookingUpMachine` - Checking if session exists locally or in backend + * - `StartingMachine` - Waiting for remote sandbox/machine to start before connecting + * - `LoadingSession` - WebSocket connected, but loading session state from daemon + * - `AuthenticationFailed` - Connection succeeded but authentication was rejected + */ +export enum ConnectionState { + // WebSocket transport states + Disconnected = 'disconnected', + Connecting = 'connecting', + Connected = 'connected', + Closing = 'closing', + + // UI/UX display states (WebSocket may be connected) + LookingUpMachine = 'looking_up_machine', + StartingMachine = 'starting_machine', + LoadingSession = 'loading_session', + /** @deprecated Use ConnectionStatus.lastFailure instead. Will be removed in a future version. */ + AuthenticationFailed = 'authentication_failed', +} + +// Terminal-specific methods +export enum DaemonTerminalMethod { + CREATE = 'daemon.create_terminal', + WRITE_DATA = 'daemon.write_terminal_data', + RESIZE = 'daemon.resize_terminal', + CLOSE = 'daemon.close_terminal', + LIST = 'daemon.list_terminals', +} + +// Terminal-specific events (notifications from server to client) +export enum DaemonTerminalEvent { + DATA = 'daemon.terminal_data', + EXIT = 'daemon.terminal_exit', + ERROR = 'daemon.terminal_error', +} + +// Droid-specific methods (for daemon-managed droid sessions) +export enum DaemonDroidMethod { + INITIALIZE_SESSION = 'daemon.initialize_session', + LOAD_SESSION = 'daemon.load_session', + ADD_USER_MESSAGE = 'daemon.add_user_message', + RESOLVE_QUEUED_USER_MESSAGE = 'daemon.resolve_queued_user_message', + INTERRUPT_SESSION = 'daemon.interrupt_session', + CLOSE_SESSION = 'daemon.close_session', + KILL_WORKER_SESSION = 'daemon.kill_worker_session', + LIST_OPENED_SESSIONS = 'daemon.list_opened_sessions', + LIST_AVAILABLE_SESSIONS = 'daemon.list_available_sessions', + GET_SESSION_MESSAGES = 'daemon.get_session_messages', + UPDATE_SESSION_SETTINGS = 'daemon.update_session_settings', + START_LOOP = 'daemon.start_loop', + STOP_LOOP = 'daemon.stop_loop', + GET_LOOP_STATUS = 'daemon.get_loop_status', + RUN_LOOP_NOW = 'daemon.run_loop_now', + VALIDATE_WORKING_DIRECTORY = 'daemon.validate_working_directory', + GET_MCP_CONFIG = 'daemon.get_mcp_config', + UPDATE_MCP_CONFIG = 'daemon.update_mcp_config', + TOGGLE_MCP_SERVER = 'daemon.toggle_mcp_server', + AUTHENTICATE_MCP_SERVER = 'daemon.authenticate_mcp_server', + CANCEL_MCP_AUTH = 'daemon.cancel_mcp_auth', + CLEAR_MCP_AUTH = 'daemon.clear_mcp_auth', + SUBMIT_MCP_AUTH_CODE = 'daemon.submit_mcp_auth_code', + LIST_FILES = 'daemon.list_files', + SEARCH_FILES = 'daemon.search_files', + ADD_MCP_SERVER = 'daemon.add_mcp_server', + REMOVE_MCP_SERVER = 'daemon.remove_mcp_server', + LIST_MCP_REGISTRY = 'daemon.list_mcp_registry', + LIST_MCP_TOOLS = 'daemon.list_mcp_tools', + LIST_MCP_SERVERS = 'daemon.list_mcp_servers', + TOGGLE_MCP_TOOL = 'daemon.toggle_mcp_tool', + SEARCH_SESSIONS = 'daemon.search_sessions', + ARCHIVE_SESSION = 'daemon.archive_session', + UNARCHIVE_SESSION = 'daemon.unarchive_session', + RENAME_SESSION = 'daemon.rename_session', + LIST_SKILLS = 'daemon.list_skills', + LIST_AVAILABLE_PLUGINS = 'daemon.list_available_plugins', + LIST_INSTALLED_PLUGINS = 'daemon.list_installed_plugins', + INSTALL_PLUGIN = 'daemon.install_plugin', + UNINSTALL_PLUGIN = 'daemon.uninstall_plugin', + UPDATE_PLUGIN = 'daemon.update_plugin', + LIST_MARKETPLACES = 'daemon.list_marketplaces', + ADD_MARKETPLACE = 'daemon.add_marketplace', + REMOVE_MARKETPLACE = 'daemon.remove_marketplace', + UPDATE_MARKETPLACE = 'daemon.update_marketplace', + LIST_AUTOMATIONS = 'daemon.list_automations', + RUN_AUTOMATION = 'daemon.run_automation', + PAUSE_AUTOMATION = 'daemon.pause_automation', + RESUME_AUTOMATION = 'daemon.resume_automation', + GET_AUTOMATION_HISTORY = 'daemon.get_automation_history', + GET_AUTOMATION_VISUAL = 'daemon.get_automation_visual', + CREATE_AUTOMATION = 'daemon.create_automation', + UPDATE_AUTOMATION_MODEL = 'daemon.update_automation_model', + UPDATE_AUTOMATION_PRIVACY = 'daemon.update_automation_privacy', + RENAME_AUTOMATION = 'daemon.rename_automation', + DELETE_AUTOMATION = 'daemon.delete_automation', + FORK_AUTOMATION = 'daemon.fork_automation', + GET_GIT_DIFF = 'daemon.get_git_diff', + GIT_PUSH = 'daemon.git_push', + GIT_COMMIT = 'daemon.git_commit', + CREATE_PR = 'daemon.create_pr', + GET_SEMANTIC_DIFF_CACHE = 'daemon.get_semantic_diff_cache', + SAVE_SEMANTIC_DIFF_CACHE = 'daemon.save_semantic_diff_cache', + GENERATE_SEMANTIC_DIFF = 'daemon.generate_semantic_diff', + GET_PROXY_TOKEN = 'daemon.get_proxy_token', + GET_WORKSPACE_FILE_CONTENT = 'daemon.get_workspace_file_content', + SUBMIT_BUG_REPORT = 'daemon.submit_bug_report', + GET_REWIND_INFO = 'daemon.get_rewind_info', + EXECUTE_REWIND = 'daemon.execute_rewind', + COMPACT_SESSION = 'daemon.compact_session', + FORK_SESSION = 'daemon.fork_session', + WARMUP_CACHE = 'daemon.warmup_cache', + GET_CONTEXT_BREAKDOWN = 'daemon.get_context_breakdown', +} + +export enum DaemonGetGitDiffUnavailableReason { + MissingSessionCwd = 'missing_session_cwd', + NotGitRepository = 'not_git_repository', + GitNotAvailable = 'git_not_available', + Unknown = 'unknown', +} + +// Daemon-only settings methods (handled by daemon, not forwarded to CLI) +export enum DaemonSettingsMethod { + GET_DEFAULT_SETTINGS = 'daemon.get_default_settings', + UPDATE_SESSION_DEFAULTS = 'daemon.update_session_defaults', +} + +// Daemon management methods (lifecycle operations like updates, restarts) +export enum DaemonManagementMethod { + TRIGGER_UPDATE = 'daemon.trigger_update', + INSTALL_SSH_KEY = 'daemon.install_ssh_key', +} + +// Daemon relay methods (request/response RPCs for relay lifecycle) +export enum DaemonRelayMethod { + START = 'daemon.relay.start', + STOP = 'daemon.relay.stop', + GET_STATUS = 'daemon.relay.get_status', +} + +// Daemon relay events (notifications from server to client, e.g. external disconnects) +export enum DaemonRelayEvent { + STATUS_CHANGED = 'daemon.relay.status_changed', +} + +// Session notification types defined at the daemon level +export enum DaemonSpecificNotificationType { + SESSION_INACTIVITY = 'session_inactivity', + SESSION_PROCESS_EXITED = 'session_process_exited', + SESSION_CLOSED = 'session_closed', + SESSION_UNSUBSCRIBED = 'session_unsubscribed', + CHILD_SESSION_AVAILABLE = 'child_session_available', +} + +// Droid-specific events (notifications and requests from server to client) +export enum DaemonDroidEvent { + SESSION_NOTIFICATION = 'daemon.session_notification', + REQUEST_PERMISSION = 'daemon.request_permission', + ASK_USER = 'daemon.ask_user', +} + +// Terminal creation errors +export enum CreateTerminalError { + TerminalIdExists = 'TerminalIdExists', +} + +// MCP Config Source +export enum McpConfigSource { + User = 'user', + Project = 'project', +} + +// Tunnel message methods (for WebSocket tunnel protocol) + +/** + * Tracks the loading state of a session in the daemon. + * + * State Transitions (Event-Driven, Non-Linear): + * - New session: NotLoaded → Loading → Loaded + * - Existing session (direct URL): NotLoaded → Loading → Loaded + * - Reconnection: Loaded → NotLoaded → Loading → Loaded + * - After inactivity: Loaded → NotLoaded → Loading → Loaded + * + * Entry Points: + * - NotLoaded: Default state, or via disconnect/inactivity/unsubscribed + * - Loading: Via markSessionLoading() for new sessions, existing sessions, reloads, or reconnections + * - Loaded: Via initializeSession() (new) or loadSession() (existing/reload) + */ +export enum SessionLoadState { + /** + * Session doesn't exist in daemon state yet, or became inactive. + * + * Effect: Default state for sessions not yet created or loaded. + * Next State: + * - Call markSessionLoading() → Loading (for all sessions) + */ + NotLoaded = 'NOT_LOADED', + + /** + * Session is fully loaded and ready for use in daemon state. + * + * Effect: Normal operation, all features available, UI fully interactive. + * Next State: + * - Via WebSocket disconnect → NotLoaded (connection lost, then reconnect flow starts) + * - Via daemon notification → NotLoaded (inactivity timeout or unsubscribed) + * - Via markSessionLoading() → Loading (manual reload without disconnect) + */ + Loaded = 'LOADED', + + /** + * Session is being loaded, initialized, or reloaded. + * + * Effect: Shows loading UI. Session may be new or existing. + * Next State: When initializeSession() or loadSession() completes → Loaded + * Usage: New session creation, initial load of existing session, reconnection, manual reload, or reload after inactive. + */ + Loading = 'LOADING', +} + +export enum SessionSearchDocKind { + MessageText = 'message_text', + Document = 'document', + ToolUse = 'tool_use', + ToolResult = 'tool_result', +} diff --git a/src/protocol/daemon/index.ts b/src/protocol/daemon/index.ts new file mode 100644 index 0000000..6835e1d --- /dev/null +++ b/src/protocol/daemon/index.ts @@ -0,0 +1,17 @@ +// Internal barrel for the daemon protocol source-of-truth modules. +// Mirrors files under factory-mono-alpha/packages/common/src/daemon/schemas/. +// NOT yet wired into the public package API (src/index.ts). + +export * from './enums.js'; +export * from './bug-report.js'; +export * from './management.js'; +export * from './skills.js'; +export * from './relay.js'; +export * from './terminal.js'; +export * from './connection.js'; +export * from './mcp.js'; +export * from './automations-enums.js'; +export * from './automations.js'; +export * from './plugins.js'; +export * from './settings.js'; +export * from './droid.js'; diff --git a/src/protocol/daemon/management.ts b/src/protocol/daemon/management.ts new file mode 100644 index 0000000..26cef94 --- /dev/null +++ b/src/protocol/daemon/management.ts @@ -0,0 +1,51 @@ +/** + * Daemon management schemas. + * These cover daemon lifecycle operations (updates, restarts, etc.) + * that are not tied to specific droid sessions or settings. + */ +import z from 'zod'; + +import { + JsonRpcBaseRequestSchema, + JsonRpcBaseResponseFailureSchema, + JsonRpcBaseResponseSuccessSchema, +} from '../json-rpc.js'; +import { DaemonManagementMethod } from './enums.js'; + +// Trigger update schemas +const DaemonTriggerUpdateRequestParamsSchema = z.object({}); + +export const DaemonTriggerUpdateRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonManagementMethod.TRIGGER_UPDATE), + params: DaemonTriggerUpdateRequestParamsSchema, + } +); + +export const DaemonTriggerUpdateResultSchema = z.object({ + triggered: z.boolean(), + message: z.string().optional(), +}); + +export const DaemonTriggerUpdateResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonTriggerUpdateResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// Install SSH key schemas +const DaemonInstallSshKeyRequestParamsSchema = z.object({ + publicKey: z.string().min(1), +}); + +export const DaemonInstallSshKeyRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonManagementMethod.INSTALL_SSH_KEY), + params: DaemonInstallSshKeyRequestParamsSchema, + } +); + +export const DaemonInstallSshKeyResultSchema = z.object({ + installed: z.boolean(), +}); diff --git a/src/protocol/daemon/mcp.ts b/src/protocol/daemon/mcp.ts new file mode 100644 index 0000000..2534ba6 --- /dev/null +++ b/src/protocol/daemon/mcp.ts @@ -0,0 +1,225 @@ +import z from 'zod'; + +import { + AddMcpServerRequestParamsSchema, + AuthenticateMcpServerRequestParamsSchema, + CancelMcpAuthRequestParamsSchema, + ClearMcpAuthRequestParamsSchema, + ListMcpRegistryRequestParamsSchema, + ListMcpRegistryResultSchema, + ListMcpServersRequestParamsSchema, + ListMcpServersResultSchema, + ListMcpToolsRequestParamsSchema, + ListMcpToolsResultSchema, + RemoveMcpServerRequestParamsSchema, + ToggleMcpServerRequestParamsSchema, + ToggleMcpToolRequestParamsSchema, +} from '../client.js'; +import { McpServerStatus } from '../enums.js'; +import { JsonRpcBaseRequestSchema } from '../json-rpc.js'; +import { McpRegistryServerSchema, McpToolInfoSchema } from '../mcp.js'; +import { DaemonDroidMethod, McpConfigSource } from './enums.js'; + +// MCP Server Config Schema (matches @factory/mcp) +const McpStdioServerConfigSchema = z.object({ + type: z.literal('stdio').optional().default('stdio'), + command: z.string(), + args: z.array(z.string()), + env: z.record(z.string(), z.string()).optional(), + disabled: z.boolean().optional().default(false), +}); + +export const McpOAuthConfigSchema = z.object({ + scopes: z.array(z.string().trim().min(1)).optional(), + clientId: z.string().trim().min(1).optional(), + clientSecret: z + .string() + .min(1) + .refine((secret) => secret.trim().length > 0, { + message: 'clientSecret cannot be blank', + }) + .optional(), + callbackPort: z.number().int().min(1).max(65535).optional(), +}); + +export const McpHttpServerConfigSchema = z.object({ + type: z.literal('http'), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + oauth: McpOAuthConfigSchema.optional(), + disabled: z.boolean().optional().default(false), +}); + +const McpSseServerConfigSchema = z.object({ + type: z.literal('sse'), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + oauth: McpOAuthConfigSchema.optional(), + disabled: z.boolean().optional().default(false), +}); + +export const McpServerConfigSchema = z.union([ + McpStdioServerConfigSchema, + McpHttpServerConfigSchema, + McpSseServerConfigSchema, +]); + +// Server info with source and status +export const McpServerInfoSchema = z.object({ + name: z.string(), + config: McpServerConfigSchema, + source: z.nativeEnum(McpConfigSource), + status: z.nativeEnum(McpServerStatus).optional(), + error: z.string().optional(), +}); + +// Simple success result used by multiple MCP operations +export const McpSuccessResultSchema = z.object({ + success: z.boolean(), +}); + +// GET_MCP_CONFIG +export const DaemonGetMcpConfigRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.GET_MCP_CONFIG), + params: z.object({}), +}); + +export const DaemonGetMcpConfigResultSchema = z.object({ + servers: z.array(McpServerInfoSchema), +}); + +// UPDATE_MCP_CONFIG +export const DaemonUpdateMcpConfigRequestParamsSchema = z.object({ + action: z.enum(['add', 'remove', 'enable', 'disable']), + serverNames: z.array(z.string()), + serverConfig: McpServerConfigSchema.optional(), +}); + +export const DaemonUpdateMcpConfigRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.UPDATE_MCP_CONFIG), + params: DaemonUpdateMcpConfigRequestParamsSchema, + }); + +export const DaemonUpdateMcpConfigResultSchema = z.object({ + success: z.boolean(), + servers: z.array(McpServerInfoSchema), + error: z.string().optional(), +}); + +// TOGGLE_MCP_SERVER - proxied to CLI session +export const DaemonToggleMcpServerRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.TOGGLE_MCP_SERVER), + params: ToggleMcpServerRequestParamsSchema.extend({ + sessionId: z.string(), + }), + }); + +// AUTHENTICATE_MCP_SERVER - proxied to CLI session for OAuth flow +export const DaemonAuthenticateMcpServerRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.AUTHENTICATE_MCP_SERVER), + params: AuthenticateMcpServerRequestParamsSchema.extend({ + sessionId: z.string(), + }), + }); + +// CANCEL_MCP_AUTH - proxied to CLI session to cancel pending OAuth flow +export const DaemonCancelMcpAuthRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.CANCEL_MCP_AUTH), + params: CancelMcpAuthRequestParamsSchema.extend({ + sessionId: z.string(), + }), + } +); + +// CLEAR_MCP_AUTH - proxied to CLI session to clear OAuth tokens +export const DaemonClearMcpAuthRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.CLEAR_MCP_AUTH), + params: ClearMcpAuthRequestParamsSchema.extend({ + sessionId: z.string(), + }), +}); + +// ADD_MCP_SERVER - proxied to CLI session to add MCP server to config +export const DaemonAddMcpServerRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.ADD_MCP_SERVER), + params: AddMcpServerRequestParamsSchema.extend({ + sessionId: z.string(), + }), +}); + +// REMOVE_MCP_SERVER - proxied to CLI session to remove MCP server from config +export const DaemonRemoveMcpServerRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.REMOVE_MCP_SERVER), + params: RemoveMcpServerRequestParamsSchema.extend({ + sessionId: z.string(), + }), + }); + +// LIST_MCP_REGISTRY - proxied to CLI session to list available MCP servers +export const DaemonListMcpRegistryRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_MCP_REGISTRY), + params: ListMcpRegistryRequestParamsSchema.extend({ + sessionId: z.string(), + }), + }); + +export const DaemonMcpRegistryServerSchema = McpRegistryServerSchema.extend({}); + +export const DaemonListMcpRegistryResultSchema = + ListMcpRegistryResultSchema.extend({}); + +// LIST_MCP_TOOLS - get all tools with enabled/disabled state +export const DaemonListMcpToolsRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_MCP_TOOLS), + params: ListMcpToolsRequestParamsSchema.extend({ + sessionId: z.string(), + }), +}); + +export const DaemonMcpToolInfoSchema = McpToolInfoSchema.extend({}); + +export const DaemonListMcpToolsResultSchema = ListMcpToolsResultSchema.extend( + {} +); + +// TOGGLE_MCP_TOOL - enable/disable a specific tool +export const DaemonToggleMcpToolRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.TOGGLE_MCP_TOOL), + params: ToggleMcpToolRequestParamsSchema.extend({ + sessionId: z.string(), + }), + } +); + +// LIST_MCP_SERVERS - get current MCP server statuses for a session +export const DaemonListMcpServersRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_MCP_SERVERS), + params: ListMcpServersRequestParamsSchema.extend({ + sessionId: z.string(), + }), + }); + +export const DaemonListMcpServersResultSchema = + ListMcpServersResultSchema.extend({}); + +// SUBMIT_MCP_AUTH_CODE - relay OAuth auth code from frontend to CLI for remote sessions +const DaemonSubmitMcpAuthCodeRequestParamsSchema = z.object({ + sessionId: z.string(), + serverName: z.string(), + code: z.string(), + state: z.string(), +}); + +export const DaemonSubmitMcpAuthCodeRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.SUBMIT_MCP_AUTH_CODE), + params: DaemonSubmitMcpAuthCodeRequestParamsSchema, + }); diff --git a/src/protocol/daemon/plugins.ts b/src/protocol/daemon/plugins.ts new file mode 100644 index 0000000..eefa57e --- /dev/null +++ b/src/protocol/daemon/plugins.ts @@ -0,0 +1,231 @@ +import z from 'zod'; + +import { JsonRpcBaseRequestSchema } from '../json-rpc.js'; +import { MarketplaceSourceSchema } from '../settings.js'; +import { DaemonDroidMethod } from './enums.js'; + +export { MarketplaceSourceSchema }; + +// CRITICAL: `RedactedMarketplaceSourceSchema` below stays intentionally +// distinct from `MarketplaceSourceSchema`. The redacted variant omits +// filesystem paths and userinfo for RPC responses; collapsing the two would +// risk credential / local-layout leakage. + +// ── LIST_AVAILABLE_PLUGINS ─────────────────────────────────────────── + +const DaemonListAvailablePluginsRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonListAvailablePluginsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_AVAILABLE_PLUGINS), + params: DaemonListAvailablePluginsRequestParamsSchema, + }); + +export const DaemonListAvailablePluginsResultSchema = z.object({ + plugins: z.array( + z.object({ + name: z.string(), + marketplace: z.string(), + description: z.string().optional(), + }) + ), +}); + +// ── LIST_INSTALLED_PLUGINS ─────────────────────────────────────────── + +const DaemonListInstalledPluginsRequestParamsSchema = z.object({ + sessionId: z.string(), + scope: z.string().optional(), +}); + +export const DaemonListInstalledPluginsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_INSTALLED_PLUGINS), + params: DaemonListInstalledPluginsRequestParamsSchema, + }); + +export const DaemonListInstalledPluginsResultSchema = z.object({ + plugins: z.array( + z.object({ + id: z.string(), + scope: z.string(), + version: z.string(), + installPath: z.string(), + installedAt: z.string(), + lastUpdated: z.string(), + source: z.string(), + }) + ), +}); + +// ── INSTALL_PLUGIN ─────────────────────────────────────────────────── + +const DaemonInstallPluginRequestParamsSchema = z.object({ + sessionId: z.string(), + marketplace: z.string(), + pluginName: z.string(), + scope: z.string(), +}); + +export const DaemonInstallPluginRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonDroidMethod.INSTALL_PLUGIN), + params: DaemonInstallPluginRequestParamsSchema, + } +); + +export const DaemonInstallPluginResultSchema = z.object({ + success: z.boolean(), + pluginId: z.string().optional(), + error: z.string().optional(), +}); + +// ── UNINSTALL_PLUGIN ───────────────────────────────────────────────── + +const DaemonUninstallPluginRequestParamsSchema = z.object({ + sessionId: z.string(), + pluginId: z.string(), + scope: z.string(), +}); + +export const DaemonUninstallPluginRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.UNINSTALL_PLUGIN), + params: DaemonUninstallPluginRequestParamsSchema, + }); + +export const DaemonUninstallPluginResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); + +// ── UPDATE_PLUGIN ──────────────────────────────────────────────────── + +const DaemonUpdatePluginRequestParamsSchema = z.object({ + sessionId: z.string(), + pluginId: z.string().optional(), + scope: z.string().optional(), +}); + +export const DaemonUpdatePluginRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.UPDATE_PLUGIN), + params: DaemonUpdatePluginRequestParamsSchema, +}); + +export const DaemonUpdatePluginResultSchema = z.object({ + results: z.array( + z.object({ + pluginId: z.string(), + success: z.boolean(), + error: z.string().optional(), + }) + ), +}); + +// ── LIST_MARKETPLACES ──────────────────────────────────────────────── + +const DaemonListMarketplacesRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonListMarketplacesRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_MARKETPLACES), + params: DaemonListMarketplacesRequestParamsSchema, + }); + +// Redacted marketplace source shape for RPC responses. Unlike +// `MarketplaceSourceSchema`, this intentionally omits filesystem paths for +// `local` sources and expects URLs for `url` / `git-subdir` sources to be +// scrubbed of credential userinfo before crossing the RPC boundary. +export const RedactedMarketplaceSourceSchema = z.discriminatedUnion('source', [ + z.object({ + source: z.literal('github'), + repo: z.string(), + }), + z.object({ + source: z.literal('url'), + url: z.string(), + }), + z.object({ + source: z.literal('local'), + }), + z.object({ + source: z.literal('git-subdir'), + url: z.string(), + path: z.string(), + }), +]); + +export const DaemonListMarketplacesResultSchema = z.object({ + marketplaces: z.array( + z.object({ + name: z.string(), + source: RedactedMarketplaceSourceSchema, + pluginCount: z.number(), + autoUpdate: z.boolean(), + }) + ), +}); + +// ── ADD_MARKETPLACE ────────────────────────────────────────────────── + +const DaemonAddMarketplaceRequestParamsSchema = z.object({ + sessionId: z.string(), + source: MarketplaceSourceSchema, +}); + +export const DaemonAddMarketplaceRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.ADD_MARKETPLACE), + params: DaemonAddMarketplaceRequestParamsSchema, + }); + +export const DaemonAddMarketplaceResultSchema = z.object({ + success: z.boolean(), + name: z.string().optional(), + error: z.string().optional(), +}); + +// ── REMOVE_MARKETPLACE ─────────────────────────────────────────────── + +const DaemonRemoveMarketplaceRequestParamsSchema = z.object({ + sessionId: z.string(), + name: z.string(), +}); + +export const DaemonRemoveMarketplaceRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.REMOVE_MARKETPLACE), + params: DaemonRemoveMarketplaceRequestParamsSchema, + }); + +export const DaemonRemoveMarketplaceResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); + +// ── UPDATE_MARKETPLACE ─────────────────────────────────────────────── + +const DaemonUpdateMarketplaceRequestParamsSchema = z.object({ + sessionId: z.string(), + name: z.string().optional(), +}); + +export const DaemonUpdateMarketplaceRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.UPDATE_MARKETPLACE), + params: DaemonUpdateMarketplaceRequestParamsSchema, + }); + +export const DaemonUpdateMarketplaceResultSchema = z.object({ + results: z.array( + z.object({ + name: z.string(), + success: z.boolean(), + error: z.string().optional(), + }) + ), +}); diff --git a/src/protocol/daemon/relay.ts b/src/protocol/daemon/relay.ts new file mode 100644 index 0000000..5fcd1b3 --- /dev/null +++ b/src/protocol/daemon/relay.ts @@ -0,0 +1,81 @@ +/** + * Daemon relay schemas. + * These cover relay connection lifecycle operations for BYOM computers. + */ +import z from 'zod'; + +import { + JsonRpcBaseNotificationSchema, + JsonRpcBaseRequestSchema, + JsonRpcBaseResponseFailureSchema, + JsonRpcBaseResponseSuccessSchema, +} from '../json-rpc.js'; +import { DaemonRelayEvent, DaemonRelayMethod } from './enums.js'; + +// daemon.relay.start +export const DaemonRelayStartRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonRelayMethod.START), + params: z.object({}), +}); + +export const DaemonRelayStartResultSchema = z.object({ + relayUrl: z.string(), + computerId: z.string(), +}); + +export const DaemonRelayStartResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonRelayStartResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// daemon.relay.stop +export const DaemonRelayStopRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonRelayMethod.STOP), + params: z.object({}), +}); + +export const DaemonRelayStopResultSchema = z.object({}).strict(); + +export const DaemonRelayStopResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonRelayStopResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// daemon.relay.get_status +export const DaemonRelayGetStatusRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonRelayMethod.GET_STATUS), + params: z.object({}), + }); + +export const DaemonRelayGetStatusResultSchema = z.object({ + connected: z.boolean(), + url: z.string().optional(), + clientCount: z.number().optional(), + computerId: z.string().optional(), +}); + +export const DaemonRelayGetStatusResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonRelayGetStatusResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// daemon.relay.status_changed (push notification for external state changes, e.g. unexpected disconnects) +export const DaemonRelayStatusChangedNotificationParamsSchema = z.object({ + connected: z.boolean(), + url: z.string().optional(), + clientCount: z.number().optional(), + computerId: z.string().optional(), +}); + +export const DaemonRelayStatusChangedNotificationSchema = + JsonRpcBaseNotificationSchema.extend({ + method: z.literal(DaemonRelayEvent.STATUS_CHANGED), + params: DaemonRelayStatusChangedNotificationParamsSchema, + }); diff --git a/src/protocol/daemon/settings.ts b/src/protocol/daemon/settings.ts new file mode 100644 index 0000000..6ed5aa2 --- /dev/null +++ b/src/protocol/daemon/settings.ts @@ -0,0 +1,199 @@ +/** + * Daemon-only settings schemas. + * These are for settings that are stored/retrieved directly by the daemon + * without forwarding to the CLI process. + */ +import z from 'zod'; + +import { AvailableModelConfigSchema } from '../client.js'; +import { + AutonomyLevel, + AutonomyMode, + ReasoningEffort, + SettingsLevel, +} from '../enums.js'; +import { GeneralSettingsSchema } from '../general-settings.js'; +import { + DroidInteractionModeSchema, + JsonRpcBaseRequestSchema, + JsonRpcBaseResponseFailureSchema, + JsonRpcBaseResponseSuccessSchema, +} from '../json-rpc.js'; +import { + MissionModelSettingsSchema, + SubagentModelSettingsSchema, +} from '../model-settings.js'; +import { + SessionDefaultSettingsSchema, + SettingsResolutionEventSchema, +} from '../settings.js'; +import { DaemonSettingsMethod } from './enums.js'; + +// --------------------------------------------------------------------------- +// Daemon settings RPC schemas +// --------------------------------------------------------------------------- + +// Request params schemas +export const DaemonGetDefaultSettingsRequestParamsSchema = z.object({}); +const DaemonSettingsManagementInfoSchema = z.object({ + disabled: z.boolean(), + source: z.nativeEnum(SettingsLevel).nullable(), + folderPath: z.string().optional(), +}); +const DaemonSettingsManagementInfoFieldSchema = + DaemonSettingsManagementInfoSchema.optional().catch(undefined); +const LegacyCompactionModelModeWireSchema = z.enum([ + 'current-model', + 'factory-default', +]); +const DaemonSubagentDefaultsManagementMapSchema = z + .object({ + lightModel: DaemonSettingsManagementInfoFieldSchema, + lightReasoningEffort: DaemonSettingsManagementInfoFieldSchema, + mediumModel: DaemonSettingsManagementInfoFieldSchema, + mediumReasoningEffort: DaemonSettingsManagementInfoFieldSchema, + heavyModel: DaemonSettingsManagementInfoFieldSchema, + heavyReasoningEffort: DaemonSettingsManagementInfoFieldSchema, + }) + .catch({}); +const DaemonMissionDefaultsManagementMapSchema = z + .object({ + orchestratorModel: DaemonSettingsManagementInfoFieldSchema, + orchestratorReasoningEffort: DaemonSettingsManagementInfoFieldSchema, + workerModel: DaemonSettingsManagementInfoFieldSchema, + workerReasoningEffort: DaemonSettingsManagementInfoFieldSchema, + validationWorkerModel: DaemonSettingsManagementInfoFieldSchema, + validationWorkerReasoningEffort: DaemonSettingsManagementInfoFieldSchema, + skipScrutiny: DaemonSettingsManagementInfoFieldSchema, + skipUserTesting: DaemonSettingsManagementInfoFieldSchema, + }) + .catch({}); +export const DaemonSessionDefaultsManagementMapSchema = z + .object({ + modelId: DaemonSettingsManagementInfoFieldSchema, + reasoningEffort: DaemonSettingsManagementInfoFieldSchema, + interactionMode: DaemonSettingsManagementInfoFieldSchema, + autonomyLevel: DaemonSettingsManagementInfoFieldSchema, + autonomyMode: DaemonSettingsManagementInfoFieldSchema, + specModeModelId: DaemonSettingsManagementInfoFieldSchema, + specModeReasoningEffort: DaemonSettingsManagementInfoFieldSchema, + specSaveDir: DaemonSettingsManagementInfoFieldSchema, + compactionTokenLimit: DaemonSettingsManagementInfoFieldSchema, + compactionTokenLimitPerModel: DaemonSettingsManagementInfoFieldSchema, + compactionModel: DaemonSettingsManagementInfoFieldSchema, + compactionModelMode: DaemonSettingsManagementInfoFieldSchema, + runInWorktree: DaemonSettingsManagementInfoFieldSchema, + worktreeDirectory: DaemonSettingsManagementInfoFieldSchema, + subagent: + DaemonSubagentDefaultsManagementMapSchema.optional().catch(undefined), + mission: + DaemonMissionDefaultsManagementMapSchema.optional().catch(undefined), + }) + .catch({}); +export const DaemonUpdateSessionDefaultsRequestParamsSchema = z.object({ + modelId: SessionDefaultSettingsSchema.shape.model, + reasoningEffort: SessionDefaultSettingsSchema.shape.reasoningEffort, + interactionMode: SessionDefaultSettingsSchema.shape.interactionMode, + autonomyLevel: SessionDefaultSettingsSchema.shape.autonomyLevel, + specModeModelId: SessionDefaultSettingsSchema.shape.specModeModel.nullable(), + specModeReasoningEffort: + SessionDefaultSettingsSchema.shape.specModeReasoningEffort.nullable(), + compactionTokenLimit: GeneralSettingsSchema.shape.compactionTokenLimit, + compactionTokenLimitPerModel: + GeneralSettingsSchema.shape.compactionTokenLimitPerModel, + compactionModel: GeneralSettingsSchema.shape.compactionModel, + compactionModelMode: LegacyCompactionModelModeWireSchema.optional().describe( + 'Deprecated wire input retained for existing daemon clients; use compactionModel instead.' + ), + subagentModelSettings: SubagentModelSettingsSchema.partial().optional(), + specSaveDir: GeneralSettingsSchema.shape.specSaveDir.nullable().optional(), + missionOrchestratorModel: GeneralSettingsSchema.shape.missionOrchestratorModel + .nullable() + .optional(), + missionOrchestratorReasoningEffort: + GeneralSettingsSchema.shape.missionOrchestratorReasoningEffort + .nullable() + .optional(), + missionModelSettings: MissionModelSettingsSchema.partial().optional(), + runInWorktree: SessionDefaultSettingsSchema.shape.runInWorktree + .nullable() + .optional(), + worktreeDirectory: GeneralSettingsSchema.shape.worktreeDirectory + .nullable() + .optional(), +}); + +const DaemonSpecSavePresetsSchema = z + .object({ + projectFactoryDir: z.string().optional(), + userFactoryDir: z.string(), + }) + .optional(); + +// Request schemas +export const DaemonGetDefaultSettingsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonSettingsMethod.GET_DEFAULT_SETTINGS), + params: DaemonGetDefaultSettingsRequestParamsSchema, + }); + +export const DaemonUpdateSessionDefaultsRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonSettingsMethod.UPDATE_SESSION_DEFAULTS), + params: DaemonUpdateSessionDefaultsRequestParamsSchema, + }); + +// Result schemas +export const DaemonGetDefaultSettingsResultSchema = z.object({ + autonomyMode: z + .nativeEnum(AutonomyMode) + .describe('Deprecated: use interactionMode + autonomyLevel instead.') + .optional(), + interactionMode: DroidInteractionModeSchema.optional().catch(undefined), + autonomyLevel: z.nativeEnum(AutonomyLevel).optional().catch(undefined), + maxAutonomyLevel: z.nativeEnum(AutonomyLevel).optional().catch(undefined), + modelId: z.string().optional(), + reasoningEffort: z.nativeEnum(ReasoningEffort).optional(), + specSaveDir: z.string().optional(), + specModeModelId: z.string().optional(), + specModeReasoningEffort: z.nativeEnum(ReasoningEffort).optional(), + compactionTokenLimit: z.number().optional(), + compactionTokenLimitPerModel: z.record(z.number()).optional(), + compactionModel: GeneralSettingsSchema.shape.compactionModel, + compactionModelMode: LegacyCompactionModelModeWireSchema.optional().describe( + 'Deprecated wire view retained for existing daemon clients; use compactionModel instead.' + ), + runInWorktree: z.boolean().optional(), + worktreeDirectory: z.string().optional(), + management: DaemonSessionDefaultsManagementMapSchema.optional(), + missionOrchestratorModel: z.string().optional(), + missionOrchestratorReasoningEffort: z.nativeEnum(ReasoningEffort).optional(), + missionSettings: MissionModelSettingsSchema.optional(), + subagentModelSettings: SubagentModelSettingsSchema.optional(), + availableModels: z.array(AvailableModelConfigSchema).optional(), + specSavePresets: DaemonSpecSavePresetsSchema, + resolutionChain: z + .array(SettingsResolutionEventSchema) + .optional() + .catch(undefined), +}); + +export const DaemonUpdateSessionDefaultsResultSchema = z.object({ + success: z.boolean(), + defaults: DaemonGetDefaultSettingsResultSchema, +}); + +// Response schemas +export const DaemonGetDefaultSettingsResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonGetDefaultSettingsResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const DaemonUpdateSessionDefaultsResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: DaemonUpdateSessionDefaultsResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); diff --git a/src/protocol/daemon/skills.ts b/src/protocol/daemon/skills.ts new file mode 100644 index 0000000..3f76289 --- /dev/null +++ b/src/protocol/daemon/skills.ts @@ -0,0 +1,19 @@ +import z from 'zod'; + +import { SkillInfoSchema } from '../client.js'; +import { JsonRpcBaseRequestSchema } from '../json-rpc.js'; +import { DaemonDroidMethod } from './enums.js'; + +// LIST_SKILLS - get all available skills for a session +const DaemonListSkillsRequestParamsSchema = z.object({ + sessionId: z.string(), +}); + +export const DaemonListSkillsRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonDroidMethod.LIST_SKILLS), + params: DaemonListSkillsRequestParamsSchema, +}); + +export const DaemonListSkillsResultSchema = z.object({ + skills: z.array(SkillInfoSchema), +}); diff --git a/src/protocol/daemon/terminal.ts b/src/protocol/daemon/terminal.ts new file mode 100644 index 0000000..040be63 --- /dev/null +++ b/src/protocol/daemon/terminal.ts @@ -0,0 +1,234 @@ +import z from 'zod'; + +import { + JsonRpcBaseRequestSchema, + JsonRpcBaseResponseSuccessSchema, + JsonRpcBaseResponseFailureSchema, +} from '../json-rpc.js'; +import { + DaemonTerminalMethod, + DaemonTerminalEvent, + CreateTerminalError, +} from './enums.js'; + +// Terminal request params schemas +export const CreateTerminalRequestParamsSchema = z.object({ + terminalId: z.string(), + cols: z.number().optional(), + rows: z.number().optional(), + cwd: z.string().optional(), + env: z.record(z.string()).optional(), +}); + +export const WriteDataRequestParamsSchema = z.object({ + terminalId: z.string(), + data: z.string(), +}); + +export const ResizeRequestParamsSchema = z.object({ + terminalId: z.string(), + cols: z.number(), + rows: z.number(), +}); + +export const CloseTerminalRequestParamsSchema = z.object({ + terminalId: z.string(), +}); + +export const ListTerminalsRequestParamsSchema = z.object({}); + +// Terminal result schemas +export const CreateTerminalResultSchema = z.discriminatedUnion('success', [ + z.object({ + success: z.literal(true), + }), + z.object({ + success: z.literal(false), + error: z.nativeEnum(CreateTerminalError), + }), +]); + +export const WriteDataResultSchema = z.object({ + success: z.boolean(), +}); + +export const ResizeResultSchema = z.object({ + success: z.boolean(), +}); + +export const CloseTerminalResultSchema = z.object({ + success: z.boolean(), +}); + +export const TerminalInfoSchema = z.object({ + id: z.string(), + pid: z.number().nullable(), + cols: z.number(), + rows: z.number(), + createdAt: z.coerce.date(), + state: z + .object({ + serialized: z.string(), + plainText: z.string(), + cols: z.number(), + rows: z.number(), + timestamp: z.coerce.date(), + cursorHidden: z.boolean().optional(), + }) + .optional(), +}); + +export const ListTerminalsResultSchema = z.object({ + terminals: z.array(TerminalInfoSchema), +}); + +// Terminal request schemas - use TerminalMethod enum +export const CreateTerminalRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonTerminalMethod.CREATE), + params: CreateTerminalRequestParamsSchema, +}); + +export const WriteDataRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonTerminalMethod.WRITE_DATA), + params: WriteDataRequestParamsSchema, +}); + +export const ResizeRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonTerminalMethod.RESIZE), + params: ResizeRequestParamsSchema, +}); + +export const CloseTerminalRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonTerminalMethod.CLOSE), + params: CloseTerminalRequestParamsSchema, +}); + +export const ListTerminalsRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonTerminalMethod.LIST), + params: ListTerminalsRequestParamsSchema, +}); + +// Terminal response schemas +export const CreateTerminalResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: CreateTerminalResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const WriteDataResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: WriteDataResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const ResizeResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: ResizeResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const CloseTerminalResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: CloseTerminalResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +export const ListTerminalsResponseSchema = z.union([ + JsonRpcBaseResponseSuccessSchema.extend({ + result: ListTerminalsResultSchema, + }), + JsonRpcBaseResponseFailureSchema, +]); + +// Union of all terminal request types (discriminated by method) +export const TerminalRequestSchema = z.discriminatedUnion('method', [ + CreateTerminalRequestSchema, + WriteDataRequestSchema, + ResizeRequestSchema, + CloseTerminalRequestSchema, + ListTerminalsRequestSchema, +]); + +// Terminal notification schemas - match session notification pattern (no params wrapper, no sessionId) +export const TerminalDataNotificationSchema = z.object({ + type: z.literal(DaemonTerminalEvent.DATA), + terminalId: z.string(), + data: z.string(), +}); + +export const TerminalExitNotificationSchema = z.object({ + type: z.literal(DaemonTerminalEvent.EXIT), + terminalId: z.string(), + exitCode: z.number(), + signal: z.string(), +}); + +// Union of all terminal notification types +export const TerminalNotificationSchema = z.discriminatedUnion('type', [ + TerminalDataNotificationSchema, + TerminalExitNotificationSchema, +]); + +// ============ DAEMON Terminal Request Params (with sessionId) ============ + +export const DaemonCreateTerminalRequestParamsSchema = + CreateTerminalRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonWriteDataRequestParamsSchema = + WriteDataRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonResizeRequestParamsSchema = ResizeRequestParamsSchema.extend( + { + sessionId: z.string(), + } +); + +export const DaemonCloseTerminalRequestParamsSchema = + CloseTerminalRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +export const DaemonListTerminalsRequestParamsSchema = + ListTerminalsRequestParamsSchema.extend({ + sessionId: z.string(), + }); + +// ============ DAEMON Terminal Request Schemas ============ + +export const DaemonCreateTerminalRequestSchema = + JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonTerminalMethod.CREATE), + params: DaemonCreateTerminalRequestParamsSchema, + }); + +export const DaemonWriteDataRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonTerminalMethod.WRITE_DATA), + params: DaemonWriteDataRequestParamsSchema, +}); + +export const DaemonResizeRequestSchema = JsonRpcBaseRequestSchema.extend({ + method: z.literal(DaemonTerminalMethod.RESIZE), + params: DaemonResizeRequestParamsSchema, +}); + +export const DaemonCloseTerminalRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonTerminalMethod.CLOSE), + params: DaemonCloseTerminalRequestParamsSchema, + } +); + +export const DaemonListTerminalsRequestSchema = JsonRpcBaseRequestSchema.extend( + { + method: z.literal(DaemonTerminalMethod.LIST), + params: DaemonListTerminalsRequestParamsSchema, + } +); diff --git a/src/protocol/enums.ts b/src/protocol/enums.ts index 561425e..e1822c3 100644 --- a/src/protocol/enums.ts +++ b/src/protocol/enums.ts @@ -522,6 +522,81 @@ export enum SessionOrigin { // llm/enums.ts (leaf subset) // --------------------------------------------------------------------------- +/** + * ModelID enum defines unique identifiers for language models. + * + * These IDs are independent of provider (Anthropic, OpenAI, etc.) and reasoning effort (Low, Medium, High). + * They represent the core model identity that remains consistent regardless of how the model is accessed. + */ +export enum ModelID { + // Claude models + CLAUDE_SONNET_3_5 = 'claude-3-5-sonnet-20241022', + CLAUDE_SONNET_3_7 = 'claude-3-7-sonnet-20250219', + CLAUDE_SONNET_4 = 'claude-sonnet-4-20250514', + CLAUDE_OPUS_4 = 'claude-opus-4-1-20250805', + CLAUDE_HAIKU_3_5 = 'claude-3-5-haiku-20241022', + CLAUDE_SONNET_4_5 = 'claude-sonnet-4-5-20250929', + CLAUDE_OPUS_4_5 = 'claude-opus-4-5-20251101', + CLAUDE_HAIKU_4_5 = 'claude-haiku-4-5-20251001', + CLAUDE_SONNET_4_6 = 'claude-sonnet-4-6', + CLAUDE_OPUS_4_6 = 'claude-opus-4-6', + CLAUDE_OPUS_4_6_FAST = 'claude-opus-4-6-fast', + CLAUDE_OPUS_4_7 = 'claude-opus-4-7', + CLAUDE_OPUS_4_7_FAST = 'claude-opus-4-7-fast', + CLAUDE_OPUS_4_8 = 'claude-opus-4-8', + CLAUDE_OPUS_4_8_FAST = 'claude-opus-4-8-fast', + ASPEN_0515 = 'aspen-05-15', + ALMOND_0527 = 'almond-05-27', + + // OpenAI models + GPT_5 = 'gpt-5-2025-08-07', + GPT_5_MINI = 'gpt-5-mini-2025-08-07', + GPT_5_NANO = 'gpt-5-nano-2025-08-07', + GPT_5_CODEX = 'gpt-5-codex', + GPT_5_1 = 'gpt-5.1', + GPT_5_1_CODEX = 'gpt-5.1-codex', + GPT_5_1_CODEX_MAX = 'gpt-5.1-codex-max', + GPT_5_2 = 'gpt-5.2', + GPT_5_2_CODEX = 'gpt-5.2-codex', + GPT_5_3_CODEX = 'gpt-5.3-codex', + GPT_5_3_CODEX_FAST = 'gpt-5.3-codex-fast', + GPT_5_4 = 'gpt-5.4', + GPT_5_4_FAST = 'gpt-5.4-fast', + GPT_5_4_MINI = 'gpt-5.4-mini', + GPT_5_5 = 'gpt-5.5', + GPT_5_5_FAST = 'gpt-5.5-fast', + GPT_5_5_PRO = 'gpt-5.5-pro', + OLM_0305 = 'olm-03-05', + ORBIT_0409 = 'orbit-04-09', + OLIVE_0522 = 'olive-05-22', + + // Google models + GEMINI_2_5_FLASH = 'gemini-2.5-flash', + GEMINI_2_5_PRO = 'gemini-2.5-pro', + GEMINI_3_PRO = 'gemini-3-pro-preview', + GEMINI_3_FLASH = 'gemini-3-flash-preview', + GEMINI_3_1_PRO = 'gemini-3.1-pro-preview', + GEMINI_3_5_FLASH = 'gemini-3.5-flash', + GANTRY_0507 = 'gantry-05-07', + + // XAI models + TITAN_0212 = 'titan-02-12', + + // Open source models + GLM_4_6 = 'glm-4.6', + GLM_4_7 = 'glm-4.7', + KIMI_K2_5 = 'kimi-k2.5', + KIMI_K2_6 = 'kimi-k2.6', + DEEPSEEK_V4_PRO = 'deepseek-v4-pro', + MINIMAX_M2_5 = 'minimax-m2.5', + MINIMAX_M2_7 = 'minimax-m2.7', + GLM_5 = 'glm-5', + GLM_5_1 = 'glm-5.1', + + // Routers + FACTORY_ROUTER = 'factory-router', +} + export enum OpenAIPhase { Commentary = 'commentary', FinalAnswer = 'final_answer', @@ -632,3 +707,44 @@ export enum SandboxMode { PerCommand = 'per-command', WholeProcess = 'whole-process', } + +// --------------------------------------------------------------------------- +// settings/enums.ts (additional leaf enums for GeneralSettingsSchema) +// --------------------------------------------------------------------------- + +export enum DiffMode { + Github = 'github', + Unified = 'unified', +} + +export enum SoundFocusMode { + Always = 'always', + Focused = 'focused', + Unfocused = 'unfocused', +} + +export enum TodoDisplayMode { + Inline = 'inline', + Pinned = 'pinned', +} + +export enum SubagentSoundMode { + Off = 'off', + Quiet = 'quiet', + Inherit = 'inherit', +} + +// --------------------------------------------------------------------------- +// cli/enums.ts (leaf enums for GeneralSettingsSchema) +// --------------------------------------------------------------------------- + +export enum LogoAnimationMode { + Once = 'once', + Always = 'always', + Off = 'off', +} + +export enum ToolResultDisplay { + Expanded = 'expanded', + Compact = 'compact', +} diff --git a/src/protocol/general-settings.ts b/src/protocol/general-settings.ts new file mode 100644 index 0000000..f5b58ce --- /dev/null +++ b/src/protocol/general-settings.ts @@ -0,0 +1,283 @@ +// Source-of-truth mirror of `ManagedSettingsBaseSchema` and +// `GeneralSettingsSchema` from `packages/common/src/settings/schema.ts`. +// Verbatim port; do not modify the schema bodies. The only allowed +// divergences are: +// 1. Import paths are rewritten to local `./` siblings with `.js` ESM +// extensions (and helpers that already exist in sibling SDK modules +// are imported instead of redeclared). +// 2. `ManagedSettingsBaseSchema` is `export const` here (upstream declares +// it `const`); the visibility is raised so `GeneralSettingsSchema` can +// call `.extend(...)` on it from a separate module boundary if needed +// and so the public API can surface it. See the inline NOTE below. +// +// Symbols ported verbatim (in upstream order): +// - SESSION_RETENTION_MIN_DAYS / SESSION_RETENTION_MAX_DAYS +// - ModelPolicySchema (+ private tolerantModelIdArray helper — upstream +// intentionally redefines this locally; we mirror that) +// - McpPolicySchema +// - MissionPolicySchema +// - NetworkPolicySchema (private, like upstream) +// - SandboxFilesystemSettingsSchema +// - SandboxNetworkSettingsSchema +// - SandboxSettingsSchema +// - ExtraMarketplaceEntrySchema (private, like upstream) +// - FactoryRouterRuleSchema +// - McpAutonomyLevelSchema (private, like upstream) +// - StatusLineConfigSchema (private, like upstream) +// - ManagedSettingsBaseSchema (exported; see NOTE) +// - GeneralSettingsSchema + +import { z } from 'zod'; + +import { + FACTORY_ROUTER_GUIDANCE_MAX_LENGTH, + FACTORY_ROUTER_RULE_GUIDANCE_MAX_LENGTH, + FACTORY_ROUTER_RULE_WHEN_MAX_LENGTH, + FACTORY_ROUTER_RULES_MAX_COUNT, +} from './constants.js'; +import { CustomModelsSchema } from './custom-models.js'; +import { + AutonomyLevel, + DiffMode, + LogoAnimationMode, + ModelID, + ReasoningEffort, + SandboxMode, + SoundFocusMode, + SubagentSoundMode, + TodoDisplayMode, + ToolResultDisplay, +} from './enums.js'; +import { + MissionModelSettingsSchema, + SubagentModelSettingsSchema, +} from './model-settings.js'; +import { UserModelPolicySchema } from './policy.js'; +import { + CompactionModelSchema, + MarketplaceSourceSchema, + SessionDefaultSettingsSchema, +} from './settings.js'; + +const ReasoningEffortSchema = z.nativeEnum(ReasoningEffort); + +// ============================================================================= +// Model Policy Schema (for org-level model access control) +// ============================================================================= + +/** + * A lenient model ID array that silently drops unrecognized values instead of + * failing validation. This is critical for backward compatibility: when a ModelID + * enum value is removed (e.g. after an EAP model cleanup), any Firestore + * documents still referencing the old ID will have it filtered out rather than + * causing the entire settings object to fail parsing. + */ +const modelIdValues: ReadonlySet = new Set(Object.values(ModelID)); +const tolerantModelIdArray = z + .array(z.string()) + .transform((ids) => ids.filter((id): id is ModelID => modelIdValues.has(id))); + +export const ModelPolicySchema = z.object({ + allowedModelIds: tolerantModelIdArray.optional(), + blockedModelIds: tolerantModelIdArray.optional(), + allowCustomModels: z.boolean().optional(), + allowedBaseUrls: z.array(z.string()).optional(), + allowAllFactoryModels: z.boolean().optional(), + isFastModelsAllowed: z.boolean().optional(), +}); + +// ============================================================================= +// MCP Policy Schema (for org-level MCP server access control) +// ============================================================================= + +export const McpPolicySchema = z.object({ + enabled: z.boolean().default(false), + allowlist: z.array(z.string()).optional(), +}); + +// ============================================================================= +// Mission Policy Schema (for org-level missions access control) +// ============================================================================= + +export const MissionPolicySchema = z.object({ + restrictedAccess: z.boolean().default(false), + allowedUserIds: z.array(z.string()).optional(), +}); + +// ============================================================================= +// Network Policy Schema (for org-level network access control) +// IP format validation is handled by @factory/utils/ip (isValidIpOrCidr). +// The schema only enforces shape: non-empty array of trimmed strings. +// ============================================================================= + +const NetworkPolicySchema = z.object({ + allowedIps: z + .array(z.string().trim()) + .min(1, 'IP allowlist must contain at least one entry when enabled'), +}); + +// ============================================================================= +// Sandbox Settings Schema +// ============================================================================= + +export const SandboxFilesystemSettingsSchema = z.object({ + allowWrite: z.array(z.string()).optional(), + allowRead: z.array(z.string()).optional(), + denyWrite: z.array(z.string()).optional(), + denyRead: z.array(z.string()).optional(), +}); + +export const SandboxNetworkSettingsSchema = z.object({ + allowedDomains: z.array(z.string()).optional(), + allowUnixSockets: z.array(z.string()).optional(), + allowAllUnixSockets: z.boolean().optional(), + allowLocalBinding: z.boolean().optional(), + httpProxyPort: z.number().optional(), + socksProxyPort: z.number().optional(), +}); + +const SandboxModeSchema = z.nativeEnum(SandboxMode); + +export const SandboxSettingsSchema = z.object({ + enabled: z.boolean().optional(), + mode: SandboxModeSchema.optional(), + filesystem: SandboxFilesystemSettingsSchema.optional(), + network: SandboxNetworkSettingsSchema.optional(), +}); + +// ============================================================================= +// Session Retention Constants +// ============================================================================= + +export const SESSION_RETENTION_MIN_DAYS = 14; +export const SESSION_RETENTION_MAX_DAYS = 365; + +const ExtraMarketplaceEntrySchema = z.object({ + source: MarketplaceSourceSchema, +}); + +export const FactoryRouterRuleSchema = z.object({ + when: z.string().max(FACTORY_ROUTER_RULE_WHEN_MAX_LENGTH).optional(), + guidance: z.string().max(FACTORY_ROUTER_RULE_GUIDANCE_MAX_LENGTH), +}); + +// ============================================================================= +// Managed Settings Schema +// ============================================================================= + +const McpAutonomyLevelSchema = z.enum([ + AutonomyLevel.Low, + AutonomyLevel.Medium, + AutonomyLevel.High, +]); + +const StatusLineConfigSchema = z.object({ + type: z.literal('command').optional(), + command: z.string(), + padding: z.number().optional(), + maxRows: z.number().int().min(1).max(3).optional(), +}); + +// NOTE: upstream declares this `const`; exported here to allow `.extend(...)` +// across module boundaries and to surface it on the public protocol namespace. +export const ManagedSettingsBaseSchema = z.object({ + sessionDefaultSettings: SessionDefaultSettingsSchema.optional(), + maxAutonomyLevel: z.nativeEnum(AutonomyLevel).optional(), + mcpAutonomyOverrides: z + .record( + z.string(), + z.object({ + defaultLevel: McpAutonomyLevelSchema.optional(), + tools: z.record(z.string(), McpAutonomyLevelSchema).optional(), + }) + ) + .optional(), + cloudSessionSync: z.boolean().optional(), + wikiCloudSync: z.boolean().optional(), + includeCoAuthoredByDroid: z.boolean().optional(), + enableDroidShield: z.boolean().optional(), + ideAutoConnect: z.boolean().optional(), + commandAllowlist: z.array(z.string()).optional(), + commandDenylist: z.array(z.string()).optional(), + customModels: CustomModelsSchema.optional(), + modelPolicy: ModelPolicySchema.optional(), + mcpPolicy: McpPolicySchema.optional(), + missionPolicy: MissionPolicySchema.optional(), + userModelPolicies: z.record(z.string(), UserModelPolicySchema).optional(), + enabledPlugins: z.record(z.boolean()).optional(), + extraKnownMarketplaces: z.record(ExtraMarketplaceEntrySchema).optional(), + strictKnownMarketplaces: z.array(MarketplaceSourceSchema).optional(), + networkPolicy: NetworkPolicySchema.optional(), + sandbox: SandboxSettingsSchema.optional(), + restrictMemberVisibility: z.boolean().optional(), + restrictApiKeyCreationToManagers: z.boolean().optional(), + managedComputersEnabled: z.boolean().optional(), + managedComputersAllowedEmails: z.array(z.string().trim()).optional(), + byomComputersEnabled: z.boolean().optional(), + byomComputersAllowedEmails: z.array(z.string().trim()).optional(), + sessionRetentionDays: z + .number() + .int() + .min(SESSION_RETENTION_MIN_DAYS) + .max(SESSION_RETENTION_MAX_DAYS) + .optional(), + factoryRouterGuidance: z + .string() + .max(FACTORY_ROUTER_GUIDANCE_MAX_LENGTH) + .optional(), + factoryRouterRules: z + .array(FactoryRouterRuleSchema) + .max(FACTORY_ROUTER_RULES_MAX_COUNT) + .optional(), +}); + +// ============================================================================= +// General Settings Schema (CLI settings.json / settings.local.json) +// ============================================================================= + +export const GeneralSettingsSchema = ManagedSettingsBaseSchema.extend({ + diffMode: z.nativeEnum(DiffMode).optional(), + ideExtensionPromptedAt: z.record(z.number()).optional(), + ideActivationNudgedForVersion: z.record(z.string()).optional(), + enableCompletionBell: z.boolean().optional(), + completionSound: z.string().optional(), + awaitingInputSound: z.string().optional(), + soundFocusMode: z.nativeEnum(SoundFocusMode).optional(), + completionSoundFocusMode: z.nativeEnum(SoundFocusMode).optional(), + awaitingInputSoundFocusMode: z.nativeEnum(SoundFocusMode).optional(), + specSaveEnabled: z.boolean().optional(), + specSaveDir: z.string().optional(), + todoDisplayMode: z.nativeEnum(TodoDisplayMode).optional(), + toolResultDisplay: z.nativeEnum(ToolResultDisplay).optional(), + showThinkingInMainView: z.boolean().optional(), + keepSystemAwakeDuringMissions: z.boolean().optional(), + showTokenUsageIndicator: z.boolean().optional(), + missionOrchestratorModel: z.string().optional(), + missionOrchestratorReasoningEffort: ReasoningEffortSchema.optional(), + modelFavorites: z.array(z.string()).optional(), + logoAnimation: z.nativeEnum(LogoAnimationMode).optional(), + missionModelSettings: MissionModelSettingsSchema.optional(), + subagentModelSettings: SubagentModelSettingsSchema.optional(), + statusLine: StatusLineConfigSchema.optional(), + theme: z.string().optional(), + overrideTerminalColors: z.boolean().optional(), + hasSeenMissionOnboarding: z.boolean().optional(), + worktreeDirectory: z.string().optional(), + windowZoomLevel: z.number().finite().optional(), + remoteAccessEnabled: z.boolean().optional(), + llmRequestTimeout: z.number().min(1000).optional(), + subagentInactivityTimeout: z.number().min(1000).optional(), + subagentSounds: z.nativeEnum(SubagentSoundMode).optional(), + nerdFont: z.boolean().optional(), + compactionTokenLimit: z.number().optional(), + compactionTokenLimitPerModel: z.record(z.number()).optional(), + compactionModel: CompactionModelSchema.optional(), +}); + +export type ManagedSettingsBase = z.infer; +export type GeneralSettings = z.infer; +export type ModelPolicy = z.infer; +export type McpPolicy = z.infer; +export type MissionPolicy = z.infer; +export type SandboxSettings = z.infer; +export type FactoryRouterRule = z.infer; diff --git a/src/protocol/index.ts b/src/protocol/index.ts index 9be1fba..43356c0 100644 --- a/src/protocol/index.ts +++ b/src/protocol/index.ts @@ -11,6 +11,8 @@ export * from './session.js'; export * from './session-source.js'; export * from './messages.js'; export * from './model-settings.js'; +export * from './settings.js'; +export * from './general-settings.js'; export * from './loop.js'; export * from './selectable-list-item.js'; export * from './custom-models.js'; @@ -18,3 +20,24 @@ export * from './mcp.js'; export * from './mission-decomposition.js'; export * from './cli.js'; export * from './client.js'; + +// TIER-4d aux leaves +export * from './automations-enums.js'; +export * from './automations.js'; +export * from './session-summary.js'; +export * from './session-tools.js'; +export * from './session-settings-schema.js'; +export * from './session-settings-types.js'; +export * from './policy.js'; +export * from './tools-enums.js'; +export * from './tools.js'; +export * from './updater.js'; + +// `./usage.js` defines its own `TokenUsageSchema` whose shape (snake-cased +// integer fields) differs from the one in `./session.js`. Both upstream +// modules ship the same symbol name (`TokenUsageSchema`) under different +// folder namespaces, so we mirror that here by re-exporting `./usage.js` +// under a namespace alias rather than as a top-level wildcard. +export * as usage from './usage.js'; + +export * as daemon from './daemon/index.js'; diff --git a/src/protocol/policy.ts b/src/protocol/policy.ts new file mode 100644 index 0000000..251f3cc --- /dev/null +++ b/src/protocol/policy.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { ModelID } from './enums.js'; + +/** + * A lenient model ID array that silently drops unrecognized values instead of + * failing validation. This ensures backward compatibility when ModelID enum + * values are removed (e.g. after EAP model cleanup) — any Firestore documents + * still referencing old IDs will have them filtered out rather than causing + * the entire security policy to fail parsing. + */ +const modelIdValues: ReadonlySet = new Set(Object.values(ModelID)); +const tolerantModelIdArray = z + .array(z.string()) + .transform((ids) => ids.filter((id): id is ModelID => modelIdValues.has(id))); + +export const UserModelPolicySchema = z.object({ + allowedModelIds: tolerantModelIdArray, + blockedModelIds: tolerantModelIdArray, +}); diff --git a/src/protocol/session-settings-schema.ts b/src/protocol/session-settings-schema.ts new file mode 100644 index 0000000..8d2a9ac --- /dev/null +++ b/src/protocol/session-settings-schema.ts @@ -0,0 +1,62 @@ +// Source-of-truth mirror of packages/common/src/session/settings/schema.ts. +// `TokenUsageSchema` is intentionally imported from `./session.js` (where it +// was previously mirrored verbatim from the same upstream definition) rather +// than redeclared here, to avoid duplicate barrel exports for the same shape. + +import { z } from 'zod'; + +import { ApiProvider, ModelProvider, ReasoningEffort } from './enums.js'; +import { TokenUsageSchema } from './session.js'; +import { SessionTagSchema } from './session.js'; +import { + CompactionModelSchema, + SessionDefaultSettingsSchema, +} from './settings.js'; + +/** + * Cached pick written once per Factory Router session (or overridden by the + * upgrade tool). Resumes + sub-agents read it instead of re-routing. + */ +export const EffectiveFactoryRouterModelSchema = z + .object({ + modelId: z.string(), + apiProvider: z.nativeEnum(ApiProvider), + reasoningEffort: z.nativeEnum(ReasoningEffort), + }) + .strict(); + +/** + * Session settings schema. + * Settings persisted to .settings.json for each session. + */ +export const SessionSettingsSchema = SessionDefaultSettingsSchema.extend({ + providerLock: z.nativeEnum(ModelProvider).optional(), + providerLockTimestamp: z.string().optional(), + apiProviderLock: z.nativeEnum(ApiProvider).optional(), + assistantActiveTimeMs: z.number().optional(), + tokenUsage: TokenUsageSchema.optional(), + inclusiveTokenUsage: TokenUsageSchema.optional(), + childInclusiveTokenUsageBySessionId: z.record(TokenUsageSchema).optional(), + + /** + * ISO timestamp when the session was archived. + * Presence indicates archived status (omit to unarchive). + */ + archivedAt: z.string().optional(), + + /** Session tags for categorization */ + tags: z.array(SessionTagSchema).optional(), + + /** Additional tool IDs enabled for this session */ + enabledToolIds: z.array(z.string()).optional(), + + /** Tool IDs explicitly disabled for this session */ + disabledToolIds: z.array(z.string()).optional(), + + /** Model to use for compaction; "current-model" follows the session/spec model. */ + compactionModel: CompactionModelSchema.optional(), + /** Whether threshold-based automatic compaction is enabled for this session */ + compactionThresholdCheckEnabled: z.boolean().optional(), + + effectiveFactoryRouterModel: EffectiveFactoryRouterModelSchema.optional(), +}); diff --git a/src/protocol/session-settings-types.ts b/src/protocol/session-settings-types.ts new file mode 100644 index 0000000..9f30b01 --- /dev/null +++ b/src/protocol/session-settings-types.ts @@ -0,0 +1,26 @@ +// Mirror of packages/common/src/session/settings/types.ts. +// +// The upstream file re-exports `TokenUsage` from its local schema. In this +// SDK the same `TokenUsageSchema` (same shape, mirrored from the same +// upstream definition) lives in `./session.js` and is already exported as +// `TokenUsage` from there. Re-exporting `TokenUsage` here would cause a +// duplicate symbol via the public protocol barrel, so it is intentionally +// omitted from this file — consumers should import `TokenUsage` from +// `./session.js` (or via the barrel). + +import { z } from 'zod'; + +import { + EffectiveFactoryRouterModelSchema, + SessionSettingsSchema, +} from './session-settings-schema.js'; + +export type EffectiveFactoryRouterModel = z.infer< + typeof EffectiveFactoryRouterModelSchema +>; + +/** + * Settings persisted to .settings.json for each session. + * Note: Field names differ from protocol (model vs modelId). + */ +export type SessionSettings = z.infer; diff --git a/src/protocol/session-summary.ts b/src/protocol/session-summary.ts new file mode 100644 index 0000000..98f3780 --- /dev/null +++ b/src/protocol/session-summary.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +import { DecompSessionType } from './enums.js'; +import { HostIdSchema } from './host.js'; + +/** + * Auto-stage for session title generation. + * Tracks whether the title was generated from the first message or first file edit. + */ +export enum SessionTitleAutoStage { + FirstMessage = 'first_message', + FirstFileEdit = 'first_file_edit', +} + +/** + * Schema for the first line (session_start event) of a session .jsonl file. + * This is the canonical definition of the session summary format on disk. + */ +export const SessionSummaryEventSchema = z.object({ + type: z.literal('session_start'), + id: z.string(), + title: z.string(), + sessionTitle: z.string().optional(), + isSessionTitleManuallySet: z.boolean().optional(), + sessionTitleAutoStage: z.nativeEnum(SessionTitleAutoStage).optional(), + owner: z.string(), + parent: z.string().nullable().optional(), + version: z.number().optional(), + cwd: z.string().optional(), + lastCwd: z.string().optional(), + decompSessionType: z.nativeEnum(DecompSessionType).optional(), + decompMissionId: z.string().optional(), + callingSessionId: z.string().optional(), + callingToolUseId: z.string().optional(), + // Durable execution-store identity for this local session file. + hostId: HostIdSchema.optional(), +}); + +export type SessionSummaryEvent = z.infer; diff --git a/src/protocol/session-tools.ts b/src/protocol/session-tools.ts new file mode 100644 index 0000000..81c57f8 --- /dev/null +++ b/src/protocol/session-tools.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const GenericToolExecutionOutputSchema = z.union([ + z.record(z.string(), z.any()), + z.string(), + z.array(z.union([z.string(), z.record(z.string(), z.any())])), +]); + +export type GenericToolExecutionOutput = z.infer< + typeof GenericToolExecutionOutputSchema +>; diff --git a/src/protocol/settings.ts b/src/protocol/settings.ts new file mode 100644 index 0000000..b25d773 --- /dev/null +++ b/src/protocol/settings.ts @@ -0,0 +1,180 @@ +// Source-of-truth mirror of factory-mono-alpha settings schemas (symbol-only). +// Faithful copies of the leaf schemas the daemon protocol references, drawn +// from packages/common/src/settings/schema.ts. Verbatim: +// - MarketplaceSourceSchema (+ inner discriminated-union variants) +// - SessionDefaultSettingsSchema +// - CompactionModelSchema +// - AutonomyModeSchema +// - SettingsSourceTypeEnum, SettingsSourceSchema, SettingsActionEnum, +// SettingsResolutionEventSchema (+ private SettingsResolutionLocationSchema) +// +// MissionModelSettingsSchema and SubagentModelSettingsSchema are already +// mirrored verbatim in ./model-settings.js; they are not re-exported here to +// avoid duplicate barrel exports. Callers needing them should import from +// './model-settings.js' as before. +// +// GeneralSettingsSchema and ManagedSettingsBaseSchema (plus their helper +// schemas: ModelPolicySchema, McpPolicySchema, MissionPolicySchema, +// SandboxSettingsSchema, FactoryRouterRuleSchema, etc.) live in +// ./general-settings.js, mirrored verbatim from the same upstream source +// file. + +import { z } from 'zod'; + +import { + CURRENT_COMPACTION_MODEL, + FACTORY_ROUTER_MODEL_ID, +} from './constants.js'; +import { + AutonomyLevel, + AutonomyMode, + ModelID, + ReasoningEffort, +} from './enums.js'; +import { DroidInteractionModeSchema } from './json-rpc.js'; + +// ============================================================================= +// Session Default Settings Schema +// ============================================================================= + +const ReasoningEffortSchema = z.nativeEnum(ReasoningEffort); + +export const AutonomyModeSchema = z.nativeEnum(AutonomyMode); + +const FIRST_COMPACTION_MODEL_ID = 'claude-3-5-sonnet-20241022'; +const CompactionModelIdValues: [string, ...string[]] = [ + FIRST_COMPACTION_MODEL_ID, + ...Object.values(ModelID).filter( + (modelId) => + modelId !== FACTORY_ROUTER_MODEL_ID && + modelId !== FIRST_COMPACTION_MODEL_ID + ), +]; + +export const CompactionModelSchema = z.union([ + z.literal(CURRENT_COMPACTION_MODEL), + z.enum(CompactionModelIdValues), + z.string().regex(/^custom:.+$/, 'Expected a custom model id'), +]); + +export const SessionDefaultSettingsSchema = z.object({ + model: z.string().optional(), + reasoningEffort: ReasoningEffortSchema.optional(), + interactionMode: DroidInteractionModeSchema.optional(), + autonomyLevel: z.nativeEnum(AutonomyLevel).optional(), + autonomyMode: AutonomyModeSchema.optional().describe( + 'Deprecated: use interactionMode + autonomyLevel instead.' + ), + specModeModel: z.string().optional(), + specModeReasoningEffort: ReasoningEffortSchema.optional(), + /** + * When true, new sessions started against a git repo are created in an + * isolated worktree. Gated behind the `DesktopWorktrees` feature flag in + * the desktop UI. + */ + runInWorktree: z.boolean().optional(), +}); + +// ============================================================================= +// Marketplace Source Schema +// ============================================================================= + +const GitHubMarketplaceSourceSchema = z.object({ + source: z.literal('github'), + repo: z.string(), +}); + +const UrlMarketplaceSourceSchema = z.object({ + source: z.literal('url'), + url: z.string(), +}); + +const LocalMarketplaceSourceSchema = z.object({ + source: z.literal('local'), + path: z.string(), +}); + +const GitSubdirMarketplaceSourceSchema = z.object({ + source: z.literal('git-subdir'), + url: z.string(), + path: z.string(), +}); + +export const MarketplaceSourceSchema = z.discriminatedUnion('source', [ + GitHubMarketplaceSourceSchema, + UrlMarketplaceSourceSchema, + LocalMarketplaceSourceSchema, + GitSubdirMarketplaceSourceSchema, +]); + +// ============================================================================= +// Settings Resolution Chain Schemas +// ============================================================================= + +export const SettingsSourceTypeEnum = z.enum([ + /** Factory's hardcoded defaults (lowest priority) */ + 'builtin-default', + /** Remote dynamic config (e.g. Statsig dynamic configs) */ + 'dynamic-config', + /** Organization-level settings pushed from the server */ + 'org', + /** User-level settings from ~/.factory/settings.json */ + 'user', + /** Project-level settings from /.factory/settings.json */ + 'project', + /** Folder-level settings from ancestor .factory/settings.json files */ + 'folder', + /** Model availability determined by a feature flag gate */ + 'feature-flag', + /** Value read from browser localStorage (CLOUD path only) */ + 'localstorage', + /** Value provided via React Router navigation state (e.g. template launch, session continuation) */ + 'nav-state', + /** Model/effort override enforced for orchestrator (mission) sessions */ + 'orchestrator-override', + /** Last-resort fallback when no model was selected after all other sources */ + 'auto-select', + /** Settings restored from the daemon's CLI process for this session — re-resolved from the settings hierarchy if the daemon restarts */ + 'session-state', +]); + +export const SettingsSourceSchema = z.object({ + type: SettingsSourceTypeEnum, + filePath: z.string().optional(), + flagName: z.string().optional(), + key: z.string().optional(), + orgId: z.string().optional(), +}); + +export const SettingsActionEnum = z.enum([ + 'set', + 'override', + 'skip', + 'fallback', +]); + +const SettingsResolutionLocationSchema = z.object({ + package: z.string(), + file: z.string(), + function: z.string().optional(), +}); + +export const SettingsResolutionEventSchema = z.object({ + timestamp: z.string(), + keys: z.array(z.string()), + action: SettingsActionEnum, + source: SettingsSourceSchema, + value: z.record(z.unknown()).optional(), + reason: z.string().optional(), + location: SettingsResolutionLocationSchema.optional(), +}); + +export type SessionDefaultSettings = z.infer< + typeof SessionDefaultSettingsSchema +>; +export type MarketplaceSource = z.infer; +export type CompactionModel = z.infer; +export type SettingsResolutionEvent = z.infer< + typeof SettingsResolutionEventSchema +>; +export type SettingsSource = z.infer; diff --git a/src/protocol/tools-enums.ts b/src/protocol/tools-enums.ts new file mode 100644 index 0000000..28ab8bb --- /dev/null +++ b/src/protocol/tools-enums.ts @@ -0,0 +1,27 @@ +/** + * The execution location for an tool: + * - Client: The tool is executed in the UI context (e.g. change the page). + * - Server: The tool is executed server-side (e.g. a code search). + * - Both: The tool can be executed in either environment depending on runtime logic. + */ +export enum ToolExecutionLocation { + Client = 'client', + Server = 'server', + Both = 'both', +} + +/** + * Common status values for tool execution. + * Used for tracking the progress and final outcome of an tool's execution. + */ +export enum ToolStatus { + Pending = 'pending', + Running = 'running', + Completed = 'completed', + Failed = 'failed', +} +export enum RiskLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', +} diff --git a/src/protocol/tools.ts b/src/protocol/tools.ts new file mode 100644 index 0000000..7b47240 --- /dev/null +++ b/src/protocol/tools.ts @@ -0,0 +1,187 @@ +import { z } from 'zod'; + +import { RiskLevel } from './tools-enums.js'; + +// ============================================================ +// Common Tool Input Schemas +// These schemas define the input parameters for tools and are +// shared between droid-core (execution) and component-library (rendering) +// ============================================================ + +/** + * Edit tool input (Anthropic-style find/replace) + */ +export const EditToolInputSchema = z.object({ + file_path: z.string(), + old_str: z.string(), + new_str: z.string(), + change_all: z.boolean().optional(), +}); + +/** + * Create tool input + */ +export const CreateToolInputSchema = z.object({ + file_path: z.string(), + content: z.string(), +}); + +/** + * Read tool input + */ +export const ReadToolInputSchema = z.object({ + file_path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), + image_quality: z.enum(['default', 'high']).optional(), +}); + +/** + * Execute tool input + */ +export const ExecuteToolInputSchema = z.object({ + summary: z.string().optional(), + command: z.string(), + timeout: z.number().optional(), + riskLevel: z.nativeEnum(RiskLevel).optional(), + riskLevelReason: z.string().optional(), + fireAndForget: z.boolean().optional(), +}); + +/** + * Glob tool input + */ +export const GlobToolInputSchema = z.object({ + patterns: z.array(z.string()).optional(), + folder: z.string().optional(), + excludePatterns: z.array(z.string()).optional(), +}); + +/** + * Grep tool input + */ +export const GrepToolInputSchema = z.object({ + pattern: z.string(), + path: z.string().optional(), + glob_pattern: z.string().optional(), + case_insensitive: z.boolean().optional(), + output_mode: z.enum(['file_paths', 'content']).optional(), + context: z.number().optional(), + context_before: z.number().optional(), + context_after: z.number().optional(), + line_numbers: z.boolean().optional(), + head_limit: z.number().optional(), +}); + +/** + * LS tool input + */ +export const LSToolInputSchema = z.object({ + directory_path: z.string().optional(), + ignorePatterns: z.array(z.string()).optional(), +}); + +/** + * WebSearch tool input + */ +export const WebSearchToolInputSchema = z.object({ + query: z.string(), + numResults: z.number().optional(), + includeDomains: z.array(z.string()).optional(), + excludeDomains: z.array(z.string()).optional(), + category: z.string().optional(), +}); + +/** + * FetchUrl tool input + */ +export const FetchUrlToolInputSchema = z.object({ + url: z.string(), +}); + +/** + * Task (subagent) tool input + */ +export const TaskToolInputSchema = z.object({ + subagent_type: z + .string() + .describe( + 'The custom droid name/identifier to run (must match an available droid; do not guess)' + ), + description: z + .string() + .describe('A short (3-5 word) description of the task (used in UI)'), + prompt: z + .string() + .describe('The full task prompt for the subagent to execute'), +}); + +/** + * TodoWrite tool input + */ +export const TodoWriteToolInputSchema = z.object({ + todos: z.string(), +}); + +/** + * ExitSpecMode tool input + */ +export const ExitSpecModeToolInputSchema = z.object({ + plan: z.string(), + title: z.string().optional(), +}); + +/** + * Skill tool input + */ +export const SkillToolInputSchema = z.object({ + skill: z.string(), +}); + +/** + * ApplyPatch tool input + */ +export const ApplyPatchToolInputSchema = z.object({ + file_path: z.string(), + patch: z.string(), +}); + +/** + * ProposeMission tool input + */ +export const ProposeMissionToolInputSchema = z.object({ + proposal: z.string(), + title: z.string().optional(), +}); + +// ============================================================ +// Common Tool Result Schemas +// These define the shape of data returned by tools +// ============================================================ + +/** + * Diff line for Edit/Create tool results + */ +export const DiffLineSchema = z.object({ + type: z.enum(['added', 'removed', 'unchanged', 'context']), + content: z.string(), + lineNumber: z + .object({ + old: z.number().optional(), + new: z.number().optional(), + }) + .optional(), +}); + +/** + * Common result payload for file operation tools (Edit, Create, ApplyPatch) + */ +export const FileOperationResultSchema = z.object({ + success: z.boolean().optional(), + diff: z.string().optional(), + diffLines: z.array(DiffLineSchema).optional(), + content: z.string().optional(), + message: z.string().optional(), + file_path: z.string().optional(), + filePath: z.string().optional(), +}); diff --git a/src/protocol/updater.ts b/src/protocol/updater.ts new file mode 100644 index 0000000..850f8b5 --- /dev/null +++ b/src/protocol/updater.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +/** + * Schema for a resolved binary download plan. + * Contains a version and fully-qualified URLs for the binary and its checksum. + * Used by both the backend API response and internal provisioning. + */ +export const BinaryDownloadPlanSchema = z.object({ + version: z.string(), + binaryUrl: z.string(), + checksumUrl: z.string(), +}); diff --git a/src/protocol/usage.ts b/src/protocol/usage.ts new file mode 100644 index 0000000..2782d4e --- /dev/null +++ b/src/protocol/usage.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +/** + * Token usage schema for tracking LLM token consumption. + */ +export const TokenUsageSchema = z.object({ + inputTokens: z.number().int(), + outputTokens: z.number().int(), + cacheCreationInputTokens: z.number().int(), + cacheReadInputTokens: z.number().int(), + thinkingTokens: z.number().int(), + factoryCredits: z.number().optional(), +}); diff --git a/tests/protocol-aux-leaves.test.ts b/tests/protocol-aux-leaves.test.ts new file mode 100644 index 0000000..38abbcf --- /dev/null +++ b/tests/protocol-aux-leaves.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; + +import { AutomationPrivacyLevel as TopLevelAutomationPrivacyLevel } from '../src/protocol/automations-enums.js'; +import { AutomationPrivacyLevel as DaemonAutomationPrivacyLevel } from '../src/protocol/daemon/automations-enums.js'; +import { + AutomationCreatedBySchema, + AutomationsHeartbeatSchema, + BinaryDownloadPlanSchema, + DiffLineSchema, + EditToolInputSchema, + EffectiveFactoryRouterModelSchema, + ExecuteToolInputSchema, + FileOperationResultSchema, + GenericToolExecutionOutputSchema, + RiskLevel, + SessionSettingsSchema, + SessionSummaryEventSchema, + SessionTitleAutoStage, + UserModelPolicySchema, + usage, +} from '../src/protocol/index.js'; + +describe('protocol/automations (TIER-4d)', () => { + it('parses a sample HEARTBEAT.md frontmatter object', () => { + const parsed = AutomationsHeartbeatSchema.parse({ + id: '11111111-1111-4111-8111-111111111111', + name: 'Daily health-check', + description: 'Pings the API', + schedule: '0 9 * * 1-5', + model: 'gpt-5', + tags: ['ops'], + paused: false, + privacyLevel: 'organization', + createdBy: { name: 'Ada', email: 'ada@example.com' }, + forkedFrom: 'parent-uuid', + }); + expect(parsed.name).toBe('Daily health-check'); + expect(parsed.privacyLevel).toBe('organization'); + }); + + it('exposes AutomationCreatedBySchema', () => { + const created = AutomationCreatedBySchema.parse({ name: 'Ada' }); + expect(created.name).toBe('Ada'); + }); + + it('consolidates AutomationPrivacyLevel between top-level and daemon paths', () => { + expect(DaemonAutomationPrivacyLevel).toBe(TopLevelAutomationPrivacyLevel); + expect(DaemonAutomationPrivacyLevel.Private).toBe('private'); + }); +}); + +describe('protocol/session-summary (TIER-4d)', () => { + it('parses a sample session_start event', () => { + const event = SessionSummaryEventSchema.parse({ + type: 'session_start', + id: 'sess-1', + title: 'Initial title', + sessionTitle: 'Working on TIER-4d', + isSessionTitleManuallySet: true, + sessionTitleAutoStage: SessionTitleAutoStage.FirstMessage, + owner: 'user@example.com', + parent: null, + version: 1, + cwd: '/tmp/work', + }); + expect(event.type).toBe('session_start'); + expect(event.sessionTitleAutoStage).toBe('first_message'); + }); +}); + +describe('protocol/session-tools (TIER-4d)', () => { + it('accepts a string, object, or array for generic tool output', () => { + expect(GenericToolExecutionOutputSchema.parse('ok')).toBe('ok'); + expect(GenericToolExecutionOutputSchema.parse({ a: 1 })).toEqual({ a: 1 }); + expect(GenericToolExecutionOutputSchema.parse(['a', { b: 2 }])).toEqual([ + 'a', + { b: 2 }, + ]); + }); +}); + +describe('protocol/session-settings (TIER-4d)', () => { + it('parses a minimal SessionSettings payload', () => { + const settings = SessionSettingsSchema.parse({}); + expect(settings).toBeDefined(); + }); + + it('parses an EffectiveFactoryRouterModel payload', () => { + const eff = EffectiveFactoryRouterModelSchema.parse({ + modelId: 'gpt-5', + apiProvider: 'openai', + reasoningEffort: 'medium', + }); + expect(eff.modelId).toBe('gpt-5'); + }); +}); + +describe('protocol/policy (TIER-4d)', () => { + it('parses a UserModelPolicy with real ModelID values and drops unknowns', () => { + const parsed = UserModelPolicySchema.parse({ + allowedModelIds: ['claude-opus-4-7', 'definitely-not-a-real-model-id'], + blockedModelIds: ['gpt-5.4', 'also-not-real'], + }); + expect(parsed.allowedModelIds).toEqual(['claude-opus-4-7']); + expect(parsed.blockedModelIds).toEqual(['gpt-5.4']); + }); +}); + +describe('protocol/tools (TIER-4d)', () => { + it('parses an EditToolInput', () => { + const input = EditToolInputSchema.parse({ + file_path: '/tmp/x.ts', + old_str: 'foo', + new_str: 'bar', + }); + expect(input.file_path).toBe('/tmp/x.ts'); + }); + + it('exposes RiskLevel members', () => { + expect(RiskLevel.LOW).toBe('low'); + expect(RiskLevel.MEDIUM).toBe('medium'); + expect(RiskLevel.HIGH).toBe('high'); + }); + + it('parses an ExecuteToolInput with a risk level', () => { + const input = ExecuteToolInputSchema.parse({ + command: 'ls', + riskLevel: RiskLevel.LOW, + riskLevelReason: 'read-only', + }); + expect(input.riskLevel).toBe('low'); + }); + + it('parses a DiffLineSchema and FileOperationResultSchema', () => { + const line = DiffLineSchema.parse({ type: 'added', content: '+ hello' }); + expect(line.type).toBe('added'); + const result = FileOperationResultSchema.parse({ + success: true, + diff: 'whatever', + }); + expect(result.success).toBe(true); + }); +}); + +describe('protocol/updater (TIER-4d)', () => { + it('parses a BinaryDownloadPlan', () => { + const plan = BinaryDownloadPlanSchema.parse({ + version: '1.2.3', + binaryUrl: 'https://example/bin', + checksumUrl: 'https://example/bin.sha256', + }); + expect(plan.version).toBe('1.2.3'); + }); +}); + +describe('protocol/usage (TIER-4d)', () => { + it('parses a TokenUsage payload via the usage namespace', () => { + const tokens = usage.TokenUsageSchema.parse({ + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + thinkingTokens: 0, + }); + expect(tokens.inputTokens).toBe(100); + }); +}); diff --git a/tests/protocol-daemon-dir.test.ts b/tests/protocol-daemon-dir.test.ts new file mode 100644 index 0000000..6ba0a86 --- /dev/null +++ b/tests/protocol-daemon-dir.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { + ConnectionState, + CreateTerminalError, + DaemonConnectionEvent, + DaemonConnectionMethod, + DaemonDroidEvent, + DaemonDroidMethod, + DaemonGetGitDiffUnavailableReason, + DaemonManagementMethod, + DaemonRelayEvent, + DaemonRelayMethod, + DaemonSettingsMethod, + DaemonSpecificNotificationType, + DaemonTerminalEvent, + DaemonTerminalMethod, + McpConfigSource, + SessionLoadState, + SessionSearchDocKind, +} from '../src/protocol/daemon/index.js'; + +describe('protocol/daemon/enums', () => { + it('exposes DaemonConnectionMethod values', () => { + expect(DaemonConnectionMethod.AUTHENTICATE).toBe('daemon.authenticate'); + expect(DaemonConnectionMethod.LOGOUT).toBe('daemon.logout'); + }); + + it('exposes DaemonConnectionEvent values', () => { + expect(DaemonConnectionEvent.CONNECTION_STATUS).toBe( + 'daemon.connection_status' + ); + }); + + it('exposes ConnectionState values', () => { + expect(ConnectionState.Disconnected).toBe('disconnected'); + expect(ConnectionState.Connecting).toBe('connecting'); + expect(ConnectionState.Connected).toBe('connected'); + expect(ConnectionState.Closing).toBe('closing'); + expect(ConnectionState.LookingUpMachine).toBe('looking_up_machine'); + expect(ConnectionState.StartingMachine).toBe('starting_machine'); + expect(ConnectionState.LoadingSession).toBe('loading_session'); + expect(ConnectionState.AuthenticationFailed).toBe('authentication_failed'); + }); + + it('exposes DaemonTerminalMethod values', () => { + expect(DaemonTerminalMethod.CREATE).toBe('daemon.create_terminal'); + expect(DaemonTerminalMethod.LIST).toBe('daemon.list_terminals'); + }); + + it('exposes DaemonTerminalEvent values', () => { + expect(DaemonTerminalEvent.DATA).toBe('daemon.terminal_data'); + expect(DaemonTerminalEvent.EXIT).toBe('daemon.terminal_exit'); + expect(DaemonTerminalEvent.ERROR).toBe('daemon.terminal_error'); + }); + + it('exposes DaemonDroidMethod values', () => { + expect(DaemonDroidMethod.INITIALIZE_SESSION).toBe( + 'daemon.initialize_session' + ); + expect(DaemonDroidMethod.GET_CONTEXT_BREAKDOWN).toBe( + 'daemon.get_context_breakdown' + ); + }); + + it('exposes DaemonGetGitDiffUnavailableReason values', () => { + expect(DaemonGetGitDiffUnavailableReason.MissingSessionCwd).toBe( + 'missing_session_cwd' + ); + expect(DaemonGetGitDiffUnavailableReason.Unknown).toBe('unknown'); + }); + + it('exposes DaemonSettingsMethod values', () => { + expect(DaemonSettingsMethod.GET_DEFAULT_SETTINGS).toBe( + 'daemon.get_default_settings' + ); + expect(DaemonSettingsMethod.UPDATE_SESSION_DEFAULTS).toBe( + 'daemon.update_session_defaults' + ); + }); + + it('exposes DaemonManagementMethod values', () => { + expect(DaemonManagementMethod.TRIGGER_UPDATE).toBe('daemon.trigger_update'); + expect(DaemonManagementMethod.INSTALL_SSH_KEY).toBe( + 'daemon.install_ssh_key' + ); + }); + + it('exposes DaemonRelayMethod values', () => { + expect(DaemonRelayMethod.START).toBe('daemon.relay.start'); + expect(DaemonRelayMethod.STOP).toBe('daemon.relay.stop'); + expect(DaemonRelayMethod.GET_STATUS).toBe('daemon.relay.get_status'); + }); + + it('exposes DaemonRelayEvent values', () => { + expect(DaemonRelayEvent.STATUS_CHANGED).toBe('daemon.relay.status_changed'); + }); + + it('exposes DaemonSpecificNotificationType values', () => { + expect(DaemonSpecificNotificationType.SESSION_INACTIVITY).toBe( + 'session_inactivity' + ); + expect(DaemonSpecificNotificationType.CHILD_SESSION_AVAILABLE).toBe( + 'child_session_available' + ); + }); + + it('exposes DaemonDroidEvent values', () => { + expect(DaemonDroidEvent.SESSION_NOTIFICATION).toBe( + 'daemon.session_notification' + ); + expect(DaemonDroidEvent.REQUEST_PERMISSION).toBe( + 'daemon.request_permission' + ); + expect(DaemonDroidEvent.ASK_USER).toBe('daemon.ask_user'); + }); + + it('exposes CreateTerminalError values', () => { + expect(CreateTerminalError.TerminalIdExists).toBe('TerminalIdExists'); + }); + + it('exposes McpConfigSource values', () => { + expect(McpConfigSource.User).toBe('user'); + expect(McpConfigSource.Project).toBe('project'); + }); + + it('exposes SessionLoadState values', () => { + expect(SessionLoadState.NotLoaded).toBe('NOT_LOADED'); + expect(SessionLoadState.Loaded).toBe('LOADED'); + expect(SessionLoadState.Loading).toBe('LOADING'); + }); + + it('exposes SessionSearchDocKind values', () => { + expect(SessionSearchDocKind.MessageText).toBe('message_text'); + expect(SessionSearchDocKind.Document).toBe('document'); + expect(SessionSearchDocKind.ToolUse).toBe('tool_use'); + expect(SessionSearchDocKind.ToolResult).toBe('tool_result'); + }); +}); + +describe('protocol/daemon namespace re-export', () => { + it('is exposed via the daemon namespace from src/protocol/index.ts', async () => { + const protocol = await import('../src/protocol/index.js'); + expect(protocol.daemon.DaemonConnectionMethod.AUTHENTICATE).toBe( + 'daemon.authenticate' + ); + expect(protocol.daemon.SessionLoadState.Loaded).toBe('LOADED'); + }); +}); diff --git a/tests/protocol-daemon-droid.test.ts b/tests/protocol-daemon-droid.test.ts new file mode 100644 index 0000000..af7c262 --- /dev/null +++ b/tests/protocol-daemon-droid.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; + +import * as daemonModule from '../src/protocol/daemon/index.js'; +import { + ChildSessionAvailableNotificationSchema, + DaemonAddUserMessageRequestParamsSchema, + DaemonAddUserMessageRequestSchema, + DaemonArchiveSessionRequestSchema, + DaemonAskUserSchema, + DaemonCloseSessionRequestSchema, + DaemonCompactSessionRequestSchema, + DaemonCreatePRRequestSchema, + DaemonExecuteRewindRequestSchema, + DaemonForkSessionRequestSchema, + DaemonGenerateSemanticDiffRequestSchema, + DaemonGetGitDiffDataSchema, + DaemonGetGitDiffRequestSchema, + DaemonGetGitDiffResultSchema, + DaemonGetLoopStatusRequestSchema, + DaemonGetProxyTokenResponseSchema, + DaemonGetProxyTokenResultSchema, + DaemonGetRewindInfoRequestSchema, + DaemonGetSemanticDiffCacheRequestSchema, + DaemonGetSessionMessagesRequestSchema, + DaemonGetWorkspaceFileContentRequestSchema, + DaemonGitCommitRequestSchema, + DaemonGitPushRequestSchema, + DaemonInitializeSessionRequestParamsSchema, + DaemonInitializeSessionRequestSchema, + DaemonInterruptSessionRequestSchema, + DaemonListAvailableSessionsRequestSchema, + DaemonListFilesRequestSchema, + DaemonListOpenedSessionsRequestSchema, + DaemonLoadSessionRequestParamsSchema, + DaemonLoadSessionRequestSchema, + DaemonRenameSessionRequestSchema, + DaemonRequestPermissionSchema, + DaemonRequestSchema, + DaemonSaveSemanticDiffCacheRequestSchema, + DaemonSearchFilesRequestSchema, + DaemonSearchSessionsRequestSchema, + DaemonSessionNotificationSchema, + DaemonStartLoopRequestSchema, + DaemonStopLoopRequestSchema, + DaemonUnarchiveSessionRequestSchema, + DaemonValidateWorkingDirectoryRequestSchema, + DaemonWarmupCacheRequestSchema, + SessionInactivityNotificationSchema, + SessionUnsubscribedNotificationSchema, +} from '../src/protocol/daemon/index.js'; + +const baseEnvelope = { + jsonrpc: '2.0' as const, + factoryApiVersion: '1.0.0', + type: 'request' as const, + id: 'req-1', +}; + +describe('protocol/daemon/droid representative schemas', () => { + it('exports core session lifecycle request schemas', () => { + expect(DaemonInitializeSessionRequestSchema).toBeDefined(); + expect(DaemonLoadSessionRequestSchema).toBeDefined(); + expect(DaemonAddUserMessageRequestSchema).toBeDefined(); + expect(DaemonInterruptSessionRequestSchema).toBeDefined(); + expect(DaemonCloseSessionRequestSchema).toBeDefined(); + expect(DaemonRenameSessionRequestSchema).toBeDefined(); + expect(DaemonArchiveSessionRequestSchema).toBeDefined(); + expect(DaemonUnarchiveSessionRequestSchema).toBeDefined(); + expect(DaemonForkSessionRequestSchema).toBeDefined(); + expect(DaemonCompactSessionRequestSchema).toBeDefined(); + }); + + it('exports file/search/listing request schemas', () => { + expect(DaemonListFilesRequestSchema).toBeDefined(); + expect(DaemonSearchFilesRequestSchema).toBeDefined(); + expect(DaemonSearchSessionsRequestSchema).toBeDefined(); + expect(DaemonListAvailableSessionsRequestSchema).toBeDefined(); + expect(DaemonListOpenedSessionsRequestSchema).toBeDefined(); + expect(DaemonGetSessionMessagesRequestSchema).toBeDefined(); + expect(DaemonGetWorkspaceFileContentRequestSchema).toBeDefined(); + }); + + it('exports git/PR/semantic diff request schemas', () => { + expect(DaemonGetGitDiffRequestSchema).toBeDefined(); + expect(DaemonGitPushRequestSchema).toBeDefined(); + expect(DaemonGitCommitRequestSchema).toBeDefined(); + expect(DaemonCreatePRRequestSchema).toBeDefined(); + expect(DaemonGetSemanticDiffCacheRequestSchema).toBeDefined(); + expect(DaemonSaveSemanticDiffCacheRequestSchema).toBeDefined(); + expect(DaemonGenerateSemanticDiffRequestSchema).toBeDefined(); + }); + + it('exports rewind / loop / validation request schemas', () => { + expect(DaemonGetRewindInfoRequestSchema).toBeDefined(); + expect(DaemonExecuteRewindRequestSchema).toBeDefined(); + expect(DaemonStartLoopRequestSchema).toBeDefined(); + expect(DaemonStopLoopRequestSchema).toBeDefined(); + expect(DaemonGetLoopStatusRequestSchema).toBeDefined(); + expect(DaemonValidateWorkingDirectoryRequestSchema).toBeDefined(); + expect(DaemonWarmupCacheRequestSchema).toBeDefined(); + }); + + it('parses a representative INITIALIZE_SESSION request', () => { + const req = DaemonInitializeSessionRequestSchema.parse({ + ...baseEnvelope, + method: 'daemon.initialize_session', + params: { token: 'tok', machineId: 'm1', cwd: '/tmp' }, + }); + expect(req.method).toBe('daemon.initialize_session'); + }); +}); + +describe('protocol/daemon/droid secret-bearing schemas', () => { + it('DaemonInitializeSessionRequestParamsSchema requires a token', () => { + const parsed = DaemonInitializeSessionRequestParamsSchema.parse({ + token: 'jwt-token', + machineId: 'm1', + cwd: '/tmp', + }); + expect(parsed.token).toBe('jwt-token'); + + const missing = DaemonInitializeSessionRequestParamsSchema.safeParse({ + machineId: 'm1', + cwd: '/tmp', + }); + expect(missing.success).toBe(false); + }); + + it('DaemonLoadSessionRequestParamsSchema requires a token', () => { + const parsed = DaemonLoadSessionRequestParamsSchema.parse({ + token: 'jwt-token', + sessionId: 'sess-1', + }); + expect(parsed.token).toBe('jwt-token'); + + const missing = DaemonLoadSessionRequestParamsSchema.safeParse({ + sessionId: 'sess-1', + }); + expect(missing.success).toBe(false); + }); + + it('DaemonGetProxyTokenResultSchema carries a token string', () => { + const parsed = DaemonGetProxyTokenResultSchema.parse({ token: 'proxy-1' }); + expect(parsed.token).toBe('proxy-1'); + + const response = DaemonGetProxyTokenResponseSchema.parse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'response', + id: 'req-1', + result: { token: 'proxy-2' }, + }); + const result = (response as { result?: { token?: string } }).result; + expect(result?.token).toBe('proxy-2'); + }); +}); + +describe('protocol/daemon/droid notification schemas', () => { + it('parses SessionInactivityNotificationSchema', () => { + const parsed = SessionInactivityNotificationSchema.parse({ + type: 'session_inactivity', + message: 'idle', + timestamp: 1700000000, + timeoutSeconds: 600, + }); + expect(parsed.type).toBe('session_inactivity'); + expect(parsed.timeoutSeconds).toBe(600); + }); + + it('parses SessionUnsubscribedNotificationSchema', () => { + const parsed = SessionUnsubscribedNotificationSchema.parse({ + type: 'session_unsubscribed', + message: 'another client took over', + }); + expect(parsed.type).toBe('session_unsubscribed'); + }); + + it('parses ChildSessionAvailableNotificationSchema', () => { + const parsed = ChildSessionAvailableNotificationSchema.parse({ + type: 'child_session_available', + childSessionId: 'child-1', + timestamp: 1700000001, + }); + expect(parsed.childSessionId).toBe('child-1'); + }); +}); + +describe('protocol/daemon/droid DaemonRequestSchema discriminated union', () => { + it('round-trips a droid.ts method (close_session)', () => { + const req = DaemonRequestSchema.parse({ + ...baseEnvelope, + method: 'daemon.close_session', + params: { sessionId: 'sess-1' }, + }); + expect(req.method).toBe('daemon.close_session'); + }); + + it('round-trips a mcp.ts sibling method (get_mcp_config)', () => { + const req = DaemonRequestSchema.parse({ + ...baseEnvelope, + method: 'daemon.get_mcp_config', + params: {}, + }); + expect(req.method).toBe('daemon.get_mcp_config'); + }); + + it('round-trips a plugins.ts sibling method (list_marketplaces)', () => { + const req = DaemonRequestSchema.parse({ + ...baseEnvelope, + method: 'daemon.list_marketplaces', + params: { sessionId: 'sess-1' }, + }); + expect(req.method).toBe('daemon.list_marketplaces'); + }); + + it('rejects an unknown method', () => { + const result = DaemonRequestSchema.safeParse({ + ...baseEnvelope, + method: 'daemon.not_a_real_method', + params: {}, + }); + expect(result.success).toBe(false); + }); +}); + +describe('protocol/daemon/droid DaemonGetGitDiffResultSchema legacy preprocess', () => { + const currentShapeData = { + diff: 'diff-text', + branch: 'feature', + baseBranch: 'main', + files: [{ path: 'a.ts', additions: 1, deletions: 2, status: 'modified' }], + totalAdditions: 1, + totalDeletions: 2, + remoteUrl: null, + commits: [{ hash: 'abc', message: 'msg' }], + }; + + it('parses the current discriminated-union success shape', () => { + const parsed = DaemonGetGitDiffResultSchema.parse({ + success: true, + data: currentShapeData, + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.branch).toBe('feature'); + expect(parsed.data.unstagedDiff).toBe(''); + expect(parsed.data.unstagedFiles).toEqual([]); + } + }); + + it('parses the unavailable variant', () => { + const parsed = DaemonGetGitDiffResultSchema.parse({ + success: false, + unavailableReason: 'not_git_repository', + unavailableMessage: 'not a git repo', + }); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.unavailableReason).toBe('not_git_repository'); + } + }); + + it('upgrades legacy bare-data shape into the success variant', () => { + const legacyInput = { + diff: 'legacy-diff', + branch: 'legacy-branch', + baseBranch: 'main', + files: [], + totalAdditions: 0, + totalDeletions: 0, + remoteUrl: null, + commits: [], + }; + const parsed = DaemonGetGitDiffResultSchema.parse(legacyInput); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.diff).toBe('legacy-diff'); + expect(parsed.data.unstagedDiff).toBe(''); + expect(parsed.data.unstagedFiles).toEqual([]); + // The legacy data must round-trip through the canonical data schema + const canonical = DaemonGetGitDiffDataSchema.parse(legacyInput); + expect(parsed.data).toEqual(canonical); + } + }); + + it('coerces an unknown unavailableReason to Unknown', () => { + const parsed = DaemonGetGitDiffResultSchema.parse({ + success: false, + unavailableReason: 'totally-not-a-real-reason', + unavailableMessage: 'huh', + }); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.unavailableReason).toBe('unknown'); + } + }); +}); + +describe('protocol/daemon/droid module surface', () => { + it('does NOT export private createResponseSchema helpers', () => { + const keys = Object.keys(daemonModule); + expect(keys).not.toContain('createResponseSchema'); + expect(keys).not.toContain('createAckCompatibleResponseSchema'); + expect( + (daemonModule as Record).createResponseSchema + ).toBeUndefined(); + expect( + (daemonModule as Record) + .createAckCompatibleResponseSchema + ).toBeUndefined(); + }); + + it('exposes the session-notification + permission + ask-user JSON-RPC envelopes', () => { + expect(DaemonSessionNotificationSchema).toBeDefined(); + expect(DaemonRequestPermissionSchema).toBeDefined(); + expect(DaemonAskUserSchema).toBeDefined(); + }); + + it('DaemonAddUserMessageRequestParamsSchema requires sessionId on top of base params', () => { + const fail = DaemonAddUserMessageRequestParamsSchema.safeParse({ + // missing sessionId + text: 'hi', + }); + expect(fail.success).toBe(false); + + const ok = DaemonAddUserMessageRequestParamsSchema.parse({ + sessionId: 'sess-1', + text: 'hi', + }); + expect(ok.sessionId).toBe('sess-1'); + }); +}); diff --git a/tests/protocol-daemon-leaves.test.ts b/tests/protocol-daemon-leaves.test.ts new file mode 100644 index 0000000..c04ed62 --- /dev/null +++ b/tests/protocol-daemon-leaves.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; + +import { + AutomationEntrySchema, + AutomationPrivacyLevel, + DaemonAuthenticateRequestParamsSchema, + DaemonAuthenticateRequestSchema, + DaemonGetDefaultSettingsResultSchema, + DaemonInstallSshKeyResultSchema, + DaemonListAutomationsRequestSchema, + DaemonListMarketplacesResultSchema, + DaemonListSkillsResultSchema, + DaemonRelayStartResultSchema, + DaemonSubmitBugReportRequestSchema, + DaemonTriggerUpdateRequestSchema, + MarketplaceSourceSchema, + McpHttpServerConfigSchema, + McpOAuthConfigSchema, + McpServerConfigSchema, + RedactedMarketplaceSourceSchema, + TerminalRequestSchema, +} from '../src/protocol/daemon/index.js'; + +describe('protocol/daemon/bug-report', () => { + it('parses a valid SUBMIT_BUG_REPORT request', () => { + const req = DaemonSubmitBugReportRequestSchema.parse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.submit_bug_report', + params: { sessionId: 's1', userComment: 'hi' }, + }); + expect(req.method).toBe('daemon.submit_bug_report'); + }); +}); + +describe('protocol/daemon/management', () => { + it('parses a TRIGGER_UPDATE request and an install result', () => { + const req = DaemonTriggerUpdateRequestSchema.parse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.trigger_update', + params: {}, + }); + expect(req.method).toBe('daemon.trigger_update'); + expect(DaemonInstallSshKeyResultSchema.parse({ installed: true })).toEqual({ + installed: true, + }); + }); +}); + +describe('protocol/daemon/skills', () => { + it('accepts a list of skills via SkillInfoSchema', () => { + const result = DaemonListSkillsResultSchema.parse({ + skills: [ + { + name: 'sk-1', + description: 'desc', + location: 'project', + filePath: '/tmp/sk', + }, + ], + }); + expect(result.skills).toHaveLength(1); + }); +}); + +describe('protocol/daemon/relay', () => { + it('parses a relay start result', () => { + const result = DaemonRelayStartResultSchema.parse({ + relayUrl: 'wss://relay.example', + computerId: 'cpu-1', + }); + expect(result.computerId).toBe('cpu-1'); + }); +}); + +describe('protocol/daemon/terminal', () => { + it('discriminates terminal requests by method', () => { + const req = TerminalRequestSchema.parse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.create_terminal', + params: { terminalId: 't1' }, + }); + expect(req.method).toBe('daemon.create_terminal'); + }); +}); + +describe('protocol/daemon/connection', () => { + it('refines token/apiKey union: requires at least one', () => { + const ok = DaemonAuthenticateRequestParamsSchema.safeParse({ + caller: 'cli', + token: 'jwt', + }); + expect(ok.success).toBe(true); + + const okApiKey = DaemonAuthenticateRequestParamsSchema.safeParse({ + caller: 'cli', + apiKey: 'fk-abc', + }); + expect(okApiKey.success).toBe(true); + + const fail = DaemonAuthenticateRequestParamsSchema.safeParse({ + caller: 'cli', + }); + expect(fail.success).toBe(false); + }); + + it('parses a full DaemonAuthenticateRequest', () => { + const req = DaemonAuthenticateRequestSchema.parse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.authenticate', + params: { caller: 'cli', token: 'jwt-token' }, + }); + expect(req.method).toBe('daemon.authenticate'); + }); +}); + +describe('protocol/daemon/mcp', () => { + it('exposes McpOAuthConfigSchema with clientSecret', () => { + const parsed = McpOAuthConfigSchema.parse({ + clientSecret: 'super-secret', + clientId: 'cid', + }); + expect(parsed.clientSecret).toBe('super-secret'); + + const blank = McpOAuthConfigSchema.safeParse({ clientSecret: ' ' }); + expect(blank.success).toBe(false); + }); + + it('round-trips McpHttpServerConfigSchema with auth-bearing headers', () => { + const cfg = McpHttpServerConfigSchema.parse({ + type: 'http', + url: 'https://mcp.example/sse', + headers: { Authorization: 'Bearer s3cret' }, + oauth: { clientSecret: 'cs' }, + }); + expect(cfg.headers?.Authorization).toBe('Bearer s3cret'); + expect(cfg.oauth?.clientSecret).toBe('cs'); + expect(cfg.disabled).toBe(false); + }); + + it('McpServerConfigSchema accepts an http variant with headers', () => { + const cfg = McpServerConfigSchema.parse({ + type: 'http', + url: 'https://mcp.example', + headers: { Authorization: 'Bearer x' }, + }); + expect('headers' in cfg).toBe(true); + }); +}); + +describe('protocol/daemon/automations', () => { + it('lists the AutomationPrivacyLevel values', () => { + expect(AutomationPrivacyLevel.Private).toBe('private'); + expect(AutomationPrivacyLevel.Organization).toBe('organization'); + }); + + it('parses an AutomationEntry', () => { + const entry = AutomationEntrySchema.parse({ + id: 'demo', + name: 'Demo', + status: 'active', + isValid: true, + path: '/tmp/automations/demo', + }); + expect(entry.id).toBe('demo'); + }); + + it('parses a LIST_AUTOMATIONS request envelope', () => { + const req = DaemonListAutomationsRequestSchema.parse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.list_automations', + params: {}, + }); + expect(req.method).toBe('daemon.list_automations'); + }); +}); + +describe('protocol/daemon/plugins', () => { + it('exposes MarketplaceSourceSchema with a path for local entries', () => { + const local = MarketplaceSourceSchema.parse({ + source: 'local', + path: '/Users/dev/marketplace', + }); + expect(local.source).toBe('local'); + if (local.source === 'local') { + expect(local.path).toBe('/Users/dev/marketplace'); + } + }); + + it('keeps RedactedMarketplaceSourceSchema separate (no path on local)', () => { + const redacted = RedactedMarketplaceSourceSchema.parse({ source: 'local' }); + expect(redacted.source).toBe('local'); + expect('path' in redacted).toBe(false); + + // The redacted schema must reject a `path` field on the `local` variant + // because keeping filesystem paths on the wire would leak local layout. + // discriminatedUnion is structural-permissive on extra keys, so we instead + // verify the inferred type omits `path` for `local` via the value above. + }); + + it('LIST_MARKETPLACES result uses the redacted variant', () => { + const parsed = DaemonListMarketplacesResultSchema.parse({ + marketplaces: [ + { + name: 'demo', + source: { source: 'local' }, + pluginCount: 2, + autoUpdate: true, + }, + ], + }); + expect(parsed.marketplaces[0].source.source).toBe('local'); + }); +}); + +describe('protocol/daemon/settings', () => { + it('parses a minimal GET_DEFAULT_SETTINGS result', () => { + const result = DaemonGetDefaultSettingsResultSchema.parse({}); + expect(result).toBeDefined(); + }); +}); diff --git a/tests/protocol-general-settings.test.ts b/tests/protocol-general-settings.test.ts new file mode 100644 index 0000000..6cb2775 --- /dev/null +++ b/tests/protocol-general-settings.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest'; + +import { + CURRENT_COMPACTION_MODEL, + FACTORY_ROUTER_GUIDANCE_MAX_LENGTH, + FACTORY_ROUTER_RULE_GUIDANCE_MAX_LENGTH, + FACTORY_ROUTER_RULE_WHEN_MAX_LENGTH, + FACTORY_ROUTER_RULES_MAX_COUNT, +} from '../src/protocol/constants.js'; +import { + AutonomyLevel, + DiffMode, + LogoAnimationMode, + ModelID, + SandboxMode, + SoundFocusMode, + SubagentSoundMode, + TodoDisplayMode, + ToolResultDisplay, +} from '../src/protocol/enums.js'; +import { + FactoryRouterRuleSchema, + GeneralSettingsSchema, + ManagedSettingsBaseSchema, + McpPolicySchema, + MissionPolicySchema, + ModelPolicySchema, + SandboxSettingsSchema, + SESSION_RETENTION_MAX_DAYS, + SESSION_RETENTION_MIN_DAYS, +} from '../src/protocol/general-settings.js'; + +describe('protocol/general-settings — verbatim port', () => { + it('exports ManagedSettingsBaseSchema and GeneralSettingsSchema as zod objects', () => { + expect(typeof ManagedSettingsBaseSchema.parse).toBe('function'); + expect(typeof GeneralSettingsSchema.parse).toBe('function'); + }); + + it('exports router-rule constants verbatim from upstream', () => { + expect(FACTORY_ROUTER_GUIDANCE_MAX_LENGTH).toBe(2000); + expect(FACTORY_ROUTER_RULES_MAX_COUNT).toBe(20); + expect(FACTORY_ROUTER_RULE_WHEN_MAX_LENGTH).toBe(300); + expect(FACTORY_ROUTER_RULE_GUIDANCE_MAX_LENGTH).toBe(600); + }); + + it('exports session-retention bounds verbatim from upstream', () => { + expect(SESSION_RETENTION_MIN_DAYS).toBe(14); + expect(SESSION_RETENTION_MAX_DAYS).toBe(365); + }); + + it('exposes the new enums verbatim from upstream', () => { + expect(DiffMode.Github).toBe('github'); + expect(DiffMode.Unified).toBe('unified'); + expect(SoundFocusMode.Always).toBe('always'); + expect(SoundFocusMode.Focused).toBe('focused'); + expect(SoundFocusMode.Unfocused).toBe('unfocused'); + expect(TodoDisplayMode.Inline).toBe('inline'); + expect(TodoDisplayMode.Pinned).toBe('pinned'); + expect(SubagentSoundMode.Off).toBe('off'); + expect(SubagentSoundMode.Quiet).toBe('quiet'); + expect(SubagentSoundMode.Inherit).toBe('inherit'); + expect(LogoAnimationMode.Once).toBe('once'); + expect(LogoAnimationMode.Always).toBe('always'); + expect(LogoAnimationMode.Off).toBe('off'); + expect(ToolResultDisplay.Expanded).toBe('expanded'); + expect(ToolResultDisplay.Compact).toBe('compact'); + }); + + it('ModelPolicySchema accepts the full upstream shape and tolerates unknown ids', () => { + const parsed = ModelPolicySchema.parse({ + allowedModelIds: [ModelID.CLAUDE_OPUS_4_7, 'not-a-real-model'], + blockedModelIds: [ModelID.GPT_5_5], + allowCustomModels: true, + allowedBaseUrls: ['https://example.com'], + allowAllFactoryModels: false, + isFastModelsAllowed: true, + }); + expect(parsed.allowedModelIds).toEqual([ModelID.CLAUDE_OPUS_4_7]); + expect(parsed.blockedModelIds).toEqual([ModelID.GPT_5_5]); + expect(parsed.allowCustomModels).toBe(true); + }); + + it('McpPolicySchema defaults enabled to false and accepts allowlist', () => { + expect(McpPolicySchema.parse({})).toEqual({ enabled: false }); + expect( + McpPolicySchema.parse({ enabled: true, allowlist: ['mcp-a'] }) + ).toEqual({ + enabled: true, + allowlist: ['mcp-a'], + }); + }); + + it('MissionPolicySchema defaults restrictedAccess to false', () => { + expect(MissionPolicySchema.parse({})).toEqual({ restrictedAccess: false }); + }); + + it('SandboxSettingsSchema accepts the full nested shape', () => { + const parsed = SandboxSettingsSchema.parse({ + enabled: true, + mode: SandboxMode.WholeProcess, + filesystem: { + allowWrite: ['/tmp/a'], + allowRead: ['/'], + denyWrite: ['/etc'], + denyRead: ['/secrets'], + }, + network: { + allowedDomains: ['example.com'], + allowUnixSockets: ['/var/run/docker.sock'], + allowAllUnixSockets: false, + allowLocalBinding: true, + httpProxyPort: 8080, + socksProxyPort: 1080, + }, + }); + expect(parsed.mode).toBe('whole-process'); + expect(parsed.filesystem?.allowRead).toEqual(['/']); + expect(parsed.network?.httpProxyPort).toBe(8080); + }); + + it('FactoryRouterRuleSchema enforces guidance and when length caps', () => { + expect( + FactoryRouterRuleSchema.parse({ + when: 'short condition', + guidance: 'do this', + }) + ).toEqual({ when: 'short condition', guidance: 'do this' }); + + const tooLongWhen = 'x'.repeat(FACTORY_ROUTER_RULE_WHEN_MAX_LENGTH + 1); + expect( + FactoryRouterRuleSchema.safeParse({ when: tooLongWhen, guidance: 'ok' }) + .success + ).toBe(false); + + const tooLongGuidance = 'y'.repeat( + FACTORY_ROUTER_RULE_GUIDANCE_MAX_LENGTH + 1 + ); + expect( + FactoryRouterRuleSchema.safeParse({ guidance: tooLongGuidance }).success + ).toBe(false); + }); + + it('ManagedSettingsBaseSchema parses a representative payload', () => { + const parsed = ManagedSettingsBaseSchema.parse({ + sessionDefaultSettings: { model: ModelID.CLAUDE_OPUS_4_7 }, + maxAutonomyLevel: AutonomyLevel.Medium, + cloudSessionSync: true, + includeCoAuthoredByDroid: false, + modelPolicy: { allowedModelIds: [ModelID.CLAUDE_OPUS_4_7] }, + sessionRetentionDays: 30, + factoryRouterRules: [{ when: 'always', guidance: 'route to opus' }], + }); + expect(parsed.maxAutonomyLevel).toBe('medium'); + expect(parsed.sessionRetentionDays).toBe(30); + expect(parsed.factoryRouterRules).toHaveLength(1); + }); + + it('GeneralSettingsSchema parses a wide payload exercising base + extended fields', () => { + const parsed = GeneralSettingsSchema.parse({ + // base fields + sessionDefaultSettings: { model: ModelID.CLAUDE_OPUS_4_7 }, + maxAutonomyLevel: AutonomyLevel.High, + cloudSessionSync: true, + wikiCloudSync: false, + includeCoAuthoredByDroid: true, + enableDroidShield: true, + ideAutoConnect: false, + commandAllowlist: ['ls'], + commandDenylist: ['rm -rf /'], + modelPolicy: { allowCustomModels: true }, + mcpPolicy: { enabled: true, allowlist: ['linear'] }, + missionPolicy: { restrictedAccess: false }, + enabledPlugins: { plugin1: true }, + sandbox: { enabled: false }, + restrictMemberVisibility: true, + restrictApiKeyCreationToManagers: false, + managedComputersEnabled: false, + managedComputersAllowedEmails: ['ops@example.com'], + byomComputersEnabled: false, + byomComputersAllowedEmails: [], + sessionRetentionDays: 90, + factoryRouterGuidance: 'route to opus for hard tasks', + factoryRouterRules: [{ guidance: 'route to opus' }], + // extended general fields + diffMode: DiffMode.Github, + ideExtensionPromptedAt: { vscode: 1700000000 }, + ideActivationNudgedForVersion: { vscode: '1.0.0' }, + enableCompletionBell: true, + completionSound: 'fx-ok01', + awaitingInputSound: 'fx-ack01', + soundFocusMode: SoundFocusMode.Always, + completionSoundFocusMode: SoundFocusMode.Focused, + awaitingInputSoundFocusMode: SoundFocusMode.Unfocused, + specSaveEnabled: true, + specSaveDir: '/tmp/specs', + todoDisplayMode: TodoDisplayMode.Pinned, + toolResultDisplay: ToolResultDisplay.Expanded, + showThinkingInMainView: false, + keepSystemAwakeDuringMissions: true, + showTokenUsageIndicator: true, + missionOrchestratorModel: ModelID.CLAUDE_OPUS_4_7, + missionOrchestratorReasoningEffort: 'high', + modelFavorites: [ModelID.CLAUDE_OPUS_4_7, ModelID.GPT_5_5], + logoAnimation: LogoAnimationMode.Once, + missionModelSettings: { workerModel: ModelID.GPT_5_5 }, + subagentModelSettings: { lightModel: ModelID.GPT_5_5 }, + statusLine: { command: 'echo hi' }, + theme: 'factory-dark', + overrideTerminalColors: false, + hasSeenMissionOnboarding: true, + worktreeDirectory: '/tmp/wt', + windowZoomLevel: 1.2, + remoteAccessEnabled: false, + llmRequestTimeout: 60000, + subagentInactivityTimeout: 30000, + subagentSounds: SubagentSoundMode.Off, + nerdFont: false, + compactionTokenLimit: 100000, + compactionTokenLimitPerModel: { [ModelID.CLAUDE_OPUS_4_7]: 200000 }, + compactionModel: CURRENT_COMPACTION_MODEL, + }); + expect(parsed.diffMode).toBe('github'); + expect(parsed.compactionModel).toBe('current-model'); + expect(parsed.subagentSounds).toBe('off'); + expect(parsed.statusLine?.command).toBe('echo hi'); + expect(parsed.missionModelSettings?.workerModel).toBe(ModelID.GPT_5_5); + expect(parsed.windowZoomLevel).toBe(1.2); + }); + + it('GeneralSettingsSchema rejects sessionRetentionDays out of range', () => { + expect( + GeneralSettingsSchema.safeParse({ + sessionRetentionDays: SESSION_RETENTION_MIN_DAYS - 1, + }).success + ).toBe(false); + expect( + GeneralSettingsSchema.safeParse({ + sessionRetentionDays: SESSION_RETENTION_MAX_DAYS + 1, + }).success + ).toBe(false); + }); + + it('GeneralSettingsSchema rejects factoryRouterRules above the cap', () => { + const tooMany = Array.from( + { length: FACTORY_ROUTER_RULES_MAX_COUNT + 1 }, + () => ({ + guidance: 'g', + }) + ); + expect( + GeneralSettingsSchema.safeParse({ factoryRouterRules: tooMany }).success + ).toBe(false); + }); + + it('GeneralSettingsSchema rejects an unknown compactionModel sentinel', () => { + expect( + GeneralSettingsSchema.safeParse({ compactionModel: 'not-a-real-model' }) + .success + ).toBe(false); + }); +}); + +describe('public API: protocol namespace exposes GeneralSettingsSchema', () => { + it('protocol.GeneralSettingsSchema is defined and parses an empty payload', async () => { + const { protocol } = await import('../src/index.js'); + expect(protocol.GeneralSettingsSchema).toBeDefined(); + expect(protocol.ManagedSettingsBaseSchema).toBeDefined(); + expect(protocol.ModelPolicySchema).toBeDefined(); + expect(protocol.McpPolicySchema).toBeDefined(); + expect(protocol.MissionPolicySchema).toBeDefined(); + expect(protocol.SandboxSettingsSchema).toBeDefined(); + expect(protocol.FactoryRouterRuleSchema).toBeDefined(); + expect(protocol.GeneralSettingsSchema.parse({})).toEqual({}); + }); +}); diff --git a/tests/protocol-settings-full.test.ts b/tests/protocol-settings-full.test.ts new file mode 100644 index 0000000..f035885 --- /dev/null +++ b/tests/protocol-settings-full.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import { + CURRENT_COMPACTION_MODEL, + FACTORY_ROUTER_MODEL_ID, +} from '../src/protocol/constants.js'; +import { + DaemonUpdateSessionDefaultsRequestSchema, + MarketplaceSourceSchema as DaemonMarketplaceSourceSchema, + RedactedMarketplaceSourceSchema, +} from '../src/protocol/daemon/index.js'; +import { ModelID } from '../src/protocol/enums.js'; +import { + AutonomyModeSchema, + CompactionModelSchema, + MarketplaceSourceSchema, + SessionDefaultSettingsSchema, + SettingsResolutionEventSchema, +} from '../src/protocol/settings.js'; + +describe('protocol/settings (ported gap)', () => { + it('ModelID enum exposes representative members verbatim', () => { + expect(ModelID.CLAUDE_OPUS_4_7).toBe('claude-opus-4-7'); + expect(ModelID.GPT_5_5).toBe('gpt-5.5'); + expect(ModelID.GEMINI_3_PRO).toBe('gemini-3-pro-preview'); + expect(ModelID.FACTORY_ROUTER).toBe('factory-router'); + }); + + it('exports sentinel constants used by CompactionModelSchema', () => { + expect(CURRENT_COMPACTION_MODEL).toBe('current-model'); + expect(FACTORY_ROUTER_MODEL_ID).toBe('factory-router'); + }); + + it('CompactionModelSchema accepts current-model sentinel', () => { + expect(CompactionModelSchema.parse(CURRENT_COMPACTION_MODEL)).toBe( + 'current-model' + ); + }); + + it('CompactionModelSchema accepts a real ModelID value', () => { + expect(CompactionModelSchema.parse(ModelID.CLAUDE_OPUS_4_7)).toBe( + 'claude-opus-4-7' + ); + }); + + it('CompactionModelSchema accepts a `custom:` prefixed model id', () => { + expect(CompactionModelSchema.parse('custom:my-byok-model')).toBe( + 'custom:my-byok-model' + ); + }); + + it('CompactionModelSchema rejects the factory router sentinel', () => { + const result = CompactionModelSchema.safeParse(FACTORY_ROUTER_MODEL_ID); + expect(result.success).toBe(false); + }); + + it('CompactionModelSchema rejects an unknown bare string', () => { + const result = CompactionModelSchema.safeParse('not-a-real-model'); + expect(result.success).toBe(false); + }); + + it('SessionDefaultSettingsSchema parses a minimal payload', () => { + const parsed = SessionDefaultSettingsSchema.parse({ + model: ModelID.GPT_5_5, + autonomyMode: 'normal', + }); + expect(parsed.model).toBe('gpt-5.5'); + }); + + it('MarketplaceSourceSchema parses each discriminated variant', () => { + expect( + MarketplaceSourceSchema.parse({ source: 'github', repo: 'owner/repo' }) + .source + ).toBe('github'); + expect( + MarketplaceSourceSchema.parse({ + source: 'url', + url: 'https://example.com/m', + }).source + ).toBe('url'); + expect( + MarketplaceSourceSchema.parse({ + source: 'local', + path: '/tmp/marketplace', + }).source + ).toBe('local'); + expect( + MarketplaceSourceSchema.parse({ + source: 'git-subdir', + url: 'https://example.com/r.git', + path: 'subdir', + }).source + ).toBe('git-subdir'); + }); + + it('SettingsResolutionEventSchema parses a representative event', () => { + const parsed = SettingsResolutionEventSchema.parse({ + timestamp: new Date().toISOString(), + keys: ['model'], + action: 'set', + source: { type: 'user', filePath: '/u/.factory/settings.json' }, + }); + expect(parsed.action).toBe('set'); + expect(parsed.source.type).toBe('user'); + }); + + it('AutonomyModeSchema accepts all autonomy mode values', () => { + expect(AutonomyModeSchema.parse('normal')).toBe('normal'); + expect(AutonomyModeSchema.parse('auto-high')).toBe('auto-high'); + }); +}); + +describe('protocol/daemon — Settings/ModelID gap fill integration', () => { + it('daemon plugins re-exports the SAME MarketplaceSourceSchema instance as the public settings module', () => { + expect(DaemonMarketplaceSourceSchema).toBe(MarketplaceSourceSchema); + }); + + it('RedactedMarketplaceSourceSchema is intentionally distinct from MarketplaceSourceSchema', () => { + expect(RedactedMarketplaceSourceSchema).not.toBe(MarketplaceSourceSchema); + + const redacted = RedactedMarketplaceSourceSchema.parse({ source: 'local' }); + expect('path' in redacted).toBe(false); + + const full = MarketplaceSourceSchema.parse({ + source: 'local', + path: '/Users/dev/marketplace', + }); + if (full.source === 'local') { + expect(full.path).toBe('/Users/dev/marketplace'); + } + }); + + it('DaemonUpdateSessionDefaultsRequestSchema parses a payload with a real CompactionModel value', () => { + const req = DaemonUpdateSessionDefaultsRequestSchema.parse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.update_session_defaults', + params: { + modelId: ModelID.CLAUDE_OPUS_4_7, + compactionModel: ModelID.CLAUDE_OPUS_4_7, + compactionTokenLimit: 100_000, + }, + }); + expect(req.method).toBe('daemon.update_session_defaults'); + expect(req.params.compactionModel).toBe('claude-opus-4-7'); + }); + + it('DaemonUpdateSessionDefaultsRequestSchema rejects an unknown compactionModel string', () => { + const result = DaemonUpdateSessionDefaultsRequestSchema.safeParse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.update_session_defaults', + params: { + compactionModel: 'not-a-real-model', + }, + }); + expect(result.success).toBe(false); + }); + + it('DaemonUpdateSessionDefaultsRequestSchema accepts all 7 inlined GeneralSettings fields together', () => { + const req = DaemonUpdateSessionDefaultsRequestSchema.parse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.update_session_defaults', + params: { + compactionTokenLimit: 100_000, + compactionTokenLimitPerModel: { [ModelID.CLAUDE_OPUS_4_7]: 200_000 }, + compactionModel: CURRENT_COMPACTION_MODEL, + specSaveDir: '/tmp/specs', + missionOrchestratorModel: ModelID.CLAUDE_OPUS_4_7, + missionOrchestratorReasoningEffort: 'medium', + worktreeDirectory: '/tmp/worktrees', + }, + }); + expect(req.params.compactionTokenLimit).toBe(100_000); + expect( + req.params.compactionTokenLimitPerModel?.[ModelID.CLAUDE_OPUS_4_7] + ).toBe(200_000); + expect(req.params.compactionModel).toBe('current-model'); + expect(req.params.specSaveDir).toBe('/tmp/specs'); + expect(req.params.missionOrchestratorModel).toBe('claude-opus-4-7'); + expect(req.params.missionOrchestratorReasoningEffort).toBe('medium'); + expect(req.params.worktreeDirectory).toBe('/tmp/worktrees'); + }); + + it('DaemonUpdateSessionDefaultsRequestSchema accepts each inlined GeneralSettings field individually optional/missing', () => { + for (const params of [ + { compactionTokenLimit: 50_000 }, + { compactionTokenLimitPerModel: { 'gpt-5.5': 75_000 } }, + { compactionModel: CURRENT_COMPACTION_MODEL }, + { specSaveDir: '/tmp/x' }, + { specSaveDir: null }, + { missionOrchestratorModel: 'gpt-5.5' }, + { missionOrchestratorModel: null }, + { missionOrchestratorReasoningEffort: 'high' }, + { missionOrchestratorReasoningEffort: null }, + { worktreeDirectory: '/tmp/wt' }, + { worktreeDirectory: null }, + {}, + ]) { + const result = DaemonUpdateSessionDefaultsRequestSchema.safeParse({ + jsonrpc: '2.0', + factoryApiVersion: '1.0.0', + type: 'request', + id: 'req-1', + method: 'daemon.update_session_defaults', + params, + }); + expect(result.success, `payload ${JSON.stringify(params)}`).toBe(true); + } + }); +}); diff --git a/tests/public-api-protocol-namespace.test.ts b/tests/public-api-protocol-namespace.test.ts new file mode 100644 index 0000000..b69b60d --- /dev/null +++ b/tests/public-api-protocol-namespace.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { protocol } from '../src/index.js'; +import * as publicApi from '../src/index.js'; + +const PRE_WIRING_PUBLIC_EXPORTS = [ + 'SDK_TAG', + 'SDK_VERSION', + 'ProcessTransport', + 'dispatchNotification', + 'ProtocolEngine', + 'DroidClient', + 'convertNotificationToStreamMessage', + 'DroidMessageType', + 'StreamStateTracker', + 'run', + 'createSdkMcpServer', + 'tool', + 'SdkMcpServer', + 'createSession', + 'resumeSession', + 'DroidSession', + 'listSessions', + 'listMachineTemplates', + 'getMachineTemplate', + 'listComputers', + 'getComputer', + 'createComputer', + 'getComputerByName', + 'updateComputer', + 'deleteComputer', + 'restartComputer', + 'refreshComputer', + 'getComputerMetrics', + 'retryInstallDeps', + 'listRemoteSessions', + 'ComputerSchema', + 'ComputerListResponseSchema', + 'ComputerMetricSchema', + 'ComputerMetricsResponseSchema', + 'MachineTemplateBuildStatusSchema', + 'MachineTemplateSchema', + 'MachineTemplateListResponseSchema', + 'RefreshComputerResponseSchema', + 'RemoteSessionSchema', + 'RemoteSessionListResponseSchema', + 'DaemonClient', + 'connectDaemon', + 'DaemonConnection', + 'resolveWebSocketUrl', + 'ensureLocalDaemon', + 'DaemonSession', + 'WebSocketTransport', + 'MachineType', +]; + +describe('public API: `protocol` namespace', () => { + it('is exported as an object from the package entry point', () => { + expect(protocol).toBeTypeOf('object'); + expect(protocol).not.toBeNull(); + }); + + it('exposes the nested `daemon` namespace with DaemonRequestSchema', () => { + expect(protocol.daemon).toBeTypeOf('object'); + expect(protocol.daemon.DaemonRequestSchema).toBeDefined(); + expect(protocol.daemon.DaemonInitializeSessionRequestSchema).toBeDefined(); + }); + + it('does not flatten daemon-namespaced schemas onto `protocol`', () => { + expect( + (protocol as Record)['DaemonRequestSchema'] + ).toBeUndefined(); + expect( + (protocol as Record)[ + 'DaemonInitializeSessionRequestSchema' + ] + ).toBeUndefined(); + }); + + it('exposes representative flat schemas on the namespace', () => { + expect(protocol.FactoryDroidMessageSchema).toBeDefined(); + expect(protocol.SessionTagSchema).toBeDefined(); + expect(protocol.HostIdSchema).toBeDefined(); + expect(protocol.ModelID).toBeDefined(); + expect(protocol.UserModelPolicySchema).toBeDefined(); + }); + + it('exposes the `usage` sub-namespace with TokenUsageSchema', () => { + expect(protocol.usage).toBeTypeOf('object'); + expect(protocol.usage.TokenUsageSchema).toBeDefined(); + }); + + it('round-trips a parse through a namespace-exposed schema', () => { + const parsed = protocol.usage.TokenUsageSchema.parse({ + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + thinkingTokens: 0, + }); + expect(parsed.inputTokens).toBe(10); + expect(parsed.outputTokens).toBe(20); + }); + + it('round-trips a parse through a flat namespace-exposed schema', () => { + const tag = protocol.SessionTagSchema.parse({ name: 'feature/test' }); + expect(tag.name).toBe('feature/test'); + }); +}); + +describe('public API: backward compatibility guard', () => { + it('preserves all pre-wiring public exports', () => { + const exported = publicApi as unknown as Record; + for (const name of PRE_WIRING_PUBLIC_EXPORTS) { + expect( + exported[name], + `expected ${name} to remain exported` + ).toBeDefined(); + } + }); + + it('still exports the new `protocol` namespace', () => { + const exported = publicApi as unknown as Record; + expect(exported['protocol']).toBeTypeOf('object'); + }); +});