From 3c446b11a4705e874f867ea4b9f9cea796712c45 Mon Sep 17 00:00:00 2001 From: matamune Date: Tue, 12 May 2026 15:15:09 +0000 Subject: [PATCH] Initial codex-flows monorepo --- .github/workflows/publish-codex-flows.yml | 43 + .gitignore | 7 + CODE_OF_CONDUCT.md | 25 + CONTRIBUTING.md | 18 + LICENSE | 176 ++ README.md | 123 + SECURITY.md | 8 + apps/cli/package.json | 24 + apps/cli/src/actions.ts | 113 + apps/cli/src/args.ts | 115 + apps/cli/src/index.ts | 123 + apps/cli/test/args.test.ts | 55 + apps/cli/tsconfig.json | 24 + apps/discord-bridge/package.json | 27 + apps/discord-bridge/src/bridge.ts | 1051 ++++++++ apps/discord-bridge/src/config.ts | 368 +++ apps/discord-bridge/src/console-output.ts | 99 + apps/discord-bridge/src/discord-transport.ts | 380 +++ apps/discord-bridge/src/index.ts | 96 + apps/discord-bridge/src/logger.ts | 71 + apps/discord-bridge/src/pretty-log.ts | 225 ++ apps/discord-bridge/src/runner.ts | 2208 ++++++++++++++++ apps/discord-bridge/src/state.ts | 222 ++ apps/discord-bridge/src/types.ts | 177 ++ apps/discord-bridge/test/bridge.test.ts | 2340 +++++++++++++++++ apps/discord-bridge/test/config.test.ts | 223 ++ .../test/console-output.test.ts | 79 + apps/discord-bridge/test/logger.test.ts | 98 + apps/discord-bridge/test/state.test.ts | 103 + apps/discord-bridge/tsconfig.json | 25 + apps/web/components.json | 23 + apps/web/index.html | 14 + apps/web/package.json | 29 + apps/web/src/App.tsx | 886 +++++++ apps/web/src/components/theme-provider.tsx | 10 + apps/web/src/main.tsx | 12 + apps/web/src/vite-env.d.ts | 1 + apps/web/tsconfig.app.json | 31 + apps/web/tsconfig.json | 16 + apps/web/tsconfig.node.json | 22 + apps/web/vite.config.ts | 36 + bun.lock | 1120 ++++++++ bunfig.toml | 2 + mprocs.yaml | 32 + package.json | 47 + packages/codex-client/README.md | 124 + packages/codex-client/package.json | 71 + packages/codex-client/scripts/pack-dry-run.ts | 67 + .../codex-client/scripts/smoke-exports.ts | 18 + .../src/app-server/browser-client.ts | 133 + .../codex-client/src/app-server/client.ts | 244 ++ .../codex-client/src/app-server/events.ts | 39 + packages/codex-client/src/app-server/flows.ts | 526 ++++ .../app-server/generated/AbsolutePathBuf.ts | 14 + .../src/app-server/generated/AgentPath.ts | 5 + .../generated/ApplyPatchApprovalParams.ts | 21 + .../generated/ApplyPatchApprovalResponse.ts | 6 + .../src/app-server/generated/AuthMode.ts | 8 + .../src/app-server/generated/ClientInfo.ts | 5 + .../generated/ClientNotification.ts | 5 + .../src/app-server/generated/ClientRequest.ts | 105 + .../app-server/generated/CollaborationMode.ts | 10 + .../src/app-server/generated/ContentItem.ts | 6 + .../generated/ConversationGitInfo.ts | 5 + .../generated/ConversationSummary.ts | 8 + .../generated/ExecCommandApprovalParams.ts | 16 + .../generated/ExecCommandApprovalResponse.ts | 6 + .../generated/ExecPolicyAmendment.ts | 12 + .../src/app-server/generated/FileChange.ts | 5 + .../app-server/generated/ForcedLoginMethod.ts | 5 + .../generated/FunctionCallOutputBody.ts | 6 + .../FunctionCallOutputContentItem.ts | 10 + .../generated/FuzzyFileSearchMatchType.ts | 5 + .../generated/FuzzyFileSearchParams.ts | 5 + .../generated/FuzzyFileSearchResponse.ts | 6 + .../generated/FuzzyFileSearchResult.ts | 9 + ...yFileSearchSessionCompletedNotification.ts | 5 + .../FuzzyFileSearchSessionStartParams.ts | 5 + .../FuzzyFileSearchSessionStartResponse.ts | 5 + .../FuzzyFileSearchSessionStopParams.ts | 5 + .../FuzzyFileSearchSessionStopResponse.ts | 5 + .../FuzzyFileSearchSessionUpdateParams.ts | 5 + .../FuzzyFileSearchSessionUpdateResponse.ts | 5 + ...zzyFileSearchSessionUpdatedNotification.ts | 6 + .../generated/GetAuthStatusParams.ts | 5 + .../generated/GetAuthStatusResponse.ts | 6 + .../generated/GetConversationSummaryParams.ts | 6 + .../GetConversationSummaryResponse.ts | 6 + .../generated/GitDiffToRemoteParams.ts | 5 + .../generated/GitDiffToRemoteResponse.ts | 6 + .../src/app-server/generated/GitSha.ts | 5 + .../src/app-server/generated/ImageDetail.ts | 5 + .../generated/InitializeCapabilities.ts | 17 + .../app-server/generated/InitializeParams.ts | 7 + .../generated/InitializeResponse.ts | 20 + .../src/app-server/generated/InputModality.ts | 8 + .../generated/InternalSessionSource.ts | 5 + .../app-server/generated/LocalShellAction.ts | 6 + .../generated/LocalShellExecAction.ts | 5 + .../app-server/generated/LocalShellStatus.ts | 5 + .../src/app-server/generated/MessagePhase.ts | 11 + .../src/app-server/generated/ModeKind.ts | 8 + .../generated/NetworkPolicyAmendment.ts | 6 + .../generated/NetworkPolicyRuleAction.ts | 5 + .../src/app-server/generated/ParsedCommand.ts | 12 + .../src/app-server/generated/Personality.ts | 5 + .../src/app-server/generated/PlanType.ts | 5 + .../generated/RealtimeConversationVersion.ts | 5 + .../generated/RealtimeOutputModality.ts | 5 + .../src/app-server/generated/RealtimeVoice.ts | 5 + .../generated/RealtimeVoicesList.ts | 6 + .../app-server/generated/ReasoningEffort.ts | 8 + .../generated/ReasoningItemContent.ts | 5 + .../ReasoningItemReasoningSummary.ts | 5 + .../app-server/generated/ReasoningSummary.ts | 10 + .../src/app-server/generated/RequestId.ts | 5 + .../src/app-server/generated/Resource.ts | 9 + .../app-server/generated/ResourceContent.ts | 17 + .../app-server/generated/ResourceTemplate.ts | 9 + .../src/app-server/generated/ResponseItem.ts | 17 + .../app-server/generated/ReviewDecision.ts | 10 + .../generated/ServerNotification.ts | 72 + .../src/app-server/generated/ServerRequest.ts | 18 + .../src/app-server/generated/ServiceTier.ts | 5 + .../src/app-server/generated/SessionSource.ts | 7 + .../src/app-server/generated/Settings.ts | 9 + .../app-server/generated/SubAgentSource.ts | 7 + .../src/app-server/generated/ThreadId.ts | 5 + .../app-server/generated/ThreadMemoryMode.ts | 5 + .../src/app-server/generated/Tool.ts | 9 + .../src/app-server/generated/Verbosity.ts | 9 + .../app-server/generated/WebSearchAction.ts | 5 + .../generated/WebSearchContextSize.ts | 5 + .../app-server/generated/WebSearchLocation.ts | 5 + .../src/app-server/generated/WebSearchMode.ts | 5 + .../generated/WebSearchToolConfig.ts | 7 + .../src/app-server/generated/index.ts | 86 + .../generated/serde_json/JsonValue.ts | 5 + .../src/app-server/generated/v2/Account.ts | 6 + .../v2/AccountLoginCompletedNotification.ts | 5 + .../AccountRateLimitsUpdatedNotification.ts | 6 + .../v2/AccountUpdatedNotification.ts | 7 + .../generated/v2/ActivePermissionProfile.ts | 21 + .../v2/ActivePermissionProfileModification.ts | 6 + .../generated/v2/AddCreditsNudgeCreditType.ts | 5 + .../v2/AddCreditsNudgeEmailStatus.ts | 5 + .../v2/AdditionalFileSystemPermissions.ts | 15 + .../v2/AdditionalNetworkPermissions.ts | 5 + .../v2/AdditionalPermissionProfile.ts | 11 + .../v2/AgentMessageDeltaNotification.ts | 5 + .../generated/v2/AnalyticsConfig.ts | 6 + .../app-server/generated/v2/AppBranding.ts | 8 + .../src/app-server/generated/v2/AppInfo.ts | 19 + .../v2/AppListUpdatedNotification.ts | 9 + .../app-server/generated/v2/AppMetadata.ts | 7 + .../src/app-server/generated/v2/AppReview.ts | 5 + .../app-server/generated/v2/AppScreenshot.ts | 5 + .../src/app-server/generated/v2/AppSummary.ts | 8 + .../generated/v2/AppToolApproval.ts | 5 + .../app-server/generated/v2/AppToolsConfig.ts | 6 + .../generated/v2/ApprovalsReviewer.ts | 12 + .../src/app-server/generated/v2/AppsConfig.ts | 8 + .../generated/v2/AppsDefaultConfig.ts | 5 + .../app-server/generated/v2/AppsListParams.ts | 24 + .../generated/v2/AppsListResponse.ts | 14 + .../app-server/generated/v2/AskForApproval.ts | 5 + .../generated/v2/AutoReviewDecisionSource.ts | 8 + .../src/app-server/generated/v2/ByteRange.ts | 5 + .../generated/v2/CancelLoginAccountParams.ts | 5 + .../v2/CancelLoginAccountResponse.ts | 6 + .../generated/v2/CancelLoginAccountStatus.ts | 5 + .../v2/ChatgptAuthTokensRefreshParams.ts | 16 + .../v2/ChatgptAuthTokensRefreshReason.ts | 5 + .../v2/ChatgptAuthTokensRefreshResponse.ts | 5 + .../app-server/generated/v2/CodexErrorInfo.ts | 12 + .../generated/v2/CollabAgentState.ts | 6 + .../generated/v2/CollabAgentStatus.ts | 5 + .../generated/v2/CollabAgentTool.ts | 5 + .../generated/v2/CollabAgentToolCallStatus.ts | 5 + .../v2/CollaborationModeListParams.ts | 8 + .../v2/CollaborationModeListResponse.ts | 9 + .../generated/v2/CollaborationModeMask.ts | 10 + .../app-server/generated/v2/CommandAction.ts | 6 + .../v2/CommandExecOutputDeltaNotification.ts | 30 + .../generated/v2/CommandExecOutputStream.ts | 8 + .../generated/v2/CommandExecParams.ts | 106 + .../generated/v2/CommandExecResizeParams.ts | 18 + .../generated/v2/CommandExecResizeResponse.ts | 8 + .../generated/v2/CommandExecResponse.ts | 24 + .../generated/v2/CommandExecTerminalSize.ts | 16 + .../v2/CommandExecTerminateParams.ts | 13 + .../v2/CommandExecTerminateResponse.ts | 8 + .../generated/v2/CommandExecWriteParams.ts | 22 + .../generated/v2/CommandExecWriteResponse.ts | 8 + .../v2/CommandExecutionApprovalDecision.ts | 7 + ...CommandExecutionOutputDeltaNotification.ts | 5 + .../CommandExecutionRequestApprovalParams.ts | 62 + ...CommandExecutionRequestApprovalResponse.ts | 6 + .../generated/v2/CommandExecutionSource.ts | 5 + .../generated/v2/CommandExecutionStatus.ts | 5 + .../generated/v2/CommandMigration.ts | 5 + .../src/app-server/generated/v2/Config.ts | 24 + .../generated/v2/ConfigBatchWriteParams.ts | 14 + .../src/app-server/generated/v2/ConfigEdit.ts | 7 + .../app-server/generated/v2/ConfigLayer.ts | 7 + .../generated/v2/ConfigLayerMetadata.ts | 6 + .../generated/v2/ConfigLayerSource.ts | 16 + .../generated/v2/ConfigReadParams.ts | 11 + .../generated/v2/ConfigReadResponse.ts | 8 + .../generated/v2/ConfigRequirements.ts | 12 + .../v2/ConfigRequirementsReadResponse.ts | 10 + .../generated/v2/ConfigValueWriteParams.ts | 11 + .../generated/v2/ConfigWarningNotification.ts | 22 + .../generated/v2/ConfigWriteResponse.ts | 12 + .../generated/v2/ConfiguredHookHandler.ts | 5 + .../v2/ConfiguredHookMatcherGroup.ts | 6 + .../v2/ContextCompactedNotification.ts | 8 + .../generated/v2/CreditsSnapshot.ts | 5 + .../v2/DeprecationNoticeNotification.ts | 13 + .../v2/DynamicToolCallOutputContentItem.ts | 5 + .../generated/v2/DynamicToolCallParams.ts | 6 + .../generated/v2/DynamicToolCallResponse.ts | 6 + .../generated/v2/DynamicToolCallStatus.ts | 5 + .../generated/v2/DynamicToolSpec.ts | 6 + .../generated/v2/ErrorNotification.ts | 6 + .../generated/v2/ExecPolicyAmendment.ts | 5 + .../generated/v2/ExperimentalFeature.ts | 37 + .../ExperimentalFeatureEnablementSetParams.ts | 12 + ...xperimentalFeatureEnablementSetResponse.ts | 9 + .../v2/ExperimentalFeatureListParams.ts | 13 + .../v2/ExperimentalFeatureListResponse.ts | 11 + .../generated/v2/ExperimentalFeatureStage.ts | 5 + .../v2/ExternalAgentConfigDetectParams.ts | 13 + .../v2/ExternalAgentConfigDetectResponse.ts | 6 + ...lAgentConfigImportCompletedNotification.ts | 5 + .../v2/ExternalAgentConfigImportParams.ts | 6 + .../v2/ExternalAgentConfigImportResponse.ts | 5 + .../v2/ExternalAgentConfigMigrationItem.ts | 11 + .../ExternalAgentConfigMigrationItemType.ts | 5 + .../generated/v2/FeedbackUploadParams.ts | 5 + .../generated/v2/FeedbackUploadResponse.ts | 5 + .../v2/FileChangeApprovalDecision.ts | 5 + .../v2/FileChangeOutputDeltaNotification.ts | 10 + .../v2/FileChangePatchUpdatedNotification.ts | 6 + .../v2/FileChangeRequestApprovalParams.ts | 18 + .../v2/FileChangeRequestApprovalResponse.ts | 6 + .../generated/v2/FileSystemAccessMode.ts | 5 + .../app-server/generated/v2/FileSystemPath.ts | 7 + .../generated/v2/FileSystemSandboxEntry.ts | 7 + .../generated/v2/FileSystemSpecialPath.ts | 5 + .../generated/v2/FileUpdateChange.ts | 6 + .../generated/v2/FsChangedNotification.ts | 17 + .../app-server/generated/v2/FsCopyParams.ts | 21 + .../app-server/generated/v2/FsCopyResponse.ts | 8 + .../generated/v2/FsCreateDirectoryParams.ts | 17 + .../generated/v2/FsCreateDirectoryResponse.ts | 8 + .../generated/v2/FsGetMetadataParams.ts | 13 + .../generated/v2/FsGetMetadataResponse.ts | 28 + .../generated/v2/FsReadDirectoryEntry.ts | 20 + .../generated/v2/FsReadDirectoryParams.ts | 13 + .../generated/v2/FsReadDirectoryResponse.ts | 13 + .../generated/v2/FsReadFileParams.ts | 13 + .../generated/v2/FsReadFileResponse.ts | 12 + .../app-server/generated/v2/FsRemoveParams.ts | 21 + .../generated/v2/FsRemoveResponse.ts | 8 + .../generated/v2/FsUnwatchParams.ts | 12 + .../generated/v2/FsUnwatchResponse.ts | 8 + .../app-server/generated/v2/FsWatchParams.ts | 17 + .../generated/v2/FsWatchResponse.ts | 13 + .../generated/v2/FsWriteFileParams.ts | 17 + .../generated/v2/FsWriteFileResponse.ts | 8 + .../generated/v2/GetAccountParams.ts | 13 + .../v2/GetAccountRateLimitsResponse.ts | 14 + .../generated/v2/GetAccountResponse.ts | 6 + .../src/app-server/generated/v2/GitInfo.ts | 5 + .../generated/v2/GrantedPermissionProfile.ts | 7 + .../generated/v2/GuardianApprovalReview.ts | 13 + .../v2/GuardianApprovalReviewAction.ts | 9 + .../v2/GuardianApprovalReviewStatus.ts | 8 + .../generated/v2/GuardianCommandSource.ts | 5 + .../generated/v2/GuardianRiskLevel.ts | 8 + .../generated/v2/GuardianUserAuthorization.ts | 8 + .../v2/GuardianWarningNotification.ts | 13 + .../generated/v2/HookCompletedNotification.ts | 6 + .../app-server/generated/v2/HookErrorInfo.ts | 5 + .../app-server/generated/v2/HookEventName.ts | 5 + .../generated/v2/HookExecutionMode.ts | 5 + .../generated/v2/HookHandlerType.ts | 5 + .../app-server/generated/v2/HookMetadata.ts | 10 + .../app-server/generated/v2/HookMigration.ts | 5 + .../generated/v2/HookOutputEntry.ts | 6 + .../generated/v2/HookOutputEntryKind.ts | 5 + .../generated/v2/HookPromptFragment.ts | 5 + .../app-server/generated/v2/HookRunStatus.ts | 5 + .../app-server/generated/v2/HookRunSummary.ts | 13 + .../src/app-server/generated/v2/HookScope.ts | 5 + .../src/app-server/generated/v2/HookSource.ts | 5 + .../generated/v2/HookStartedNotification.ts | 6 + .../generated/v2/HookTrustStatus.ts | 5 + .../app-server/generated/v2/HooksListEntry.ts | 7 + .../generated/v2/HooksListParams.ts | 9 + .../generated/v2/HooksListResponse.ts | 6 + .../generated/v2/ItemCompletedNotification.ts | 10 + ...dianApprovalReviewCompletedNotification.ts | 38 + ...ardianApprovalReviewStartedNotification.ts | 33 + .../generated/v2/ItemStartedNotification.ts | 10 + .../generated/v2/ListMcpServerStatusParams.ts | 19 + .../v2/ListMcpServerStatusResponse.ts | 11 + .../generated/v2/LoginAccountParams.ts | 21 + .../generated/v2/LoginAccountResponse.ts | 17 + .../generated/v2/LogoutAccountResponse.ts | 5 + .../generated/v2/ManagedHooksRequirements.ts | 6 + .../generated/v2/MarketplaceAddParams.ts | 5 + .../generated/v2/MarketplaceAddResponse.ts | 6 + .../generated/v2/MarketplaceInterface.ts | 5 + .../generated/v2/MarketplaceLoadErrorInfo.ts | 6 + .../generated/v2/MarketplaceRemoveParams.ts | 5 + .../generated/v2/MarketplaceRemoveResponse.ts | 6 + .../v2/MarketplaceUpgradeErrorInfo.ts | 5 + .../generated/v2/MarketplaceUpgradeParams.ts | 5 + .../v2/MarketplaceUpgradeResponse.ts | 7 + .../app-server/generated/v2/McpAuthStatus.ts | 5 + .../generated/v2/McpElicitationArrayType.ts | 5 + .../v2/McpElicitationBooleanSchema.ts | 6 + .../generated/v2/McpElicitationBooleanType.ts | 5 + .../generated/v2/McpElicitationConstOption.ts | 5 + .../generated/v2/McpElicitationEnumSchema.ts | 8 + .../McpElicitationLegacyTitledEnumSchema.ts | 6 + .../v2/McpElicitationMultiSelectEnumSchema.ts | 7 + .../v2/McpElicitationNumberSchema.ts | 6 + .../generated/v2/McpElicitationNumberType.ts | 5 + .../generated/v2/McpElicitationObjectType.ts | 5 + .../v2/McpElicitationPrimitiveSchema.ts | 9 + .../generated/v2/McpElicitationSchema.ts | 13 + .../McpElicitationSingleSelectEnumSchema.ts | 7 + .../v2/McpElicitationStringFormat.ts | 5 + .../v2/McpElicitationStringSchema.ts | 7 + .../generated/v2/McpElicitationStringType.ts | 5 + .../v2/McpElicitationTitledEnumItems.ts | 6 + ...pElicitationTitledMultiSelectEnumSchema.ts | 7 + ...ElicitationTitledSingleSelectEnumSchema.ts | 7 + .../v2/McpElicitationUntitledEnumItems.ts | 6 + ...licitationUntitledMultiSelectEnumSchema.ts | 7 + ...icitationUntitledSingleSelectEnumSchema.ts | 6 + .../generated/v2/McpResourceReadParams.ts | 5 + .../generated/v2/McpResourceReadResponse.ts | 6 + .../v2/McpServerElicitationAction.ts | 5 + .../v2/McpServerElicitationRequestParams.ts | 16 + .../v2/McpServerElicitationRequestResponse.ts | 17 + .../generated/v2/McpServerMigration.ts | 5 + ...cpServerOauthLoginCompletedNotification.ts | 5 + .../generated/v2/McpServerOauthLoginParams.ts | 5 + .../v2/McpServerOauthLoginResponse.ts | 5 + .../generated/v2/McpServerRefreshResponse.ts | 5 + .../generated/v2/McpServerStartupState.ts | 5 + .../generated/v2/McpServerStatus.ts | 9 + .../generated/v2/McpServerStatusDetail.ts | 5 + .../v2/McpServerStatusUpdatedNotification.ts | 6 + .../generated/v2/McpServerToolCallParams.ts | 6 + .../generated/v2/McpServerToolCallResponse.ts | 6 + .../generated/v2/McpToolCallError.ts | 5 + .../v2/McpToolCallProgressNotification.ts | 5 + .../generated/v2/McpToolCallResult.ts | 6 + .../generated/v2/McpToolCallStatus.ts | 5 + .../app-server/generated/v2/MemoryCitation.ts | 6 + .../generated/v2/MemoryCitationEntry.ts | 5 + .../generated/v2/MemoryResetResponse.ts | 5 + .../app-server/generated/v2/MergeStrategy.ts | 5 + .../generated/v2/MigrationDetails.ts | 11 + .../v2/MockExperimentalMethodParams.ts | 9 + .../v2/MockExperimentalMethodResponse.ts | 9 + .../src/app-server/generated/v2/Model.ts | 15 + .../generated/v2/ModelAvailabilityNux.ts | 5 + .../generated/v2/ModelListParams.ts | 17 + .../generated/v2/ModelListResponse.ts | 11 + .../v2/ModelProviderCapabilitiesReadParams.ts | 5 + .../ModelProviderCapabilitiesReadResponse.ts | 5 + .../generated/v2/ModelRerouteReason.ts | 5 + .../generated/v2/ModelReroutedNotification.ts | 6 + .../generated/v2/ModelServiceTier.ts | 5 + .../generated/v2/ModelUpgradeInfo.ts | 5 + .../generated/v2/ModelVerification.ts | 5 + .../v2/ModelVerificationNotification.ts | 6 + .../app-server/generated/v2/NetworkAccess.ts | 5 + .../generated/v2/NetworkApprovalContext.ts | 6 + .../generated/v2/NetworkApprovalProtocol.ts | 5 + .../generated/v2/NetworkDomainPermission.ts | 5 + .../generated/v2/NetworkPolicyAmendment.ts | 6 + .../generated/v2/NetworkPolicyRuleAction.ts | 5 + .../generated/v2/NetworkRequirements.ts | 32 + .../v2/NetworkUnixSocketPermission.ts | 5 + .../generated/v2/NonSteerableTurnKind.ts | 5 + .../generated/v2/OverriddenMetadata.ts | 7 + .../generated/v2/PatchApplyStatus.ts | 5 + .../generated/v2/PatchChangeKind.ts | 5 + .../generated/v2/PermissionGrantScope.ts | 5 + .../generated/v2/PermissionProfile.ts | 7 + .../PermissionProfileFileSystemPermissions.ts | 6 + .../v2/PermissionProfileModificationParams.ts | 6 + .../v2/PermissionProfileNetworkPermissions.ts | 5 + .../v2/PermissionProfileSelectionParams.ts | 6 + .../v2/PermissionsRequestApprovalParams.ts | 11 + .../v2/PermissionsRequestApprovalResponse.ts | 11 + .../generated/v2/PlanDeltaNotification.ts | 9 + .../generated/v2/PluginAuthPolicy.ts | 5 + .../generated/v2/PluginAvailability.ts | 5 + .../app-server/generated/v2/PluginDetail.ts | 10 + .../generated/v2/PluginHookSummary.ts | 6 + .../generated/v2/PluginInstallParams.ts | 6 + .../generated/v2/PluginInstallPolicy.ts | 5 + .../generated/v2/PluginInstallResponse.ts | 7 + .../generated/v2/PluginInterface.ts | 35 + .../generated/v2/PluginListMarketplaceKind.ts | 5 + .../generated/v2/PluginListParams.ts | 17 + .../generated/v2/PluginListResponse.ts | 7 + .../generated/v2/PluginMarketplaceEntry.ts | 13 + .../generated/v2/PluginReadParams.ts | 6 + .../generated/v2/PluginReadResponse.ts | 6 + .../generated/v2/PluginShareContext.ts | 6 + .../generated/v2/PluginShareDeleteParams.ts | 5 + .../generated/v2/PluginShareDeleteResponse.ts | 5 + .../v2/PluginShareDiscoverability.ts | 5 + .../generated/v2/PluginShareListItem.ts | 7 + .../generated/v2/PluginShareListParams.ts | 5 + .../generated/v2/PluginShareListResponse.ts | 6 + .../generated/v2/PluginSharePrincipal.ts | 6 + .../generated/v2/PluginSharePrincipalType.ts | 5 + .../generated/v2/PluginShareSaveParams.ts | 8 + .../generated/v2/PluginShareSaveResponse.ts | 5 + .../generated/v2/PluginShareTarget.ts | 6 + .../v2/PluginShareUpdateDiscoverability.ts | 5 + .../v2/PluginShareUpdateTargetsParams.ts | 7 + .../v2/PluginShareUpdateTargetsResponse.ts | 7 + .../generated/v2/PluginSkillReadParams.ts | 5 + .../generated/v2/PluginSkillReadResponse.ts | 5 + .../app-server/generated/v2/PluginSource.ts | 6 + .../app-server/generated/v2/PluginSummary.ts | 19 + .../generated/v2/PluginUninstallParams.ts | 5 + .../generated/v2/PluginUninstallResponse.ts | 5 + .../generated/v2/PluginsMigration.ts | 5 + .../generated/v2/ProcessExitedNotification.ts | 42 + .../generated/v2/ProcessKillParams.ts | 12 + .../generated/v2/ProcessKillResponse.ts | 8 + .../v2/ProcessOutputDeltaNotification.ts | 26 + .../generated/v2/ProcessOutputStream.ts | 8 + .../generated/v2/ProcessResizePtyParams.ts | 17 + .../generated/v2/ProcessResizePtyResponse.ts | 8 + .../generated/v2/ProcessSpawnParams.ts | 73 + .../generated/v2/ProcessSpawnResponse.ts | 8 + .../generated/v2/ProcessTerminalSize.ts | 16 + .../generated/v2/ProcessWriteStdinParams.ts | 21 + .../generated/v2/ProcessWriteStdinResponse.ts | 8 + .../src/app-server/generated/v2/ProfileV2.ts | 19 + .../generated/v2/RateLimitReachedType.ts | 5 + .../generated/v2/RateLimitSnapshot.ts | 9 + .../generated/v2/RateLimitWindow.ts | 5 + .../RawResponseItemCompletedNotification.ts | 6 + .../generated/v2/ReasoningEffortOption.ts | 6 + .../ReasoningSummaryPartAddedNotification.ts | 5 + .../ReasoningSummaryTextDeltaNotification.ts | 5 + .../v2/ReasoningTextDeltaNotification.ts | 5 + .../RemoteControlClientConnectionAudience.ts | 8 + .../RemoteControlClientEnrollmentAudience.ts | 8 + .../v2/RemoteControlConnectionStatus.ts | 5 + .../RemoteControlStatusChangedNotification.ts | 9 + .../generated/v2/RequestPermissionProfile.ts | 7 + .../generated/v2/ResidencyRequirement.ts | 5 + .../app-server/generated/v2/ReviewDelivery.ts | 5 + .../generated/v2/ReviewStartParams.ts | 12 + .../generated/v2/ReviewStartResponse.ts | 13 + .../app-server/generated/v2/ReviewTarget.ts | 9 + .../app-server/generated/v2/SandboxMode.ts | 5 + .../app-server/generated/v2/SandboxPolicy.ts | 7 + .../generated/v2/SandboxWorkspaceWrite.ts | 5 + .../v2/SendAddCreditsNudgeEmailParams.ts | 6 + .../v2/SendAddCreditsNudgeEmailResponse.ts | 6 + .../v2/ServerRequestResolvedNotification.ts | 6 + .../generated/v2/SessionMigration.ts | 5 + .../app-server/generated/v2/SessionSource.ts | 6 + .../generated/v2/SkillDependencies.ts | 6 + .../app-server/generated/v2/SkillErrorInfo.ts | 5 + .../app-server/generated/v2/SkillInterface.ts | 6 + .../app-server/generated/v2/SkillMetadata.ts | 13 + .../src/app-server/generated/v2/SkillScope.ts | 5 + .../app-server/generated/v2/SkillSummary.ts | 7 + .../generated/v2/SkillToolDependency.ts | 5 + .../generated/v2/SkillsChangedNotification.ts | 11 + .../generated/v2/SkillsConfigWriteParams.ts | 14 + .../generated/v2/SkillsConfigWriteResponse.ts | 5 + .../generated/v2/SkillsListEntry.ts | 7 + .../generated/v2/SkillsListParams.ts | 13 + .../generated/v2/SkillsListResponse.ts | 6 + .../app-server/generated/v2/SortDirection.ts | 5 + .../generated/v2/SubagentMigration.ts | 5 + .../v2/TerminalInteractionNotification.ts | 5 + .../app-server/generated/v2/TextElement.ts | 14 + .../app-server/generated/v2/TextPosition.ts | 13 + .../src/app-server/generated/v2/TextRange.ts | 6 + .../src/app-server/generated/v2/Thread.ts | 86 + .../generated/v2/ThreadActiveFlag.ts | 5 + ...ThreadApproveGuardianDeniedActionParams.ts | 10 + ...readApproveGuardianDeniedActionResponse.ts | 5 + .../generated/v2/ThreadArchiveParams.ts | 5 + .../generated/v2/ThreadArchiveResponse.ts | 5 + .../v2/ThreadArchivedNotification.ts | 5 + .../ThreadBackgroundTerminalsCleanParams.ts | 5 + .../ThreadBackgroundTerminalsCleanResponse.ts | 5 + .../generated/v2/ThreadClosedNotification.ts | 5 + .../generated/v2/ThreadCompactStartParams.ts | 5 + .../v2/ThreadCompactStartResponse.ts | 5 + .../v2/ThreadDecrementElicitationParams.ts | 12 + .../v2/ThreadDecrementElicitationResponse.ts | 16 + .../generated/v2/ThreadForkParams.ts | 56 + .../generated/v2/ThreadForkResponse.ts | 37 + .../src/app-server/generated/v2/ThreadGoal.ts | 6 + .../generated/v2/ThreadGoalClearParams.ts | 5 + .../generated/v2/ThreadGoalClearResponse.ts | 5 + .../v2/ThreadGoalClearedNotification.ts | 5 + .../generated/v2/ThreadGoalGetParams.ts | 5 + .../generated/v2/ThreadGoalGetResponse.ts | 6 + .../generated/v2/ThreadGoalSetParams.ts | 6 + .../generated/v2/ThreadGoalSetResponse.ts | 6 + .../generated/v2/ThreadGoalStatus.ts | 5 + .../v2/ThreadGoalUpdatedNotification.ts | 6 + .../v2/ThreadIncrementElicitationParams.ts | 12 + .../v2/ThreadIncrementElicitationResponse.ts | 16 + .../generated/v2/ThreadInjectItemsParams.ts | 10 + .../generated/v2/ThreadInjectItemsResponse.ts | 5 + .../src/app-server/generated/v2/ThreadItem.ts | 101 + .../generated/v2/ThreadListParams.ts | 54 + .../generated/v2/ThreadListResponse.ts | 18 + .../generated/v2/ThreadLoadedListParams.ts | 13 + .../generated/v2/ThreadLoadedListResponse.ts | 14 + .../generated/v2/ThreadMemoryModeSetParams.ts | 6 + .../v2/ThreadMemoryModeSetResponse.ts | 5 + .../v2/ThreadMetadataGitInfoUpdateParams.ts | 20 + .../v2/ThreadMetadataUpdateParams.ts | 12 + .../v2/ThreadMetadataUpdateResponse.ts | 6 + .../v2/ThreadNameUpdatedNotification.ts | 5 + .../generated/v2/ThreadReadParams.ts | 9 + .../generated/v2/ThreadReadResponse.ts | 6 + .../v2/ThreadRealtimeAppendAudioParams.ts | 9 + .../v2/ThreadRealtimeAppendAudioResponse.ts | 8 + .../v2/ThreadRealtimeAppendTextParams.ts | 8 + .../v2/ThreadRealtimeAppendTextResponse.ts | 8 + .../generated/v2/ThreadRealtimeAudioChunk.ts | 8 + .../v2/ThreadRealtimeClosedNotification.ts | 8 + .../v2/ThreadRealtimeErrorNotification.ts | 8 + .../v2/ThreadRealtimeItemAddedNotification.ts | 9 + .../v2/ThreadRealtimeListVoicesParams.ts | 8 + .../v2/ThreadRealtimeListVoicesResponse.ts | 9 + ...eadRealtimeOutputAudioDeltaNotification.ts | 9 + .../v2/ThreadRealtimeSdpNotification.ts | 8 + .../generated/v2/ThreadRealtimeStartParams.ts | 16 + .../v2/ThreadRealtimeStartResponse.ts | 8 + .../v2/ThreadRealtimeStartTransport.ts | 13 + .../v2/ThreadRealtimeStartedNotification.ts | 9 + .../generated/v2/ThreadRealtimeStopParams.ts | 8 + .../v2/ThreadRealtimeStopResponse.ts | 8 + ...readRealtimeTranscriptDeltaNotification.ts | 13 + ...hreadRealtimeTranscriptDoneNotification.ts | 13 + .../generated/v2/ThreadResumeParams.ts | 61 + .../generated/v2/ThreadResumeResponse.ts | 37 + .../generated/v2/ThreadRollbackParams.ts | 12 + .../generated/v2/ThreadRollbackResponse.ts | 14 + .../generated/v2/ThreadSetNameParams.ts | 5 + .../generated/v2/ThreadSetNameResponse.ts | 5 + .../generated/v2/ThreadShellCommandParams.ts | 12 + .../v2/ThreadShellCommandResponse.ts | 5 + .../app-server/generated/v2/ThreadSortKey.ts | 5 + .../app-server/generated/v2/ThreadSource.ts | 5 + .../generated/v2/ThreadSourceKind.ts | 5 + .../generated/v2/ThreadStartParams.ts | 55 + .../generated/v2/ThreadStartResponse.ts | 37 + .../generated/v2/ThreadStartSource.ts | 5 + .../generated/v2/ThreadStartedNotification.ts | 6 + .../app-server/generated/v2/ThreadStatus.ts | 6 + .../v2/ThreadStatusChangedNotification.ts | 6 + .../generated/v2/ThreadTokenUsage.ts | 6 + .../v2/ThreadTokenUsageUpdatedNotification.ts | 6 + .../v2/ThreadTurnsItemsListParams.ts | 18 + .../v2/ThreadTurnsItemsListResponse.ts | 16 + .../generated/v2/ThreadTurnsListParams.ts | 23 + .../generated/v2/ThreadTurnsListResponse.ts | 18 + .../generated/v2/ThreadUnarchiveParams.ts | 5 + .../generated/v2/ThreadUnarchiveResponse.ts | 6 + .../v2/ThreadUnarchivedNotification.ts | 5 + .../generated/v2/ThreadUnsubscribeParams.ts | 5 + .../generated/v2/ThreadUnsubscribeResponse.ts | 6 + .../generated/v2/ThreadUnsubscribeStatus.ts | 5 + .../generated/v2/TokenUsageBreakdown.ts | 5 + .../v2/ToolRequestUserInputAnswer.ts | 8 + .../v2/ToolRequestUserInputOption.ts | 8 + .../v2/ToolRequestUserInputParams.ts | 9 + .../v2/ToolRequestUserInputQuestion.ts | 9 + .../v2/ToolRequestUserInputResponse.ts | 9 + .../src/app-server/generated/v2/ToolsV2.ts | 6 + .../src/app-server/generated/v2/Turn.ts | 33 + .../generated/v2/TurnCompletedNotification.ts | 6 + .../v2/TurnDiffUpdatedNotification.ts | 9 + .../generated/v2/TurnEnvironmentParams.ts | 6 + .../src/app-server/generated/v2/TurnError.ts | 6 + .../generated/v2/TurnInterruptParams.ts | 5 + .../generated/v2/TurnInterruptResponse.ts | 5 + .../app-server/generated/v2/TurnItemsView.ts | 5 + .../app-server/generated/v2/TurnPlanStep.ts | 6 + .../generated/v2/TurnPlanStepStatus.ts | 5 + .../v2/TurnPlanUpdatedNotification.ts | 6 + .../generated/v2/TurnStartParams.ts | 85 + .../generated/v2/TurnStartResponse.ts | 6 + .../generated/v2/TurnStartedNotification.ts | 6 + .../src/app-server/generated/v2/TurnStatus.ts | 5 + .../generated/v2/TurnSteerParams.ts | 15 + .../generated/v2/TurnSteerResponse.ts | 5 + .../src/app-server/generated/v2/UserInput.ts | 10 + .../generated/v2/WarningNotification.ts | 13 + .../generated/v2/WebSearchAction.ts | 5 + .../generated/v2/WindowsSandboxReadiness.ts | 5 + .../v2/WindowsSandboxReadinessResponse.ts | 6 + ...indowsSandboxSetupCompletedNotification.ts | 6 + .../generated/v2/WindowsSandboxSetupMode.ts | 5 + .../v2/WindowsSandboxSetupStartParams.ts | 7 + .../v2/WindowsSandboxSetupStartResponse.ts | 5 + ...WindowsWorldWritableWarningNotification.ts | 5 + .../app-server/generated/v2/WriteStatus.ts | 5 + .../src/app-server/generated/v2/index.ts | 489 ++++ packages/codex-client/src/app-server/rpc.ts | 87 + .../src/app-server/stdio-transport.ts | 261 ++ .../src/app-server/websocket-transport.ts | 179 ++ packages/codex-client/src/browser.ts | 25 + packages/codex-client/src/index.ts | 29 + packages/codex-client/test/flows.test.ts | 323 +++ .../codex-client/test/stdio-transport.test.ts | 70 + packages/codex-client/tsconfig.build.json | 15 + packages/codex-client/tsconfig.json | 22 + packages/ui/components.json | 23 + packages/ui/package.json | 34 + packages/ui/src/components/button-variants.ts | 32 + packages/ui/src/components/button.tsx | 21 + packages/ui/src/lib/utils.ts | 6 + packages/ui/src/styles/globals.css | 102 + packages/ui/tsconfig.json | 23 + 642 files changed, 19676 insertions(+) create mode 100644 .github/workflows/publish-codex-flows.yml create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/actions.ts create mode 100644 apps/cli/src/args.ts create mode 100644 apps/cli/src/index.ts create mode 100644 apps/cli/test/args.test.ts create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/discord-bridge/package.json create mode 100644 apps/discord-bridge/src/bridge.ts create mode 100644 apps/discord-bridge/src/config.ts create mode 100644 apps/discord-bridge/src/console-output.ts create mode 100644 apps/discord-bridge/src/discord-transport.ts create mode 100644 apps/discord-bridge/src/index.ts create mode 100644 apps/discord-bridge/src/logger.ts create mode 100644 apps/discord-bridge/src/pretty-log.ts create mode 100644 apps/discord-bridge/src/runner.ts create mode 100644 apps/discord-bridge/src/state.ts create mode 100644 apps/discord-bridge/src/types.ts create mode 100644 apps/discord-bridge/test/bridge.test.ts create mode 100644 apps/discord-bridge/test/config.test.ts create mode 100644 apps/discord-bridge/test/console-output.test.ts create mode 100644 apps/discord-bridge/test/logger.test.ts create mode 100644 apps/discord-bridge/test/state.test.ts create mode 100644 apps/discord-bridge/tsconfig.json create mode 100644 apps/web/components.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/theme-provider.tsx create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tsconfig.app.json create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 mprocs.yaml create mode 100644 package.json create mode 100644 packages/codex-client/README.md create mode 100644 packages/codex-client/package.json create mode 100644 packages/codex-client/scripts/pack-dry-run.ts create mode 100644 packages/codex-client/scripts/smoke-exports.ts create mode 100644 packages/codex-client/src/app-server/browser-client.ts create mode 100644 packages/codex-client/src/app-server/client.ts create mode 100644 packages/codex-client/src/app-server/events.ts create mode 100644 packages/codex-client/src/app-server/flows.ts create mode 100644 packages/codex-client/src/app-server/generated/AbsolutePathBuf.ts create mode 100644 packages/codex-client/src/app-server/generated/AgentPath.ts create mode 100644 packages/codex-client/src/app-server/generated/ApplyPatchApprovalParams.ts create mode 100644 packages/codex-client/src/app-server/generated/ApplyPatchApprovalResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/AuthMode.ts create mode 100644 packages/codex-client/src/app-server/generated/ClientInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/ClientNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/ClientRequest.ts create mode 100644 packages/codex-client/src/app-server/generated/CollaborationMode.ts create mode 100644 packages/codex-client/src/app-server/generated/ContentItem.ts create mode 100644 packages/codex-client/src/app-server/generated/ConversationGitInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/ConversationSummary.ts create mode 100644 packages/codex-client/src/app-server/generated/ExecCommandApprovalParams.ts create mode 100644 packages/codex-client/src/app-server/generated/ExecCommandApprovalResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/ExecPolicyAmendment.ts create mode 100644 packages/codex-client/src/app-server/generated/FileChange.ts create mode 100644 packages/codex-client/src/app-server/generated/ForcedLoginMethod.ts create mode 100644 packages/codex-client/src/app-server/generated/FunctionCallOutputBody.ts create mode 100644 packages/codex-client/src/app-server/generated/FunctionCallOutputContentItem.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchMatchType.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchParams.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchResult.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchSessionCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchSessionStartParams.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchSessionStartResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchSessionStopParams.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchSessionStopResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchSessionUpdateParams.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchSessionUpdateResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/FuzzyFileSearchSessionUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/GetAuthStatusParams.ts create mode 100644 packages/codex-client/src/app-server/generated/GetAuthStatusResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/GetConversationSummaryParams.ts create mode 100644 packages/codex-client/src/app-server/generated/GetConversationSummaryResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/GitDiffToRemoteParams.ts create mode 100644 packages/codex-client/src/app-server/generated/GitDiffToRemoteResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/GitSha.ts create mode 100644 packages/codex-client/src/app-server/generated/ImageDetail.ts create mode 100644 packages/codex-client/src/app-server/generated/InitializeCapabilities.ts create mode 100644 packages/codex-client/src/app-server/generated/InitializeParams.ts create mode 100644 packages/codex-client/src/app-server/generated/InitializeResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/InputModality.ts create mode 100644 packages/codex-client/src/app-server/generated/InternalSessionSource.ts create mode 100644 packages/codex-client/src/app-server/generated/LocalShellAction.ts create mode 100644 packages/codex-client/src/app-server/generated/LocalShellExecAction.ts create mode 100644 packages/codex-client/src/app-server/generated/LocalShellStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/MessagePhase.ts create mode 100644 packages/codex-client/src/app-server/generated/ModeKind.ts create mode 100644 packages/codex-client/src/app-server/generated/NetworkPolicyAmendment.ts create mode 100644 packages/codex-client/src/app-server/generated/NetworkPolicyRuleAction.ts create mode 100644 packages/codex-client/src/app-server/generated/ParsedCommand.ts create mode 100644 packages/codex-client/src/app-server/generated/Personality.ts create mode 100644 packages/codex-client/src/app-server/generated/PlanType.ts create mode 100644 packages/codex-client/src/app-server/generated/RealtimeConversationVersion.ts create mode 100644 packages/codex-client/src/app-server/generated/RealtimeOutputModality.ts create mode 100644 packages/codex-client/src/app-server/generated/RealtimeVoice.ts create mode 100644 packages/codex-client/src/app-server/generated/RealtimeVoicesList.ts create mode 100644 packages/codex-client/src/app-server/generated/ReasoningEffort.ts create mode 100644 packages/codex-client/src/app-server/generated/ReasoningItemContent.ts create mode 100644 packages/codex-client/src/app-server/generated/ReasoningItemReasoningSummary.ts create mode 100644 packages/codex-client/src/app-server/generated/ReasoningSummary.ts create mode 100644 packages/codex-client/src/app-server/generated/RequestId.ts create mode 100644 packages/codex-client/src/app-server/generated/Resource.ts create mode 100644 packages/codex-client/src/app-server/generated/ResourceContent.ts create mode 100644 packages/codex-client/src/app-server/generated/ResourceTemplate.ts create mode 100644 packages/codex-client/src/app-server/generated/ResponseItem.ts create mode 100644 packages/codex-client/src/app-server/generated/ReviewDecision.ts create mode 100644 packages/codex-client/src/app-server/generated/ServerNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/ServerRequest.ts create mode 100644 packages/codex-client/src/app-server/generated/ServiceTier.ts create mode 100644 packages/codex-client/src/app-server/generated/SessionSource.ts create mode 100644 packages/codex-client/src/app-server/generated/Settings.ts create mode 100644 packages/codex-client/src/app-server/generated/SubAgentSource.ts create mode 100644 packages/codex-client/src/app-server/generated/ThreadId.ts create mode 100644 packages/codex-client/src/app-server/generated/ThreadMemoryMode.ts create mode 100644 packages/codex-client/src/app-server/generated/Tool.ts create mode 100644 packages/codex-client/src/app-server/generated/Verbosity.ts create mode 100644 packages/codex-client/src/app-server/generated/WebSearchAction.ts create mode 100644 packages/codex-client/src/app-server/generated/WebSearchContextSize.ts create mode 100644 packages/codex-client/src/app-server/generated/WebSearchLocation.ts create mode 100644 packages/codex-client/src/app-server/generated/WebSearchMode.ts create mode 100644 packages/codex-client/src/app-server/generated/WebSearchToolConfig.ts create mode 100644 packages/codex-client/src/app-server/generated/index.ts create mode 100644 packages/codex-client/src/app-server/generated/serde_json/JsonValue.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/Account.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AccountLoginCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AccountRateLimitsUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AccountUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ActivePermissionProfile.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ActivePermissionProfileModification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AddCreditsNudgeCreditType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AddCreditsNudgeEmailStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AdditionalFileSystemPermissions.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AdditionalNetworkPermissions.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AdditionalPermissionProfile.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AgentMessageDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AnalyticsConfig.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppBranding.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppListUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppMetadata.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppReview.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppScreenshot.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppSummary.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppToolApproval.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppToolsConfig.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ApprovalsReviewer.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppsConfig.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppsDefaultConfig.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppsListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AppsListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AskForApproval.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/AutoReviewDecisionSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ByteRange.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CancelLoginAccountParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CancelLoginAccountResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CancelLoginAccountStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ChatgptAuthTokensRefreshParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ChatgptAuthTokensRefreshReason.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ChatgptAuthTokensRefreshResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CodexErrorInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CollabAgentState.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CollabAgentStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CollabAgentTool.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CollabAgentToolCallStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CollaborationModeListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CollaborationModeListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CollaborationModeMask.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandAction.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecOutputDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecOutputStream.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecResizeParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecResizeResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecTerminalSize.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecTerminateParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecTerminateResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecWriteParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecWriteResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecutionApprovalDecision.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecutionOutputDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecutionRequestApprovalParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecutionRequestApprovalResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecutionSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandExecutionStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CommandMigration.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/Config.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigBatchWriteParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigEdit.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigLayer.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigLayerMetadata.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigLayerSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigReadParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigReadResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigRequirements.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigRequirementsReadResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigValueWriteParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigWarningNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfigWriteResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfiguredHookHandler.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ConfiguredHookMatcherGroup.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ContextCompactedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/CreditsSnapshot.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/DeprecationNoticeNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/DynamicToolCallOutputContentItem.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/DynamicToolCallParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/DynamicToolCallResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/DynamicToolCallStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/DynamicToolSpec.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ErrorNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExecPolicyAmendment.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExperimentalFeature.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExperimentalFeatureEnablementSetParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExperimentalFeatureEnablementSetResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExperimentalFeatureListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExperimentalFeatureListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExperimentalFeatureStage.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExternalAgentConfigDetectParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExternalAgentConfigDetectResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExternalAgentConfigImportCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExternalAgentConfigImportParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExternalAgentConfigImportResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExternalAgentConfigMigrationItem.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ExternalAgentConfigMigrationItemType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FeedbackUploadParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FeedbackUploadResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileChangeApprovalDecision.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileChangeOutputDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileChangePatchUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileChangeRequestApprovalParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileChangeRequestApprovalResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileSystemAccessMode.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileSystemPath.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileSystemSandboxEntry.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileSystemSpecialPath.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FileUpdateChange.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsChangedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsCopyParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsCopyResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsCreateDirectoryParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsCreateDirectoryResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsGetMetadataParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsGetMetadataResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsReadDirectoryEntry.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsReadDirectoryParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsReadDirectoryResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsReadFileParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsReadFileResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsRemoveParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsRemoveResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsUnwatchParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsUnwatchResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsWatchParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsWatchResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsWriteFileParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/FsWriteFileResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GetAccountParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GetAccountRateLimitsResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GetAccountResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GitInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GrantedPermissionProfile.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GuardianApprovalReview.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GuardianApprovalReviewAction.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GuardianApprovalReviewStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GuardianCommandSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GuardianRiskLevel.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GuardianUserAuthorization.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/GuardianWarningNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookErrorInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookEventName.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookExecutionMode.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookHandlerType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookMetadata.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookMigration.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookOutputEntry.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookOutputEntryKind.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookPromptFragment.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookRunStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookRunSummary.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookScope.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookStartedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HookTrustStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HooksListEntry.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HooksListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/HooksListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ItemCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ItemGuardianApprovalReviewCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ItemGuardianApprovalReviewStartedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ItemStartedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ListMcpServerStatusParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ListMcpServerStatusResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/LoginAccountParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/LoginAccountResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/LogoutAccountResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ManagedHooksRequirements.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceAddParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceAddResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceInterface.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceLoadErrorInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceRemoveParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceRemoveResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceUpgradeErrorInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceUpgradeParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MarketplaceUpgradeResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpAuthStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationArrayType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationBooleanSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationBooleanType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationConstOption.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationEnumSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationLegacyTitledEnumSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationMultiSelectEnumSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationNumberSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationNumberType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationObjectType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationPrimitiveSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationSingleSelectEnumSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationStringFormat.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationStringSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationStringType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationTitledEnumItems.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationTitledMultiSelectEnumSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationTitledSingleSelectEnumSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationUntitledEnumItems.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationUntitledMultiSelectEnumSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpElicitationUntitledSingleSelectEnumSchema.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpResourceReadParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpResourceReadResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerElicitationAction.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerElicitationRequestParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerElicitationRequestResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerMigration.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerOauthLoginCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerOauthLoginParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerOauthLoginResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerRefreshResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerStartupState.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerStatusDetail.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerStatusUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerToolCallParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpServerToolCallResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpToolCallError.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpToolCallProgressNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpToolCallResult.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/McpToolCallStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MemoryCitation.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MemoryCitationEntry.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MemoryResetResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MergeStrategy.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MigrationDetails.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MockExperimentalMethodParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/MockExperimentalMethodResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/Model.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelAvailabilityNux.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelProviderCapabilitiesReadParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelProviderCapabilitiesReadResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelRerouteReason.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelReroutedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelServiceTier.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelUpgradeInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelVerification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ModelVerificationNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NetworkAccess.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NetworkApprovalContext.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NetworkApprovalProtocol.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NetworkDomainPermission.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NetworkPolicyAmendment.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NetworkPolicyRuleAction.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NetworkRequirements.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NetworkUnixSocketPermission.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/NonSteerableTurnKind.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/OverriddenMetadata.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PatchApplyStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PatchChangeKind.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PermissionGrantScope.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PermissionProfile.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PermissionProfileFileSystemPermissions.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PermissionProfileModificationParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PermissionProfileNetworkPermissions.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PermissionProfileSelectionParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PermissionsRequestApprovalParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PermissionsRequestApprovalResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PlanDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginAuthPolicy.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginAvailability.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginDetail.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginHookSummary.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginInstallParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginInstallPolicy.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginInstallResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginInterface.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginListMarketplaceKind.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginMarketplaceEntry.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginReadParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginReadResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareContext.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareDeleteParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareDeleteResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareDiscoverability.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareListItem.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginSharePrincipal.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginSharePrincipalType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareSaveParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareSaveResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareTarget.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareUpdateDiscoverability.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareUpdateTargetsParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginShareUpdateTargetsResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginSkillReadParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginSkillReadResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginSummary.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginUninstallParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginUninstallResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/PluginsMigration.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessExitedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessKillParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessKillResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessOutputDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessOutputStream.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessResizePtyParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessResizePtyResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessSpawnParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessSpawnResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessTerminalSize.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessWriteStdinParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProcessWriteStdinResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ProfileV2.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RateLimitReachedType.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RateLimitSnapshot.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RateLimitWindow.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RawResponseItemCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ReasoningEffortOption.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ReasoningSummaryPartAddedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ReasoningSummaryTextDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ReasoningTextDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RemoteControlClientConnectionAudience.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RemoteControlClientEnrollmentAudience.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RemoteControlConnectionStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RemoteControlStatusChangedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/RequestPermissionProfile.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ResidencyRequirement.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ReviewDelivery.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ReviewStartParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ReviewStartResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ReviewTarget.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SandboxMode.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SandboxPolicy.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SandboxWorkspaceWrite.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SendAddCreditsNudgeEmailParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SendAddCreditsNudgeEmailResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ServerRequestResolvedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SessionMigration.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SessionSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillDependencies.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillErrorInfo.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillInterface.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillMetadata.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillScope.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillSummary.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillToolDependency.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillsChangedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillsConfigWriteParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillsConfigWriteResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillsListEntry.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillsListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SkillsListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SortDirection.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/SubagentMigration.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TerminalInteractionNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TextElement.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TextPosition.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TextRange.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/Thread.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadActiveFlag.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadApproveGuardianDeniedActionParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadApproveGuardianDeniedActionResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadArchiveParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadArchiveResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadArchivedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadBackgroundTerminalsCleanParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadBackgroundTerminalsCleanResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadClosedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadCompactStartParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadCompactStartResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadDecrementElicitationParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadDecrementElicitationResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadForkParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadForkResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoal.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalClearParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalClearResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalClearedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalGetParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalGetResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalSetParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalSetResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadGoalUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadIncrementElicitationParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadIncrementElicitationResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadInjectItemsParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadInjectItemsResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadItem.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadLoadedListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadLoadedListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadMemoryModeSetParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadMemoryModeSetResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadMetadataGitInfoUpdateParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadMetadataUpdateParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadMetadataUpdateResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadNameUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadReadParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadReadResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeAppendAudioParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeAppendAudioResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeAppendTextParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeAppendTextResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeAudioChunk.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeClosedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeErrorNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeItemAddedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeListVoicesParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeListVoicesResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeOutputAudioDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeSdpNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeStartParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeStartResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeStartTransport.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeStartedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeStopParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeStopResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeTranscriptDeltaNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRealtimeTranscriptDoneNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadResumeParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadResumeResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRollbackParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadRollbackResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadSetNameParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadSetNameResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadShellCommandParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadShellCommandResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadSortKey.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadSourceKind.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadStartParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadStartResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadStartSource.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadStartedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadStatusChangedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadTokenUsage.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadTokenUsageUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadTurnsItemsListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadTurnsItemsListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadTurnsListParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadTurnsListResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadUnarchiveParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadUnarchiveResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadUnarchivedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadUnsubscribeParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadUnsubscribeResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ThreadUnsubscribeStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TokenUsageBreakdown.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ToolRequestUserInputAnswer.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ToolRequestUserInputOption.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ToolRequestUserInputParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ToolRequestUserInputQuestion.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ToolRequestUserInputResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/ToolsV2.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/Turn.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnDiffUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnEnvironmentParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnError.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnInterruptParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnInterruptResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnItemsView.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnPlanStep.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnPlanStepStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnPlanUpdatedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnStartParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnStartResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnStartedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnSteerParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/TurnSteerResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/UserInput.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WarningNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WebSearchAction.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WindowsSandboxReadiness.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WindowsSandboxReadinessResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WindowsSandboxSetupCompletedNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WindowsSandboxSetupMode.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WindowsSandboxSetupStartParams.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WindowsSandboxSetupStartResponse.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WindowsWorldWritableWarningNotification.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/WriteStatus.ts create mode 100644 packages/codex-client/src/app-server/generated/v2/index.ts create mode 100644 packages/codex-client/src/app-server/rpc.ts create mode 100644 packages/codex-client/src/app-server/stdio-transport.ts create mode 100644 packages/codex-client/src/app-server/websocket-transport.ts create mode 100644 packages/codex-client/src/browser.ts create mode 100644 packages/codex-client/src/index.ts create mode 100644 packages/codex-client/test/flows.test.ts create mode 100644 packages/codex-client/test/stdio-transport.test.ts create mode 100644 packages/codex-client/tsconfig.build.json create mode 100644 packages/codex-client/tsconfig.json create mode 100644 packages/ui/components.json create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/components/button-variants.ts create mode 100644 packages/ui/src/components/button.tsx create mode 100644 packages/ui/src/lib/utils.ts create mode 100644 packages/ui/src/styles/globals.css create mode 100644 packages/ui/tsconfig.json diff --git a/.github/workflows/publish-codex-flows.yml b/.github/workflows/publish-codex-flows.yml new file mode 100644 index 0000000..97c16c0 --- /dev/null +++ b/.github/workflows/publish-codex-flows.yml @@ -0,0 +1,43 @@ +name: Publish @peezy-tech/codex-flows + +on: + workflow_dispatch: + inputs: + confirm_package: + description: "Type @peezy-tech/codex-flows to publish" + required: true + type: string + +permissions: + contents: read + id-token: write + +jobs: + publish: + if: inputs.confirm_package == '@peezy-tech/codex-flows' + runs-on: ubuntu-latest + environment: npm-publish + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Release check + run: bun run --filter @peezy-tech/codex-flows release:check + + - name: Publish + working-directory: packages/codex-client + run: npm publish --access public diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88b38d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.local +.DS_Store +*.log diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..fa3c8e4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,25 @@ +# Code of Conduct + +This project expects respectful, direct, and constructive participation. + +## Expected Behavior + +- Assume good intent while staying precise about technical risks. +- Keep discussion focused on the work and its impact. +- Be clear when disagreeing, and explain the reasoning. +- Respect maintainers' time by providing reproducible reports and scoped changes. + +## Unacceptable Behavior + +- Harassment, threats, or personal attacks. +- Discriminatory language or behavior. +- Publishing private information without permission. +- Repeated disruption of issues, pull requests, or discussions. + +## Enforcement + +Maintainers may remove comments, close issues, reject contributions, or block +participants who violate this code of conduct. + +If you need to report a conduct issue, contact a maintainer privately through +the repository's available private contact or moderation channel. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..00021f4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Contributing + +Install dependencies and run the checks before submitting changes: + +```bash +bun install +bun run build +bun run test +``` + +Keep changes scoped to the bare package set: + +- `apps/web` +- `packages/codex-client` +- `packages/ui` + +Avoid reintroducing service, workspace, gateway, job, or host setup code on this +branch. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ea0acc --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000..48ef377 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Codex Bare + +Thin browser UI plus TypeScript client for `codex app-server`. + +This branch intentionally drops the workspace service, runtime, gateways, jobs, +delegation, and host setup layer. The remaining source is: + +- `apps/web`: React/Vite UI that connects directly to a Codex app-server WebSocket. +- `apps/cli`: Bun CLI that sends JSON-RPC actions to a listening Codex app-server. +- `packages/codex-client`: JSON-RPC client, app-server transports, flow helpers, and generated protocol types. +- `packages/ui`: small shared UI primitives and styling. + +## Run + +Install dependencies: + +```bash +bun install +``` + +Start a Codex app-server WebSocket in a separate shell: + +```bash +codex app-server --listen ws://127.0.0.1:3585 --enable apps --enable hooks +``` + +Start the web app: + +```bash +bun run dev +``` + +In development, the web app defaults to a same-origin Vite WebSocket proxy at +`/__codex-app-server`, which forwards to `ws://127.0.0.1:3585`. This avoids +browser `Origin` header rejections from the app-server, which can show up in +WSL and other browser-to-localhost setups. + +Set `VITE_CODEX_APP_SERVER_PROXY_TARGET` to proxy to a different app-server +URL. Set `VITE_CODEX_APP_SERVER_WS_URL` only when you explicitly want the +browser to connect directly to an app-server WebSocket. + +Send a command to the running app-server: + +```bash +bun apps/cli/src/index.ts thread/list '{"limit": 20, "sourceKinds": []}' +echo '{"refreshToken": false}' | bun apps/cli/src/index.ts account/read +``` + +List available actions: + +```bash +bun apps/cli/src/index.ts actions +``` + +## Build And Test + +```bash +bun run build +bun run test +``` + +`bun run test` currently runs the `@peezy-tech/codex-flows` transport tests. + +## Publishing + +The public home for this monorepo is `peezy-tech/codex-flows`. When seeding that +repo, copy this working tree without `.git`, initialize a fresh git history, and +push it to the new public GitHub repo. + +`@peezy-tech/codex-flows` is published from `packages/codex-client`. + +Before the first publish: + +```bash +bun run --filter @peezy-tech/codex-flows release:check +``` + +Because the npm package does not exist yet, bootstrap the first version with a +human npm session or short-lived npm token from the public repo checkout: + +```bash +cd packages/codex-client +npm publish --access public +``` + +After the package exists, configure npm trusted publishing for: + +- Package: `@peezy-tech/codex-flows` +- Repository: `peezy-tech/codex-flows` +- Workflow: `.github/workflows/publish-codex-flows.yml` +- Environment: `npm-publish` + +Future publishes should use the GitHub Actions workflow and should not require +an npm token. + +## Packages + +### `@peezy-tech/codex-flows` + +The low-level app-server client package. It exports: + +- `@peezy-tech/codex-flows`: Node/Bun entry with stdio and WebSocket transports. +- `@peezy-tech/codex-flows/browser`: browser entry with WebSocket transport only. +- `@peezy-tech/codex-flows/flows`: framework-agnostic helpers for app servers that want to start Codex-backed workflows. +- `@peezy-tech/codex-flows/rpc`: JSON-RPC helpers and types. +- `@peezy-tech/codex-flows/generated`: generated Codex app-server protocol types. + +### `web` + +The browser app imports `@peezy-tech/codex-flows/browser`, opens a direct WebSocket +connection, lists threads, starts turns, interrupts running turns, and renders +thread items and live app-server events. + +### `cli` + +CLI package for piping JSON params into app-server JSON-RPC actions over a +running WebSocket listener. It defaults to `ws://127.0.0.1:3585`, respects +`CODEX_WORKSPACE_APP_SERVER_WS_URL`, supports `--url`, and lists available +actions from the generated app-server action list. + +### `@workspace/ui` + +Shared Tailwind/shadcn-compatible UI primitives used by the web app. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..147790c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +`codex-bare` is a browser UI for a Codex app-server WebSocket. It does not add +authentication, authorization, persistence, or request filtering in front of the +app-server. + +Keep the app-server bound to localhost or another trusted network boundary. Do +not expose the app-server WebSocket directly to the public internet. diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..5ee0990 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "codex-app-cli", + "version": "0.1.0", + "description": "CLI for sending JSON-RPC commands to a running Codex app-server.", + "type": "module", + "private": true, + "license": "Apache-2.0", + "bin": { + "codex-app": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "check:types": "tsc --noEmit", + "test": "bun test test/*.test.ts" + }, + "dependencies": { + "@peezy-tech/codex-flows": "workspace:*" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:" + } +} diff --git a/apps/cli/src/actions.ts b/apps/cli/src/actions.ts new file mode 100644 index 0000000..31a6d8d --- /dev/null +++ b/apps/cli/src/actions.ts @@ -0,0 +1,113 @@ +// TODO: generate this after generating app-server ts bindings on auto-update job when it exists +export const APP_SERVER_ACTIONS = [ + "initialize", + "thread/start", + "thread/resume", + "thread/fork", + "thread/archive", + "thread/unsubscribe", + "thread/increment_elicitation", + "thread/decrement_elicitation", + "thread/name/set", + "thread/goal/set", + "thread/goal/get", + "thread/goal/clear", + "thread/metadata/update", + "thread/memoryMode/set", + "memory/reset", + "thread/unarchive", + "thread/compact/start", + "thread/shellCommand", + "thread/approveGuardianDeniedAction", + "thread/backgroundTerminals/clean", + "thread/rollback", + "thread/list", + "thread/loaded/list", + "thread/read", + "thread/turns/list", + "thread/turns/items/list", + "thread/inject_items", + "skills/list", + "hooks/list", + "marketplace/add", + "marketplace/remove", + "marketplace/upgrade", + "plugin/list", + "plugin/read", + "plugin/skill/read", + "plugin/share/save", + "plugin/share/updateTargets", + "plugin/share/list", + "plugin/share/delete", + "app/list", + "fs/readFile", + "fs/writeFile", + "fs/createDirectory", + "fs/getMetadata", + "fs/readDirectory", + "fs/remove", + "fs/copy", + "fs/watch", + "fs/unwatch", + "skills/config/write", + "plugin/install", + "plugin/uninstall", + "turn/start", + "turn/steer", + "turn/interrupt", + "thread/realtime/start", + "thread/realtime/appendAudio", + "thread/realtime/appendText", + "thread/realtime/stop", + "thread/realtime/listVoices", + "review/start", + "model/list", + "modelProvider/capabilities/read", + "experimentalFeature/list", + "experimentalFeature/enablement/set", + "collaborationMode/list", + "mock/experimentalMethod", + "mcpServer/oauth/login", + "config/mcpServer/reload", + "mcpServerStatus/list", + "mcpServer/resource/read", + "mcpServer/tool/call", + "windowsSandbox/setupStart", + "windowsSandbox/readiness", + "account/login/start", + "account/login/cancel", + "account/logout", + "account/rateLimits/read", + "account/sendAddCreditsNudgeEmail", + "feedback/upload", + "command/exec", + "command/exec/write", + "command/exec/terminate", + "command/exec/resize", + "process/spawn", + "process/writeStdin", + "process/kill", + "process/resizePty", + "config/read", + "externalAgentConfig/detect", + "externalAgentConfig/import", + "config/value/write", + "config/batchWrite", + "configRequirements/read", + "account/read", + "getConversationSummary", + "gitDiffToRemote", + "getAuthStatus", + "fuzzyFileSearch", + "fuzzyFileSearch/sessionStart", + "fuzzyFileSearch/sessionUpdate", + "fuzzyFileSearch/sessionStop", +] as const; + +export type AppServerAction = (typeof APP_SERVER_ACTIONS)[number]; + +const actionSet = new Set(APP_SERVER_ACTIONS); + +export function isAppServerAction(value: string): value is AppServerAction { + return actionSet.has(value); +} diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts new file mode 100644 index 0000000..6c76d50 --- /dev/null +++ b/apps/cli/src/args.ts @@ -0,0 +1,115 @@ +import { isAppServerAction, type AppServerAction } from "./actions.ts"; + +export type ParsedArgs = + | { type: "help" } + | { type: "actions" } + | { + type: "call"; + action: AppServerAction; + paramsText: string | undefined; + url: string; + timeoutMs: number; + pretty: boolean; + }; + +export const DEFAULT_WS_URL = "ws://127.0.0.1:3585"; +const defaultTimeoutMs = 90_000; + +export function parseArgs(argv: string[], env: NodeJS.ProcessEnv): ParsedArgs { + const positionals: string[] = []; + let url = env.CODEX_WORKSPACE_APP_SERVER_WS_URL ?? DEFAULT_WS_URL; + let timeoutMs = defaultTimeoutMs; + let pretty = true; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg) { + continue; + } + if (arg === "-h" || arg === "--help") { + return { type: "help" }; + } + if (arg === "--url" || arg === "--ws-url") { + const value = argv[index + 1]; + if (!value) { + throw new Error(`${arg} requires a WebSocket URL`); + } + url = value; + index += 1; + continue; + } + if (arg.startsWith("--url=")) { + url = arg.slice("--url=".length); + continue; + } + if (arg.startsWith("--ws-url=")) { + url = arg.slice("--ws-url=".length); + continue; + } + if (arg === "--timeout-ms") { + const value = argv[index + 1]; + if (!value) { + throw new Error("--timeout-ms requires a number"); + } + timeoutMs = parseTimeout(value); + index += 1; + continue; + } + if (arg.startsWith("--timeout-ms=")) { + timeoutMs = parseTimeout(arg.slice("--timeout-ms=".length)); + continue; + } + if (arg === "--compact") { + pretty = false; + continue; + } + if (arg === "--pretty") { + pretty = true; + continue; + } + if (arg === "--") { + positionals.push(...argv.slice(index + 1)); + break; + } + if (arg.startsWith("-")) { + throw new Error(`Unknown option: ${arg}`); + } + positionals.push(arg); + } + + const command = positionals[0]; + if (!command) { + return { type: "help" }; + } + if (command === "help") { + return { type: "help" }; + } + if (command === "actions") { + return { type: "actions" }; + } + + const action = command === "call" ? positionals[1] : command; + const paramsParts = command === "call" ? positionals.slice(2) : positionals.slice(1); + if (!action) { + throw new Error("call requires an action name"); + } + if (!isAppServerAction(action)) { + throw new Error(`Unknown action: ${action}`); + } + return { + type: "call", + action, + paramsText: paramsParts.length > 0 ? paramsParts.join(" ") : undefined, + url, + timeoutMs, + pretty, + }; +} + +function parseTimeout(value: string) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error("--timeout-ms must be a positive integer"); + } + return parsed; +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 0000000..a3ec45a --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env bun +import { CodexAppServerClient } from "@peezy-tech/codex-flows"; + +import { APP_SERVER_ACTIONS } from "./actions.ts"; +import { DEFAULT_WS_URL, parseArgs } from "./args.ts"; + +async function main() { + try { + const parsed = parseArgs(Bun.argv.slice(2), process.env); + switch (parsed.type) { + case "help": + write(helpText()); + return; + case "actions": + write(`${APP_SERVER_ACTIONS.join("\n")}\n`); + return; + case "call": + await callAction(parsed); + return; + } + } catch (error) { + writeError(`${errorMessage(error)}\n`); + process.exitCode = 1; + } +} + +type CallArgs = Extract, { type: "call" }>; + +async function callAction(args: CallArgs) { + const params = await readParams(args.paramsText); + const client = new CodexAppServerClient({ + webSocketTransportOptions: { + url: args.url, + requestTimeoutMs: args.timeoutMs, + }, + clientName: "codex-app-cli", + clientTitle: "Codex App CLI", + clientVersion: "0.1.0", + }); + + client.on("request", (message) => { + client.respondError(message.id, -32603, "codex-app CLI does not handle server requests"); + }); + + try { + await client.connect(); + const result = await client.request(args.action, params); + write(formatJson(result, args.pretty)); + } finally { + client.close(); + } +} + +async function readParams(paramsText: string | undefined) { + if (paramsText !== undefined) { + return parseJsonParams(paramsText); + } + if (process.stdin.isTTY) { + return undefined; + } + const text = await readStdin(); + if (!text.trim()) { + return undefined; + } + return parseJsonParams(text); +} + +async function readStdin() { + let text = ""; + for await (const chunk of process.stdin) { + text += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + } + return text; +} + +function parseJsonParams(text: string) { + try { + return JSON.parse(text) as unknown; + } catch (error) { + throw new Error(`Failed to parse params JSON: ${errorMessage(error)}`); + } +} + +function formatJson(value: unknown, pretty: boolean) { + return `${JSON.stringify(value, null, pretty ? 2 : 0)}\n`; +} + +function helpText() { + return `codex-app sends JSON-RPC actions to a running Codex app-server. + +Usage: + codex-app [options] [params-json] + codex-app [options] call [params-json] + echo '' | codex-app [options] + codex-app actions + +Options: + --url, --ws-url App-server WebSocket URL + Defaults to CODEX_WORKSPACE_APP_SERVER_WS_URL or ${DEFAULT_WS_URL} + --timeout-ms Request timeout in milliseconds + --compact Print compact JSON + --pretty Print pretty JSON + -h, --help Show this help + +Examples: + codex-app thread/list '{"limit": 20, "sourceKinds": []}' + echo '{"refreshToken": false}' | codex-app account/read +`; +} + +function write(text: string) { + process.stdout.write(text); +} + +function writeError(text: string) { + process.stderr.write(text); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +await main(); diff --git a/apps/cli/test/args.test.ts b/apps/cli/test/args.test.ts new file mode 100644 index 0000000..e9a3fd7 --- /dev/null +++ b/apps/cli/test/args.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; + +import { parseArgs } from "../src/args.ts"; + +test("parses a direct action call with params JSON", () => { + expect(parseArgs(["thread/list", "{\"limit\":10}"], {})).toEqual({ + type: "call", + action: "thread/list", + paramsText: "{\"limit\":10}", + url: "ws://127.0.0.1:3585", + timeoutMs: 90_000, + pretty: true, + }); +}); + +test("parses call alias, url, timeout, and compact output", () => { + expect( + parseArgs( + [ + "--url", + "ws://localhost:4000", + "--timeout-ms=1234", + "--compact", + "call", + "account/read", + ], + {}, + ), + ).toEqual({ + type: "call", + action: "account/read", + paramsText: undefined, + url: "ws://localhost:4000", + timeoutMs: 1234, + pretty: false, + }); +}); + +test("uses environment URL default", () => { + const parsed = parseArgs(["account/read"], { + CODEX_WORKSPACE_APP_SERVER_WS_URL: "ws://127.0.0.1:9999", + }); + expect(parsed).toMatchObject({ + type: "call", + url: "ws://127.0.0.1:9999", + }); +}); + +test("rejects unknown actions before connecting", () => { + expect(() => parseArgs(["not-a-method"], {})).toThrow("Unknown action"); +}); + +test("completion command is not supported", () => { + expect(() => parseArgs(["completion", "zsh"], {})).toThrow("Unknown action"); +}); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..ffaead6 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@peezy-tech/codex-flows": ["../../packages/codex-client/src/index.ts"], + "@peezy-tech/codex-flows/*": ["../../packages/codex-client/src/*"] + } + }, + "include": ["src", "test"] +} diff --git a/apps/discord-bridge/package.json b/apps/discord-bridge/package.json new file mode 100644 index 0000000..fe7d676 --- /dev/null +++ b/apps/discord-bridge/package.json @@ -0,0 +1,27 @@ +{ + "name": "codex-discord-bridge", + "version": "0.1.0", + "description": "Long-lived Discord sidecar for bridging Discord threads to Codex app-server threads.", + "type": "module", + "private": true, + "license": "Apache-2.0", + "bin": { + "codex-discord-bridge": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "check:types": "tsc --noEmit", + "pretty-log": "bun ./src/pretty-log.ts", + "start:debug:commentary": "bun ./src/index.ts --local-app-server --debug --progress-mode commentary", + "test": "bun test test/*.test.ts" + }, + "dependencies": { + "@peezy-tech/codex-flows": "workspace:*", + "discord.js": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:" + } +} diff --git a/apps/discord-bridge/src/bridge.ts b/apps/discord-bridge/src/bridge.ts new file mode 100644 index 0000000..aee1fe4 --- /dev/null +++ b/apps/discord-bridge/src/bridge.ts @@ -0,0 +1,1051 @@ +import os from "node:os"; +import path from "node:path"; + +import type { JsonRpcNotification, JsonRpcRequest } from "@peezy-tech/codex-flows/rpc"; +import type { v2 } from "@peezy-tech/codex-flows/generated"; + +import type { DiscordConsoleOutput } from "./console-output.ts"; +import { DiscordThreadRunner, MessageDeduplicator } from "./runner.ts"; +import { + createDiscordBridgeLogger, + type DiscordBridgeLogger, +} from "./logger.ts"; +import type { + CodexBridgeClient, + DiscordBridgeConfig, + DiscordBridgeSession, + DiscordBridgeState, + DiscordBridgeStateStore, + DiscordBridgeTransport, + DiscordClearInbound, + DiscordInbound, + DiscordMessageInbound, + DiscordThreadStartInbound, +} from "./types.ts"; + +const maxDiscordMessageLength = 2000; + +type ThreadSnapshot = { + terminalTurnIds: string[]; + lastFinal?: { + turnId: string; + text: string; + }; +}; + +export class DiscordCodexBridge { + readonly client: CodexBridgeClient; + readonly transport: DiscordBridgeTransport; + readonly store: DiscordBridgeStateStore; + readonly config: DiscordBridgeConfig; + #state: DiscordBridgeState | undefined; + #runnersByDiscordThread = new Map(); + #runnersByCodexThread = new Map(); + #persistChain: Promise = Promise.resolve(); + #now: () => Date; + #dedupe: MessageDeduplicator; + #logger: DiscordBridgeLogger; + #consoleOutput: DiscordConsoleOutput | undefined; + + constructor(options: { + client: CodexBridgeClient; + transport: DiscordBridgeTransport; + store: DiscordBridgeStateStore; + config: DiscordBridgeConfig; + now?: () => Date; + logger?: DiscordBridgeLogger; + consoleOutput?: DiscordConsoleOutput; + }) { + this.client = options.client; + this.transport = options.transport; + this.store = options.store; + this.config = options.config; + this.#now = options.now ?? (() => new Date()); + this.#dedupe = new MessageDeduplicator({ now: this.#now }); + this.#logger = options.logger ?? + createDiscordBridgeLogger({ + debug: this.config.debug, + logLevel: this.config.logLevel, + now: this.#now, + }); + this.#consoleOutput = options.consoleOutput; + } + + async start(): Promise { + this.#state = await this.store.load(); + for (const session of this.#state.sessions) { + this.#registerRunner(session); + } + this.#debug("bridge.start", { + sessions: this.#state.sessions.length, + queue: this.#state.queue.length, + deliveries: this.#state.deliveries.length, + allowedUsers: this.config.allowedUserIds.size, + allowedChannels: this.config.allowedChannelIds.size, + cwd: this.config.cwd, + summary: this.config.summary, + }); + this.client.on("notification", (message) => { + void this.#handleNotification(message).catch((error) => { + this.#debug("notification.error", { + method: message.method, + error: errorMessage(error), + }); + this.#error("notification.failed", { + method: message.method, + error: errorMessage(error), + }); + }); + }); + this.client.on("request", (message) => this.#handleServerRequest(message)); + await this.client.connect(); + this.#debug("client.connected"); + await this.transport.start({ + onInbound: (inbound) => { + void this.#handleInbound(inbound).catch((error) => { + this.#debug("inbound.error", { + kind: inbound.kind, + channelId: inbound.channelId, + error: errorMessage(error), + }); + this.#error("inbound.failed", { + kind: inbound.kind, + channelId: inbound.channelId, + error: errorMessage(error), + }); + }); + }, + }); + this.#debug("transport.started"); + await this.transport.registerCommands(); + this.#debug("commands.registered"); + for (const runner of this.#runnersByDiscordThread.values()) { + runner.start(); + } + } + + async stop(): Promise { + this.#debug("bridge.stop", { + runners: this.#runnersByDiscordThread.size, + }); + await Promise.all( + [...this.#runnersByDiscordThread.values()].map((runner) => runner.stop()), + ); + await this.#persistChain.catch(() => undefined); + await this.transport.stop(); + this.client.close(); + } + + stateForTest(): DiscordBridgeState { + return structuredClone(this.#requireState()); + } + + async flushSummariesForTest(): Promise { + await Promise.all( + [...this.#runnersByDiscordThread.values()].map((runner) => + runner.flushSummariesForTest() + ), + ); + } + + async #handleInbound(inbound: DiscordInbound): Promise { + this.#debug("inbound.received", { + kind: inbound.kind, + channelId: inbound.channelId, + authorId: inbound.author.id, + isBot: inbound.author.isBot, + messageId: inbound.kind === "message" ? inbound.messageId : undefined, + sourceMessageId: inbound.kind === "threadStart" ? inbound.sourceMessageId : undefined, + contentLength: inbound.kind === "message" + ? inbound.content.length + : inbound.kind === "threadStart" + ? inbound.prompt?.length + : undefined, + mentionedUserIds: inbound.kind === "threadStart" + ? inbound.mentionedUserIds?.length + : undefined, + }); + if (inbound.author.isBot) { + this.#debug("inbound.ignored.bot", { + kind: inbound.kind, + channelId: inbound.channelId, + authorId: inbound.author.id, + }); + return; + } + + if (inbound.kind === "clear") { + await this.#handleClear(inbound); + return; + } + + if (inbound.kind === "threadStart") { + if (!this.config.allowedUserIds.has(inbound.author.id)) { + this.#debug("threadStart.ignored.user", { + channelId: inbound.channelId, + authorId: inbound.author.id, + }); + return; + } + if (!this.#isAllowedInboundChannel(inbound)) { + this.#debug("threadStart.ignored.channel", { + channelId: inbound.channelId, + }); + return; + } + await this.#handleThreadStart(inbound); + return; + } + await this.#handleMessage(inbound); + } + + async #handleClear(command: DiscordClearInbound): Promise { + if (!this.config.allowedUserIds.has(command.author.id)) { + this.#debug("clear.ignored.user", { + channelId: command.channelId, + authorId: command.author.id, + }); + await command.reply?.("Only globally allowed Discord users can clear bridge threads."); + return; + } + if (!this.transport.deleteThread) { + this.#debug("clear.unsupported", { channelId: command.channelId }); + await command.reply?.("This Discord transport cannot delete threads."); + return; + } + const state = this.#requireState(); + const scopedSessions = state.sessions.filter((session) => + this.#isSessionInClearScope(session, command) + ); + const inactive = scopedSessions.filter((session) => + !this.#isSessionRunning(session, state) + ); + const runningCount = scopedSessions.length - inactive.length; + const deletedThreadIds: string[] = []; + const failed: Array<{ threadId: string; error: string }> = []; + this.#debug("clear.start", { + channelId: command.channelId, + guildId: command.guildId, + scoped: scopedSessions.length, + inactive: inactive.length, + running: runningCount, + }); + for (const session of inactive) { + try { + await this.transport.deleteThread(session.discordThreadId); + await this.#deleteSourceMessage(session); + deletedThreadIds.push(session.discordThreadId); + const runner = this.#runnersByDiscordThread.get(session.discordThreadId); + await runner?.stop(); + this.#runnersByDiscordThread.delete(session.discordThreadId); + this.#runnersByCodexThread.delete(session.codexThreadId); + this.#debug("clear.threadDeleted", { + discordThreadId: session.discordThreadId, + codexThreadId: session.codexThreadId, + }); + } catch (error) { + const message = errorMessage(error); + failed.push({ threadId: session.discordThreadId, error: message }); + this.#debug("clear.threadDeleteFailed", { + discordThreadId: session.discordThreadId, + codexThreadId: session.codexThreadId, + error: message, + }); + } + } + if (deletedThreadIds.length > 0) { + const deleted = new Set(deletedThreadIds); + state.sessions = state.sessions.filter( + (session) => !deleted.has(session.discordThreadId), + ); + state.queue = state.queue.filter( + (item) => !deleted.has(item.discordThreadId), + ); + state.activeTurns = state.activeTurns.filter( + (active) => !deleted.has(active.discordThreadId), + ); + state.deliveries = state.deliveries.filter( + (delivery) => !deleted.has(delivery.discordThreadId), + ); + await this.#persist(); + } + await command.reply?.(clearSummary({ + deleted: deletedThreadIds.length, + running: runningCount, + failed: failed.length, + })); + } + + async #handleThreadStart(start: DiscordThreadStartInbound): Promise { + const state = this.#requireState(); + if ( + this.#dedupe.isDuplicate(start.sourceMessageId) || + isDuplicate(state, start.sourceMessageId) + ) { + this.#debug("threadStart.ignored.duplicate", { + channelId: start.channelId, + sourceMessageId: start.sourceMessageId, + }); + return; + } + const participantUserIds = normalizeParticipantUserIds( + start.mentionedUserIds, + start.author.id, + ); + const intent = parseThreadStartIntent(threadPrompt(start)); + if (intent.kind === "invalid") { + await start.reply?.(intent.message); + this.#debug("threadStart.ignored.invalidIntent", { + channelId: start.channelId, + sourceMessageId: start.sourceMessageId, + message: intent.message, + }); + return; + } + const title = intent.kind === "resume" + ? resumeThreadTitle(start, intent.codexThreadId) + : threadTitle(start, intent.prompt); + this.#debug("threadStart.start", { + channelId: start.channelId, + sourceMessageId: start.sourceMessageId, + title, + intent: intent.kind, + cwd: intent.cwd, + hasPrompt: intent.kind === "new" && Boolean(intent.prompt), + participantUserIds, + }); + const discordThreadId = await this.transport.createThread( + start.channelId, + title, + start.sourceMessageId, + ); + this.#debug("discord.thread.created", { + parentChannelId: start.channelId, + discordThreadId, + title, + }); + const started = intent.kind === "resume" + ? await this.client.resumeThread(this.#threadResumeParams(intent.codexThreadId, intent.cwd)) + : await this.client.startThread(this.#threadStartParams(intent.cwd)); + const codexThreadId = started.thread.id; + if (intent.kind === "new") { + await this.client.setThreadName({ + threadId: codexThreadId, + name: `[discord] ${title}`, + }); + } + const sessionCwd = intent.kind === "resume" + ? intent.cwd ?? resumeResponseCwd(started) + : intent.cwd; + const session: DiscordBridgeSession = { + discordThreadId, + parentChannelId: start.channelId, + guildId: start.guildId, + sourceMessageId: start.sourceMessageId, + codexThreadId, + title, + createdAt: this.#now().toISOString(), + ownerUserId: start.author.id, + participantUserIds, + cwd: sessionCwd, + mode: intent.kind === "resume" ? "resumed" : "new", + }; + await this.#addThreadMembers(discordThreadId, participantUserIds); + state.sessions.push(session); + const runner = this.#registerRunner(session); + await this.#persist(); + await runner.ensureStatusMessage(); + await start.reply?.(`${intent.kind === "resume" ? "Resumed" : "Started"} Codex thread ${compactId(codexThreadId)} in <#${discordThreadId}>.`); + this.#debug("threadStart.acknowledged", { + discordThreadId, + codexThreadId, + }); + + if (intent.kind === "resume") { + const snapshot = mergeThreadSnapshots( + await this.#readThreadSnapshot(codexThreadId), + threadSnapshotFromThread(started.thread), + ); + const outboundMessageIds = snapshot.lastFinal + ? await this.transport.sendMessage(discordThreadId, snapshot.lastFinal.text) + : await this.transport.sendMessage( + discordThreadId, + "No final assistant message found for this Codex thread.", + ); + this.#recordResumeHistoryDeliveries( + session, + start.sourceMessageId, + snapshot, + outboundMessageIds, + ); + await this.#persist(); + if (snapshot.lastFinal) { + this.#debug("threadStart.resumeFinalReplayed", { + discordThreadId, + codexThreadId, + turnId: snapshot.lastFinal.turnId, + outboundMessageIds, + terminalTurns: snapshot.terminalTurnIds.length, + }); + } else { + this.#debug("threadStart.resumeFinalMissing", { + discordThreadId, + codexThreadId, + terminalTurns: snapshot.terminalTurnIds.length, + }); + } + runner.start(); + return; + } + + if (intent.prompt) { + this.#debug("threadStart.enqueuePrompt", { + discordThreadId, + codexThreadId, + promptLength: intent.prompt.length, + }); + await runner.enqueueMessage({ + kind: "message", + channelId: discordThreadId, + messageId: start.sourceMessageId, + author: start.author, + content: intent.prompt, + createdAt: start.createdAt, + }); + } else { + runner.start(); + } + } + + async #handleMessage(message: DiscordMessageInbound): Promise { + if (this.#dedupe.isDuplicate(message.messageId)) { + this.#debug("message.ignored.rawDuplicate", { + channelId: message.channelId, + messageId: message.messageId, + }); + return; + } + const runner = this.#runnersByDiscordThread.get(message.channelId); + if (!runner) { + this.#debug("message.ignored.noSession", { + channelId: message.channelId, + messageId: message.messageId, + }); + return; + } + if (!this.#isAllowedInboundChannel(message)) { + this.#debug("message.ignored.channel", { + channelId: message.channelId, + messageId: message.messageId, + }); + return; + } + if (!this.#isAllowedSessionUser(runner.session, message.author.id)) { + this.#debug("message.ignored.user", { + channelId: message.channelId, + messageId: message.messageId, + authorId: message.author.id, + ownerUserId: runner.session.ownerUserId, + participantUserIds: runner.session.participantUserIds, + }); + return; + } + await runner.enqueueMessage(message); + } + + async #handleNotification(message: JsonRpcNotification): Promise { + const params = record(message.params); + const threadId = stringValue(params.threadId); + if (!threadId) { + this.#debug("notification.ignored.missingThread", { + method: message.method, + }); + return; + } + const runner = this.#runnersByCodexThread.get(threadId); + if (!runner) { + this.#debug("notification.ignored.noRunner", { + method: message.method, + threadId, + }); + return; + } + await runner.handleNotification(message); + } + + #handleServerRequest(message: JsonRpcRequest): void { + this.client.respondError( + message.id, + -32603, + "codex-discord-bridge does not handle app-server requests yet", + ); + } + + #registerRunner(session: DiscordBridgeSession): DiscordThreadRunner { + const existing = this.#runnersByDiscordThread.get(session.discordThreadId); + if (existing) { + return existing; + } + const runner = new DiscordThreadRunner(session, { + client: this.client, + transport: this.transport, + config: this.config, + getState: () => this.#requireState(), + persist: () => this.#persist(), + now: () => this.#now(), + debug: (event, fields = {}) => this.#debug(event, fields), + consoleOutput: this.#consoleOutput, + }); + this.#runnersByDiscordThread.set(session.discordThreadId, runner); + this.#runnersByCodexThread.set(session.codexThreadId, runner); + return runner; + } + + #isSessionRunning( + session: DiscordBridgeSession, + state: DiscordBridgeState, + ): boolean { + const hasActiveTurn = state.activeTurns.some( + (active) => + active.discordThreadId === session.discordThreadId && + active.codexThreadId === session.codexThreadId, + ); + if (hasActiveTurn) { + return true; + } + return state.queue.some( + (item) => + item.discordThreadId === session.discordThreadId && + item.codexThreadId === session.codexThreadId && + item.status !== "failed", + ); + } + + #isAllowedChannel(channelId: string): boolean { + if (this.config.allowedChannelIds.size === 0) { + return true; + } + if (this.config.allowedChannelIds.has(channelId)) { + return true; + } + const session = this.#requireState().sessions.find( + (candidate) => candidate.discordThreadId === channelId, + ); + return Boolean( + session && this.config.allowedChannelIds.has(session.parentChannelId), + ); + } + + #isAllowedInboundChannel( + inbound: DiscordMessageInbound | DiscordThreadStartInbound, + ): boolean { + if (!inbound.guildId && this.config.allowedUserIds.has(inbound.author.id)) { + return true; + } + return this.#isAllowedChannel(inbound.channelId); + } + + #isAllowedSessionUser(session: DiscordBridgeSession, userId: string): boolean { + return ( + this.config.allowedUserIds.has(userId) || + session.ownerUserId === userId || + Boolean(session.participantUserIds?.includes(userId)) + ); + } + + #isSessionInClearScope( + session: DiscordBridgeSession, + command: DiscordClearInbound, + ): boolean { + if (!command.guildId) { + return true; + } + return session.guildId === command.guildId || + (!session.guildId && session.parentChannelId === command.channelId); + } + + async #addThreadMembers( + discordThreadId: string, + participantUserIds: string[], + ): Promise { + if (participantUserIds.length === 0 || !this.transport.addThreadMembers) { + return; + } + try { + await this.transport.addThreadMembers(discordThreadId, participantUserIds); + this.#debug("discord.thread.members.added", { + discordThreadId, + participantUserIds, + }); + } catch (error) { + this.#debug("discord.thread.members.addFailed", { + discordThreadId, + participantUserIds, + error: errorMessage(error), + }); + } + } + + async #deleteSourceMessage(session: DiscordBridgeSession): Promise { + if (!session.sourceMessageId) { + return; + } + try { + await this.transport.deleteMessage( + session.parentChannelId, + session.sourceMessageId, + ); + this.#debug("clear.sourceMessageDeleted", { + parentChannelId: session.parentChannelId, + sourceMessageId: session.sourceMessageId, + discordThreadId: session.discordThreadId, + }); + } catch (error) { + this.#debug("clear.sourceMessageDeleteFailed", { + parentChannelId: session.parentChannelId, + sourceMessageId: session.sourceMessageId, + discordThreadId: session.discordThreadId, + error: errorMessage(error), + }); + } + } + + #threadStartParams(cwd: string | undefined): v2.ThreadStartParams { + return { + cwd: cwd ?? this.config.cwd ?? null, + model: this.config.model ?? null, + modelProvider: this.config.modelProvider ?? null, + serviceTier: this.config.serviceTier ?? null, + approvalPolicy: this.config.approvalPolicy ?? null, + sandbox: this.config.sandbox ?? null, + permissions: this.config.permissions ?? null, + threadSource: "user", + experimentalRawEvents: false, + persistExtendedHistory: false, + }; + } + + #threadResumeParams( + threadId: string, + cwd: string | undefined, + ): v2.ThreadResumeParams { + return { + threadId, + cwd: cwd ?? null, + model: this.config.model ?? null, + modelProvider: this.config.modelProvider ?? null, + serviceTier: this.config.serviceTier ?? null, + approvalPolicy: this.config.approvalPolicy ?? null, + sandbox: this.config.sandbox ?? null, + permissions: this.config.permissions ?? null, + persistExtendedHistory: false, + }; + } + + async #readThreadSnapshot(threadId: string): Promise { + try { + const response = await this.client.readThread({ + threadId, + includeTurns: true, + }); + return threadSnapshotFromThread(response.thread); + } catch (error) { + this.#debug("thread.final.readFailed", { + threadId, + error: errorMessage(error), + }); + return emptyThreadSnapshot(); + } + } + + #recordResumeHistoryDeliveries( + session: DiscordBridgeSession, + sourceMessageId: string, + snapshot: ThreadSnapshot, + lastFinalOutboundMessageIds: string[], + ): void { + const state = this.#requireState(); + addProcessedMessageId(state, sourceMessageId); + for (const turnId of snapshot.terminalTurnIds) { + if ( + state.deliveries.some((delivery) => + delivery.discordThreadId === session.discordThreadId && + delivery.codexThreadId === session.codexThreadId && + delivery.turnId === turnId && + delivery.kind === "final" + ) + ) { + continue; + } + state.deliveries.push({ + discordMessageId: `resume:${sourceMessageId}:${turnId}`, + discordThreadId: session.discordThreadId, + codexThreadId: session.codexThreadId, + turnId, + kind: "final", + outboundMessageIds: turnId === snapshot.lastFinal?.turnId + ? lastFinalOutboundMessageIds + : [], + deliveredAt: this.#now().toISOString(), + }); + } + } + + async #persist(): Promise { + const save = this.#persistChain + .catch(() => undefined) + .then(async () => { + await this.store.save(this.#requireState()); + this.#debug("state.persisted", { + sessions: this.#requireState().sessions.length, + queue: this.#requireState().queue.length, + deliveries: this.#requireState().deliveries.length, + processed: this.#requireState().processedMessageIds.length, + }); + }); + this.#persistChain = save; + await save; + } + + #requireState(): DiscordBridgeState { + if (!this.#state) { + throw new Error("Discord bridge is not started"); + } + return this.#state; + } + + #debug(event: string, fields: Record = {}): void { + this.#logger.debug(event, fields); + } + + #error(event: string, fields: Record = {}): void { + this.#logger.error(event, fields); + } +} + +export function splitDiscordMessage(text: string): string[] { + const chunks: string[] = []; + let remaining = text.trim(); + while (remaining.length > maxDiscordMessageLength) { + const splitAt = bestSplitIndex(remaining, maxDiscordMessageLength); + chunks.push(remaining.slice(0, splitAt).trimEnd()); + remaining = remaining.slice(splitAt).trimStart(); + } + if (remaining) { + chunks.push(remaining); + } + return chunks.length > 0 ? chunks : [""]; +} + +function threadTitle(command: DiscordThreadStartInbound, prompt = threadPrompt(command)): string { + return truncateDiscordThreadName( + command.title?.trim() || + firstLine(prompt) || + `Codex ${command.author.name}`, + ); +} + +function threadPrompt(command: DiscordThreadStartInbound): string { + let prompt = command.prompt ?? ""; + for (const userId of command.mentionedUserIds ?? []) { + prompt = prompt.replace(new RegExp(`<@!?${escapeRegExp(userId)}>`, "g"), ""); + } + return prompt.trim(); +} + +type ThreadStartIntent = + | { kind: "new"; prompt: string; cwd?: string } + | { kind: "resume"; codexThreadId: string; cwd?: string } + | { kind: "invalid"; message: string }; + +export function parseThreadStartIntent(text: string): ThreadStartIntent { + const tokens = tokenize(text); + const removeRanges: TextRange[] = []; + let cwd: string | undefined; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) { + continue; + } + const inlineDir = inlineDirValue(token.value); + if (inlineDir !== undefined) { + cwd = resolveHomeDir(inlineDir); + removeRanges.push({ start: token.start, end: token.end }); + continue; + } + if (token.value === "--dir" || token.value === "--cwd") { + const next = tokens[index + 1]; + if (!next) { + return { kind: "invalid", message: "Missing directory after --dir." }; + } + cwd = resolveHomeDir(next.value); + removeRanges.push({ start: token.start, end: next.end }); + index += 1; + } + } + const remainingText = removeRangesFromText(text, removeRanges).trim(); + const remainingTokens = tokenize(remainingText); + if (remainingTokens[0]?.value === "resume") { + const codexThreadId = remainingTokens[1]?.value; + if (!codexThreadId) { + return { + kind: "invalid", + message: "Usage: @codex resume [--dir path]", + }; + } + return { kind: "resume", codexThreadId, cwd }; + } + return { kind: "new", prompt: remainingText, cwd }; +} + +function resumeThreadTitle( + command: DiscordThreadStartInbound, + codexThreadId: string, +): string { + return truncateDiscordThreadName( + command.title?.trim() || `Codex ${compactId(codexThreadId)}`, + ); +} + +type TextToken = { + value: string; + start: number; + end: number; +}; + +type TextRange = { + start: number; + end: number; +}; + +function tokenize(text: string): TextToken[] { + const tokens: TextToken[] = []; + let index = 0; + while (index < text.length) { + while (index < text.length && /\s/.test(text[index] ?? "")) { + index += 1; + } + if (index >= text.length) { + break; + } + const start = index; + const quote = text[index] === "\"" || text[index] === "'" + ? text[index] + : undefined; + let value = ""; + if (quote) { + index += 1; + while (index < text.length && text[index] !== quote) { + value += text[index] ?? ""; + index += 1; + } + if (text[index] === quote) { + index += 1; + } + tokens.push({ value, start, end: index }); + continue; + } + while (index < text.length && !/\s/.test(text[index] ?? "")) { + value += text[index] ?? ""; + index += 1; + } + tokens.push({ value, start, end: index }); + } + return tokens; +} + +function inlineDirValue(value: string): string | undefined { + if (value.startsWith("--dir=")) { + return value.slice("--dir=".length); + } + if (value.startsWith("--cwd=")) { + return value.slice("--cwd=".length); + } + return undefined; +} + +function removeRangesFromText(text: string, ranges: TextRange[]): string { + if (ranges.length === 0) { + return text; + } + const sorted = [...ranges].sort((left, right) => left.start - right.start); + let result = ""; + let cursor = 0; + for (const range of sorted) { + result += text.slice(cursor, range.start); + cursor = Math.max(cursor, range.end); + } + result += text.slice(cursor); + return result.replace(/[ \t]{2,}/g, " "); +} + +function resolveHomeDir(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + if (path.isAbsolute(value)) { + return value; + } + return path.join(os.homedir(), value); +} + +function truncateDiscordThreadName(name: string): string { + const trimmed = name.trim().replace(/\s+/g, " "); + if (trimmed.length <= 90) { + return trimmed || "Codex thread"; + } + return `${trimmed.slice(0, 87).trimEnd()}...`; +} + +function firstLine(value: string | undefined): string | undefined { + const line = value?.split(/\r?\n/, 1)[0]?.trim(); + return line || undefined; +} + +function bestSplitIndex(text: string, maxLength: number): number { + const newline = text.lastIndexOf("\n", maxLength); + if (newline > maxLength * 0.6) { + return newline; + } + const space = text.lastIndexOf(" ", maxLength); + if (space > maxLength * 0.6) { + return space; + } + return maxLength; +} + +function isDuplicate(state: DiscordBridgeState, messageId: string): boolean { + return ( + state.processedMessageIds.includes(messageId) || + state.queue.some((item) => item.discordMessageId === messageId) || + state.deliveries.some((delivery) => delivery.discordMessageId === messageId) + ); +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function compactId(value: string): string { + return value.length > 14 ? `${value.slice(0, 6)}...${value.slice(-6)}` : value; +} + +function clearSummary(input: { + deleted: number; + running: number; + failed: number; +}): string { + const parts = [ + `Deleted ${input.deleted} inactive Discord thread${input.deleted === 1 ? "" : "s"}.`, + ]; + if (input.running > 0) { + parts.push(`Left ${input.running} running thread${input.running === 1 ? "" : "s"} alone.`); + } + if (input.failed > 0) { + parts.push(`Failed to delete ${input.failed} thread${input.failed === 1 ? "" : "s"}.`); + } + return parts.join(" "); +} + +function emptyThreadSnapshot(): ThreadSnapshot { + return { terminalTurnIds: [] }; +} + +function mergeThreadSnapshots( + first: ThreadSnapshot, + second: ThreadSnapshot, +): ThreadSnapshot { + const terminalTurnIds = [ + ...new Set([...first.terminalTurnIds, ...second.terminalTurnIds]), + ]; + return { + terminalTurnIds, + lastFinal: first.lastFinal ?? second.lastFinal, + }; +} + +function threadSnapshotFromThread(thread: { turns?: unknown[] }): ThreadSnapshot { + const turns = Array.isArray(thread.turns) ? thread.turns : []; + const terminalTurnIds: string[] = []; + let lastFinal: ThreadSnapshot["lastFinal"]; + for (const turn of turns) { + const parsed = record(turn); + const turnId = stringValue(parsed.id); + if (turnId && isTerminalTurnStatus(parsed.status)) { + terminalTurnIds.push(turnId); + } + } + for (const turn of [...turns].reverse()) { + const parsed = record(turn); + const turnId = stringValue(parsed.id); + const text = lastFinalTextFromTurn(parsed); + if (turnId && text) { + lastFinal = { turnId, text }; + break; + } + } + if (lastFinal && !terminalTurnIds.includes(lastFinal.turnId)) { + terminalTurnIds.push(lastFinal.turnId); + } + return { + terminalTurnIds: [...new Set(terminalTurnIds)], + lastFinal, + }; +} + +function resumeResponseCwd(response: unknown): string | undefined { + const responseRecord = record(response); + return stringValue(responseRecord.cwd) ?? + stringValue(record(responseRecord.thread).cwd); +} + +function lastFinalTextFromTurn(turn: Record): string { + const items = Array.isArray(turn.items) ? turn.items : []; + for (const item of [...items].reverse()) { + const candidate = record(item); + if ( + candidate.type === "agentMessage" && + candidate.phase === "final_answer" + ) { + return stringValue(candidate.text)?.trim() ?? ""; + } + } + return ""; +} + +function isTerminalTurnStatus(value: unknown): boolean { + return value === "completed" || value === "failed" || value === "interrupted"; +} + +function addProcessedMessageId(state: DiscordBridgeState, messageId: string): void { + state.processedMessageIds = [ + ...state.processedMessageIds.filter((candidate) => candidate !== messageId), + messageId, + ].slice(-1000); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function normalizeParticipantUserIds( + userIds: string[] | undefined, + ownerUserId: string, +): string[] { + return [...new Set((userIds ?? []).filter( + (userId) => userId.length > 0 && userId !== ownerUserId, + ))]; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/apps/discord-bridge/src/config.ts b/apps/discord-bridge/src/config.ts new file mode 100644 index 0000000..4a025ab --- /dev/null +++ b/apps/discord-bridge/src/config.ts @@ -0,0 +1,368 @@ +import os from "node:os"; +import path from "node:path"; + +import type { + ReasoningEffort, + ReasoningSummary, + v2, +} from "@peezy-tech/codex-flows/generated"; + +import type { + DiscordBridgeConfig, + DiscordConsoleOutputMode, + DiscordProgressMode, +} from "./types.ts"; +import type { DiscordBridgeLogLevelSetting } from "./logger.ts"; + +export type ParsedConfig = + | { + type: "run"; + discordToken: string; + appServerUrl?: string; + localAppServer?: boolean; + config: DiscordBridgeConfig; + } + | { type: "help"; text: string }; + +const effortValues = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +]); +const summaryValues = new Set([ + "auto", + "concise", + "detailed", + "none", +]); +const progressModeValues = new Set([ + "summary", + "commentary", + "none", +]); +const consoleOutputValues = new Set([ + "messages", + "none", +]); +const logLevelValues = new Set([ + "debug", + "info", + "warn", + "error", + "silent", +]); +const approvalPolicyValues = new Set([ + "untrusted", + "on-failure", + "on-request", + "never", +]); +const sandboxValues = new Set([ + "read-only", + "workspace-write", + "danger-full-access", +]); + +export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfig { + const args = parseFlags(argv); + if (args.has("help") || args.has("h")) { + return { type: "help", text: helpText() }; + } + const discordToken = stringFlag(args, "token") ?? env.CODEX_DISCORD_BOT_TOKEN; + if (!discordToken) { + throw new Error("Missing Discord bot token. Set CODEX_DISCORD_BOT_TOKEN or pass --token."); + } + const allowedUserIds = csvSet( + stringFlag(args, "allowed-user-ids") ?? env.CODEX_DISCORD_ALLOWED_USER_IDS, + ); + if (allowedUserIds.size === 0) { + throw new Error( + "Missing allowed Discord users. Set CODEX_DISCORD_ALLOWED_USER_IDS or pass --allowed-user-ids.", + ); + } + const explicitAppServerUrl = + stringFlag(args, "app-server-url") ?? + stringFlag(args, "url"); + const localAppServer = booleanFlag(args, "local-app-server"); + if (localAppServer && explicitAppServerUrl) { + throw new Error("Cannot set both --local-app-server and --app-server-url."); + } + const appServerUrl = localAppServer + ? undefined + : explicitAppServerUrl ?? env.CODEX_WORKSPACE_APP_SERVER_WS_URL; + const statePath = + stringFlag(args, "state-path") ?? + env.CODEX_DISCORD_STATE_PATH ?? + path.join(os.homedir(), ".codex", "discord-bridge", "state.json"); + const permissionsProfile = stringFlag(args, "permissions-profile") ?? + env.CODEX_DISCORD_PERMISSIONS_PROFILE; + const approvalPolicy = optionalApprovalPolicy( + stringFlag(args, "approval-policy") ?? env.CODEX_DISCORD_APPROVAL_POLICY, + ); + const sandbox = optionalSandbox( + stringFlag(args, "sandbox") ?? env.CODEX_DISCORD_SANDBOX, + ); + if (sandbox && permissionsProfile) { + throw new Error("Cannot set both --sandbox and --permissions-profile."); + } + const debug = booleanFlag(args, "debug") || envFlag(env.CODEX_DISCORD_DEBUG); + const logLevel = optionalLogLevel( + stringFlag(args, "log-level") ?? env.CODEX_DISCORD_LOG_LEVEL, + ) ?? (debug ? "debug" : undefined); + + return { + type: "run", + discordToken, + appServerUrl, + localAppServer, + config: { + allowedUserIds, + allowedChannelIds: csvSet( + stringFlag(args, "allowed-channel-ids") ?? + env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS, + ), + statePath, + cwd: resolveHomeDir( + stringFlag(args, "dir") ?? + stringFlag(args, "positional-dir") ?? + env.CODEX_DISCORD_DIR ?? + stringFlag(args, "cwd") ?? + env.CODEX_DISCORD_CWD, + ), + model: stringFlag(args, "model") ?? env.CODEX_DISCORD_MODEL, + modelProvider: + stringFlag(args, "model-provider") ?? + env.CODEX_DISCORD_MODEL_PROVIDER, + serviceTier: + stringFlag(args, "service-tier") ?? env.CODEX_DISCORD_SERVICE_TIER, + effort: optionalEffort( + stringFlag(args, "effort") ?? env.CODEX_DISCORD_EFFORT, + ), + summary: optionalSummary( + stringFlag(args, "summary") ?? + env.CODEX_DISCORD_REASONING_SUMMARY ?? + "auto", + ), + progressMode: optionalProgressMode( + stringFlag(args, "progress-mode") ?? + env.CODEX_DISCORD_PROGRESS_MODE ?? + "summary", + ), + consoleOutput: optionalConsoleOutput( + stringFlag(args, "console-output") ?? + env.CODEX_DISCORD_CONSOLE_OUTPUT, + ), + logLevel, + approvalPolicy, + sandbox, + permissions: permissionsProfile + ? { type: "profile", id: permissionsProfile } + : undefined, + debug, + }, + }; +} + +function parseFlags(argv: string[]): Map { + const flags = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg?.startsWith("--")) { + if (flags.has("positional-dir")) { + throw new Error(`Unexpected argument: ${arg ?? ""}`); + } + flags.set("positional-dir", arg ?? ""); + continue; + } + const [rawName, inlineValue] = arg.slice(2).split("=", 2); + if (!rawName) { + throw new Error(`Invalid flag: ${arg}`); + } + if (inlineValue !== undefined) { + flags.set(rawName, inlineValue); + continue; + } + if (booleanFlagNames.has(rawName)) { + flags.set(rawName, true); + continue; + } + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + flags.set(rawName, true); + continue; + } + flags.set(rawName, next); + index += 1; + } + if ( + flags.has("positional-dir") && + (flags.has("dir") || flags.has("cwd")) + ) { + throw new Error("Cannot set both positional directory and --dir/--cwd."); + } + return flags; +} + +const booleanFlagNames = new Set(["debug", "help", "h", "local-app-server"]); + +function stringFlag( + flags: Map, + name: string, +): string | undefined { + const value = flags.get(name); + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function csvSet(value: string | undefined): Set { + return new Set( + (value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); +} + +function booleanFlag(flags: Map, name: string): boolean { + const value = flags.get(name); + if (value === true) { + return true; + } + return envFlag(typeof value === "string" ? value : undefined); +} + +function envFlag(value: string | undefined): boolean { + return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""); +} + +function optionalEffort(value: string | undefined): ReasoningEffort | undefined { + if (!value) { + return undefined; + } + if (!effortValues.has(value as ReasoningEffort)) { + throw new Error("Invalid effort. Expected none, minimal, low, medium, high, or xhigh."); + } + return value as ReasoningEffort; +} + +function optionalSummary(value: string | undefined): ReasoningSummary | undefined { + if (!value) { + return undefined; + } + if (!summaryValues.has(value as ReasoningSummary)) { + throw new Error("Invalid summary. Expected auto, concise, detailed, or none."); + } + return value as ReasoningSummary; +} + +function optionalProgressMode(value: string | undefined): DiscordProgressMode | undefined { + if (!value) { + return undefined; + } + if (!progressModeValues.has(value as DiscordProgressMode)) { + throw new Error("Invalid progress mode. Expected summary, commentary, or none."); + } + return value as DiscordProgressMode; +} + +function optionalConsoleOutput( + value: string | undefined, +): DiscordConsoleOutputMode | undefined { + if (!value) { + return undefined; + } + if (!consoleOutputValues.has(value as DiscordConsoleOutputMode)) { + throw new Error("Invalid console output. Expected messages or none."); + } + return value as DiscordConsoleOutputMode; +} + +function optionalLogLevel( + value: string | undefined, +): DiscordBridgeLogLevelSetting | undefined { + if (!value) { + return undefined; + } + if (!logLevelValues.has(value as DiscordBridgeLogLevelSetting)) { + throw new Error("Invalid log level. Expected debug, info, warn, error, or silent."); + } + return value as DiscordBridgeLogLevelSetting; +} + +function optionalApprovalPolicy( + value: string | undefined, +): v2.AskForApproval | undefined { + if (!value) { + return undefined; + } + if (!approvalPolicyValues.has(value)) { + throw new Error( + "Invalid approval policy. Expected untrusted, on-failure, on-request, or never.", + ); + } + return value as v2.AskForApproval; +} + +function optionalSandbox(value: string | undefined): v2.SandboxMode | undefined { + if (!value) { + return undefined; + } + if (!sandboxValues.has(value as v2.SandboxMode)) { + throw new Error( + "Invalid sandbox. Expected read-only, workspace-write, or danger-full-access.", + ); + } + return value as v2.SandboxMode; +} + +function helpText(): string { + return `codex-discord-bridge connects Discord threads to Codex app-server threads. + +Usage: + codex-discord-bridge [options] [dir] + +Required: + --token Discord bot token, or CODEX_DISCORD_BOT_TOKEN + --allowed-user-ids Comma-separated Discord user ids, or CODEX_DISCORD_ALLOWED_USER_IDS + +Options: + --app-server-url Existing app-server WebSocket URL + --local-app-server Start a local app-server over stdio + --state-path Persistent bridge state file + --allowed-channel-ids Comma-separated parent channel ids + [dir] Optional Codex thread directory, resolved from home + --dir Codex thread directory, resolved from home + --cwd Alias for --dir + --model Codex model override + --model-provider Codex model provider override + --service-tier Codex service tier override + --effort none|minimal|low|medium|high|xhigh + --summary auto|concise|detailed|none + --progress-mode summary|commentary|none + --console-output messages|none + --log-level debug|info|warn|error|silent + --approval-policy untrusted|on-failure|on-request|never + --sandbox read-only|workspace-write|danger-full-access + --permissions-profile Named Codex permissions profile + --debug Emit verbose bridge diagnostics to stderr + --help Show this help +`; +} + +function resolveHomeDir(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + if (path.isAbsolute(value)) { + return value; + } + return path.join(os.homedir(), value); +} diff --git a/apps/discord-bridge/src/console-output.ts b/apps/discord-bridge/src/console-output.ts new file mode 100644 index 0000000..7079ea7 --- /dev/null +++ b/apps/discord-bridge/src/console-output.ts @@ -0,0 +1,99 @@ +export type DiscordConsoleMessageKind = + | "summary" + | "commentary" + | "final" + | "error"; + +export type DiscordConsoleMessage = { + kind: DiscordConsoleMessageKind; + text: string; + discordThreadId: string; + codexThreadId: string; + turnId?: string; + title?: string; + at?: Date; +}; + +export type DiscordConsoleOutput = { + message(message: DiscordConsoleMessage): void; +}; + +export type ConsoleMessageOutputOptions = { + color?: boolean; + now?: () => Date; + stream?: Pick; +}; + +export type ConsoleMessageFormatOptions = { + color?: boolean; + now?: () => Date; +}; + +const resetColor = "\x1b[0m"; +const kindColors: Record = { + summary: "\x1b[90m", + commentary: "\x1b[36m", + final: "\x1b[32m", + error: "\x1b[31m", +}; + +export function createDiscordConsoleOutput( + options: ConsoleMessageOutputOptions = {}, +): DiscordConsoleOutput { + const stream = options.stream ?? process.stdout; + const color = options.color ?? + Boolean(process.stdout.isTTY && !process.env.NO_COLOR); + const now = options.now ?? (() => new Date()); + return { + message(message) { + stream.write(`${formatConsoleMessage(message, { color, now })}\n`); + }, + }; +} + +export function formatConsoleMessage( + message: DiscordConsoleMessage, + options: ConsoleMessageFormatOptions = {}, +): string { + const now = options.now ?? (() => new Date()); + const time = formatTime(message.at ?? now()); + const kind = message.kind.toUpperCase().padEnd(10); + const coloredKind = colorize(kind, kindColors[message.kind], options.color ?? false); + const title = (message.title?.trim() || compactId(message.codexThreadId)).replace( + /\s+/g, + " ", + ); + const metadata = [ + `thread=${compactId(message.codexThreadId)}`, + message.turnId ? `turn=${compactId(message.turnId)}` : undefined, + ].filter(Boolean).join(" "); + const header = `[${time}] ${coloredKind} ${title} ${metadata}`; + const body = formatBody(message.text); + return body ? `${header}\n${body}` : header; +} + +function formatBody(text: string): string { + const trimmed = text.trim(); + if (!trimmed) { + return ""; + } + return trimmed + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +} + +function formatTime(date: Date): string { + return date.toISOString().slice(11, 23); +} + +function compactId(id: string): string { + if (id.length <= 12) { + return id; + } + return `${id.slice(0, 6)}...${id.slice(-4)}`; +} + +function colorize(text: string, color: string, enabled: boolean): string { + return enabled ? `${color}${text}${resetColor}` : text; +} diff --git a/apps/discord-bridge/src/discord-transport.ts b/apps/discord-bridge/src/discord-transport.ts new file mode 100644 index 0000000..bb40692 --- /dev/null +++ b/apps/discord-bridge/src/discord-transport.ts @@ -0,0 +1,380 @@ +import { + Client, + Events, + GatewayIntentBits, + type Interaction, + type Message, +} from "discord.js"; + +import { splitDiscordMessage } from "./bridge.ts"; +import { + createDiscordBridgeLogger, + type DiscordBridgeLogger, +} from "./logger.ts"; +import type { + DiscordBridgeTransport, + DiscordBridgeTransportHandlers, +} from "./types.ts"; + +export type DiscordJsBridgeTransportOptions = { + token: string; + logger?: DiscordBridgeLogger; +}; + +export class DiscordJsBridgeTransport implements DiscordBridgeTransport { + #token: string; + #logger: DiscordBridgeLogger; + #client: Client | undefined; + #handlers: DiscordBridgeTransportHandlers | undefined; + + constructor(options: DiscordJsBridgeTransportOptions) { + this.#token = options.token; + this.#logger = options.logger ?? createDiscordBridgeLogger(); + } + + async start(handlers: DiscordBridgeTransportHandlers): Promise { + this.#handlers = handlers; + if (this.#client) { + return; + } + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + }); + this.#client = client; + client.once(Events.ClientReady, (readyClient) => { + this.#logger.info("discord.connected", { + userId: readyClient.user.id, + tag: readyClient.user.tag, + }); + }); + client.on(Events.MessageCreate, (message) => this.#handleMessage(message)); + client.on(Events.InteractionCreate, (interaction) => + void this.#handleInteraction(interaction).catch((error) => { + this.#logger.error("discord.interaction.failed", { + error: errorMessage(error), + }); + }) + ); + await client.login(this.#token); + } + + async stop(): Promise { + this.#client?.destroy(); + this.#client = undefined; + } + + async registerCommands(): Promise { + const application = this.#client?.application; + if (!application) { + return; + } + await application.commands.set([ + { + name: "clear", + description: "Delete inactive Codex bridge threads", + }, + ]); + } + + async createThread( + channelId: string, + name: string, + sourceMessageId?: string, + ): Promise { + const channel = await this.#sendableChannel(channelId); + if (sourceMessageId) { + const messages = getMessagesManager(channel); + if (messages) { + const sourceMessage = await messages.fetch(sourceMessageId); + if (sourceMessage.startThread) { + const thread = await sourceMessage.startThread({ + name, + autoArchiveDuration: 1440, + reason: "Codex Discord bridge thread", + }); + if (thread.id) { + return thread.id; + } + } + } + } + const threads = getThreadsManager(channel); + if (!threads) { + throw new Error(`Discord channel cannot create threads: ${channelId}`); + } + const thread = await threads.create({ + name, + autoArchiveDuration: 1440, + reason: "Codex Discord bridge thread", + }); + if (!thread.id) { + throw new Error("Discord did not return a thread id"); + } + return thread.id; + } + + async sendMessage(channelId: string, text: string): Promise { + const channel = await this.#sendableChannel(channelId); + const messageIds: string[] = []; + for (const chunk of splitDiscordMessage(text)) { + const sent = await channel.send({ + content: chunk, + allowedMentions: { + parse: [], + users: [], + roles: [], + repliedUser: false, + }, + }); + if (typeof sent.id === "string") { + messageIds.push(sent.id); + } + } + return messageIds; + } + + async updateMessage( + channelId: string, + messageId: string, + text: string, + ): Promise { + const channel = await this.#sendableChannel(channelId); + const messages = getMessagesManager(channel); + if (!messages) { + throw new Error(`Discord channel cannot fetch messages: ${channelId}`); + } + const message = await messages.fetch(messageId); + await message.edit({ + content: splitDiscordMessage(text)[0] ?? "", + allowedMentions: { + parse: [], + users: [], + roles: [], + repliedUser: false, + }, + }); + } + + async deleteMessage(channelId: string, messageId: string): Promise { + const channel = await this.#sendableChannel(channelId); + const messages = getMessagesManager(channel); + if (!messages) { + throw new Error(`Discord channel cannot fetch messages: ${channelId}`); + } + const message = await messages.fetch(messageId); + await message.delete(); + } + + async deleteThread(channelId: string): Promise { + const client = this.#client; + if (!client) { + throw new Error("Discord bridge is not connected"); + } + const channel = await client.channels.fetch(channelId); + if (!channel || !("delete" in channel) || typeof channel.delete !== "function") { + throw new Error(`Discord channel cannot be deleted: ${channelId}`); + } + await channel.delete("Codex Discord bridge clear command"); + } + + async addThreadMembers(channelId: string, userIds: string[]): Promise { + const channel = await this.#sendableChannel(channelId); + const members = getThreadMembersManager(channel); + if (!members) { + throw new Error(`Discord channel cannot add thread members: ${channelId}`); + } + for (const userId of userIds) { + await members.add(userId); + } + } + + async pinMessage(channelId: string, messageId: string): Promise { + const channel = await this.#sendableChannel(channelId); + const messages = getMessagesManager(channel); + if (!messages) { + throw new Error(`Discord channel cannot fetch messages: ${channelId}`); + } + const message = await messages.fetch(messageId); + if (!message.pin) { + throw new Error(`Discord message cannot be pinned: ${messageId}`); + } + if (message.pinned) { + return; + } + await message.pin(); + } + + async sendTyping(channelId: string): Promise { + const channel = await this.#sendableChannel(channelId); + await channel.sendTyping?.(); + } + + #handleMessage(message: Message): void { + const botUserId = this.#client?.user?.id; + if ( + botUserId && + !isThreadChannel(message.channel) && + message.mentions.users.has(botUserId) + ) { + const mentionedUserIds = message.mentions.users + .filter((user) => user.id !== botUserId && !user.bot) + .map((user) => user.id); + const prompt = stripUserMentions(message.content ?? "", [ + botUserId, + ...mentionedUserIds, + ]); + this.#handlers?.onInbound({ + kind: "threadStart", + sourceMessageId: message.id, + channelId: message.channelId, + guildId: message.guildId ?? undefined, + author: { + id: message.author.id, + name: message.member?.displayName || + message.author.globalName || + message.author.username, + isBot: message.author.bot, + }, + prompt, + mentionedUserIds, + createdAt: message.createdAt.toISOString(), + }); + return; + } + this.#handlers?.onInbound({ + kind: "message", + channelId: message.channelId, + guildId: message.guildId ?? undefined, + messageId: message.id, + author: { + id: message.author.id, + name: message.member?.displayName || + message.author.globalName || + message.author.username, + isBot: message.author.bot, + }, + content: message.content ?? "", + createdAt: message.createdAt.toISOString(), + }); + } + + async #handleInteraction(interaction: Interaction): Promise { + if (!interaction.isChatInputCommand() || interaction.commandName !== "clear") { + return; + } + const channelId = interaction.channelId; + this.#handlers?.onInbound({ + kind: "clear", + channelId, + guildId: interaction.guildId ?? undefined, + author: { + id: interaction.user.id, + name: interaction.member && "displayName" in interaction.member + ? String(interaction.member.displayName) + : interaction.user.globalName || interaction.user.username, + isBot: interaction.user.bot, + }, + createdAt: new Date().toISOString(), + reply: async (text) => { + await interaction.reply({ + content: text, + ephemeral: true, + allowedMentions: { + parse: [], + users: [], + roles: [], + repliedUser: false, + }, + }); + }, + }); + } + + async #sendableChannel(channelId: string): Promise { + const client = this.#client; + if (!client) { + throw new Error("Discord bridge is not connected"); + } + const channel = await client.channels.fetch(channelId); + if (!channel || !("send" in channel)) { + throw new Error(`Discord channel is not text-sendable: ${channelId}`); + } + return channel as unknown as SendableChannel; + } +} + +type ThreadCreateOptions = { + name: string; + autoArchiveDuration?: number; + reason?: string; +}; + +type SendableChannel = { + id: string; + send(options: Record): Promise<{ id?: string }>; + sendTyping?: () => Promise; + threads?: { + create(options: ThreadCreateOptions): Promise<{ id?: string }>; + }; + members?: { + add(userId: string): Promise; + }; + messages?: { + fetch(messageId: string): Promise<{ + delete(): Promise; + edit(options: Record): Promise; + pinned?: boolean; + pin?(): Promise; + startThread?(options: ThreadCreateOptions): Promise<{ id?: string }>; + }>; + }; +}; + +function getThreadsManager( + channel: SendableChannel, +): SendableChannel["threads"] | undefined { + return channel.threads; +} + +function getMessagesManager( + channel: SendableChannel, +): SendableChannel["messages"] | undefined { + return channel.messages; +} + +function getThreadMembersManager( + channel: SendableChannel, +): SendableChannel["members"] | undefined { + return channel.members; +} + +function isThreadChannel(channel: unknown): boolean { + return Boolean( + channel && + typeof channel === "object" && + "isThread" in channel && + typeof channel.isThread === "function" && + channel.isThread(), + ); +} + +function stripUserMentions(content: string, userIds: string[]): string { + let stripped = content; + for (const userId of userIds) { + stripped = stripped.replace(new RegExp(`<@!?${escapeRegExp(userId)}>`, "g"), ""); + } + return stripped.trim(); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/discord-bridge/src/index.ts b/apps/discord-bridge/src/index.ts new file mode 100644 index 0000000..357a222 --- /dev/null +++ b/apps/discord-bridge/src/index.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env bun +import { + CodexAppServerClient, + CodexStdioTransport, +} from "@peezy-tech/codex-flows"; + +import { DiscordCodexBridge } from "./bridge.ts"; +import { createDiscordConsoleOutput } from "./console-output.ts"; +import { parseConfig } from "./config.ts"; +import { DiscordJsBridgeTransport } from "./discord-transport.ts"; +import { createDiscordBridgeLogger } from "./logger.ts"; +import { JsonFileStateStore } from "./state.ts"; + +async function main(): Promise { + let logger = createDiscordBridgeLogger(); + try { + const parsed = parseConfig(Bun.argv.slice(2), process.env); + if (parsed.type === "help") { + process.stdout.write(parsed.text); + return; + } + logger = createDiscordBridgeLogger({ + debug: parsed.config.debug, + logLevel: parsed.config.logLevel, + }); + const consoleOutput = parsed.config.consoleOutput === "messages" + ? createDiscordConsoleOutput() + : undefined; + const client = new CodexAppServerClient({ + transport: parsed.localAppServer + ? new CodexStdioTransport({ + args: localAppServerArgs(), + requestTimeoutMs: 90_000, + }) + : undefined, + webSocketTransportOptions: parsed.appServerUrl + ? { url: parsed.appServerUrl, requestTimeoutMs: 90_000 } + : undefined, + clientName: "codex-discord-bridge", + clientTitle: "Codex Discord Bridge", + clientVersion: "0.1.0", + }); + const bridge = new DiscordCodexBridge({ + client, + transport: new DiscordJsBridgeTransport({ + token: parsed.discordToken, + logger, + }), + store: new JsonFileStateStore(parsed.config.statePath), + config: parsed.config, + logger, + consoleOutput, + }); + await bridge.start(); + logger.info("bridge.started", { + appServerUrl: parsed.appServerUrl ?? "local", + localAppServer: Boolean(parsed.localAppServer), + progressMode: parsed.config.progressMode ?? "summary", + statePath: parsed.config.statePath, + }); + await waitForShutdown(bridge); + } catch (error) { + logger.error("bridge.fatal", { error: errorMessage(error) }); + process.exitCode = 1; + } +} + +function localAppServerArgs(): string[] { + return [ + "app-server", + "--listen", + "stdio://", + "--enable", + "apps", + "--enable", + "hooks", + ]; +} + +function waitForShutdown(bridge: DiscordCodexBridge): Promise { + return new Promise((resolve) => { + const shutdown = () => { + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + void bridge.stop().finally(resolve); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +await main(); diff --git a/apps/discord-bridge/src/logger.ts b/apps/discord-bridge/src/logger.ts new file mode 100644 index 0000000..d113ae8 --- /dev/null +++ b/apps/discord-bridge/src/logger.ts @@ -0,0 +1,71 @@ +export type DiscordBridgeLogLevel = "debug" | "info" | "warn" | "error"; +export type DiscordBridgeLogLevelSetting = DiscordBridgeLogLevel | "silent"; + +export type DiscordBridgeLogFields = Record; + +export type DiscordBridgeLogger = { + debug(event: string, fields?: DiscordBridgeLogFields): void; + info(event: string, fields?: DiscordBridgeLogFields): void; + warn(event: string, fields?: DiscordBridgeLogFields): void; + error(event: string, fields?: DiscordBridgeLogFields): void; +}; + +export type DiscordBridgeLoggerOptions = { + component?: string; + debug?: boolean; + logLevel?: DiscordBridgeLogLevelSetting; + now?: () => Date; + stream?: Pick; +}; + +const logLevelRanks: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export function createDiscordBridgeLogger( + options: DiscordBridgeLoggerOptions = {}, +): DiscordBridgeLogger { + const component = options.component ?? "codex-discord-bridge"; + const now = options.now ?? (() => new Date()); + const stream = options.stream ?? process.stderr; + const logLevel = options.logLevel ?? (options.debug ? "debug" : "info"); + + const write = ( + level: DiscordBridgeLogLevel, + event: string, + fields: DiscordBridgeLogFields = {}, + ): void => { + if (!shouldWrite(level, logLevel)) { + return; + } + stream.write( + `${JSON.stringify({ + time: now().toISOString(), + component, + level, + event, + ...fields, + })}\n`, + ); + }; + + return { + debug: (event, fields) => write("debug", event, fields), + info: (event, fields) => write("info", event, fields), + warn: (event, fields) => write("warn", event, fields), + error: (event, fields) => write("error", event, fields), + }; +} + +function shouldWrite( + level: DiscordBridgeLogLevel, + configured: DiscordBridgeLogLevelSetting, +): boolean { + if (configured === "silent") { + return false; + } + return logLevelRanks[level] >= logLevelRanks[configured]; +} diff --git a/apps/discord-bridge/src/pretty-log.ts b/apps/discord-bridge/src/pretty-log.ts new file mode 100644 index 0000000..1accf25 --- /dev/null +++ b/apps/discord-bridge/src/pretty-log.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env bun +import type { DiscordBridgeLogLevel } from "./logger.ts"; + +type PrettyLogOptions = { + color?: boolean; + name?: string; + now?: () => Date; +}; + +type PrettyLogRecord = Record & { + component?: unknown; + event?: unknown; + level?: unknown; + message?: unknown; + time?: unknown; +}; + +const reservedFields = new Set(["time", "component", "level", "event"]); +const resetColor = "\x1b[0m"; +const levelColors: Record = { + debug: "\x1b[90m", + info: "\x1b[36m", + warn: "\x1b[33m", + error: "\x1b[31m", +}; + +export function formatPrettyLogLine( + line: string, + options: PrettyLogOptions = {}, +): string { + const now = options.now ?? (() => new Date()); + const record = parseRecord(line); + if (!record) { + return formatParts({ + color: options.color ?? false, + component: options.name ?? "process", + fields: "", + level: "info", + message: line, + time: formatTime(now()), + }); + } + + const level = normalizeLevel(record.level); + const message = stringifyMainMessage(record); + return formatParts({ + color: options.color ?? false, + component: stringifyComponent(record.component, options.name), + fields: stringifyFields(record), + level, + message, + time: formatTime(record.time, now), + }); +} + +export async function runPrettyLogCli( + args: string[], + input: AsyncIterable, + output: Pick, +): Promise { + const options = parseCliArgs(args); + let buffer = ""; + for await (const chunk of input) { + buffer += typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString("utf8"); + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = trimTrailingCarriageReturn(buffer.slice(0, newlineIndex)); + output.write(`${formatPrettyLogLine(line, options)}\n`); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf("\n"); + } + } + if (buffer.length > 0) { + output.write( + `${formatPrettyLogLine(trimTrailingCarriageReturn(buffer), options)}\n`, + ); + } +} + +function parseCliArgs(args: string[]): PrettyLogOptions { + const options: PrettyLogOptions = { + color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR), + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--name") { + const name = args[index + 1]; + if (!name) { + throw new Error("Missing value for --name"); + } + options.name = name; + index += 1; + continue; + } + if (arg === "--color") { + options.color = true; + continue; + } + if (arg === "--no-color") { + options.color = false; + continue; + } + throw new Error(`Unexpected argument: ${arg ?? ""}`); + } + return options; +} + +function parseRecord(line: string): PrettyLogRecord | undefined { + try { + const value: unknown = JSON.parse(line); + return value !== null && typeof value === "object" + ? value as PrettyLogRecord + : undefined; + } catch { + return undefined; + } +} + +function normalizeLevel(level: unknown): DiscordBridgeLogLevel { + if (typeof level !== "string") { + return "info"; + } + const normalized = level.toLowerCase(); + if ( + normalized === "debug" || normalized === "info" || normalized === "warn" || + normalized === "error" + ) { + return normalized; + } + return "info"; +} + +function stringifyComponent(component: unknown, fallback: string | undefined): string { + return typeof component === "string" && component.length > 0 + ? component + : fallback ?? "process"; +} + +function stringifyMainMessage(record: PrettyLogRecord): string { + if (typeof record.event === "string" && record.event.length > 0) { + return record.event; + } + if (typeof record.message === "string" && record.message.length > 0) { + return record.message; + } + return "log"; +} + +function stringifyFields(record: PrettyLogRecord): string { + const fields: string[] = []; + for (const [key, value] of Object.entries(record)) { + if (reservedFields.has(key) || value === undefined) { + continue; + } + if (key === "message" && typeof record.event !== "string") { + continue; + } + fields.push(`${key}=${stringifyFieldValue(value)}`); + } + return fields.join(" "); +} + +function stringifyFieldValue(value: unknown): string { + if (typeof value === "string") { + return /^[^\s=]+$/.test(value) ? value : JSON.stringify(value); + } + if ( + typeof value === "number" || typeof value === "boolean" || value === null + ) { + return String(value); + } + return JSON.stringify(value) ?? String(value); +} + +function formatTime(time: unknown, now?: () => Date): string { + const date = time instanceof Date + ? time + : typeof time === "string" || typeof time === "number" + ? new Date(time) + : now?.() ?? new Date(); + if (Number.isNaN(date.getTime())) { + const fallback = now?.() ?? new Date(); + return fallback.toISOString().slice(11, 23); + } + return date.toISOString().slice(11, 23); +} + +function formatParts(options: { + color: boolean; + component: string; + fields: string; + level: DiscordBridgeLogLevel; + message: string; + time: string; +}): string { + const level = options.level.toUpperCase().padEnd(5); + const coloredLevel = colorize(level, levelColors[options.level], options.color); + const message = options.fields.length > 0 + ? `${options.message} ${options.fields}` + : options.message; + return `[${options.time}] ${coloredLevel} ${options.component} ${message}`; +} + +function colorize(text: string, color: string, enabled: boolean): string { + return enabled ? `${color}${text}${resetColor}` : text; +} + +function trimTrailingCarriageReturn(line: string): string { + return line.endsWith("\r") ? line.slice(0, -1) : line; +} + +if (import.meta.main) { + try { + await runPrettyLogCli(Bun.argv.slice(2), process.stdin, process.stdout); + } catch (error) { + process.stderr.write( + `pretty-log failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + } +} diff --git a/apps/discord-bridge/src/runner.ts b/apps/discord-bridge/src/runner.ts new file mode 100644 index 0000000..17102c0 --- /dev/null +++ b/apps/discord-bridge/src/runner.ts @@ -0,0 +1,2208 @@ +import type { JsonRpcNotification } from "@peezy-tech/codex-flows/rpc"; +import type { v2 } from "@peezy-tech/codex-flows/generated"; + +import type { + DiscordConsoleMessageKind, + DiscordConsoleOutput, +} from "./console-output.ts"; +import type { + CodexBridgeClient, + DiscordBridgeActiveTurn, + DiscordBridgeConfig, + DiscordBridgeDelivery, + DiscordBridgeQueueItem, + DiscordBridgeSession, + DiscordBridgeState, + DiscordBridgeTransport, + DiscordMessageInbound, +} from "./types.ts"; + +const maxAttempts = 3; +const defaultTypingIntervalMs = 8_000; +const defaultReconcileIntervalMs = 30_000; +const activeTurnRetryMs = 2_000; +const runningCommandStatusDelayMs = 5_000; + +export type ThreadRunnerContext = { + client: CodexBridgeClient; + transport: DiscordBridgeTransport; + config: DiscordBridgeConfig; + getState(): DiscordBridgeState; + persist(): Promise; + now(): Date; + debug(event: string, fields?: Record): void; + consoleOutput?: DiscordConsoleOutput; +}; + +export class DiscordThreadRunner { + readonly session: DiscordBridgeSession; + #context: ThreadRunnerContext; + #mailbox: Promise = Promise.resolve(); + #stopped = false; + #retryTimers = new Map(); + #typingTimer: Timer | undefined; + #typingTurnKey: string | undefined; + #reconcileTimer: Timer | undefined; + #runningCommandStatusTimers = new Map(); + #finalAssistantText = new Map(); + #agentMessageBuffers = new Map(); + #completedAgentMessages = new Set(); + #summaryBuffers = new Map(); + #summaryMessages = new Map(); + #goal: RuntimeGoal | undefined; + #planExplanation: string | undefined; + #planSteps: RuntimePlanStep[] = []; + #planTextBuffers = new Map(); + #runningCommands = new Map(); + #activities = new Map(); + #pinnedStatusMessageId: string | undefined; + + constructor(session: DiscordBridgeSession, context: ThreadRunnerContext) { + this.session = session; + this.#context = context; + } + + start(): void { + void this.#enqueue("runner.start", async () => { + await this.#refreshGoal(); + await this.#ensureStatusMessage(); + await this.#cleanupDeliveredTurnProgress(); + await this.#reconcilePersistedProcessing(); + await this.#reconcilePersistedActiveTurns(); + await this.#processQueue(); + }); + } + + async stop(): Promise { + this.#stopped = true; + for (const timer of this.#retryTimers.values()) { + clearTimeout(timer); + } + this.#retryTimers.clear(); + this.#stopTypingHeartbeat(); + this.#clearReconcileTimer(); + this.#clearRunningCommandStatusTimers(); + await this.#mailbox.catch(() => undefined); + } + + enqueueMessage(message: DiscordMessageInbound): Promise { + return this.#enqueue("runner.enqueueMessage", async () => { + await this.#enqueueMessage(message); + }); + } + + handleNotification(message: JsonRpcNotification): Promise { + return this.#enqueue("runner.notification", async () => { + await this.#handleNotification(message); + }); + } + + flushSummariesForTest(): Promise { + return this.#enqueue("runner.flushSummariesForTest", async () => { + for (const key of [...this.#summaryBuffers.keys()]) { + await this.#finalizeSummary(summaryKeyParts(key)); + } + }); + } + + ensureStatusMessage(): Promise { + return this.#enqueue("runner.ensureStatusMessage", async () => { + await this.#refreshGoal(); + await this.#ensureStatusMessage(); + }); + } + + #enqueue(label: string, work: () => Promise): Promise { + const run = this.#mailbox + .catch(() => undefined) + .then(async () => { + if (this.#stopped) { + return; + } + await work(); + }); + this.#mailbox = run.catch((error) => { + this.#debug(`${label}.error`, { error: errorMessage(error) }); + }); + return run; + } + + async #enqueueMessage(message: DiscordMessageInbound): Promise { + const state = this.#state(); + const content = message.content.trim(); + if (!content) { + this.#debug("message.ignored.empty", { + discordThreadId: this.session.discordThreadId, + messageId: message.messageId, + }); + return; + } + if (isDuplicate(state, message.messageId)) { + this.#debug("message.ignored.duplicate", { + discordThreadId: this.session.discordThreadId, + messageId: message.messageId, + }); + return; + } + const active = this.#activeTurn(); + if (active) { + await this.#steerActiveTurn(active, message, content); + return; + } + const item: DiscordBridgeQueueItem = { + id: `${message.messageId}-${Date.now()}`, + status: "pending", + discordMessageId: message.messageId, + discordThreadId: this.session.discordThreadId, + codexThreadId: this.session.codexThreadId, + authorId: message.author.id, + authorName: message.author.name, + content, + createdAt: message.createdAt, + receivedAt: this.#context.now().toISOString(), + attempts: 0, + }; + state.queue.push(item); + this.#debug("queue.enqueued", { + queueId: item.id, + discordThreadId: item.discordThreadId, + codexThreadId: item.codexThreadId, + messageId: item.discordMessageId, + contentLength: content.length, + sessionQueueLength: this.#sessionQueueItems().length, + }); + await this.#context.persist(); + await this.#updateStatusMessage(); + await this.#processQueue(); + } + + async #steerActiveTurn( + active: DiscordBridgeActiveTurn, + message: DiscordMessageInbound, + content: string, + ): Promise { + this.#debug("turn.steer.request", { + activeQueueId: active.queueItemId, + origin: active.origin, + turnId: active.turnId, + messageId: message.messageId, + contentLength: content.length, + }); + await this.#context.client.steerTurn({ + threadId: active.codexThreadId, + expectedTurnId: active.turnId, + input: [ + { + type: "text", + text: formatDiscordPrompt({ + id: `${message.messageId}-steer`, + status: "pending", + discordMessageId: message.messageId, + discordThreadId: this.session.discordThreadId, + codexThreadId: this.session.codexThreadId, + authorId: message.author.id, + authorName: message.author.name, + content, + createdAt: message.createdAt, + receivedAt: this.#context.now().toISOString(), + attempts: 0, + }), + text_elements: [], + }, + ], + responsesapiClientMetadata: null, + }); + addProcessedMessageId(this.#state(), message.messageId); + await this.#context.persist(); + this.#debug("turn.steer.accepted", { + activeQueueId: active.queueItemId, + origin: active.origin, + turnId: active.turnId, + messageId: message.messageId, + }); + await this.#updateStatusMessage(); + } + + async #reconcilePersistedProcessing(): Promise { + const processingItems = this.#sessionQueueItems().filter( + (item) => item.status === "processing", + ); + if (processingItems.length === 0) { + return; + } + this.#debug("runner.reconcile.start", { + discordThreadId: this.session.discordThreadId, + codexThreadId: this.session.codexThreadId, + processing: processingItems.length, + }); + for (const item of processingItems) { + if (!item.turnId) { + item.status = "pending"; + item.lastError = "Recovered processing item without a turn id"; + item.nextAttemptAt = undefined; + this.#debug("runner.reconcile.resetMissingTurn", { + queueId: item.id, + }); + continue; + } + const turn = await this.#readTurn(item.turnId); + if (!turn) { + item.status = "pending"; + item.turnId = undefined; + item.lastError = "Recovered processing item whose turn was not found"; + item.nextAttemptAt = new Date( + this.#context.now().getTime() + activeTurnRetryMs, + ).toISOString(); + this.#debug("runner.reconcile.resetMissingRemoteTurn", { + queueId: item.id, + }); + this.#scheduleRetry(item.id, activeTurnRetryMs); + continue; + } + if (turn.status === "completed") { + await this.#completeTurn(this.session.codexThreadId, item.turnId, turn); + continue; + } + if (turn.status === "failed" || turn.status === "interrupted") { + await this.#completeFailedTurn(item, turn.status); + continue; + } + this.#finalAssistantText.set(turnKey(item.codexThreadId, item.turnId), ""); + const active = this.#upsertActiveTurn({ + turnId: item.turnId, + origin: "discord", + queueItemId: item.id, + startedAt: turnStartedAt(turn), + }); + await this.#startTypingHeartbeat(active); + this.#scheduleActiveTurnReconcile(active); + await this.#updateStatusMessage(); + } + await this.#context.persist(); + } + + async #reconcilePersistedActiveTurns(): Promise { + const activeTurns = [...this.#sessionActiveTurns()]; + if (activeTurns.length === 0) { + return; + } + this.#debug("runner.reconcileActive.start", { + activeTurns: activeTurns.length, + }); + for (const active of activeTurns) { + const turn = await this.#readTurn(active.turnId); + if (!turn) { + this.#removeActiveTurn(active.turnId); + this.#debug("runner.reconcileActive.removedMissingTurn", { + turnId: active.turnId, + origin: active.origin, + }); + continue; + } + if (turn.status === "completed") { + await this.#completeTurn(active.codexThreadId, active.turnId, turn); + continue; + } + if (turn.status === "failed" || turn.status === "interrupted") { + await this.#completeFailedTurn(active, turn.status); + continue; + } + this.#finalAssistantText.set(turnKey(active.codexThreadId, active.turnId), ""); + await this.#startTypingHeartbeat(active); + this.#scheduleActiveTurnReconcile(active); + await this.#updateStatusMessage(); + } + await this.#context.persist(); + } + + async #processQueue(): Promise { + const active = this.#activeTurn(); + if (active) { + this.#debug("queue.process.activeTurn", { + queueId: active.queueItemId, + origin: active.origin, + turnId: active.turnId, + pending: this.#sessionQueueItems().filter((item) => item.status === "pending").length, + }); + return; + } + for (const item of this.#sessionQueueItems()) { + if (item.status !== "pending") { + continue; + } + const delayMs = retryDelayMs(item, this.#context.now()); + if (delayMs > 0) { + this.#scheduleRetry(item.id, delayMs); + this.#debug("queue.item.delayed", { + queueId: item.id, + delayMs, + attempts: item.attempts, + }); + continue; + } + await this.#startTurn(item); + return; + } + } + + async #startTurn(item: DiscordBridgeQueueItem): Promise { + try { + this.#debug("turn.start.request", { + queueId: item.id, + codexThreadId: item.codexThreadId, + discordThreadId: item.discordThreadId, + inputLength: item.content.length, + cwd: this.#cwd(), + model: this.#context.config.model, + summary: this.#context.config.summary, + progressMode: this.#progressMode(), + }); + const started = await this.#context.client.startTurn({ + threadId: item.codexThreadId, + input: [ + { + type: "text", + text: formatDiscordPrompt(item), + text_elements: [], + }, + ], + cwd: this.#cwd() ?? null, + model: this.#context.config.model ?? null, + serviceTier: this.#context.config.serviceTier ?? null, + effort: this.#context.config.effort ?? null, + summary: this.#context.config.summary ?? null, + approvalPolicy: this.#context.config.approvalPolicy ?? null, + permissions: this.#context.config.permissions ?? null, + outputSchema: null, + }); + item.status = "processing"; + item.turnId = started.turn.id; + item.lastError = undefined; + item.nextAttemptAt = undefined; + this.#clearRuntimeState(); + this.#finalAssistantText.set(turnKey(item.codexThreadId, started.turn.id), ""); + const active = this.#upsertActiveTurn({ + turnId: started.turn.id, + origin: "discord", + queueItemId: item.id, + startedAt: turnStartedAt(started.turn), + }); + await this.#startTypingHeartbeat(active); + this.#scheduleActiveTurnReconcile(active); + await this.#context.persist(); + await this.#updateStatusMessage(); + this.#debug("turn.start.accepted", { + queueId: item.id, + codexThreadId: item.codexThreadId, + turnId: started.turn.id, + }); + } catch (error) { + const message = errorMessage(error); + if (message.includes("already has an active turn")) { + item.lastError = message; + item.nextAttemptAt = new Date( + this.#context.now().getTime() + activeTurnRetryMs, + ).toISOString(); + await this.#context.persist(); + await this.#updateStatusMessage(); + this.#debug("turn.start.activeTurn", { + queueId: item.id, + codexThreadId: item.codexThreadId, + nextAttemptAt: item.nextAttemptAt, + error: message, + }); + this.#scheduleRetry(item.id, activeTurnRetryMs); + return; + } + item.attempts += 1; + item.lastError = message; + if (item.attempts >= maxAttempts) { + item.status = "failed"; + item.nextAttemptAt = undefined; + await this.#deliverError(item, message); + await this.#context.persist(); + await this.#updateStatusMessage(); + this.#debug("turn.start.failed.permanent", { + queueId: item.id, + attempts: item.attempts, + error: message, + }); + return; + } + item.nextAttemptAt = new Date( + this.#context.now().getTime() + backoffMs(item.attempts), + ).toISOString(); + await this.#context.persist(); + await this.#updateStatusMessage(); + this.#debug("turn.start.failed.retry", { + queueId: item.id, + attempts: item.attempts, + nextAttemptAt: item.nextAttemptAt, + error: message, + }); + this.#scheduleRetry(item.id, retryDelayMs(item, this.#context.now())); + } + } + + async #handleNotification(message: JsonRpcNotification): Promise { + const params = record(message.params); + const threadId = stringValue(params.threadId); + if (threadId !== this.session.codexThreadId) { + this.#debug("notification.ignored.runnerMismatch", { + method: message.method, + threadId, + codexThreadId: this.session.codexThreadId, + }); + return; + } + if (message.method === "thread/goal/updated") { + this.#goal = runtimeGoal(record(params.goal)); + await this.#updateStatusMessage(); + return; + } + if (message.method === "thread/goal/cleared") { + this.#goal = undefined; + await this.#updateStatusMessage(); + return; + } + const turnId = + stringValue(params.turnId) ?? + stringValue(record(params.turn).id); + if (!turnId) { + this.#debug("notification.ignored.runnerMismatch", { + method: message.method, + threadId, + turnId, + codexThreadId: this.session.codexThreadId, + }); + return; + } + this.#debug("notification.received", { + method: message.method, + threadId, + turnId, + itemId: stringValue(params.itemId), + summaryIndex: numberValue(params.summaryIndex), + deltaLength: stringValue(params.delta)?.length, + hasTurnPayload: Boolean(params.turn), + }); + if (this.#hasDelivery(turnId, "final") && !this.#processingItemForTurn(turnId)) { + await this.#ignoreDeliveredTurnNotification(message.method, threadId, turnId); + return; + } + if ( + message.method !== "turn/started" && + message.method !== "turn/completed" && + !this.#activeTurnForTurn(turnId) && + !this.#hasDelivery(turnId, "final") + ) { + await this.#adoptStartedTurn(turnId, record(params.turn)); + } + if (message.method === "turn/started") { + await this.#adoptStartedTurn(turnId, record(params.turn)); + await this.#updateStatusMessage(); + return; + } + if (message.method === "turn/plan/updated") { + this.#planExplanation = stringValue(params.explanation); + this.#planSteps = Array.isArray(params.plan) + ? params.plan.filter(isRecord).map((step) => ({ + step: stringValue(step.step) ?? "", + status: planStepStatus(step.status), + })).filter((step) => step.step) + : []; + await this.#updateStatusMessage(); + return; + } + if (message.method === "item/plan/delta") { + const itemId = stringValue(params.itemId) ?? "plan"; + const delta = stringValue(params.delta); + if (delta) { + this.#planTextBuffers.set( + itemId, + `${this.#planTextBuffers.get(itemId) ?? ""}${delta}`, + ); + await this.#updateStatusMessage(); + } + return; + } + if (message.method === "item/started") { + await this.#handleItemStarted(turnId, record(params.item)); + return; + } + if (message.method === "item/commandExecution/outputDelta") { + const itemId = stringValue(params.itemId) ?? "command"; + this.#upsertRunningCommand(itemId, undefined); + await this.#updateStatusMessage(); + return; + } + if (message.method === "item/reasoning/summaryPartAdded") { + if (this.#progressMode() !== "summary") { + return; + } + const summaryKey = summaryNotificationKey(threadId, turnId, params); + await this.#finalizeEarlierSummaries(summaryKey); + if (this.#summaryBuffers.get(summaryKeyString(summaryKey))?.trim()) { + await this.#finalizeSummary(summaryKey); + } else { + this.#ensureSummary(summaryKey); + } + return; + } + if (message.method === "item/reasoning/summaryTextDelta") { + if (this.#progressMode() !== "summary") { + return; + } + const delta = stringValue(params.delta); + if (delta) { + await this.#appendSummary(summaryNotificationKey(threadId, turnId, params), delta); + } + return; + } + if (message.method === "item/completed") { + await this.#handleItemCompleted(threadId, turnId, record(params.item)); + return; + } + if (message.method === "item/agentMessage/delta") { + const delta = stringValue(params.delta); + if (delta) { + this.#appendAgentMessageDelta( + threadId, + turnId, + stringValue(params.itemId) ?? "agent-message", + delta, + ); + } + return; + } + if (message.method === "turn/completed") { + await this.#completeTurn(threadId, turnId, record(params.turn)); + } + } + + async #adoptStartedTurn( + turnId: string, + turn: Record, + ): Promise { + const existing = this.#activeTurnForTurn(turnId); + const previous = this.#activeTurn(); + const item = this.#processingItemForTurn(turnId); + if (!existing && previous?.turnId !== turnId) { + this.#clearRuntimeState(); + } + const active = this.#upsertActiveTurn({ + turnId, + origin: item ? "discord" : "external", + queueItemId: item?.id, + startedAt: turnStartedAt(turn), + }); + if (!this.#finalAssistantText.has(turnKey(active.codexThreadId, active.turnId))) { + this.#finalAssistantText.set(turnKey(active.codexThreadId, active.turnId), ""); + } + await this.#startTypingHeartbeat(active); + this.#scheduleActiveTurnReconcile(active); + await this.#context.persist(); + this.#debug("turn.adopted", { + turnId, + origin: active.origin, + queueId: active.queueItemId, + }); + } + + async #handleItemCompleted( + threadId: string, + turnId: string, + item: Record, + ): Promise { + const trackedActivity = this.#handleActivityItem(turnId, item, "completed"); + if (item.type === "commandExecution") { + await this.#handleCommandExecutionItem(item); + return; + } + if (item.type === "plan") { + const itemId = stringValue(item.id) ?? "plan"; + const text = stringValue(item.text); + if (text) { + this.#planTextBuffers.set(itemId, text); + await this.#updateStatusMessage(); + } + return; + } + if (item.type === "agentMessage") { + await this.#handleAgentMessageCompleted(threadId, turnId, item); + return; + } + if (trackedActivity) { + await this.#updateStatusMessage(); + return; + } + if (this.#progressMode() !== "summary") { + return; + } + if (item.type !== "reasoning" || !Array.isArray(item.summary)) { + return; + } + for (let index = 0; index < item.summary.length; index += 1) { + const text = stringValue(item.summary[index]); + if (!text) { + continue; + } + const summaryParts = { + threadId, + turnId, + itemId: stringValue(item.id) ?? "reasoning", + summaryIndex: index, + }; + const encodedKey = summaryKeyString(summaryParts); + if (!this.#summaryMessages.has(encodedKey)) { + this.#summaryBuffers.set(encodedKey, text); + await this.#finalizeSummary(summaryParts); + } + } + } + + async #handleItemStarted( + turnId: string, + item: Record, + ): Promise { + const trackedActivity = this.#handleActivityItem(turnId, item, "inProgress"); + if (item.type === "commandExecution") { + await this.#handleCommandExecutionItem(item); + return; + } + if (item.type === "plan") { + const itemId = stringValue(item.id) ?? `plan-${turnId}`; + this.#planTextBuffers.set(itemId, stringValue(item.text) ?? ""); + await this.#updateStatusMessage(); + } + if (trackedActivity) { + await this.#updateStatusMessage(); + } + } + + async #handleCommandExecutionItem( + item: Record, + ): Promise { + const itemId = stringValue(item.id) ?? "command"; + const status = commandStatus(item.status); + if (status === "inProgress") { + this.#upsertRunningCommand(itemId, stringValue(item.command)); + } else { + this.#deleteRunningCommand(itemId); + } + await this.#updateStatusMessage(); + } + + #handleActivityItem( + turnId: string, + item: Record, + fallbackStatus: RuntimeActivity["status"], + ): boolean { + const activity = activityFromItem( + item, + turnId, + fallbackStatus, + this.#context.now(), + ); + if (!activity) { + return false; + } + this.#activities.set(activity.itemId, activity); + this.#trimActivities(); + return true; + } + + #trimActivities(): void { + const activities = [...this.#activities.values()] + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)); + const completed = activities.filter((activity) => + activity.status !== "inProgress" + ); + while (this.#activities.size > 8 && completed.length > 0) { + const oldest = completed.shift(); + if (!oldest) { + break; + } + this.#activities.delete(oldest.itemId); + } + while (this.#activities.size > 12) { + const oldest = [...this.#activities.values()] + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))[0]; + if (!oldest) { + break; + } + this.#activities.delete(oldest.itemId); + } + } + + #upsertRunningCommand(itemId: string, command: string | undefined): void { + const existing = this.#runningCommands.get(itemId); + const running: RunningCommand = { + itemId, + command: command ?? existing?.command ?? `command ${compactId(itemId)}`, + status: "inProgress", + startedAt: existing?.startedAt ?? this.#context.now().toISOString(), + lastOutputAt: this.#context.now().toISOString(), + }; + this.#runningCommands.set(itemId, running); + this.#scheduleRunningCommandStatusRefresh(running); + } + + #deleteRunningCommand(itemId: string): void { + this.#runningCommands.delete(itemId); + this.#clearRunningCommandStatusTimer(itemId); + } + + #scheduleRunningCommandStatusRefresh(command: RunningCommand): void { + if (this.#visibleRunningCommand(command)) { + this.#clearRunningCommandStatusTimer(command.itemId); + return; + } + if (this.#runningCommandStatusTimers.has(command.itemId)) { + return; + } + const startedAtMs = Date.parse(command.startedAt); + const elapsedMs = Number.isFinite(startedAtMs) + ? this.#context.now().getTime() - startedAtMs + : 0; + const delayMs = Math.max(0, runningCommandStatusDelayMs - elapsedMs); + const timer = setTimeout(() => { + this.#runningCommandStatusTimers.delete(command.itemId); + void this.#enqueue("command.status.visible", async () => { + const current = this.#runningCommands.get(command.itemId); + if (current && this.#visibleRunningCommand(current)) { + await this.#updateStatusMessage(); + } + }); + }, delayMs); + timer.unref?.(); + this.#runningCommandStatusTimers.set(command.itemId, timer); + } + + #clearRunningCommandStatusTimer(itemId: string): void { + const timer = this.#runningCommandStatusTimers.get(itemId); + if (!timer) { + return; + } + clearTimeout(timer); + this.#runningCommandStatusTimers.delete(itemId); + } + + #clearRunningCommandStatusTimers(): void { + for (const timer of this.#runningCommandStatusTimers.values()) { + clearTimeout(timer); + } + this.#runningCommandStatusTimers.clear(); + } + + #appendAgentMessageDelta( + threadId: string, + turnId: string, + itemId: string, + delta: string, + ): void { + const encodedKey = agentMessageKey({ threadId, turnId, itemId }); + this.#agentMessageBuffers.set( + encodedKey, + `${this.#agentMessageBuffers.get(encodedKey) ?? ""}${delta}`, + ); + this.#debug("agentMessage.delta.buffered", { + itemId, + turnId, + deltaLength: delta.length, + bufferLength: this.#agentMessageBuffers.get(encodedKey)?.length, + }); + } + + async #handleAgentMessageCompleted( + threadId: string, + turnId: string, + item: Record, + ): Promise { + const itemId = stringValue(item.id) ?? "agent-message"; + const encodedKey = agentMessageKey({ threadId, turnId, itemId }); + if (this.#completedAgentMessages.has(encodedKey)) { + return; + } + const text = + stringValue(item.text)?.trim() ?? + this.#agentMessageBuffers.get(encodedKey)?.trim() ?? + ""; + const phase = messagePhase(item.phase); + this.#completedAgentMessages.add(encodedKey); + this.#agentMessageBuffers.delete(encodedKey); + if (!text) { + return; + } + if (phase === "commentary") { + if (this.#progressMode() === "commentary") { + await this.#sendCommentaryMessage(turnId, itemId, text); + } + return; + } + if (phase === "final_answer" || !phase) { + const key = turnKey(threadId, turnId); + const existing = this.#finalAssistantText.get(key)?.trim(); + this.#finalAssistantText.set( + key, + existing ? `${existing}\n\n${text}` : text, + ); + } + } + + async #sendCommentaryMessage( + turnId: string, + itemId: string, + text: string, + ): Promise { + const active = this.#activeTurnForTurn(turnId) ?? + this.#upsertActiveTurn({ turnId, origin: "external" }); + const outboundMessageIds = await this.#context.transport.sendMessage( + active.discordThreadId, + text, + ); + this.#recordDeliveryForTurn(active, "commentary", outboundMessageIds); + this.#emitConsoleMessage("commentary", turnId, text); + await this.#context.persist(); + this.#debug("commentary.message.sent", { + turnId, + itemId, + outboundMessageIds, + textLength: text.length, + }); + } + + #ensureSummary(key: SummaryKeyParts): void { + const encodedKey = summaryKeyString(key); + if (!this.#summaryBuffers.has(encodedKey)) { + this.#summaryBuffers.set(encodedKey, ""); + this.#debug("summary.ensure", { + itemId: key.itemId, + summaryIndex: key.summaryIndex, + turnId: key.turnId, + }); + } + } + + async #appendSummary(key: SummaryKeyParts, delta: string): Promise { + const encodedKey = summaryKeyString(key); + this.#summaryBuffers.set( + encodedKey, + `${this.#summaryBuffers.get(encodedKey) ?? ""}${delta}`, + ); + this.#debug("summary.delta.buffered", { + itemId: key.itemId, + summaryIndex: key.summaryIndex, + turnId: key.turnId, + deltaLength: delta.length, + bufferLength: this.#summaryBuffers.get(encodedKey)?.length, + }); + } + + async #sendSummaryMessage(key: SummaryKeyParts): Promise { + const encodedKey = summaryKeyString(key); + const text = this.#summaryBuffers.get(encodedKey)?.trim(); + if (!text) { + return; + } + const active = this.#activeTurnForTurn(key.turnId) ?? + this.#upsertActiveTurn({ turnId: key.turnId, origin: "external" }); + if (this.#summaryMessages.has(encodedKey)) { + this.#debug("summary.send.skipped.alreadySent", { + turnId: key.turnId, + itemId: key.itemId, + summaryIndex: key.summaryIndex, + textLength: text.length, + }); + return; + } + const outboundMessageIds = await this.#context.transport.sendMessage( + active.discordThreadId, + text, + ); + this.#summaryMessages.set(encodedKey, outboundMessageIds); + this.#recordDeliveryForTurn(active, "summary", outboundMessageIds); + this.#emitConsoleMessage("summary", key.turnId, text); + await this.#context.persist(); + this.#debug("summary.message.sent", { + turnId: key.turnId, + itemId: key.itemId, + summaryIndex: key.summaryIndex, + outboundMessageIds, + textLength: text.length, + }); + } + + async #finalizeSummary(key: SummaryKeyParts): Promise { + const encodedKey = summaryKeyString(key); + this.#debug("summary.finalize", { + turnId: key.turnId, + itemId: key.itemId, + summaryIndex: key.summaryIndex, + bufferLength: this.#summaryBuffers.get(encodedKey)?.length, + }); + await this.#sendSummaryMessage(key); + this.#summaryBuffers.delete(encodedKey); + } + + async #finalizeEarlierSummaries(key: SummaryKeyParts): Promise { + for (const encodedKey of [...this.#summaryBuffers.keys()]) { + const parts = summaryKeyParts(encodedKey); + if ( + parts.threadId === key.threadId && + parts.turnId === key.turnId && + parts.itemId === key.itemId && + parts.summaryIndex < key.summaryIndex + ) { + await this.#finalizeSummary(parts); + } + } + } + + async #ignoreDeliveredTurnNotification( + method: string, + threadId: string, + turnId: string, + ): Promise { + const active = this.#activeTurnForTurn(turnId); + const cleaned = await this.#deleteProgressMessagesForTurn( + active ?? this.#progressCleanupTarget(turnId), + turnId, + ); + if (active) { + this.#removeActiveTurn(turnId); + this.#clearAgentMessagesForTurn(turnId); + this.#clearSummariesForTurn(turnId); + this.#clearRuntimeState(); + this.#stopTypingHeartbeat(); + this.#clearReconcileTimer(); + } + if (active || cleaned) { + await this.#context.persist(); + await this.#updateStatusMessage(); + } + this.#debug("notification.ignored.deliveredTurn", { + method, + threadId, + turnId, + hadActiveTurn: Boolean(active), + cleanedProgress: cleaned, + }); + } + + async #cleanupDeliveredTurnProgress(): Promise { + const deliveredTurnIds = [ + ...new Set( + this.#state().deliveries + .filter( + (delivery) => + delivery.discordThreadId === this.session.discordThreadId && + delivery.codexThreadId === this.session.codexThreadId && + delivery.kind === "final" && + Boolean(delivery.turnId), + ) + .map((delivery) => delivery.turnId as string), + ), + ]; + let changed = false; + for (const turnId of deliveredTurnIds) { + if (this.#processingItemForTurn(turnId)) { + continue; + } + const active = this.#activeTurnForTurn(turnId); + const cleaned = await this.#deleteProgressMessagesForTurn( + active ?? this.#progressCleanupTarget(turnId), + turnId, + ); + if (active) { + this.#removeActiveTurn(turnId); + changed = true; + } + changed = cleaned || changed; + } + if (changed) { + this.#clearRuntimeState(); + this.#stopTypingHeartbeat(); + this.#clearReconcileTimer(); + await this.#context.persist(); + await this.#updateStatusMessage(); + } + } + + async #completeTurn( + threadId: string, + turnId: string, + completedTurn: Record, + ): Promise { + const key = turnKey(threadId, turnId); + const item = this.#processingItemForTurn(turnId); + const active = this.#activeTurnForTurn(turnId) ?? + (item + ? this.#upsertActiveTurn({ + turnId, + origin: "discord", + queueItemId: item.id, + startedAt: turnStartedAt(completedTurn), + }) + : this.#upsertActiveTurn({ + turnId, + origin: "external", + startedAt: turnStartedAt(completedTurn), + })); + try { + this.#debug("turn.complete.start", { + queueId: item?.id, + origin: active.origin, + threadId, + turnId, + finalTextLength: this.#finalAssistantText.get(key)?.length ?? 0, + summaryBuffers: this.#summaryBuffers.size, + }); + await this.#handleCompletedTurnItems(threadId, turnId, completedTurn); + await this.#flushSummariesForTurn(turnId); + const finalText = + (this.#finalAssistantText.get(key) ?? "").trim() || + finalTextFromTurn(completedTurn).trim() || + (await this.#readFinalTurnText(turnId)).trim(); + if (finalText && !this.#hasDelivery(turnId, "final")) { + const outboundMessageIds = await this.#context.transport.sendMessage( + active.discordThreadId, + finalText, + ); + this.#recordDeliveryForTurn(active, "final", outboundMessageIds); + this.#emitConsoleMessage("final", turnId, finalText); + this.#debug("turn.final.sent", { + queueId: item?.id, + origin: active.origin, + turnId, + outboundMessageIds, + textLength: finalText.length, + }); + await this.#deleteProgressMessagesForTurn(active, turnId); + } else { + this.#debug("turn.final.empty", { + queueId: item?.id, + origin: active.origin, + turnId, + alreadyDelivered: this.#hasDelivery(turnId, "final"), + }); + } + if (item) { + this.#removeQueueItem(item); + addProcessedMessageId(this.#state(), item.discordMessageId); + } + this.#removeActiveTurn(turnId); + await this.#context.persist(); + } catch (error) { + if (item) { + item.status = "failed"; + item.lastError = errorMessage(error); + item.nextAttemptAt = undefined; + } + this.#removeActiveTurn(turnId); + await this.#context.persist(); + this.#debug("turn.complete.failedDelivery", { + queueId: item?.id, + origin: active.origin, + turnId, + error: errorMessage(error), + }); + } finally { + this.#finalAssistantText.delete(key); + this.#clearAgentMessagesForTurn(turnId); + this.#clearSummariesForTurn(turnId); + this.#clearRuntimeState(); + this.#stopTypingHeartbeat(); + this.#clearReconcileTimer(); + await this.#updateStatusMessage(); + } + await this.#processQueue(); + } + + async #completeFailedTurn( + activeOrItem: DiscordBridgeActiveTurn | DiscordBridgeQueueItem, + status: "failed" | "interrupted", + ): Promise { + const active = isActiveTurn(activeOrItem) + ? activeOrItem + : this.#upsertActiveTurn({ + turnId: activeOrItem.turnId ?? "unknown", + origin: "discord", + queueItemId: activeOrItem.id, + }); + const item = this.#processingItemForTurn(active.turnId); + await this.#deliverError(active, `Codex turn ${status}.`); + if (item) { + this.#removeQueueItem(item); + addProcessedMessageId(this.#state(), item.discordMessageId); + } + this.#removeActiveTurn(active.turnId); + this.#stopTypingHeartbeat(); + this.#clearReconcileTimer(); + await this.#context.persist(); + this.#clearRuntimeState(); + await this.#updateStatusMessage(); + this.#debug("turn.reconcile.completedFailed", { + queueId: item?.id, + origin: active.origin, + turnId: active.turnId, + status, + }); + } + + async #flushSummariesForTurn(turnId: string): Promise { + for (const key of [...this.#summaryBuffers.keys()]) { + const parts = summaryKeyParts(key); + if (parts.turnId === turnId) { + await this.#finalizeSummary(parts); + } + } + } + + async #handleCompletedTurnItems( + threadId: string, + turnId: string, + completedTurn: Record, + ): Promise { + const items = Array.isArray(completedTurn.items) ? completedTurn.items : []; + for (const item of items.filter(isRecord)) { + await this.#handleItemCompleted(threadId, turnId, item); + } + } + + #clearAgentMessagesForTurn(turnId: string): void { + for (const key of [...this.#agentMessageBuffers.keys()]) { + if (agentMessageKeyParts(key).turnId === turnId) { + this.#agentMessageBuffers.delete(key); + } + } + for (const key of [...this.#completedAgentMessages]) { + if (agentMessageKeyParts(key).turnId === turnId) { + this.#completedAgentMessages.delete(key); + } + } + } + + #clearSummariesForTurn(turnId: string): void { + for (const key of [...this.#summaryBuffers.keys()]) { + if (summaryKeyParts(key).turnId === turnId) { + this.#summaryBuffers.delete(key); + } + } + for (const key of [...this.#summaryMessages.keys()]) { + if (summaryKeyParts(key).turnId === turnId) { + this.#summaryMessages.delete(key); + } + } + } + + async #readTurn(turnId: string): Promise { + try { + const response = await this.#context.client.readThread({ + threadId: this.session.codexThreadId, + includeTurns: true, + }); + return response.thread.turns.find((candidate) => candidate.id === turnId); + } catch (error) { + this.#debug("turn.read.error", { + turnId, + error: errorMessage(error), + }); + return undefined; + } + } + + async #readFinalTurnText(turnId: string): Promise { + const turn = await this.#readTurn(turnId); + if (!turn) { + return ""; + } + return finalTextFromTurn(turn); + } + + async #deliverError( + target: DiscordBridgeActiveTurn | DiscordBridgeQueueItem, + message: string, + ): Promise { + const active = isActiveTurn(target) ? target : undefined; + const item = active ? undefined : target as DiscordBridgeQueueItem; + const outboundMessageIds = await this.#context.transport.sendMessage( + target.discordThreadId, + `Codex turn failed: ${message}`, + ); + this.#emitConsoleMessage("error", target.turnId, `Codex turn failed: ${message}`); + if (active) { + this.#recordDeliveryForTurn(active, "error", outboundMessageIds); + } else if (item) { + this.#state().deliveries.push({ + discordMessageId: item.discordMessageId, + discordThreadId: item.discordThreadId, + codexThreadId: item.codexThreadId, + turnId: item.turnId, + kind: "error", + outboundMessageIds, + deliveredAt: this.#context.now().toISOString(), + }); + } + this.#debug("error.delivered", { + queueId: active ? active.queueItemId : item?.id, + origin: active?.origin ?? "discord", + turnId: target.turnId, + outboundMessageIds, + errorLength: message.length, + }); + } + + async #deleteProgressMessagesForTurn( + active: DiscordBridgeActiveTurn, + turnId: string, + ): Promise { + const progressDeliveries = this.#state().deliveries.filter( + (delivery) => + (delivery.kind === "summary" || + delivery.kind === "commentary") && + delivery.turnId === turnId && + delivery.discordThreadId === active.discordThreadId && + delivery.codexThreadId === active.codexThreadId, + ); + const messageIds = [ + ...new Set( + progressDeliveries.flatMap((delivery) => delivery.outboundMessageIds), + ), + ]; + if (messageIds.length === 0) { + this.#debug("progress.cleanup.skipped.empty", { + queueId: active.queueItemId, + origin: active.origin, + turnId, + }); + return false; + } + this.#debug("progress.cleanup.start", { + queueId: active.queueItemId, + origin: active.origin, + turnId, + messageIds, + }); + const deletedMessageIds = new Set(); + for (const messageId of messageIds) { + try { + await this.#context.transport.deleteMessage( + active.discordThreadId, + messageId, + ); + deletedMessageIds.add(messageId); + this.#debug("progress.cleanup.deleted", { + queueId: active.queueItemId, + origin: active.origin, + turnId, + messageId, + }); + } catch (error) { + this.#debug("progress.cleanup.deleteFailed", { + queueId: active.queueItemId, + origin: active.origin, + turnId, + messageId, + error: errorMessage(error), + }); + } + } + if (deletedMessageIds.size > 0) { + for (const delivery of progressDeliveries) { + delivery.outboundMessageIds = delivery.outboundMessageIds.filter( + (messageId) => !deletedMessageIds.has(messageId), + ); + } + } + return deletedMessageIds.size > 0; + } + + async #startTypingHeartbeat(active: DiscordBridgeActiveTurn): Promise { + this.#stopTypingHeartbeat(); + this.#typingTurnKey = turnKey(active.codexThreadId, active.turnId); + await this.#context.transport.sendTyping(active.discordThreadId); + const intervalMs = + this.#context.config.typingIntervalMs ?? defaultTypingIntervalMs; + this.#debug("typing.start", { + queueId: active.queueItemId, + origin: active.origin, + turnId: active.turnId, + intervalMs, + }); + const timer = setInterval(() => { + void this.#enqueue("typing.tick", async () => { + await this.#context.transport.sendTyping(active.discordThreadId); + this.#debug("typing.tick", { + turnId: active.turnId, + }); + }).catch((error) => { + this.#debug("typing.error", { + turnId: active.turnId, + error: errorMessage(error), + }); + }); + }, intervalMs); + timer.unref?.(); + this.#typingTimer = timer; + } + + #stopTypingHeartbeat(): void { + if (!this.#typingTimer) { + return; + } + clearInterval(this.#typingTimer); + this.#typingTimer = undefined; + this.#debug("typing.stop", { + key: this.#typingTurnKey, + }); + this.#typingTurnKey = undefined; + } + + #scheduleActiveTurnReconcile(active: DiscordBridgeActiveTurn): void { + this.#clearReconcileTimer(); + const intervalMs = + this.#context.config.reconcileIntervalMs ?? defaultReconcileIntervalMs; + const timer = setTimeout(() => { + this.#reconcileTimer = undefined; + void this.#enqueue("turn.reconcile", async () => { + await this.#reconcileActiveTurn(); + }); + }, intervalMs); + timer.unref?.(); + this.#reconcileTimer = timer; + this.#debug("turn.reconcile.scheduled", { + queueId: active.queueItemId, + origin: active.origin, + turnId: active.turnId, + intervalMs, + }); + } + + async #reconcileActiveTurn(): Promise { + const active = this.#activeTurn(); + if (!active) { + return; + } + const turn = await this.#readTurn(active.turnId); + if (!turn) { + this.#scheduleActiveTurnReconcile(active); + return; + } + if (turn.status === "completed") { + await this.#completeTurn(active.codexThreadId, active.turnId, turn); + return; + } + if (turn.status === "failed" || turn.status === "interrupted") { + await this.#completeFailedTurn(active, turn.status); + await this.#processQueue(); + return; + } + this.#scheduleActiveTurnReconcile(active); + } + + #clearReconcileTimer(): void { + if (!this.#reconcileTimer) { + return; + } + clearTimeout(this.#reconcileTimer); + this.#reconcileTimer = undefined; + } + + async #ensureStatusMessage(): Promise { + const text = this.#renderStatusMessage(); + if (this.session.statusMessageId) { + await this.#updateStatusMessage(); + await this.#pinStatusMessage(this.session.statusMessageId); + return; + } + const [messageId] = await this.#context.transport.sendMessage( + this.session.discordThreadId, + text, + ); + if (!messageId) { + return; + } + this.session.statusMessageId = messageId; + await this.#pinStatusMessage(messageId); + await this.#context.persist(); + this.#debug("status.message.created", { + messageId, + textLength: text.length, + }); + } + + async #updateStatusMessage(): Promise { + const messageId = this.session.statusMessageId; + if (!messageId) { + await this.#ensureStatusMessage(); + return; + } + if (!this.#context.transport.updateMessage) { + return; + } + try { + const text = this.#renderStatusMessage(); + await this.#context.transport.updateMessage( + this.session.discordThreadId, + messageId, + text, + ); + this.#debug("status.message.updated", { + messageId, + textLength: text.length, + }); + } catch (error) { + this.#debug("status.message.updateFailed", { + messageId, + error: errorMessage(error), + }); + this.session.statusMessageId = undefined; + await this.#context.persist(); + await this.#ensureStatusMessage(); + } + } + + async #pinStatusMessage(messageId: string): Promise { + if (!this.#context.transport.pinMessage) { + return; + } + if (this.#pinnedStatusMessageId === messageId) { + return; + } + try { + await this.#context.transport.pinMessage( + this.session.discordThreadId, + messageId, + ); + this.#pinnedStatusMessageId = messageId; + this.#debug("status.message.pinned", { messageId }); + } catch (error) { + this.#debug("status.message.pinFailed", { + messageId, + error: errorMessage(error), + }); + } + } + + async #refreshGoal(): Promise { + try { + const response = await this.#context.client.getThreadGoal({ + threadId: this.session.codexThreadId, + }); + this.#goal = response.goal ? runtimeGoal(response.goal) : undefined; + } catch (error) { + this.#debug("goal.refresh.failed", { + error: errorMessage(error), + }); + } + } + + #renderStatusMessage(): string { + return renderStatusMessage({ + session: this.session, + config: this.#context.config, + activeTurn: this.#activeTurn(), + activeItem: this.#activeProcessingItem(), + pendingCount: this.#sessionQueueItems().filter((item) => item.status === "pending").length, + failedCount: this.#sessionQueueItems().filter((item) => item.status === "failed").length, + goal: this.#goal, + planExplanation: this.#planExplanation, + planSteps: this.#planSteps, + planText: [...this.#planTextBuffers.values()].join("\n").trim(), + runningCommands: [...this.#runningCommands.values()].filter((command) => + this.#visibleRunningCommand(command) + ), + activities: [...this.#activities.values()], + }); + } + + #visibleRunningCommand(command: RunningCommand): boolean { + const startedAtMs = Date.parse(command.startedAt); + return Number.isFinite(startedAtMs) && + this.#context.now().getTime() - startedAtMs >= runningCommandStatusDelayMs; + } + + #clearRuntimeState(): void { + this.#planExplanation = undefined; + this.#planSteps = []; + this.#planTextBuffers.clear(); + this.#runningCommands.clear(); + this.#activities.clear(); + this.#clearRunningCommandStatusTimers(); + } + + #recordDeliveryForTurn( + active: DiscordBridgeActiveTurn, + kind: DiscordBridgeDelivery["kind"], + outboundMessageIds: string[], + ): void { + const item = this.#processingItemForTurn(active.turnId); + this.#state().deliveries.push({ + discordMessageId: item?.discordMessageId ?? `external:${active.turnId}`, + discordThreadId: active.discordThreadId, + codexThreadId: active.codexThreadId, + turnId: active.turnId, + kind, + outboundMessageIds, + deliveredAt: this.#context.now().toISOString(), + }); + this.#debug("delivery.recorded", { + discordMessageId: item?.discordMessageId ?? `external:${active.turnId}`, + origin: active.origin, + kind, + outboundMessageIds, + turnId: active.turnId, + }); + } + + #hasDelivery(turnId: string, kind: DiscordBridgeDelivery["kind"]): boolean { + return this.#state().deliveries.some( + (delivery) => + delivery.discordThreadId === this.session.discordThreadId && + delivery.codexThreadId === this.session.codexThreadId && + delivery.turnId === turnId && + delivery.kind === kind, + ); + } + + #scheduleRetry(itemId: string, delayMs: number): void { + const existing = this.#retryTimers.get(itemId); + if (existing) { + clearTimeout(existing); + } + const timer = setTimeout(() => { + this.#retryTimers.delete(itemId); + void this.#enqueue("retry.fire", async () => { + await this.#processQueue(); + }); + }, Math.max(0, delayMs)); + timer.unref?.(); + this.#retryTimers.set(itemId, timer); + } + + #activeProcessingItem(): DiscordBridgeQueueItem | undefined { + return this.#sessionQueueItems().find((item) => item.status === "processing"); + } + + #activeTurn(): DiscordBridgeActiveTurn | undefined { + return this.#sessionActiveTurns()[0]; + } + + #activeTurnForTurn(turnId: string): DiscordBridgeActiveTurn | undefined { + return this.#sessionActiveTurns().find((active) => active.turnId === turnId); + } + + #sessionActiveTurns(): DiscordBridgeActiveTurn[] { + return this.#state().activeTurns.filter( + (active) => + active.discordThreadId === this.session.discordThreadId && + active.codexThreadId === this.session.codexThreadId, + ); + } + + #upsertActiveTurn(input: { + turnId: string; + origin: DiscordBridgeActiveTurn["origin"]; + queueItemId?: string; + startedAt?: string; + }): DiscordBridgeActiveTurn { + const state = this.#state(); + const observedAt = this.#context.now().toISOString(); + state.activeTurns = state.activeTurns.filter( + (active) => + active.discordThreadId !== this.session.discordThreadId || + active.codexThreadId !== this.session.codexThreadId || + active.turnId === input.turnId, + ); + const existing = this.#activeTurnForTurn(input.turnId); + if (existing) { + existing.origin = input.origin === "discord" ? "discord" : existing.origin; + existing.queueItemId = input.queueItemId ?? existing.queueItemId; + existing.startedAt = input.startedAt ?? existing.startedAt; + existing.observedAt = observedAt; + return existing; + } + const active: DiscordBridgeActiveTurn = { + turnId: input.turnId, + discordThreadId: this.session.discordThreadId, + codexThreadId: this.session.codexThreadId, + origin: input.origin, + queueItemId: input.queueItemId, + startedAt: input.startedAt, + observedAt, + }; + state.activeTurns.push(active); + return active; + } + + #removeActiveTurn(turnId: string): void { + const state = this.#state(); + state.activeTurns = state.activeTurns.filter( + (active) => + active.discordThreadId !== this.session.discordThreadId || + active.codexThreadId !== this.session.codexThreadId || + active.turnId !== turnId, + ); + } + + #progressCleanupTarget(turnId: string): DiscordBridgeActiveTurn { + return { + turnId, + discordThreadId: this.session.discordThreadId, + codexThreadId: this.session.codexThreadId, + origin: "external", + observedAt: this.#context.now().toISOString(), + }; + } + + #processingItemForTurn(turnId: string): DiscordBridgeQueueItem | undefined { + return this.#sessionQueueItems().find( + (item) => item.status === "processing" && item.turnId === turnId, + ); + } + + #sessionQueueItems(): DiscordBridgeQueueItem[] { + return this.#state().queue.filter( + (item) => + item.discordThreadId === this.session.discordThreadId && + item.codexThreadId === this.session.codexThreadId, + ); + } + + #removeQueueItem(item: DiscordBridgeQueueItem): void { + const state = this.#state(); + state.queue = state.queue.filter((candidate) => candidate !== item); + } + + #state(): DiscordBridgeState { + return this.#context.getState(); + } + + #progressMode(): "summary" | "commentary" | "none" { + return this.#context.config.progressMode ?? "summary"; + } + + #emitConsoleMessage( + kind: DiscordConsoleMessageKind, + turnId: string | undefined, + text: string, + ): void { + try { + this.#context.consoleOutput?.message({ + kind, + text, + discordThreadId: this.session.discordThreadId, + codexThreadId: this.session.codexThreadId, + turnId, + title: this.session.title, + at: this.#context.now(), + }); + } catch (error) { + this.#debug("console.message.failed", { + kind, + turnId, + error: errorMessage(error), + }); + } + } + + #cwd(): string | undefined { + return this.session.cwd ?? this.#context.config.cwd; + } + + #debug(event: string, fields: Record = {}): void { + this.#context.debug(event, { + discordThreadId: this.session.discordThreadId, + codexThreadId: this.session.codexThreadId, + ...fields, + }); + } +} + +export class MessageDeduplicator { + #seen = new Map(); + #ttlMs: number; + #maxSize: number; + #now: () => Date; + + constructor(options: { ttlMs?: number; maxSize?: number; now: () => Date }) { + this.#ttlMs = options.ttlMs ?? 300_000; + this.#maxSize = options.maxSize ?? 2_000; + this.#now = options.now; + } + + isDuplicate(id: string): boolean { + if (!id) { + return false; + } + const now = this.#now().getTime(); + const seenAt = this.#seen.get(id); + if (seenAt !== undefined && now - seenAt < this.#ttlMs) { + return true; + } + this.#seen.set(id, now); + if (this.#seen.size > this.#maxSize) { + const cutoff = now - this.#ttlMs; + for (const [candidate, timestamp] of this.#seen) { + if (timestamp <= cutoff) { + this.#seen.delete(candidate); + } + } + if (this.#seen.size > this.#maxSize) { + const newest = [...this.#seen.entries()] + .sort((left, right) => left[1] - right[1]) + .slice(-this.#maxSize); + this.#seen = new Map(newest); + } + } + return false; + } +} + +type SummaryKeyParts = { + threadId: string; + turnId: string; + itemId: string; + summaryIndex: number; +}; + +type AgentMessageKeyParts = { + threadId: string; + turnId: string; + itemId: string; +}; + +type RuntimeGoal = { + objective: string; + status: string; +}; + +type RuntimePlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; + +type RunningCommand = { + itemId: string; + command: string; + status: "inProgress"; + startedAt: string; + lastOutputAt?: string; +}; + +type RuntimeActivity = { + itemId: string; + turnId: string; + kind: string; + label: string; + status: "inProgress" | "completed" | "failed" | "declined"; + updatedAt: string; +}; + +type StatusRenderInput = { + session: DiscordBridgeSession; + config: DiscordBridgeConfig; + activeTurn?: DiscordBridgeActiveTurn; + activeItem?: DiscordBridgeQueueItem; + pendingCount: number; + failedCount: number; + goal?: RuntimeGoal; + planExplanation?: string; + planSteps: RuntimePlanStep[]; + planText: string; + runningCommands: RunningCommand[]; + activities: RuntimeActivity[]; +}; + +function renderStatusMessage(input: StatusRenderInput): string { + const lines = [ + "**Codex Discord Bridge**", + `Mode: \`${input.session.mode ?? "new"}\``, + `Codex thread: \`${input.session.codexThreadId}\``, + `Dir: \`${input.session.cwd ?? input.config.cwd ?? "default"}\``, + `Progress: \`${input.config.progressMode ?? "summary"}\``, + `Model: \`${input.config.model ?? "default"}\``, + `Permissions: ${permissionSummary(input.config)}`, + "", + "**Access**", + `Owner: ${mentionUser(input.session.ownerUserId)}`, + `Participants: ${mentionUsers(input.session.participantUserIds ?? [])}`, + `Global admins: ${mentionUsers([...input.config.allowedUserIds])}`, + "", + "**Turn**", + `Status: ${turnStatus(input.activeTurn, input.activeItem)}`, + `Queue: ${input.pendingCount} pending, ${input.failedCount} failed`, + `Goal: ${goalSummary(input.goal)}`, + "", + "**Plan**", + ...planLines(input), + "", + "**Running Commands**", + ...runningCommandLines(input.runningCommands), + "", + "**Activity**", + ...activityLines(input.activities), + ]; + const text = lines.join("\n"); + return text.length <= 1900 ? text : `${text.slice(0, 1897).trimEnd()}...`; +} + +function permissionSummary(config: DiscordBridgeConfig): string { + const parts = [ + `approval \`${config.approvalPolicy ?? "default"}\``, + `permission profile \`${permissionProfileLabel(config.permissions)}\``, + `sandbox \`${config.sandbox ?? "default"}\``, + ]; + return parts.join(", "); +} + +function permissionProfileLabel( + permissions: DiscordBridgeConfig["permissions"], +): string { + if (!permissions) { + return "default"; + } + if (typeof permissions === "object" && "id" in permissions) { + return String(permissions.id); + } + return "custom"; +} + +function turnStatus( + active: DiscordBridgeActiveTurn | undefined, + item: DiscordBridgeQueueItem | undefined, +): string { + if (!active) { + return "`idle`"; + } + const queue = item ? `, queue \`${item.status}\`` : ""; + return `\`inProgress\`, origin \`${active.origin}\`, turn \`${compactId(active.turnId)}\`${queue}`; +} + +function goalSummary(goal: RuntimeGoal | undefined): string { + if (!goal) { + return "none"; + } + return `\`${goal.status}\` ${truncateOneLine(goal.objective, 160)}`; +} + +function planLines(input: StatusRenderInput): string[] { + if (input.planSteps.length > 0) { + return input.planSteps.slice(0, 8).map((step) => + `- \`${step.status}\` ${truncateOneLine(step.step, 160)}` + ); + } + if (input.planText) { + return input.planText.split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8) + .map((line) => `- ${truncateOneLine(line, 160)}`); + } + if (input.planExplanation) { + return [`- ${truncateOneLine(input.planExplanation, 160)}`]; + } + return ["none"]; +} + +function runningCommandLines(commands: RunningCommand[]): string[] { + if (commands.length === 0) { + return ["none"]; + } + return commands.slice(0, 8).map((command) => + `- \`${truncateOneLine(command.command, 140)}\`` + ); +} + +function activityLines(activities: RuntimeActivity[]): string[] { + if (activities.length === 0) { + return ["none"]; + } + return activities + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .slice(0, 6) + .map((activity) => + `- \`${activity.status}\` ${activity.kind}: ${truncateOneLine(activity.label, 120)}` + ); +} + +function mentionUser(userId: string | undefined): string { + return userId ? `<@${userId}>` : "unknown"; +} + +function mentionUsers(userIds: string[]): string { + return userIds.length > 0 + ? userIds.map((userId) => `<@${userId}>`).join(", ") + : "none"; +} + +function runtimeGoal(value: Record): RuntimeGoal | undefined { + const objective = stringValue(value.objective); + if (!objective) { + return undefined; + } + return { + objective, + status: stringValue(value.status) ?? "active", + }; +} + +function planStepStatus(value: unknown): RuntimePlanStep["status"] { + return value === "pending" || value === "inProgress" || value === "completed" + ? value + : "pending"; +} + +function commandStatus(value: unknown): "inProgress" | "completed" | "failed" | "declined" { + return value === "inProgress" || + value === "completed" || + value === "failed" || + value === "declined" + ? value + : "inProgress"; +} + +function activityFromItem( + item: Record, + turnId: string, + fallbackStatus: RuntimeActivity["status"], + now: Date, +): RuntimeActivity | undefined { + const itemId = stringValue(item.id); + if (!itemId) { + return undefined; + } + const status = activityStatus(item.status, fallbackStatus); + const base = { + itemId, + turnId, + status, + updatedAt: now.toISOString(), + }; + if (item.type === "fileChange") { + const changes = Array.isArray(item.changes) ? item.changes.length : 0; + return { + ...base, + kind: "files", + label: changes > 0 ? `${changes} file change${changes === 1 ? "" : "s"}` : "file changes", + }; + } + if (item.type === "mcpToolCall") { + const server = stringValue(item.server) ?? "mcp"; + const tool = stringValue(item.tool) ?? "tool"; + return { + ...base, + kind: "mcp", + label: `${server}.${tool}`, + }; + } + if (item.type === "dynamicToolCall") { + const namespace = stringValue(item.namespace); + const tool = stringValue(item.tool) ?? "tool"; + return { + ...base, + kind: "tool", + label: namespace ? `${namespace}.${tool}` : tool, + }; + } + if (item.type === "collabAgentToolCall") { + return { + ...base, + kind: "agent", + label: stringValue(item.tool) ?? "collab agent", + }; + } + if (item.type === "webSearch") { + return { + ...base, + kind: "web", + label: stringValue(item.query) ?? "web search", + }; + } + if (item.type === "imageGeneration") { + return { + ...base, + kind: "image", + label: "image generation", + }; + } + if (item.type === "contextCompaction") { + return { + ...base, + kind: "context", + label: "compaction", + }; + } + return undefined; +} + +function activityStatus( + value: unknown, + fallback: RuntimeActivity["status"], +): RuntimeActivity["status"] { + return value === "inProgress" || + value === "completed" || + value === "failed" || + value === "declined" + ? value + : fallback; +} + +function truncateOneLine(value: string, maxLength: number): string { + const oneLine = value.trim().replace(/\s+/g, " "); + if (oneLine.length <= maxLength) { + return oneLine; + } + return `${oneLine.slice(0, maxLength - 3).trimEnd()}...`; +} + +function formatDiscordPrompt(item: DiscordBridgeQueueItem): string { + return [ + "[discord]", + `Author: ${item.authorName} (${item.authorId})`, + `Message: ${item.discordMessageId}`, + `Discord thread: ${item.discordThreadId}`, + "", + item.content, + ].join("\n"); +} + +function retryDelayMs(item: DiscordBridgeQueueItem, now: Date): number { + if (!item.nextAttemptAt) { + return 0; + } + return Math.max(0, new Date(item.nextAttemptAt).getTime() - now.getTime()); +} + +function backoffMs(attempts: number): number { + return Math.min(30_000, 1000 * 2 ** Math.max(0, attempts - 1)); +} + +function turnKey(threadId: string, turnId: string): string { + return `${threadId}/${turnId}`; +} + +function compactId(value: string): string { + return value.length > 14 ? `${value.slice(0, 6)}...${value.slice(-6)}` : value; +} + +function summaryNotificationKey( + threadId: string, + turnId: string, + params: Record, +): SummaryKeyParts { + return { + threadId, + turnId, + itemId: stringValue(params.itemId) ?? "reasoning", + summaryIndex: numberValue(params.summaryIndex) ?? 0, + }; +} + +function summaryKeyString(parts: SummaryKeyParts): string { + return JSON.stringify([ + parts.threadId, + parts.turnId, + parts.itemId, + parts.summaryIndex, + ]); +} + +function summaryKeyParts(key: string): SummaryKeyParts { + const parsed = JSON.parse(key) as unknown; + if (!Array.isArray(parsed)) { + throw new Error("Invalid summary key"); + } + return { + threadId: String(parsed[0] ?? ""), + turnId: String(parsed[1] ?? ""), + itemId: String(parsed[2] ?? "reasoning"), + summaryIndex: typeof parsed[3] === "number" ? parsed[3] : 0, + }; +} + +function agentMessageKey(parts: AgentMessageKeyParts): string { + return JSON.stringify([parts.threadId, parts.turnId, parts.itemId]); +} + +function agentMessageKeyParts(key: string): AgentMessageKeyParts { + const parsed = JSON.parse(key) as unknown; + if (!Array.isArray(parsed)) { + throw new Error("Invalid agent message key"); + } + return { + threadId: String(parsed[0] ?? ""), + turnId: String(parsed[1] ?? ""), + itemId: String(parsed[2] ?? "agent-message"), + }; +} + +function messagePhase(value: unknown): "commentary" | "final_answer" | undefined { + return value === "commentary" || value === "final_answer" ? value : undefined; +} + +function turnStartedAt(turn: Record): string | undefined { + const startedAt = numberValue(turn.startedAt); + return startedAt === undefined + ? undefined + : new Date(startedAt * 1000).toISOString(); +} + +function isActiveTurn( + value: DiscordBridgeActiveTurn | DiscordBridgeQueueItem, +): value is DiscordBridgeActiveTurn { + return "origin" in value; +} + +function isDuplicate(state: DiscordBridgeState, messageId: string): boolean { + return ( + state.processedMessageIds.includes(messageId) || + state.queue.some((item) => item.discordMessageId === messageId) || + state.deliveries.some((delivery) => delivery.discordMessageId === messageId) + ); +} + +function addProcessedMessageId(state: DiscordBridgeState, messageId: string): void { + state.processedMessageIds = [ + ...state.processedMessageIds.filter((candidate) => candidate !== messageId), + messageId, + ].slice(-1000); +} + +function finalTextFromTurn(turn: Record): string { + const items = Array.isArray(turn.items) ? turn.items : []; + const agentMessages = items + .filter(isRecord) + .filter((item) => item.type === "agentMessage"); + const finalMessages = agentMessages.filter( + (item) => item.phase === "final_answer", + ); + const selected = finalMessages.length > 0 ? finalMessages : agentMessages; + return selected + .map((item) => stringValue(item.text) ?? "") + .filter(Boolean) + .join("\n\n"); +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/discord-bridge/src/state.ts b/apps/discord-bridge/src/state.ts new file mode 100644 index 0000000..25e4be7 --- /dev/null +++ b/apps/discord-bridge/src/state.ts @@ -0,0 +1,222 @@ +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +import type { + DiscordBridgeActiveTurn, + DiscordBridgeDelivery, + DiscordBridgeQueueItem, + DiscordBridgeSession, + DiscordBridgeState, + DiscordBridgeStateStore, +} from "./types.ts"; + +const maxProcessedMessageIds = 1000; +const maxDeliveries = 500; + +export class JsonFileStateStore implements DiscordBridgeStateStore { + readonly path: string; + + constructor(filePath: string) { + this.path = path.resolve(filePath); + } + + async load(): Promise { + const file = Bun.file(this.path); + if (!(await file.exists())) { + return emptyState(); + } + const parsed = JSON.parse(await file.text()) as unknown; + return parseState(parsed); + } + + async save(state: DiscordBridgeState): Promise { + trimState(state); + await mkdir(path.dirname(this.path), { recursive: true }); + const tempPath = `${this.path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + await writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`); + await rename(tempPath, this.path); + } +} + +export class MemoryStateStore implements DiscordBridgeStateStore { + state: DiscordBridgeState; + + constructor(state: DiscordBridgeState = emptyState()) { + this.state = structuredClone(state); + } + + async load(): Promise { + return structuredClone(this.state); + } + + async save(state: DiscordBridgeState): Promise { + this.state = structuredClone(state); + } +} + +export function emptyState(): DiscordBridgeState { + return { + version: 1, + sessions: [], + queue: [], + activeTurns: [], + processedMessageIds: [], + deliveries: [], + }; +} + +export function trimState(state: DiscordBridgeState): void { + state.processedMessageIds = state.processedMessageIds.slice( + -maxProcessedMessageIds, + ); + state.deliveries = state.deliveries.slice(-maxDeliveries); +} + +function parseState(value: unknown): DiscordBridgeState { + if (!isRecord(value) || value.version !== 1) { + throw new Error("Invalid Discord bridge state file"); + } + return { + version: 1, + sessions: Array.isArray(value.sessions) + ? value.sessions.map(parseSession) + : [], + queue: Array.isArray(value.queue) ? value.queue.map(parseQueueItem) : [], + activeTurns: Array.isArray(value.activeTurns) + ? value.activeTurns.map(parseActiveTurn) + : [], + processedMessageIds: Array.isArray(value.processedMessageIds) + ? value.processedMessageIds.filter( + (candidate): candidate is string => typeof candidate === "string", + ) + : [], + deliveries: Array.isArray(value.deliveries) + ? value.deliveries.map(parseDelivery) + : [], + }; +} + +function parseActiveTurn(value: unknown): DiscordBridgeActiveTurn { + if (!isRecord(value)) { + throw new Error("Invalid Discord bridge active turn"); + } + const origin = value.origin === "discord" || value.origin === "external" + ? value.origin + : "external"; + return { + turnId: requiredString(value.turnId, "activeTurns.turnId"), + discordThreadId: requiredString(value.discordThreadId, "activeTurns.discordThreadId"), + codexThreadId: requiredString(value.codexThreadId, "activeTurns.codexThreadId"), + origin, + queueItemId: optionalString(value.queueItemId), + startedAt: optionalString(value.startedAt), + observedAt: requiredString(value.observedAt, "activeTurns.observedAt"), + }; +} + +function parseSession(value: unknown): DiscordBridgeSession { + if (!isRecord(value)) { + throw new Error("Invalid Discord bridge session"); + } + return { + discordThreadId: requiredString(value.discordThreadId, "session.discordThreadId"), + parentChannelId: requiredString(value.parentChannelId, "session.parentChannelId"), + guildId: optionalString(value.guildId), + sourceMessageId: optionalString(value.sourceMessageId), + codexThreadId: requiredString(value.codexThreadId, "session.codexThreadId"), + title: requiredString(value.title, "session.title"), + createdAt: requiredString(value.createdAt, "session.createdAt"), + ownerUserId: optionalString(value.ownerUserId), + participantUserIds: Array.isArray(value.participantUserIds) + ? uniqueStrings(value.participantUserIds) + : undefined, + cwd: optionalString(value.cwd), + mode: parseSessionMode(value.mode), + statusMessageId: optionalString(value.statusMessageId), + }; +} + +function parseQueueItem(value: unknown): DiscordBridgeQueueItem { + if (!isRecord(value)) { + throw new Error("Invalid Discord bridge queue item"); + } + const status = value.status; + if (status !== "pending" && status !== "processing" && status !== "failed") { + throw new Error("Invalid Discord bridge queue item status"); + } + return { + id: requiredString(value.id, "queue.id"), + status, + discordMessageId: requiredString(value.discordMessageId, "queue.discordMessageId"), + discordThreadId: requiredString(value.discordThreadId, "queue.discordThreadId"), + codexThreadId: requiredString(value.codexThreadId, "queue.codexThreadId"), + authorId: requiredString(value.authorId, "queue.authorId"), + authorName: requiredString(value.authorName, "queue.authorName"), + content: requiredString(value.content, "queue.content"), + createdAt: requiredString(value.createdAt, "queue.createdAt"), + receivedAt: requiredString(value.receivedAt, "queue.receivedAt"), + attempts: optionalNumber(value.attempts) ?? 0, + turnId: optionalString(value.turnId), + lastError: optionalString(value.lastError), + nextAttemptAt: optionalString(value.nextAttemptAt), + }; +} + +function parseDelivery(value: unknown): DiscordBridgeDelivery { + if (!isRecord(value)) { + throw new Error("Invalid Discord bridge delivery"); + } + const kind = value.kind; + if ( + kind !== "summary" && + kind !== "commentary" && + kind !== "final" && + kind !== "error" + ) { + throw new Error("Invalid Discord bridge delivery kind"); + } + return { + discordMessageId: requiredString(value.discordMessageId, "delivery.discordMessageId"), + discordThreadId: requiredString(value.discordThreadId, "delivery.discordThreadId"), + codexThreadId: requiredString(value.codexThreadId, "delivery.codexThreadId"), + turnId: optionalString(value.turnId), + kind, + outboundMessageIds: Array.isArray(value.outboundMessageIds) + ? value.outboundMessageIds.filter( + (candidate): candidate is string => typeof candidate === "string", + ) + : [], + deliveredAt: requiredString(value.deliveredAt, "delivery.deliveredAt"), + }; +} + +function requiredString(value: unknown, fieldName: string): string { + const parsed = optionalString(value); + if (!parsed) { + throw new Error(`Invalid Discord bridge state ${fieldName}: expected string`); + } + return parsed; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function parseSessionMode(value: unknown): DiscordBridgeSession["mode"] { + return value === "new" || value === "resumed" ? value : undefined; +} + +function uniqueStrings(values: unknown[]): string[] { + return [...new Set(values.filter( + (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0, + ))]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/discord-bridge/src/types.ts b/apps/discord-bridge/src/types.ts new file mode 100644 index 0000000..81c0119 --- /dev/null +++ b/apps/discord-bridge/src/types.ts @@ -0,0 +1,177 @@ +import type { + ReasoningEffort, + ReasoningSummary, + v2, +} from "@peezy-tech/codex-flows/generated"; +import type { JsonRpcNotification, JsonRpcRequest } from "@peezy-tech/codex-flows/rpc"; +import type { DiscordBridgeLogLevelSetting } from "./logger.ts"; + +export type DiscordBridgeConfig = { + allowedUserIds: Set; + allowedChannelIds: Set; + statePath: string; + cwd?: string; + model?: string; + modelProvider?: string; + serviceTier?: string; + effort?: ReasoningEffort; + summary?: ReasoningSummary; + approvalPolicy?: v2.AskForApproval; + sandbox?: v2.SandboxMode; + permissions?: v2.PermissionProfileSelectionParams; + typingIntervalMs?: number; + reconcileIntervalMs?: number; + progressMode?: DiscordProgressMode; + consoleOutput?: DiscordConsoleOutputMode; + logLevel?: DiscordBridgeLogLevelSetting; + debug?: boolean; +}; + +export type DiscordProgressMode = "summary" | "commentary" | "none"; +export type DiscordConsoleOutputMode = "messages" | "none"; + +export type DiscordAuthor = { + id: string; + name: string; + isBot: boolean; +}; + +export type DiscordMessageInbound = { + kind: "message"; + channelId: string; + guildId?: string; + messageId: string; + author: DiscordAuthor; + content: string; + createdAt: string; +}; + +export type DiscordThreadStartInbound = { + kind: "threadStart"; + sourceMessageId: string; + channelId: string; + guildId?: string; + author: DiscordAuthor; + prompt?: string; + mentionedUserIds?: string[]; + title?: string; + createdAt: string; + reply?: (text: string) => Promise; +}; + +export type DiscordClearInbound = { + kind: "clear"; + channelId: string; + guildId?: string; + author: DiscordAuthor; + createdAt: string; + reply?: (text: string) => Promise; +}; + +export type DiscordInbound = + | DiscordMessageInbound + | DiscordThreadStartInbound + | DiscordClearInbound; + +export type DiscordBridgeTransportHandlers = { + onInbound(inbound: DiscordInbound): void; +}; + +export type DiscordBridgeTransport = { + start(handlers: DiscordBridgeTransportHandlers): Promise; + stop(): Promise; + registerCommands(): Promise; + createThread( + channelId: string, + name: string, + sourceMessageId?: string, + ): Promise; + sendMessage(channelId: string, text: string): Promise; + updateMessage?(channelId: string, messageId: string, text: string): Promise; + deleteMessage(channelId: string, messageId: string): Promise; + deleteThread?(channelId: string): Promise; + addThreadMembers?(channelId: string, userIds: string[]): Promise; + pinMessage?(channelId: string, messageId: string): Promise; + sendTyping(channelId: string): Promise; +}; + +export type CodexBridgeClient = { + connect(): Promise; + close(): void; + on(event: "notification", listener: (message: JsonRpcNotification) => void): unknown; + on(event: "request", listener: (message: JsonRpcRequest) => void): unknown; + startThread(params: v2.ThreadStartParams): Promise; + resumeThread(params: v2.ThreadResumeParams): Promise; + setThreadName(params: v2.ThreadSetNameParams): Promise; + startTurn(params: v2.TurnStartParams): Promise; + steerTurn(params: v2.TurnSteerParams): Promise; + readThread(params: v2.ThreadReadParams): Promise; + getThreadGoal(params: v2.ThreadGoalGetParams): Promise; + respondError(id: string | number, code: number, message: string, data?: unknown): void; +}; + +export type DiscordBridgeState = { + version: 1; + sessions: DiscordBridgeSession[]; + queue: DiscordBridgeQueueItem[]; + activeTurns: DiscordBridgeActiveTurn[]; + processedMessageIds: string[]; + deliveries: DiscordBridgeDelivery[]; +}; + +export type DiscordBridgeSession = { + discordThreadId: string; + parentChannelId: string; + guildId?: string; + sourceMessageId?: string; + codexThreadId: string; + title: string; + createdAt: string; + ownerUserId?: string; + participantUserIds?: string[]; + cwd?: string; + mode?: "new" | "resumed"; + statusMessageId?: string; +}; + +export type DiscordBridgeQueueItem = { + id: string; + status: "pending" | "processing" | "failed"; + discordMessageId: string; + discordThreadId: string; + codexThreadId: string; + authorId: string; + authorName: string; + content: string; + createdAt: string; + receivedAt: string; + attempts: number; + turnId?: string; + lastError?: string; + nextAttemptAt?: string; +}; + +export type DiscordBridgeActiveTurn = { + turnId: string; + discordThreadId: string; + codexThreadId: string; + origin: "discord" | "external"; + queueItemId?: string; + startedAt?: string; + observedAt: string; +}; + +export type DiscordBridgeDelivery = { + discordMessageId: string; + discordThreadId: string; + codexThreadId: string; + turnId?: string; + kind: "summary" | "commentary" | "final" | "error"; + outboundMessageIds: string[]; + deliveredAt: string; +}; + +export type DiscordBridgeStateStore = { + load(): Promise; + save(state: DiscordBridgeState): Promise; +}; diff --git a/apps/discord-bridge/test/bridge.test.ts b/apps/discord-bridge/test/bridge.test.ts new file mode 100644 index 0000000..bad0d7e --- /dev/null +++ b/apps/discord-bridge/test/bridge.test.ts @@ -0,0 +1,2340 @@ +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "bun:test"; +import type { JsonRpcNotification, JsonRpcRequest } from "@peezy-tech/codex-flows/rpc"; +import type { v2 } from "@peezy-tech/codex-flows/generated"; + +import { DiscordCodexBridge, parseThreadStartIntent } from "../src/bridge.ts"; +import type { + DiscordConsoleMessage, + DiscordConsoleOutput, +} from "../src/console-output.ts"; +import { MemoryStateStore, emptyState } from "../src/state.ts"; +import type { + CodexBridgeClient, + DiscordBridgeConfig, + DiscordBridgeTransport, + DiscordBridgeTransportHandlers, + DiscordInbound, +} from "../src/types.ts"; + +describe("DiscordCodexBridge", () => { + test("parses mention control text for resume and per-thread directories", () => { + expect(parseThreadStartIntent("resume codex-thread-123 --dir ~/project")).toEqual({ + kind: "resume", + codexThreadId: "codex-thread-123", + cwd: path.join(os.homedir(), "project"), + }); + expect(parseThreadStartIntent("--dir projects/demo inspect this")).toEqual({ + kind: "new", + prompt: "inspect this", + cwd: path.join(os.homedir(), "projects/demo"), + }); + expect(parseThreadStartIntent("resume")).toEqual({ + kind: "invalid", + message: "Usage: @codex resume [--dir path]", + }); + }); + + test("starts a Discord thread from a mention and sends summaries only after chunks complete", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-mention-1", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + title: "Investigate release", + prompt: "What changed in this release?", + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + expect(transport.createdThreads).toEqual([ + { + channelId: "parent-channel", + name: "Investigate release", + sourceMessageId: "message-mention-1", + }, + ]); + expect(client.startThreadCalls).toHaveLength(1); + expect(client.setThreadNameCalls[0]).toEqual({ + threadId: "codex-thread-1", + name: "[discord] Investigate release", + }); + expect(client.startTurnCalls[0]?.input[0]).toEqual( + expect.objectContaining({ + type: "text", + text: expect.stringContaining("What changed in this release?"), + }), + ); + + const messageCountAfterStart = transport.messages.length; + client.emitNotification({ + method: "item/reasoning/summaryPartAdded", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "reasoning-1", + summaryIndex: 0, + }, + }); + client.emitNotification({ + method: "item/reasoning/summaryTextDelta", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "reasoning-1", + summaryIndex: 0, + delta: "Checking changed files.", + }, + }); + client.emitNotification({ + method: "item/reasoning/summaryTextDelta", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "reasoning-1", + summaryIndex: 0, + delta: " Reading test coverage.", + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(transport.messages).toHaveLength(messageCountAfterStart); + expect( + transport.updatedMessages.some((message) => + message.text.includes("Checking changed files") + ), + ).toBe(false); + expect( + transport.messages.filter((message) => + message.text.includes("Checking changed files") + ), + ).toHaveLength(0); + client.emitNotification({ + method: "item/reasoning/summaryPartAdded", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "reasoning-1", + summaryIndex: 1, + }, + }); + await waitFor(() => + transport.messages.some((message) => + message.text === "Checking changed files. Reading test coverage." + ) + ); + client.emitNotification({ + method: "item/reasoning/summaryTextDelta", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "reasoning-1", + summaryIndex: 1, + delta: "Inspecting implementation boundaries.", + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect( + transport.messages.some((message) => + message.text === "Inspecting implementation boundaries." + ), + ).toBe(false); + client.emitNotification({ + method: "item/completed", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + id: "reasoning-1", + type: "reasoning", + summary: [ + "Checking changed files. Reading test coverage.", + "Inspecting implementation boundaries.", + ], + }, + }, + }); + await waitFor(() => + transport.messages.some((message) => + message.text === "Inspecting implementation boundaries." + ) + ); + expect( + transport.updatedMessages.some((message) => + message.text === "Inspecting implementation boundaries." + ), + ).toBe(false); + await waitFor(() => transport.typingCount >= 2); + client.emitNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "message-1", + delta: "The release changed the Discord bridge.", + }, + }); + client.emitNotification({ + method: "item/completed", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + id: "message-1", + type: "agentMessage", + text: "The release changed the Discord bridge.", + phase: "final_answer", + memoryCitation: null, + }, + }, + }); + client.emitNotification({ + method: "turn/completed", + params: { + threadId: "codex-thread-1", + turn: { id: "turn-1" }, + }, + }); + + await waitFor(() => + transport.messages.some((message) => + message.text === "The release changed the Discord bridge." + ) + ); + expect(bridge.stateForTest().processedMessageIds).toContain( + "message-mention-1", + ); + expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([ + "summary", + "summary", + "final", + ]); + await waitFor(() => transport.deletedMessages.length === 2); + expect(transport.deletedMessages.map((message) => message.text)).toEqual([ + "Checking changed files. Reading test coverage.", + "Inspecting implementation boundaries.", + ]); + expect( + transport.messages + .map((message) => message.text) + .filter((text) => + [ + "Checking changed files. Reading test coverage.", + "Inspecting implementation boundaries.", + "The release changed the Discord bridge.", + ].includes(text) + ), + ).toEqual([ + "The release changed the Discord bridge.", + ]); + const typingCountAfterFinal = transport.typingCount; + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(transport.typingCount).toBe(typingCountAfterFinal); + await bridge.stop(); + }); + + test("starts a thread from a bot DM by a global user outside allowed guild channels", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(), + config: testConfig({ allowedChannelIds: new Set(["guild-parent-channel"]) }), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-dm-1", + channelId: "bot-dm-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + title: "DM request", + prompt: "Handle this from DM.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + expect(transport.createdThreads).toEqual([ + { + channelId: "bot-dm-channel", + name: "DM request", + sourceMessageId: "message-dm-1", + }, + ]); + expect(bridge.stateForTest().sessions[0]).toEqual( + expect.objectContaining({ + discordThreadId: "discord-thread-1", + parentChannelId: "bot-dm-channel", + guildId: undefined, + }), + ); + await bridge.stop(); + }); + + test("can use commentary messages as progress and keep final output phase-aware", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const consoleOutput = new FakeConsoleOutput(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig({ progressMode: "commentary" }), + now: () => new Date("2026-05-11T00:00:00.000Z"), + consoleOutput, + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-mention-2", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + title: "Scan repo", + prompt: "Scan this repo.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + const messageCountAfterStart = transport.messages.length; + client.emitNotification({ + method: "item/reasoning/summaryPartAdded", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "reasoning-1", + summaryIndex: 0, + }, + }); + client.emitNotification({ + method: "item/reasoning/summaryTextDelta", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "reasoning-1", + summaryIndex: 0, + delta: "Reasoning summary should not post.", + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(transport.messages).toHaveLength(messageCountAfterStart); + + client.emitNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "commentary-1", + delta: "I will scan the repo.", + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect( + transport.messages.some((message) => + message.text === "I will scan the repo." + ), + ).toBe(false); + client.emitNotification({ + method: "item/completed", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + id: "commentary-1", + type: "agentMessage", + text: "I will scan the repo.", + phase: "commentary", + memoryCitation: null, + }, + }, + }); + await waitFor(() => + transport.messages.some((message) => + message.text === "I will scan the repo." + ) + ); + expect(consoleOutput.messages).toEqual([ + expect.objectContaining({ + kind: "commentary", + text: "I will scan the repo.", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-1", + turnId: "turn-1", + title: "Scan repo", + }), + ]); + + client.emitNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "final-1", + delta: "Repo scan complete.", + }, + }); + client.emitNotification({ + method: "item/completed", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + id: "final-1", + type: "agentMessage", + text: "Repo scan complete.", + phase: "final_answer", + memoryCitation: null, + }, + }, + }); + client.emitNotification({ + method: "turn/completed", + params: { + threadId: "codex-thread-1", + turn: { + id: "turn-1", + items: [ + { + id: "commentary-1", + type: "agentMessage", + text: "I will scan the repo.", + phase: "commentary", + memoryCitation: null, + }, + { + id: "final-1", + type: "agentMessage", + text: "Repo scan complete.", + phase: "final_answer", + memoryCitation: null, + }, + ], + }, + }, + }); + + await waitFor(() => + transport.messages.some((message) => message.text === "Repo scan complete.") + ); + expect(consoleOutput.messages).toEqual([ + expect.objectContaining({ + kind: "commentary", + text: "I will scan the repo.", + }), + expect.objectContaining({ + kind: "final", + text: "Repo scan complete.", + turnId: "turn-1", + title: "Scan repo", + }), + ]); + await waitFor(() => transport.deletedMessages.length === 1); + expect(transport.deletedMessages[0]?.text).toBe("I will scan the repo."); + expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([ + "commentary", + "final", + ]); + expect( + transport.messages.some((message) => + message.text.includes("Reasoning summary should not post") + ), + ).toBe(false); + expect( + transport.messages.some((message) => + message.text === "I will scan the repo." + ), + ).toBe(false); + expect( + transport.messages.filter((message) => + message.text === "Repo scan complete." + ), + ).toHaveLength(1); + await bridge.stop(); + }); + + test("grants mentioned users access only to the created Discord thread", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig({ allowedUserIds: new Set(["user-1", "user-admin"]) }), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-grant-start", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + prompt: "<@user-2> <@!user-3> Please investigate this repo.", + mentionedUserIds: ["user-2", "user-3", "user-1", "user-2"], + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + expect(transport.createdThreads).toEqual([ + { + channelId: "parent-channel", + name: "Please investigate this repo.", + sourceMessageId: "message-grant-start", + }, + ]); + expect(transport.addedThreadMembers).toEqual([ + { channelId: "discord-thread-1", userIds: ["user-2", "user-3"] }, + ]); + const initialPrompt = inputText(client.startTurnCalls[0]?.input[0]); + expect(initialPrompt).toContain("Please investigate this repo."); + expect(initialPrompt).not.toContain("<@user-2>"); + expect(initialPrompt).not.toContain("<@!user-3>"); + expect(bridge.stateForTest().sessions[0]).toEqual( + expect.objectContaining({ + ownerUserId: "user-1", + participantUserIds: ["user-2", "user-3"], + }), + ); + + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-from-grantee", + author: { id: "user-2", name: "Grace", isBot: false }, + content: "Here is more context.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => client.steerTurnCalls.length === 1); + expect(client.steerTurnCalls[0]?.input[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining("Here is more context."), + }), + ); + + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-from-rando", + author: { id: "user-4", name: "Edsger", isBot: false }, + content: "I should not reach Codex.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(client.steerTurnCalls).toHaveLength(1); + + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-from-admin", + author: { id: "user-admin", name: "Admin", isBot: false }, + content: "Admin context should reach Codex.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => client.steerTurnCalls.length === 2); + expect(client.steerTurnCalls[1]?.input[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining("Admin context should reach Codex."), + }), + ); + + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-grantee-start-denied", + channelId: "parent-channel", + author: { id: "user-2", name: "Grace", isBot: false }, + prompt: "Start a second thread.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(transport.createdThreads).toHaveLength(1); + expect(client.startThreadCalls).toHaveLength(1); + await bridge.stop(); + }); + + test("stores per-thread directories and pins a status message for new threads", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig({ + allowedUserIds: new Set(["user-1", "user-admin"]), + approvalPolicy: "on-request", + sandbox: "workspace-write", + }), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-dir-start", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + prompt: "--dir ~/game-protocol-workspace Build the parser.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + const expectedCwd = path.join(os.homedir(), "game-protocol-workspace"); + expect(client.startThreadCalls[0]?.cwd).toBe(expectedCwd); + expect(client.startTurnCalls[0]?.cwd).toBe(expectedCwd); + expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( + "Build the parser.", + ); + expect(inputText(client.startTurnCalls[0]?.input[0])).not.toContain("--dir"); + expect(bridge.stateForTest().sessions[0]).toEqual( + expect.objectContaining({ + cwd: expectedCwd, + mode: "new", + statusMessageId: "message-out-1", + }), + ); + expect(transport.pinnedMessages).toEqual([ + { channelId: "discord-thread-1", messageId: "message-out-1" }, + ]); + const statusText = transport.messages.find((message) => + message.id === "message-out-1" + )?.text ?? ""; + expect(statusText).toContain("**Codex Discord Bridge**"); + expect(statusText).toContain(`Dir: \`${expectedCwd}\``); + expect(statusText).toContain("Global admins: <@user-1>, <@user-admin>"); + expect(statusText).toContain("Permissions: approval `on-request`"); + + client.emitNotification({ + method: "item/completed", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + id: "message-final", + type: "agentMessage", + text: "First turn done.", + phase: "final_answer", + memoryCitation: null, + }, + }, + }); + client.emitNotification({ + method: "turn/completed", + params: { + threadId: "codex-thread-1", + turn: { id: "turn-1" }, + }, + }); + await waitFor(() => bridge.stateForTest().queue.length === 0); + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-follow-up", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "Continue in the same directory.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => client.startTurnCalls.length === 2); + expect(client.startTurnCalls[1]?.cwd).toBe(expectedCwd); + await bridge.stop(); + }); + + test("resumes arbitrary Codex threads without prompting and replays the last final message", async () => { + const client = new FakeCodexClient(); + const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5"; + client.threadTurns.set(resumedThreadId, [ + { + id: "turn-old-1", + status: "completed", + items: [ + { + type: "agentMessage", + id: "old-final", + text: "Earlier answer.", + phase: "final_answer", + memoryCitation: null, + }, + ], + } as unknown as v2.Turn, + { + id: "turn-old-2", + status: "completed", + items: [ + { + type: "agentMessage", + id: "latest-commentary", + text: "This is commentary.", + phase: "commentary", + memoryCitation: null, + }, + { + type: "agentMessage", + id: "latest-final", + text: "Latest final answer.", + phase: "final_answer", + memoryCitation: null, + }, + ], + } as unknown as v2.Turn, + ]); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-resume-start", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + prompt: `resume ${resumedThreadId} --dir ~/game-protocol-workspace`, + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => + transport.messages.some((message) => message.text === "Latest final answer.") + ); + const expectedCwd = path.join(os.homedir(), "game-protocol-workspace"); + expect(client.resumeThreadCalls).toHaveLength(1); + expect(client.resumeThreadCalls[0]).toEqual( + expect.objectContaining({ + threadId: resumedThreadId, + cwd: expectedCwd, + }), + ); + expect(client.startThreadCalls).toHaveLength(0); + expect(client.startTurnCalls).toHaveLength(0); + expect(client.setThreadNameCalls).toHaveLength(0); + expect(bridge.stateForTest().sessions[0]).toEqual( + expect.objectContaining({ + codexThreadId: resumedThreadId, + cwd: expectedCwd, + mode: "resumed", + statusMessageId: "message-out-1", + }), + ); + expect(transport.pinnedMessages).toEqual([ + { channelId: "discord-thread-1", messageId: "message-out-1" }, + ]); + expect(transport.messages[0]?.text).toContain("Mode: `resumed`"); + expect(transport.messages[0]?.text).toContain(`Dir: \`${expectedCwd}\``); + expect(transport.messages.map((message) => message.text)).toContain( + "Latest final answer.", + ); + await bridge.stop(); + }); + + test("ignores historical progress notifications after resume replay", async () => { + const client = new FakeCodexClient(); + const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5"; + const completedTurn = { + id: "turn-history-1", + status: "completed", + items: [ + { + id: "latest-final", + type: "agentMessage", + text: "Latest final answer.", + phase: "final_answer", + memoryCitation: null, + }, + ], + } as unknown as v2.Turn; + client.threadTurns.set(resumedThreadId, [completedTurn]); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig({ progressMode: "commentary" }), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-resume-history", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + prompt: `resume ${resumedThreadId}`, + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => + bridge.stateForTest().processedMessageIds.includes("message-resume-history") + ); + const messagesAfterResume = transport.messages.map((message) => message.text); + expect( + messagesAfterResume.filter((message) => message === "Latest final answer."), + ).toHaveLength(1); + + client.emitNotification({ + method: "item/completed", + params: { + threadId: resumedThreadId, + turnId: "turn-history-1", + itemId: "historical-commentary", + item: { + id: "historical-commentary", + type: "agentMessage", + text: "Historical commentary.", + phase: "commentary", + memoryCitation: null, + }, + }, + } as JsonRpcNotification); + client.emitNotification({ + method: "turn/completed", + params: { + threadId: resumedThreadId, + turnId: "turn-history-1", + turn: completedTurn, + }, + } as JsonRpcNotification); + await sleep(50); + + expect(transport.messages.map((message) => message.text)).toEqual( + messagesAfterResume, + ); + expect(transport.deletedMessages).toEqual([]); + expect(bridge.stateForTest().activeTurns).toEqual([]); + await bridge.stop(); + }); + + test("cleans stale historical progress after resume restart", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + transport.messages.push( + { + channelId: "discord-thread-1", + id: "message-stale-commentary-1", + text: "Stale commentary 1.", + }, + { + channelId: "discord-thread-1", + id: "message-stale-commentary-2", + text: "Stale commentary 2.", + }, + ); + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-1", + parentChannelId: "parent-channel", + sourceMessageId: "message-resume-start", + codexThreadId: "codex-thread-resumed", + title: "Resumed thread", + createdAt: "2026-05-11T00:00:00.000Z", + mode: "resumed", + statusMessageId: "message-status-1", + }, + ], + activeTurns: [ + { + turnId: "turn-history-1", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-resumed", + origin: "external", + observedAt: "2026-05-11T00:00:00.000Z", + }, + ], + deliveries: [ + { + discordMessageId: "resume:message-resume-start:turn-history-1", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-resumed", + turnId: "turn-history-1", + kind: "final", + outboundMessageIds: ["message-final-1"], + deliveredAt: "2026-05-11T00:00:00.000Z", + }, + { + discordMessageId: "external:turn-history-1", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-resumed", + turnId: "turn-history-1", + kind: "commentary", + outboundMessageIds: [ + "message-stale-commentary-1", + "message-stale-commentary-2", + ], + deliveredAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + await waitFor(() => transport.deletedMessages.length === 2); + + expect(transport.deletedMessages.map((message) => message.messageId)).toEqual([ + "message-stale-commentary-1", + "message-stale-commentary-2", + ]); + expect(bridge.stateForTest().activeTurns).toEqual([]); + expect( + bridge.stateForTest().deliveries.find( + (delivery) => delivery.kind === "commentary", + )?.outboundMessageIds, + ).toEqual([]); + await bridge.stop(); + }); + + test("resume without dir uses the resumed Codex thread cwd", async () => { + const client = new FakeCodexClient(); + const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5"; + const threadCwd = "/home/peezy/original-thread-workspace"; + client.threadCwds.set(resumedThreadId, threadCwd); + client.threadTurns.set(resumedThreadId, [ + { + id: "turn-old-1", + status: "completed", + items: [ + { + type: "agentMessage", + id: "latest-final", + text: "Original cwd answer.", + phase: "final_answer", + memoryCitation: null, + }, + ], + } as unknown as v2.Turn, + ]); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig({ cwd: "/home/peezy/game-protocol-workspace" }), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-resume-no-dir", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + prompt: `resume ${resumedThreadId}`, + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => + transport.messages.some((message) => message.text === "Original cwd answer.") + ); + expect(client.resumeThreadCalls[0]).toEqual( + expect.objectContaining({ + threadId: resumedThreadId, + cwd: null, + }), + ); + expect(bridge.stateForTest().sessions[0]).toEqual( + expect.objectContaining({ + codexThreadId: resumedThreadId, + cwd: threadCwd, + mode: "resumed", + }), + ); + expect(transport.messages[0]?.text).toContain(`Dir: \`${threadCwd}\``); + await bridge.stop(); + }); + + test("updates pinned status with goal, plan, and running command metadata", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + let now = new Date("2026-05-11T00:00:00.000Z"); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => now, + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-status-start", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + prompt: "Inspect status updates.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + client.emitNotification({ + method: "thread/goal/updated", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + goal: { + threadId: "codex-thread-1", + objective: "Ship the Discord bridge status surface", + status: "active", + tokenBudget: null, + tokensUsed: 10, + timeUsedSeconds: 2, + createdAt: 0, + updatedAt: 0, + }, + }, + }); + client.emitNotification({ + method: "turn/plan/updated", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + explanation: null, + plan: [ + { step: "Inspect current bridge", status: "completed" }, + { step: "Implement pinned status", status: "inProgress" }, + ], + }, + }); + client.emitNotification({ + method: "item/started", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + type: "commandExecution", + id: "command-1", + command: "bun test test/*.test.ts", + cwd: "/workspace", + processId: "process-1", + source: "agent", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }); + + await waitFor(() => { + const text = statusMessageText(transport); + return text.includes("Ship the Discord bridge status surface") && + text.includes("Implement pinned status"); + }); + let statusText = statusMessageText(transport); + expect(statusText).toContain("Goal: `active` Ship the Discord bridge status surface"); + expect(statusText).toContain("- `completed` Inspect current bridge"); + expect(statusText).toContain("- `inProgress` Implement pinned status"); + expect(statusText).toContain("**Running Commands**\nnone"); + expect(statusText).not.toContain("bun test test/*.test.ts"); + + now = new Date("2026-05-11T00:00:05.000Z"); + client.emitNotification({ + method: "item/commandExecution/outputDelta", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + itemId: "command-1", + delta: "test output", + }, + }); + await waitFor(() => + statusMessageText(transport).includes("bun test test/*.test.ts") + ); + statusText = statusMessageText(transport); + expect(statusText).toContain("- `bun test test/*.test.ts`"); + + client.emitNotification({ + method: "item/completed", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + type: "commandExecution", + id: "command-1", + command: "bun test test/*.test.ts", + cwd: "/workspace", + processId: "process-1", + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "", + exitCode: 0, + durationMs: 10, + }, + }, + }); + await waitFor(() => !statusMessageText(transport).includes("bun test")); + statusText = statusMessageText(transport); + expect(statusText).toContain("**Running Commands**\nnone"); + + const messageCountBeforeActivity = transport.messages.length; + client.emitNotification({ + method: "item/started", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + type: "fileChange", + id: "patch-1", + changes: [ + { type: "add", path: "/workspace/src/worker.ts", content: "test" }, + { type: "update", path: "/workspace/package.json", content: "test" }, + ], + status: "inProgress", + }, + }, + }); + await waitFor(() => + statusMessageText(transport).includes("files: 2 file changes") + ); + statusText = statusMessageText(transport); + expect(statusText).toContain("**Activity**"); + expect(statusText).toContain("- `inProgress` files: 2 file changes"); + expect(transport.messages).toHaveLength(messageCountBeforeActivity); + + client.emitNotification({ + method: "item/completed", + params: { + threadId: "codex-thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "mcp-1", + server: "github", + tool: "search", + status: "completed", + arguments: {}, + result: null, + error: null, + durationMs: 42, + }, + }, + }); + await waitFor(() => + statusMessageText(transport).includes("mcp: github.search") + ); + statusText = statusMessageText(transport); + expect(statusText).toContain("- `completed` mcp: github.search"); + expect(transport.messages).toHaveLength(messageCountBeforeActivity); + await bridge.stop(); + }); + + test("mirrors external turns on managed threads into Discord", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-watch-start", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + title: "Watch external work", + prompt: "", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => bridge.stateForTest().sessions.length === 1); + expect(client.startTurnCalls).toHaveLength(0); + + client.emitNotification({ + method: "turn/started", + params: { + threadId: "codex-thread-1", + turn: { + id: "external-turn-1", + status: "inProgress", + items: [], + startedAt: 1778457600, + }, + }, + }); + await waitFor(() => + statusMessageText(transport).includes("origin `external`") + ); + expect(bridge.stateForTest().activeTurns[0]).toEqual( + expect.objectContaining({ + turnId: "external-turn-1", + origin: "external", + }), + ); + expect(transport.typingCount).toBeGreaterThan(0); + + client.emitNotification({ + method: "item/reasoning/summaryPartAdded", + params: { + threadId: "codex-thread-1", + turnId: "external-turn-1", + itemId: "reasoning-1", + summaryIndex: 0, + }, + }); + client.emitNotification({ + method: "item/reasoning/summaryTextDelta", + params: { + threadId: "codex-thread-1", + turnId: "external-turn-1", + itemId: "reasoning-1", + summaryIndex: 0, + delta: "External source is working.", + }, + }); + client.emitNotification({ + method: "item/reasoning/summaryPartAdded", + params: { + threadId: "codex-thread-1", + turnId: "external-turn-1", + itemId: "reasoning-1", + summaryIndex: 1, + }, + }); + await waitFor(() => + transport.messages.some((message) => + message.text === "External source is working." + ) + ); + + client.emitNotification({ + method: "item/completed", + params: { + threadId: "codex-thread-1", + turnId: "external-turn-1", + item: { + id: "message-final", + type: "agentMessage", + text: "External final answer.", + phase: "final_answer", + memoryCitation: null, + }, + }, + }); + client.emitNotification({ + method: "turn/completed", + params: { + threadId: "codex-thread-1", + turn: { + id: "external-turn-1", + status: "completed", + items: [], + }, + }, + }); + + await waitFor(() => + transport.messages.some((message) => message.text === "External final answer.") + ); + await waitFor(() => transport.deletedMessages.length === 1); + expect(transport.deletedMessages[0]?.text).toBe("External source is working."); + expect(bridge.stateForTest().activeTurns).toEqual([]); + expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([ + "summary", + "final", + ]); + await bridge.stop(); + }); + + test("steers Discord messages into externally started active turns", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "threadStart", + sourceMessageId: "message-cross-source-start", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + title: "Cross source steering", + prompt: "", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => bridge.stateForTest().sessions.length === 1); + client.emitNotification({ + method: "turn/started", + params: { + threadId: "codex-thread-1", + turn: { + id: "external-turn-1", + status: "inProgress", + items: [], + }, + }, + }); + await waitFor(() => bridge.stateForTest().activeTurns.length === 1); + + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-steer-external", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "Please include the Discord context.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => client.steerTurnCalls.length === 1); + expect(client.startTurnCalls).toHaveLength(0); + expect(client.steerTurnCalls[0]).toEqual( + expect.objectContaining({ + threadId: "codex-thread-1", + expectedTurnId: "external-turn-1", + }), + ); + expect(inputText(client.steerTurnCalls[0]?.input[0])).toContain( + "Please include the Discord context.", + ); + expect(bridge.stateForTest().processedMessageIds).toContain( + "message-steer-external", + ); + + client.emitNotification({ + method: "turn/completed", + params: { + threadId: "codex-thread-1", + turn: { + id: "external-turn-1", + status: "completed", + items: [ + { + id: "message-final", + type: "agentMessage", + text: "External turn completed.", + phase: "final_answer", + memoryCitation: null, + }, + ], + }, + }, + }); + await waitFor(() => bridge.stateForTest().activeTurns.length === 0); + + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-new-turn", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "Start a new Discord turn now.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => client.startTurnCalls.length === 1); + expect(client.steerTurnCalls).toHaveLength(1); + await bridge.stop(); + }); + + test("recovers persisted external active turns and edits the status message", async () => { + const client = new FakeCodexClient(); + client.threadTurns.set("codex-thread-existing", [ + { + id: "external-turn-recovered", + status: "inProgress", + items: [], + } as unknown as v2.Turn, + ]); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-1", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-existing", + title: "Existing thread", + createdAt: "2026-05-11T00:00:00.000Z", + statusMessageId: "message-status-1", + }, + ], + activeTurns: [ + { + turnId: "external-turn-recovered", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-existing", + origin: "external", + observedAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig({ reconcileIntervalMs: 10 }), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + await waitFor(() => + transport.updatedMessages.some((message) => + message.messageId === "message-status-1" && + message.text.includes("origin `external`") + ) + ); + expect(transport.pinnedMessages).toContainEqual({ + channelId: "discord-thread-1", + messageId: "message-status-1", + }); + expect(transport.typingCount).toBeGreaterThan(0); + + client.threadTurns.set("codex-thread-existing", [ + { + id: "external-turn-recovered", + status: "completed", + items: [ + { + id: "message-final", + type: "agentMessage", + text: "Recovered external final.", + phase: "final_answer", + memoryCitation: null, + }, + ], + } as unknown as v2.Turn, + ]); + + await waitFor(() => + transport.messages.some((message) => message.text === "Recovered external final.") + ); + expect(bridge.stateForTest().activeTurns).toEqual([]); + await bridge.stop(); + }); + + test("clear deletes inactive managed threads and preserves running threads", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const replies: string[] = []; + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-idle", + parentChannelId: "parent-channel", + sourceMessageId: "message-idle-start", + codexThreadId: "codex-thread-idle", + title: "Idle", + createdAt: "2026-05-11T00:00:00.000Z", + }, + { + discordThreadId: "discord-thread-active", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-active", + title: "Active", + createdAt: "2026-05-11T00:00:00.000Z", + }, + { + discordThreadId: "discord-thread-pending", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-pending", + title: "Pending", + createdAt: "2026-05-11T00:00:00.000Z", + }, + { + discordThreadId: "discord-thread-failed", + parentChannelId: "parent-channel", + sourceMessageId: "message-failed-start", + codexThreadId: "codex-thread-failed", + title: "Failed", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + activeTurns: [ + { + turnId: "turn-active", + discordThreadId: "discord-thread-active", + codexThreadId: "codex-thread-active", + origin: "external", + observedAt: "2026-05-11T00:00:00.000Z", + }, + ], + queue: [ + { + id: "queue-pending", + status: "pending", + discordMessageId: "message-pending", + discordThreadId: "discord-thread-pending", + codexThreadId: "codex-thread-pending", + authorId: "user-1", + authorName: "Ada", + content: "Pending work.", + createdAt: "2026-05-11T00:00:00.000Z", + receivedAt: "2026-05-11T00:00:00.000Z", + attempts: 0, + }, + { + id: "queue-failed", + status: "failed", + discordMessageId: "message-failed", + discordThreadId: "discord-thread-failed", + codexThreadId: "codex-thread-failed", + authorId: "user-1", + authorName: "Ada", + content: "Failed work.", + createdAt: "2026-05-11T00:00:00.000Z", + receivedAt: "2026-05-11T00:00:00.000Z", + attempts: 3, + }, + ], + deliveries: [ + { + discordMessageId: "message-idle", + discordThreadId: "discord-thread-idle", + codexThreadId: "codex-thread-idle", + kind: "final", + outboundMessageIds: ["message-out-idle"], + deliveredAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + transport.messages.push( + { + channelId: "parent-channel", + id: "message-idle-start", + text: "<@bot> scan idle", + }, + { + channelId: "parent-channel", + id: "message-failed-start", + text: "<@bot> scan failed", + }, + ); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "clear", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + createdAt: "2026-05-11T00:00:00.000Z", + reply: async (text) => { + replies.push(text); + }, + }); + await waitFor(() => replies.length === 1); + + expect(transport.deletedThreads).toEqual([ + "discord-thread-idle", + "discord-thread-failed", + ]); + expect( + transport.deletedMessages.map(({ channelId, messageId }) => ({ + channelId, + messageId, + })), + ).toEqual([ + { channelId: "parent-channel", messageId: "message-idle-start" }, + { channelId: "parent-channel", messageId: "message-failed-start" }, + ]); + expect(replies[0]).toBe( + "Deleted 2 inactive Discord threads. Left 2 running threads alone.", + ); + expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId)) + .toEqual(["discord-thread-active", "discord-thread-pending"]); + expect(bridge.stateForTest().queue.map((item) => item.discordThreadId)) + .toEqual(["discord-thread-pending"]); + expect(bridge.stateForTest().deliveries).toEqual([]); + await bridge.stop(); + }); + + test("clear only deletes inactive managed threads in the command guild", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const replies: string[] = []; + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-guild-a-idle", + parentChannelId: "parent-channel-a", + guildId: "guild-a", + codexThreadId: "codex-thread-guild-a-idle", + title: "Guild A idle", + createdAt: "2026-05-11T00:00:00.000Z", + }, + { + discordThreadId: "discord-thread-guild-a-active", + parentChannelId: "parent-channel-a", + guildId: "guild-a", + codexThreadId: "codex-thread-guild-a-active", + title: "Guild A active", + createdAt: "2026-05-11T00:00:00.000Z", + }, + { + discordThreadId: "discord-thread-guild-b-idle", + parentChannelId: "parent-channel-b", + guildId: "guild-b", + codexThreadId: "codex-thread-guild-b-idle", + title: "Guild B idle", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + activeTurns: [ + { + turnId: "turn-guild-a-active", + discordThreadId: "discord-thread-guild-a-active", + codexThreadId: "codex-thread-guild-a-active", + origin: "external", + observedAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "clear", + channelId: "parent-channel-a", + guildId: "guild-a", + author: { id: "user-1", name: "Ada", isBot: false }, + createdAt: "2026-05-11T00:00:00.000Z", + reply: async (text) => { + replies.push(text); + }, + }); + await waitFor(() => replies.length === 1); + + expect(transport.deletedThreads).toEqual(["discord-thread-guild-a-idle"]); + expect(replies[0]).toBe( + "Deleted 1 inactive Discord thread. Left 1 running thread alone.", + ); + expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId)) + .toEqual(["discord-thread-guild-a-active", "discord-thread-guild-b-idle"]); + await bridge.stop(); + }); + + test("clear from a bot DM by a global user deletes inactive threads across guilds", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const replies: string[] = []; + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-guild-a-idle", + parentChannelId: "parent-channel-a", + guildId: "guild-a", + codexThreadId: "codex-thread-guild-a-idle", + title: "Guild A idle", + createdAt: "2026-05-11T00:00:00.000Z", + }, + { + discordThreadId: "discord-thread-guild-b-idle", + parentChannelId: "parent-channel-b", + guildId: "guild-b", + codexThreadId: "codex-thread-guild-b-idle", + title: "Guild B idle", + createdAt: "2026-05-11T00:00:00.000Z", + }, + { + discordThreadId: "discord-thread-guild-b-active", + parentChannelId: "parent-channel-b", + guildId: "guild-b", + codexThreadId: "codex-thread-guild-b-active", + title: "Guild B active", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + activeTurns: [ + { + turnId: "turn-guild-b-active", + discordThreadId: "discord-thread-guild-b-active", + codexThreadId: "codex-thread-guild-b-active", + origin: "external", + observedAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "clear", + channelId: "bot-dm-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + createdAt: "2026-05-11T00:00:00.000Z", + reply: async (text) => { + replies.push(text); + }, + }); + await waitFor(() => replies.length === 1); + + expect(transport.deletedThreads).toEqual([ + "discord-thread-guild-a-idle", + "discord-thread-guild-b-idle", + ]); + expect(replies[0]).toBe( + "Deleted 2 inactive Discord threads. Left 1 running thread alone.", + ); + expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId)) + .toEqual(["discord-thread-guild-b-active"]); + await bridge.stop(); + }); + + test("clear is restricted to global allowed users", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const replies: string[] = []; + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-idle", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-idle", + title: "Idle", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "clear", + channelId: "parent-channel", + author: { id: "user-2", name: "Grace", isBot: false }, + createdAt: "2026-05-11T00:00:00.000Z", + reply: async (text) => { + replies.push(text); + }, + }); + await waitFor(() => replies.length === 1); + + expect(transport.deletedThreads).toEqual([]); + expect(replies[0]).toBe( + "Only globally allowed Discord users can clear bridge threads.", + ); + expect(bridge.stateForTest().sessions).toHaveLength(1); + await bridge.stop(); + }); + + test("continues existing managed Discord threads and dedupes messages", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-1", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-existing", + title: "Existing thread", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-1", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "Continue here.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + expect(client.startThreadCalls).toHaveLength(0); + expect(client.startTurnCalls[0]?.threadId).toBe("codex-thread-existing"); + expect(client.startTurnCalls[0]?.input[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining("Message: message-1"), + }), + ); + + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-1", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "Continue here.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(client.startTurnCalls).toHaveLength(1); + await bridge.stop(); + }); + + test("dedupes replayed mention starts before creating another thread", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + const mentionStart: DiscordInbound = { + kind: "threadStart", + sourceMessageId: "mention-replay-1", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + prompt: "Please inspect this once.", + createdAt: "2026-05-11T00:00:00.000Z", + }; + + await bridge.start(); + transport.emit(mentionStart); + transport.emit(mentionStart); + + await waitFor(() => client.startTurnCalls.length === 1); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(transport.createdThreads).toHaveLength(1); + expect(client.startThreadCalls).toHaveLength(1); + expect(client.startTurnCalls).toHaveLength(1); + await bridge.stop(); + }); + + test("steers an active turn in one Discord thread without blocking another thread", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-1", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-1", + title: "Thread one", + createdAt: "2026-05-11T00:00:00.000Z", + }, + { + discordThreadId: "discord-thread-2", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-2", + title: "Thread two", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-a1", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "First same-thread message.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => client.startTurnCalls.length === 1); + await waitFor(() => + bridge.stateForTest().queue.some((item) => + item.discordMessageId === "message-a1" && + item.status === "processing" && + item.turnId === "turn-1" + ) + ); + + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-a2", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "Second same-thread message.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(client.startTurnCalls).toHaveLength(1); + expect(client.steerTurnCalls).toHaveLength(1); + expect(client.steerTurnCalls[0]).toEqual( + expect.objectContaining({ + threadId: "codex-thread-1", + expectedTurnId: "turn-1", + }), + ); + expect(client.steerTurnCalls[0]?.input[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining("Second same-thread message."), + }), + ); + expect(bridge.stateForTest().processedMessageIds).toContain("message-a2"); + + transport.emit({ + kind: "message", + channelId: "discord-thread-2", + messageId: "message-b1", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "Other thread message.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => client.startTurnCalls.length === 2); + expect(client.startTurnCalls.map((call) => call.threadId)).toEqual([ + "codex-thread-1", + "codex-thread-2", + ]); + + await waitFor(() => + bridge.stateForTest().queue.filter((item) => item.status === "processing") + .length === 2 + ); + expect( + bridge.stateForTest().queue.filter((item) => item.status === "pending") + .map((item) => item.discordMessageId), + ).toEqual([]); + await bridge.stop(); + }); + + test("reconciles a completed persisted turn on startup", async () => { + const client = new FakeCodexClient(); + client.threadTurns.set("codex-thread-existing", [ + { + id: "turn-recovered", + status: "completed", + items: [ + { + type: "agentMessage", + id: "message-final", + text: "Recovered final answer.", + phase: "final_answer", + memoryCitation: null, + }, + ], + } as unknown as v2.Turn, + ]); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-1", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-existing", + title: "Existing thread", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + queue: [ + { + id: "queue-1", + status: "processing", + discordMessageId: "message-1", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-existing", + authorId: "user-1", + authorName: "Ada", + content: "Recover this.", + createdAt: "2026-05-11T00:00:00.000Z", + receivedAt: "2026-05-11T00:00:00.000Z", + attempts: 0, + turnId: "turn-recovered", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + await waitFor(() => + transport.messages.some((message) => message.text === "Recovered final answer.") + ); + expect(bridge.stateForTest().queue).toEqual([]); + expect(bridge.stateForTest().processedMessageIds).toContain("message-1"); + expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([ + "final", + ]); + await bridge.stop(); + }); + + test("reconciles an active turn when the completion notification is missed", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const store = new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "discord-thread-1", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-existing", + title: "Existing thread", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store, + config: testConfig({ reconcileIntervalMs: 10 }), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "message", + channelId: "discord-thread-1", + messageId: "message-1", + author: { id: "user-1", name: "Ada", isBot: false }, + content: "Complete without a notification.", + createdAt: "2026-05-11T00:00:00.000Z", + }); + await waitFor(() => client.startTurnCalls.length === 1); + client.threadTurns.set("codex-thread-existing", [ + { + id: "turn-1", + status: "completed", + items: [ + { + type: "agentMessage", + id: "message-final", + text: "Recovered by polling.", + phase: "final_answer", + memoryCitation: null, + }, + ], + } as unknown as v2.Turn, + ]); + + await waitFor(() => + transport.messages.some((message) => message.text === "Recovered by polling.") + ); + expect(bridge.stateForTest().queue).toEqual([]); + expect(bridge.stateForTest().processedMessageIds).toContain("message-1"); + await bridge.stop(); + }); +}); + +function testConfig( + overrides: Partial = {}, +): DiscordBridgeConfig { + return { + allowedUserIds: new Set(["user-1"]), + allowedChannelIds: new Set(["parent-channel"]), + statePath: "/tmp/codex-discord-bridge-test/state.json", + cwd: "/workspace", + summary: "auto", + progressMode: "summary", + typingIntervalMs: 10, + ...overrides, + }; +} + +class FakeCodexClient implements CodexBridgeClient { + startThreadCalls: v2.ThreadStartParams[] = []; + resumeThreadCalls: v2.ThreadResumeParams[] = []; + setThreadNameCalls: v2.ThreadSetNameParams[] = []; + startTurnCalls: v2.TurnStartParams[] = []; + steerTurnCalls: v2.TurnSteerParams[] = []; + readThreadCalls: v2.ThreadReadParams[] = []; + getThreadGoalCalls: v2.ThreadGoalGetParams[] = []; + threadTurns = new Map(); + threadCwds = new Map(); + threadGoals = new Map(); + blockStartTurn = false; + #startTurnResolvers: Array<() => void> = []; + #notificationListeners: Array<(message: JsonRpcNotification) => void> = []; + #requestListeners: Array<(message: JsonRpcRequest) => void> = []; + + async connect(): Promise {} + + close(): void {} + + on( + event: "notification", + listener: (message: JsonRpcNotification) => void, + ): unknown; + on( + event: "request", + listener: (message: JsonRpcRequest) => void, + ): unknown; + on( + event: "notification" | "request", + listener: + | ((message: JsonRpcNotification) => void) + | ((message: JsonRpcRequest) => void), + ): unknown { + if (event === "notification") { + this.#notificationListeners.push( + listener as (message: JsonRpcNotification) => void, + ); + return; + } + this.#requestListeners.push(listener as (message: JsonRpcRequest) => void); + } + + async startThread(params: v2.ThreadStartParams): Promise { + this.startThreadCalls.push(params); + return { + thread: { id: `codex-thread-${this.startThreadCalls.length}` }, + } as v2.ThreadStartResponse; + } + + async resumeThread(params: v2.ThreadResumeParams): Promise { + this.resumeThreadCalls.push(params); + const cwd = params.cwd ?? this.threadCwds.get(params.threadId) ?? "/workspace"; + return { + cwd, + thread: { + id: params.threadId, + cwd, + turns: this.threadTurns.get(params.threadId) ?? [], + }, + } as unknown as v2.ThreadResumeResponse; + } + + async setThreadName( + params: v2.ThreadSetNameParams, + ): Promise { + this.setThreadNameCalls.push(params); + return {}; + } + + async startTurn(params: v2.TurnStartParams): Promise { + this.startTurnCalls.push(params); + const turnNumber = this.startTurnCalls.length; + if (this.blockStartTurn) { + await new Promise((resolve) => { + this.#startTurnResolvers.push(resolve); + }); + } + return { + turn: { id: `turn-${turnNumber}` }, + } as v2.TurnStartResponse; + } + + async steerTurn(params: v2.TurnSteerParams): Promise { + this.steerTurnCalls.push(params); + return { turnId: params.expectedTurnId }; + } + + async readThread(params: v2.ThreadReadParams): Promise { + this.readThreadCalls.push(params); + return { + thread: { turns: this.threadTurns.get(params.threadId) ?? [] }, + } as unknown as v2.ThreadReadResponse; + } + + async getThreadGoal( + params: v2.ThreadGoalGetParams, + ): Promise { + this.getThreadGoalCalls.push(params); + return { + goal: this.threadGoals.get(params.threadId) ?? null, + }; + } + + respondError(): void {} + + resolveAllStartTurns(): void { + for (const resolve of this.#startTurnResolvers.splice(0)) { + resolve(); + } + } + + emitNotification(message: JsonRpcNotification): void { + for (const listener of this.#notificationListeners) { + listener(message); + } + } +} + +class FakeDiscordTransport implements DiscordBridgeTransport { + handlers: DiscordBridgeTransportHandlers | undefined; + createdThreads: Array<{ + channelId: string; + name: string; + sourceMessageId?: string; + }> = []; + messages: Array<{ channelId: string; id: string; text: string }> = []; + updatedMessages: Array<{ + channelId: string; + messageId: string; + text: string; + }> = []; + deletedMessages: Array<{ + channelId: string; + messageId: string; + text: string; + }> = []; + deletedThreads: string[] = []; + addedThreadMembers: Array<{ channelId: string; userIds: string[] }> = []; + pinnedMessages: Array<{ channelId: string; messageId: string }> = []; + typingCount = 0; + + async start(handlers: DiscordBridgeTransportHandlers): Promise { + this.handlers = handlers; + } + + async stop(): Promise {} + + async registerCommands(): Promise {} + + async createThread( + channelId: string, + name: string, + sourceMessageId?: string, + ): Promise { + this.createdThreads.push({ channelId, name, sourceMessageId }); + return `discord-thread-${this.createdThreads.length}`; + } + + async sendMessage(channelId: string, text: string): Promise { + const id = `message-out-${this.messages.length + 1}`; + this.messages.push({ channelId, id, text }); + return [id]; + } + + async updateMessage( + channelId: string, + messageId: string, + text: string, + ): Promise { + this.updatedMessages.push({ channelId, messageId, text }); + const message = this.messages.find((candidate) => candidate.id === messageId); + if (message) { + message.text = text; + } + } + + async deleteMessage(channelId: string, messageId: string): Promise { + const message = this.messages.find((candidate) => candidate.id === messageId); + if (message) { + this.deletedMessages.push({ channelId, messageId, text: message.text }); + this.messages = this.messages.filter( + (candidate) => candidate.id !== messageId, + ); + } + } + + async deleteThread(channelId: string): Promise { + this.deletedThreads.push(channelId); + } + + async addThreadMembers(channelId: string, userIds: string[]): Promise { + this.addedThreadMembers.push({ channelId, userIds }); + } + + async pinMessage(channelId: string, messageId: string): Promise { + this.pinnedMessages.push({ channelId, messageId }); + } + + async sendTyping(): Promise { + this.typingCount += 1; + } + + emit(inbound: DiscordInbound): void { + this.handlers?.onInbound(inbound); + } +} + +class FakeConsoleOutput implements DiscordConsoleOutput { + messages: DiscordConsoleMessage[] = []; + + message(message: DiscordConsoleMessage): void { + this.messages.push(message); + } +} + +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs = 1000, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for predicate"); +} + +async function sleep(delayMs: number): Promise { + await new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function inputText(value: unknown): string { + if (typeof value !== "object" || value === null || !("text" in value)) { + return ""; + } + const text = (value as { text?: unknown }).text; + return typeof text === "string" ? text : ""; +} + +function statusMessageText(transport: FakeDiscordTransport): string { + return transport.messages.find((message) => message.id === "message-out-1") + ?.text ?? ""; +} diff --git a/apps/discord-bridge/test/config.test.ts b/apps/discord-bridge/test/config.test.ts new file mode 100644 index 0000000..eebc606 --- /dev/null +++ b/apps/discord-bridge/test/config.test.ts @@ -0,0 +1,223 @@ +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { parseConfig } from "../src/config.ts"; + +describe("parseConfig", () => { + test("resolves --dir relative to the home directory", () => { + const parsed = parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--dir", + "projects/demo", + ], + {}, + ); + + expect(parsed.type).toBe("run"); + if (parsed.type === "run") { + expect(parsed.config.cwd).toBe(path.join(os.homedir(), "projects/demo")); + } + }); + + test("expands tilde dir paths from the home directory", () => { + const parsed = parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--dir", + "~/projects/demo", + ], + {}, + ); + + expect(parsed.type).toBe("run"); + if (parsed.type === "run") { + expect(parsed.config.cwd).toBe(path.join(os.homedir(), "projects/demo")); + } + }); + + test("accepts one positional directory for root script usage", () => { + const parsed = parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--local-app-server", + "~/game-protocol-workspace", + ], + { CODEX_DISCORD_DIR: "env-dir" }, + ); + + expect(parsed.type).toBe("run"); + if (parsed.type === "run") { + expect(parsed.localAppServer).toBe(true); + expect(parsed.config.cwd).toBe( + path.join(os.homedir(), "game-protocol-workspace"), + ); + } + }); + + test("rejects multiple directory arguments", () => { + expect(() => + parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "one", + "two", + ], + {}, + ) + ).toThrow("Unexpected argument: two"); + expect(() => + parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--dir", + "one", + "two", + ], + {}, + ) + ).toThrow("Cannot set both positional directory and --dir/--cwd."); + }); + + test("prefers CODEX_DISCORD_DIR over legacy cwd env", () => { + const parsed = parseConfig( + ["--token", "discord-token", "--allowed-user-ids", "user-1"], + { + CODEX_DISCORD_DIR: "current", + CODEX_DISCORD_CWD: "/legacy", + }, + ); + + expect(parsed.type).toBe("run"); + if (parsed.type === "run") { + expect(parsed.config.cwd).toBe(path.join(os.homedir(), "current")); + } + }); + + test("enables debug logging from flag or environment", () => { + const fromFlag = parseConfig( + ["--token", "discord-token", "--allowed-user-ids", "user-1", "--debug"], + {}, + ); + const fromEnv = parseConfig( + ["--token", "discord-token", "--allowed-user-ids", "user-1"], + { CODEX_DISCORD_DEBUG: "true" }, + ); + + expect(fromFlag.type).toBe("run"); + expect(fromEnv.type).toBe("run"); + if (fromFlag.type === "run" && fromEnv.type === "run") { + expect(fromFlag.config.debug).toBe(true); + expect(fromEnv.config.debug).toBe(true); + } + }); + + test("parses progress mode from flag or environment", () => { + const fromFlag = parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--progress-mode", + "commentary", + ], + {}, + ); + const fromEnv = parseConfig( + ["--token", "discord-token", "--allowed-user-ids", "user-1"], + { CODEX_DISCORD_PROGRESS_MODE: "none" }, + ); + + expect(fromFlag.type).toBe("run"); + expect(fromEnv.type).toBe("run"); + if (fromFlag.type === "run" && fromEnv.type === "run") { + expect(fromFlag.config.progressMode).toBe("commentary"); + expect(fromEnv.config.progressMode).toBe("none"); + } + }); + + test("parses console output and log level from flag or environment", () => { + const fromFlag = parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--console-output", + "messages", + "--log-level", + "warn", + ], + {}, + ); + const fromEnv = parseConfig( + ["--token", "discord-token", "--allowed-user-ids", "user-1"], + { + CODEX_DISCORD_CONSOLE_OUTPUT: "none", + CODEX_DISCORD_LOG_LEVEL: "silent", + }, + ); + + expect(fromFlag.type).toBe("run"); + expect(fromEnv.type).toBe("run"); + if (fromFlag.type === "run" && fromEnv.type === "run") { + expect(fromFlag.config.consoleOutput).toBe("messages"); + expect(fromFlag.config.logLevel).toBe("warn"); + expect(fromEnv.config.consoleOutput).toBe("none"); + expect(fromEnv.config.logLevel).toBe("silent"); + } + }); + + test("can force a local app-server even when workspace URL env is set", () => { + const parsed = parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--local-app-server", + ], + { CODEX_WORKSPACE_APP_SERVER_WS_URL: "ws://127.0.0.1:9999" }, + ); + + expect(parsed.type).toBe("run"); + if (parsed.type === "run") { + expect(parsed.localAppServer).toBe(true); + expect(parsed.appServerUrl).toBeUndefined(); + } + }); + + test("rejects mixing local and explicit external app-server modes", () => { + expect(() => + parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--local-app-server", + "--app-server-url", + "ws://127.0.0.1:9999", + ], + {}, + ) + ).toThrow("Cannot set both --local-app-server and --app-server-url."); + }); +}); diff --git a/apps/discord-bridge/test/console-output.test.ts b/apps/discord-bridge/test/console-output.test.ts new file mode 100644 index 0000000..4d1ff1e --- /dev/null +++ b/apps/discord-bridge/test/console-output.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; + +import { + createDiscordConsoleOutput, + formatConsoleMessage, +} from "../src/console-output.ts"; + +describe("discord bridge console output", () => { + test("formats delivered assistant messages for terminal output", () => { + expect( + formatConsoleMessage( + { + kind: "final", + text: "Repo scan complete.\nNo regressions found.", + discordThreadId: "discord-thread-123456", + codexThreadId: "codex-thread-abcdef", + turnId: "turn-1234567890", + title: "Scan repo", + at: new Date("2026-05-12T04:22:00.123Z"), + }, + { color: false }, + ), + ).toBe( + [ + "[04:22:00.123] FINAL Scan repo thread=codex-...cdef turn=turn-1...7890", + " Repo scan complete.", + " No regressions found.", + ].join("\n"), + ); + }); + + test("writes one formatted block per message", () => { + const output = createMemoryOutput(); + const consoleOutput = createDiscordConsoleOutput({ + color: false, + now: () => new Date("2026-05-12T04:22:01.456Z"), + stream: output.stream, + }); + + consoleOutput.message({ + kind: "commentary", + text: "I will inspect the bridge.", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-1", + turnId: "turn-1", + title: "Bridge status", + }); + + expect(output.text).toBe( + [ + "[04:22:01.456] COMMENTARY Bridge status thread=codex-...ad-1 turn=turn-1", + " I will inspect the bridge.", + "", + ].join("\n"), + ); + }); +}); + +function createMemoryOutput(): { + readonly stream: Pick; + readonly text: string; +} { + const chunks: string[] = []; + return { + stream: { + write: ((chunk: string | Uint8Array) => { + chunks.push( + typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString("utf8"), + ); + return true; + }) as NodeJS.WriteStream["write"], + }, + get text() { + return chunks.join(""); + }, + }; +} diff --git a/apps/discord-bridge/test/logger.test.ts b/apps/discord-bridge/test/logger.test.ts new file mode 100644 index 0000000..c2ca632 --- /dev/null +++ b/apps/discord-bridge/test/logger.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +import { createDiscordBridgeLogger } from "../src/logger.ts"; +import { formatPrettyLogLine } from "../src/pretty-log.ts"; + +describe("discord bridge logger", () => { + test("writes info logs as structured json and gates debug logs", () => { + const output = createMemoryOutput(); + const logger = createDiscordBridgeLogger({ + component: "test-bridge", + now: () => new Date("2026-05-12T04:22:00.123Z"), + stream: output.stream, + }); + + logger.debug("hidden.debug", { threadId: "thread-1" }); + logger.info("bridge.started", { + appServerUrl: "local", + statePath: "/tmp/discord-state.json", + }); + + const lines = output.text.trim().split("\n"); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? "")).toEqual({ + time: "2026-05-12T04:22:00.123Z", + component: "test-bridge", + level: "info", + event: "bridge.started", + appServerUrl: "local", + statePath: "/tmp/discord-state.json", + }); + }); + + test("filters logs below the configured log level", () => { + const output = createMemoryOutput(); + const logger = createDiscordBridgeLogger({ + component: "test-bridge", + logLevel: "warn", + now: () => new Date("2026-05-12T04:22:00.123Z"), + stream: output.stream, + }); + + logger.debug("hidden.debug"); + logger.info("hidden.info"); + logger.warn("visible.warn"); + logger.error("visible.error"); + + expect(output.text.trim().split("\n").map((line) => JSON.parse(line).event)) + .toEqual(["visible.warn", "visible.error"]); + }); + + test("pretty prints structured json logs and plain process output", () => { + const structured = formatPrettyLogLine( + JSON.stringify({ + time: "2026-05-12T04:22:00.123Z", + component: "codex-discord-bridge", + level: "info", + event: "bridge.started", + appServerUrl: "local", + localAppServer: true, + }), + { color: false }, + ); + const plain = formatPrettyLogLine("listening on ws://127.0.0.1:3585", { + color: false, + name: "codex-remote-control", + now: () => new Date("2026-05-12T04:22:01.456Z"), + }); + + expect(structured).toBe( + "[04:22:00.123] INFO codex-discord-bridge bridge.started appServerUrl=local localAppServer=true", + ); + expect(plain).toBe( + "[04:22:01.456] INFO codex-remote-control listening on ws://127.0.0.1:3585", + ); + }); +}); + +function createMemoryOutput(): { + readonly stream: Pick; + readonly text: string; +} { + const chunks: string[] = []; + return { + stream: { + write: ((chunk: string | Uint8Array) => { + chunks.push( + typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString("utf8"), + ); + return true; + }) as NodeJS.WriteStream["write"], + }, + get text() { + return chunks.join(""); + }, + }; +} diff --git a/apps/discord-bridge/test/state.test.ts b/apps/discord-bridge/test/state.test.ts new file mode 100644 index 0000000..f490d68 --- /dev/null +++ b/apps/discord-bridge/test/state.test.ts @@ -0,0 +1,103 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { JsonFileStateStore } from "../src/state.ts"; + +describe("JsonFileStateStore", () => { + test("loads per-thread grant metadata and older sessions without grants", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "discord-bridge-state-")); + try { + const statePath = path.join(dir, "state.json"); + await writeFile( + statePath, + `${JSON.stringify({ + version: 1, + sessions: [ + { + discordThreadId: "discord-thread-1", + parentChannelId: "parent-channel", + sourceMessageId: "message-start-1", + codexThreadId: "codex-thread-1", + title: "Granted thread", + createdAt: "2026-05-11T00:00:00.000Z", + ownerUserId: "user-1", + participantUserIds: ["user-2", "", "user-2", "user-3"], + cwd: "/workspace/project", + mode: "resumed", + statusMessageId: "message-status-1", + }, + { + discordThreadId: "discord-thread-2", + parentChannelId: "parent-channel", + codexThreadId: "codex-thread-2", + title: "Older thread", + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + queue: [], + activeTurns: [ + { + turnId: "turn-active-1", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-1", + origin: "external", + startedAt: "2026-05-11T00:00:01.000Z", + observedAt: "2026-05-11T00:00:02.000Z", + }, + { + turnId: "turn-active-2", + discordThreadId: "discord-thread-2", + codexThreadId: "codex-thread-2", + origin: "unknown", + queueItemId: "queue-1", + observedAt: "2026-05-11T00:00:03.000Z", + }, + ], + processedMessageIds: [], + deliveries: [], + })}\n`, + ); + + const state = await new JsonFileStateStore(statePath).load(); + + expect(state.sessions).toHaveLength(2); + expect(state.sessions[0]?.ownerUserId).toBe("user-1"); + expect(state.sessions[0]?.sourceMessageId).toBe("message-start-1"); + expect(state.sessions[0]?.participantUserIds).toEqual([ + "user-2", + "user-3", + ]); + expect(state.sessions[0]?.cwd).toBe("/workspace/project"); + expect(state.sessions[0]?.mode).toBe("resumed"); + expect(state.sessions[0]?.statusMessageId).toBe("message-status-1"); + expect(state.sessions[1]?.ownerUserId).toBeUndefined(); + expect(state.sessions[1]?.sourceMessageId).toBeUndefined(); + expect(state.sessions[1]?.participantUserIds).toBeUndefined(); + expect(state.sessions[1]?.cwd).toBeUndefined(); + expect(state.sessions[1]?.mode).toBeUndefined(); + expect(state.sessions[1]?.statusMessageId).toBeUndefined(); + expect(state.activeTurns).toEqual([ + { + turnId: "turn-active-1", + discordThreadId: "discord-thread-1", + codexThreadId: "codex-thread-1", + origin: "external", + startedAt: "2026-05-11T00:00:01.000Z", + observedAt: "2026-05-11T00:00:02.000Z", + }, + { + turnId: "turn-active-2", + discordThreadId: "discord-thread-2", + codexThreadId: "codex-thread-2", + origin: "external", + queueItemId: "queue-1", + observedAt: "2026-05-11T00:00:03.000Z", + }, + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/discord-bridge/tsconfig.json b/apps/discord-bridge/tsconfig.json new file mode 100644 index 0000000..3279881 --- /dev/null +++ b/apps/discord-bridge/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "types": ["node", "bun"], + "baseUrl": ".", + "paths": { + "@peezy-tech/codex-flows": ["../../packages/codex-client/src/index.ts"], + "@peezy-tech/codex-flows/*": ["../../packages/codex-client/src/*"] + } + }, + "include": ["src", "test"] +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..4575d42 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-lyra", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "../../packages/ui/src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "utils": "@workspace/ui/lib/utils", + "ui": "@workspace/ui/components" + }, + "rtl": false, + "menuColor": "default", + "menuAccent": "subtle" +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..fca81a2 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + + Codex Workspace Service + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..ba8d8ab --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "web", + "version": "0.0.1", + "type": "module", + "private": true, + "license": "Apache-2.0", + "scripts": { + "build": "tsc -b && vite build", + "check:types": "tsc --noEmit", + "dev": "vite --host 127.0.0.1", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@workspace/ui": "workspace:*", + "@peezy-tech/codex-flows": "workspace:*", + "lucide-react": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..7b568a5 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,886 @@ +import { Button } from "@workspace/ui/components/button"; +import { + AlertCircle, + Copy, + Loader2, + Plug, + RefreshCw, + Send, + Square, + TerminalSquare, + Unplug, +} from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type FormEvent, + type ReactNode, +} from "react"; + +import { + CodexAppServerClient, + JsonRpcError, + type JsonRpcNotification, + type JsonRpcRequest, + type v2, +} from "@peezy-tech/codex-flows/browser"; + +import { ThemeProvider } from "./components/theme-provider.tsx"; + +type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; + +type EventLogEntry = { + id: string; + at: string; + kind: "notification" | "request" | "error" | "control"; + title: string; + body?: string; +}; + +const defaultWsUrl = + import.meta.env.VITE_CODEX_APP_SERVER_WS_URL ?? defaultProxiedWsUrl(); + +export function App() { + return ( + + + + ); +} + +function BareCodexApp() { + const clientRef = useRef(null); + const [wsUrl, setWsUrl] = useState(initialWsUrl); + const [connectedUrl, setConnectedUrl] = useState(); + const [status, setStatus] = useState("disconnected"); + const [error, setError] = useState(); + const [threads, setThreads] = useState([]); + const [selectedThreadId, setSelectedThreadId] = useState(); + const [selectedThread, setSelectedThread] = useState(); + const [account, setAccount] = useState(); + const [prompt, setPrompt] = useState(""); + const [cwd, setCwd] = useState(""); + const [eventLog, setEventLog] = useState([]); + const [busyAction, setBusyAction] = useState(); + + const appendEvent = useCallback((entry: Omit) => { + setEventLog((current) => + [ + { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + at: new Date().toISOString(), + ...entry, + }, + ...current, + ].slice(0, 80), + ); + }, []); + + const readThread = useCallback( + async (threadId: string, client = clientRef.current) => { + if (!client) { + return; + } + const response = await client.readThread({ threadId, includeTurns: true }); + setSelectedThread(response.thread); + }, + [], + ); + + const refreshThreads = useCallback( + async (client = clientRef.current) => { + if (!client) { + return; + } + const response = await client.listThreads({ + limit: 60, + sortKey: "updated_at", + sortDirection: "desc", + archived: false, + sourceKinds: [], + useStateDbOnly: false, + }); + setThreads(response.data); + const nextSelected = + selectedThreadId ?? + response.data.find((thread) => thread.status.type !== "notLoaded")?.id ?? + response.data[0]?.id; + if (nextSelected) { + setSelectedThreadId(nextSelected); + await readThread(nextSelected, client); + } + }, + [readThread, selectedThreadId], + ); + + const refreshAccount = useCallback(async (client = clientRef.current) => { + if (!client) { + return; + } + try { + setAccount(await client.getAccount({ refreshToken: false })); + } catch { + setAccount(undefined); + } + }, []); + + const refreshCurrent = useCallback(async () => { + const client = clientRef.current; + if (!client) { + return; + } + setBusyAction("refresh"); + try { + await Promise.all([ + refreshThreads(client), + refreshAccount(client), + selectedThreadId ? readThread(selectedThreadId, client) : undefined, + ]); + } catch (refreshError) { + setError(errorMessage(refreshError)); + } finally { + setBusyAction(undefined); + } + }, [readThread, refreshAccount, refreshThreads, selectedThreadId]); + + const handleNotification = useCallback( + (message: JsonRpcNotification) => { + appendEvent({ + kind: "notification", + title: message.method, + body: previewJson(message.params, 900), + }); + const threadId = notificationThreadId(message); + if (threadId) { + if (!selectedThreadId || selectedThreadId === threadId) { + setSelectedThreadId(threadId); + void readThread(threadId).catch((readError) => + setError(errorMessage(readError)), + ); + } + void refreshThreads().catch((refreshError) => + setError(errorMessage(refreshError)), + ); + } + }, + [appendEvent, readThread, refreshThreads, selectedThreadId], + ); + + const connect = useCallback(async () => { + const url = wsUrl.trim(); + if (!url) { + setError("WebSocket URL is required"); + setStatus("error"); + return; + } + + clientRef.current?.close(); + const client = new CodexAppServerClient({ + webSocketTransportOptions: { url, requestTimeoutMs: 90_000 }, + clientName: "bare-web", + clientTitle: "Codex Bare Web", + clientVersion: "0.1.0", + }); + clientRef.current = client; + client.on("notification", handleNotification); + client.on("request", (message: JsonRpcRequest) => { + appendEvent({ + kind: "request", + title: message.method, + body: previewJson(message.params, 900), + }); + }); + client.on("error", (eventError: unknown) => { + appendEvent({ + kind: "error", + title: "transport error", + body: errorMessage(eventError), + }); + setError(errorMessage(eventError)); + setStatus("error"); + }); + client.on("close", (code: number, reason: string) => { + appendEvent({ + kind: "control", + title: "closed", + body: [code, reason].filter(Boolean).join(" "), + }); + if (clientRef.current === client) { + setConnectedUrl(undefined); + setStatus("disconnected"); + } + }); + + setStatus("connecting"); + setError(undefined); + try { + await client.connect(); + window.localStorage.setItem("codex-bare.ws-url", url); + setConnectedUrl(url); + setStatus("connected"); + appendEvent({ kind: "control", title: "connected", body: url }); + await Promise.all([refreshThreads(client), refreshAccount(client)]); + } catch (connectError) { + if (clientRef.current === client) { + clientRef.current = null; + setConnectedUrl(undefined); + setStatus("error"); + } + client.close(); + setError(errorMessage(connectError)); + } + }, [ + appendEvent, + handleNotification, + refreshAccount, + refreshThreads, + wsUrl, + ]); + + const disconnect = useCallback(() => { + clientRef.current?.close(); + clientRef.current = null; + setConnectedUrl(undefined); + setStatus("disconnected"); + appendEvent({ kind: "control", title: "disconnected" }); + }, [appendEvent]); + + useEffect(() => () => clientRef.current?.close(), []); + + const selectThread = async (threadId: string) => { + setSelectedThreadId(threadId); + setBusyAction("read"); + try { + await readThread(threadId); + } catch (readError) { + setError(errorMessage(readError)); + } finally { + setBusyAction(undefined); + } + }; + + const sendPrompt = async (event: FormEvent) => { + event.preventDefault(); + const client = clientRef.current; + const text = prompt.trim(); + if (!client || !text) { + return; + } + + setBusyAction("send"); + setError(undefined); + try { + let threadId = selectedThreadId; + if (!threadId) { + const started = await client.startThread({ + cwd: optionalText(cwd), + experimentalRawEvents: false, + persistExtendedHistory: false, + }); + threadId = started.thread.id; + setSelectedThreadId(threadId); + setSelectedThread(started.thread); + } + + await client.startTurn({ + threadId, + input: [{ type: "text", text, text_elements: [] }], + cwd: optionalText(cwd), + }); + setPrompt(""); + await Promise.all([refreshThreads(client), readThread(threadId, client)]); + } catch (sendError) { + setError(errorMessage(sendError)); + } finally { + setBusyAction(undefined); + } + }; + + const interruptTurn = async () => { + const client = clientRef.current; + const turn = activeTurn(selectedThread); + if (!client || !selectedThreadId || !turn) { + return; + } + setBusyAction("interrupt"); + try { + await client.interruptTurn({ threadId: selectedThreadId, turnId: turn.id }); + await readThread(selectedThreadId, client); + } catch (interruptError) { + setError(errorMessage(interruptError)); + } finally { + setBusyAction(undefined); + } + }; + + const copyThreadId = async () => { + if (selectedThreadId && navigator.clipboard) { + await navigator.clipboard.writeText(selectedThreadId); + } + }; + + const selectedItems = useMemo( + () => selectedThread?.turns.flatMap((turn) => turn.items) ?? [], + [selectedThread], + ); + const runningTurn = activeTurn(selectedThread); + const connected = status === "connected"; + + return ( +
+
+
+
+
+ +

Codex Bare

+
+

+ {connectedUrl ?? "No app-server connection"} +

+
+
{ + event.preventDefault(); + void connect(); + }} + > + setWsUrl(event.target.value)} + placeholder="ws://127.0.0.1:3585" + value={wsUrl} + /> +
+ + +
+
+
+
+ +
+ + +
+ {error ? ( +
+ + {error} +
+ ) : null} + + + + +
+ } + title={selectedThread?.name || selectedThread?.preview || "Thread"} + > +
+ + + +
+
+ {selectedItems.length > 0 ? ( +
+ {selectedItems.map((item) => ( + + ))} +
+ ) : ( +
+ {selectedThreadId ? "No loaded items" : "New thread"} +
+ )} +
+ + +
+
+