From db63d3d2f7f09656e178f14d443aeecab8b49fe8 Mon Sep 17 00:00:00 2001 From: qingnian108 Date: Fri, 30 Oct 2026 22:48:44 +0800 Subject: [PATCH 1/8] Add magpie-agent-console page plugin to registry A Magpie window page that drives local Agent CLIs (Claude Code, Codex, Cursor, and others) via a persistent Node daemon. Co-authored-by: Cursor --- registry.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/registry.json b/registry.json index 67c2cd2f..4bd7a19b 100644 --- a/registry.json +++ b/registry.json @@ -153,6 +153,16 @@ "en": "Third-party Magpie adapter for dsh-providers-hub (by JetLi): five providers, each a separate channel you can sign in to, meter and switch off on its own — AStudio (iFlytek), Accio (Alibaba), LobsterAI (Youdao), MagiCrew (letsmagic.cn / magicrew.ai). Channels that already exist in the registry are not shipped. Runs without DSH.", "zh": "dsh-providers-hub(作者 JetLi)的第三方适配:五条供应商(每渠道一条,可各自登录/看额度/单独关闭)——AStudio(讯飞)、Accio(阿里)、LobsterAI(有道)、超级麦吉/MagiCrew(letsmagic.cn / magicrew.ai)。与社区 registry 已有插件重叠的渠道不随包提供。不依赖 DSH 运行。" } + }, + { + "package": "magpie-agent-console", + "name": "Agent Console", + "kind": "page", + "icon": "https://raw.githubusercontent.com/qingnian108/magpie-agent-console/main/assets/icon.svg", + "summary": { + "en": "A page in magpie to drive local Agent CLIs (Claude Code, Codex, Cursor, Copilot, OpenCode, Pi, Devin, dsh) with a persistent daemon and a structured timeline.", + "zh": "在 magpie 窗口里驱动本机 Agent CLI(Claude Code、Codex、Cursor、Copilot、OpenCode、Pi、Devin、dsh):常驻后台 + 结构化时间线。" + } } ] } From d03aa9e7d1dffa663e90be996c40d5105eff913e Mon Sep 17 00:00:00 2001 From: qingnian108 Date: Sat, 31 Oct 2026 10:26:42 +0800 Subject: [PATCH 2/8] agent-console 1.0.0: bring the source into packages/agent-console The source of magpie-agent-console 1.0.0 (qingnian108/magpie-agent-console@0d3c19e, tag v1.0.0), moved here as @magpie-community/page-agent-console so every version ships from this repository through a pull request (review of #535). - TypeScript source (daemon/src, web/src), index.mjs, lib/, page/, assets/, build and verify scripts, tsconfigs, vitest config, package-lock.json, LICENSE, README, THIRD-PARTY-NOTICES and the vitest suite (test/v1, including pr535-http, gate, lifeline and the new terminal-fallback). - app/ and daemon/dist/ are committed: publish.yml packs and publishes each package as checked in (npm pack --ignore-scripts, no build step), so a PR's bundles are what users get. `npm ci --workspaces=false && npm run verify:build` in the folder rebuilds them byte for byte. - package.json: magpie.page and permissions: [] (the page makes no magpie.read), magpie.icon as a data URI, repository/bugs/homepage here. - dependencies are what the daemon and index.mjs load (ACP SDK, Claude Agent SDK, ignore, ws, zod); node-pty is optional; the 11 UI packages bundled into app/app.js are devDependencies. Versions and lockfile integrities unchanged. - README: why node-pty is 1.2.0-beta.15 and what happens when it can't load. - agent-console.test.mjs: what the root `bun test` can check with no install (package shape, dependency split, the page reads nothing and reaches no network, the gate's Origin/Host/key rules). - bunfig.toml: the root `bun test` skips the package's vitest files, which need its npm dependencies. - README: the package in the Pages table. --- README.md | 1 + bunfig.toml | 4 + packages/agent-console/.gitattributes | 10 + packages/agent-console/.gitignore | 6 + packages/agent-console/LICENSE | 21 + packages/agent-console/README.md | 185 + packages/agent-console/THIRD-PARTY-NOTICES.md | 49 + packages/agent-console/agent-console.test.mjs | 139 + packages/agent-console/app/app.css | 37 + packages/agent-console/app/app.js | 202 + packages/agent-console/app/index.html | 14 + packages/agent-console/assets/icon.svg | 10 + packages/agent-console/daemon/dist/main.js | 7404 +++++ .../agent-console/daemon/dist/main.js.map | 1 + .../agent-console/daemon/src/agents/diff.ts | 136 + .../agent-console/daemon/src/agents/kinds.ts | 84 + .../daemon/src/agents/manager.ts | 788 + packages/agent-console/daemon/src/config.ts | 65 + .../agent-console/daemon/src/files/service.ts | 127 + .../agent-console/daemon/src/git/service.ts | 137 + .../agent-console/daemon/src/history/codex.ts | 169 + .../daemon/src/history/import.ts | 108 + packages/agent-console/daemon/src/http.ts | 229 + packages/agent-console/daemon/src/log.ts | 53 + packages/agent-console/daemon/src/main.ts | 186 + .../daemon/src/migrate/legacy.ts | 240 + .../daemon/src/providers/acp/cliModels.ts | 66 + .../src/providers/acp/clientHandlers.ts | 50 + .../daemon/src/providers/acp/cursorModels.ts | 72 + .../daemon/src/providers/acp/mapUpdate.ts | 119 + .../daemon/src/providers/acp/probe.ts | 195 + .../daemon/src/providers/acp/provider.ts | 124 + .../daemon/src/providers/acp/session.ts | 487 + .../daemon/src/providers/acp/spawn.ts | 31 + .../daemon/src/providers/acp/specs.ts | 71 + .../daemon/src/providers/catalog.ts | 31 + .../daemon/src/providers/claude/map.ts | 204 + .../daemon/src/providers/claude/models.ts | 8 + .../daemon/src/providers/claude/provider.ts | 108 + .../daemon/src/providers/claude/session.ts | 397 + .../daemon/src/providers/claude/thinking.ts | 20 + .../daemon/src/providers/codex/commands.ts | 118 + .../daemon/src/providers/codex/map.ts | 205 + .../daemon/src/providers/codex/provider.ts | 154 + .../daemon/src/providers/codex/rpc.ts | 186 + .../schema/ApplyPatchApprovalParams.json | 114 + .../schema/ApplyPatchApprovalResponse.json | 146 + .../schema/AttestationGenerateParams.json | 5 + .../schema/AttestationGenerateResponse.json | 14 + .../ChatgptAuthTokensRefreshParams.json | 33 + .../ChatgptAuthTokensRefreshResponse.json | 23 + .../codex/schema/ClientNotification.json | 22 + .../providers/codex/schema/ClientRequest.json | 8854 +++++ ...CommandExecutionRequestApprovalParams.json | 648 + ...mmandExecutionRequestApprovalResponse.json | 116 + .../codex/schema/DynamicToolCallParams.json | 33 + .../codex/schema/DynamicToolCallResponse.json | 86 + .../schema/ExecCommandApprovalParams.json | 165 + .../schema/ExecCommandApprovalResponse.json | 146 + .../FileChangeRequestApprovalParams.json | 41 + .../FileChangeRequestApprovalResponse.json | 47 + .../codex/schema/FuzzyFileSearchParams.json | 26 + .../codex/schema/FuzzyFileSearchResponse.json | 66 + ...ileSearchSessionCompletedNotification.json | 13 + ...yFileSearchSessionUpdatedNotification.json | 74 + .../providers/codex/schema/JSONRPCError.json | 48 + .../codex/schema/JSONRPCErrorError.json | 19 + .../codex/schema/JSONRPCMessage.json | 137 + .../codex/schema/JSONRPCNotification.json | 15 + .../codex/schema/JSONRPCRequest.json | 60 + .../codex/schema/JSONRPCResponse.json | 29 + .../McpServerElicitationRequestParams.json | 651 + .../McpServerElicitationRequestResponse.json | 29 + .../PermissionsRequestApprovalParams.json | 336 + .../PermissionsRequestApprovalResponse.json | 322 + .../src/providers/codex/schema/RequestId.json | 13 + .../codex/schema/ServerNotification.json | 8869 +++++ .../providers/codex/schema/ServerRequest.json | 2096 ++ .../schema/ToolRequestUserInputParams.json | 98 + .../schema/ToolRequestUserInputResponse.json | 34 + .../codex_app_server_protocol.schemas.json | 27256 ++++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 24969 ++++++++++++++ .../codex/schema/v1/InitializeParams.json | 88 + .../codex/schema/v1/InitializeResponse.json | 38 + .../v2/AccountLoginCompletedNotification.json | 43 + .../AccountRateLimitsUpdatedNotification.json | 214 + .../schema/v2/AccountUpdatedNotification.json | 115 + .../v2/AgentMessageDeltaNotification.json | 25 + .../schema/v2/AppListUpdatedNotification.json | 288 + .../codex/schema/v2/AppsInstalledParams.json | 19 + .../schema/v2/AppsInstalledResponse.json | 48 + .../codex/schema/v2/AppsListParams.json | 35 + .../codex/schema/v2/AppsListResponse.json | 295 + .../codex/schema/v2/AppsReadParams.json | 29 + .../codex/schema/v2/AppsReadResponse.json | 124 + .../schema/v2/AuthRecoveryNotification.json | 25 + .../schema/v2/CancelLoginAccountParams.json | 13 + .../schema/v2/CancelLoginAccountResponse.json | 22 + .../CommandExecOutputDeltaNotification.json | 55 + .../codex/schema/v2/CommandExecParams.json | 238 + .../schema/v2/CommandExecResizeParams.json | 48 + .../schema/v2/CommandExecResizeResponse.json | 6 + .../codex/schema/v2/CommandExecResponse.json | 26 + .../schema/v2/CommandExecTerminateParams.json | 15 + .../v2/CommandExecTerminateResponse.json | 6 + .../schema/v2/CommandExecWriteParams.json | 26 + .../schema/v2/CommandExecWriteResponse.json | 6 + ...mmandExecutionOutputDeltaNotification.json | 25 + .../schema/v2/ConfigBatchWriteParams.json | 59 + .../codex/schema/v2/ConfigReadParams.json | 17 + .../codex/schema/v2/ConfigReadResponse.json | 1181 + .../v2/ConfigRequirementsReadResponse.json | 1216 + .../schema/v2/ConfigValueWriteParams.json | 41 + .../schema/v2/ConfigWarningNotification.json | 77 + .../codex/schema/v2/ConfigWriteResponse.json | 297 + ...sumeAccountRateLimitResetCreditParams.json | 21 + ...meAccountRateLimitResetCreditResponse.json | 47 + .../v2/ContextCompactedNotification.json | 18 + .../v2/DeprecationNoticeNotification.json | 21 + .../v2/EnvironmentConnectionNotification.json | 17 + .../codex/schema/v2/ErrorNotification.json | 271 + ...xperimentalFeatureEnablementSetParams.json | 17 + ...erimentalFeatureEnablementSetResponse.json | 17 + .../v2/ExperimentalFeatureListParams.json | 30 + .../v2/ExperimentalFeatureListResponse.json | 116 + .../v2/ExternalAgentConfigDetectParams.json | 53 + .../v2/ExternalAgentConfigDetectResponse.json | 254 + ...gentConfigImportCompletedNotification.json | 142 + ...gentConfigImportHistoriesReadResponse.json | 183 + ...lAgentConfigImportHistoryRecordParams.json | 144 + ...gentConfigImportHistoryRecordResponse.json | 13 + .../v2/ExternalAgentConfigImportParams.json | 240 + ...AgentConfigImportProgressNotification.json | 142 + .../v2/ExternalAgentConfigImportResponse.json | 13 + .../codex/schema/v2/FeedbackSaveParams.json | 60 + .../codex/schema/v2/FeedbackSaveResponse.json | 29 + .../codex/schema/v2/FeedbackUploadParams.json | 46 + .../schema/v2/FeedbackUploadResponse.json | 21 + .../v2/FileChangeOutputDeltaNotification.json | 26 + .../FileChangePatchUpdatedNotification.json | 107 + .../schema/v2/FsChangedNotification.json | 29 + .../codex/schema/v2/FsCopyParams.json | 38 + .../codex/schema/v2/FsCopyResponse.json | 6 + .../schema/v2/FsCreateDirectoryParams.json | 32 + .../schema/v2/FsCreateDirectoryResponse.json | 6 + .../codex/schema/v2/FsGetMetadataParams.json | 25 + .../schema/v2/FsGetMetadataResponse.json | 37 + .../schema/v2/FsReadDirectoryParams.json | 25 + .../schema/v2/FsReadDirectoryResponse.json | 43 + .../codex/schema/v2/FsReadFileParams.json | 25 + .../codex/schema/v2/FsReadFileResponse.json | 15 + .../codex/schema/v2/FsRemoveParams.json | 39 + .../codex/schema/v2/FsRemoveResponse.json | 6 + .../codex/schema/v2/FsUnwatchParams.json | 15 + .../codex/schema/v2/FsUnwatchResponse.json | 6 + .../codex/schema/v2/FsWatchParams.json | 30 + .../codex/schema/v2/FsWatchResponse.json | 25 + .../codex/schema/v2/FsWriteFileParams.json | 30 + .../codex/schema/v2/FsWriteFileResponse.json | 6 + .../schema/v2/GatewayOAuthCancelResponse.json | 5 + .../v2/GatewayOAuthChangedNotification.json | 41 + .../schema/v2/GatewayOAuthLoginResponse.json | 5 + .../schema/v2/GatewayOAuthReadResponse.json | 51 + .../codex/schema/v2/GetAccountParams.json | 11 + .../v2/GetAccountRateLimitsResponse.json | 341 + .../codex/schema/v2/GetAccountResponse.json | 162 + .../v2/GetAccountTokenUsageResponse.json | 187 + .../v2/GetWorkspaceMessagesResponse.json | 67 + .../v2/GuardianWarningNotification.json | 19 + .../schema/v2/HookCompletedNotification.json | 202 + .../schema/v2/HookStartedNotification.json | 202 + .../codex/schema/v2/HooksListParams.json | 14 + .../codex/schema/v2/HooksListResponse.json | 266 + .../schema/v2/ItemCompletedNotification.json | 2034 ++ ...anApprovalReviewCompletedNotification.json | 667 + ...dianApprovalReviewStartedNotification.json | 650 + .../schema/v2/ItemStartedNotification.json | 2034 ++ .../schema/v2/ListMcpServerStatusParams.json | 56 + .../v2/ListMcpServerStatusResponse.json | 292 + .../codex/schema/v2/LoginAccountParams.json | 178 + .../codex/schema/v2/LoginAccountResponse.json | 109 + .../schema/v2/LogoutAccountResponse.json | 5 + .../codex/schema/v2/MarketplaceAddParams.json | 28 + .../schema/v2/MarketplaceAddResponse.json | 27 + .../schema/v2/MarketplaceRemoveParams.json | 13 + .../schema/v2/MarketplaceRemoveResponse.json | 29 + .../schema/v2/MarketplaceUpgradeParams.json | 13 + .../schema/v2/MarketplaceUpgradeResponse.json | 51 + .../schema/v2/McpResourceReadParams.json | 68 + .../schema/v2/McpResourceReadResponse.json | 76 + .../v2/McpServerEventStreamNotification.json | 32 + ...ServerOauthLoginCompletedNotification.json | 36 + .../schema/v2/McpServerOauthLoginParams.json | 56 + .../v2/McpServerOauthLoginResponse.json | 21 + .../schema/v2/McpServerRefreshResponse.json | 5 + .../McpServerStatusUpdatedNotification.json | 56 + .../schema/v2/McpServerToolCallParams.json | 23 + .../schema/v2/McpServerToolCallResponse.json | 22 + .../v2/McpToolCallProgressNotification.json | 25 + .../codex/schema/v2/ModelListParams.json | 30 + .../codex/schema/v2/ModelListResponse.json | 308 + .../ModelProviderCapabilitiesReadParams.json | 5 + ...ModelProviderCapabilitiesReadResponse.json | 17 + .../schema/v2/ModelReroutedNotification.json | 37 + ...delSafetyBufferingUpdatedNotification.json | 45 + .../v2/ModelVerificationNotification.json | 32 + .../NullableGetAccountRateLimitsParams.json | 28 + .../NullableGetAccountTokenUsageParams.json | 26 + .../v2/PermissionProfileListParams.json | 30 + .../v2/PermissionProfileListResponse.json | 49 + .../schema/v2/PlanDeltaNotification.json | 26 + .../codex/schema/v2/PluginInstallParams.json | 42 + .../schema/v2/PluginInstallResponse.json | 63 + .../schema/v2/PluginInstalledParams.json | 33 + .../schema/v2/PluginInstalledResponse.json | 657 + .../codex/schema/v2/PluginListParams.json | 47 + .../codex/schema/v2/PluginListResponse.json | 664 + .../codex/schema/v2/PluginReadParams.json | 35 + .../codex/schema/v2/PluginReadResponse.json | 1060 + .../schema/v2/PluginReconcileParams.json | 14 + .../schema/v2/PluginReconcileResponse.json | 66 + .../schema/v2/PluginShareCheckoutParams.json | 13 + .../v2/PluginShareCheckoutResponse.json | 45 + .../schema/v2/PluginShareDeleteParams.json | 13 + .../schema/v2/PluginShareDeleteResponse.json | 5 + .../schema/v2/PluginShareListParams.json | 5 + .../schema/v2/PluginShareListResponse.json | 606 + .../schema/v2/PluginShareSaveParams.json | 86 + .../schema/v2/PluginShareSaveResponse.json | 24 + .../v2/PluginShareUpdateTargetsParams.json | 68 + .../v2/PluginShareUpdateTargetsResponse.json | 69 + .../schema/v2/PluginSkillReadParams.json | 21 + .../schema/v2/PluginSkillReadResponse.json | 13 + .../schema/v2/PluginUninstallParams.json | 13 + .../schema/v2/PluginUninstallResponse.json | 5 + .../schema/v2/ProcessExitedNotification.json | 41 + .../v2/ProcessOutputDeltaNotification.json | 55 + .../schema/v2/ProjectChangedNotification.json | 27 + .../v2/RawResponseCompletedNotification.json | 94 + .../RawResponseItemCompletedNotification.json | 1453 + ...ReasoningSummaryPartAddedNotification.json | 26 + ...ReasoningSummaryTextDeltaNotification.json | 30 + .../v2/ReasoningTextDeltaNotification.json | 30 + ...emoteControlStatusChangedNotification.json | 39 + .../codex/schema/v2/ReviewStartParams.json | 129 + .../codex/schema/v2/ReviewStartResponse.json | 2378 ++ .../v2/SendAddCreditsNudgeEmailParams.json | 22 + .../v2/SendAddCreditsNudgeEmailResponse.json | 22 + .../v2/ServerRequestResolvedNotification.json | 30 + .../schema/v2/SkillsChangedNotification.json | 6 + .../schema/v2/SkillsConfigWriteParams.json | 37 + .../schema/v2/SkillsConfigWriteResponse.json | 13 + .../schema/v2/SkillsExtraRootsSetParams.json | 22 + .../v2/SkillsExtraRootsSetResponse.json | 5 + .../codex/schema/v2/SkillsListParams.json | 18 + .../codex/schema/v2/SkillsListResponse.json | 251 + .../v2/StrictReviewRequiredNotification.json | 23 + .../v2/TerminalInteractionNotification.json | 29 + ...readApproveGuardianDeniedActionParams.json | 17 + ...adApproveGuardianDeniedActionResponse.json | 5 + .../codex/schema/v2/ThreadArchiveParams.json | 13 + .../schema/v2/ThreadArchiveResponse.json | 5 + .../schema/v2/ThreadArchivedNotification.json | 13 + .../schema/v2/ThreadAttachmentAddParams.json | 24 + .../v2/ThreadAttachmentAddResponse.json | 55 + .../schema/v2/ThreadAttachmentListParams.json | 28 + .../v2/ThreadAttachmentListResponse.json | 52 + .../v2/ThreadAttachmentOwnerListParams.json | 39 + .../v2/ThreadAttachmentOwnerListResponse.json | 42 + .../v2/ThreadAttachmentRemoveParams.json | 22 + .../v2/ThreadAttachmentRemoveResponse.json | 6 + .../ThreadAttachmentUpdatedNotification.json | 40 + .../schema/v2/ThreadClosedNotification.json | 13 + .../schema/v2/ThreadCompactStartParams.json | 13 + .../schema/v2/ThreadCompactStartResponse.json | 5 + .../codex/schema/v2/ThreadDeleteParams.json | 13 + .../codex/schema/v2/ThreadDeleteResponse.json | 5 + .../schema/v2/ThreadDeletedNotification.json | 13 + .../codex/schema/v2/ThreadForkParams.json | 189 + .../codex/schema/v2/ThreadForkResponse.json | 3179 ++ .../schema/v2/ThreadGoalClearParams.json | 34 + .../schema/v2/ThreadGoalClearResponse.json | 13 + .../v2/ThreadGoalClearedNotification.json | 13 + .../codex/schema/v2/ThreadGoalGetParams.json | 13 + .../schema/v2/ThreadGoalGetResponse.json | 76 + .../codex/schema/v2/ThreadGoalSetParams.json | 68 + .../schema/v2/ThreadGoalSetResponse.json | 72 + .../v2/ThreadGoalUpdatedNotification.json | 82 + .../schema/v2/ThreadInjectItemsParams.json | 19 + .../schema/v2/ThreadInjectItemsResponse.json | 5 + .../schema/v2/ThreadItemsListParams.json | 96 + .../schema/v2/ThreadItemsListResponse.json | 2069 ++ .../codex/schema/v2/ThreadListParams.json | 167 + .../codex/schema/v2/ThreadListResponse.json | 2985 ++ .../schema/v2/ThreadLoadedListParams.json | 23 + .../schema/v2/ThreadLoadedListResponse.json | 24 + .../schema/v2/ThreadMetadataUpdateParams.json | 52 + .../v2/ThreadMetadataUpdateResponse.json | 2904 ++ .../v2/ThreadNameUpdatedNotification.json | 19 + .../ThreadPredictionUpdatedNotification.json | 65 + .../v2/ThreadProjectUpdatedNotification.json | 20 + .../v2/ThreadQueueChangedNotification.json | 13 + .../codex/schema/v2/ThreadReadParams.json | 17 + .../codex/schema/v2/ThreadReadResponse.json | 2968 ++ .../ThreadReadStateChangedNotification.json | 83 + .../v2/ThreadRealtimeClosedNotification.json | 20 + .../v2/ThreadRealtimeErrorNotification.json | 18 + .../ThreadRealtimeItemAddedNotification.json | 16 + ...readRealtimeItemCompletedNotification.json | 199 + ...ThreadRealtimeItemStartedNotification.json | 199 + ...altimeItemTranscriptDeltaNotification.json | 22 + ...dRealtimeOutputAudioDeltaNotification.json | 58 + .../v2/ThreadRealtimeSdpNotification.json | 18 + .../v2/ThreadRealtimeStartedNotification.json | 34 + ...adRealtimeTranscriptDeltaNotification.json | 23 + ...eadRealtimeTranscriptDoneNotification.json | 23 + .../codex/schema/v2/ThreadResumeParams.json | 1683 + .../codex/schema/v2/ThreadResumeResponse.json | 3284 ++ .../codex/schema/v2/ThreadRevertParams.json | 19 + .../codex/schema/v2/ThreadRevertResponse.json | 2923 ++ .../schema/v2/ThreadRevertedNotification.json | 13 + .../schema/v2/ThreadSectionCreateParams.json | 46 + .../v2/ThreadSectionCreateResponse.json | 64 + .../schema/v2/ThreadSectionDeleteParams.json | 15 + .../v2/ThreadSectionDeleteResponse.json | 6 + .../schema/v2/ThreadSectionListParams.json | 24 + .../schema/v2/ThreadSectionListResponse.json | 74 + .../schema/v2/ThreadSectionMoveParams.json | 30 + .../schema/v2/ThreadSectionMoveResponse.json | 5 + .../schema/v2/ThreadSectionUpdateParams.json | 51 + .../v2/ThreadSectionUpdateResponse.json | 64 + .../codex/schema/v2/ThreadSetNameParams.json | 17 + .../schema/v2/ThreadSetNameResponse.json | 5 + .../v2/ThreadSettingsUpdatedNotification.json | 408 + .../schema/v2/ThreadShellCommandParams.json | 26 + .../schema/v2/ThreadShellCommandResponse.json | 5 + .../codex/schema/v2/ThreadStartParams.json | 426 + .../codex/schema/v2/ThreadStartResponse.json | 3179 ++ .../schema/v2/ThreadStartedNotification.json | 2904 ++ .../v2/ThreadStatusChangedNotification.json | 101 + .../ThreadTokenUsageUpdatedNotification.json | 82 + .../schema/v2/ThreadTurnsListParams.json | 85 + .../schema/v2/ThreadTurnsListResponse.json | 2390 ++ .../schema/v2/ThreadUnarchiveParams.json | 13 + .../schema/v2/ThreadUnarchiveResponse.json | 2904 ++ .../v2/ThreadUnarchivedNotification.json | 13 + .../schema/v2/ThreadUnsubscribeParams.json | 13 + .../schema/v2/ThreadUnsubscribeResponse.json | 23 + .../schema/v2/TurnCompletedNotification.json | 2377 ++ .../v2/TurnDiffUpdatedNotification.json | 22 + .../codex/schema/v2/TurnInterruptParams.json | 17 + .../schema/v2/TurnInterruptResponse.json | 5 + .../TurnModerationMetadataNotification.json | 19 + .../v2/TurnPlanUpdatedNotification.json | 55 + .../codex/schema/v2/TurnStartParams.json | 914 + .../codex/schema/v2/TurnStartResponse.json | 2373 ++ .../schema/v2/TurnStartedNotification.json | 2377 ++ .../codex/schema/v2/TurnSteerParams.json | 310 + .../codex/schema/v2/TurnSteerResponse.json | 13 + .../codex/schema/v2/WarningNotification.json | 21 + .../v2/WindowsSandboxReadinessResponse.json | 23 + ...dowsSandboxSetupCompletedNotification.json | 32 + .../v2/WindowsSandboxSetupStartParams.json | 36 + .../v2/WindowsSandboxSetupStartResponse.json | 13 + ...ndowsWorldWritableWarningNotification.json | 26 + .../daemon/src/providers/codex/session.ts | 345 + .../daemon/src/providers/enabled.ts | 12 + .../daemon/src/providers/infoCache.ts | 86 + .../daemon/src/providers/magpieGateway.ts | 141 + .../daemon/src/providers/mock.ts | 450 + .../daemon/src/providers/registry.ts | 37 + .../daemon/src/providers/slash.ts | 43 + .../daemon/src/providers/stubProvider.ts | 62 + .../daemon/src/providers/types.ts | 73 + .../daemon/src/providers/warm.ts | 16 + packages/agent-console/daemon/src/security.ts | 44 + .../daemon/src/store/attachments.ts | 83 + .../daemon/src/store/projects.ts | 83 + .../daemon/src/store/settings.ts | 75 + .../agent-console/daemon/src/store/threads.ts | 207 + .../daemon/src/terminal/service.ts | 308 + .../daemon/src/timeline/coalescer.ts | 45 + .../daemon/src/timeline/types.ts | 47 + .../agent-console/daemon/src/util/atomic.ts | 16 + .../daemon/src/util/attachmentRefs.ts | 51 + packages/agent-console/daemon/src/util/bin.ts | 283 + .../daemon/src/util/buildStamp.ts | 32 + .../agent-console/daemon/src/util/i18n.ts | 56 + .../agent-console/daemon/src/util/jsonl.ts | 58 + .../agent-console/daemon/src/util/paths.ts | 50 + .../daemon/src/util/pickFolder.ts | 67 + .../daemon/src/util/pickFolderWin.ts | 44 + .../agent-console/daemon/src/util/proc.ts | 36 + .../agent-console/daemon/src/util/queue.ts | 60 + .../agent-console/daemon/src/ws/gateway.ts | 220 + .../agent-console/daemon/src/ws/methods.ts | 552 + packages/agent-console/docs/PROVIDERS-REF.md | 49 + packages/agent-console/docs/REQUIREMENTS.md | 229 + packages/agent-console/docs/VERIFY-windows.md | 57 + packages/agent-console/index.mjs | 453 + packages/agent-console/lib/gate.d.mts | 9 + packages/agent-console/lib/gate.mjs | 157 + packages/agent-console/lib/pathscan.d.mts | 15 + packages/agent-console/lib/pathscan.mjs | 152 + packages/agent-console/package-lock.json | 3886 +++ packages/agent-console/package.json | 104 + packages/agent-console/page/boot.js | 99 + packages/agent-console/page/index.html | 31 + .../agent-console/scripts/browser-path.mjs | 33 + packages/agent-console/scripts/build-v1.mjs | 100 + packages/agent-console/scripts/e2e-v1.mjs | 541 + packages/agent-console/scripts/shot-v1.mjs | 199 + .../agent-console/scripts/verify-build.mjs | 31 + .../agent-console/scripts/verify-windows.mjs | 420 + .../agent-console/test/v1/acp-probe.test.ts | 70 + .../test/v1/attachments-send.test.ts | 35 + .../agent-console/test/v1/buildStamp.test.ts | 15 + .../agent-console/test/v1/claude-map.test.ts | 148 + .../test/v1/claude-thinking.test.ts | 15 + .../agent-console/test/v1/cli-models.test.ts | 20 + .../agent-console/test/v1/coalescer.test.ts | 58 + .../agent-console/test/v1/codex-rpc.test.ts | 144 + .../test/v1/crossplatform.test.ts | 351 + .../test/v1/cursor-models.test.ts | 29 + packages/agent-console/test/v1/files.test.ts | 88 + packages/agent-console/test/v1/fixes.test.ts | 138 + packages/agent-console/test/v1/gate.test.ts | 97 + packages/agent-console/test/v1/git.test.ts | 98 + packages/agent-console/test/v1/helpers-net.ts | 72 + packages/agent-console/test/v1/i18n.test.ts | 20 + .../test/v1/layout-bounds.test.ts | 24 + .../agent-console/test/v1/lifeline.test.ts | 103 + .../test/v1/magpie-gateway.test.ts | 17 + .../agent-console/test/v1/manager.test.ts | 210 + .../agent-console/test/v1/migrate.test.ts | 141 + packages/agent-console/test/v1/mock.test.ts | 236 + .../agent-console/test/v1/pickFolder.test.ts | 35 + .../test/v1/plugin-entry.test.ts | 170 + packages/agent-console/test/v1/polish.test.ts | 60 + .../agent-console/test/v1/pr535-http.test.ts | 172 + .../test/v1/provider-cache.test.ts | 53 + .../test/v1/providers-catalog.test.ts | 39 + .../test/v1/providers-integration.test.ts | 38 + .../agent-console/test/v1/security.test.ts | 107 + .../agent-console/test/v1/slash-acp.test.ts | 18 + .../test/v1/slash-claude.test.ts | 35 + .../agent-console/test/v1/slash-codex.test.ts | 30 + packages/agent-console/test/v1/store.test.ts | 65 + .../agent-console/test/v1/stores2.test.ts | 78 + .../test/v1/terminal-fallback.test.ts | 80 + packages/agent-console/test/v1/ws.test.ts | 127 + packages/agent-console/tsconfig.daemon.json | 16 + packages/agent-console/tsconfig.json | 7 + packages/agent-console/tsconfig.web.json | 17 + packages/agent-console/vitest.config.ts | 9 + packages/agent-console/web/index.html | 14 + packages/agent-console/web/src/App.tsx | 72 + packages/agent-console/web/src/Debug.tsx | 192 + packages/agent-console/web/src/api/token.ts | 5 + packages/agent-console/web/src/api/ws.ts | 174 + .../agent-console/web/src/components/Chat.tsx | 225 + .../web/src/components/CommandPalette.tsx | 98 + .../web/src/components/Composer.tsx | 616 + .../web/src/components/Dialog.tsx | 59 + .../web/src/components/Icons.tsx | 33 + .../web/src/components/Layout.tsx | 173 + .../web/src/components/Markdown.tsx | 72 + .../agent-console/web/src/components/Menu.tsx | 77 + .../web/src/components/Panel.tsx | 365 + .../web/src/components/Settings.tsx | 230 + .../web/src/components/Sidebar.tsx | 514 + .../web/src/components/TerminalPanel.tsx | 234 + .../web/src/components/Timeline.tsx | 349 + .../web/src/components/Toast.tsx | 27 + .../web/src/components/Topbar.tsx | 163 + .../web/src/composer/ime.test.ts | 8 + .../agent-console/web/src/composer/ime.ts | 8 + .../web/src/composer/slashMenu.test.ts | 68 + .../web/src/composer/slashMenu.ts | 66 + packages/agent-console/web/src/i18n/index.ts | 84 + .../agent-console/web/src/i18n/messages.ts | 287 + packages/agent-console/web/src/i18n/modes.ts | 34 + .../web/src/layout/applyMagpieTokens.ts | 18 + .../agent-console/web/src/layout/bounds.ts | 40 + packages/agent-console/web/src/main.tsx | 106 + .../web/src/stores/connection.ts | 14 + packages/agent-console/web/src/stores/data.ts | 97 + .../agent-console/web/src/stores/timeline.ts | 147 + packages/agent-console/web/src/stores/ui.ts | 146 + .../agent-console/web/src/styles/base.css | 146 + .../agent-console/web/src/styles/chat.css | 145 + .../agent-console/web/src/styles/composer.css | 197 + .../agent-console/web/src/styles/layout.css | 303 + .../agent-console/web/src/styles/tokens.css | 43 + .../agent-console/web/src/terminal/theme.ts | 63 + .../web/src/timeline/diffHunks.test.ts | 27 + .../web/src/timeline/diffHunks.ts | 34 + .../web/src/timeline/reducer.test.ts | 153 + .../agent-console/web/src/timeline/reducer.ts | 285 + .../agent-console/web/src/timeline/reveal.ts | 43 + .../agent-console/web/src/timeline/types.ts | 26 + .../agent-console/web/src/types/assets.d.ts | 5 + .../agent-console/web/src/types/models.ts | 54 + packages/agent-console/web/src/util/format.ts | 33 + 504 files changed, 178962 insertions(+) create mode 100644 bunfig.toml create mode 100644 packages/agent-console/.gitattributes create mode 100644 packages/agent-console/.gitignore create mode 100644 packages/agent-console/LICENSE create mode 100644 packages/agent-console/README.md create mode 100644 packages/agent-console/THIRD-PARTY-NOTICES.md create mode 100644 packages/agent-console/agent-console.test.mjs create mode 100644 packages/agent-console/app/app.css create mode 100644 packages/agent-console/app/app.js create mode 100644 packages/agent-console/app/index.html create mode 100644 packages/agent-console/assets/icon.svg create mode 100644 packages/agent-console/daemon/dist/main.js create mode 100644 packages/agent-console/daemon/dist/main.js.map create mode 100644 packages/agent-console/daemon/src/agents/diff.ts create mode 100644 packages/agent-console/daemon/src/agents/kinds.ts create mode 100644 packages/agent-console/daemon/src/agents/manager.ts create mode 100644 packages/agent-console/daemon/src/config.ts create mode 100644 packages/agent-console/daemon/src/files/service.ts create mode 100644 packages/agent-console/daemon/src/git/service.ts create mode 100644 packages/agent-console/daemon/src/history/codex.ts create mode 100644 packages/agent-console/daemon/src/history/import.ts create mode 100644 packages/agent-console/daemon/src/http.ts create mode 100644 packages/agent-console/daemon/src/log.ts create mode 100644 packages/agent-console/daemon/src/main.ts create mode 100644 packages/agent-console/daemon/src/migrate/legacy.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/cliModels.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/clientHandlers.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/cursorModels.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/mapUpdate.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/probe.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/provider.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/session.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/spawn.ts create mode 100644 packages/agent-console/daemon/src/providers/acp/specs.ts create mode 100644 packages/agent-console/daemon/src/providers/catalog.ts create mode 100644 packages/agent-console/daemon/src/providers/claude/map.ts create mode 100644 packages/agent-console/daemon/src/providers/claude/models.ts create mode 100644 packages/agent-console/daemon/src/providers/claude/provider.ts create mode 100644 packages/agent-console/daemon/src/providers/claude/session.ts create mode 100644 packages/agent-console/daemon/src/providers/claude/thinking.ts create mode 100644 packages/agent-console/daemon/src/providers/codex/commands.ts create mode 100644 packages/agent-console/daemon/src/providers/codex/map.ts create mode 100644 packages/agent-console/daemon/src/providers/codex/provider.ts create mode 100644 packages/agent-console/daemon/src/providers/codex/rpc.ts create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ApplyPatchApprovalParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ApplyPatchApprovalResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/AttestationGenerateParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/AttestationGenerateResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ChatgptAuthTokensRefreshParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ChatgptAuthTokensRefreshResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ClientNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ClientRequest.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/CommandExecutionRequestApprovalParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/CommandExecutionRequestApprovalResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/DynamicToolCallParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/DynamicToolCallResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ExecCommandApprovalParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ExecCommandApprovalResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/FileChangeRequestApprovalParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/FileChangeRequestApprovalResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/FuzzyFileSearchParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/FuzzyFileSearchResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/FuzzyFileSearchSessionCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/FuzzyFileSearchSessionUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/JSONRPCError.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/JSONRPCErrorError.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/JSONRPCMessage.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/JSONRPCNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/JSONRPCRequest.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/JSONRPCResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/McpServerElicitationRequestParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/McpServerElicitationRequestResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/PermissionsRequestApprovalParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/PermissionsRequestApprovalResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/RequestId.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ServerNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ServerRequest.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ToolRequestUserInputParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/ToolRequestUserInputResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/codex_app_server_protocol.schemas.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/codex_app_server_protocol.v2.schemas.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v1/InitializeParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v1/InitializeResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AccountLoginCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AccountRateLimitsUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AccountUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AgentMessageDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AppListUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AppsInstalledParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AppsInstalledResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AppsListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AppsListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AppsReadParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AppsReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/AuthRecoveryNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CancelLoginAccountParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CancelLoginAccountResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecOutputDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecResizeParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecResizeResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecTerminateParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecTerminateResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecWriteParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecWriteResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/CommandExecutionOutputDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConfigBatchWriteParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConfigReadParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConfigReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConfigRequirementsReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConfigValueWriteParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConfigWarningNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConfigWriteResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConsumeAccountRateLimitResetCreditParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ConsumeAccountRateLimitResetCreditResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ContextCompactedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/DeprecationNoticeNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/EnvironmentConnectionNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ErrorNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExperimentalFeatureEnablementSetParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExperimentalFeatureEnablementSetResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExperimentalFeatureListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExperimentalFeatureListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigDetectParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigDetectResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigImportCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigImportHistoriesReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigImportHistoryRecordParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigImportHistoryRecordResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigImportParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigImportProgressNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ExternalAgentConfigImportResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FeedbackSaveParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FeedbackSaveResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FeedbackUploadParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FeedbackUploadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FileChangeOutputDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FileChangePatchUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsChangedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsCopyParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsCopyResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsCreateDirectoryParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsCreateDirectoryResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsGetMetadataParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsGetMetadataResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsReadDirectoryParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsReadDirectoryResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsReadFileParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsReadFileResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsRemoveParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsRemoveResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsUnwatchParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsUnwatchResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsWatchParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsWatchResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsWriteFileParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/FsWriteFileResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GatewayOAuthCancelResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GatewayOAuthChangedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GatewayOAuthLoginResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GatewayOAuthReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GetAccountParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GetAccountRateLimitsResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GetAccountResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GetAccountTokenUsageResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GetWorkspaceMessagesResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/GuardianWarningNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/HookCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/HookStartedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/HooksListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/HooksListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ItemCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ItemGuardianApprovalReviewCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ItemGuardianApprovalReviewStartedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ItemStartedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ListMcpServerStatusParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ListMcpServerStatusResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/LoginAccountParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/LoginAccountResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/LogoutAccountResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/MarketplaceAddParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/MarketplaceAddResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/MarketplaceRemoveParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/MarketplaceRemoveResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/MarketplaceUpgradeParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/MarketplaceUpgradeResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpResourceReadParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpResourceReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpServerEventStreamNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpServerOauthLoginCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpServerOauthLoginParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpServerOauthLoginResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpServerRefreshResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpServerStatusUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpServerToolCallParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpServerToolCallResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/McpToolCallProgressNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ModelListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ModelListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ModelProviderCapabilitiesReadParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ModelProviderCapabilitiesReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ModelReroutedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ModelSafetyBufferingUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ModelVerificationNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/NullableGetAccountRateLimitsParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/NullableGetAccountTokenUsageParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PermissionProfileListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PermissionProfileListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PlanDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginInstallParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginInstallResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginInstalledParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginInstalledResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginReadParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginReconcileParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginReconcileResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareCheckoutParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareCheckoutResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareDeleteParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareDeleteResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareSaveParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareSaveResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareUpdateTargetsParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginShareUpdateTargetsResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginSkillReadParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginSkillReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginUninstallParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/PluginUninstallResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ProcessExitedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ProcessOutputDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ProjectChangedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/RawResponseCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/RawResponseItemCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ReasoningSummaryPartAddedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ReasoningSummaryTextDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ReasoningTextDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/RemoteControlStatusChangedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ReviewStartParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ReviewStartResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SendAddCreditsNudgeEmailParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SendAddCreditsNudgeEmailResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ServerRequestResolvedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SkillsChangedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SkillsConfigWriteParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SkillsConfigWriteResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SkillsExtraRootsSetParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SkillsExtraRootsSetResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SkillsListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/SkillsListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/StrictReviewRequiredNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TerminalInteractionNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadApproveGuardianDeniedActionParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadApproveGuardianDeniedActionResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadArchiveParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadArchiveResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadArchivedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentAddParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentAddResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentOwnerListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentOwnerListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentRemoveParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentRemoveResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadAttachmentUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadClosedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadCompactStartParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadCompactStartResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadDeleteParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadDeleteResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadDeletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadForkParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadForkResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadGoalClearParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadGoalClearResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadGoalClearedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadGoalGetParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadGoalGetResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadGoalSetParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadGoalSetResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadGoalUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadInjectItemsParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadInjectItemsResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadItemsListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadItemsListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadLoadedListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadLoadedListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadMetadataUpdateParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadMetadataUpdateResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadNameUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadPredictionUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadProjectUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadQueueChangedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadReadParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadReadResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadReadStateChangedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeClosedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeErrorNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeItemAddedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeItemCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeItemStartedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeItemTranscriptDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeOutputAudioDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeSdpNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeStartedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeTranscriptDeltaNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRealtimeTranscriptDoneNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadResumeParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadResumeResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRevertParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRevertResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadRevertedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionCreateParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionCreateResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionDeleteParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionDeleteResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionMoveParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionMoveResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionUpdateParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSectionUpdateResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSetNameParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSetNameResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadSettingsUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadShellCommandParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadShellCommandResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadStartParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadStartResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadStartedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadStatusChangedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadTokenUsageUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadTurnsListParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadTurnsListResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadUnarchiveParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadUnarchiveResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadUnarchivedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadUnsubscribeParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/ThreadUnsubscribeResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnDiffUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnInterruptParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnInterruptResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnModerationMetadataNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnPlanUpdatedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnStartParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnStartResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnStartedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnSteerParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/TurnSteerResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/WarningNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/WindowsSandboxReadinessResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/WindowsSandboxSetupCompletedNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/WindowsSandboxSetupStartParams.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/WindowsSandboxSetupStartResponse.json create mode 100644 packages/agent-console/daemon/src/providers/codex/schema/v2/WindowsWorldWritableWarningNotification.json create mode 100644 packages/agent-console/daemon/src/providers/codex/session.ts create mode 100644 packages/agent-console/daemon/src/providers/enabled.ts create mode 100644 packages/agent-console/daemon/src/providers/infoCache.ts create mode 100644 packages/agent-console/daemon/src/providers/magpieGateway.ts create mode 100644 packages/agent-console/daemon/src/providers/mock.ts create mode 100644 packages/agent-console/daemon/src/providers/registry.ts create mode 100644 packages/agent-console/daemon/src/providers/slash.ts create mode 100644 packages/agent-console/daemon/src/providers/stubProvider.ts create mode 100644 packages/agent-console/daemon/src/providers/types.ts create mode 100644 packages/agent-console/daemon/src/providers/warm.ts create mode 100644 packages/agent-console/daemon/src/security.ts create mode 100644 packages/agent-console/daemon/src/store/attachments.ts create mode 100644 packages/agent-console/daemon/src/store/projects.ts create mode 100644 packages/agent-console/daemon/src/store/settings.ts create mode 100644 packages/agent-console/daemon/src/store/threads.ts create mode 100644 packages/agent-console/daemon/src/terminal/service.ts create mode 100644 packages/agent-console/daemon/src/timeline/coalescer.ts create mode 100644 packages/agent-console/daemon/src/timeline/types.ts create mode 100644 packages/agent-console/daemon/src/util/atomic.ts create mode 100644 packages/agent-console/daemon/src/util/attachmentRefs.ts create mode 100644 packages/agent-console/daemon/src/util/bin.ts create mode 100644 packages/agent-console/daemon/src/util/buildStamp.ts create mode 100644 packages/agent-console/daemon/src/util/i18n.ts create mode 100644 packages/agent-console/daemon/src/util/jsonl.ts create mode 100644 packages/agent-console/daemon/src/util/paths.ts create mode 100644 packages/agent-console/daemon/src/util/pickFolder.ts create mode 100644 packages/agent-console/daemon/src/util/pickFolderWin.ts create mode 100644 packages/agent-console/daemon/src/util/proc.ts create mode 100644 packages/agent-console/daemon/src/util/queue.ts create mode 100644 packages/agent-console/daemon/src/ws/gateway.ts create mode 100644 packages/agent-console/daemon/src/ws/methods.ts create mode 100644 packages/agent-console/docs/PROVIDERS-REF.md create mode 100644 packages/agent-console/docs/REQUIREMENTS.md create mode 100644 packages/agent-console/docs/VERIFY-windows.md create mode 100644 packages/agent-console/index.mjs create mode 100644 packages/agent-console/lib/gate.d.mts create mode 100644 packages/agent-console/lib/gate.mjs create mode 100644 packages/agent-console/lib/pathscan.d.mts create mode 100644 packages/agent-console/lib/pathscan.mjs create mode 100644 packages/agent-console/package-lock.json create mode 100644 packages/agent-console/package.json create mode 100644 packages/agent-console/page/boot.js create mode 100644 packages/agent-console/page/index.html create mode 100644 packages/agent-console/scripts/browser-path.mjs create mode 100644 packages/agent-console/scripts/build-v1.mjs create mode 100644 packages/agent-console/scripts/e2e-v1.mjs create mode 100644 packages/agent-console/scripts/shot-v1.mjs create mode 100644 packages/agent-console/scripts/verify-build.mjs create mode 100644 packages/agent-console/scripts/verify-windows.mjs create mode 100644 packages/agent-console/test/v1/acp-probe.test.ts create mode 100644 packages/agent-console/test/v1/attachments-send.test.ts create mode 100644 packages/agent-console/test/v1/buildStamp.test.ts create mode 100644 packages/agent-console/test/v1/claude-map.test.ts create mode 100644 packages/agent-console/test/v1/claude-thinking.test.ts create mode 100644 packages/agent-console/test/v1/cli-models.test.ts create mode 100644 packages/agent-console/test/v1/coalescer.test.ts create mode 100644 packages/agent-console/test/v1/codex-rpc.test.ts create mode 100644 packages/agent-console/test/v1/crossplatform.test.ts create mode 100644 packages/agent-console/test/v1/cursor-models.test.ts create mode 100644 packages/agent-console/test/v1/files.test.ts create mode 100644 packages/agent-console/test/v1/fixes.test.ts create mode 100644 packages/agent-console/test/v1/gate.test.ts create mode 100644 packages/agent-console/test/v1/git.test.ts create mode 100644 packages/agent-console/test/v1/helpers-net.ts create mode 100644 packages/agent-console/test/v1/i18n.test.ts create mode 100644 packages/agent-console/test/v1/layout-bounds.test.ts create mode 100644 packages/agent-console/test/v1/lifeline.test.ts create mode 100644 packages/agent-console/test/v1/magpie-gateway.test.ts create mode 100644 packages/agent-console/test/v1/manager.test.ts create mode 100644 packages/agent-console/test/v1/migrate.test.ts create mode 100644 packages/agent-console/test/v1/mock.test.ts create mode 100644 packages/agent-console/test/v1/pickFolder.test.ts create mode 100644 packages/agent-console/test/v1/plugin-entry.test.ts create mode 100644 packages/agent-console/test/v1/polish.test.ts create mode 100644 packages/agent-console/test/v1/pr535-http.test.ts create mode 100644 packages/agent-console/test/v1/provider-cache.test.ts create mode 100644 packages/agent-console/test/v1/providers-catalog.test.ts create mode 100644 packages/agent-console/test/v1/providers-integration.test.ts create mode 100644 packages/agent-console/test/v1/security.test.ts create mode 100644 packages/agent-console/test/v1/slash-acp.test.ts create mode 100644 packages/agent-console/test/v1/slash-claude.test.ts create mode 100644 packages/agent-console/test/v1/slash-codex.test.ts create mode 100644 packages/agent-console/test/v1/store.test.ts create mode 100644 packages/agent-console/test/v1/stores2.test.ts create mode 100644 packages/agent-console/test/v1/terminal-fallback.test.ts create mode 100644 packages/agent-console/test/v1/ws.test.ts create mode 100644 packages/agent-console/tsconfig.daemon.json create mode 100644 packages/agent-console/tsconfig.json create mode 100644 packages/agent-console/tsconfig.web.json create mode 100644 packages/agent-console/vitest.config.ts create mode 100644 packages/agent-console/web/index.html create mode 100644 packages/agent-console/web/src/App.tsx create mode 100644 packages/agent-console/web/src/Debug.tsx create mode 100644 packages/agent-console/web/src/api/token.ts create mode 100644 packages/agent-console/web/src/api/ws.ts create mode 100644 packages/agent-console/web/src/components/Chat.tsx create mode 100644 packages/agent-console/web/src/components/CommandPalette.tsx create mode 100644 packages/agent-console/web/src/components/Composer.tsx create mode 100644 packages/agent-console/web/src/components/Dialog.tsx create mode 100644 packages/agent-console/web/src/components/Icons.tsx create mode 100644 packages/agent-console/web/src/components/Layout.tsx create mode 100644 packages/agent-console/web/src/components/Markdown.tsx create mode 100644 packages/agent-console/web/src/components/Menu.tsx create mode 100644 packages/agent-console/web/src/components/Panel.tsx create mode 100644 packages/agent-console/web/src/components/Settings.tsx create mode 100644 packages/agent-console/web/src/components/Sidebar.tsx create mode 100644 packages/agent-console/web/src/components/TerminalPanel.tsx create mode 100644 packages/agent-console/web/src/components/Timeline.tsx create mode 100644 packages/agent-console/web/src/components/Toast.tsx create mode 100644 packages/agent-console/web/src/components/Topbar.tsx create mode 100644 packages/agent-console/web/src/composer/ime.test.ts create mode 100644 packages/agent-console/web/src/composer/ime.ts create mode 100644 packages/agent-console/web/src/composer/slashMenu.test.ts create mode 100644 packages/agent-console/web/src/composer/slashMenu.ts create mode 100644 packages/agent-console/web/src/i18n/index.ts create mode 100644 packages/agent-console/web/src/i18n/messages.ts create mode 100644 packages/agent-console/web/src/i18n/modes.ts create mode 100644 packages/agent-console/web/src/layout/applyMagpieTokens.ts create mode 100644 packages/agent-console/web/src/layout/bounds.ts create mode 100644 packages/agent-console/web/src/main.tsx create mode 100644 packages/agent-console/web/src/stores/connection.ts create mode 100644 packages/agent-console/web/src/stores/data.ts create mode 100644 packages/agent-console/web/src/stores/timeline.ts create mode 100644 packages/agent-console/web/src/stores/ui.ts create mode 100644 packages/agent-console/web/src/styles/base.css create mode 100644 packages/agent-console/web/src/styles/chat.css create mode 100644 packages/agent-console/web/src/styles/composer.css create mode 100644 packages/agent-console/web/src/styles/layout.css create mode 100644 packages/agent-console/web/src/styles/tokens.css create mode 100644 packages/agent-console/web/src/terminal/theme.ts create mode 100644 packages/agent-console/web/src/timeline/diffHunks.test.ts create mode 100644 packages/agent-console/web/src/timeline/diffHunks.ts create mode 100644 packages/agent-console/web/src/timeline/reducer.test.ts create mode 100644 packages/agent-console/web/src/timeline/reducer.ts create mode 100644 packages/agent-console/web/src/timeline/reveal.ts create mode 100644 packages/agent-console/web/src/timeline/types.ts create mode 100644 packages/agent-console/web/src/types/assets.d.ts create mode 100644 packages/agent-console/web/src/types/models.ts create mode 100644 packages/agent-console/web/src/util/format.ts diff --git a/README.md b/README.md index 61ca20a1..54fe7408 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ These add a page to magpie's window, in the app and in `magpie web`, under Plugi | [agent-office](packages/agent-office) | What are my agents doing: live sessions as workers in a voxel office (3D/2D) or a list, with status, current step and resume commands | no `magpie.read`; its host part reads the local session files of Claude Code, Codex, dsh, Gemini CLI, Qwen Code, Pi, omp, Qoder, WorkBuddy and CodeBuddy, including prompts, replies and tool commands (`text:false` keeps statuses and tool names only) | | [fingerprint](packages/fingerprint) | Numeric response fingerprints to investigate suspected model substitution across providers, with shared challenges, candidate rankings, local evidence and JSON export | none (its local service calls magpie’s gateway; sampling uses provider quota) | | [chat](packages/chat) | Chat with magpie's models, your MCP servers, instructions and skills, by Magic_zero ([Magiczerowxy](https://github.com/Magiczerowxy)). Has a host part: a local service the page goes to | none (its service calls magpie's gateway) | +| [agent-console](packages/agent-console) | Drive the Agent CLIs on this computer (Claude Code, Codex, Cursor, Copilot, OpenCode, Pi, Devin, dsh) with a timeline, Git changes, files and a terminal, by [qingnian108](https://github.com/qingnian108). Has a host part: a gate and a daemon on 127.0.0.1, started when the page opens | none (its daemon runs the Agent CLIs you pick, in the projects you open) | ```sh magpie plugin add @magpie-community/page- diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..4446bbd1 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +[test] +# packages/agent-console has its own vitest suite with npm dependencies (npm ci +# in that folder, then npm test); the root `bun test` doesn't install them +pathIgnorePatterns = ["packages/agent-console/test/**", "packages/agent-console/web/**"] diff --git a/packages/agent-console/.gitattributes b/packages/agent-console/.gitattributes new file mode 100644 index 00000000..23ef6e6d --- /dev/null +++ b/packages/agent-console/.gitattributes @@ -0,0 +1,10 @@ +*.ts text eol=lf +*.tsx text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.css text eol=lf +*.html text eol=lf +*.json text eol=lf +*.md text eol=lf +app/** -text +daemon/dist/** -text diff --git a/packages/agent-console/.gitignore b/packages/agent-console/.gitignore new file mode 100644 index 00000000..41c73637 --- /dev/null +++ b/packages/agent-console/.gitignore @@ -0,0 +1,6 @@ +_tmp/ +node_modules/ +*.log +.DS_Store +page/session.js +*.tgz diff --git a/packages/agent-console/LICENSE b/packages/agent-console/LICENSE new file mode 100644 index 00000000..96ebe89b --- /dev/null +++ b/packages/agent-console/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 qingnian108 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/agent-console/README.md b/packages/agent-console/README.md new file mode 100644 index 00000000..03df685d --- /dev/null +++ b/packages/agent-console/README.md @@ -0,0 +1,185 @@ +# Agent Console + +A Magpie page plugin that drives real local Agent CLIs (Claude Code, Codex, Cursor, Copilot, OpenCode, Pi, Devin, dsh). Each conversation is a **persistent Agent session** on a standalone Node daemon. Close or refresh the page — the Agent keeps running while Magpie is open. Reopen the page and you pick up where you left off. When Magpie quits, the daemon and its Agents stop; threads are persisted and can be resumed next time. + +## Install + +Requires [Magpie](https://usemagpie.ai) ≥ 0.1.1340 and Node.js ≥ 20. + +```bash +magpie plugin add @magpie-community/page-agent-console +``` + +Or in Magpie: **Settings → Plugins → Add plugin**, enter `@magpie-community/page-agent-console` (or find **Agent Console** in Discover). After install, **Agent Console** appears in the sidebar plugin group. + +Install the Agent CLIs you want to use (for example `claude` or `codex`). Missing CLIs show an install hint in Settings. + +--- + +This is not a gateway chat box. The daemon talks to the real CLIs through `@anthropic-ai/claude-agent-sdk`, `codex app-server`, and ACP, normalizes a structured event stream (tool calls, thinking, permission requests, usage), and pushes it to the React page over WebSocket. + +UI principle: **obvious at a glance, nothing extra**. Three columns — sidebar (projects × threads), conversation timeline, and a right panel (changes / files / terminal). + +## Features + +- **Persistent sessions**: each thread binds to a long-lived Agent process; after a daemon restart, running threads are marked closed and can be resumed (`--resume` / `thread/resume`) +- **Multiple Agents**: Claude Code, Codex, Cursor, Copilot, OpenCode, Pi, Devin, dsh; missing CLIs show an install hint +- **Model / permission / thinking**: per-thread; four permission modes and five thinking levels (what each Agent actually supports) +- **Structured timeline**: user message → thinking (expandable) → tool calls (read / edit / command, with line-level diffs) → todos → usage and duration; answer permission requests (tools / plan / questions) on the card +- **Steer and queue**: steer while running, queue the next message, interrupt +- **Changes panel**: git status refresh every 3s, unified per-file diffs; discarded untracked files go to trash instead of a hard delete +- **Files panel**: lazy directory tree (respects `.gitignore`), syntax-highlighted preview +- **Terminal panel**: real PTY via node-pty (pwsh / powershell); reconnects after reopening the page; degrades if PTY is unavailable +- **Import history**: import matching sessions from `~/.claude/projects` and `~/.codex/sessions` as new threads (resumable) +- **Thread management**: search (Ctrl+K), pin, rename, archive, delete (trash), export Markdown, thread info (usage / cost / session ID) +- **Mock Agent**: `AGENT_CONSOLE_MOCK=1` registers a demo Agent with 8 scripts (`default` / `perm` / `plan` / `ask` / `slow` / `error` / `long` / `exit`) so UI work does not burn tokens + +## Architecture + +``` +Magpie window + └─ page/index.html (sandbox="allow-scripts" iframe, served by Magpie from /_magpie/plugin//) + │ reads page/session.js {port, key} (gate key only, written by the plugin host) + │ navigates itself to http://127.0.0.1:/open?k=&theme=… + ▼ +Magpie plugin host (Bun) ── index.mjs + lib/gate.mjs "gate" on 127.0.0.1: (default 3488) + │ first /open: spawns the daemon as a child process (node, --port 0, stdin lifeline, --parent-pid) + │ 302 → http://127.0.0.1:/?theme=…#t= + ▼ +daemon (Node, daemon/dist/main.js) ── HTTP + WebSocket on an ephemeral 127.0.0.1 port + └─ React app (app/) reads the token from location.hash, sends it as the x-agent-console header + and in the WebSocket hello +``` + +## Security model + +The daemon can run Agents with full access to your machine, so the token that unlocks it must never be readable by another web page. + +- **Two secrets per plugin-host run.** Each run generates two independent random 256-bit values: a **gate key** and the **daemon token**. Only the gate key is ever written to a file. +- **Why `session.js` holds only the gate key.** Magpie serves the page folder at `/_magpie/plugin//`, where the id is an HMAC that changes every time Magpie starts and is handed out only to Magpie's own window. Magpie does not check `Origin`, `Host`, `Sec-Fetch-*` or a cookie on these files, and sends no `Cross-Origin-Resource-Policy`, so the unguessable path is their only protection. A classic ` + + diff --git a/packages/agent-console/assets/icon.svg b/packages/agent-console/assets/icon.svg new file mode 100644 index 00000000..d75b7e0d --- /dev/null +++ b/packages/agent-console/assets/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/agent-console/daemon/dist/main.js b/packages/agent-console/daemon/dist/main.js new file mode 100644 index 00000000..9d4f5bfd --- /dev/null +++ b/packages/agent-console/daemon/dist/main.js @@ -0,0 +1,7404 @@ +import { createRequire } from 'module'; const require = createRequire(import.meta.url); +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __esm = (fn, res, err) => function __init() { + if (err) throw err[0]; + try { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; + } catch (e) { + throw err = [e], e; + } +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; + +// daemon/src/log.ts +import { appendFileSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { join as join3 } from "node:path"; +function fileName(d) { + const p = (n) => String(n).padStart(2, "0"); + return `daemon-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}.log`; +} +function initLog(dataDir) { + logDir = join3(dataDir, "logs"); + mkdirSync(logDir, { recursive: true }); + try { + const keep = /* @__PURE__ */ new Set(); + const now = /* @__PURE__ */ new Date(); + for (let i = 0; i < 7; i++) { + keep.add(fileName(new Date(now.getTime() - i * 864e5))); + } + for (const f of readdirSync(logDir)) { + if (/^daemon-\d{4}-\d{2}-\d{2}\.log$/.test(f) && !keep.has(f)) { + rmSync(join3(logDir, f), { force: true }); + } + } + } catch { + } +} +function log(level, msg, extra) { + const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] ${msg}${extra !== void 0 ? " " + safeJson(extra) : ""}`; + try { + const d = /* @__PURE__ */ new Date(); + const name = fileName(d); + if (name !== today && logDir) { + today = name; + logFile = join3(logDir, name); + } + if (logFile) appendFileSync(logFile, line + "\n", "utf8"); + } catch { + } + const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log; + fn(`[agent-console] ${msg}`, extra !== void 0 ? extra : ""); +} +function safeJson(v) { + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} +var logDir, today, logFile; +var init_log = __esm({ + "daemon/src/log.ts"() { + logDir = ""; + today = ""; + logFile = ""; + } +}); + +// daemon/src/util/paths.ts +import { realpathSync, existsSync as existsSync3 } from "node:fs"; +import { posix, sep, win32 } from "node:path"; +function normPath(p, platform = process.platform) { + let r = (platform === "win32" ? win32 : posix).resolve(p); + try { + r = realpathSync.native(r); + } catch { + try { + r = realpathSync(r); + } catch { + } + } + if (platform === "win32") { + r = r.replace(/\//g, "\\").replace(/^\\\\\?\\(?!UNC\\)/i, "").replace(/^\\\\\?\\UNC\\/i, "\\\\").toLowerCase(); + } else { + r = r.replace(/\\/g, "/"); + if (platform === "darwin") r = r.normalize("NFC").toLowerCase(); + } + while (r.length > 3 && (r.endsWith("/") || r.endsWith("\\"))) r = r.slice(0, -1); + return r; +} +function samePath(a, b) { + return normPath(a) === normPath(b); +} +function validId(id) { + return typeof id === "string" && ID_RE.test(id); +} +var ID_RE; +var init_paths = __esm({ + "daemon/src/util/paths.ts"() { + ID_RE = /^[\w-]{1,80}$/; + } +}); + +// daemon/src/util/queue.ts +var AsyncQueue; +var init_queue = __esm({ + "daemon/src/util/queue.ts"() { + AsyncQueue = class { + buf = []; + waiters = []; + closed = false; + err = null; + push(item) { + if (this.closed) return; + const w = this.waiters.shift(); + if (w) w({ value: item, done: false }); + else this.buf.push(item); + } + close() { + if (this.closed) return; + this.closed = true; + while (this.waiters.length) { + const w = this.waiters.shift(); + w({ value: void 0, done: true }); + } + } + fail(err) { + if (this.closed) return; + this.err = err; + this.closed = true; + while (this.waiters.length) { + const w = this.waiters.shift(); + w({ value: void 0, done: true }); + } + } + get isClosed() { + return this.closed; + } + [Symbol.asyncIterator]() { + return { + next: () => { + if (this.buf.length) return Promise.resolve({ value: this.buf.shift(), done: false }); + if (this.closed) { + if (this.err) return Promise.reject(this.err); + return Promise.resolve({ value: void 0, done: true }); + } + if (this.err) return Promise.reject(this.err); + return new Promise((res, rej) => { + this.waiters.push((r) => { + if (this.err) { + rej(this.err); + return; + } + res(r); + }); + }); + } + }; + } + }; + } +}); + +// daemon/src/agents/diff.ts +function diffLines(oldStr = "", newStr = "", { context = CONTEXT } = {}) { + const a = splitLines(oldStr); + const b = splitLines(newStr); + if (a.length > MAX_LINES || b.length > MAX_LINES) return coarseDiff(a, b); + const ops = lcsOps(a, b); + return buildHunks(ops, context); +} +function splitLines(s) { + s = String(s); + if (!s) return []; + return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); +} +function lcsOps(a, b) { + const n = a.length; + const m = b.length; + const dp = new Uint32Array((n + 1) * (m + 1)); + for (let i2 = n - 1; i2 >= 0; i2--) + for (let j2 = m - 1; j2 >= 0; j2--) + dp[i2 * (m + 1) + j2] = a[i2] === b[j2] ? dp[(i2 + 1) * (m + 1) + j2 + 1] + 1 : Math.max(dp[(i2 + 1) * (m + 1) + j2], dp[i2 * (m + 1) + j2 + 1]); + const ops = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + ops.push({ t: " ", s: a[i] }); + i++; + j++; + } else if (dp[(i + 1) * (m + 1) + j] >= dp[i * (m + 1) + j + 1]) { + ops.push({ t: "-", s: a[i] }); + i++; + } else { + ops.push({ t: "+", s: b[j] }); + j++; + } + } + while (i < n) ops.push({ t: "-", s: a[i++] }); + while (j < m) ops.push({ t: "+", s: b[j++] }); + return ops; +} +function buildHunks(ops, context) { + const changed = []; + ops.forEach((op, i) => { + if (op.t !== " ") changed.push(i); + }); + if (!changed.length) return { hunks: [], added: 0, removed: 0 }; + let added = 0; + let removed = 0; + for (const op of ops) { + if (op.t === "+") added++; + else if (op.t === "-") removed++; + } + const ranges = []; + for (const idx of changed) { + const s = Math.max(0, idx - context); + const e = Math.min(ops.length, idx + context + 1); + const last = ranges[ranges.length - 1]; + if (last && s <= last.e + MERGE_GAP) last.e = Math.max(last.e, e); + else ranges.push({ s, e }); + } + const hunks = []; + let oldLn = 1; + let newLn = 1; + const oldNos = []; + const newNos = []; + for (const op of ops) { + oldNos.push(oldLn); + newNos.push(newLn); + if (op.t !== "+") oldLn++; + if (op.t !== "-") newLn++; + } + for (const r of ranges) { + hunks.push({ + oldStart: oldNos[r.s], + newStart: newNos[r.s], + lines: ops.slice(r.s, r.e).map((op) => ({ t: op.t, s: op.s })) + }); + } + return { hunks, added, removed }; +} +function coarseDiff(a, b) { + return { + hunks: [ + { + oldStart: 1, + newStart: 1, + lines: [...a.map((s) => ({ t: "-", s })), ...b.map((s) => ({ t: "+", s }))].slice(0, 2e3) + } + ], + added: b.length, + removed: a.length, + truncated: true + }; +} +function editDetail(detail = {}) { + if (Array.isArray(detail.edits) && detail.edits.length) { + const hunks = []; + let added = 0; + let removed = 0; + for (const e of detail.edits) { + const d = diffLines(e.old_string ?? e.old ?? "", e.new_string ?? e.new ?? ""); + for (const h of d.hunks) hunks.push(h); + added += d.added; + removed += d.removed; + } + return { path: detail.path, hunks, added, removed }; + } + if (detail.old !== void 0 || detail.new !== void 0) { + const d = diffLines(String(detail.old ?? ""), String(detail.new ?? "")); + return { path: detail.path, hunks: d.hunks, added: d.added, removed: d.removed }; + } + return detail; +} +var CONTEXT, MERGE_GAP, MAX_LINES; +var init_diff = __esm({ + "daemon/src/agents/diff.ts"() { + CONTEXT = 3; + MERGE_GAP = 6; + MAX_LINES = 400; + } +}); + +// daemon/src/providers/slash.ts +function classifyClaudeSlashCommand(name) { + return CLAUDE_BUILTIN_COMMANDS.has(name) ? "command" : "skill"; +} +function parseSlashInput(text) { + const t = text.trim(); + if (!t.startsWith("/")) return null; + const body = t.slice(1).trim(); + if (!body) return null; + const sp = body.indexOf(" "); + if (sp === -1) return { name: body, args: "", raw: t }; + return { name: body.slice(0, sp), args: body.slice(sp + 1).trim(), raw: t }; +} +function mapAcpAvailableCommands(cmds) { + return cmds.map((c) => ({ + name: c.name, + description: c.description, + argumentHint: c.input?.hint, + kind: "skill" + })); +} +function sortSlashCommands(list) { + return [...list].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "command" ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} +var CLAUDE_BUILTIN_COMMANDS; +var init_slash = __esm({ + "daemon/src/providers/slash.ts"() { + CLAUDE_BUILTIN_COMMANDS = /* @__PURE__ */ new Set(["clear", "compact", "context", "usage", "init", "debug"]); + } +}); + +// daemon/src/providers/mock.ts +async function scriptDefault(ctx) { + const { emit } = ctx; + const sleepA = (ms) => sleepAbortable(ms, ctx); + emit({ type: "reasoning_delta", itemId: "r1", text: "\u8BA9\u6211\u770B\u770B\u9879\u76EE\u7ED3\u6784\u2026" }); + await sleepA(500); + emit({ type: "reasoning", itemId: "r1", text: "\u8BA9\u6211\u770B\u770B\u9879\u76EE\u7ED3\u6784\u2026\u9700\u8981\u5148\u8BFB\u51E0\u4E2A\u6587\u4EF6\u3002" }); + const tools = [ + { id: "t1", name: "Read", input: { file_path: ctx.input.text.includes("abs") ? "D:/x/a.ts" : "src/index.ts" } }, + { id: "t2", name: "Read", input: { file_path: "package.json" } } + ]; + for (const t of tools) { + emit({ type: "tool_call", itemId: t.id, kind: "read", title: `\u8BFB\u53D6 ${t.input.file_path}`, status: "running", input: t.input }); + if (await sleepA(300)) return; + emit({ type: "tool_call", itemId: t.id, kind: "read", title: `\u8BFB\u53D6 ${t.input.file_path}`, status: "ok", output: "\u2026\u6587\u4EF6\u5185\u5BB9\u2026", durationMs: 300 }); + } + emit({ type: "tool_call", itemId: "t3", kind: "search", title: '\u641C\u7D22 "TODO"', status: "running", input: { pattern: "TODO" } }); + await sleepA(200); + emit({ type: "tool_call", itemId: "t3", kind: "search", title: '\u641C\u7D22 "TODO"', status: "ok", output: "3 \u5904\u5339\u914D" }); + const oldStr = "const x = 1;\nconst y = 2;\nreturn x + y;"; + const newStr = "const x = 1;\nconst y = 2;\nconst z = 3;\nreturn x + y + z;"; + emit({ + type: "tool_call", + itemId: "t4", + kind: "edit", + title: "\u7F16\u8F91 src/index.ts", + status: "running", + input: { file_path: "src/index.ts", old_string: oldStr, new_string: newStr }, + detail: editDetail({ path: "src/index.ts", old: oldStr, new: newStr }) + }); + if (await sleepA(400)) return; + emit({ + type: "tool_call", + itemId: "t4", + kind: "edit", + title: "\u7F16\u8F91 src/index.ts", + status: "ok", + detail: editDetail({ path: "src/index.ts", old: oldStr, new: newStr }), + durationMs: 400 + }); + emit({ type: "tool_call", itemId: "t5", kind: "bash", title: "\u8FD0\u884C npm test", status: "running", input: { command: "npm test" } }); + for (let i = 1; i <= 20; i++) { + if (await sleepA(60)) return; + emit({ type: "tool_output_delta", itemId: "t5", text: `\u7B2C ${i} \u884C\u8F93\u51FA +` }); + } + emit({ type: "tool_call", itemId: "t5", kind: "bash", title: "\u8FD0\u884C npm test", status: "ok", output: "\u2713 \u5168\u90E8\u901A\u8FC7", exitCode: 0, durationMs: 1200 }); + emit({ + type: "todo", + items: [ + { text: "\u5206\u6790\u9700\u6C42", status: "done" }, + { text: "\u4FEE\u6539\u4EE3\u7801", status: "done" }, + { text: "\u8DD1\u6D4B\u8BD5", status: "done" }, + { text: "\u5199\u6587\u6863", status: "in_progress" } + ] + }); + await sleepA(300); + const reply = ctx.replyPrefix() + mockReply(ctx); + for (const ch of chunkText(reply, 40)) { + if (await sleepA(30)) return; + emit({ type: "assistant_delta", itemId: "m1", text: ch }); + } + emit({ type: "assistant_message", itemId: "m1", text: reply }); +} +async function scriptPerm(ctx) { + const { emit } = ctx; + const requestId = "perm-" + rid(); + emit({ + type: "permission_request", + requestId, + title: "\u8FD0\u884C npm test", + kind: "tool", + detail: { command: "npm test", cwd: ".", toolName: "Bash" }, + options: [ + { id: "accept", label: "\u5141\u8BB8", kind: "allow_once" }, + { id: "acceptForSession", label: "\u672C\u5BF9\u8BDD\u90FD\u5141\u8BB8", kind: "allow_always" }, + { id: "decline", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const ans = await ctx.waitFor(requestId); + if (ans.cancelled || ans.optionId === "decline") { + emit({ type: "tool_call", itemId: "p1", kind: "bash", title: "\u8FD0\u884C npm test", status: "failed", output: "\u5DF2\u88AB\u7528\u6237\u62D2\u7EDD", exitCode: 1 }); + emit({ type: "assistant_message", itemId: "pm", text: "\u5DF2\u6309\u4F60\u7684\u8981\u6C42\u8DF3\u8FC7\u8BE5\u64CD\u4F5C\u3002" }); + return; + } + emit({ type: "tool_call", itemId: "p1", kind: "bash", title: "\u8FD0\u884C npm test", status: "running", input: { command: "npm test" } }); + if (await sleepAbortable(500, ctx)) return; + emit({ type: "tool_call", itemId: "p1", kind: "bash", title: "\u8FD0\u884C npm test", status: "ok", output: "\u2713 12 \u4E2A\u6D4B\u8BD5\u901A\u8FC7", exitCode: 0, durationMs: 500 }); + emit({ type: "assistant_message", itemId: "pm", text: "\u6D4B\u8BD5\u901A\u8FC7\u4E86\u3002" }); +} +async function scriptPlan(ctx) { + const { emit } = ctx; + emit({ + type: "plan", + itemId: "plan1", + markdown: "## \u5B9E\u65BD\u8BA1\u5212\n\n1. \u6539 `src/index.ts` \u5165\u53E3\n2. \u66F4\u65B0\u6D4B\u8BD5\n3. \u8DD1\u4E00\u904D e2e", + entries: [ + { step: "\u6539 src/index.ts \u5165\u53E3", status: "pending" }, + { step: "\u66F4\u65B0\u6D4B\u8BD5", status: "pending" }, + { step: "\u8DD1\u4E00\u904D e2e", status: "pending" } + ] + }); + const requestId = "perm-" + rid(); + emit({ + type: "permission_request", + requestId, + title: "\u5B9E\u65BD\u8FD9\u4E2A\u8BA1\u5212\uFF1F", + kind: "plan", + detail: { markdown: "\u89C1\u4E0A\u65B9\u8BA1\u5212" }, + options: [ + { id: "implement", label: "\u5F00\u59CB\u5B9E\u65BD", kind: "allow_once" }, + { id: "reject", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const ans = await ctx.waitFor(requestId); + if (ans.cancelled || ans.optionId === "reject") { + emit({ type: "assistant_message", itemId: "pm", text: "\u597D\u7684\uFF0C\u8BF7\u544A\u8BC9\u6211\u600E\u4E48\u6539\u3002" }); + return; + } + emit({ type: "tool_call", itemId: "pl1", kind: "edit", title: "\u7F16\u8F91 src/index.ts", status: "ok" }); + await sleepAbortable(400, ctx); + emit({ type: "assistant_message", itemId: "pm", text: "\u5DF2\u6309\u8BA1\u5212\u6539\u5B8C\u5165\u53E3\u548C\u6D4B\u8BD5\u3002" }); +} +async function scriptAsk(ctx) { + const { emit } = ctx; + const requestId = "q-" + rid(); + emit({ + type: "question", + requestId, + questions: [ + { + id: "q1", + header: "\u90E8\u7F72\u76EE\u6807", + question: "\u8981\u90E8\u7F72\u5230\u54EA\u4E2A\u73AF\u5883\uFF1F", + options: [ + { id: "staging", label: "Staging" }, + { id: "prod", label: "\u751F\u4EA7\u73AF\u5883" } + ] + }, + { + id: "q2", + header: "\u5907\u6CE8", + question: "\u8FD8\u6709\u4EC0\u4E48\u8981\u8865\u5145\u7684\uFF1F", + freeText: true + } + ] + }); + const ans = await ctx.waitFor(requestId); + const env = ans.answers?.q1 ?? "\uFF08\u672A\u9009\uFF09"; + const note = ans.answers?.q2 ?? ""; + emit({ type: "assistant_message", itemId: "am", text: `\u6536\u5230\uFF1A\u90E8\u7F72\u5230 ${env}${note ? `\uFF0C\u5907\u6CE8\u300C${note}\u300D` : ""}\u3002` }); +} +async function scriptSlow(ctx) { + const { emit } = ctx; + for (let i = 1; i <= 6; i++) { + const id = "s" + i; + emit({ type: "tool_call", itemId: id, kind: "bash", title: `\u6B65\u9AA4 ${i}/6`, status: "running", input: { command: `step ${i}` } }); + if (await sleepAbortable(2e3, ctx)) return; + emit({ type: "tool_call", itemId: id, kind: "bash", title: `\u6B65\u9AA4 ${i}/6`, status: "ok", output: `\u6B65\u9AA4 ${i} \u5B8C\u6210`, durationMs: 2e3 }); + } + emit({ type: "assistant_message", itemId: "sm", text: "6 \u4E2A\u6B65\u9AA4\u5168\u90E8\u8DD1\u5B8C\u3002" }); +} +async function scriptError(ctx) { + const { emit } = ctx; + emit({ type: "tool_call", itemId: "e1", kind: "bash", title: "\u8FD0\u884C ./build.sh", status: "running", input: { command: "./build.sh" } }); + await sleepAbortable(800, ctx); + throw new Error("\u6784\u5EFA\u5931\u8D25\uFF1A\u627E\u4E0D\u5230\u6A21\u5757 foo\uFF08\u6A21\u62DF\u9519\u8BEF\uFF09"); +} +async function scriptLong(ctx) { + const { emit } = ctx; + emit({ type: "assistant_delta", itemId: "L1", text: "" }); + for (let i = 0; i < 5e3; i++) { + if (i % 100 === 0 && ctx.interrupted()) return; + emit({ type: "assistant_delta", itemId: "L1", text: `\u5757${i} ` }); + if (i % 200 === 0) await sleep(0); + } + emit({ type: "assistant_message", itemId: "L1", text: "\uFF085000 \u4E2A\u589E\u91CF\u5408\u5E76\u540E\u7684\u957F\u6587\uFF09" }); +} +async function scriptExit(_ctx) { + await sleep(300); + throw new MockCrash(); +} +function pickScript(text) { + const t = text.trim(); + if (t.startsWith("/perm")) return scriptPerm; + if (t.startsWith("/plan")) return scriptPlan; + if (t.startsWith("/ask")) return scriptAsk; + if (t.startsWith("/slow")) return scriptSlow; + if (t.startsWith("/error")) return scriptError; + if (t.startsWith("/long")) return scriptLong; + if (t.startsWith("/exit")) return scriptExit; + return scriptDefault; +} +async function sleepAbortable(ms, ctx) { + const start = Date.now(); + while (Date.now() - start < ms) { + if (ctx.interrupted()) return true; + await sleep(Math.min(20, Math.max(1, ms - (Date.now() - start)))); + } + return ctx.interrupted(); +} +function chunkText(s, n) { + const out = []; + for (let i = 0; i < s.length; i += n) out.push(s.slice(i, i + n)); + return out; +} +function mockReply(ctx) { + const base = [ + "\u597D\u7684\uFF0C\u6211\u5DF2\u7ECF\u6309\u4F60\u7684\u8981\u6C42\u6539\u597D\u4E86\u3002", + "", + "## \u6539\u52A8", + "- `src/index.ts`\uFF1A\u628A\u8FD4\u56DE\u903B\u8F91\u8865\u6210\u4E09\u53D8\u91CF\u6C42\u548C", + "- \u65B0\u589E\u4E00\u884C\u5E38\u91CF `z`", + "", + "```ts", + "const z = 3;", + "return x + y + z;", + "```", + "", + "| \u6587\u4EF6 | \u53D8\u66F4 |", + "|---|---|", + "| src/index.ts | +2/-1 |", + "", + "\u6D4B\u8BD5\u5168\u90E8\u901A\u8FC7\u3002" + ].join("\n"); + return ctx.steerText() ? base + ` + +> \u63D2\u8BDD\uFF1A${ctx.steerText()}` : base; +} +function createMockProvider() { + return { + id: "mock", + name: "Mock\uFF08\u6F14\u793A\uFF09", + capabilities: mockCapabilities, + async detect() { + return { available: true, bin: "mock", version: "demo" }; + }, + async listModels() { + return [ + { id: "demo-fast", name: "\u6F14\u793A\xB7\u5FEB", isDefault: true, efforts: ["low", "medium", "high"] }, + { id: "demo-pro", name: "\u6F14\u793A\xB7\u5F3A", efforts: ["low", "medium", "high"] } + ]; + }, + async createSession(o) { + mockProviderMetrics.createSessionCalls++; + return new MockSession(o); + } + }; +} +var mockCapabilities, rid, sleep, MockCrash, MockSession, mockProviderMetrics; +var init_mock = __esm({ + "daemon/src/providers/mock.ts"() { + init_queue(); + init_diff(); + init_slash(); + mockCapabilities = { + resume: true, + interrupt: true, + steer: true, + images: true, + plan: true, + questions: true, + cost: true, + effort: ["low", "medium", "high"], + modes: ["plan", "default", "acceptEdits", "bypassPermissions"] + }; + rid = () => Math.random().toString(36).slice(2, 10); + sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + MockCrash = class extends Error { + constructor() { + super("__mock_crash__"); + } + }; + MockSession = class { + constructor(o) { + this.o = o; + this.sessionId = o.resumeId ?? "mock-" + rid(); + this.resumed = o.resumeId !== null; + this.events = this.q; + this.q.push({ type: "provider_session_id", id: this.sessionId }); + } + o; + events; + q = new AsyncQueue(); + sessionId; + turnId = null; + turnNum = 0; + interruptFlag = false; + closed = false; + pendingReq = /* @__PURE__ */ new Map(); + steered = []; + resumed = false; + emit(ev) { + ev.turnId ??= this.turnId ?? void 0; + this.q.push(ev); + } + async send(input) { + if (this.closed) throw new Error("mock session closed"); + this.turnNum++; + this.turnId = "u_" + Date.now().toString(36) + "_" + this.turnNum; + this.interruptFlag = false; + this.steered = []; + this.emit({ + type: "turn_started", + turnId: this.turnId, + model: this.o.model ?? "demo-fast", + mode: this.o.mode, + effort: this.o.effort + }); + void this.runTurn(input); + } + async runTurn(input) { + const p = "i" + rid() + "_"; + const ctx = { + emit: (ev) => { + const e = ev; + if (e.itemId) e.itemId = p + e.itemId; + this.emit(ev); + }, + waitFor: (id) => this.waitFor(id), + interrupted: () => this.interruptFlag || this.closed, + steerText: () => this.steered.join("\uFF1B"), + replyPrefix: () => this.resumePrefix(), + input + }; + try { + const script = pickScript(input.text); + await script(ctx); + if (this.interruptFlag || this.closed) { + this.emit({ type: "turn_ended", turnId: this.turnId, status: "interrupted", durationMs: 0 }); + } else { + this.emit({ type: "usage", inputTokens: 1200, outputTokens: 340, cacheReadTokens: 800, costUsd: 0.0123 }); + this.emit({ type: "turn_ended", turnId: this.turnId, status: "completed", durationMs: 6e3, filesChanged: 1 }); + } + } catch (e) { + if (e instanceof MockCrash) { + this.q.close(); + return; + } + if (this.interruptFlag || this.closed) { + this.emit({ type: "turn_ended", turnId: this.turnId, status: "interrupted", durationMs: 0 }); + } else { + this.emit({ type: "turn_ended", turnId: this.turnId, status: "failed", error: String(e?.message ?? e) }); + } + } + } + respond(requestId, answer) { + const resolve2 = this.pendingReq.get(requestId); + if (resolve2) { + this.pendingReq.delete(requestId); + resolve2(answer); + } + } + waitFor(requestId) { + return new Promise((resolve2) => { + this.pendingReq.set(requestId, resolve2); + }); + } + async interrupt() { + this.interruptFlag = true; + for (const [id, resolve2] of this.pendingReq) { + resolve2({ cancelled: true }); + this.pendingReq.delete(id); + } + await sleep(120); + } + async steer(input) { + this.steered.push(input.text); + } + async setModel(model) { + this.o.model = model; + } + async setMode(mode) { + this.o.mode = mode; + } + async setEffort(effort) { + this.o.effort = effort; + } + async listCommands() { + return sortSlashCommands([ + { name: "perm", description: "\u6F14\u793A\u6388\u6743\u5F39\u7A97", kind: "command" }, + { name: "plan", description: "\u6F14\u793A\u8BA1\u5212\u5BA1\u6279", kind: "command" }, + { name: "ask", description: "\u6F14\u793A\u63D0\u95EE", kind: "command" }, + { name: "slow", description: "\u6162\u901F\u591A\u6B65\u5DE5\u5177", kind: "skill" }, + { name: "error", description: "\u6A21\u62DF\u5931\u8D25", kind: "skill" } + ]); + } + async close() { + this.closed = true; + for (const [, resolve2] of this.pendingReq) resolve2({ cancelled: true }); + this.pendingReq.clear(); + this.q.close(); + } + // With resumeId the first reply gets a "(已续接)" prefix + resumePrefix() { + if (this.resumed && this.turnNum === 1) return "\uFF08\u5DF2\u7EED\u63A5\uFF09"; + return ""; + } + }; + mockProviderMetrics = { createSessionCalls: 0 }; + } +}); + +// lib/pathscan.mjs +import { accessSync, constants, statSync as statSync2 } from "node:fs"; +import { homedir as homedir2 } from "node:os"; +import path from "node:path"; +function defaultExists(file, platform) { + try { + if (!statSync2(file).isFile()) return false; + if (platform !== "win32") accessSync(file, constants.X_OK); + return true; + } catch { + return false; + } +} +function envGet(env, name) { + if (env[name] !== void 0) return env[name]; + const lower = name.toLowerCase(); + for (const key of Object.keys(env)) if (key.toLowerCase() === lower) return env[key]; + return void 0; +} +function extraBinDirs({ platform = process.platform, env = process.env, home = homedir2() } = {}) { + const p = platform === "win32" ? path.win32 : path.posix; + const dirs = []; + if (platform === "win32") { + const appData = envGet(env, "APPDATA"); + const localAppData = envGet(env, "LOCALAPPDATA"); + const programFiles = envGet(env, "ProgramFiles") || "C:\\Program Files"; + const nvmSymlink = envGet(env, "NVM_SYMLINK"); + if (home) dirs.push(p.join(home, ".local", "bin")); + if (appData) dirs.push(p.join(appData, "npm")); + if (nvmSymlink) dirs.push(nvmSymlink); + dirs.push(p.join(programFiles, "nodejs")); + if (localAppData) dirs.push(p.join(localAppData, "Programs", "nodejs")); + if (home) { + dirs.push(p.join(home, ".volta", "bin")); + dirs.push(p.join(home, ".bun", "bin")); + } + return dirs; + } + if (home) { + dirs.push(p.join(home, ".local", "bin")); + dirs.push(p.join(home, ".npm-global", "bin")); + dirs.push(p.join(home, ".volta", "bin")); + dirs.push(p.join(home, ".bun", "bin")); + dirs.push(p.join(home, ".claude", "local")); + } + if (platform === "darwin") dirs.push("/opt/homebrew/bin"); + dirs.push("/usr/local/bin", "/usr/bin", "/bin"); + return dirs; +} +function searchDirs({ platform = process.platform, env = process.env, home = homedir2(), extra = true } = {}) { + const sep4 = platform === "win32" ? ";" : ":"; + const raw = String(envGet(env, "PATH") ?? "").split(sep4).map((d) => d.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean); + const all = extra ? [...raw, ...extraBinDirs({ platform, env, home })] : raw; + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const d of all) { + const key = platform === "win32" ? d.toLowerCase().replace(/[\\/]+$/, "") : d.replace(/\/+$/, ""); + if (seen.has(key)) continue; + seen.add(key); + out.push(d); + } + return out; +} +function windowsExts(env) { + const list = String(envGet(env, "PATHEXT") || ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim().toLowerCase()).filter((e) => e.startsWith(".")); + if (!list.includes(".ps1")) list.push(".ps1"); + return list; +} +function findOnPath(name, { platform = process.platform, env = process.env, home = homedir2(), extra = true, exists } = {}) { + if (!name) return []; + const p = platform === "win32" ? path.win32 : path.posix; + const check = exists ?? ((file) => defaultExists(file, platform)); + if (name.includes("/") || platform === "win32" && name.includes("\\")) { + return check(name) ? [name] : []; + } + let names = [name]; + if (platform === "win32") { + const exts = windowsExts(env); + const ext = p.extname(name).toLowerCase(); + names = ext && exts.includes(ext) ? [name] : exts.map((e) => name + e); + } + const hits = []; + for (const dir of searchDirs({ platform, env, home, extra })) { + for (const n of names) { + const file = p.join(dir, n); + if (check(file) && !hits.includes(file)) hits.push(file); + } + } + return hits; +} +function findNode(opts = {}) { + return findOnPath("node", opts).find((file) => isNodeExecutable(file)) ?? null; +} +function isNodeExecutable(file) { + return /^node(\.exe)?$/i.test(String(file || "").split(/[\\/]/).pop() ?? ""); +} +var init_pathscan = __esm({ + "lib/pathscan.mjs"() { + } +}); + +// daemon/src/util/bin.ts +import { execFileSync, spawn } from "node:child_process"; +import { readFile as readFile3 } from "node:fs/promises"; +import { existsSync as existsSync4 } from "node:fs"; +import path2 from "node:path"; +function which(name) { + return findOnPath(name); +} +function escapeCmdCommand(cmd) { + return cmd.replace(CMD_META, "^$1"); +} +function escapeCmdArgument(arg, doubleEscape) { + let a = String(arg); + a = a.replace(/(\\*)"/g, '$1$1\\"'); + a = a.replace(/(\\*)$/, "$1$1"); + a = `"${a}"`; + a = a.replace(CMD_META, "^$1"); + if (doubleEscape) a = a.replace(CMD_META, "^$1"); + return a; +} +function argvFor(r, args) { + if (r.via === "cmd-shim" && r.shim) { + const line = [escapeCmdCommand(r.shim), ...args.map((a) => escapeCmdArgument(a, true))].join(" "); + return { cmd: r.cmd, args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true }; + } + return { cmd: r.cmd, args: [...r.prefixArgs, ...args] }; +} +function reapOnExit() { + for (const pid of liveTrees) { + try { + if (IS_WIN) execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 3e3 }); + else process.kill(-pid, "SIGKILL"); + } catch { + } + } + liveTrees.clear(); +} +function trackTree(child) { + if (child.pid == null) return; + const pid = child.pid; + liveTrees.add(pid); + child.once("exit", () => liveTrees.delete(pid)); + if (!exitHookInstalled) { + exitHookInstalled = true; + process.once("exit", reapOnExit); + } +} +function spawnBin(r, args, opts = {}) { + const a = argvFor(r, args); + const { group, ...rest } = opts; + const child = spawn(a.cmd, a.args, { + windowsHide: true, + ...rest, + ...a.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}, + ...group && !IS_WIN ? { detached: true } : {} + }); + if (group) trackTree(child); + return child; +} +function shimTarget(cmdText, cmdDir, deps = {}) { + const exists = deps.exists ?? existsSync4; + const P = deps.win ?? IS_WIN ? path2.win32 : path2; + const cands = []; + const re = /"([^"]+?\.(?:js|mjs|cjs|exe|cmd|bat))"/gi; + let m; + while (m = re.exec(cmdText)) cands.push(m[1]); + const re2 = /(?:%~?dp0%?)?[A-Za-z]:?[\\/\w.@-]*?\.(?:js|mjs|cjs|exe)\b/gi; + while (m = re2.exec(cmdText)) cands.push(m[0]); + const found = []; + for (const raw of cands) { + let p = raw.replace(/%~?dp0%?/gi, ""); + p = p.replace(/%[A-Z_]+%/gi, ""); + p = p.trim().replace(/^[\\/]+/, ""); + if (!p || /%\w+%/.test(p) || !/\.(js|mjs|cjs|exe)$/i.test(p)) continue; + if (!P.isAbsolute(p)) { + if (!cmdDir) continue; + p = P.join(cmdDir, p); + } + if (exists(p)) found.push(P.resolve(p)); + } + return found.find((f) => /\.(js|mjs|cjs)$/i.test(f)) ?? found.find((f) => /\.exe$/i.test(f) && !isNodeExecutable(P.basename(f))) ?? null; +} +function findNode2() { + if (isNodeExecutable(process.execPath)) return process.execPath; + return findNode() ?? "node"; +} +async function resolveBin(name) { + const hit = cache.get(name); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.out; + const out = await resolveBinUncached(name); + cache.set(name, { out, at: Date.now() }); + return out; +} +async function resolveFirstBin(names) { + const uniq = [...new Set(names.filter(Boolean))]; + for (const binName of uniq) { + const r = await resolveBin(binName); + if (r.ok) return { ...r, binName }; + } + const label = uniq[0] ?? "cli"; + return { ok: false, reason: `PATH \u91CC\u627E\u4E0D\u5230 ${uniq.length > 1 ? uniq.join(" \u6216 ") : label}` }; +} +function clearBinCache(name) { + if (name) cache.delete(name); + else cache.clear(); +} +function resolveBinUncached(name) { + return resolveBinWith(name); +} +async function resolveBinWith(name, deps = {}) { + const platform = deps.platform ?? process.platform; + const win = platform === "win32"; + const P = win ? path2.win32 : path2.posix; + const exists = deps.exists ?? existsSync4; + const hits = (deps.find ?? which)(name); + if (!hits.length) return { ok: false, reason: `PATH \u91CC\u627E\u4E0D\u5230 ${name}` }; + const first = hits[0]; + if (!win) return { ok: true, cmd: first, prefixArgs: [], display: first, via: "path" }; + const ext = P.extname(first).toLowerCase(); + if (ext === ".exe" || ext === ".com") return { ok: true, cmd: first, prefixArgs: [], display: first, via: "exe" }; + if (ext === ".ps1") { + return { + ok: true, + cmd: "powershell.exe", + prefixArgs: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", first], + display: first, + via: "ps1" + }; + } + const dir = P.dirname(first); + if (name.toLowerCase() === "npx") { + const node = P.join(dir, "node.exe"); + const cli = P.join(dir, "node_modules", "npm", "bin", "npx-cli.js"); + if (exists(node) && exists(cli)) + return { ok: true, cmd: node, prefixArgs: [cli], display: `${node} ${cli}`, via: "npx-shim" }; + } + const text = await (deps.readText ?? ((f) => readFile3(f, "utf8")))(first).catch(() => ""); + const target = shimTarget(text, dir, { exists, win: true }); + if (target && /\.(js|mjs|cjs)$/i.test(target)) { + const node = (deps.node ?? findNode2)(); + return { ok: true, cmd: node, prefixArgs: [target], display: `${node} ${target}`, via: "shim-js" }; + } + if (target && /\.exe$/i.test(target)) { + return { ok: true, cmd: target, prefixArgs: [], display: target, via: "shim-exe" }; + } + const comspec = deps.comspec ?? (process.env.ComSpec || "cmd.exe"); + return { ok: true, cmd: comspec, prefixArgs: [], display: first, via: "cmd-shim", shim: first }; +} +function killTree(child, { graceMs = 3e3 } = {}) { + if (!child || child.pid == null) return Promise.resolve(); + const pid = child.pid; + if (IS_WIN) { + return new Promise((resolvePromise) => { + const tk = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true }); + tk.on("error", () => { + try { + child.kill(); + } catch { + } + resolvePromise(); + }); + tk.on("close", () => resolvePromise()); + setTimeout(resolvePromise, graceMs + 2e3); + }); + } + return new Promise((resolvePromise) => { + let done = false; + const finish = () => { + if (!done) { + done = true; + resolvePromise(); + } + }; + try { + process.kill(-pid, "SIGTERM"); + } catch { + try { + child.kill("SIGTERM"); + } catch { + } + } + if (child.exitCode !== null || child.signalCode !== null) { + finish(); + return; + } + const t = setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + child.kill("SIGKILL"); + } catch { + } + } + finish(); + }, graceMs); + t.unref?.(); + child.on("close", () => { + clearTimeout(t); + finish(); + }); + }); +} +var IS_WIN, cache, CACHE_MS, CMD_META, liveTrees, exitHookInstalled; +var init_bin = __esm({ + "daemon/src/util/bin.ts"() { + init_pathscan(); + IS_WIN = process.platform === "win32"; + cache = /* @__PURE__ */ new Map(); + CACHE_MS = 60 * 1e3; + CMD_META = /([()\][%!^"`<>&|;, *?])/g; + liveTrees = /* @__PURE__ */ new Set(); + exitHookInstalled = false; + } +}); + +// daemon/src/agents/kinds.ts +function relPath(p, cwd) { + if (!p) return ""; + const s = String(p); + if (!cwd) return s; + const norm = (x) => x.replace(/\\/g, "/").replace(/\/+$/, ""); + const a = norm(s); + const b = norm(cwd); + if (a.toLowerCase().startsWith(b.toLowerCase() + "/")) return a.slice(b.length + 1); + if (a.toLowerCase() === b.toLowerCase()) return "."; + return s; +} +function callInfo(name, input = {}, cwd = "") { + const t = String(name || ""); + const f = (kind, title, detail) => ({ kind, title, detail }); + const file = String(input.file_path ?? input.path ?? input.notebook_path ?? ""); + switch (t) { + case "Read": + return f("read", `\u8BFB\u53D6 ${relPath(file, cwd)}`, { path: file, offset: input.offset, limit: input.limit }); + case "Edit": + return f("edit", `\u7F16\u8F91 ${relPath(file, cwd)}`, { path: file, old: input.old_string, new: input.new_string }); + case "MultiEdit": + return f("edit", `\u7F16\u8F91 ${relPath(file, cwd)}\uFF08${(input.edits || []).length} \u5904\uFF09`, { path: file, edits: input.edits ?? [] }); + case "Write": + return f("write", `${input._exists ? "\u8986\u76D6" : "\u65B0\u5EFA"} ${relPath(file, cwd)}`, { path: file, content: input.content }); + case "NotebookEdit": + return f("edit", `\u7F16\u8F91\u7B14\u8BB0\u672C ${relPath(String(input.notebook_path || file), cwd)}`, { path: input.notebook_path ?? file }); + case "Bash": + return f("bash", `\u8FD0\u884C ${trunc(String(input.command ?? ""))}`, { command: input.command, description: input.description }); + case "BashOutput": + return f("bash", `\u67E5\u770B\u8F93\u51FA ${String(input.bash_id ?? "")}`, { bashId: input.bash_id }); + case "KillShell": + return f("bash", `\u7ED3\u675F\u8FDB\u7A0B ${String(input.shell_id ?? "")}`, { shellId: input.shell_id }); + case "Grep": + return f("search", `\u641C\u7D22 "${trunc(String(input.pattern ?? ""), 40)}"`, { pattern: input.pattern, path: input.path, glob: input.glob }); + case "Glob": + case "LS": + return f("glob", `\u67E5\u627E ${trunc(String(input.pattern ?? input.path ?? ""), 40)}`, { pattern: input.pattern, path: input.path }); + case "WebFetch": + return f("web", `\u6253\u5F00 ${hostOf(String(input.url ?? ""))}`, { url: input.url }); + case "WebSearch": + return f("web", `\u641C\u7D22\u7F51\u9875 "${trunc(String(input.query ?? ""), 40)}"`, { query: input.query }); + case "TodoWrite": { + const items = todoItems(input.todos ?? []); + const done = items.filter((x) => x.status === "done").length; + return f("todo", `\u66F4\u65B0\u5F85\u529E\uFF08${done}/${items.length}\uFF09`, { items }); + } + case "Task": + case "Agent": + return f("task", `\u5B50\u4EFB\u52A1\uFF1A${trunc(String(input.description ?? input.prompt ?? ""), 40)}`, { description: input.description }); + default: + break; + } + const m = /^mcp__([^_]+)__(.+)$/.exec(t); + if (m) return f("mcp", `${m[1]} \xB7 ${m[2]}`, { server: m[1], tool: m[2] }); + return f("other", t || "\u5DE5\u5177", {}); +} +function hostOf(url) { + try { + return new URL(url).host; + } catch { + return String(url || "").slice(0, 40); + } +} +function todoItems(todos = []) { + return todos.map((x) => ({ + text: x.content || x.text || "", + status: x.status === "completed" ? "done" : x.status === "in_progress" ? "in_progress" : "pending" + })); +} +var MAX_CMD, trunc; +var init_kinds = __esm({ + "daemon/src/agents/kinds.ts"() { + MAX_CMD = 80; + trunc = (s, n = MAX_CMD) => s.length > n ? s.slice(0, n - 1) + "\u2026" : s; + } +}); + +// daemon/src/providers/claude/map.ts +function newMapState() { + return { blocks: /* @__PURE__ */ new Map(), msgId: "", itemIdByBlock: /* @__PURE__ */ new Map(), typeSeq: /* @__PURE__ */ new Map(), sawInterrupt: false }; +} +function itemIdFor(st, blockIdx, blkType) { + const k = blockKind(blkType); + const n = st.typeSeq.get(k) ?? 0; + st.typeSeq.set(k, n + 1); + const id = `${st.msgId}:${k}${n}`; + st.itemIdByBlock.set(blockIdx, id); + return id; +} +function mapSdkMessage(m, st, cwd) { + const out = []; + if (!m || typeof m !== "object") return out; + const r = m; + if (r.type === "system") { + if (r.subtype === "init") { + const sid = str(r.session_id) ?? ""; + if (sid) out.push({ type: "provider_session_id", id: sid }); + } + if (r.subtype === "api_retry") { + out.push({ type: "notice", level: "warn", message: `API \u91CD\u8BD5\u4E2D\uFF08\u7B2C ${r.attempt ?? "?"} \u6B21\uFF09` }); + } + if (r.subtype === "compact_boundary") { + out.push({ type: "notice", level: "info", message: "\u4E0A\u4E0B\u6587\u5DF2\u538B\u7F29" }); + } + return out; + } + if (r.type === "stream_event") { + const e = rec(m.event); + if (e.type === "message_start") { + const mid = str(rec(e.message).id); + if (mid) st.msgId = mid; + st.blocks.clear(); + st.itemIdByBlock.clear(); + st.typeSeq.clear(); + return out; + } + if (e.type === "content_block_start") { + const idx = typeof e.index === "number" ? e.index : 0; + const bt = str(rec(e.content_block).type) ?? ""; + st.blocks.set(idx, bt); + itemIdFor(st, idx, bt); + return out; + } + if (e.type === "content_block_delta") { + const idx = typeof e.index === "number" ? e.index : 0; + const d = rec(e.delta); + const itemId = st.itemIdByBlock.get(idx) ?? itemIdFor(st, idx, d.type === "thinking_delta" ? "thinking" : "text"); + if (d.type === "text_delta" && typeof d.text === "string" && d.text) { + out.push({ type: "assistant_delta", itemId, text: d.text }); + } else if (d.type === "thinking_delta") { + const t = str(d.thinking) ?? str(d.text); + if (t) out.push({ type: "reasoning_delta", itemId, text: t }); + } + } + return out; + } + if (r.type === "assistant") { + const mid = str(rec(r.message).id) ?? st.msgId ?? ""; + const content = rec(r.message).content; + const parts = Array.isArray(content) ? content : []; + const seqs = /* @__PURE__ */ new Map(); + parts.forEach((p) => { + const blk = rec(p); + const t = str(blk.type) ?? ""; + const k = blockKind(t); + const n = seqs.get(k) ?? 0; + seqs.set(k, n + 1); + const itemId = `${mid}:${k}${n}`; + if (blk.type === "text" && typeof blk.text === "string" && blk.text) { + out.push({ type: "assistant_message", itemId, text: blk.text }); + } else if (blk.type === "thinking" || blk.type === "redacted_thinking") { + const t2 = str(blk.thinking) ?? str(blk.text); + if (t2) out.push({ type: "reasoning", itemId, text: t2 }); + } else if (blk.type === "tool_use") { + out.push(...mapToolUse(blk, cwd)); + } + }); + return out; + } + if (r.type === "user") { + const content = rec(r.message).content; + const parts = Array.isArray(content) ? content : []; + for (const p of parts) { + const blk = rec(p); + if (blk.type === "tool_result") { + const output = clean(contentText(blk.content)); + out.push({ + type: "tool_call", + itemId: str(blk.tool_use_id) ?? "", + kind: "other", + title: "", + status: blk.is_error ? "failed" : "ok", + output: output.length > MAX_OUTPUT ? output.slice(0, MAX_OUTPUT) : output + }); + } + } + return out; + } + if (r.type === "result") { + const usage = rec(r.usage); + const num = (k) => typeof usage[k] === "number" ? usage[k] : 0; + out.push({ + type: "usage", + inputTokens: num("input_tokens"), + outputTokens: num("output_tokens"), + cacheReadTokens: num("cache_read_input_tokens"), + costUsd: typeof r.total_cost_usd === "number" ? r.total_cost_usd : 0 + }); + const subtype = str(r.subtype) ?? ""; + const interrupted = st.sawInterrupt || /interrupt/i.test(subtype); + const isError = r.is_error === true; + out.push({ + type: "turn_ended", + turnId: "", + // The session fills in the current turnId + status: interrupted ? "interrupted" : isError ? "failed" : "completed", + durationMs: typeof r.duration_ms === "number" ? r.duration_ms : void 0, + error: isError && !interrupted ? str(r.result) ?? subtype : void 0, + filesChanged: typeof r.num_turns === "number" ? void 0 : void 0 + }); + st.sawInterrupt = false; + return out; + } + return out; +} +function mapToolUse(blk, cwd) { + const input = rec(blk.input); + const name = str(blk.name) ?? "tool"; + const id = str(blk.id) ?? ""; + const info = callInfo(name, { ...input, _exists: true }, cwd); + let detail = info.detail; + if (info.kind === "edit" && (input.old_string !== void 0 || Array.isArray(input.edits))) { + detail = editDetail({ path: info.detail.path, old: input.old_string, new: input.new_string, edits: input.edits }); + } + if (info.kind === "write" && typeof input.content === "string") { + const lines = input.content.split("\n"); + detail = { path: info.detail.path, lines: lines.slice(0, 200), totalLines: lines.length }; + } + const out = [ + { + type: "tool_call", + itemId: id, + kind: info.kind, + title: info.title, + status: "running", + input, + detail + } + ]; + if (name === "TodoWrite") { + out.push({ type: "todo", items: todoItems(input.todos ?? []) }); + } + return out; +} +function contentText(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((p) => str(rec(p).text) ?? "").join(""); + return ""; +} +var MAX_OUTPUT, ANSI_RE, clean, blockKind, rec, str; +var init_map = __esm({ + "daemon/src/providers/claude/map.ts"() { + init_kinds(); + init_diff(); + MAX_OUTPUT = 20 * 1024; + ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g; + clean = (s) => String(s ?? "").replace(ANSI_RE, ""); + blockKind = (t) => t === "thinking" || t === "redacted_thinking" ? "k" : t === "text" ? "t" : "x"; + rec = (v) => v && typeof v === "object" ? v : {}; + str = (v) => typeof v === "string" ? v : void 0; + } +}); + +// daemon/src/util/i18n.ts +function normalizeLang(raw) { + const s = String(raw ?? "").trim().toLowerCase(); + if (!s) return "zh"; + if (s === "zh" || s.startsWith("zh-")) return "zh"; + return "en"; +} +function setAppLocale(lang) { + locale = normalizeLang(lang); +} +function tr(key, params) { + let s = dict[locale][key] ?? dict.en[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v)); + } + return s; +} +var locale, dict; +var init_i18n = __esm({ + "daemon/src/util/i18n.ts"() { + locale = "zh"; + dict = { + zh: { + "thread.defaultTitle": "\u65B0\u5BF9\u8BDD", + "notice.daemonRestart": "\u540E\u53F0\u5DF2\u91CD\u542F\uFF0C\u672C\u8F6E\u5DF2\u4E2D\u65AD", + "notice.sessionStartFailed": "\u4F1A\u8BDD\u542F\u52A8\u5931\u8D25\uFF1A{detail}", + "notice.sendFailed": "\u53D1\u9001\u5931\u8D25\uFF1A{detail}", + "notice.effortNextTurn": "\u601D\u8003\u5F3A\u5EA6\u5DF2\u4FDD\u5B58\uFF0C\u5C06\u5728\u4E0B\u4E00\u8F6E\u751F\u6548", + "notice.effortResetOnModel": "\u5F53\u524D\u6A21\u578B\u4E0D\u652F\u6301\u8BE5\u601D\u8003\u5F3A\u5EA6\uFF0C\u5DF2\u6062\u590D\u4E3A Default", + "turn.agentExit": "Agent \u8FDB\u7A0B\u610F\u5916\u9000\u51FA", + "attachment.missing": "\u9644\u4EF6\u4E0D\u5B58\u5728\u6216\u5DF2\u5931\u6548\uFF08{ref}\uFF09\uFF0C\u8BF7\u91CD\u65B0\u4E0A\u4F20", + "export.defaultModel": "\u9ED8\u8BA4" + }, + en: { + "thread.defaultTitle": "New chat", + "notice.daemonRestart": "Daemon restarted \u2014 this turn was interrupted", + "notice.sessionStartFailed": "Session start failed: {detail}", + "notice.sendFailed": "Send failed: {detail}", + "notice.effortNextTurn": "Reasoning effort saved \u2014 takes effect on the next turn", + "notice.effortResetOnModel": "This model does not support that effort level; reset to Default", + "turn.agentExit": "Agent process exited unexpectedly", + "attachment.missing": "Attachment missing or expired ({ref}) \u2014 upload again", + "export.defaultModel": "Default" + } + }; + } +}); + +// daemon/src/util/attachmentRefs.ts +var attachmentRefs_exports = {}; +__export(attachmentRefs_exports, { + bindAttachmentStore: () => bindAttachmentStore, + resolveAttachmentRefs: () => resolveAttachmentRefs, + resolveImagePath: () => resolveImagePath +}); +import { existsSync as existsSync5 } from "node:fs"; +function bindAttachmentStore(store) { + globalAttachmentStore = store; +} +async function lookupAttachmentPath(ref) { + const store = globalAttachmentStore; + if (!store || !validId(ref)) return null; + const att = await store.get(ref); + return att?.path ?? null; +} +async function resolveAttachmentRefs(store, refs) { + const out = []; + for (const a of refs) { + const att = validId(a.path) ? await store.get(a.path) : null; + if (att) { + out.push({ path: att.path, mime: att.mime || a.mime }); + continue; + } + const viaGlobal = await lookupAttachmentPath(a.path); + if (viaGlobal) { + out.push({ path: viaGlobal, mime: a.mime }); + continue; + } + if (existsSync5(a.path)) { + out.push(a); + continue; + } + throw new Error(tr("attachment.missing", { ref: a.path })); + } + return out; +} +async function resolveImagePath(ref) { + if (existsSync5(ref)) return ref; + const p = await lookupAttachmentPath(ref); + if (p) return p; + throw new Error(tr("attachment.missing", { ref })); +} +var globalAttachmentStore; +var init_attachmentRefs = __esm({ + "daemon/src/util/attachmentRefs.ts"() { + init_paths(); + init_i18n(); + globalAttachmentStore = null; + } +}); + +// daemon/src/providers/claude/session.ts +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { readFile as readFile4 } from "node:fs/promises"; +function claudeExecutable(o) { + if (!o.binCmd) return null; + if (o.binVia === "cmd-shim" || o.binVia === "ps1") return null; + const last = o.binPrefixArgs[o.binPrefixArgs.length - 1]; + if (last && /\.(c|m)?js$/i.test(last)) return { path: last, node: true }; + if (o.binPrefixArgs.length) return null; + return { path: o.binCmd, node: false }; +} +var EFFORTS, rid2, ClaudeSession; +var init_session = __esm({ + "daemon/src/providers/claude/session.ts"() { + init_queue(); + init_kinds(); + init_diff(); + init_map(); + init_log(); + init_attachmentRefs(); + init_slash(); + EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + rid2 = () => Math.random().toString(36).slice(2, 10); + ClaudeSession = class { + constructor(o) { + this.o = o; + this.sessionId = o.resumeId; + this.events = this.q; + } + o; + events; + q = new AsyncQueue(); + input = new AsyncQueue(); + query = null; + abort = new AbortController(); + sessionId; + turnId = null; + turnNum = 0; + st = newMapState(); + pending = /* @__PURE__ */ new Map(); + needsRestart = false; + restarting = null; + closed = false; + stderrTail = ""; + pumpDone = null; + // The query is only started before the first send (a failed detection should be reported quickly) + ensureQuery() { + if (this.query && !this.needsRestart) return this.query; + this.abort = new AbortController(); + this.st = newMapState(); + const options = { + cwd: this.o.cwd, + model: this.o.model ?? void 0, + permissionMode: this.o.mode, + canUseTool: (toolName, input, opts) => this.canUseTool(toolName, input, opts), + includePartialMessages: true, + settingSources: ["user", "project", "local"], + systemPrompt: { type: "preset", preset: "claude_code" }, + abortController: this.abort, + ...this.sessionId ? { resume: this.sessionId } : {}, + stderr: (d) => { + this.stderrTail = (this.stderrTail + d).slice(-4096); + } + }; + const exe = claudeExecutable(this.o); + if (exe) { + options.pathToClaudeCodeExecutable = exe.path; + if (exe.node) options.executable = "node"; + } + if (this.o.effort && EFFORTS.includes(this.o.effort)) { + options.effort = this.o.effort; + } + const q = query({ prompt: this.input, options }); + this.query = q; + this.needsRestart = false; + this.pumpDone = this.pump(q); + return q; + } + async pump(q) { + try { + for await (const m of q) { + for (const ev of mapSdkMessage(m, this.st, this.o.cwd)) this.emit(ev); + } + } catch (e) { + if (!this.abort.signal.aborted && !this.closed) { + this.emit({ type: "notice", level: "error", message: "Claude \u4F1A\u8BDD\u5F02\u5E38\uFF1A" + this.errText(e) }); + } + } + if (!this.closed && this.query === q) { + this.needsRestart = true; + this.q.close(); + } + } + emit(ev) { + if (ev.type === "turn_ended" && !ev.turnId) ev.turnId = this.turnId ?? "unknown"; + if (ev.type === "provider_session_id" && ev.id) this.sessionId = ev.id; + this.q.push(ev); + } + errText(e) { + const base = String(e?.message ?? e); + return this.stderrTail ? `${base}\uFF1Bstderr: ${this.stderrTail.slice(-400)}` : base; + } + async send(input) { + if (this.closed) throw new Error("\u4F1A\u8BDD\u5DF2\u5173\u95ED"); + if (this.needsRestart) await this.restart(); + const q = this.ensureQuery(); + this.turnNum++; + this.turnId = "u_" + Date.now().toString(36) + "_" + this.turnNum; + this.st.sawInterrupt = false; + this.emit({ + type: "turn_started", + turnId: this.turnId, + model: this.o.model, + mode: this.o.mode, + effort: this.o.effort + }); + const content = []; + const blocks = content; + for (const img of input.images ?? []) { + try { + const disk = await resolveImagePath(img.path); + const data = await readFile4(disk); + blocks.push({ + type: "image", + source: { type: "base64", media_type: img.mime, data: data.toString("base64") } + }); + } catch (e) { + this.emit({ type: "notice", level: "warn", message: `\u56FE\u7247\u8BFB\u53D6\u5931\u8D25 ${img.path}\uFF1A${String(e)}` }); + } + } + blocks.push({ type: "text", text: input.text }); + this.input.push({ + type: "user", + message: { role: "user", content }, + parent_tool_use_id: null, + session_id: this.sessionId ?? "" + }); + void q; + } + async restart() { + if (this.restarting) return this.restarting; + this.restarting = (async () => { + this.query = null; + try { + this.input.close(); + this.abort.abort(); + } catch { + } + if (this.pumpDone) await this.pumpDone.catch(() => { + }); + this.input = new AsyncQueue(); + try { + this.ensureQuery(); + } finally { + this.restarting = null; + } + })(); + return this.restarting; + } + // ---- permissions (§7.2.4) ---- + canUseTool(toolName, input, opts) { + const requestId = "perm_" + rid2(); + const { signal } = opts; + if (toolName === "ExitPlanMode") { + return this.handlePlanMode(requestId, input, signal, opts.toolUseID); + } + if (toolName === "AskUserQuestion" && Array.isArray(input.questions)) { + return this.handleQuestion(requestId, input, signal); + } + const info = callInfo(toolName, { ...input, _exists: true }, this.o.cwd); + let detail = { ...info.detail, toolName }; + if (info.kind === "edit" && (input.old_string !== void 0 || Array.isArray(input.edits))) { + detail = { ...editDetail({ path: info.detail.path, old: input.old_string, new: input.new_string, edits: input.edits }), toolName }; + } + this.emit({ + type: "permission_request", + requestId, + title: info.title, + kind: "tool", + detail, + options: [ + { id: "allow", label: "\u5141\u8BB8", kind: "allow_once" }, + { id: "allow_always", label: "\u672C\u5BF9\u8BDD\u90FD\u5141\u8BB8", kind: "allow_always" }, + { id: "deny", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + return new Promise((resolve2) => { + this.pending.set(requestId, { kind: "permission", resolve: (a) => resolve2(this.toPermissionResult(a, input)) }); + signal.addEventListener("abort", () => { + if (this.pending.delete(requestId)) { + this.emit({ type: "permission_resolved", requestId, cancelled: true }); + resolve2({ behavior: "deny", message: "\u5DF2\u53D6\u6D88", interrupt: true }); + } + }, { once: true }); + }); + } + toPermissionResult(a, input) { + if (a.cancelled || a.optionId === "deny") { + return { behavior: "deny", message: "\u7528\u6237\u62D2\u7EDD\u4E86\u6B64\u64CD\u4F5C", ...a.cancelled ? { interrupt: true } : {} }; + } + return { behavior: "allow", updatedInput: input }; + } + async handlePlanMode(requestId, input, signal, toolUseID) { + const planText = typeof input.plan === "string" ? input.plan : ""; + if (planText) { + this.emit({ type: "plan", itemId: toolUseID ?? requestId, markdown: planText }); + } + this.emit({ + type: "permission_request", + requestId, + title: "Agent \u63D0\u4EA4\u4E86\u8BA1\u5212", + kind: "plan", + detail: { plan: planText }, + options: [ + { id: "implement", label: "\u5B9E\u65BD", kind: "allow_once" }, + { id: "reject", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const answer = await new Promise((resolve2) => { + this.pending.set(requestId, { kind: "permission", resolve: (a) => resolve2(a) }); + signal.addEventListener("abort", () => { + if (this.pending.delete(requestId)) { + this.emit({ type: "permission_resolved", requestId, cancelled: true }); + resolve2({ cancelled: true }); + } + }, { once: true }); + }); + if (answer.cancelled || answer.optionId === "reject") { + return { behavior: "deny", message: "\u7528\u6237\u62D2\u7EDD\u4E86\u8BA1\u5212\uFF0C\u8BF7\u6839\u636E\u53CD\u9988\u4FEE\u6539", ...answer.cancelled ? { interrupt: true } : {} }; + } + try { + await this.query?.setPermissionMode("default"); + this.o.mode = "default"; + this.o.onModeChange?.("default"); + } catch (e) { + log("warn", "setPermissionMode(default) \u5931\u8D25", { err: String(e) }); + } + return { behavior: "allow", updatedInput: input }; + } + async handleQuestion(requestId, input, signal) { + this.emit({ type: "question", requestId, questions: input.questions }); + const answer = await new Promise((resolve2) => { + this.pending.set(requestId, { kind: "question", resolve: (a) => resolve2(a) }); + signal.addEventListener("abort", () => { + if (this.pending.delete(requestId)) { + this.emit({ type: "permission_resolved", requestId, cancelled: true }); + resolve2({ cancelled: true }); + } + }, { once: true }); + }); + if (answer.cancelled) { + return { behavior: "deny", message: "\u7528\u6237\u53D6\u6D88\u4E86\u63D0\u95EE", interrupt: true }; + } + const questions = input.questions ?? []; + const normalized = {}; + for (const ques of questions) { + const text = ques.question; + if (!text) continue; + const v = answer.answers?.[text] ?? (ques.header ? answer.answers?.[ques.header] : void 0); + if (v) normalized[text] = v; + } + return { behavior: "allow", updatedInput: { ...input, answers: normalized } }; + } + respond(requestId, answer) { + const p = this.pending.get(requestId); + if (p) { + this.pending.delete(requestId); + p.resolve(answer); + } + } + async interrupt() { + this.st.sawInterrupt = true; + for (const [id, p] of this.pending) { + this.emit({ type: "permission_resolved", requestId: id, cancelled: true }); + p.resolve({ cancelled: true }); + } + this.pending.clear(); + try { + const q = this.query; + if (q) { + await Promise.race([ + q.interrupt(), + new Promise((_, rej) => setTimeout(() => rej(new Error("interrupt \u8D85\u65F6")), 3e3)) + ]); + } + } catch (e) { + log("warn", "interrupt \u5931\u8D25", { err: String(e) }); + this.needsRestart = true; + } + } + async setModel(model) { + this.o.model = model; + try { + await this.query?.setModel(model ?? void 0); + } catch (e) { + log("warn", "setModel \u5931\u8D25", { err: String(e) }); + } + } + async setMode(mode) { + this.o.mode = mode; + try { + await this.query?.setPermissionMode(mode); + } catch (e) { + log("warn", "setPermissionMode \u5931\u8D25", { err: String(e) }); + } + } + async setEffort(effort) { + this.o.effort = effort; + this.needsRestart = true; + } + async listCommands() { + try { + const q = this.ensureQuery(); + const commands = await q.supportedCommands(); + const map = /* @__PURE__ */ new Map(); + for (const cmd of commands) { + if (!map.has(cmd.name)) { + map.set(cmd.name, { + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + kind: classifyClaudeSlashCommand(cmd.name) + }); + } + } + return sortSlashCommands([...map.values()]); + } catch { + return []; + } + } + async close() { + this.closed = true; + for (const [, p] of this.pending) p.resolve({ cancelled: true }); + this.pending.clear(); + this.input.close(); + try { + this.abort.abort(); + } catch { + } + if (this.pumpDone) { + await Promise.race([this.pumpDone.catch(() => { + }), new Promise((r) => setTimeout(r, 3e3))]); + } + this.q.close(); + } + }; + } +}); + +// daemon/src/providers/claude/models.ts +async function listClaudeModels() { + return []; +} +var init_models = __esm({ + "daemon/src/providers/claude/models.ts"() { + } +}); + +// daemon/src/history/import.ts +import { readdir, readFile as readFile5, stat as stat3 } from "node:fs/promises"; +import { existsSync as existsSync6 } from "node:fs"; +import { join as join6 } from "node:path"; +import { homedir as homedir3 } from "node:os"; +function parseLine(line) { + let r; + try { + r = rec2(JSON.parse(line)); + } catch { + return null; + } + const cwd = str2(r.cwd); + let userText; + if (r.type === "user") { + const content = rec2(r.message).content; + if (typeof content === "string") userText = content; + else if (Array.isArray(content)) { + const t = content.map((p) => rec2(p).type === "text" ? str2(rec2(p).text) : void 0).find(Boolean); + userText = t; + } + } + return { cwd, userText }; +} +async function* linesOf(path3) { + const text = await readFile5(path3, "utf8").catch(() => ""); + for (const line of text.split("\n")) { + if (line.trim()) yield line; + } +} +async function listClaudeHistory(cwd) { + const out = []; + const dirs = await readdir(CLAUDE_ROOT).catch(() => []); + for (const d of dirs) { + const dirPath = join6(CLAUDE_ROOT, d); + const files = (await readdir(dirPath).catch(() => [])).filter((f) => f.endsWith(".jsonl")); + for (const f of files) { + const path3 = join6(dirPath, f); + let match = false; + let title = ""; + let count = 0; + for await (const line of linesOf(path3)) { + const p = parseLine(line); + if (!p) continue; + if (!match) { + if (!p.cwd) continue; + match = samePath(p.cwd, cwd); + if (!match) break; + } + if (p.userText && !title) title = p.userText.slice(0, 40); + if (p.userText) count++; + } + if (!match) continue; + const s = await stat3(path3).catch(() => null); + out.push({ + sessionId: f.replace(/\.jsonl$/, ""), + title: title || "\uFF08\u65E0\u6807\u9898\uFF09", + updatedAt: s ? Math.round(s.mtimeMs) : 0, + messageCount: count + }); + } + } + out.sort((a, b) => b.updatedAt - a.updatedAt); + return out; +} +async function readClaudeHistory(sessionId, cwd) { + const dirs = await readdir(CLAUDE_ROOT).catch(() => []); + for (const d of dirs) { + const path3 = join6(CLAUDE_ROOT, d, `${sessionId}.jsonl`); + if (!existsSync6(path3)) continue; + const msgs = []; + let matched = false; + for await (const line of linesOf(path3)) { + const p = parseLine(line); + if (!matched) { + if (p?.cwd && samePath(p.cwd, cwd)) matched = true; + else continue; + } + let r; + try { + r = rec2(JSON.parse(line)); + } catch { + continue; + } + if (r.type === "user" || r.type === "assistant") msgs.push(r); + } + return msgs; + } + return []; +} +var CLAUDE_ROOT, rec2, str2; +var init_import = __esm({ + "daemon/src/history/import.ts"() { + init_paths(); + CLAUDE_ROOT = join6(homedir3(), ".claude", "projects"); + rec2 = (v) => v && typeof v === "object" ? v : {}; + str2 = (v) => typeof v === "string" ? v : void 0; + } +}); + +// daemon/src/providers/claude/provider.ts +function runVersion(r, args, timeoutMs = 5e3) { + return new Promise((resolvePromise, reject) => { + let out = ""; + const p = spawnBin(r, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" } }); + const t = setTimeout(() => { + try { + void killTree(p, { graceMs: 500 }); + } catch { + } + reject(new Error("timeout")); + }, timeoutMs); + p.stdout?.on("data", (c) => out += c.toString()); + p.stderr?.on("data", (c) => out += c.toString()); + p.on("error", reject); + p.on("close", (code) => { + clearTimeout(t); + if (code === 0) resolvePromise(out); + else reject(new Error("exit " + code)); + }); + }); +} +function createClaudeProvider() { + return { + id: "claude", + name: "Claude Code", + capabilities: claudeCapabilities, + async detect() { + const bin = await resolveBin("claude"); + if (!bin.ok) return { available: false, reason: bin.reason, installHint: INSTALL_HINT }; + try { + const version = await runVersion(bin, ["--version"]); + return { available: true, bin: bin.display, version: version.trim().split("\n")[0] || "" }; + } catch { + return { available: true, bin: bin.display, version: "" }; + } + }, + async listModels() { + return listClaudeModels(); + }, + async createSession(o) { + const bin = await resolveBin("claude"); + if (!bin.ok) throw new Error(`\u627E\u4E0D\u5230 claude\uFF1A${bin.reason}`); + return new ClaudeSession({ + threadId: o.threadId, + cwd: o.cwd, + model: o.model, + mode: o.mode, + effort: o.effort, + resumeId: o.resumeId, + binCmd: bin.cmd, + binPrefixArgs: bin.prefixArgs, + binVia: bin.via + }); + }, + // §7.6: jsonl sessions under ~/.claude/projects matched by cwd + async listHistory(cwd) { + const items = await listClaudeHistory(cwd); + return items.map((i) => ({ provider: "claude", ...i, imported: false })); + }, + async readHistory(sessionId, cwd) { + const msgs = await readClaudeHistory(sessionId, cwd); + const st = newMapState(); + const out = []; + for (const m of msgs) { + const r = m; + if (r.type === "user") { + const content = r.message?.content; + const text = typeof content === "string" ? content : Array.isArray(content) ? content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("") : ""; + if (text.trim()) out.push({ type: "user_message", text }); + else out.push(...mapSdkMessage(m, st, cwd)); + continue; + } + out.push(...mapSdkMessage(m, st, cwd)); + } + out.push({ type: "turn_ended", turnId: "", status: "completed" }); + return out; + } + }; +} +var INSTALL_HINT, claudeCapabilities; +var init_provider = __esm({ + "daemon/src/providers/claude/provider.ts"() { + init_bin(); + init_session(); + init_models(); + init_import(); + init_map(); + INSTALL_HINT = "npm i -g @anthropic-ai/claude-code"; + claudeCapabilities = { + resume: true, + interrupt: true, + steer: false, + // Claude doesn't support real steering — the manager queues instead (§7.2.2) + images: true, + plan: true, + questions: true, + cost: true, + effort: ["low", "medium", "high", "xhigh", "max"], + modes: ["plan", "default", "acceptEdits", "bypassPermissions"] + }; + } +}); + +// daemon/src/history/codex.ts +import { readdir as readdir2, readFile as readFile6, stat as stat4 } from "node:fs/promises"; +import { join as join7 } from "node:path"; +import { homedir as homedir4 } from "node:os"; +function textOf(content, kind) { + if (!Array.isArray(content)) return ""; + return content.filter((p) => p.type === kind || p.type === "input_text" || p.type === "output_text").map((p) => str3(p.text) ?? "").join("\n").trim(); +} +async function* walk(dir, depth = 0) { + if (depth > 6) return; + for (const name of await readdir2(dir).catch(() => [])) { + const p = join7(dir, name); + const s = await stat4(p).catch(() => null); + if (!s) continue; + if (s.isDirectory()) yield* walk(p, depth + 1); + else if (/^rollout-.*\.jsonl$/.test(name)) yield p; + } +} +async function listCodexHistory(cwd) { + const out = []; + for await (const path3 of walk(CODEX_ROOT)) { + const m = /rollout-.*-([0-9a-f-]{36})\.jsonl$/i.exec(path3); + const sessionId = m?.[1]; + if (!sessionId) continue; + const text = await readFile6(path3, "utf8").catch(() => ""); + let match = false; + let title = ""; + let count = 0; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + let r; + try { + r = rec3(JSON.parse(line)); + } catch { + continue; + } + if (r.type === "session_meta") { + const cwdInFile = str3(rec3(r.payload).cwd); + if (cwdInFile && samePath(cwdInFile, cwd)) match = true; + else break; + continue; + } + if (!match) continue; + if (r.type === "response_item") { + const p = rec3(r.payload); + if (p.type === "message" && p.role === "user") { + const t = textOf(p.content, "input_text"); + if (t && !WRAPPERS.test(t)) { + count++; + if (!title) title = t.slice(0, 40); + } + } + } + } + if (!match) continue; + const s = await stat4(path3).catch(() => null); + out.push({ sessionId, title: title || "\uFF08\u65E0\u6807\u9898\uFF09", updatedAt: s ? Math.round(s.mtimeMs) : 0, messageCount: count }); + } + out.sort((a, b) => b.updatedAt - a.updatedAt); + return out; +} +async function readCodexHistory(sessionId, cwd) { + const out = []; + let file = null; + for await (const p of walk(CODEX_ROOT)) { + if (p.endsWith(`${sessionId}.jsonl`)) { + file = p; + break; + } + } + if (!file) return out; + const text = await readFile6(file, "utf8").catch(() => ""); + let matched = false; + let aid = 0; + const seenText = /* @__PURE__ */ new Set(); + const pushText = (type, t) => { + const key = type + ":" + t; + if (!t || seenText.has(key)) return; + seenText.add(key); + out.push(type === "user_message" ? { type, text: t } : { type, itemId: `imp_a${aid++}`, text: t }); + }; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + let r; + try { + r = rec3(JSON.parse(line)); + } catch { + continue; + } + if (r.type === "session_meta") { + const c = str3(rec3(r.payload).cwd); + if (c && samePath(c, cwd)) matched = true; + continue; + } + if (!matched) continue; + if (r.type === "response_item") { + const p = rec3(r.payload); + if (p.type === "message") { + const role = str3(p.role); + const phase = str3(p.phase); + if (role === "user") { + const t = textOf(p.content, "input_text"); + if (t && !WRAPPERS.test(t)) pushText("user_message", t); + } else if (role === "assistant" && phase !== "commentary") { + const t = textOf(p.content, "output_text"); + if (t) pushText("assistant_message", t); + } + } else if (p.type === "function_call" || p.type === "custom_tool_call") { + const name = str3(p.name) ?? "tool"; + out.push({ type: "notice", level: "info", message: `\uFF08\u5386\u53F2\uFF09\u8C03\u7528 ${name}` }); + } + continue; + } + if (r.type === "event_msg") { + const p = rec3(r.payload); + if (p.type === "item_completed") { + const it = rec3(p.item); + if (it.type === "AgentMessage" && it.phase !== "commentary") { + const t = textOf(it.content, "Text"); + if (t) pushText("assistant_message", t); + } else if (it.type === "UserMessage") { + const t = textOf(it.content, "text"); + if (t && !WRAPPERS.test(t)) pushText("user_message", t); + } + } else if (p.type === "task_complete") { + const last = str3(p.last_agent_message); + if (last) pushText("assistant_message", last); + } + continue; + } + const p2 = rec3(r.payload); + if (p2.type === "function_call" || p2.type === "custom_tool_call") { + const name = str3(p2.name) ?? "tool"; + out.push({ type: "notice", level: "info", message: `\uFF08\u5386\u53F2\uFF09\u8C03\u7528 ${name}` }); + } + } + out.push({ type: "turn_ended", turnId: "import", status: "completed" }); + return out; +} +var CODEX_ROOT, rec3, str3, WRAPPERS; +var init_codex = __esm({ + "daemon/src/history/codex.ts"() { + init_paths(); + CODEX_ROOT = join7(homedir4(), ".codex", "sessions"); + rec3 = (v) => v && typeof v === "object" ? v : {}; + str3 = (v) => typeof v === "string" ? v : void 0; + WRAPPERS = /^\s*<(environment_context|user_instructions|skills_instructions|permissions instructions)>/i; + } +}); + +// daemon/src/providers/codex/rpc.ts +import { spawn as spawn2 } from "node:child_process"; +var CodexRpcError, CodexProcessExitedError, CodexRpc; +var init_rpc = __esm({ + "daemon/src/providers/codex/rpc.ts"() { + init_log(); + init_bin(); + CodexRpcError = class extends Error { + code; + constructor(message, code) { + super(message); + this.code = code; + } + }; + CodexProcessExitedError = class extends CodexRpcError { + constructor() { + super("Codex \u8FDB\u7A0B\u5DF2\u9000\u51FA"); + } + }; + CodexRpc = class { + proc; + nextId = 1; + pending = /* @__PURE__ */ new Map(); + buf = ""; + exited = false; + onServerRequest; + onNotification; + onExit; + constructor(argv, opts = {}) { + this.proc = spawn2(argv.cmd, argv.args, { + cwd: opts.cwd, + env: opts.env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + ...argv.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}, + // Non-Windows: its own process group, so after a close() timeout killTree also ends the commands codex started + ...process.platform !== "win32" ? { detached: true } : {} + }); + trackTree(this.proc); + this.proc.stdout.setEncoding("utf8"); + this.proc.stdout.on("data", (chunk) => this.onData(chunk)); + this.proc.stderr.setEncoding("utf8"); + this.proc.stderr.on("data", (chunk) => { + this.stderrTail = (this.stderrTail + chunk).slice(-4096); + }); + this.proc.on("error", () => this.failAll()); + this.proc.on("close", (code) => { + this.exited = true; + this.failAll(); + this.onExit?.(code); + }); + } + stderrTail = ""; + get stderr() { + return this.stderrTail; + } + get isExited() { + return this.exited; + } + onData(chunk) { + this.buf += chunk; + let i; + while ((i = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, i).trim(); + this.buf = this.buf.slice(i + 1); + if (!line) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + log("warn", "codex rpc: \u975E JSON \u884C", { line: line.slice(0, 200) }); + continue; + } + void this.dispatch(msg); + } + } + async dispatch(msg) { + const hasId = msg.id !== void 0 && msg.id !== null; + if (hasId && ("result" in msg || "error" in msg)) { + const id = Number(msg.id); + const p = this.pending.get(id); + if (p) { + this.pending.delete(id); + if (msg.error) { + const e = msg.error; + p.reject(new CodexRpcError(e.message ?? "RPC error", e.code)); + } else { + p.resolve(msg.result); + } + } + return; + } + if (hasId && msg.method) { + const id = Number(msg.id); + try { + const result = this.onServerRequest ? await this.onServerRequest(String(msg.method), msg.params ?? {}) : void 0; + this.send({ id, result: result ?? null }); + } catch (e) { + this.send({ id, error: { code: -32603, message: String(e?.message ?? e) } }); + } + return; + } + if (!hasId && msg.method) { + try { + this.onNotification?.(String(msg.method), msg.params ?? {}); + } catch (e) { + log("warn", "codex rpc: \u901A\u77E5\u5904\u7406\u5931\u8D25", { method: msg.method, err: String(e) }); + } + } + } + send(obj) { + if (this.exited) return; + try { + this.proc.stdin.write(JSON.stringify(obj) + "\n"); + } catch { + } + } + request(method, params, timeoutMs = 6e4) { + if (this.exited) return Promise.reject(new CodexProcessExitedError()); + const id = this.nextId++; + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new CodexRpcError(`\u8BF7\u6C42\u8D85\u65F6\uFF1A${method}`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (v) => { + clearTimeout(timer); + resolvePromise(v); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + } + }); + this.send({ jsonrpc: "2.0", id, method, params: params ?? {} }); + }); + } + notify(method, params) { + this.send({ jsonrpc: "2.0", method, params: params ?? {} }); + } + failAll() { + const err = new CodexProcessExitedError(); + for (const [, p] of this.pending) p.reject(err); + this.pending.clear(); + } + async close() { + try { + this.proc.stdin?.end(); + } catch { + } + await new Promise((resolvePromise) => { + const t = setTimeout(() => { + void killTree(this.proc, { graceMs: 1500 }); + resolvePromise(); + }, 3e3); + this.proc.on("close", () => { + clearTimeout(t); + resolvePromise(); + }); + if (this.exited) { + clearTimeout(t); + resolvePromise(); + } + }); + } + }; + } +}); + +// daemon/src/providers/codex/map.ts +function newCodexMapState() { + return { currentTurnId: null, filesChanged: 0, diffText: "" }; +} +function mapNotification(method, params, st) { + const out = []; + const turnId = str4(params.turnId); + switch (method) { + case "turn/started": { + const turn = rec4(params.turn); + const tid = str4(turn.id); + if (tid) st.currentTurnId = tid; + st.filesChanged = 0; + st.diffText = ""; + break; + } + case "item/agentMessage/delta": { + const itemId = str4(params.itemId) ?? ""; + const delta = str4(params.delta) ?? ""; + if (itemId && delta) out.push({ type: "assistant_delta", itemId, text: delta }); + break; + } + case "item/plan/delta": { + break; + } + case "item/reasoning/summaryTextDelta": + case "item/reasoning/textDelta": { + const itemId = str4(params.itemId) ?? ""; + const delta = str4(params.delta) ?? ""; + if (itemId && delta) out.push({ type: "reasoning_delta", itemId, text: delta }); + break; + } + case "item/commandExecution/outputDelta": + case "item/fileChange/outputDelta": + case "command/exec/outputDelta": { + const itemId = str4(params.itemId) ?? ""; + const delta = str4(params.delta) ?? ""; + if (itemId && delta) out.push({ type: "tool_output_delta", itemId, text: delta }); + break; + } + case "item/started": { + const item = rec4(params.item); + const type = str4(item.type) ?? ""; + const itemId = str4(item.id) ?? ""; + if (type === "commandExecution") { + const cmd = str4(item.command) ?? ""; + out.push({ + type: "tool_call", + itemId, + kind: "bash", + title: `\u8FD0\u884C ${trunc2(cmd)}`, + status: "running", + detail: { command: cmd, cwd: str4(item.cwd) } + }); + } else if (type === "fileChange") { + const changes = Array.isArray(item.changes) ? item.changes : []; + out.push({ + type: "tool_call", + itemId, + kind: "edit", + title: `\u4FEE\u6539 ${changes.length} \u4E2A\u6587\u4EF6`, + status: "running", + detail: { changes } + }); + } else if (type === "mcpToolCall" || type === "dynamicToolCall") { + const tool = str4(item.tool) ?? str4(item.name) ?? ""; + const server = str4(item.server) ?? ""; + out.push({ + type: "tool_call", + itemId, + kind: "mcp", + title: `${server || "mcp"} \xB7 ${tool}`, + status: "running" + }); + } else if (type === "webSearch") { + out.push({ + type: "tool_call", + itemId, + kind: "web", + title: `\u641C\u7D22\u7F51\u9875 "${trunc2(str4(item.query) ?? "", 40)}"`, + status: "running" + }); + } + break; + } + case "item/completed": { + const item = rec4(params.item); + const type = str4(item.type) ?? ""; + const itemId = str4(item.id) ?? ""; + if (type === "agentMessage") { + const text = str4(item.text) ?? ""; + if (text) out.push({ type: "assistant_message", itemId, text }); + } else if (type === "reasoning") { + const summary = Array.isArray(item.summary) ? item.summary : []; + const text = summary.filter((s) => typeof s === "string").join("\n"); + if (text) out.push({ type: "reasoning", itemId, text }); + } else if (type === "commandExecution") { + const cmd = str4(item.command) ?? ""; + const exitCode = typeof item.exitCode === "number" ? item.exitCode : void 0; + const output = str4(item.aggregatedOutput) ?? ""; + out.push({ + type: "tool_call", + itemId, + kind: "bash", + title: `\u8FD0\u884C ${trunc2(cmd)}`, + status: exitCode === 0 ? "ok" : "failed", + output: output.length > MAX_OUTPUT2 ? output.slice(0, MAX_OUTPUT2) : output, + exitCode, + durationMs: typeof item.durationMs === "number" ? item.durationMs : void 0 + }); + } else if (type === "fileChange") { + const changes = Array.isArray(item.changes) ? item.changes : []; + const istatus = str4(item.status); + st.filesChanged += changes.length; + out.push({ + type: "tool_call", + itemId, + kind: "edit", + title: `\u4FEE\u6539 ${changes.length} \u4E2A\u6587\u4EF6`, + status: istatus === "failed" ? "failed" : "ok", + detail: { changes } + }); + } else if (type === "mcpToolCall" || type === "dynamicToolCall") { + const tool = str4(item.tool) ?? str4(item.name) ?? ""; + const server = str4(item.server) ?? ""; + const istatus = str4(item.status); + const err = str4(item.error); + out.push({ + type: "tool_call", + itemId, + kind: "mcp", + title: `${server || "mcp"} \xB7 ${tool}`, + status: istatus === "failed" || err ? "failed" : "ok", + output: err ?? void 0 + }); + } else if (type === "webSearch") { + out.push({ + type: "tool_call", + itemId, + kind: "web", + title: `\u641C\u7D22\u7F51\u9875 "${trunc2(str4(item.query) ?? "", 40)}"`, + status: "ok" + }); + } else if (type === "plan") { + const text = str4(item.text) ?? ""; + if (text) out.push({ type: "plan", itemId, markdown: text }); + } + break; + } + case "turn/plan/updated": { + const steps = Array.isArray(params.plan) ? params.plan : []; + const items = steps.map((s) => { + const step = rec4(s); + const status = str4(step.status) ?? "pending"; + return { + text: str4(step.step) ?? "", + status: status === "completed" ? "done" : status === "inProgress" || status === "in_progress" ? "in_progress" : "pending" + }; + }); + if (items.length) out.push({ type: "todo", items }); + break; + } + case "turn/diff/updated": { + const diff = str4(params.diff) ?? ""; + if (diff) st.diffText = diff; + break; + } + case "thread/tokenUsage/updated": { + const usage = rec4(params.tokenUsage); + const last = rec4(usage.last) || usage; + const input = typeof last.inputTokens === "number" ? last.inputTokens : 0; + const output = typeof last.outputTokens === "number" ? last.outputTokens : 0; + const cached = typeof last.cachedInputTokens === "number" ? last.cachedInputTokens : 0; + out.push({ + type: "usage", + inputTokens: input, + outputTokens: output, + cacheReadTokens: cached, + costUsd: 0 + }); + break; + } + case "turn/completed": { + const turn = rec4(params.turn); + const status = str4(turn.status) ?? "completed"; + const error = rec4(turn.error); + out.push({ + type: "turn_ended", + turnId: str4(turn.id) ?? turnId ?? st.currentTurnId ?? "", + status: status === "completed" ? "completed" : status === "interrupted" ? "interrupted" : "failed", + durationMs: typeof turn.durationMs === "number" ? turn.durationMs : void 0, + error: str4(error.message), + filesChanged: st.filesChanged || void 0 + }); + break; + } + case "error": { + const msg = str4(params.message) ?? "Codex \u9519\u8BEF"; + out.push({ type: "notice", level: "error", message: msg }); + break; + } + default: + break; + } + return out; +} +var MAX_OUTPUT2, trunc2, rec4, str4; +var init_map2 = __esm({ + "daemon/src/providers/codex/map.ts"() { + MAX_OUTPUT2 = 20 * 1024; + trunc2 = (s, n = 80) => s.length > n ? s.slice(0, n - 1) + "\u2026" : s; + rec4 = (v) => v && typeof v === "object" ? v : {}; + str4 = (v) => typeof v === "string" ? v : void 0; + } +}); + +// daemon/src/providers/codex/commands.ts +import { readdir as readdir3, readFile as readFile7, stat as stat5 } from "node:fs/promises"; +import { homedir as homedir5 } from "node:os"; +import { basename, join as join8 } from "node:path"; +function parseFrontMatter(raw) { + if (!raw.startsWith("---\n")) return { meta: {}, body: raw }; + const end = raw.indexOf("\n---\n", 4); + if (end === -1) return { meta: {}, body: raw }; + const fm = raw.slice(4, end); + const meta = {}; + for (const line of fm.split(/\r?\n/)) { + const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/); + if (m) meta[m[1]] = m[2].trim().replace(/^["']|["']$/g, ""); + } + return { meta, body: raw.slice(end + 5) }; +} +function firstLine(body) { + for (const line of body.split(/\r?\n/)) { + const t = line.trim(); + if (t && !t.startsWith("#")) return t.replace(/^#+\s*/, "").slice(0, 120); + } + return ""; +} +async function readPromptFiles(dir) { + const out = []; + let names = []; + try { + names = await readdir3(dir); + } catch { + return out; + } + for (const f of names) { + if (!f.endsWith(".md")) continue; + const path3 = join8(dir, f); + try { + const st = await stat5(path3); + if (!st.isFile()) continue; + const raw = await readFile7(path3, "utf8"); + const { meta, body } = parseFrontMatter(raw); + const name = basename(f, ".md"); + out.push({ + name, + description: meta.description || firstLine(body) || name, + argumentHint: meta.argumentHint || meta["argument-hint"], + kind: "command", + body + }); + } catch { + } + } + return out; +} +async function readSkillDirs(root) { + const out = []; + let dirs = []; + try { + dirs = await readdir3(root); + } catch { + return out; + } + for (const d of dirs) { + const skillPath = join8(root, d, "SKILL.md"); + try { + const st = await stat5(skillPath); + if (!st.isFile()) continue; + const raw = await readFile7(skillPath, "utf8"); + const { meta, body } = parseFrontMatter(raw); + const name = meta.name || d; + out.push({ + name, + description: meta.description || firstLine(body) || name, + argumentHint: meta.argumentHint, + kind: "skill", + body + }); + } catch { + } + } + return out; +} +async function loadCodexSlashEntries(cwd, opts) { + const home = homedir5(); + const parts = await Promise.all([ + opts?.skipHome ? Promise.resolve([]) : readPromptFiles(join8(home, ".codex", "prompts")), + readPromptFiles(join8(cwd, ".codex", "prompts")), + opts?.skipHome ? Promise.resolve([]) : readSkillDirs(join8(home, ".codex", "skills")), + readSkillDirs(join8(cwd, ".codex", "skills")) + ]); + const byName = /* @__PURE__ */ new Map(); + for (const list of parts) { + for (const e of list) { + if (!byName.has(e.name)) byName.set(e.name, e); + } + } + return sortSlashCommands([...byName.values()]); +} +function expandCodexSlash(text, entries) { + const parsed = parseSlashInput(text); + if (!parsed) return text; + const hit = entries.find((e) => e.name === parsed.name); + if (!hit) return text; + return hit.body.replace(/\$ARGUMENTS/g, parsed.args); +} +function codexSlashCommandsFromEntries(entries) { + return entries.map(({ body: _b, ...rest }) => rest); +} +var init_commands = __esm({ + "daemon/src/providers/codex/commands.ts"() { + init_slash(); + } +}); + +// daemon/src/providers/codex/session.ts +var rid3, MODE_PRESETS, CodexSession; +var init_session2 = __esm({ + "daemon/src/providers/codex/session.ts"() { + init_attachmentRefs(); + init_queue(); + init_rpc(); + init_bin(); + init_map2(); + init_commands(); + init_log(); + rid3 = () => Math.random().toString(36).slice(2, 10); + MODE_PRESETS = { + plan: { approvalPolicy: "on-request", sandbox: "read-only", sandboxPolicy: { type: "readOnly" } }, + default: { approvalPolicy: "on-request", sandbox: "read-only", sandboxPolicy: { type: "readOnly" } }, + acceptEdits: { approvalPolicy: "on-request", sandbox: "workspace-write", sandboxPolicy: { type: "workspaceWrite" } }, + bypassPermissions: { approvalPolicy: "never", sandbox: "danger-full-access", sandboxPolicy: { type: "dangerFullAccess" } }, + ask: { approvalPolicy: "on-request", sandbox: "read-only", sandboxPolicy: { type: "readOnly" } } + }; + CodexSession = class { + constructor(o) { + this.o = o; + this.events = this.q; + } + o; + events; + q = new AsyncQueue(); + rpc = null; + st = newCodexMapState(); + sessionThreadId = null; + pending = /* @__PURE__ */ new Map(); + closed = false; + started = null; + interruptWanted = false; + ourTurnId = null; + // The current turn's turnId in the timeline (generated on send) + staleCodexTurns = /* @__PURE__ */ new Set(); + codexSlash = null; + emit(ev) { + if (ev.type === "turn_ended") { + if (ev.turnId && this.staleCodexTurns.delete(ev.turnId)) return; + if (this.ourTurnId) ev.turnId = this.ourTurnId; + this.ourTurnId = null; + this.st.currentTurnId = null; + this.interruptWanted = false; + } + this.q.push(ev); + } + async start() { + if (this.started) return this.started; + const attempt = (async () => { + const rpc = new CodexRpc( + argvFor( + { ok: true, cmd: this.o.binCmd, prefixArgs: this.o.binPrefixArgs, display: this.o.binCmd, via: this.o.binVia ?? "path", shim: this.o.binShim }, + ["app-server"] + ), + { cwd: this.o.cwd } + ); + this.rpc = rpc; + rpc.onNotification = (m, p) => { + for (const ev of mapNotification(m, p, this.st)) this.emit(ev); + if (this.interruptWanted && this.st.currentTurnId) void this.interrupt(); + }; + rpc.onServerRequest = (m, p) => this.onServerRequest(m, p); + rpc.onExit = () => { + for (const [, p] of this.pending) p.resolve({ cancelled: true }); + this.pending.clear(); + if (!this.closed) this.q.close(); + }; + await rpc.request("initialize", { + clientInfo: { name: "agent-console", title: "Agent Console", version: this.o.version }, + capabilities: { experimentalApi: true } + }); + rpc.notify("initialized", {}); + const preset = MODE_PRESETS[this.o.mode]; + let threadId; + if (this.o.resumeId) { + const res = await rpc.request("thread/resume", { + threadId: this.o.resumeId, + cwd: this.o.cwd, + model: this.o.model, + approvalPolicy: preset.approvalPolicy, + sandbox: preset.sandbox + }); + threadId = res.thread?.id ?? this.o.resumeId; + } else { + const res = await rpc.request("thread/start", { + cwd: this.o.cwd, + model: this.o.model, + approvalPolicy: preset.approvalPolicy, + sandbox: preset.sandbox + }); + threadId = res.thread?.id ?? ""; + } + if (threadId) { + this.sessionThreadId = threadId; + this.emit({ type: "provider_session_id", id: threadId }); + } + })(); + this.started = attempt.catch((e) => { + if (this.started === guarded) { + this.started = null; + const rpc = this.rpc; + this.rpc = null; + if (rpc) { + rpc.onExit = () => { + }; + void Promise.resolve(rpc.close()).catch(() => { + }); + } + } + throw e; + }); + const guarded = this.started; + return this.started; + } + async send(input) { + if (this.closed) throw new Error("\u4F1A\u8BDD\u5DF2\u5173\u95ED"); + await this.start(); + const rpc = this.rpc; + const preset = MODE_PRESETS[this.o.mode]; + const entries = this.codexSlash ?? await loadCodexSlashEntries(this.o.cwd); + this.codexSlash = entries; + const wireText = expandCodexSlash(input.text, entries); + const items = [{ type: "text", text: wireText }]; + for (const img of input.images ?? []) { + items.push({ type: "localImage", path: await resolveImagePath(img.path) }); + } + this.interruptWanted = false; + const turnId = "u_" + Date.now().toString(36) + "_" + rid3(); + if (this.ourTurnId && this.st.currentTurnId) this.staleCodexTurns.add(this.st.currentTurnId); + this.ourTurnId = turnId; + this.st.currentTurnId = null; + this.emit({ + type: "turn_started", + turnId, + model: this.o.model, + mode: this.o.mode, + effort: this.o.effort + }); + try { + await rpc.request("turn/start", { + threadId: this.sessionThreadId ?? void 0, + input: items, + model: this.o.model, + effort: this.o.effort, + approvalPolicy: preset.approvalPolicy, + sandboxPolicy: preset.sandboxPolicy + }); + } catch (e) { + this.emit({ + type: "turn_ended", + turnId, + status: "failed", + error: String(e?.message ?? e) + }); + } + } + async steer(input) { + if (!this.rpc || !this.st.currentTurnId) throw new Error("\u5F53\u524D\u8F6E\u6B21\u8FD8\u6CA1\u5F00\u59CB\uFF0C\u65E0\u6CD5\u63D2\u8BDD"); + const items = [{ type: "text", text: input.text }]; + for (const img of input.images ?? []) { + items.push({ type: "localImage", path: await resolveImagePath(img.path) }); + } + try { + await this.rpc.request("turn/steer", { + threadId: this.threadId(), + expectedTurnId: this.st.currentTurnId, + input: items + }); + } catch (e) { + log("warn", "codex steer \u5931\u8D25", { err: String(e) }); + } + } + async interrupt() { + for (const [, p] of this.pending) p.resolve({ cancelled: true }); + this.pending.clear(); + if (!this.rpc) return; + if (!this.st.currentTurnId) { + this.interruptWanted = true; + return; + } + this.interruptWanted = false; + try { + await this.rpc.request( + "turn/interrupt", + { threadId: this.threadId(), turnId: this.st.currentTurnId }, + 5e3 + ); + } catch (e) { + const msg = String(e?.message ?? e); + if (!/idle|not running|no.*turn/i.test(msg)) { + log("warn", "codex interrupt \u5931\u8D25", { err: msg }); + } + } + } + threadId() { + return this.sessionThreadId ?? void 0; + } + // ---- server requests (§7.3.5 approvals) ---- + async onServerRequest(method, params) { + switch (method) { + case "item/commandExecution/requestApproval": { + const command = String(params.command ?? ""); + const requestId = "perm_" + rid3(); + this.emit({ + type: "permission_request", + requestId, + title: `\u8FD0\u884C ${command.slice(0, 80)}`, + kind: "tool", + detail: { command, cwd: params.cwd, reason: params.reason, approvalId: params.approvalId, itemId: params.itemId }, + options: [ + { id: "accept", label: "\u5141\u8BB8", kind: "allow_once" }, + { id: "acceptForSession", label: "\u672C\u5BF9\u8BDD\u90FD\u5141\u8BB8", kind: "allow_always" }, + { id: "decline", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const a = await this.waitFor(requestId); + if (a.cancelled) return { decision: "cancel" }; + const opt = a.optionId === "acceptForSession" ? "acceptForSession" : a.optionId === "decline" ? "decline" : "accept"; + return { decision: opt }; + } + case "item/fileChange/requestApproval": { + const changes = Array.isArray(params.changes) ? params.changes : []; + const requestId = "perm_" + rid3(); + this.emit({ + type: "permission_request", + requestId, + title: "\u4FEE\u6539\u6587\u4EF6", + kind: "tool", + detail: { changes, approvalId: params.approvalId, itemId: params.itemId, reason: params.reason }, + options: [ + { id: "accept", label: "\u5141\u8BB8", kind: "allow_once" }, + { id: "acceptForSession", label: "\u672C\u5BF9\u8BDD\u90FD\u5141\u8BB8", kind: "allow_always" }, + { id: "decline", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const a = await this.waitFor(requestId); + if (a.cancelled) return { decision: "cancel" }; + const opt = a.optionId === "acceptForSession" ? "acceptForSession" : a.optionId === "decline" ? "decline" : "accept"; + return { decision: opt }; + } + case "item/tool/requestUserInput": { + const requestId = "q_" + rid3(); + this.emit({ + type: "question", + requestId, + questions: params.questions ?? [] + }); + const a = await this.waitFor(requestId); + if (a.cancelled) return { answers: {} }; + const raw = a.answers ?? {}; + const shaped = {}; + for (const [k, v] of Object.entries(raw)) shaped[k] = { answers: [v] }; + return { answers: shaped }; + } + default: + throw new Error(`\u672A\u8BC6\u522B\u7684\u670D\u52A1\u7AEF\u8BF7\u6C42 ${method}`); + } + } + waitFor(requestId) { + return new Promise((resolve2) => { + this.pending.set(requestId, { kind: "permission", resolve: resolve2 }); + }); + } + respond(requestId, answer) { + const p = this.pending.get(requestId); + if (p) { + this.pending.delete(requestId); + p.resolve(answer); + } + } + async setModel(model) { + this.o.model = model; + } + async setMode(mode) { + this.o.mode = mode; + } + async setEffort(effort) { + this.o.effort = effort; + } + async listCommands() { + try { + const entries = this.codexSlash ?? await loadCodexSlashEntries(this.o.cwd); + this.codexSlash = entries; + return codexSlashCommandsFromEntries(entries); + } catch { + return []; + } + } + async close() { + this.closed = true; + for (const [, p] of this.pending) p.resolve({ cancelled: true }); + this.pending.clear(); + if (this.rpc) await this.rpc.close(); + this.q.close(); + } + }; + } +}); + +// daemon/src/providers/codex/provider.ts +function runVersion2(r, args, timeoutMs = 5e3) { + return new Promise((resolvePromise, reject) => { + let out = ""; + const p = spawnBin(r, args, { stdio: ["ignore", "pipe", "pipe"] }); + const t = setTimeout(() => { + try { + void killTree(p, { graceMs: 500 }); + } catch { + } + reject(new Error("timeout")); + }, timeoutMs); + p.stdout?.on("data", (c) => out += c.toString()); + p.stderr?.on("data", (c) => out += c.toString()); + p.on("error", reject); + p.on("close", (code) => { + clearTimeout(t); + if (code === 0) resolvePromise(out); + else reject(new Error("exit " + code)); + }); + }); +} +function normalizeEfforts(raw) { + const out = []; + for (const e of raw ?? []) { + const v = typeof e === "string" ? e : e?.reasoningEffort; + if (typeof v === "string" && v && !out.includes(v)) out.push(v); + } + return out; +} +async function fetchModels(r, cwd) { + const rpc = new CodexRpc(argvFor(r, ["app-server"]), { cwd }); + try { + await rpc.request("initialize", { + clientInfo: { name: "agent-console", title: "Agent Console", version: "0" }, + capabilities: { experimentalApi: true } + }, 15e3); + rpc.notify("initialized", {}); + const res = await rpc.request("model/list", {}, 15e3); + const rows = res.data ?? []; + return rows.filter((m) => typeof m.id === "string" && m.id).map((m) => { + const efforts = normalizeEfforts(m.supportedReasoningEfforts); + const thinkingOptions = efforts.map((e) => ({ id: e, label: e })); + const defaultThinking = (typeof m.defaultReasoningEffort === "string" ? m.defaultReasoningEffort : null) ?? thinkingOptions[0]?.id ?? null; + return { + id: m.id, + name: m.displayName ?? m.id, + isDefault: m.isDefault, + efforts, + thinkingOptions, + defaultThinking + }; + }); + } finally { + await rpc.close(); + } +} +function createCodexProvider(version) { + return { + id: "codex", + name: "Codex", + capabilities: codexCapabilities, + async detect() { + const bin = await resolveBin("codex"); + if (!bin.ok) { + return { available: false, reason: bin.reason, installHint: INSTALL_HINT2 }; + } + try { + const v = await runVersion2(bin, ["--version"]); + return { available: true, bin: bin.display, version: v.trim().split("\n")[0] || "" }; + } catch (e) { + return { available: true, bin: bin.display, version: "", reason: String(e) }; + } + }, + async listModels() { + if (modelCache && Date.now() - modelCache.at < MODEL_CACHE_MS) return modelCache.models; + const bin = await resolveBin("codex"); + if (!bin.ok) return [{ id: "", name: "\u9ED8\u8BA4" }]; + try { + const models2 = await fetchModels(bin, process.cwd()); + modelCache = { at: Date.now(), models: models2 }; + return models2; + } catch (e) { + log("warn", "codex model/list \u5931\u8D25", { err: String(e) }); + return [{ id: "", name: "\u9ED8\u8BA4" }]; + } + }, + async createSession(o) { + const bin = await resolveBin("codex"); + if (!bin.ok) throw new Error(`\u627E\u4E0D\u5230 codex\uFF1A${bin.reason}`); + return new CodexSession({ + threadId: o.threadId, + cwd: o.cwd, + model: o.model, + mode: o.mode, + effort: o.effort, + resumeId: o.resumeId, + binCmd: bin.cmd, + binPrefixArgs: bin.prefixArgs, + binVia: bin.via, + binShim: bin.shim, + version + }); + }, + // §7.6: ~/.codex/sessions/**/rollout-*.jsonl matched by cwd + async listHistory(cwd) { + const items = await listCodexHistory(cwd); + return items.map((i) => ({ provider: "codex", ...i, imported: false })); + }, + async readHistory(sessionId, cwd) { + return await readCodexHistory(sessionId, cwd); + } + }; +} +var INSTALL_HINT2, codexCapabilities, modelCache, MODEL_CACHE_MS; +var init_provider2 = __esm({ + "daemon/src/providers/codex/provider.ts"() { + init_codex(); + init_bin(); + init_rpc(); + init_session2(); + init_log(); + INSTALL_HINT2 = "npm i -g @openai/codex"; + codexCapabilities = { + resume: true, + interrupt: true, + steer: true, + images: true, + plan: true, + questions: true, + cost: false, + // tokenUsage yes, cost no + effort: [], + // effort values depend on the model — filled from model/list after detect + modes: ["plan", "default", "acceptEdits", "bypassPermissions"] + }; + modelCache = null; + MODEL_CACHE_MS = 10 * 60 * 1e3; + } +}); + +// daemon/src/providers/catalog.ts +function catalogById(id) { + return PROVIDER_CATALOG.find((p) => p.id === id); +} +function providerCreatable(status) { + return status !== "planned"; +} +var PROVIDER_CATALOG; +var init_catalog = __esm({ + "daemon/src/providers/catalog.ts"() { + PROVIDER_CATALOG = [ + { id: "claude", name: "Claude Code", bins: ["claude"], installHint: "npm i -g @anthropic-ai/claude-code", status: "verified" }, + { id: "codex", name: "Codex", bins: ["codex"], installHint: "\u89C1 OpenAI Codex CLI \u5B89\u88C5\u8BF4\u660E", status: "verified" }, + { id: "cursor", name: "Cursor", bins: ["cursor-agent", "cursor"], installHint: "\u5B89\u88C5 Cursor \u5E76\u786E\u4FDD CLI \u5728 PATH", status: "verified" }, + { id: "copilot", name: "Copilot", bins: ["copilot"], installHint: "GitHub Copilot CLI \u5B89\u88C5\u8BF4\u660E", status: "verified" }, + { id: "opencode", name: "OpenCode", bins: ["opencode"], installHint: "npm i -g opencode / \u89C1 OpenCode \u6587\u6863", status: "verified" }, + { id: "pi", name: "Pi", bins: ["pi-acp", "pi", "pi-coding-agent"], installHint: "npm i -g pi-acp\uFF08\u6216 Pi coding agent + ACP \u9002\u914D\u5668\uFF09", status: "verified" }, + { id: "devin", name: "Devin CLI", bins: ["devin"], installHint: "Devin CLI \u5B98\u65B9\u5B89\u88C5", status: "verified" }, + { id: "dsh", name: "dsh", bins: ["dsh"], installHint: "npm i -g @deepseek-ai/dsh", status: "verified" } + ]; + } +}); + +// daemon/src/providers/acp/spawn.ts +import { Readable, Writable } from "node:stream"; +import { resolve as resolvePath } from "node:path"; +import * as acp from "@agentclientprotocol/sdk"; +function spawnResolved(r, args, o = {}) { + return spawnBin(r, args, { + cwd: o.cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, + group: true + }); +} +function acpStreamFromChild(child) { + if (!child.stdin || !child.stdout) throw new Error("ACP \u5B50\u8FDB\u7A0B\u7F3A\u5C11 stdio \u7BA1\u9053"); + const output = Writable.toWeb(child.stdin); + const input = Readable.toWeb(child.stdout); + return acp.ndJsonStream(output, input); +} +function absCwd(cwd) { + return resolvePath(cwd); +} +async function killAcpChild(child) { + await killTree(child); +} +var init_spawn = __esm({ + "daemon/src/providers/acp/spawn.ts"() { + init_bin(); + } +}); + +// daemon/src/providers/acp/clientHandlers.ts +import { readFile as readFile8, writeFile as writeFile2 } from "node:fs/promises"; +import { methods } from "@agentclientprotocol/sdk"; +function registerAcpClientHandlers(app, bridge) { + app.onRequest(methods.client.fs.readTextFile, async (ctx) => { + const text = await readFile8(ctx.params.path, "utf8"); + return { content: text }; + }); + app.onRequest(methods.client.fs.writeTextFile, async (ctx) => { + await writeFile2(ctx.params.path, ctx.params.content, "utf8"); + return {}; + }); + app.onRequest(methods.client.session.requestPermission, async (ctx) => { + const tc = ctx.params.toolCall; + const options = ctx.params.options.map((o) => ({ + id: o.optionId, + label: o.name, + kind: o.kind + })); + if (bridge.autoAcceptPermissions?.()) { + const allow = options.find((o) => o.kind === "allow_once" || o.kind === "allow_always") ?? options[0]; + if (allow) return { outcome: { outcome: "selected", optionId: allow.id } }; + } + const requestId = "p" + Math.random().toString(36).slice(2, 10); + bridge.push({ + type: "permission_request", + requestId, + title: tc.title ?? tc.name ?? "\u9700\u8981\u6388\u6743", + kind: tc.kind ?? "tool", + detail: tc.rawInput ?? void 0, + options + }); + const answer = await bridge.waitFor(requestId); + if (answer.cancelled) return { outcome: { outcome: "cancelled" } }; + const optionId = answer.optionId ?? options[0]?.id; + if (!optionId) return { outcome: { outcome: "cancelled" } }; + return { outcome: { outcome: "selected", optionId } }; + }); +} +var init_clientHandlers = __esm({ + "daemon/src/providers/acp/clientHandlers.ts"() { + } +}); + +// daemon/src/providers/acp/mapUpdate.ts +function mapToolKind(k) { + switch (k) { + case "read": + case "edit": + case "search": + return k; + case "execute": + return "bash"; + case "fetch": + return "web"; + default: + return "other"; + } +} +function mapToolStatus(s) { + if (s === "completed") return "ok"; + if (s === "failed") return "failed"; + return "running"; +} +function textFromContent(content) { + if (content.type === "text") return content.text; + return ""; +} +function newMapCtx() { + return { itemPrefix: "a" + rid4() + "_", messageIds: /* @__PURE__ */ new Map() }; +} +function mapSessionUpdate(update, ctx) { + const out = []; + const mid = "messageId" in update && update.messageId ? String(update.messageId) : null; + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const text = textFromContent(update.content); + if (!text) break; + const itemId = mid ? ctx.messageIds.get(mid) ?? ctx.itemPrefix + mid : ctx.itemPrefix + "msg"; + if (mid) ctx.messageIds.set(mid, itemId); + out.push({ type: "assistant_delta", itemId, text }); + break; + } + case "agent_thought_chunk": { + const text = textFromContent(update.content); + if (!text) break; + const itemId = mid ? ctx.itemPrefix + "t_" + mid : ctx.itemPrefix + "think"; + out.push({ type: "reasoning_delta", itemId, text }); + break; + } + case "tool_call": + out.push({ + type: "tool_call", + itemId: ctx.itemPrefix + update.toolCallId, + kind: mapToolKind(update.kind), + title: update.title, + status: mapToolStatus(update.status), + input: update.rawInput + }); + break; + case "tool_call_update": + out.push({ + type: "tool_call", + itemId: ctx.itemPrefix + update.toolCallId, + kind: mapToolKind(update.kind), + title: update.title ?? update.name ?? "tool", + status: mapToolStatus(update.status), + input: update.rawInput, + output: typeof update.rawOutput === "string" ? update.rawOutput : void 0 + }); + break; + case "plan": + out.push({ + type: "plan", + itemId: ctx.itemPrefix + "plan", + markdown: update.entries.map((e) => `- ${e.content}`).join("\n"), + entries: update.entries.map((e) => ({ step: e.content, status: e.status ?? "" })) + }); + break; + case "notice": + out.push({ + type: "notice", + level: update.severity === "error" ? "error" : update.severity === "warning" ? "warn" : "info", + message: update.description ? `${update.title}: ${update.description}` : update.title + }); + break; + case "usage_update": + out.push({ + type: "usage", + inputTokens: update.used, + outputTokens: 0, + cacheReadTokens: 0, + costUsd: typeof update.cost?.amount === "number" ? update.cost.amount : 0, + contextWindow: update.size + }); + break; + default: + break; + } + return out; +} +function stopToTurnEnd(stopReason, turnId, err) { + if (stopReason === "cancelled") { + return { type: "turn_ended", turnId, status: "interrupted" }; + } + if (stopReason === "refusal" || stopReason === "max_tokens") { + return { type: "turn_ended", turnId, status: "failed", error: err ?? stopReason }; + } + return { type: "turn_ended", turnId, status: "completed" }; +} +var rid4; +var init_mapUpdate = __esm({ + "daemon/src/providers/acp/mapUpdate.ts"() { + rid4 = () => Math.random().toString(36).slice(2, 8); + } +}); + +// daemon/src/providers/acp/session.ts +import { client, methods as methods2, PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; +var AcpSession; +var init_session3 = __esm({ + "daemon/src/providers/acp/session.ts"() { + init_slash(); + init_queue(); + init_bin(); + init_spawn(); + init_clientHandlers(); + init_mapUpdate(); + init_log(); + AcpSession = class { + constructor(o) { + this.o = o; + this.events = this.q; + this.cursorFast = o.cursorFast ?? false; + this.cursorAutoReview = false; + this.commandsReady = new Promise((r) => this.settleCommands = r); + this.initPromise = this.boot(); + } + o; + events; + q = new AsyncQueue(); + child = null; + conn = null; + ctx = null; + active = null; + initErr = null; + initPromise; + closed = false; + turnId = null; + turnNum = 0; + running = false; + permPending = /* @__PURE__ */ new Map(); + mapCtx = newMapCtx(); + modelConfigId = null; + thoughtConfigId = null; + cursorFast = false; + cursorAutoReview = false; + slashCommands = []; + activeTurnId = null; + configOptions = null; + wakePump = null; + // listCommands waits for the first batch of commands (or confirmation there are none), following Paseo's waitForInitialCommands + commandsReady; + settleCommands; + async boot() { + const bin = this.o.launch ? { + ok: true, + cmd: this.o.launch.cmd, + prefixArgs: this.o.launch.prefixArgs, + display: this.o.launch.cmd, + via: "custom", + binName: "custom" + } : await resolveFirstBin(this.o.binCandidates?.length ? this.o.binCandidates : [this.o.binName]); + if (!bin.ok) throw new Error(bin.reason); + try { + this.child = spawnResolved(bin, this.o.acpArgs, { cwd: this.o.cwd }); + this.child.stderr?.on("data", (d) => { + const t = d.toString().trim(); + if (t) log("info", `[${this.o.binName} acp] ${t.slice(0, 500)}`); + }); + this.child.on("error", (e) => this.failInit(e)); + this.child.on("close", (code) => { + if (!this.closed && code !== 0) { + this.q.push({ type: "notice", level: "error", message: `${this.o.binName} \u8FDB\u7A0B\u9000\u51FA (${code})` }); + } + }); + const app = client({ name: "magpie-agent-console" }); + registerAcpClientHandlers(app, { + push: (ev) => this.q.push(ev), + waitFor: (id) => this.waitPerm(id), + autoAcceptPermissions: () => this.cursorAutoReview + }); + const stream = acpStreamFromChild(this.child); + this.conn = app.connect(stream); + this.ctx = this.conn.agent; + await this.ctx.request(methods2.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + // Cursor: declare support for the parameterized model picker (as Paseo does); only then does it expose levels / Fast as separate config options + clientCapabilities: this.o.flavor === "cursor" ? { _meta: { parameterizedModelPicker: true } } : {}, + clientInfo: { name: "Agent Console", version: this.o.appVersion } + }); + const cwd = absCwd(this.o.cwd); + const ctx = this.ctx; + if (!ctx) throw new Error("ACP \u672A\u8FDE\u63A5"); + if (this.o.resumeId) { + const loaded = await ctx.request(methods2.agent.session.load, { + sessionId: this.o.resumeId, + cwd, + mcpServers: [] + }); + this.active = ctx.attachSession({ + sessionId: this.o.resumeId, + modes: loaded.modes + }); + } else { + this.active = await ctx.buildSession(cwd).start(); + } + this.syncModelConfigId(); + this.syncThoughtConfigId(); + await this.applySessionMode(this.o.mode); + await this.applySessionModel(this.o.model); + await this.applyCursorAutoReviewConfig(this.cursorAutoReview); + this.q.push({ type: "provider_session_id", id: this.active.sessionId }); + const init = this.active.newSessionResponse?.availableCommands; + if (init?.length) this.applyAvailableCommands(init); + void this.pumpUpdates(); + } catch (e) { + this.failInit(e); + throw e; + } + } + failInit(e) { + this.initErr = e instanceof Error ? e : new Error(String(e)); + } + applyAvailableCommands(raw) { + this.slashCommands = sortSlashCommands(mapAcpAvailableCommands(raw)); + this.q.push({ type: "slash_commands", commands: this.slashCommands }); + this.settleCommands(); + } + async ready() { + await this.initPromise.catch(() => { + }); + if (this.initErr) throw this.initErr; + if (!this.active) throw new Error("ACP \u4F1A\u8BDD\u672A\u5C31\u7EEA"); + } + /** The config options currently in effect: each set_config_option response returns the latest set (levels change after switching models); otherwise use session/new's */ + currentConfig() { + return this.configOptions ?? this.active?.newSessionResponse.configOptions ?? []; + } + syncModelConfigId() { + for (const o of this.currentConfig()) { + if (o.type === "select" && (o.category === "model" || o.id === "model")) { + this.modelConfigId = o.id; + return; + } + } + if (this.o.flavor === "cursor") this.modelConfigId = "model"; + } + // Level options belong to the current model: recompute on every refresh (cleared when switching to a model without levels) + syncThoughtConfigId() { + this.thoughtConfigId = null; + const opts = this.currentConfig().filter((o) => o.type === "select"); + const hit = opts.find((o) => o.category === "thought_level") ?? opts.find((o) => /thought|thinking|effort|reasoning/i.test(o.id ?? "")); + if (hit) this.thoughtConfigId = hit.id; + } + /** set_config_option, then refresh local state from the latest config options it returns */ + async setOption(configId, value) { + if (!this.ctx || !this.active) return false; + try { + const res = await this.ctx.request(methods2.agent.session.setConfigOption, { + sessionId: this.active.sessionId, + configId, + value + }); + if (Array.isArray(res?.configOptions)) { + this.configOptions = res.configOptions; + this.syncThoughtConfigId(); + } + return true; + } catch (e) { + log("info", `ACP set_config_option(${configId}=${value}): ${String(e)}`); + return false; + } + } + async applySessionThought(effort) { + if (!effort || !this.thoughtConfigId) return; + const opt = this.currentConfig().find((o) => o.id === this.thoughtConfigId); + const values = (opt?.options ?? []).flatMap((x) => x.options ? x.options : [x]).map((x) => x.value); + if (values.length && !values.includes(effort)) return; + await this.setOption(this.thoughtConfigId, effort); + } + /** Mode choices the Agent reports: prefer a category=mode select in configOptions, otherwise ACP modes.availableModes */ + modeChoices() { + const opt = this.currentConfig().find( + (o) => o.type === "select" && (o.category === "mode" || o.id === "mode") + ); + if (opt?.options?.length) { + return opt.options.filter((x) => typeof x.value === "string").map((x) => ({ id: x.value, name: x.name ?? x.value, configId: opt.id })); + } + return (this.active?.modes?.availableModes ?? []).map((m) => ({ id: m.id, name: m.name, configId: null })); + } + resolveAcpModeId(mode) { + if (this.o.modeMap) { + const want = this.o.modeMap[mode]; + if (!want) return null; + const hit = this.modeChoices().find( + (m) => m.id === want || m.id.endsWith(`#${want}`) || m.name.toLowerCase() === want + ); + return hit?.id ?? want; + } + if (this.o.flavor === "cursor") { + const map = { default: "agent", plan: "plan", ask: "ask" }; + const want = map[mode] ?? "agent"; + const avail = this.active?.modes?.availableModes ?? []; + const hit = avail.find((m) => m.id === want || m.name.toLowerCase() === want); + return hit?.id ?? want; + } + return mode === "plan" ? "plan" : "default"; + } + async applySessionMode(mode) { + if (!this.ctx || !this.active) return; + const hasAllowAll = this.currentConfig().some((o) => o.id === "allow_all"); + if (this.o.modeMap?.[mode] === "allow-all") { + const agentId = this.modeChoices().find((m) => m.id === "agent" || m.id.endsWith("#agent")); + if (agentId?.configId) await this.setOption(agentId.configId, agentId.id); + if (hasAllowAll) await this.setOption("allow_all", "on"); + return; + } + if (hasAllowAll && this.o.modeMap) await this.setOption("allow_all", "off"); + const modeId = this.resolveAcpModeId(mode); + if (!modeId) return; + try { + const viaConfig = this.modeChoices().find((m) => m.id === modeId)?.configId; + if (viaConfig) { + await this.setOption(viaConfig, modeId); + return; + } + await this.ctx.request(methods2.agent.session.setMode, { + sessionId: this.active.sessionId, + modeId + }); + } catch (e) { + log("info", `ACP setMode(${mode}): ${String(e)}`); + } + } + /** + * Switch model → refresh level options from the new config options it returns → then apply the level (as Paseo does). + * Cursor connects with parameterizedModelPicker: only the base model name is sent (grok-4.6); level / Fast are separate config options. + */ + async applySessionModel(model) { + if (!this.ctx || !this.active) return; + const value = model && this.o.flavor === "cursor" ? model.replace(/\[.*\]$/u, "") : model; + if (value) { + const modelOption = this.currentConfig().find((o) => o.id === (this.modelConfigId ?? "model")); + const values = (modelOption?.options ?? []).flatMap((x) => x.options ? x.options : [x]).map((x) => x.value).filter((x) => typeof x === "string"); + if (!values.length || values.includes(value)) { + await this.setOption(this.modelConfigId ?? "model", value); + } else { + log("info", `\u5FFD\u7565 ${this.o.binName} \u7684\u65E0\u6548\u6A21\u578B\uFF1A${value}`); + } + } + if (this.o.flavor === "cursor" && this.cursorFast && this.currentConfig().some((o) => o.id === "fast")) { + await this.setOption("fast", "true"); + } + await this.applySessionThought(this.o.effort); + } + async applyCursorAutoReviewConfig(on) { + if (this.o.flavor !== "cursor" || !this.ctx || !this.active) return; + for (const configId of ["auto_review", "autoReview", "smart_auto"]) { + try { + await this.ctx.request(methods2.agent.session.setConfigOption, { + sessionId: this.active.sessionId, + configId, + type: "boolean", + value: on + }); + return; + } catch { + } + } + } + /** Cursor only: Fast / Auto-review (can be changed while the session is open) */ + async updateCursorOpts(fast, autoReview) { + this.cursorFast = fast; + void autoReview; + await this.ready(); + await this.applySessionModel(this.o.model); + await this.applyCursorAutoReviewConfig(false); + } + waitPerm(requestId) { + return new Promise((resolve2) => { + this.permPending.set(requestId, resolve2); + }); + } + respond(requestId, answer) { + const r = this.permPending.get(requestId); + if (r) { + this.permPending.delete(requestId); + r(answer); + this.q.push({ + type: "permission_resolved", + requestId, + optionId: answer.optionId, + cancelled: answer.cancelled + }); + } + } + async send(input) { + await this.ready(); + if (this.running) throw new Error("\u5F53\u524D\u8F6E\u6B21\u5C1A\u672A\u7ED3\u675F"); + this.turnNum++; + this.turnId = "u_" + Date.now().toString(36) + "_" + this.turnNum; + this.mapCtx = newMapCtx(); + this.running = true; + this.q.push({ + type: "turn_started", + turnId: this.turnId, + model: this.o.model, + mode: this.o.mode, + effort: this.o.effort + }); + const text = input.text.trim(); + if (!text) { + this.running = false; + this.q.push({ type: "turn_ended", turnId: this.turnId, status: "completed" }); + return; + } + void this.runTurn(text); + } + // Send this turn's prompt; reading updates is the job of the persistent pumpUpdates() (following Paseo: session/update is handled at session level) + async runTurn(text) { + this.activeTurnId = this.turnId; + this.wakePump?.(); + this.wakePump = null; + this.active.prompt(text).catch(() => { + }); + } + endTurn(ev) { + this.q.push(ev); + this.activeTurnId = null; + this.running = false; + } + /** + * Read session/update for the whole lifetime of the session. + * It used to read only during a turn: the available_commands_update an Agent pushes after session/new and before the first message went unread, + * so the slash menu stayed empty. Now command updates are cached at any time; conversation content becomes timeline events only while a turn is in progress. + */ + async pumpUpdates() { + const session = this.active; + if (!session) return; + while (!this.closed) { + let msg; + try { + msg = await session.nextUpdate(); + } catch (e) { + if (this.closed) return; + const turnId = this.activeTurnId; + if (turnId) { + this.endTurn({ type: "turn_ended", turnId, status: "failed", error: String(e?.message ?? e) }); + } + await new Promise((r) => this.wakePump = r); + continue; + } + if (msg.kind === "session_update") { + const u = msg.update; + if (u.sessionUpdate === "available_commands_update") { + this.applyAvailableCommands(u.availableCommands ?? []); + continue; + } + if (!this.activeTurnId) continue; + for (const ev of mapSessionUpdate(u, this.mapCtx)) this.q.push(ev); + } else if (this.activeTurnId) { + this.endTurn(stopToTurnEnd(msg.stopReason, this.activeTurnId)); + } + } + } + async interrupt() { + await this.ready(); + if (!this.ctx || !this.active) return; + try { + await this.ctx.request(methods2.agent.session.cancel, { sessionId: this.active.sessionId }); + } catch (e) { + log("info", `ACP cancel: ${String(e)}`); + } + } + async setModel(model) { + this.o.model = model; + await this.ready(); + await this.applySessionModel(model); + } + async setMode(mode) { + this.o.mode = mode; + await this.ready(); + await this.applySessionMode(mode); + } + async setEffort(effort) { + this.o.effort = effort; + await this.ready(); + await this.applySessionThought(effort); + } + async listCommands() { + try { + await this.ready(); + } catch { + return []; + } + if (!this.slashCommands.length) { + await Promise.race([this.commandsReady, new Promise((r) => setTimeout(r, 1e4))]); + } + return this.slashCommands; + } + async close() { + this.closed = true; + this.wakePump?.(); + this.settleCommands(); + for (const r of this.permPending.values()) r({ cancelled: true }); + this.permPending.clear(); + try { + this.active?.dispose(); + } catch { + } + try { + this.conn?.close(); + } catch { + } + await killAcpChild(this.child); + this.child = null; + this.q.close(); + } + }; + } +}); + +// daemon/src/providers/acp/provider.ts +function runVersion3(r, args, timeoutMs = 8e3) { + return new Promise((resolvePromise, reject) => { + let out = ""; + const p = spawnBin(r, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1" } }); + const t = setTimeout(() => { + try { + void killTree(p, { graceMs: 500 }); + } catch { + } + reject(new Error("timeout")); + }, timeoutMs); + p.stdout?.on("data", (c) => out += c.toString()); + p.stderr?.on("data", (c) => out += c.toString()); + p.on("error", reject); + p.on("close", (code) => { + clearTimeout(t); + if (code === 0 || out.trim()) resolvePromise(out.trim()); + else reject(new Error("exit " + code)); + }); + }); +} +async function resolveProviderBin(cfg2) { + const names = cfg2.binCandidates?.length ? cfg2.binCandidates : [cfg2.binName]; + return resolveFirstBin(names); +} +function createAcpProvider(cfg2) { + const base = cfg2.capabilities ?? acpCapabilities; + const ORDER = ["plan", "default", "acceptEdits", "bypassPermissions"]; + const caps = cfg2.modeMap ? { ...base, modes: ORDER.filter((m) => cfg2.modeMap[m] !== void 0), modeLabels: cfg2.modeLabels } : base; + return { + id: cfg2.id, + name: cfg2.name, + capabilities: caps, + async detect() { + const r = await resolveProviderBin(cfg2); + if (!r.ok) { + return { available: false, reason: r.reason, installHint: cfg2.installHint }; + } + let version; + try { + if (cfg2.id === "cursor") { + version = (await runVersion3(r, ["--version"])).split(/\r?\n/)[0]?.trim(); + } else if (cfg2.id === "devin") { + version = (await runVersion3(r, ["version"])).split(/\r?\n/)[0]?.trim(); + } else if (cfg2.id === "opencode") { + version = (await runVersion3(r, ["--version"])).split(/\r?\n/)[0]?.trim(); + } + } catch { + } + return { available: true, bin: r.display, version }; + }, + async listModels() { + const r = await resolveProviderBin(cfg2); + if (!r.ok) return [{ id: "default", name: "\u9ED8\u8BA4", isDefault: true }]; + if (cfg2.fetchModels) return cfg2.fetchModels(r); + return [{ id: "default", name: "\u9ED8\u8BA4", isDefault: true }]; + }, + async createSession(o) { + return new AcpSession({ + threadId: o.threadId, + cwd: o.cwd, + model: o.model, + mode: o.mode, + effort: o.effort, + resumeId: o.resumeId, + binName: cfg2.binName, + binCandidates: cfg2.binCandidates ?? [cfg2.binName], + acpArgs: cfg2.acpArgs, + appVersion: cfg2.appVersion ?? "0.0.1", + flavor: cfg2.flavor ?? "generic", + modeMap: cfg2.modeMap, + cursorFast: o.cursorFast, + cursorAutoReview: o.cursorAutoReview, + launch: o.launch + }); + } + }; +} +var acpCapabilities, cursorAcpCapabilities; +var init_provider3 = __esm({ + "daemon/src/providers/acp/provider.ts"() { + init_bin(); + init_bin(); + init_session3(); + acpCapabilities = { + resume: true, + interrupt: true, + steer: false, + images: false, + plan: true, + questions: false, + cost: false, + effort: [], + modes: ["plan", "default", "acceptEdits", "bypassPermissions"] + }; + cursorAcpCapabilities = { + ...acpCapabilities, + questions: true, + modes: ["default", "plan", "ask"] + }; + } +}); + +// daemon/src/providers/acp/probe.ts +import { tmpdir } from "node:os"; +function flatten(opts) { + const out = []; + for (const o of opts ?? []) { + if (o.options) out.push(...flatten(o.options)); + else if (typeof o.value === "string" && o.value) { + out.push({ id: o.value, name: typeof o.name === "string" && o.name ? o.name : o.value }); + } + } + return out.filter((m, i) => out.findIndex((x) => x.id === m.id) === i); +} +function parseAcpSession(res) { + const opts = res.configOptions ?? []; + const modelOpt = opts.find((o) => o.type === "select" && (o.category === "model" || o.id === "model")); + const thoughtOpt = opts.find((o) => o.type === "select" && (o.category === "thought_level" || o.id === "thought_level")); + let models2 = flatten(modelOpt?.options); + let currentModel = typeof modelOpt?.currentValue === "string" ? modelOpt.currentValue : null; + if (!models2.length && res.models?.availableModels?.length) { + models2 = res.models.availableModels.filter((m) => typeof m.modelId === "string" && m.modelId).map((m) => ({ id: m.modelId, name: m.name || m.modelId })); + models2 = models2.filter((m, i) => models2.findIndex((x) => x.id === m.id) === i); + currentModel = res.models.currentModelId ?? currentModel; + } + const thinking = flatten(thoughtOpt?.options).map((t) => ({ id: t.id, label: t.name })); + const currentThinking = typeof thoughtOpt?.currentValue === "string" ? thoughtOpt.currentValue : null; + return { models: models2, currentModel, thinking, currentThinking }; +} +function probeAcp(r, acpArgs, timeoutMs = 3e4) { + return probeAcpRaw(r, acpArgs, timeoutMs).then((x) => x.parsed); +} +function probeAcpRaw(r, acpArgs, timeoutMs = 3e4, extMethod) { + return new Promise((resolvePromise, reject) => { + const child = spawnResolved(r, acpArgs, { cwd: tmpdir() }); + let buf = ""; + let nextId = 0; + const pending = /* @__PURE__ */ new Map(); + let done = false; + const finish = (err, v) => { + if (done) return; + done = true; + clearTimeout(timer); + void killAcpChild(child).catch(() => { + }); + if (err) reject(err); + else resolvePromise(v); + }; + const timer = setTimeout(() => finish(new Error("ACP \u63A2\u6D4B\u8D85\u65F6")), timeoutMs); + const send2 = (method, params) => new Promise((res, rej) => { + const id = ++nextId; + pending.set(id, { res, rej }); + child.stdin?.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + child.on("error", (e) => finish(e)); + child.on("close", () => finish(new Error("ACP \u8FDB\u7A0B\u63D0\u524D\u9000\u51FA"))); + child.stderr?.on("data", () => { + }); + child.stdout?.on("data", (d) => { + buf += d.toString(); + let n; + while ((n = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, n).trim(); + buf = buf.slice(n + 1); + if (!line) continue; + let m; + try { + m = JSON.parse(line); + } catch { + continue; + } + if (m.id !== void 0 && !m.method && pending.has(m.id)) { + const p = pending.get(m.id); + pending.delete(m.id); + if (m.error) p.rej(new Error(JSON.stringify(m.error))); + else p.res(m.result); + } else if (m.id !== void 0 && m.method) { + child.stdin?.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, result: null }) + "\n"); + } + } + }); + void (async () => { + try { + await send2("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "Agent Console", version: "probe" } }); + const res = await send2("session/new", { cwd: tmpdir(), mcpServers: [] }); + let ext = null; + if (extMethod) ext = await send2(extMethod, {}).catch(() => null); + finish(null, { parsed: parseAcpSession(res ?? {}), ext }); + } catch (e) { + finish(e instanceof Error ? e : new Error(String(e))); + } + })(); + }); +} +function probeToModels(p) { + return p.models.map((m) => ({ + id: m.id, + name: m.name, + isDefault: m.id === p.currentModel, + ...p.thinking.length ? { thinkingOptions: p.thinking, defaultThinking: p.currentThinking ?? void 0 } : {} + })); +} +function cursorCatalogToModels(catalog, currentModel) { + const currentBase = currentModel?.replace(/\[.*\]$/u, "") ?? null; + const out = []; + for (const m of catalog.models ?? []) { + if (typeof m.value !== "string" || !m.value) continue; + const id = m.value.replace(/\[.*\]$/u, ""); + const thought = (m.configOptions ?? []).find((o) => o.category === "thought_level"); + const levels = flatten(thought?.options); + out.push({ + id, + name: typeof m.name === "string" && m.name ? m.name : id, + isDefault: id === currentBase, + ...thought && levels.length ? { + thinkingOptions: levels.map((l) => ({ id: l.id, label: EFFORT_LABEL[l.id] ?? l.name })), + defaultThinking: typeof thought.currentValue === "string" ? thought.currentValue : void 0, + thinkingParam: thought.id + } : {} + }); + } + return out; +} +function cursorProbeModels(acpArgs) { + return async (r) => { + try { + const { parsed, ext } = await probeAcpRaw(r, acpArgs, 4e4, "cursor/list_available_models"); + const fromCatalog = ext ? cursorCatalogToModels(ext, parsed.currentModel) : []; + return fromCatalog.length ? fromCatalog : probeToModels(parsed); + } catch (e) { + log("warn", "Cursor \u6A21\u578B\u63A2\u6D4B\u5931\u8D25", { err: String(e) }); + return []; + } + }; +} +function acpProbeModels(acpArgs) { + return async (r) => { + try { + return probeToModels(await probeAcp(r, acpArgs)); + } catch (e) { + log("warn", "ACP \u63A2\u6D4B\u6A21\u578B\u5931\u8D25", { err: String(e) }); + return []; + } + }; +} +var EFFORT_LABEL; +var init_probe = __esm({ + "daemon/src/providers/acp/probe.ts"() { + init_spawn(); + init_log(); + EFFORT_LABEL = { + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max" + }; + } +}); + +// daemon/src/providers/acp/specs.ts +function acpConfigForEntry(entry, appVersion) { + if (!ACP_IDS.has(entry.id)) return null; + const base = { + id: entry.id, + name: entry.name, + binName: entry.bins[0] ?? entry.id, + binCandidates: entry.bins, + installHint: entry.installHint, + appVersion + }; + switch (entry.id) { + case "cursor": + return { + ...base, + acpArgs: ["acp"], + flavor: "cursor", + capabilities: cursorAcpCapabilities, + // Each model's own thinking levels come from cursor/list_available_models (as Paseo does) + fetchModels: cursorProbeModels(["acp"]) + }; + case "devin": + return { + ...base, + acpArgs: ["acp"], + fetchModels: acpProbeModels(["acp"]), + modeMap: { plan: "plan", default: "ask", acceptEdits: "accept-edits", bypassPermissions: "bypass" }, + modeLabels: { plan: "Plan", default: "Ask", acceptEdits: "Code", bypassPermissions: "Bypass Permissions" } + }; + case "opencode": + return { + ...base, + acpArgs: ["acp"], + fetchModels: acpProbeModels(["acp"]), + modeMap: { plan: "plan", default: "build" }, + modeLabels: { plan: "Plan", default: "Build" } + }; + case "copilot": + return { + ...base, + acpArgs: ["--acp"], + fetchModels: acpProbeModels(["--acp"]), + modeMap: { plan: "plan", default: "agent", bypassPermissions: "allow-all" }, + modeLabels: { plan: "Plan", default: "Agent", bypassPermissions: "Allow All" } + }; + case "pi": + return { ...base, acpArgs: [], fetchModels: acpProbeModels([]), modeMap: { default: "" } }; + case "dsh": + return { ...base, acpArgs: ["--profile", "acp"], fetchModels: acpProbeModels(["--profile", "acp"]) }; + default: + return null; + } +} +var ACP_IDS; +var init_specs = __esm({ + "daemon/src/providers/acp/specs.ts"() { + init_provider3(); + init_probe(); + ACP_IDS = /* @__PURE__ */ new Set(["cursor", "devin", "opencode", "dsh", "pi", "copilot"]); + } +}); + +// daemon/src/providers/registry.ts +function registerProvider(p) { + providers.set(p.id, p); +} +function getProvider(id) { + return providers.get(id); +} +function listProviders() { + return [...providers.values()]; +} +function initProviders(version = "") { + providers.clear(); + for (const entry of PROVIDER_CATALOG) { + if (entry.id === "claude") registerProvider(createClaudeProvider()); + else if (entry.id === "codex") registerProvider(createCodexProvider(version)); + else { + const acp2 = acpConfigForEntry(entry, version); + if (acp2) registerProvider(createAcpProvider(acp2)); + } + } + if (process.env.AGENT_CONSOLE_MOCK === "1") { + registerProvider(createMockProvider()); + } +} +var providers; +var init_registry = __esm({ + "daemon/src/providers/registry.ts"() { + init_mock(); + init_provider(); + init_provider2(); + init_catalog(); + init_provider3(); + init_specs(); + providers = /* @__PURE__ */ new Map(); + } +}); + +// daemon/src/providers/enabled.ts +function isProviderEnabled(settings, id) { + const m = settings.providerEnabled; + if (m && typeof m === "object" && typeof m[id] === "boolean") return m[id]; + const entry = catalogById(id); + if (!entry) return true; + return entry.status === "verified"; +} +var init_enabled = __esm({ + "daemon/src/providers/enabled.ts"() { + init_catalog(); + } +}); + +// daemon/src/util/pickFolderWin.ts +import { execFile } from "node:child_process"; +function decodePickFolderStdout(stdout) { + const buf = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout, "utf8"); + const line = buf.toString("utf8").trim().split(/\r?\n/).pop()?.trim() ?? ""; + if (!line) return null; + if (/^[A-Za-z0-9+/]+=*$/.test(line)) { + const decoded = Buffer.from(line, "base64").toString("utf8"); + if (decoded && !decoded.includes("\uFFFD")) return decoded; + } + if (!line.includes("\uFFFD")) return line; + const gbkLine = new TextDecoder("gbk").decode(buf).trim().split(/\r?\n/).pop()?.trim() ?? ""; + return gbkLine || null; +} +function pickFolderWindows(initial) { + return new Promise((resolvePromise) => { + if (process.platform !== "win32") return resolvePromise({ available: false, path: null }); + const init = (initial ?? "").replace(/'/g, "''"); + const script = "Add-Type -AssemblyName System.Windows.Forms;$d = New-Object System.Windows.Forms.FolderBrowserDialog;$d.Description = '\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939';$d.ShowNewFolderButton = $true;" + (init ? `$d.SelectedPath = '${init}';` : "") + "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($d.SelectedPath)) }"; + execFile( + "powershell.exe", + ["-NoProfile", "-STA", "-NonInteractive", "-Command", script], + { timeout: 12e4, windowsHide: false, encoding: "buffer" }, + (e, stdout) => { + if (e) return resolvePromise({ available: true, path: null }); + resolvePromise({ available: true, path: decodePickFolderStdout(stdout ?? Buffer.alloc(0)) }); + } + ); + }); +} +var init_pickFolderWin = __esm({ + "daemon/src/util/pickFolderWin.ts"() { + } +}); + +// daemon/src/util/pickFolder.ts +import { execFile as execFile2 } from "node:child_process"; +import { existsSync as existsSync7, statSync as statSync3 } from "node:fs"; +function appleScriptString(s) { + return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; +} +function isDir(p) { + try { + return !!p && existsSync7(p) && statSync3(p).isDirectory(); + } catch { + return false; + } +} +function pickFolderCommand(platform, initial, find = (n) => findOnPath(n)[0] ?? null, env = process.env) { + const init = isDir(initial) ? initial : void 0; + if (platform === "darwin") { + const prompt = appleScriptString("\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939 / Choose a project folder"); + const loc = init ? ` default location (POSIX file ${appleScriptString(init)})` : ""; + return { cmd: "/usr/bin/osascript", args: ["-e", `POSIX path of (choose folder with prompt ${prompt}${loc})`] }; + } + if (platform === "linux" || platform === "freebsd" || platform === "openbsd") { + if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return null; + const zenity = find("zenity"); + if (zenity) { + const args = ["--file-selection", "--directory", "--title=\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939 / Choose a project folder"]; + if (init) args.push(`--filename=${init.endsWith("/") ? init : init + "/"}`); + return { cmd: zenity, args }; + } + const kdialog = find("kdialog"); + if (kdialog) return { cmd: kdialog, args: ["--getexistingdirectory", init ?? env.HOME ?? "/"] }; + } + return null; +} +function pickFolder(initial) { + if (process.platform === "win32") return pickFolderWindows(initial); + const c = pickFolderCommand(process.platform, initial); + if (!c) return Promise.resolve({ available: false, path: null }); + return new Promise((resolvePromise) => { + execFile2(c.cmd, c.args, { timeout: TIMEOUT, encoding: "utf8" }, (e, stdout) => { + if (e) return resolvePromise({ available: true, path: null }); + let p = String(stdout ?? "").replace(/\r?\n$/, "").trim(); + if (p.length > 1) p = p.replace(/\/+$/, ""); + resolvePromise({ available: true, path: p || null }); + }).on("error", () => resolvePromise({ available: false, path: null })); + }); +} +var TIMEOUT; +var init_pickFolder = __esm({ + "daemon/src/util/pickFolder.ts"() { + init_pathscan(); + init_pickFolderWin(); + TIMEOUT = 10 * 60 * 1e3; + } +}); + +// daemon/src/providers/magpieGateway.ts +import { readFile as readFile9 } from "node:fs/promises"; +import { homedir as homedir6 } from "node:os"; +import { join as join9 } from "node:path"; +async function readJson(path3) { + try { + return JSON.parse(await readFile9(path3, "utf8")); + } catch { + return null; + } +} +async function readApplied() { + return await readJson(join9(homedir6(), ".config", "magpie", "applied.json")) ?? {}; +} +async function readClaudeEnv() { + const s = await readJson(join9(homedir6(), ".claude", "settings.json")); + return s?.env ?? {}; +} +async function gatewayBase() { + const env = await readClaudeEnv(); + const raw = env.ANTHROPIC_BASE_URL?.trim(); + return (raw && /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(raw) ? raw : "http://127.0.0.1:3425").replace(/\/+$/, ""); +} +async function fetchGatewayModelIds() { + const base = await gatewayBase(); + try { + const res = await fetch(`${base}/v1/models`, { signal: AbortSignal.timeout(5e3) }); + if (!res.ok) return []; + const j = await res.json(); + return (j.data ?? []).map((m) => m.id).filter((id) => typeof id === "string" && !!id); + } catch (e) { + log("warn", "\u8BFB\u53D6 magpie \u7F51\u5173\u6A21\u578B\u5217\u8868\u5931\u8D25", { err: String(e) }); + return []; + } +} +function parseCapabilities(raw) { + const out = /* @__PURE__ */ new Map(); + for (const part of (raw ?? "").split(";")) { + const i = part.indexOf("="); + if (i <= 0) continue; + const flags = new Set(part.slice(i + 1).split(",").map((s) => s.trim())); + if (!flags.has("effort")) continue; + const levels = ["low", "medium", "high"]; + if (flags.has("xhigh_effort")) levels.push("xhigh"); + if (flags.has("max_effort")) levels.push("max"); + out.set(part.slice(0, i).trim(), levels); + } + return out; +} +function isPlaceholder(m) { + return !m.id || /^(default|auto)$/i.test(m.id); +} +async function withMagpie(providerId, own) { + const tool = MAGPIE_TOOLS[providerId]; + if (!tool) return own; + const applied = (await readApplied())[tool.key]?.fields; + if (!applied?.model) return own; + const caps = parseCapabilities((await readClaudeEnv()).CLAUDE_CODE_MODEL_CAPABILITIES); + const real = own.filter((m) => !isPlaceholder(m)); + if (providerId === "dsh") { + const appliedId = applied.model.replace(/^magpie\//, ""); + const selected = real.find( + (m) => m.id === applied.model || m.id === appliedId || appliedId.includes("/") && m.id.includes(appliedId) + ); + const fallback = selected ?? real.find((m) => m.isDefault) ?? real[0]; + return real.map((m) => ({ ...m, isDefault: m.id === fallback?.id })); + } + const knowsMagpie = tool.prefix ? real.some((m) => m.id.startsWith(tool.prefix)) : real.length > 0; + let list; + if (knowsMagpie) { + const mine = real.filter((m) => !tool.prefix || m.id.startsWith(tool.prefix)); + const others = tool.prefix ? real.filter((m) => !m.id.startsWith(tool.prefix)) : []; + list = [...mine, ...others]; + } else { + list = (await fetchGatewayModelIds()).map((gid) => ({ id: tool.prefix + gid, name: gid })); + } + const defId = applied.model; + if (!list.some((m) => m.id === defId)) list.unshift({ id: defId, name: defId.slice(tool.prefix.length) || defId }); + list = list.map((m) => ({ ...m, isDefault: m.id === defId })); + list.sort((a, b) => Number(!!b.isDefault) - Number(!!a.isDefault)); + return list.map((m) => { + const name = tool.prefix && m.name.startsWith(tool.prefix) ? m.name.slice(tool.prefix.length) : m.name; + if (m.thinkingOptions?.length && providerId !== "claude") return { ...m, name }; + const levels = caps.get(m.id.slice(tool.prefix.length) || m.id); + if (!levels) return { ...m, name, thinkingOptions: providerId === "claude" ? [] : m.thinkingOptions }; + const def = applied.effort && levels.includes(applied.effort) ? applied.effort : levels.includes("high") ? "high" : levels[0]; + return { + ...m, + name, + thinkingOptions: levels.map((id) => ({ id, label: EFFORT_LABEL2[id] ?? id })), + defaultThinking: m.isDefault ? def : m.defaultThinking ?? def + }; + }); +} +var MAGPIE_TOOLS, EFFORT_LABEL2; +var init_magpieGateway = __esm({ + "daemon/src/providers/magpieGateway.ts"() { + init_log(); + MAGPIE_TOOLS = { + claude: { key: "claude", prefix: "" }, + codex: { key: "codex", prefix: "" }, + opencode: { key: "opencode", prefix: "magpie/" }, + pi: { key: "pi", prefix: "magpie/" }, + dsh: { key: "dsh", prefix: "magpie/" } + }; + EFFORT_LABEL2 = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max" + }; + } +}); + +// daemon/src/providers/infoCache.ts +function withTimeout(p, ms, fb) { + return Promise.race([p, new Promise((r) => setTimeout(() => r(fb), ms))]); +} +function fresh(e, ttl) { + return !!e && Date.now() - e.at < ttl; +} +async function cachedDetect(p) { + const hit = detects.get(p.id); + if (fresh(hit, DETECT_MS)) return hit.value; + let job = inflightDetect.get(p.id); + if (!job) { + job = withTimeout( + p.detect().catch((e) => ({ available: false, reason: String(e?.message ?? e) })), + STEP_TIMEOUT_MS, + { available: false, reason: "\u68C0\u6D4B\u8D85\u65F6" } + ).then((v) => { + if (v.reason !== "\u68C0\u6D4B\u8D85\u65F6") detects.set(p.id, { at: Date.now(), value: v }); + return v; + }).finally(() => inflightDetect.delete(p.id)); + inflightDetect.set(p.id, job); + } + return job; +} +async function cachedModels(p) { + const hit = models.get(p.id); + if (fresh(hit, MODELS_MS)) return hit.value; + let job = inflightModels.get(p.id); + if (!job) { + job = withTimeout(p.listModels().catch(() => []), MODELS_TIMEOUT_MS, null).then(async (v) => { + if (v === null) return models.get(p.id)?.value ?? []; + const list = await withMagpie(p.id, dropPlaceholder(v)).catch(() => dropPlaceholder(v)); + models.set(p.id, { at: Date.now(), value: list }); + return list; + }).finally(() => inflightModels.delete(p.id)); + inflightModels.set(p.id, job); + } + return job; +} +function dropPlaceholder(list) { + if (list.length === 1 && list[0].id === "default" && list[0].name === "\u9ED8\u8BA4") return []; + return list; +} +function clearProviderCache(id) { + if (id) { + detects.delete(id); + models.delete(id); + } else { + detects.clear(); + models.clear(); + } +} +var DETECT_MS, MODELS_MS, STEP_TIMEOUT_MS, MODELS_TIMEOUT_MS, detects, models, inflightDetect, inflightModels; +var init_infoCache = __esm({ + "daemon/src/providers/infoCache.ts"() { + init_magpieGateway(); + DETECT_MS = 60 * 1e3; + MODELS_MS = 10 * 60 * 1e3; + STEP_TIMEOUT_MS = 15e3; + MODELS_TIMEOUT_MS = 35e3; + detects = /* @__PURE__ */ new Map(); + models = /* @__PURE__ */ new Map(); + inflightDetect = /* @__PURE__ */ new Map(); + inflightModels = /* @__PURE__ */ new Map(); + } +}); + +// daemon/src/ws/methods.ts +import { z } from "zod"; +import { existsSync as existsSync8, statSync as statSync4 } from "node:fs"; +import { mkdir as mkdir2, writeFile as writeFile3 } from "node:fs/promises"; +import { homedir as homedir7 } from "node:os"; +import { dirname as dirname3, join as join10, win32 as win32Path } from "node:path"; +import { execFile as execFile3 } from "node:child_process"; +function wrap(schema, fn) { + return (params, ctx) => { + const r = schema.safeParse(params ?? {}); + if (!r.success) { + throw new ApiError("bad_params", "\u53C2\u6570\u4E0D\u5BF9\uFF1A" + r.error.issues.map((i) => i.message).join("\uFF1B")); + } + return fn(r.data, ctx); + }; +} +function registerMethods(gw, svc) { + const zEmpty = z.object({}).passthrough().optional(); + gw.method("app.info", wrap(zEmpty, () => ({ + version: svc.cfg.version, + dataDir: svc.cfg.dataDir, + pid: process.pid, + port: svc.cfg.port + }))); + gw.method("app.shutdown", wrap(zEmpty, () => { + setTimeout(() => process.exit(0), 100); + return { ok: true }; + })); + gw.method("settings.get", wrap(zEmpty, () => svc.settings.get())); + gw.method("settings.set", wrap( + z.object({ patch: z.record(z.string(), z.unknown()) }), + (p) => svc.settings.set(p.patch) + )); + gw.method("sub.add", wrap( + z.object({ ch: z.string().min(1).max(120), afterSeq: z.number().int().nonnegative().optional() }), + async (p, ctx) => { + svc.gateway.subscribe(ctx.conn, p.ch); + const m = /^thread:(.+)$/.exec(p.ch); + if (m && p.afterSeq != null) { + const events = await svc.threads.readEvents(m[1], p.afterSeq, 5e3).catch(() => []); + for (const ev of events) ctx.conn.send({ t: "evt", ch: p.ch, d: { type: "event", event: ev } }); + } + return { ok: true }; + } + )); + gw.method("sub.remove", wrap(z.object({ ch: z.string().min(1).max(120) }), (p, ctx) => { + svc.gateway.unsubscribe(ctx.conn, p.ch); + return { ok: true }; + })); + gw.method("providers.list", wrap( + z.object({ refresh: z.boolean().optional() }).passthrough().optional(), + async (p) => { + if (p?.refresh) { + clearBinCache(); + clearProviderCache(); + } + const settings = await svc.settings.get(); + const rows = await Promise.all( + listProviders().map(async (p2) => { + const entry = catalogById(p2.id); + const enabled = isProviderEnabled(settings, p2.id); + const skip = !enabled || entry?.status === "planned"; + const det = skip ? { available: false, reason: !enabled ? "\u5DF2\u5728\u8BBE\u7F6E\u4E2D\u7981\u7528" : "\u5C1A\u672A\u63A5\u5165" } : await cachedDetect(p2); + const models2 = !skip && det.available ? await cachedModels(p2) : []; + return { + id: p2.id, + name: p2.name, + available: det.available, + enabled, + status: entry?.status ?? "verified", + ready: entry?.status === "verified", + bin: det.bin, + version: det.version, + reason: !enabled ? "\u5DF2\u5728\u8BBE\u7F6E\u4E2D\u7981\u7528" : det.reason, + installHint: det.installHint, + capabilities: p2.capabilities, + models: models2 + }; + }) + ); + const byId = new Map(rows.map((r) => [r.id, r])); + const ordered = PROVIDER_CATALOG.map((e) => byId.get(e.id)).filter(Boolean); + for (const r of rows) { + if (!PROVIDER_CATALOG.some((e) => e.id === r.id)) ordered.push(r); + } + return ordered; + } + )); + gw.method("projects.list", wrap(zEmpty, () => svc.projects.list())); + gw.method("projects.create", wrap( + z.object({ path: z.string().min(1), name: z.string().optional() }), + async (p) => { + const proj = await svc.projects.create(p.path, p.name); + const s = await svc.settings.get(); + const dirs = (s.recentDirs ?? []).filter((d) => d.toLowerCase() !== p.path.toLowerCase()); + await svc.settings.set({ recentDirs: [p.path, ...dirs].slice(0, 12) }); + gw.publish("sidebar", { type: "project.upsert", project: proj }); + return proj; + } + )); + gw.method("projects.rename", wrap(z.object({ id: z.string(), name: z.string() }), async (p) => { + const proj = await svc.projects.rename(p.id, p.name); + gw.publish("sidebar", { type: "project.upsert", project: proj }); + return proj; + })); + gw.method("projects.remove", wrap(z.object({ id: z.string() }), async (p) => { + const proj = await svc.projects.get(p.id); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + for (const t of await svc.threads.list()) { + if (t.projectId === p.id) { + await svc.threads.delete(t.id); + gw.publish("sidebar", { type: "thread.remove", id: t.id }); + } + } + await svc.projects.remove(p.id); + gw.publish("sidebar", { type: "project.remove", id: p.id }); + return { ok: true }; + })); + gw.method("projects.open", wrap(z.object({ id: z.string() }), async (p) => { + const proj = await svc.projects.get(p.id); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + if (!existsSync8(proj.path)) throw new ApiError("bad_path", "\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u662F\u6587\u4EF6\u5939"); + const cmd = process.platform === "win32" ? "explorer.exe" : process.platform === "darwin" ? "open" : "xdg-open"; + const target = process.platform === "win32" ? win32Path.normalize(proj.path) : proj.path; + execFile3(cmd, [target], { windowsHide: true }, () => { + }).on("error", () => { + }); + return { ok: true }; + })); + gw.method("fs.check", wrap(z.object({ path: z.string().min(1) }), (p) => { + let exists = false; + let isDir2 = false; + try { + exists = existsSync8(p.path); + isDir2 = exists && statSync4(p.path).isDirectory(); + } catch { + } + return { exists, isDir: isDir2 }; + })); + gw.method("fs.recentDirs", wrap(zEmpty, async () => { + const s = await svc.settings.get(); + return (s.recentDirs ?? []).slice(0, 12); + })); + gw.method("fs.pickFolder", wrap( + z.object({ initial: z.string().optional() }).passthrough().optional(), + (p) => pickFolder(p?.initial) + )); + gw.method("threads.list", wrap(zEmpty, () => svc.threads.list())); + gw.method("threads.create", wrap( + z.object({ + projectId: z.string().min(1), + provider: z.string().min(1), + model: z.string().nullable().optional(), + mode: threadModeSchema.optional(), + effort: z.string().nullable().optional(), + title: z.string().optional(), + providerSessionId: z.string().nullable().optional(), + importedFrom: z.object({ provider: z.string(), sessionId: z.string() }).optional() + }), + async (p) => { + const proj = await svc.projects.get(p.projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + if (!existsSync8(proj.path)) throw new ApiError("bad_path", "\u9879\u76EE\u76EE\u5F55\u5DF2\u4E0D\u5B58\u5728"); + if (!p.providerSessionId && !isProviderEnabled(await svc.settings.get(), p.provider)) { + throw new ApiError("bad_params", "\u8BE5 Agent \u5DF2\u5728\u8BBE\u7F6E\u4E2D\u7981\u7528"); + } + const cat = catalogById(p.provider); + if (!p.providerSessionId && cat && !providerCreatable(cat.status)) { + throw new ApiError("bad_params", `${cat.name} \u5C1A\u672A\u63A5\u5165\uFF0C\u6682\u4E0D\u80FD\u65B0\u5EFA\u5BF9\u8BDD`); + } + const meta = await svc.threads.create(p); + gw.publish("sidebar", { type: "thread.upsert", thread: meta }); + return meta; + } + )); + gw.method("threads.get", wrap( + z.object({ id: z.string(), afterSeq: z.number().int().nonnegative().optional() }), + async (p) => { + const meta = await svc.threads.get(p.id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const events = await svc.threads.readEvents(p.id, p.afterSeq ?? 0, 5e3); + const rt = svc.runtime?.(p.id) ?? null; + return { + meta, + events, + queue: rt?.queue ?? [], + pending: rt?.pending ?? [], + runtimeStatus: rt?.status ?? meta.status + }; + } + )); + gw.method("threads.update", wrap( + z.object({ + id: z.string(), + title: z.string().optional(), + pinned: z.boolean().optional(), + archived: z.boolean().optional() + }), + async (p) => { + const meta = await svc.threads.get(p.id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const patch = {}; + if (p.title !== void 0) { + patch.title = p.title.trim() || meta.title; + patch.titleAuto = false; + } + if (p.pinned !== void 0) patch.pinned = p.pinned; + if (p.archived !== void 0) patch.archived = p.archived; + const next = await svc.threads.patch(p.id, patch); + gw.publish("sidebar", { type: "thread.upsert", thread: next }); + return next; + } + )); + gw.method("threads.delete", wrap(z.object({ id: z.string() }), async (p) => { + const meta = await svc.threads.get(p.id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + if (svc.manager) await svc.manager.deleteThread(p.id); + else await svc.threads.delete(p.id); + gw.publish("sidebar", { type: "thread.remove", id: p.id }); + return { ok: true }; + })); + const projectPath = async (projectId) => { + const proj = await svc.projects.get(projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + if (!existsSync8(proj.path)) throw new ApiError("bad_path", "\u9879\u76EE\u76EE\u5F55\u5DF2\u4E0D\u5B58\u5728"); + return proj.path; + }; + gw.method("git.status", wrap( + z.object({ projectId: z.string() }), + async (p) => svc.git.status(await projectPath(p.projectId)) + )); + gw.method("git.diff", wrap( + z.object({ projectId: z.string(), path: z.string() }), + async (p) => svc.git.diff(await projectPath(p.projectId), p.path) + )); + gw.method("git.discard", wrap( + z.object({ projectId: z.string(), path: z.string() }), + async (p) => svc.git.discard(await projectPath(p.projectId), p.path, svc.cfg.dataDir) + )); + gw.method("files.tree", wrap( + z.object({ projectId: z.string(), dir: z.string().default(".") }), + async (p) => svc.files.tree(await projectPath(p.projectId), p.dir) + )); + gw.method("files.read", wrap( + z.object({ projectId: z.string(), path: z.string() }), + async (p) => svc.files.read(await projectPath(p.projectId), p.path) + )); + gw.method("history.list", wrap(z.object({ projectId: z.string() }), async (p) => { + const proj = await svc.projects.get(p.projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + const imported = new Set( + (await svc.threads.list()).filter((t) => t.projectId === p.projectId && t.providerSessionId).map((t) => t.providerSessionId) + ); + const out = []; + for (const provider of listProviders()) { + if (!provider.listHistory) continue; + const items = await provider.listHistory(proj.path).catch(() => []); + for (const i of items) out.push({ ...i, imported: imported.has(i.sessionId) }); + } + return out; + })); + gw.method("history.import", wrap( + z.object({ + projectId: z.string(), + items: z.array(z.object({ provider: z.string(), sessionId: z.string(), title: z.string().optional() })).min(1).max(20) + }), + async (p) => { + const proj = await svc.projects.get(p.projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + const metas = []; + for (const item of p.items) { + const provider = getProvider(item.provider); + if (!provider?.readHistory) continue; + const existing = (await svc.threads.list()).find( + (t) => t.projectId === p.projectId && t.provider === item.provider && t.providerSessionId === item.sessionId + ); + if (existing) { + metas.push(existing); + continue; + } + const meta = await svc.threads.create({ + projectId: p.projectId, + provider: item.provider, + title: item.title, + providerSessionId: item.sessionId, + importedFrom: { provider: item.provider, sessionId: item.sessionId } + }); + const events = await provider.readHistory(item.sessionId, proj.path).catch(() => []); + for (const ev of events) { + await svc.threads.appendEvent(meta.id, ev); + } + await svc.threads.patch(meta.id, { status: "closed" }); + gw.publish("sidebar", { type: "thread.upsert", thread: await svc.threads.get(meta.id) }); + metas.push(await svc.threads.get(meta.id)); + } + return metas; + } + )); + gw.method("terminal.list", wrap( + z.object({ projectId: z.string().optional() }).passthrough().optional(), + (p) => svc.terminals.list(p?.projectId) + )); + gw.method("terminal.create", wrap( + z.object({ projectId: z.string(), cols: z.number().int().min(20).max(500).default(80), rows: z.number().int().min(5).max(200).default(24) }), + async (p) => { + const path3 = await projectPath(p.projectId); + const r = await svc.terminals.create(p.projectId, path3, p.cols, p.rows); + if ("error" in r) throw new ApiError("bad_params", r.error); + return r; + } + )); + gw.method("terminal.attach", wrap(z.object({ id: z.string() }), async (p, ctx) => { + const r = svc.terminals.attach(p.id); + if (r.error || r.index == null) throw new ApiError("not_found", r.error ?? "\u7EC8\u7AEF\u4E0D\u5B58\u5728"); + svc.gateway.subscribe(ctx.conn, `terminal:${r.index}`); + return r; + })); + gw.method("terminal.resize", wrap( + z.object({ id: z.string(), cols: z.number().int(), rows: z.number().int() }), + (p) => svc.terminals.resize(p.id, p.cols, p.rows) + )); + gw.method("terminal.kill", wrap(z.object({ id: z.string() }), (p) => svc.terminals.kill(p.id))); + const needManager = () => { + if (!svc.manager) throw new ApiError("unavailable", "\u4F1A\u8BDD\u7BA1\u7406\u5668\u672A\u542F\u52A8"); + return svc.manager; + }; + gw.method("thread.send", wrap( + z.object({ + id: z.string(), + text: z.string(), + attachments: z.array(z.object({ path: z.string(), mime: z.string(), name: z.string().optional() })).optional(), + behavior: z.enum(["queue", "steer"]).optional() + }), + async (p) => { + await needManager().send(p.id, { text: p.text, attachments: p.attachments }, p.behavior ?? "queue"); + return { ok: true }; + } + )); + gw.method("thread.interrupt", wrap(z.object({ id: z.string() }), async (p) => { + await needManager().interrupt(p.id); + return { ok: true }; + })); + gw.method("thread.respond", wrap( + z.object({ + id: z.string(), + requestId: z.string(), + optionId: z.string().optional(), + answers: z.record(z.string(), z.string()).optional(), + cancelled: z.boolean().optional() + }), + async (p) => { + await needManager().respond(p.id, p.requestId, { optionId: p.optionId, answers: p.answers, cancelled: p.cancelled }); + return { ok: true }; + } + )); + gw.method("thread.setModel", wrap(z.object({ id: z.string(), model: z.string().nullable() }), async (p) => { + await needManager().setModel(p.id, p.model); + return { ok: true }; + })); + gw.method("thread.setMode", wrap(z.object({ id: z.string(), mode: threadModeSchema }), async (p) => { + await needManager().setMode(p.id, p.mode); + return { ok: true }; + })); + gw.method("thread.setEffort", wrap(z.object({ id: z.string(), effort: z.string().nullable() }), async (p) => { + await needManager().setEffort(p.id, p.effort); + return { ok: true }; + })); + gw.method("thread.commands", wrap(z.object({ id: z.string(), load: z.boolean().optional() }), async (p) => { + const commands = await needManager().listCommands(p.id, { load: p.load }); + return { commands }; + })); + gw.method("thread.setCursorOpts", wrap( + z.object({ + id: z.string(), + cursorFast: z.boolean().optional(), + cursorAutoReview: z.boolean().optional() + }), + async (p) => { + await needManager().setCursorOpts(p.id, { + cursorFast: p.cursorFast, + cursorAutoReview: p.cursorAutoReview + }); + return { ok: true }; + } + )); + gw.method("thread.setProvider", wrap(z.object({ id: z.string(), provider: z.string().min(1) }), async (p) => { + await needManager().setProvider(p.id, p.provider); + return { ok: true }; + })); + gw.method("thread.close", wrap(z.object({ id: z.string() }), async (p) => { + await needManager().close(p.id); + return { ok: true }; + })); + gw.method("thread.exportMarkdown", wrap(z.object({ id: z.string() }), async (p) => { + const meta = await svc.threads.get(p.id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const events = await svc.threads.readEvents(p.id, 0, 2e4); + const L = []; + L.push(`# ${meta.title}`, ""); + L.push(`> ${meta.provider} \xB7 ${meta.model ?? "\u9ED8\u8BA4"} \xB7 ${meta.mode} \xB7 ${new Date(meta.createdAt).toLocaleString("zh-CN")}`, ""); + for (const e of events) { + switch (e.type) { + case "user_message": + L.push(`**\u6211**${e.steered ? "\uFF08\u63D2\u8BDD\uFF09" : ""}\uFF1A + +${e.text}`, ""); + break; + case "assistant_message": + L.push(e.text, ""); + break; + case "reasoning": + L.push(`
\u601D\u8003 + +${e.text} +
`, ""); + break; + case "tool_call": { + const icon = e.status === "ok" ? "\u2713" : e.status === "failed" ? "\u2717" : "\u2026"; + L.push(`- \`${e.kind}\` ${icon} ${e.title}${e.output ? ` + \`\`\` + ${e.output.slice(0, 2e3)} + \`\`\`` : ""}`); + break; + } + case "plan": + L.push("### \u8BA1\u5212\n\n" + e.markdown, ""); + break; + case "todo": + L.push("### \u5F85\u529E\n\n" + e.items.map((i) => `- [${i.status === "done" || i.status === "completed" ? "x" : " "}] ${i.text}`).join("\n"), ""); + break; + case "notice": + L.push(`_${e.message}_`, ""); + break; + case "turn_ended": + L.push(`\u2014 ${e.status}${e.durationMs ? ` \xB7 ${Math.round(e.durationMs / 1e3)}s` : ""}${e.error ? ` \xB7 ${e.error}` : ""} \u2014`, ""); + break; + default: + break; + } + } + const markdown = L.join("\n"); + const safe = meta.title.replace(/[\\/:*?"<>|\s]+/g, "_").slice(0, 60) || "thread"; + const filename = `${safe}.md`; + const dir = join10(homedir7(), "Downloads"); + await mkdir2(dir, { recursive: true }); + let dest = join10(dir, filename); + if (existsSync8(dest)) { + const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19); + dest = join10(dir, `${safe}-${stamp2}.md`); + } + await writeFile3(dest, markdown, "utf8"); + revealInFileManager(dest); + return { markdown, filename, path: dest }; + })); +} +function revealInFileManager(filePath) { + if (process.platform === "win32") { + execFile3("explorer.exe", ["/select," + win32Path.normalize(filePath)], { windowsHide: true }, () => { + }).on("error", () => { + }); + return; + } + if (process.platform === "darwin") { + execFile3("open", ["-R", filePath], () => { + }).on("error", () => { + }); + return; + } + execFile3("xdg-open", [dirname3(filePath)], () => { + }).on("error", () => { + }); +} +var threadModeSchema, ApiError; +var init_methods = __esm({ + "daemon/src/ws/methods.ts"() { + init_registry(); + init_catalog(); + init_enabled(); + init_pickFolder(); + init_bin(); + init_infoCache(); + threadModeSchema = z.enum(["plan", "default", "acceptEdits", "bypassPermissions", "ask"]); + ApiError = class extends Error { + code; + constructor(code, message) { + super(message); + this.code = code; + } + }; + } +}); + +// daemon/src/util/atomic.ts +import { writeFile as writeFile4, rename, mkdir as mkdir3 } from "node:fs/promises"; +import { dirname as dirname4 } from "node:path"; +async function writeJsonAtomic(file, obj) { + try { + await mkdir3(dirname4(file), { recursive: true }); + const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; + await writeFile4(tmp, JSON.stringify(obj, null, 2), "utf8"); + await rename(tmp, file); + } catch (e) { + if (e instanceof ApiError) throw e; + throw new ApiError("io_error", `\u5199\u5165\u6570\u636E\u5931\u8D25\uFF08${file}\uFF09\uFF1A${e?.message ?? e}`); + } +} +var init_atomic = __esm({ + "daemon/src/util/atomic.ts"() { + init_methods(); + } +}); + +// daemon/src/util/jsonl.ts +import { appendFile, readFile as readFile12, mkdir as mkdir4, rename as rename2, writeFile as writeFile5 } from "node:fs/promises"; +import { dirname as dirname5 } from "node:path"; +async function readJsonl(file) { + let text = ""; + try { + text = await readFile12(file, "utf8"); + } catch { + return []; + } + const out = []; + for (const line of text.split("\n")) { + const t = line.trim(); + if (!t) continue; + try { + out.push(JSON.parse(t)); + } catch { + } + } + return out; +} +function appendJsonl(file, obj) { + const prev = writers.get(file) ?? Promise.resolve(); + const next = prev.then(async () => { + try { + await mkdir4(dirname5(file), { recursive: true }); + await appendFile(file, JSON.stringify(obj) + "\n", "utf8"); + } catch (e) { + if (e instanceof ApiError) throw e; + throw new ApiError("io_error", `\u5199\u5165\u6570\u636E\u5931\u8D25\uFF08${file}\uFF09\uFF1A${e?.message ?? e}`); + } + }); + writers.set( + file, + next.catch(() => { + }) + ); + return next; +} +var writers; +var init_jsonl = __esm({ + "daemon/src/util/jsonl.ts"() { + init_methods(); + writers = /* @__PURE__ */ new Map(); + } +}); + +// daemon/src/migrate/legacy.ts +var legacy_exports = {}; +__export(legacy_exports, { + migrateLegacy: () => migrateLegacy +}); +import { cp as cp2, mkdir as mkdir7, readdir as readdir6, readFile as readFile16 } from "node:fs/promises"; +import { existsSync as existsSync12 } from "node:fs"; +import { join as join17 } from "node:path"; +function stamp() { + const d = /* @__PURE__ */ new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; +} +async function migrateLegacy(dataDir, stores) { + const settings = await stores.settings.get(); + if (settings.v1Migrated) return { migrated: 0, skipped: true }; + const oldDir = join17(dataDir, "sessions"); + const oldSettingsPath = join17(dataDir, "settings.json"); + if (!existsSync12(oldDir) && !existsSync12(oldSettingsPath)) { + await stores.settings.set({ v1Migrated: true }); + return { migrated: 0, skipped: true }; + } + const backup = join17(dataDir, "trash", `legacy-${stamp()}`); + await mkdir7(backup, { recursive: true }); + if (existsSync12(oldDir)) await cp2(oldDir, join17(backup, "sessions"), { recursive: true }).catch(() => { + }); + if (existsSync12(oldSettingsPath)) await cp2(oldSettingsPath, join17(backup, "settings.json")).catch(() => { + }); + const oldSettings = existsSync12(oldSettingsPath) ? JSON.parse(await readFile16(oldSettingsPath, "utf8").catch(() => "{}")) : {}; + const projectIdByPath = /* @__PURE__ */ new Map(); + const lc = (p) => p.replace(/\\/g, "/").toLowerCase().replace(/\/+$/, ""); + for (const p of oldSettings.projects ?? []) { + if (!p?.path) continue; + try { + const proj = await stores.projects.create(p.path, p.name); + projectIdByPath.set(lc(proj.path), proj.id); + } catch (e) { + log("warn", "\u8FC1\u79FB\uFF1A\u5EFA\u9879\u76EE\u5931\u8D25", { path: p.path, err: String(e) }); + } + } + let sessionIds = []; + try { + sessionIds = (await readdir6(oldDir)).filter((d) => validId(d) && existsSync12(join17(oldDir, d, "meta.json"))); + } catch { + } + const metas = []; + for (const id of sessionIds) { + try { + const m = JSON.parse(await readFile16(join17(oldDir, id, "meta.json"), "utf8")); + if (m?.cwd) metas.push(m); + } catch { + } + } + const extraDirs = []; + const projPaths = [...projectIdByPath.keys()]; + for (const m of metas) { + const covered = projPaths.some((pp) => belongsTo(m.cwd, pp)) || extraDirs.some((d) => belongsTo(m.cwd, d)); + if (covered) continue; + for (let i = extraDirs.length - 1; i >= 0; i--) if (belongsTo(extraDirs[i], m.cwd)) extraDirs.splice(i, 1); + extraDirs.push(m.cwd); + } + for (const d of extraDirs) { + if (!existsSync12(d)) continue; + try { + const name = d.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? d; + const proj = await stores.projects.create(d, name); + projectIdByPath.set(lc(proj.path), proj.id); + projPaths.push(lc(proj.path)); + } catch (e) { + log("warn", "\u8FC1\u79FB\uFF1A\u8865\u5EFA\u5B64\u513F\u9879\u76EE\u5931\u8D25", { path: d, err: String(e) }); + } + } + const projectOf = (cwd) => { + const l = lc(cwd); + let best = null; + for (const [pp, id] of projectIdByPath) { + if (belongsTo(l, pp) && (best == null || pp.length > best.length)) best = pp; + } + return best ? projectIdByPath.get(best) : null; + }; + let migrated = 0; + const threadsDir = join17(dataDir, "threads"); + for (const m of metas) { + try { + const projectId = projectOf(m.cwd); + if (!projectId) { + log("warn", "\u8FC1\u79FB\uFF1A\u65E0\u9879\u76EE\u53EF\u5F52\uFF0C\u8DF3\u8FC7", { id: m.id, cwd: m.cwd }); + continue; + } + const testTitle = SKIP_TITLE.test(m.title ?? "") || m.title === "\u9ED8\u8BA4\u5267\u672C\u6F14\u793A"; + const provider = m.agent === "claude" || m.agent === "codex" ? m.agent : "mock"; + const archived = testTitle || m.agent !== "claude" && m.agent !== "codex"; + const now = Date.now(); + const meta = { + id: `t_${m.id.replace(/^s?_?/, "")}`.slice(0, 80), + projectId, + title: m.title || "\u65E7\u5BF9\u8BDD", + titleAuto: false, + provider, + model: m.model || null, + mode: MODE_MAP[m.permission] ?? "default", + effort: null, + providerSessionId: m.cliSessionId || null, + status: "closed", + pinned: !!m.pinned, + archived, + createdAt: m.created || now, + updatedAt: m.updated || now, + lastTurnStatus: m.lastStatus === "ok" ? "completed" : m.lastStatus === "error" ? "failed" : null, + usage: { + inputTokens: m.totalUsage?.inputTokens ?? 0, + outputTokens: m.totalUsage?.outputTokens ?? 0, + cacheReadTokens: m.totalUsage?.cacheReadTokens ?? 0, + costUsd: m.totalCost ?? 0 + }, + importedFrom: m.cliSessionId ? { provider, sessionId: m.cliSessionId } : void 0, + seq: 0 + }; + const tdir = join17(threadsDir, meta.id); + if (existsSync12(tdir)) meta.id = `t_${m.id}_v1`.slice(0, 80); + await mkdir7(join17(threadsDir, meta.id), { recursive: true }); + const oldEvts = (await readFile16(join17(oldDir, m.id, "events.jsonl"), "utf8").catch(() => "")).split("\n").filter((l) => l.trim()).map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }).filter((x) => !!x); + const lines = []; + const openTools = /* @__PURE__ */ new Map(); + let seq = 0; + const push = (ev) => { + seq++; + lines.push({ ...ev, seq, ts: Date.now(), threadId: meta.id }); + }; + for (const e of oldEvts) { + switch (e.type) { + case "user": + push({ type: "user_message", text: String(e.text ?? "") }); + break; + case "text": + push({ type: "assistant_message", itemId: `lg_${seq}`, text: String(e.text ?? "") }); + break; + case "thinking": + push({ type: "reasoning", itemId: `lg_${seq}`, text: String(e.text ?? "") }); + break; + case "tool.call": { + const key = String(e.itemId ?? e.callId ?? `c${seq}`); + openTools.set(key, { kind: String(e.kind ?? "other"), title: String(e.title ?? e.kind ?? "\u5DE5\u5177"), input: e.input, detail: e.detail }); + push({ type: "tool_call", itemId: key, kind: "other", title: String(e.title ?? "\u5DE5\u5177"), status: "running", input: e.input, detail: e.detail }); + break; + } + case "tool.result": { + const key = String(e.itemId ?? e.callId ?? `c${seq}`); + const open = openTools.get(key); + openTools.delete(key); + push({ + type: "tool_call", + itemId: key, + kind: open?.kind ?? "other", + title: open?.title ?? "\u5DE5\u5177", + status: e.ok === false || e.error ? "failed" : "ok", + output: typeof e.output === "string" ? e.output.slice(0, 2e4) : void 0, + detail: open?.detail + }); + break; + } + case "todo": { + const items = e.items ?? []; + push({ type: "todo", items: items.map((i) => ({ text: String(i.text ?? ""), status: i.status === "done" ? "done" : i.status === "in_progress" ? "in_progress" : "todo" })) }); + break; + } + case "permission.denied": + push({ type: "notice", level: "warn", message: `\u5DF2\u62D2\u7EDD\uFF1A${String(e.title ?? e.kind ?? "")}` }); + break; + case "permission.request": + case "permission.resolved": + break; + // Old permission cards are stale; don't recreate them + case "notice": + push({ type: "notice", level: e.level ?? "info", message: String(e.message ?? "") }); + break; + case "run.end": { + const u = e.usage; + if (u) push({ type: "usage", turnId: `lg_t${seq}`, inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0, cacheReadTokens: u.cacheReadTokens ?? 0, costUsd: e.costUsd ?? 0 }); + push({ type: "turn_ended", turnId: `lg_t${seq}`, status: e.status === "error" ? "failed" : e.status === "interrupted" ? "interrupted" : "completed", durationMs: e.ms ?? void 0, error: e.error }); + break; + } + case "run.start": + break; + case "init": + break; + default: + break; + } + } + for (const [key, o] of openTools) { + seq++; + lines.push({ type: "tool_call", itemId: key, kind: "other", title: o.title, status: "failed", seq, ts: Date.now(), threadId: meta.id }); + } + for (const ev of lines) await appendJsonl(join17(threadsDir, meta.id, "timeline.jsonl"), ev); + meta.seq = seq; + await writeJsonAtomic(join17(threadsDir, meta.id, "meta.json"), meta); + migrated++; + } catch (e) { + log("warn", "\u8FC1\u79FB\uFF1A\u4F1A\u8BDD\u5931\u8D25", { id: m.id, err: String(e) }); + } + } + const patch = { v1Migrated: true }; + if (typeof oldSettings.theme === "string") patch.theme = oldSettings.theme; + if (typeof oldSettings.sendKey === "string") patch.sendKey = oldSettings.sendKey; + if (typeof oldSettings.showThinking === "boolean") patch.showThinking = oldSettings.showThinking; + if (typeof oldSettings.defaultAgent === "string") patch.defaultProvider = oldSettings.defaultAgent === "claude" || oldSettings.defaultAgent === "codex" ? oldSettings.defaultAgent : "claude"; + if (typeof oldSettings.defaultPermission === "string") patch.defaultMode = MODE_MAP[oldSettings.defaultPermission] ?? "default"; + await stores.settings.set(patch); + log("info", `\u65E7\u6570\u636E\u8FC1\u79FB\u5B8C\u6210\uFF1A${migrated} \u4E2A\u4F1A\u8BDD\u3001${projectIdByPath.size} \u4E2A\u9879\u76EE\uFF0C\u5907\u4EFD\u5728 ${backup}`); + return { migrated, skipped: false }; +} +var SKIP_TITLE, MODE_MAP, belongsTo; +var init_legacy = __esm({ + "daemon/src/migrate/legacy.ts"() { + init_jsonl(); + init_atomic(); + init_paths(); + init_log(); + SKIP_TITLE = /^\/(slow|deny|error|long|codex)/; + MODE_MAP = { + plan: "plan", + default: "default", + acceptEdits: "acceptEdits", + bypassPermissions: "bypassPermissions", + auto: "acceptEdits", + full: "bypassPermissions" + }; + belongsTo = (cwd, parent) => { + const c = cwd.replace(/\\/g, "/").toLowerCase().replace(/\/+$/, ""); + const p = parent.replace(/\\/g, "/").toLowerCase().replace(/\/+$/, ""); + return c === p || c.startsWith(p + "/"); + }; + } +}); + +// daemon/src/providers/warm.ts +var warm_exports = {}; +__export(warm_exports, { + warmProviderCache: () => warmProviderCache +}); +async function warmProviderCache(settings) { + await Promise.all( + listProviders().map(async (p) => { + if (!isProviderEnabled(settings, p.id) || catalogById(p.id)?.status === "planned") return; + const det = await cachedDetect(p); + if (det.available) await cachedModels(p); + }) + ); +} +var init_warm = __esm({ + "daemon/src/providers/warm.ts"() { + init_registry(); + init_catalog(); + init_enabled(); + init_infoCache(); + } +}); + +// daemon/src/main.ts +import { mkdirSync as mkdirSync2, rmSync as rmSync2, chmodSync as chmodSync2 } from "node:fs"; +import { join as join18, dirname as dirname7 } from "node:path"; +import { fileURLToPath as fileURLToPath3 } from "node:url"; + +// daemon/src/config.ts +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join as join2, resolve, dirname as dirname2 } from "node:path"; +import { fileURLToPath as fileURLToPath2 } from "node:url"; + +// daemon/src/util/buildStamp.ts +import { existsSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +function resolveDaemonMainJs() { + const self = fileURLToPath(import.meta.url); + const norm = self.replace(/\\/g, "/"); + if (norm.endsWith("/daemon/dist/main.js") || norm.endsWith("/dist/main.js")) return self; + const here = dirname(self); + const candidates = [ + join(here, "..", "..", "dist", "main.js"), + // src/util → daemon/dist/main.js (vitest/tsc) + join(here, "main.js"), + // Already in the dist directory + join(here, "..", "main.js") + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return null; +} +function daemonBuildStamp() { + try { + const mainJs = resolveDaemonMainJs(); + if (!mainJs) return "0"; + return String(statSync(mainJs).mtimeMs); + } catch { + return "0"; + } +} + +// daemon/src/config.ts +function pkgVersion() { + try { + const here = dirname2(fileURLToPath2(import.meta.url)); + const pkg = JSON.parse(readFileSync(join2(here, "..", "..", "package.json"), "utf8")); + return String(pkg.version || "0.0.0"); + } catch { + return "0.0.0"; + } +} +function parseConfig(argv) { + let port = null; + let data = null; + let token = null; + let parentPid; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--port" && argv[i + 1]) port = Number(argv[++i]); + else if (a.startsWith("--port=")) port = Number(a.slice(7)); + else if (a === "--data" && argv[i + 1]) data = argv[++i]; + else if (a.startsWith("--data=")) data = a.slice(7); + else if (a === "--token" && argv[i + 1]) token = argv[++i]; + else if (a.startsWith("--token=")) token = a.slice(8); + else if (a === "--parent-pid" && argv[i + 1]) parentPid = Number(argv[++i]); + else if (a.startsWith("--parent-pid=")) parentPid = Number(a.slice(13)); + } + if (port == null || !Number.isInteger(port) || port < 0 || port > 65535) { + const envPort = Number(process.env.AGENT_CONSOLE_PORT || ""); + port = Number.isInteger(envPort) && envPort >= 0 && envPort <= 65535 ? envPort : 3488; + } + const xdg = data ?? process.env.XDG_CONFIG_HOME ?? join2(homedir(), ".config"); + const dataDir = resolve(join2(xdg, "magpie", "agent-console")); + const idleEnv = Number(process.env.AGENT_CONSOLE_IDLE_EXIT_MIN || ""); + return { + port, + dataDir, + token: token ?? process.env.AGENT_CONSOLE_TOKEN ?? randomBytes(16).toString("hex"), + version: pkgVersion(), + idleExitMinutes: Number.isFinite(idleEnv) && idleEnv > 0 ? idleEnv : 60, + buildStamp: daemonBuildStamp(), + parentPid: Number.isInteger(parentPid) ? parentPid : void 0 + }; +} + +// daemon/src/main.ts +init_log(); + +// daemon/src/http.ts +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { existsSync as existsSync2 } from "node:fs"; +import { join as join4, normalize, extname } from "node:path"; + +// daemon/src/security.ts +import { timingSafeEqual, createHash } from "node:crypto"; +function checkHost(host, port) { + if (!host) return { ok: false, code: 421, message: "Host \u4E0D\u5BF9" }; + const h = host.toLowerCase(); + if (h === `127.0.0.1:${port}` || h === `localhost:${port}`) return { ok: true }; + return { ok: false, code: 421, message: "Host \u4E0D\u5BF9" }; +} +function classifyOrigin(origin, port) { + if (origin === void 0 || origin === "") return "none"; + if (origin === "null") return "opaque"; + const o = origin.toLowerCase(); + if (o === `http://127.0.0.1:${port}` || o === `http://localhost:${port}`) return "self"; + return "foreign"; +} +function tokenEqual(a, b) { + const ha = createHash("sha256").update(a).digest(); + const hb = createHash("sha256").update(b).digest(); + return timingSafeEqual(ha, hb); +} + +// daemon/src/http.ts +init_log(); +var MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".woff2": "font/woff2", + ".map": "application/json", + ".json": "application/json; charset=utf-8" +}; +var MAX_UPLOAD = 50 * 1024 * 1024; +var MAX_MSG = 200 * 1024; +function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return null; + } +} +function send(res, code, body, headers = {}) { + res.writeHead(code, headers); + res.end(body); +} +function sendJson(res, code, obj, headers = {}) { + send(res, code, JSON.stringify(obj), { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers }); +} +var STATIC_HEADERS = { + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer" +}; +function createHttpServer(cfg2, appDir, attachments, hooks = {}) { + const server = createServer(async (req, res) => { + try { + await route(req, res); + } catch (e) { + log("error", "HTTP \u5904\u7406\u5F02\u5E38", { url: req.url, err: String(e) }); + if (!res.headersSent) sendJson(res, 500, { ok: false, e: { code: "internal", message: "\u670D\u52A1\u5185\u90E8\u9519\u8BEF" } }); + else res.end(); + } + }); + async function route(req, res) { + const hostCheck = checkHost(req.headers.host, cfg2.port); + if (!hostCheck.ok) { + sendJson(res, hostCheck.code, { ok: false, e: { code: "bad_host", message: hostCheck.message } }); + return; + } + const origin = classifyOrigin(req.headers.origin, cfg2.port); + if (origin === "foreign") { + sendJson(res, 403, { ok: false, e: { code: "bad_origin", message: "\u6765\u6E90\u4E0D\u5141\u8BB8" } }); + return; + } + const url = new URL(req.url ?? "/", `http://127.0.0.1:${cfg2.port}`); + const pathname = url.pathname; + const isApi = pathname.startsWith("/api/"); + const isAttachment = pathname.startsWith("/attachments/"); + if (req.method === "OPTIONS") { + if (pathname !== "/api/upload" || origin === "none") { + sendJson(res, 403, { ok: false, e: { code: "bad_preflight", message: "\u9884\u68C0\u4E0D\u5141\u8BB8" } }); + return; + } + send(res, 204, "", { + "access-control-allow-origin": origin === "opaque" ? "null" : `http://127.0.0.1:${cfg2.port}`, + "access-control-allow-headers": "x-agent-console, content-type", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-max-age": "600", + vary: "origin" + }); + return; + } + if (isApi || isAttachment) { + const headerToken = req.headers["x-agent-console"]; + const queryToken = isAttachment ? url.searchParams.get("t") : null; + const validToken = typeof headerToken === "string" && tokenEqual(headerToken, cfg2.token) || typeof queryToken === "string" && tokenEqual(queryToken, cfg2.token); + if (!validToken) { + sendJson(res, 403, { ok: false, e: { code: "bad_token", message: "\u4EE4\u724C\u65E0\u6548" } }); + return; + } + if (origin === "opaque") { + res.setHeader("access-control-allow-origin", "null"); + res.setHeader("vary", "origin"); + } + if (isApi) { + await routeApi(req, res, pathname, url); + return; + } + const id = safeDecode(pathname.slice("/attachments/".length)); + const a = id === null ? null : await attachments.get(id); + if (!a) { + sendJson(res, 404, { ok: false, e: { code: "not_found", message: "\u9644\u4EF6\u4E0D\u5B58\u5728" } }); + return; + } + const data2 = await attachments.read(a.id); + send(res, 200, data2 ?? Buffer.alloc(0), { ...STATIC_HEADERS, "content-type": a.mime }); + return; + } + if (origin === "opaque") { + sendJson(res, 403, { ok: false, e: { code: "bad_origin", message: "\u6765\u6E90\u4E0D\u5141\u8BB8" } }); + return; + } + if (req.method !== "GET" && req.method !== "HEAD") { + sendJson(res, 405, { ok: false, e: { code: "method", message: "\u65B9\u6CD5\u4E0D\u5141\u8BB8" } }); + return; + } + if (pathname === "/" || pathname === "/index.html") { + try { + const html = await readFile(join4(appDir, "index.html"), "utf8"); + send(res, 200, html, { ...STATIC_HEADERS, "content-type": "text/html; charset=utf-8" }); + } catch { + send(res, 503, "app \u8FD8\u6CA1\u6784\u5EFA\uFF08\u7F3A app/index.html\uFF09", { ...STATIC_HEADERS, "content-type": "text/plain; charset=utf-8" }); + } + return; + } + const decoded = safeDecode(pathname); + if (decoded === null) { + sendJson(res, 400, { ok: false, e: { code: "bad_path", message: "\u8DEF\u5F84\u4E0D\u5408\u6CD5" } }); + return; + } + const rel = normalize(decoded).replace(/^([\\/])+/, ""); + const full = normalize(join4(appDir, rel)); + if (!full.startsWith(normalize(appDir)) || rel.includes("..")) { + sendJson(res, 403, { ok: false, e: { code: "bad_path", message: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" } }); + return; + } + if (!existsSync2(full) || !(await stat(full)).isFile()) { + sendJson(res, 404, { ok: false, e: { code: "not_found", message: "\u6587\u4EF6\u4E0D\u5B58\u5728" } }); + return; + } + const data = await readFile(full); + send(res, 200, data, { ...STATIC_HEADERS, "content-type": MIME[extname(full).toLowerCase()] ?? "application/octet-stream" }); + } + async function routeApi(req, res, pathname, url) { + if (pathname === "/api/ping" && req.method === "GET") { + sendJson(res, 200, { ok: true, version: cfg2.version, port: cfg2.port, pid: process.pid, dataDir: cfg2.dataDir, buildStamp: cfg2.buildStamp }); + return; + } + if (pathname === "/api/upload" && req.method === "POST") { + const name = url.searchParams.get("name") ?? ""; + if (!attachments.allowedExt(name)) { + sendJson(res, 400, { ok: false, e: { code: "bad_type", message: "\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u7C7B\u578B" } }); + return; + } + const declared = Number(req.headers["content-length"] ?? 0); + if (declared > MAX_UPLOAD) { + res.setHeader("connection", "close"); + sendJson(res, 413, { ok: false, e: { code: "too_large", message: "\u6587\u4EF6\u592A\u5927\uFF08\u4E0A\u9650 50MB\uFF09" } }); + req.resume(); + return; + } + const chunks = []; + let size = 0; + for await (const c of req) { + const b = c; + size += b.length; + if (size > MAX_UPLOAD) { + res.setHeader("connection", "close"); + sendJson(res, 413, { ok: false, e: { code: "too_large", message: "\u6587\u4EF6\u592A\u5927\uFF08\u4E0A\u9650 50MB\uFF09" } }); + return; + } + chunks.push(b); + } + const a = await attachments.save(name, Buffer.concat(chunks)); + sendJson(res, 200, { ok: true, id: a.id, name: a.name, mime: a.mime, size: a.size }); + return; + } + sendJson(res, 404, { ok: false, e: { code: "not_found", message: "\u63A5\u53E3\u4E0D\u5B58\u5728" } }); + } + if (hooks.onUpgrade) { + server.on("upgrade", hooks.onUpgrade); + } + return server; +} + +// daemon/src/store/attachments.ts +init_paths(); +import { writeFile, mkdir, stat as stat2, readFile as readFile2 } from "node:fs/promises"; +import { join as join5, extname as extname2 } from "node:path"; +import { randomBytes as randomBytes2 } from "node:crypto"; +var IMG_EXT = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); +var TEXT_EXT = /* @__PURE__ */ new Set([".txt", ".md", ".log", ".json", ".csv", ".xml", ".yaml", ".yml", ".html", ".css", ".js", ".ts", ".py", ".toml", ".ini", ".conf"]); +var MIME2 = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp" +}; +var TEXT_MIME = "text/plain; charset=utf-8"; +var AttachmentStore = class { + constructor(dir) { + this.dir = dir; + } + dir; + // id → file name: avoids a readdir of the whole directory on every lookup (slow with many attachments) + files = /* @__PURE__ */ new Map(); + safeExt(name) { + const ext = extname2(name).toLowerCase(); + if (IMG_EXT.has(ext) || TEXT_EXT.has(ext)) return ext; + if (!ext) return ".txt"; + return null; + } + allowedExt(name) { + return this.safeExt(name) !== null; + } + async save(name, data) { + const ext = this.safeExt(name); + if (!ext) throw new Error("\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u7C7B\u578B"); + const id = "a_" + Date.now().toString(36) + randomBytes2(3).toString("hex"); + await mkdir(this.dir, { recursive: true }); + const file = join5(this.dir, id + ext); + await writeFile(file, data); + this.files.set(id, id + ext); + const mime = MIME2[ext] ?? TEXT_MIME; + return { id, name: name || "\u672A\u547D\u540D" + ext, mime, size: data.length, path: file }; + } + async get(id) { + if (!validId(id)) return null; + const known = this.files.get(id); + if (known) { + const hit = await this.describe(id, known); + if (hit) return hit; + this.files.delete(id); + } + const { readdir: readdir7 } = await import("node:fs/promises"); + try { + for (const f of await readdir7(this.dir)) { + if (f.startsWith(id + ".")) { + this.files.set(id, f); + return await this.describe(id, f); + } + } + } catch { + } + return null; + } + async describe(id, f) { + const p = join5(this.dir, f); + const s = await stat2(p).catch(() => null); + if (!s?.isFile()) return null; + const ext = extname2(f).toLowerCase(); + return { id, name: f.slice(id.length + 1) || f, mime: MIME2[ext] ?? TEXT_MIME, size: s.size, path: p }; + } + async read(id) { + const a = await this.get(id); + if (!a) return null; + return readFile2(a.path); + } +}; + +// daemon/src/store/settings.ts +init_atomic(); +import { readFile as readFile10 } from "node:fs/promises"; +import { join as join11 } from "node:path"; +var DEFAULT_SETTINGS = { + defaultProvider: "claude", + defaultMode: "default", + defaultEffort: null, + sendKey: "enter", + runningSend: "queue", + showThinking: true, + density: "comfortable", + theme: "auto", + idleCloseMinutes: 30, + idleExitMinutes: 60, + collapsedProjects: [], + panel: { open: false, tab: "changes", width: 440 }, + sidebarWidth: 248, + lastProjectId: null, + lastThreadId: null, + recentDirs: [] +}; +var SettingsStore = class { + file; + cache = null; + writing = Promise.resolve(); + constructor(dataDir) { + this.file = join11(dataDir, "settings.json"); + } + async get() { + if (this.cache) return this.cache; + let raw = {}; + try { + raw = JSON.parse(await readFile10(this.file, "utf8")); + } catch { + } + this.cache = { ...DEFAULT_SETTINGS, ...raw, panel: { ...DEFAULT_SETTINGS.panel, ...raw.panel } }; + return this.cache; + } + async set(patch) { + const cur = await this.get(); + const next = { ...cur, ...patch, panel: { ...cur.panel, ...patch.panel } }; + this.cache = next; + const snap = next; + this.writing = this.writing.then(() => writeJsonAtomic(this.file, snap)); + await this.writing; + return next; + } +}; + +// daemon/src/store/projects.ts +init_atomic(); +init_paths(); +init_methods(); +import { readFile as readFile11 } from "node:fs/promises"; +import { existsSync as existsSync9, statSync as statSync5 } from "node:fs"; +import { join as join12 } from "node:path"; +import { randomBytes as randomBytes3 } from "node:crypto"; +function newId() { + return "p_" + Date.now().toString(36) + randomBytes3(3).toString("hex"); +} +var ProjectsStore = class { + file; + cache = null; + writing = Promise.resolve(); + constructor(dataDir) { + this.file = join12(dataDir, "projects.json"); + } + async list() { + if (this.cache) return this.cache; + try { + this.cache = JSON.parse(await readFile11(this.file, "utf8")); + if (!Array.isArray(this.cache)) this.cache = []; + } catch { + this.cache = []; + } + return this.cache; + } + async get(id) { + if (!validId(id)) return null; + return (await this.list()).find((p) => p.id === id) ?? null; + } + async save(list) { + this.cache = list; + const snap = list; + this.writing = this.writing.then(() => writeJsonAtomic(this.file, snap)); + await this.writing; + } + async create(path3, name) { + if (!path3 || typeof path3 !== "string") throw new ApiError("bad_params", "\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A"); + if (!existsSync9(path3) || !statSync5(path3).isDirectory()) { + throw new ApiError("bad_path", "\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u662F\u6587\u4EF6\u5939"); + } + const np = normPath(path3); + const list = await this.list(); + const dup = list.find((p) => normPath(p.path) === np); + if (dup) return dup; + const proj = { + id: newId(), + name: name?.trim() || path3.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path3, + path: path3, + createdAt: Date.now() + }; + list.push(proj); + await this.save(list); + return proj; + } + async rename(id, name) { + const list = await this.list(); + const p = list.find((x) => x.id === id); + if (!p) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + p.name = name.trim() || p.name; + await this.save(list); + return p; + } + async remove(id) { + const list = await this.list(); + const idx = list.findIndex((x) => x.id === id); + if (idx < 0) return; + list.splice(idx, 1); + await this.save(list); + } +}; + +// daemon/src/store/threads.ts +init_atomic(); +init_jsonl(); +init_paths(); +init_methods(); +init_i18n(); +import { mkdir as mkdir5, rename as rename3, readdir as readdir4, readFile as readFile13 } from "node:fs/promises"; +import { join as join13 } from "node:path"; +import { randomBytes as randomBytes4 } from "node:crypto"; +function newId2() { + return "t_" + Date.now().toString(36) + randomBytes4(3).toString("hex"); +} +var ThreadsStore = class { + dir; + trashDir; + metas = /* @__PURE__ */ new Map(); + loaded = false; + // Serialize meta writes: concurrent patch/appendEvent would overwrite each other (setStatus/patch/provider_session_id run concurrently in the manager) + writeQ = Promise.resolve(); + serial(fn) { + const p = this.writeQ.then(fn, fn); + this.writeQ = p.catch(() => { + }); + return p; + } + constructor(dataDir) { + this.dir = join13(dataDir, "threads"); + this.trashDir = join13(dataDir, "trash", "threads"); + } + tdir(id) { + return join13(this.dir, id); + } + metaPath(id) { + return join13(this.tdir(id), "meta.json"); + } + timelinePath(id) { + return join13(this.tdir(id), "timeline.jsonl"); + } + async loadAll() { + if (this.loaded) return; + this.loaded = true; + let ids = []; + try { + ids = (await readdir4(this.dir)).filter((d) => validId(d)); + } catch { + return; + } + for (const id of ids) { + try { + const m = JSON.parse(await readFile13(this.metaPath(id), "utf8")); + if (m && typeof m.id === "string") this.metas.set(m.id, m); + } catch { + } + } + } + async list() { + await this.loadAll(); + return [...this.metas.values()].sort((a, b) => b.updatedAt - a.updatedAt); + } + async get(id) { + if (!validId(id)) return null; + await this.loadAll(); + return this.metas.get(id) ?? null; + } + async saveMeta(meta) { + this.metas.set(meta.id, meta); + await writeJsonAtomic(this.metaPath(meta.id), meta); + } + async create(o) { + const now = Date.now(); + const meta = { + id: newId2(), + projectId: o.projectId, + title: o.title ?? tr("thread.defaultTitle"), + titleAuto: o.title == null, + provider: o.provider, + model: o.model ?? null, + mode: o.mode ?? "default", + effort: o.effort ?? null, + providerSessionId: o.providerSessionId ?? null, + status: "idle", + pinned: false, + archived: false, + createdAt: now, + updatedAt: now, + lastTurnStatus: null, + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0 }, + importedFrom: o.importedFrom, + seq: 0 + }; + try { + await mkdir5(this.tdir(meta.id), { recursive: true }); + } catch (e) { + throw new ApiError("io_error", `\u521B\u5EFA\u5BF9\u8BDD\u76EE\u5F55\u5931\u8D25\uFF1A${e?.message ?? e}`); + } + await this.saveMeta(meta); + return meta; + } + async patch(id, patch) { + return this.serial(async () => { + const meta = await this.get(id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + if (patch.title !== void 0 && patch.titleAuto === void 0) patch = { ...patch, titleAuto: false }; + const next = { ...meta, ...patch, id: meta.id, updatedAt: Date.now() }; + await this.saveMeta(next); + return next; + }); + } + // Append an event: inside the serialized section take meta.seq+1 as seq, write the jsonl and update meta. + // A passed-in seq is ignored (keeps it strictly increasing); ts/threadId are filled in when missing. + async appendEvent(id, ev) { + return this.serial(async () => { + const meta = await this.get(id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const full = { + ...ev, + seq: meta.seq + 1, + ts: ev.ts ?? Date.now(), + threadId: id + }; + await appendJsonl(this.timelinePath(id), full); + meta.seq = full.seq; + meta.updatedAt = Date.now(); + this.metas.set(id, meta); + await writeJsonAtomic(this.metaPath(id), meta); + return full; + }); + } + async readEvents(id, afterSeq = 0, limit = 5e3) { + const rows = await readJsonl(this.timelinePath(id)); + const out = rows.filter((e) => typeof e.seq === "number" && e.seq > afterSeq); + return out.slice(-limit); + } + async delete(id) { + const meta = await this.get(id); + if (!meta) return; + const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19); + await mkdir5(this.trashDir, { recursive: true }); + for (let attempt = 0; ; attempt++) { + try { + await rename3(this.tdir(id), join13(this.trashDir, `${id}-${stamp2}`)); + break; + } catch (e) { + const code = e?.code; + if ((code === "EPERM" || code === "EBUSY" || code === "EACCES") && attempt < 8) { + await new Promise((r) => setTimeout(r, 250 * (attempt + 1))); + continue; + } + throw new ApiError("internal", "\u79FB\u5165\u56DE\u6536\u76EE\u5F55\u5931\u8D25\uFF1A" + String(e)); + } + } + this.metas.delete(id); + } +}; + +// daemon/src/ws/gateway.ts +import { WebSocketServer, WebSocket } from "ws"; +import { randomBytes as randomBytes5 } from "node:crypto"; +init_log(); +init_i18n(); +var Conn = class { + id; + ws; + authed = false; + subs = /* @__PURE__ */ new Set(); + alive = true; + lastMsg = Date.now(); + constructor(ws) { + this.id = "c_" + randomBytes5(4).toString("hex"); + this.ws = ws; + } + send(obj) { + if (this.ws.readyState !== WebSocket.OPEN) return; + try { + const s = JSON.stringify(obj); + if (this.overflow(s.length)) { + log("warn", `\u8FDE\u63A5 ${this.id} \u51FA\u7AD9\u7F13\u51B2\u8D85 8MB\uFF0C\u65AD\u5F00`); + this.ws.close(4e3, "buffer_overflow"); + return; + } + this.ws.send(s); + } catch { + } + } + // Look at bytes really still unsent in the socket (only grows when the client is stuck), not the cumulative amount sent + overflow(next) { + return this.ws.bufferedAmount + next > 8 * 1024 * 1024; + } + sendBinary(frame) { + if (this.ws.readyState !== WebSocket.OPEN) return; + if (this.overflow(frame.length)) { + log("warn", `\u8FDE\u63A5 ${this.id} \u51FA\u7AD9\u7F13\u51B2\u8D85 8MB\uFF0C\u65AD\u5F00`); + this.ws.close(4e3, "buffer_overflow"); + return; + } + try { + this.ws.send(frame); + } catch { + } + } +}; +var Gateway = class { + constructor(cfg2) { + this.cfg = cfg2; + this.wss = new WebSocketServer({ noServer: true }); + } + cfg; + wss; + conns = /* @__PURE__ */ new Map(); + methods = /* @__PURE__ */ new Map(); + helloTimers = /* @__PURE__ */ new Map(); + onTerminalInput; + method(name, fn) { + this.methods.set(name, fn); + } + get connectionCount() { + return this.conns.size; + } + handleUpgrade(req, socket, head) { + const hostCheck = checkHost(req.headers.host, this.cfg.port); + if (!hostCheck.ok) { + socket.write("HTTP/1.1 421 Misdirected Request\r\n\r\n"); + socket.destroy(); + return; + } + if (classifyOrigin(req.headers.origin, this.cfg.port) === "foreign") { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.cfg.port}`); + if (url.pathname !== "/ws") { + socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); + socket.destroy(); + return; + } + this.wss.handleUpgrade(req, socket, head, (ws) => this.onConn(ws)); + } + onConn(ws) { + const conn = new Conn(ws); + this.conns.set(conn.id, conn); + const timer = setTimeout(() => { + if (!conn.authed) ws.close(4401, "auth_timeout"); + }, 5e3); + this.helloTimers.set(conn.id, timer); + ws.on("message", (data, isBinary) => { + conn.lastMsg = Date.now(); + if (isBinary) { + this.onBinary(conn, data); + return; + } + let msg; + try { + msg = JSON.parse(String(data)); + } catch { + conn.send({ t: "res", id: 0, ok: false, e: { code: "bad_json", message: "\u6D88\u606F\u4E0D\u662F\u5408\u6CD5 JSON" } }); + return; + } + this.onMessage(conn, msg); + }); + ws.on("close", () => { + this.conns.delete(conn.id); + const t = this.helloTimers.get(conn.id); + if (t) clearTimeout(t); + this.helloTimers.delete(conn.id); + }); + ws.on("error", () => { + }); + } + onMessage(conn, msg) { + if (msg.t === "hello") { + if (!conn.authed) { + if (typeof msg.token === "string" && tokenEqual(msg.token, this.cfg.token)) { + conn.authed = true; + setAppLocale(msg.lang); + const t = this.helloTimers.get(conn.id); + if (t) clearTimeout(t); + conn.send({ t: "hello.ok", version: this.cfg.version, serverTime: Date.now() }); + } else { + conn.send({ t: "res", id: 0, ok: false, e: { code: "bad_token", message: "\u4EE4\u724C\u65E0\u6548" } }); + conn.ws.close(4401, "bad_token"); + } + } + return; + } + if (msg.t === "ping") { + conn.send({ t: "pong" }); + return; + } + if (msg.t === "req") { + if (!conn.authed) { + conn.send({ t: "res", id: msg.id, ok: false, e: { code: "unauthorized", message: "\u672A\u9274\u6743" } }); + return; + } + void this.dispatch(conn, msg); + return; + } + } + async dispatch(conn, msg) { + const fn = this.methods.get(msg.m); + if (!fn) { + conn.send({ t: "res", id: msg.id, ok: false, e: { code: "not_found", message: `\u672A\u77E5\u65B9\u6CD5 ${msg.m}` } }); + return; + } + try { + const r = await fn(msg.p, { connId: conn.id, conn }); + conn.send({ t: "res", id: msg.id, ok: true, r: r ?? null }); + } catch (e) { + const err = e; + conn.send({ + t: "res", + id: msg.id, + ok: false, + e: { code: err.code ?? "internal", message: err.message ?? "\u670D\u52A1\u5185\u90E8\u9519\u8BEF" } + }); + } + } + // Terminal binary frame: [op:1][terminalIndex:2 BE][payload] + onBinary(conn, buf) { + if (buf.length < 3) return; + const op = buf.readUInt8(0); + const index = buf.readUInt16BE(1); + this.onTerminalInput?.(conn, index, op, buf.subarray(3)); + } + publish(ch, data) { + for (const c of this.conns.values()) { + if (c.authed && c.subs.has(ch)) c.send({ t: "evt", ch, d: data }); + } + } + publishTerminal(index, payload) { + const frame = Buffer.concat([Buffer.from([1, index >> 8 & 255, index & 255]), payload]); + for (const c of this.conns.values()) { + if (c.authed && c.subs.has(`terminal:${index}`)) c.sendBinary(frame); + } + } + subscribe(conn, ch) { + conn.subs.add(ch); + } + unsubscribe(conn, ch) { + conn.subs.delete(ch); + } + closeAll() { + for (const c of this.conns.values()) { + try { + c.ws.close(1001, "server_shutdown"); + } catch { + } + } + this.conns.clear(); + } +}; + +// daemon/src/main.ts +init_methods(); +init_atomic(); +init_registry(); + +// daemon/src/agents/manager.ts +init_attachmentRefs(); +init_registry(); +init_catalog(); +init_enabled(); +init_infoCache(); + +// daemon/src/timeline/coalescer.ts +var Coalescer = class { + windowMs; + acc = /* @__PURE__ */ new Map(); + onEmit; + constructor(windowMs, onEmit) { + this.windowMs = windowMs; + this.onEmit = onEmit; + } + // Push a delta; the first goes out immediately, the rest are merged within the window and sent trailing + push(itemId, text) { + const acc = this.acc.get(itemId); + if (!acc) { + this.onEmit(itemId, text); + this.acc.set(itemId, { text: "", timer: setTimeout(() => this.flush(itemId), this.windowMs) }); + return; + } + acc.text += text; + } + // Must flush before the complete event arrives to keep ordering (deltas before the complete event) + flush(itemId) { + const acc = this.acc.get(itemId); + if (!acc) return; + if (acc.timer) { + clearTimeout(acc.timer); + acc.timer = null; + } + if (acc.text.length > 0) this.onEmit(itemId, acc.text); + this.acc.delete(itemId); + } + // Call before an itemId's complete event arrives: flush the deltas not yet sent + flushItem(itemId) { + this.flush(itemId); + } + dispose() { + for (const id of [...this.acc.keys()]) this.flush(id); + } +}; + +// daemon/src/agents/manager.ts +init_methods(); +init_log(); +init_i18n(); +init_commands(); +var SLASH_CACHE_TTL_MS = 10 * 60 * 1e3; +var DELTA_TYPES = ["assistant_delta", "reasoning_delta", "tool_output_delta"]; +var deltaKey = (type, itemId) => `${type}\0${itemId}`; +var AgentManager = class { + constructor(gw, threads, projects, settings, attachments) { + this.gw = gw; + this.threads = threads; + this.projects = projects; + this.settings = settings; + this.attachments = attachments; + } + gw; + threads; + projects; + settings; + attachments; + runtimes = /* @__PURE__ */ new Map(); + recycleTimer = null; + slashCache = /* @__PURE__ */ new Map(); + /** attachment.path in the frontend's thread.send is really an upload id; resolve it to the file under dataDir/attachments */ + resolveAttachments(refs) { + return resolveAttachmentRefs(this.attachments, refs); + } + attachmentLabels(refs) { + return refs.map((a) => ({ id: a.path, name: (a.name ?? "").trim() || a.path, mime: a.mime })); + } + // Called at startup: running/initializing conversations are recovered as closed (§5.7.6) + async recoverOnStart() { + for (const meta of await this.threads.list()) { + if (meta.status === "running" || meta.status === "initializing") { + await this.appendRaw(meta.id, { + type: "notice", + level: "warn", + message: tr("notice.daemonRestart") + }); + const events = await this.threads.readEvents(meta.id, 0, 5e4); + let open = null; + for (const e of events) { + if (e.type === "turn_started") open = e.turnId ?? "unknown"; + else if (e.type === "user_message" && !e.steered) open = open ?? "unknown"; + else if (e.type === "turn_ended") open = null; + } + if (open !== null) { + await this.appendRaw(meta.id, { type: "turn_ended", status: "interrupted", ...open && open !== "unknown" ? { turnId: open } : {} }); + } + const resolved = new Set( + events.filter((e) => e.type === "permission_resolved").map((e) => e.requestId) + ); + for (const e of events) { + if (e.type === "permission_request" && !resolved.has(e.requestId)) { + await this.appendRaw(meta.id, { + type: "permission_resolved", + requestId: e.requestId, + cancelled: true + }); + } + } + await this.threads.patch(meta.id, { status: "closed", lastTurnStatus: "interrupted" }); + } + } + this.recycleTimer = setInterval(() => void this.recycleIdle(), 6e4); + this.recycleTimer.unref?.(); + } + /** Close every session; the returned Promise resolves once each close finishes (or fails). Callers add their own timeout. */ + shutdown() { + if (this.recycleTimer) clearInterval(this.recycleTimer); + const all = [...this.runtimes.values()].map((rt) => rt.session?.close().catch(() => { + })); + this.runtimes.clear(); + return Promise.all(all).then(() => { + }); + } + // Idle-exit check (§5.2.7): busy if any runtime is not idle + anyBusy() { + for (const rt of this.runtimes.values()) { + if (rt.status === "running" || rt.status === "initializing") return true; + } + return false; + } + runtimeInfo(threadId) { + const rt = this.runtimes.get(threadId); + if (!rt) return null; + return { + status: rt.status, + queue: rt.queue, + pending: [...rt.pending.values()].map((p) => ({ + requestId: p.requestId, + kind: p.kind, + options: p.options + })) + }; + } + rt(threadId) { + let r = this.runtimes.get(threadId); + if (!r) { + r = { + threadId, + session: null, + status: "idle", + currentTurnId: null, + queue: [], + pending: /* @__PURE__ */ new Map(), + allowAlways: /* @__PURE__ */ new Set(), + lastActivity: Date.now(), + coalescer: this.newCoalescer(threadId), + interruptTimer: null, + endedTurnIds: /* @__PURE__ */ new Set(), + starting: false, + commands: [], + sessionCreating: null + }; + this.runtimes.set(threadId, r); + } + return r; + } + newCoalescer(threadId) { + return new Coalescer(60, (key, text) => { + const i = key.indexOf("\0"); + const type = key.slice(0, i); + void this.pushEvent(threadId, { type, itemId: key.slice(i + 1), text }); + }); + } + flushDeltas(rt, itemId) { + for (const t of DELTA_TYPES) rt.coalescer.flushItem(deltaKey(t, itemId)); + } + // ---- send ---- + async send(threadId, input, behavior = "queue") { + const meta = await this.mustThread(threadId); + if (meta.archived) { + await this.threads.patch(threadId, { archived: false }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + const rt = this.rt(threadId); + rt.lastActivity = Date.now(); + const attachmentRefs = input.attachments ?? []; + if (meta.status === "closed" && rt.session) { + try { + await rt.session.close(); + } catch { + } + rt.session = null; + rt.commands = []; + } + if (!rt.starting && rt.session === null && (rt.status === "running" || rt.status === "initializing" || rt.status === "error")) { + rt.status = "idle"; + } + if (rt.status === "running" || rt.starting || rt.status === "initializing") { + const provider = this.providerOf(meta); + if (behavior === "steer" && provider?.capabilities.steer && rt.session?.steer && !rt.starting) { + try { + const images = await this.resolveAttachments(attachmentRefs); + await rt.session.steer({ text: input.text, images }); + await this.appendRaw(threadId, { + type: "user_message", + text: input.text, + steered: true, + attachments: this.attachmentLabels(attachmentRefs) + }); + return; + } catch (e) { + log("info", `thread ${threadId} \u63D2\u8BDD\u5931\u8D25\uFF0C\u6539\u4E3A\u6392\u961F\uFF1A${String(e)}`); + } + } + const q = { id: "q" + Date.now().toString(36), text: input.text, attachments: attachmentRefs }; + rt.queue.push(q); + this.gw.publish(`thread:${threadId}`, { type: "queue", queue: rt.queue }); + return; + } + await this.startTurn(threadId, input.text, attachmentRefs); + } + async startTurn(threadId, text, attachmentRefs) { + const rt = this.rt(threadId); + rt.starting = true; + try { + await this.startTurnInner(threadId, text, attachmentRefs); + } finally { + rt.starting = false; + } + } + async startTurnInner(threadId, text, attachmentRefs) { + const meta = await this.mustThread(threadId); + const rt = this.rt(threadId); + const provider = await this.assertProviderUsable(meta.provider, { allowDisabled: !!meta.providerSessionId }); + if (!rt.session) { + await this.setStatus(threadId, "initializing"); + try { + await this.createSessionIfNeeded(threadId, { updateStatus: true }); + } catch (e) { + await this.appendRaw(threadId, { type: "notice", level: "error", message: tr("notice.sessionStartFailed", { detail: String(e?.message ?? e) }) }); + await this.appendRaw(threadId, { type: "turn_ended", status: "failed", error: String(e?.message ?? e) }); + await this.setStatus(threadId, "error"); + throw e; + } + } + const trimmed = text.trim(); + if (meta.titleAuto && trimmed) { + await this.threads.patch(threadId, { title: trimmed.slice(0, 40), titleAuto: true }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + await this.appendRaw(threadId, { + type: "user_message", + text, + attachments: this.attachmentLabels(attachmentRefs) + }); + await this.setStatus(threadId, "running"); + try { + const images = await this.resolveAttachments(attachmentRefs); + await rt.session.send({ text, images }); + } catch (e) { + const msg = String(e?.message ?? e); + await this.appendRaw(threadId, { type: "notice", level: "error", message: tr("notice.sendFailed", { detail: msg }) }); + await this.appendRaw(threadId, { type: "turn_ended", status: "failed", error: msg }); + await this.threads.patch(threadId, { lastTurnStatus: "failed" }); + rt.currentTurnId = null; + await this.setStatus(threadId, "idle"); + throw e; + } + } + // ---- event pump ---- + async pumpEvents(threadId, session) { + const rt = this.rt(threadId); + let ended = false; + try { + for await (const ev of session.events) { + await this.onProviderEvent(threadId, ev); + if (ev.type === "turn_started") ended = false; + if (ev.type === "turn_ended") ended = true; + } + } catch (e) { + log("warn", `thread ${threadId} \u4E8B\u4EF6\u6D41\u5F02\u5E38\uFF1A${String(e)}`); + } + if (!ended && this.rt(threadId).session === session) { + await this.onProviderEvent(threadId, { + type: "turn_ended", + turnId: rt.currentTurnId ?? "unknown", + status: "failed", + error: tr("turn.agentExit") + }); + rt.session = null; + await this.setStatus(threadId, "closed"); + } + } + async onProviderEvent(threadId, ev) { + const rt = this.rt(threadId); + rt.lastActivity = Date.now(); + if (ev.type === "provider_session_id") { + await this.threads.patch(threadId, { providerSessionId: ev.id }); + return; + } + if (ev.type === "slash_commands") { + rt.commands = ev.commands; + const meta = await this.mustThread(threadId); + const proj = await this.projects.get(meta.projectId); + if (proj) this.putSlashCache(meta.provider, proj.path, ev.commands); + this.gw.publish(`thread:${threadId}`, { type: "commands", commands: ev.commands }); + return; + } + if (ev.type === "turn_started") { + rt.currentTurnId = ev.turnId; + await this.appendRaw(threadId, ev); + return; + } + if (ev.type === "assistant_delta" || ev.type === "reasoning_delta" || ev.type === "tool_output_delta") { + rt.coalescer.push(deltaKey(ev.type, ev.itemId), ev.text); + return; + } + const evItemId = ev.itemId; + if (typeof evItemId === "string") this.flushDeltas(rt, evItemId); + if (ev.type === "assistant_message" || ev.type === "reasoning") { + await this.appendRaw(threadId, ev); + return; + } + if (ev.type === "usage") { + const meta = await this.mustThread(threadId); + const u = meta.usage; + await this.threads.patch(threadId, { + usage: { + inputTokens: u.inputTokens + ev.inputTokens, + outputTokens: u.outputTokens + ev.outputTokens, + cacheReadTokens: u.cacheReadTokens + ev.cacheReadTokens, + costUsd: u.costUsd + ev.costUsd + } + }); + await this.pushEvent(threadId, ev); + return; + } + if (ev.type === "permission_request" || ev.type === "question") { + const options = ev.type === "permission_request" ? ev.options : []; + const pending = { + kind: ev.type === "permission_request" ? "permission" : "question", + requestId: ev.requestId, + options, + resolve: () => { + }, + event: ev + }; + const toolName = ev.detail?.toolName; + if (toolName && rt.allowAlways.has(toolName)) { + rt.session?.respond(ev.requestId, { optionId: "accept" }); + return; + } + rt.pending.set(ev.requestId, pending); + await this.appendRaw(threadId, ev); + return; + } + if (ev.type === "permission_resolved" || ev.type === "question_answered") { + if (!rt.pending.delete(ev.requestId)) return; + await this.appendRaw(threadId, ev); + return; + } + if (ev.type === "turn_ended") { + if (ev.turnId && ev.turnId !== "unknown" && rt.endedTurnIds.has(ev.turnId)) return; + if (rt.interruptTimer) { + clearTimeout(rt.interruptTimer); + rt.interruptTimer = null; + } + if (ev.turnId && ev.turnId !== "unknown") rt.endedTurnIds.add(ev.turnId); + rt.coalescer.dispose(); + rt.coalescer = this.newCoalescer(threadId); + await this.appendRaw(threadId, ev); + await this.threads.patch(threadId, { lastTurnStatus: ev.status }); + rt.currentTurnId = null; + if (ev.status === "failed" && rt.session && !this.alive(rt.session)) rt.session = null; + await this.setStatus(threadId, "idle"); + const next = rt.queue.shift(); + if (next) { + this.gw.publish(`thread:${threadId}`, { type: "queue", queue: rt.queue }); + await this.startTurn(threadId, next.text, next.attachments).catch((e) => { + log("warn", `thread ${threadId} \u6392\u961F\u6D88\u606F\u542F\u52A8\u5931\u8D25\uFF1A${String(e)}`); + }); + } + return; + } + await this.appendRaw(threadId, ev); + } + alive(_s) { + return true; + } + // ---- respond (permission/question answers) ---- + async respond(threadId, requestId, answer) { + const rt = this.rt(threadId); + const pending = rt.pending.get(requestId); + if (!pending) throw new ApiError("not_found", "\u6388\u6743/\u63D0\u95EE\u4E0D\u5B58\u5728\u6216\u5DF2\u56DE\u5E94"); + if (pending.kind === "permission" && answer.optionId) { + const valid = pending.options.some((o) => o.id === answer.optionId); + if (!valid) throw new ApiError("bad_params", `optionId \u975E\u6CD5\uFF1A${answer.optionId}`); + } + rt.pending.delete(requestId); + if (pending.kind === "permission") { + await this.appendRaw(threadId, { + type: "permission_resolved", + requestId, + optionId: answer.optionId, + cancelled: answer.cancelled + }); + const opt = pending.options.find((o) => o.id === answer.optionId); + if (opt?.kind === "allow_always") { + const toolName = pending.event.detail?.toolName; + if (toolName) rt.allowAlways.add(toolName); + } + } else { + await this.appendRaw(threadId, { + type: "question_answered", + requestId, + answers: answer.answers ?? {} + }); + } + rt.session?.respond(requestId, answer); + } + // ---- interrupt ---- + async interrupt(threadId) { + const rt = this.rt(threadId); + if (!rt.session || rt.status !== "running") return; + for (const [id, p] of rt.pending) { + await this.appendRaw(threadId, { type: "permission_resolved", requestId: id, cancelled: true }); + p.resolve({ cancelled: true }); + } + rt.pending.clear(); + await rt.session.interrupt(); + const turnId = rt.currentTurnId; + rt.interruptTimer = setTimeout(() => { + rt.interruptTimer = null; + if (rt.currentTurnId === turnId && turnId) { + void this.onProviderEvent(threadId, { type: "turn_ended", turnId, status: "interrupted" }).catch((e) => { + log("warn", `thread ${threadId} \u6253\u65AD\u515C\u5E95\u5931\u8D25\uFF1A${String(e)}`); + }); + } + }, 1e3); + } + // ---- setModel / setMode / setEffort ---- + async setModel(threadId, model) { + const meta = await this.mustThread(threadId); + let effort = meta.effort; + const p = getProvider(meta.provider); + if (p) { + const models2 = await cachedModels(p); + const row = models2.find((m) => model ? m.id === model : m.isDefault) ?? models2[0]; + const opts = row?.thinkingOptions ?? []; + if (effort && opts.length && !opts.some((o) => o.id === effort)) { + effort = null; + await this.appendRaw(threadId, { + type: "notice", + level: "info", + message: tr("notice.effortResetOnModel") + }); + } + } + await this.threads.patch(threadId, { model, effort }); + const rt = this.rt(threadId); + if (rt.session) await rt.session.setModel(model); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + async setMode(threadId, mode) { + await this.threads.patch(threadId, { mode }); + const rt = this.rt(threadId); + if (rt.session) await rt.session.setMode(mode); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + async setEffort(threadId, effort) { + const meta = await this.mustThread(threadId); + await this.threads.patch(threadId, { effort }); + const rt = this.rt(threadId); + if (rt.session) await rt.session.setEffort(effort); + if (meta.provider === "claude" && (rt.status === "running" || rt.status === "initializing")) { + await this.appendRaw(threadId, { + type: "notice", + level: "info", + message: tr("notice.effortNextTurn") + }); + } + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + async setCursorOpts(threadId, patch) { + const meta = await this.mustThread(threadId); + if (meta.provider !== "cursor") throw new ApiError("bad_params", "\u4EC5 Cursor \u5BF9\u8BDD\u652F\u6301\u8BE5\u9009\u9879"); + const next = await this.threads.patch(threadId, patch); + const rt = this.rt(threadId); + const sess = rt.session; + if (sess?.updateCursorOpts) { + await sess.updateCursorOpts(!!next.cursorFast, !!next.cursorAutoReview); + } + this.gw.publish("sidebar", { type: "thread.upsert", thread: next }); + } + /** The Agent can be switched only before any user message and while not running */ + async setProvider(threadId, providerId) { + await this.mustThread(threadId); + const meta = await this.threads.get(threadId); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const rt = this.rt(threadId); + if (rt.status === "running" || rt.status === "initializing" || rt.starting) { + throw new ApiError("busy", "\u5BF9\u8BDD\u8FD0\u884C\u4E2D\uFF0C\u4E0D\u80FD\u66F4\u6362 Agent"); + } + const events = await this.threads.readEvents(threadId, 0, 5e3); + if (events.some((e) => e.type === "user_message")) { + throw new ApiError("bad_params", "\u5DF2\u6709\u6D88\u606F\uFF0C\u4E0D\u80FD\u66F4\u6362 Agent"); + } + const p = await this.assertProviderUsable(providerId); + if (rt.session) { + try { + await rt.session.close(); + } catch { + } + rt.session = null; + rt.commands = []; + } + rt.status = "idle"; + await this.threads.patch(threadId, { + provider: providerId, + model: null, + mode: p.capabilities.modes.includes(meta.mode) ? meta.mode : p.capabilities.modes[0] ?? "default", + effort: null, + providerSessionId: null, + status: "idle" + }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + async close(threadId) { + const rt = this.rt(threadId); + if (rt.session) { + try { + await rt.session.close(); + } catch { + } + rt.session = null; + rt.commands = []; + } + await this.setStatus(threadId, "closed"); + } + /** Slash commands: no session by default; only with load:true do we create a session for providers that need one to list them */ + async listCommands(threadId, opts) { + let meta; + try { + meta = await this.mustThread(threadId); + } catch { + return []; + } + const rt = this.rt(threadId); + const proj = await this.projects.get(meta.projectId); + if (!proj) return []; + if (meta.provider === "codex") { + try { + const commands = codexSlashCommandsFromEntries(await loadCodexSlashEntries(proj.path)); + rt.commands = commands; + this.putSlashCache(meta.provider, proj.path, commands); + return commands; + } catch { + return this.getSlashCache(meta.provider, proj.path) ?? rt.commands; + } + } + if (rt.session) { + try { + const commands = await rt.session.listCommands(); + rt.commands = commands; + this.putSlashCache(meta.provider, proj.path, commands); + return commands; + } catch { + return rt.commands.length ? rt.commands : this.getSlashCache(meta.provider, proj.path) ?? []; + } + } + const cached = this.getSlashCache(meta.provider, proj.path); + if (cached) { + rt.commands = cached; + return cached; + } + if (!opts?.load) return []; + try { + const session = await this.createSessionIfNeeded(threadId, { updateStatus: false }); + const commands = await session.listCommands(); + rt.commands = commands; + this.putSlashCache(meta.provider, proj.path, commands); + return commands; + } catch { + return []; + } + } + slashCacheKey(provider, projectPath) { + return `${provider}\0${projectPath}`; + } + getSlashCache(provider, projectPath) { + const hit = this.slashCache.get(this.slashCacheKey(provider, projectPath)); + if (!hit) return null; + if (Date.now() > hit.expires) { + this.slashCache.delete(this.slashCacheKey(provider, projectPath)); + return null; + } + return hit.commands; + } + putSlashCache(provider, projectPath, commands) { + if (!commands.length) return; + this.slashCache.set(this.slashCacheKey(provider, projectPath), { + commands, + expires: Date.now() + SLASH_CACHE_TTL_MS + }); + } + async createSessionIfNeeded(threadId, opts) { + const rt = this.rt(threadId); + if (rt.session) return rt.session; + if (rt.sessionCreating) return rt.sessionCreating; + const creating = this.createSessionInner(threadId, opts); + rt.sessionCreating = creating; + try { + return await creating; + } finally { + if (rt.sessionCreating === creating) rt.sessionCreating = null; + } + } + async createSessionInner(threadId, opts) { + const meta = await this.mustThread(threadId); + const rt = this.rt(threadId); + if (rt.session) return rt.session; + const provider = await this.assertProviderUsable(meta.provider, { allowDisabled: !!meta.providerSessionId }); + const proj = await this.projects.get(meta.projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + let model = meta.model; + if (meta.provider === "dsh" && model) { + const available = await cachedModels(provider).catch(() => []); + if (available.length && !available.some((m) => m.id === model)) { + model = available.find((m) => /magpie/i.test(m.id))?.id ?? available.find((m) => m.isDefault)?.id ?? null; + await this.threads.patch(threadId, { model }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + } + if (opts.updateStatus) await this.setStatus(threadId, "initializing"); + const settings = await this.settings.get(); + const custom = settings.providerCommand?.[meta.provider]; + const launch = custom?.executable?.trim() ? { cmd: custom.executable.trim(), prefixArgs: custom.args ?? [] } : void 0; + rt.session = await provider.createSession({ + threadId, + cwd: proj.path, + model, + mode: meta.mode, + effort: meta.effort, + resumeId: meta.providerSessionId, + cursorFast: meta.cursorFast, + cursorAutoReview: meta.cursorAutoReview, + launch + }); + void this.pumpEvents(threadId, rt.session); + return rt.session; + } + async deleteThread(threadId) { + await this.close(threadId); + this.runtimes.delete(threadId); + await this.threads.delete(threadId); + } + // ---- internals ---- + async mustThread(threadId) { + const meta = await this.threads.get(threadId); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + return meta; + } + providerOf(meta) { + return getProvider(meta.provider); + } + async assertProviderUsable(providerId, opts = {}) { + const p = getProvider(providerId); + if (!p) throw new ApiError("bad_params", `\u672A\u77E5 Agent\uFF1A${providerId}`); + const s = await this.settings.get(); + if (!opts.allowDisabled && !isProviderEnabled(s, providerId)) { + throw new ApiError("bad_params", `${p.name} \u5DF2\u5728\u8BBE\u7F6E\u4E2D\u7981\u7528`); + } + const entry = catalogById(providerId); + if (entry && !providerCreatable(entry.status)) { + throw new ApiError("bad_params", `${p.name} \u5C1A\u672A\u63A5\u5165\uFF0C\u6682\u4E0D\u80FD\u65B0\u5EFA\u5BF9\u8BDD`); + } + const det = await cachedDetect(p); + if (!det.available) throw new ApiError("bad_params", det.reason ?? `${p.name} \u672A\u5B89\u88C5\u6216\u4E0D\u53EF\u7528`); + return p; + } + async setStatus(threadId, status) { + const rt = this.rt(threadId); + rt.status = status; + const meta = await this.threads.patch(threadId, { status }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: meta }); + this.gw.publish(`thread:${threadId}`, { type: "status", status }); + } + // Append an event: seq is assigned in the store's serialized section, persisted, then pushed to thread: + async appendRaw(threadId, ev) { + const full = await this.threads.appendEvent(threadId, { ...ev, ts: Date.now() }); + this.gw.publish(`thread:${threadId}`, { type: "event", event: full }); + } + // Push without persisting (for coalescer deltas; usage is already accumulated above). + // Unpersisted events take no seq: seq=-1 so the frontend dedupes only by persisted seq, + // otherwise deltas would push the client's maxSeq up and the following persisted events would be dropped by mistake. + async pushEvent(threadId, ev) { + const full = { ...ev, seq: -1, ts: Date.now(), threadId }; + this.gw.publish(`thread:${threadId}`, { type: "event", event: full }); + } + async recycleIdle() { + const s = await this.settings.get(); + const limit = (s.idleCloseMinutes ?? 30) * 6e4; + for (const rt of this.runtimes.values()) { + if (rt.status === "idle" && rt.pending.size === 0 && Date.now() - rt.lastActivity > limit) { + log("info", `\u7A7A\u95F2\u56DE\u6536 thread ${rt.threadId}`); + await this.close(rt.threadId); + } + } + } +}; + +// daemon/src/git/service.ts +import { execFile as execFile4 } from "node:child_process"; +import { existsSync as existsSync10 } from "node:fs"; +import { cp, mkdir as mkdir6, readFile as readFile14, rename as rename4, rm, stat as stat7 } from "node:fs/promises"; +import { join as join14, normalize as normalize2, sep as sep2 } from "node:path"; +var TIMEOUT2 = 15e3; +var DIFF_LIMIT = 1024 * 1024; +function runGit(cwd, args) { + return new Promise((res) => { + execFile4("git", ["-c", "core.quotepath=false", ...args], { cwd, timeout: TIMEOUT2, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (e, stdout, stderr) => { + const code = e && typeof e.code === "number" ? e.code : e ? 1 : 0; + const missing = e?.code === "ENOENT"; + res({ code, out: String(stdout ?? ""), err: missing ? "\u627E\u4E0D\u5230 git\uFF08\u8BF7\u5148\u5B89\u88C5 git \u5E76\u52A0\u5165 PATH\uFF09" : String(stderr || (e?.message ?? "")) }); + }); + }); +} +function insidePath(projectPath, p) { + const rel = normalize2(p).replace(/\\/g, "/"); + const abs = normalize2(join14(projectPath, rel)); + const proj = normalize2(projectPath); + if (abs !== proj && !abs.startsWith(proj + sep2)) return null; + return rel.replace(/\\/g, "/"); +} +async function moveAcrossDevices(from, to, ops = {}) { + const ren = ops.rename ?? rename4; + try { + await ren(from, to); + } catch (e) { + const code = e?.code; + if (code !== "EXDEV") throw e; + await (ops.cp ?? cp)(from, to, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true }); + await (ops.rm ?? rm)(from, { recursive: true, force: true }); + } +} +var GitService = class { + // projectPath is resolved from projectId by the caller (the service doesn't know about the store) + async status(projectPath) { + const inside = await runGit(projectPath, ["rev-parse", "--is-inside-work-tree"]); + if (inside.code !== 0 || inside.out.trim() !== "true") return { isRepo: false }; + const br = await runGit(projectPath, ["rev-parse", "--abbrev-ref", "HEAD"]); + const st = await runGit(projectPath, ["status", "--porcelain=v1", "-z", "-uall"]); + if (st.code !== 0) return { isRepo: true, branch: br.out.trim() || void 0, error: st.err.trim() || "status \u5931\u8D25" }; + const files = []; + const parts = st.out.split("\0"); + for (let i = 0; i < parts.length; i++) { + const rec5 = parts[i]; + if (!rec5 || rec5.length < 4) continue; + const index = rec5[0]; + const worktree = rec5[1]; + let path3 = rec5.slice(3); + let kind = "modified"; + if (index === "?" && worktree === "?") kind = "untracked"; + else if (index === "R" || worktree === "R") { + kind = "renamed"; + i++; + } else if (index === "A" || worktree === "A") kind = "added"; + else if (index === "D" || worktree === "D") kind = "deleted"; + files.push({ path: path3, index, worktree, kind }); + } + return { isRepo: true, branch: br.out.trim() || void 0, files }; + } + // Unified diff text; {tooLarge:true} when the diff is > 1MB + async diff(projectPath, p) { + const rel = insidePath(projectPath, p); + if (!rel) return { error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + const ls = await runGit(projectPath, ["ls-files", "--error-unmatch", "--", rel]); + if (ls.code !== 0) { + const full = join14(projectPath, rel); + if (!existsSync10(full)) return { error: "\u6587\u4EF6\u4E0D\u5B58\u5728" }; + const s = await stat7(full); + if (!s.isFile()) return { error: "\u4E0D\u662F\u6587\u4EF6" }; + if (s.size > DIFF_LIMIT) return { tooLarge: true }; + const text = await readFile14(full, "utf8").catch(() => null); + if (text === null) return { error: "\u4E8C\u8FDB\u5236\u6216\u65E0\u6CD5\u8BFB\u53D6" }; + const lines = text.split("\n").map((l) => "+" + l).join("\n"); + return { untracked: true, diff: `--- /dev/null ++++ b/${rel} +@@ -0,0 +1,${text.split("\n").length} @@ +${lines}` }; + } + let d = await runGit(projectPath, ["diff", "--no-color", "--", rel]); + if (d.code !== 0) return { error: d.err.trim() || "diff \u5931\u8D25" }; + if (!d.out.trim()) { + d = await runGit(projectPath, ["diff", "--cached", "--no-color", "--", rel]); + if (d.code !== 0) return { error: d.err.trim() || "diff \u5931\u8D25" }; + } + if (d.out.length > DIFF_LIMIT) return { tooLarge: true }; + return { diff: d.out }; + } + // Tracked → git restore; untracked → move to trash/discarded// + async discard(projectPath, p, dataDir) { + const rel = insidePath(projectPath, p); + if (!rel) return { ok: false, error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + if (rel === "." || rel === "" || rel === "./") return { ok: false, error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + const ls = await runGit(projectPath, ["ls-files", "--error-unmatch", "--", rel]); + if (ls.code === 0) { + let r = await runGit(projectPath, ["restore", "--staged", "--worktree", "--", rel]); + if (r.code !== 0) r = await runGit(projectPath, ["checkout", "--", rel]); + if (r.code !== 0) return { ok: false, error: r.err.trim() || "\u653E\u5F03\u5931\u8D25" }; + return { ok: true }; + } + const full = join14(projectPath, rel); + if (!existsSync10(full)) return { ok: false, error: "\u6587\u4EF6\u4E0D\u5B58\u5728" }; + const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19); + const dest = join14(dataDir, "trash", "discarded", stamp2, rel); + await mkdir6(join14(dest, ".."), { recursive: true }); + try { + await moveAcrossDevices(full, dest); + } catch (e) { + return { ok: false, error: "\u79FB\u52A8\u5931\u8D25\uFF1A" + String(e?.message ?? e) }; + } + return { ok: true, trashedTo: dest }; + } +}; + +// daemon/src/files/service.ts +import { readdir as readdir5, readFile as readFile15, stat as stat8 } from "node:fs/promises"; +import { join as join15, normalize as normalize3, sep as sep3, extname as extname3 } from "node:path"; +import ignore from "ignore"; +function decodeText(buf) { + try { + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(buf); + return { text, encoding: "utf8" }; + } catch { + } + try { + const text = new TextDecoder("gbk", { fatal: true }).decode(buf); + return { text, encoding: "gbk" }; + } catch { + } + return { text: new TextDecoder("utf-8").decode(buf), encoding: "utf8" }; +} +var MAX_READ = 1024 * 1024; +var MAX_PER_LEVEL = 2e3; +var ALWAYS_IGNORE = /* @__PURE__ */ new Set([".git", "node_modules"]); +var LANG = { + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".json": "json", + ".md": "markdown", + ".py": "python", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".hpp": "cpp", + ".cs": "csharp", + ".css": "css", + ".scss": "scss", + ".html": "xml", + ".htm": "xml", + ".xml": "xml", + ".svg": "xml", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "ini", + ".ini": "ini", + ".sh": "bash", + ".bash": "bash", + ".ps1": "powershell", + ".sql": "sql", + ".lua": "lua", + ".rb": "ruby", + ".php": "php", + ".swift": "swift", + ".kt": "kotlin", + ".vue": "xml", + ".svelte": "xml", + ".dockerfile": "dockerfile", + ".txt": "plaintext", + ".log": "plaintext" +}; +function insidePath2(projectPath, p) { + const rel = normalize3(p || ".").replace(/\\/g, "/"); + const abs = normalize3(join15(projectPath, rel)); + const proj = normalize3(projectPath); + if (abs !== proj && !abs.startsWith(proj + sep3)) return null; + return rel; +} +var FileService = class { + // Cache an ignore instance per project (.gitignore changes are picked up on the next daemon restart; acceptable cost) + // Invalidate by .gitignore mtime: an edited .gitignore takes effect the next time a directory is expanded + igCache = /* @__PURE__ */ new Map(); + async igFor(projectPath) { + const gi = join15(projectPath, ".gitignore"); + const st = await stat8(gi).catch(() => null); + const mtime = st?.isFile() ? st.mtimeMs : -1; + const hit = this.igCache.get(projectPath); + if (hit && hit.mtime === mtime) return hit.ig; + let ig = null; + if (mtime >= 0) { + try { + ig = ignore().add(await readFile15(gi, "utf8")); + } catch { + ig = null; + } + } + this.igCache.set(projectPath, { mtime, ig }); + return ig; + } + async tree(projectPath, dir) { + const rel = insidePath2(projectPath, dir); + if (rel === null) return { error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + const abs = join15(projectPath, rel); + const s = await stat8(abs).catch(() => null); + if (!s?.isDirectory()) return { error: "\u76EE\u5F55\u4E0D\u5B58\u5728" }; + const ig = await this.igFor(projectPath); + const dirents = await readdir5(abs, { withFileTypes: true }).catch(() => null); + if (!dirents) return { error: "\u65E0\u6CD5\u8BFB\u53D6\u76EE\u5F55" }; + const out = []; + for (const d of dirents) { + if (out.length >= MAX_PER_LEVEL) break; + const name = d.name; + if (ALWAYS_IGNORE.has(name)) continue; + const childRel = rel === "." ? name : `${rel}/${name}`; + let isDir2 = d.isDirectory(); + if (d.isSymbolicLink()) { + const st = await stat8(join15(projectPath, childRel)).catch(() => null); + if (!st) continue; + isDir2 = st.isDirectory(); + } else if (!d.isFile() && !isDir2) { + continue; + } + if (ig) { + if (ig.ignores(isDir2 ? childRel + "/" : childRel)) continue; + } + out.push({ name, path: childRel, isDir: isDir2 }); + } + out.sort((a, b) => a.isDir === b.isDir ? a.name.localeCompare(b.name, "zh-CN") : a.isDir ? -1 : 1); + return { entries: out }; + } + async read(projectPath, p) { + const rel = insidePath2(projectPath, p); + if (rel === null) return { error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + const abs = join15(projectPath, rel); + const s = await stat8(abs).catch(() => null); + if (!s) return { error: "\u6587\u4EF6\u4E0D\u5B58\u5728" }; + if (!s.isFile()) return { error: "\u4E0D\u662F\u6587\u4EF6" }; + if (s.size > MAX_READ) return { tooLarge: true }; + const buf = await readFile15(abs).catch(() => null); + if (!buf) return { error: "\u65E0\u6CD5\u8BFB\u53D6" }; + const head = buf.subarray(0, 8192); + if (head.includes(0)) return { binary: true }; + const { text, encoding } = decodeText(buf); + return { text, encoding, language: LANG[extname3(rel).toLowerCase()] ?? "plaintext" }; + } +}; + +// daemon/src/terminal/service.ts +init_pathscan(); +init_log(); +import { spawn as spawn3, execFileSync as execFileSync2 } from "node:child_process"; +import { accessSync as accessSync2, chmodSync, constants as fsc, existsSync as existsSync11, statSync as statSync6 } from "node:fs"; +import { createRequire as moduleRequire } from "node:module"; +import { release } from "node:os"; +import { dirname as dirname6, join as join16 } from "node:path"; +import { StringDecoder } from "node:string_decoder"; +var MAX_TERMS_PER_PROJECT = 8; +var BUF_MAX = 2 * 1024 * 1024; +var SNAP_LINES = 5e3; +function isExecFile(file) { + try { + accessSync2(file, fsc.X_OK); + return statSync6(file).isFile(); + } catch { + return false; + } +} +function pickShell(o = {}) { + const platform = o.platform ?? process.platform; + const env = o.env ?? process.env; + const find = o.find ?? ((n) => findOnPath(n, { platform, env })[0] ?? null); + const isExec = o.isExec ?? isExecFile; + if (platform === "win32") { + for (const s of ["pwsh", "powershell"]) { + const hit = find(s); + if (hit) return hit; + } + const sysRoot = env.SystemRoot || env.SYSTEMROOT || env.windir || "C:\\Windows"; + const ps = `${sysRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + if (isExec(ps)) return ps; + return env.ComSpec || env.COMSPEC || "cmd.exe"; + } + if (env.SHELL && env.SHELL.startsWith("/") && isExec(env.SHELL)) return env.SHELL; + const order = platform === "darwin" ? ["/bin/zsh", "/bin/bash", "/bin/sh"] : ["/bin/bash", "/usr/bin/bash", "/bin/zsh", "/bin/sh"]; + for (const s of order) if (isExec(s)) return s; + return "/bin/sh"; +} +function simpleShellArgs(shell, platform = process.platform) { + if (platform === "win32") { + if (/(^|[\\/])cmd(\.exe)?$/i.test(shell)) return ["/Q"]; + return ["-NoLogo"]; + } + return ["-i"]; +} +function fixSpawnHelper() { + if (process.platform !== "darwin") return; + try { + const req = moduleRequire(import.meta.url); + const root = dirname6(req.resolve("node-pty/package.json")); + for (const sub of [`prebuilds/darwin-${process.arch}`, "build/Release"]) { + const f = join16(root, sub, "spawn-helper"); + if (existsSync11(f) && !isExecFile(f)) { + chmodSync(f, 493); + log("info", "\u5DF2\u4FEE\u590D node-pty spawn-helper \u7684\u53EF\u6267\u884C\u4F4D", { file: f }); + } + } + } catch { + } +} +function conptyInfo() { + const build = Number(release().split(".")[2]); + return { backend: "conpty", buildNumber: Number.isFinite(build) ? build : 0 }; +} +var nextIndex = 1; +var rid5 = () => "t_" + Math.random().toString(36).slice(2, 10); +var TerminalService = class { + constructor(gw) { + this.gw = gw; + gw.onTerminalInput = (conn, index, op, payload) => this.onInput(conn, index, op, payload); + } + gw; + terms = /* @__PURE__ */ new Map(); + byIndex = /* @__PURE__ */ new Map(); + ptyLib; + async loadPty() { + if (this.ptyLib !== void 0) return this.ptyLib; + try { + fixSpawnHelper(); + this.ptyLib = await import("node-pty"); + } catch (e) { + log("warn", "node-pty \u52A0\u8F7D\u5931\u8D25\uFF0C\u7EC8\u7AEF\u9000\u5316\u65E0 PTY \u65B9\u6848", { err: String(e) }); + this.ptyLib = null; + } + return this.ptyLib; + } + list(projectId) { + return [...this.terms.values()].filter((t) => !projectId || t.projectId === projectId).map((t) => ({ id: t.id, index: t.index, projectId: t.projectId, title: t.title, alive: t.alive, simple: t.simple })); + } + get count() { + return [...this.terms.values()].filter((t) => t.alive).length; + } + async create(projectId, cwd, cols, rows) { + const alive = [...this.terms.values()].filter((t) => t.projectId === projectId && t.alive); + if (alive.length >= MAX_TERMS_PER_PROJECT) return { error: `\u6BCF\u4E2A\u9879\u76EE\u6700\u591A ${MAX_TERMS_PER_PROJECT} \u4E2A\u7EC8\u7AEF` }; + const shell = pickShell(); + const term = { + id: rid5(), + index: nextIndex++, + projectId, + cwd, + title: `\u7EC8\u7AEF ${alive.length + 1}`, + alive: true, + simple: false, + pty: null, + proc: null, + buf: "", + pend: "", + timer: null + }; + const pty = await this.loadPty(); + if (pty) { + try { + const p = pty.spawn(shell, [], { + name: "xterm-256color", + cols: Math.max(20, cols), + rows: Math.max(5, rows), + cwd, + env: process.env + }); + term.pty = { write: (d) => p.write(d), resize: (c, r) => p.resize(c, r), kill: () => p.kill(), onData: (cb) => p.onData(cb), onExit: (cb) => p.onExit(cb), pid: p.pid }; + term.pty.onData((d) => this.onData(term, d)); + term.pty.onExit(() => this.onExit(term)); + } catch (e) { + log("warn", "pty.spawn \u5931\u8D25\uFF0C\u9000\u5316\u65E0 PTY \u65B9\u6848", { err: String(e) }); + } + } + if (!term.pty) { + term.simple = true; + const args = simpleShellArgs(shell); + const proc = spawn3(shell, args, { + cwd, + stdio: "pipe", + windowsHide: true, + env: process.env, + detached: process.platform !== "win32" + }); + term.proc = proc; + const outDec = new StringDecoder("utf8"); + const errDec = new StringDecoder("utf8"); + proc.stdout.on("data", (d) => this.onData(term, outDec.write(d))); + proc.stderr.on("data", (d) => this.onData(term, errDec.write(d))); + proc.on("exit", () => this.onExit(term)); + proc.on("error", () => this.onExit(term)); + } + this.terms.set(term.id, term); + this.byIndex.set(term.index, term); + return { id: term.id, index: term.index, simple: term.simple }; + } + attach(id) { + const t = this.terms.get(id); + if (!t) return { error: "\u7EC8\u7AEF\u4E0D\u5B58\u5728" }; + const lines = t.buf.split("\n"); + const snapshot = lines.length > SNAP_LINES ? lines.slice(-SNAP_LINES).join("\n") : t.buf; + const windowsPty = process.platform === "win32" && t.pty ? conptyInfo() : void 0; + return { index: t.index, snapshot, simple: t.simple, ...windowsPty ? { windowsPty } : {} }; + } + indexOf(id) { + return this.terms.get(id)?.index ?? null; + } + resize(id, cols, rows) { + const t = this.terms.get(id); + if (!t || !t.alive) return { ok: false }; + t.pty?.resize(Math.max(20, cols), Math.max(5, rows)); + return { ok: true }; + } + kill(id) { + const t = this.terms.get(id); + if (!t) return { ok: false }; + this.killTerm(t); + return { ok: true }; + } + killAll() { + for (const t of this.terms.values()) this.killTerm(t, true); + } + // ---- internals ---- + // Killing only the shell would orphan the npm run dev etc. it started → on Windows taskkill /T kills the whole tree + killTree(pid, sync, group) { + if (!pid) return; + if (process.platform !== "win32") { + if (group) { + try { + process.kill(-pid, "SIGTERM"); + } catch { + } + const t = setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + } + }, 1500); + t.unref?.(); + } + return; + } + const args = ["/pid", String(pid), "/T", "/F"]; + try { + if (sync) execFileSync2("taskkill", args, { windowsHide: true, stdio: "ignore", timeout: 5e3 }); + else spawn3("taskkill", args, { windowsHide: true, stdio: "ignore" }).on("error", () => { + }); + } catch { + } + } + killTerm(t, sync = false) { + t.alive = false; + this.killTree(t.pty?.pid ?? t.proc?.pid, sync, !t.pty && !!t.proc); + if (t.timer) { + clearTimeout(t.timer); + t.timer = null; + } + try { + t.pty?.kill(); + } catch { + } + try { + t.proc?.kill(); + } catch { + } + this.terms.delete(t.id); + this.byIndex.delete(t.index); + } + onExit(t) { + if (t.timer) { + clearTimeout(t.timer); + t.timer = null; + if (t.pend) { + this.gw.publishTerminal(t.index, Buffer.from(t.pend, "utf8")); + t.pend = ""; + } + } + if (t.exited) return; + t.exited = true; + t.alive = false; + this.terms.delete(t.id); + this.byIndex.delete(t.index); + this.gw.publish("terminals", { type: "terminal.exit", id: t.id, index: t.index }); + } + onData(t, d) { + t.buf += d; + if (t.buf.length > BUF_MAX) t.buf = t.buf.slice(-BUF_MAX / 2); + t.pend += d; + if (!t.timer) { + t.timer = setTimeout(() => { + t.timer = null; + if (t.pend) { + this.gw.publishTerminal(t.index, Buffer.from(t.pend, "utf8")); + t.pend = ""; + } + }, 16); + } + } + // Input frames: op 2=input, 3=resize + onInput(conn, index, op, payload) { + const t = this.byIndex.get(index); + if (!t || !t.alive) return; + if (!conn.subs.has(`terminal:${index}`)) return; + if (op === 2) { + const s = payload.toString("utf8"); + if (t.pty) t.pty.write(s); + else t.proc?.stdin.write(s); + } else if (op === 3) { + try { + const { cols, rows } = JSON.parse(payload.toString("utf8")); + if (cols && rows) this.resize(t.id, cols, rows); + } catch { + } + } + } +}; + +// daemon/src/util/proc.ts +init_pathscan(); +import { delimiter } from "node:path"; +function pidAlive(pid, kill = (p, s) => process.kill(p, s)) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + kill(pid, 0); + return true; + } catch (e) { + return e?.code === "EPERM"; + } +} +function augmentPath(env, nodeDir, platform = process.platform, home) { + const win = platform === "win32"; + const sep4 = win ? ";" : platform === process.platform ? delimiter : ":"; + const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH"; + const cur = (env[key] ?? "").split(sep4).filter(Boolean); + const norm = (d) => win ? d.replace(/[\\/]+$/, "").toLowerCase() : d.replace(/\/+$/, ""); + const seen = new Set(cur.map(norm)); + const out = [...cur]; + for (const d of [nodeDir, ...extraBinDirs({ platform, env, home })]) { + if (!d || seen.has(norm(d))) continue; + seen.add(norm(d)); + out.push(d); + } + return out.join(sep4); +} + +// daemon/src/main.ts +var nodeMajor = Number(/^v?(\d+)/.exec(process.versions.node ?? "")?.[1] ?? 0); +if (nodeMajor < 20) { + console.error(`[agent-console] \u9700\u8981 Node.js 20 \u6216\u66F4\u9AD8\u7248\u672C\uFF0C\u5F53\u524D ${process.version}\uFF08${process.execPath}\uFF09`); + process.exit(3); +} +process.env.PATH = augmentPath(process.env, dirname7(process.execPath)); +var cfg = parseConfig(process.argv.slice(2)); +process.on("uncaughtException", (e) => { + log("error", "uncaughtException", { err: String(e?.stack ?? e) }); +}); +process.on("unhandledRejection", (e) => { + log("error", "unhandledRejection", { err: String(e) }); +}); +async function main() { + initLog(cfg.dataDir); + for (const d of ["threads", "attachments", "trash", "logs"]) { + mkdirSync2(join18(cfg.dataDir, d), { recursive: true }); + } + const appDir = join18(dirname7(fileURLToPath3(import.meta.url)), "..", "..", "app"); + const attachments = new AttachmentStore(join18(cfg.dataDir, "attachments")); + const { bindAttachmentStore: bindAttachmentStore2 } = await Promise.resolve().then(() => (init_attachmentRefs(), attachmentRefs_exports)); + bindAttachmentStore2(attachments); + const settings = new SettingsStore(cfg.dataDir); + const projects = new ProjectsStore(cfg.dataDir); + const threads = new ThreadsStore(cfg.dataDir); + const gateway = new Gateway(cfg); + initProviders(cfg.version); + const { migrateLegacy: migrateLegacy2 } = await Promise.resolve().then(() => (init_legacy(), legacy_exports)); + try { + await migrateLegacy2(cfg.dataDir, { settings, projects, threads }); + } catch (e) { + log("error", "\u65E7\u6570\u636E\u8FC1\u79FB\u5931\u8D25", { err: String(e) }); + } + const manager = new AgentManager(gateway, threads, projects, settings, attachments); + await manager.recoverOnStart(); + const terminals = new TerminalService(gateway); + registerMethods(gateway, { + cfg, + settings, + gateway, + projects, + threads, + manager, + git: new GitService(), + files: new FileService(), + terminals, + runtime: (id) => manager.runtimeInfo(id) + }); + const server = createHttpServer(cfg, appDir, attachments, { + onUpgrade: (req, socket, head) => gateway.handleUpgrade(req, socket, head) + }); + let attempts = 0; + await new Promise((resolveListen, rejectListen) => { + const tryListen = () => { + attempts++; + server.once("error", (e) => { + if (e.code === "EADDRINUSE" && attempts < 6) { + log("warn", `\u7AEF\u53E3 ${cfg.port} \u88AB\u5360\u7528\uFF0C500ms \u540E\u91CD\u8BD5\uFF08\u7B2C ${attempts} \u6B21\uFF09`); + setTimeout(tryListen, 500); + } else { + rejectListen(e); + } + }); + server.listen(cfg.port, "127.0.0.1"); + }; + server.once("listening", () => resolveListen()); + tryListen(); + }).catch((e) => { + log("error", `\u7AEF\u53E3 ${cfg.port} \u65E0\u6CD5\u76D1\u542C`, { err: String(e) }); + process.exit(2); + }); + const bound = server.address(); + if (bound && typeof bound === "object") cfg.port = bound.port; + process.stdout.write(JSON.stringify({ agentConsoleReady: true, port: cfg.port, pid: process.pid }) + "\n"); + void (async () => { + const { warmProviderCache: warmProviderCache2 } = await Promise.resolve().then(() => (init_warm(), warm_exports)); + await warmProviderCache2(await settings.get()); + })().catch((e) => log("warn", "Provider \u9884\u70ED\u5931\u8D25", { err: String(e) })); + const daemonJsonPath = join18(cfg.dataDir, "daemon.json"); + await writeJsonAtomic(daemonJsonPath, { + pid: process.pid, + port: cfg.port, + token: cfg.token, + version: cfg.version, + buildStamp: cfg.buildStamp, + startedAt: Date.now() + }); + chmodSync2(daemonJsonPath, 384); + log("info", `daemon \u5DF2\u542F\u52A8 \u7AEF\u53E3 ${cfg.port} \u6570\u636E\u76EE\u5F55 ${cfg.dataDir} \u7248\u672C ${cfg.version} pid ${process.pid}`); + if (!cfg.parentPid) console.log(`[agent-console] \u6253\u5F00 http://127.0.0.1:${cfg.port}/#t=${cfg.token}`); + let idleSince = Date.now(); + setInterval(() => { + const busy = gateway.connectionCount > 0 || manager.anyBusy() || terminals.count > 0; + if (busy) idleSince = Date.now(); + if (Date.now() - idleSince > cfg.idleExitMinutes * 60 * 1e3) { + log("info", "\u7A7A\u95F2\u8D85\u65F6\uFF0Cdaemon \u9000\u51FA"); + shutdown(0); + } + }, 60 * 1e3).unref(); + let shuttingDown = false; + function shutdown(code) { + if (shuttingDown) return; + shuttingDown = true; + log("info", "daemon \u5173\u95ED\u4E2D"); + terminals.killAll(); + gateway.closeAll(); + try { + rmSync2(daemonJsonPath, { force: true }); + } catch { + } + const sessionsClosed = Promise.race([manager.shutdown(), new Promise((r) => setTimeout(r, 2500))]); + void sessionsClosed.then(() => server.close(() => process.exit(code))); + setTimeout(() => process.exit(code), 4e3).unref(); + } + process.on("SIGINT", () => shutdown(0)); + process.on("SIGTERM", () => shutdown(0)); + if (cfg.parentPid) { + const parentPid = cfg.parentPid; + const parentWatch = setInterval(() => { + if (!pidAlive(parentPid)) { + clearInterval(parentWatch); + log("info", `\u7236\u8FDB\u7A0B ${parentPid} \u5DF2\u9000\u51FA\uFF0Cdaemon \u8DDF\u968F\u9000\u51FA`); + shutdown(0); + } + }, 2e3); + parentWatch.unref(); + process.stdin.on("end", () => shutdown(0)); + process.stdin.on("close", () => shutdown(0)); + process.stdin.on("error", () => shutdown(0)); + process.stdin.resume(); + } +} +main().catch((e) => { + console.error("[agent-console] \u542F\u52A8\u5931\u8D25", e); + process.exit(1); +}); +//# sourceMappingURL=main.js.map diff --git a/packages/agent-console/daemon/dist/main.js.map b/packages/agent-console/daemon/dist/main.js.map new file mode 100644 index 00000000..f1cab392 --- /dev/null +++ b/packages/agent-console/daemon/dist/main.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/log.ts","../src/util/paths.ts","../src/util/queue.ts","../src/agents/diff.ts","../src/providers/slash.ts","../src/providers/mock.ts","../../lib/pathscan.mjs","../src/util/bin.ts","../src/agents/kinds.ts","../src/providers/claude/map.ts","../src/util/i18n.ts","../src/util/attachmentRefs.ts","../src/providers/claude/session.ts","../src/providers/claude/models.ts","../src/history/import.ts","../src/providers/claude/provider.ts","../src/history/codex.ts","../src/providers/codex/rpc.ts","../src/providers/codex/map.ts","../src/providers/codex/commands.ts","../src/providers/codex/session.ts","../src/providers/codex/provider.ts","../src/providers/catalog.ts","../src/providers/acp/spawn.ts","../src/providers/acp/clientHandlers.ts","../src/providers/acp/mapUpdate.ts","../src/providers/acp/session.ts","../src/providers/acp/provider.ts","../src/providers/acp/probe.ts","../src/providers/acp/specs.ts","../src/providers/registry.ts","../src/providers/enabled.ts","../src/util/pickFolderWin.ts","../src/util/pickFolder.ts","../src/providers/magpieGateway.ts","../src/providers/infoCache.ts","../src/ws/methods.ts","../src/util/atomic.ts","../src/util/jsonl.ts","../src/migrate/legacy.ts","../src/providers/warm.ts","../src/main.ts","../src/config.ts","../src/util/buildStamp.ts","../src/http.ts","../src/security.ts","../src/store/attachments.ts","../src/store/settings.ts","../src/store/projects.ts","../src/store/threads.ts","../src/ws/gateway.ts","../src/agents/manager.ts","../src/timeline/coalescer.ts","../src/git/service.ts","../src/files/service.ts","../src/terminal/service.ts","../src/util/proc.ts"],"sourcesContent":["// Simple logging: writes dataDir/logs/daemon-YYYY-MM-DD.log (kept 7 days) and to the console.\nimport { appendFileSync, mkdirSync, readdirSync, rmSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nlet logDir = \"\";\nlet today = \"\";\nlet logFile = \"\";\n\nfunction fileName(d: Date): string {\n const p = (n: number) => String(n).padStart(2, \"0\");\n return `daemon-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}.log`;\n}\n\nexport function initLog(dataDir: string): void {\n logDir = join(dataDir, \"logs\");\n mkdirSync(logDir, { recursive: true });\n // Keep 7 days\n try {\n const keep = new Set();\n const now = new Date();\n for (let i = 0; i < 7; i++) {\n keep.add(fileName(new Date(now.getTime() - i * 86400000)));\n }\n for (const f of readdirSync(logDir)) {\n if (/^daemon-\\d{4}-\\d{2}-\\d{2}\\.log$/.test(f) && !keep.has(f)) {\n rmSync(join(logDir, f), { force: true });\n }\n }\n } catch {}\n}\n\nexport function log(level: \"info\" | \"warn\" | \"error\", msg: string, extra?: unknown): void {\n const line = `[${new Date().toISOString()}] [${level}] ${msg}${extra !== undefined ? \" \" + safeJson(extra) : \"\"}`;\n try {\n const d = new Date();\n const name = fileName(d);\n if (name !== today && logDir) {\n today = name;\n logFile = join(logDir, name);\n }\n if (logFile) appendFileSync(logFile, line + \"\\n\", \"utf8\");\n } catch {}\n const fn = level === \"error\" ? console.error : level === \"warn\" ? console.warn : console.log;\n fn(`[agent-console] ${msg}`, extra !== undefined ? extra : \"\");\n}\n\nfunction safeJson(v: unknown): string {\n try {\n return JSON.stringify(v);\n } catch {\n return String(v);\n }\n}\n","// Path utilities: normalization, samePath, isInside (decided after resolving realpath).\nimport { realpathSync, existsSync } from \"node:fs\";\nimport { posix, sep, win32 } from \"node:path\";\n\n// Normalization: realpath (when it exists) + unified separators + case-insensitive on Windows/macOS (NTFS and APFS are case-insensitive by default).\n// Only for comparison/dedupe, never for display or storage.\nexport function normPath(p: string, platform: NodeJS.Platform = process.platform): string {\n // Resolve with the target platform's rules: normalizing a macOS/Linux path on Windows must not prepend the host drive (D:\\…)\n let r = (platform === \"win32\" ? win32 : posix).resolve(p);\n try {\n // native restores 8.3 short names (C:\\PROGRA~1), subst drives and letter case to what's on disk; fall back to the JS implementation on failure\n r = realpathSync.native(r);\n } catch {\n try {\n r = realpathSync(r);\n } catch {}\n }\n if (platform === \"win32\") {\n // Strip the \\\\?\\ long-path prefix; UNC stays \\\\server\\share\n r = r.replace(/\\//g, \"\\\\\").replace(/^\\\\\\\\\\?\\\\(?!UNC\\\\)/i, \"\").replace(/^\\\\\\\\\\?\\\\UNC\\\\/i, \"\\\\\\\\\").toLowerCase();\n } else {\n r = r.replace(/\\\\/g, \"/\");\n if (platform === \"darwin\") r = r.normalize(\"NFC\").toLowerCase();\n }\n // Strip trailing separators (except for the root)\n while (r.length > 3 && (r.endsWith(\"/\") || r.endsWith(\"\\\\\"))) r = r.slice(0, -1);\n return r;\n}\n\nexport function samePath(a: string, b: string): boolean {\n return normPath(a) === normPath(b);\n}\n\n// Whether child is inside the parent directory (including equal).\nexport function isInside(child: string, parent: string): boolean {\n const c = normPath(child);\n const p = normPath(parent);\n if (c === p) return true;\n return c.startsWith(p + sep) || c.startsWith(p + \"/\") || c.startsWith(p + \"\\\\\");\n}\n\n// id check: only safe characters, prevents path traversal.\nconst ID_RE = /^[\\w-]{1,80}$/;\nexport function validId(id: unknown): id is string {\n return typeof id === \"string\" && ID_RE.test(id);\n}\n\nexport function pathExists(p: string): boolean {\n return existsSync(p);\n}\n","// AsyncQueue: push/close/[Symbol.asyncIterator], the prompt input stream for the Claude SDK.\nexport class AsyncQueue implements AsyncIterable {\n private buf: T[] = [];\n private waiters: ((v: IteratorResult) => void)[] = [];\n private closed = false;\n private err: unknown = null;\n\n push(item: T): void {\n if (this.closed) return;\n const w = this.waiters.shift();\n if (w) w({ value: item, done: false });\n else this.buf.push(item);\n }\n\n close(): void {\n if (this.closed) return;\n this.closed = true;\n while (this.waiters.length) {\n const w = this.waiters.shift()!;\n w({ value: undefined as never, done: true });\n }\n }\n\n fail(err: unknown): void {\n if (this.closed) return;\n this.err = err;\n this.closed = true;\n while (this.waiters.length) {\n const w = this.waiters.shift()!;\n // Make waiters throw\n w({ value: undefined as never, done: true });\n }\n }\n\n get isClosed(): boolean {\n return this.closed;\n }\n\n [Symbol.asyncIterator](): AsyncIterator {\n return {\n next: (): Promise> => {\n if (this.buf.length) return Promise.resolve({ value: this.buf.shift()!, done: false });\n if (this.closed) {\n if (this.err) return Promise.reject(this.err);\n return Promise.resolve({ value: undefined as never, done: true });\n }\n if (this.err) return Promise.reject(this.err);\n return new Promise>((res, rej) => {\n this.waiters.push((r) => {\n if (this.err) {\n rej(this.err);\n return;\n }\n res(r);\n });\n });\n },\n };\n }\n}\n","// Line-level diff (ported from lib/diff.mjs): oldStr/newStr → { hunks, added, removed }\n// hunk: { oldStart, newStart, lines: [{ t: \" \"|\"+\"|\"-\", s }] }\n// LCS minimal edit script; keep context lines between changes and merge nearby small blocks into one hunk.\n\nconst CONTEXT = 3;\nconst MERGE_GAP = 6;\nconst MAX_LINES = 400;\n\nexport type DiffLine = { t: \" \" | \"+\" | \"-\"; s: string };\nexport type Hunk = { oldStart: number; newStart: number; lines: DiffLine[] };\nexport type DiffResult = { hunks: Hunk[]; added: number; removed: number; truncated?: boolean };\n\nexport function diffLines(oldStr = \"\", newStr = \"\", { context = CONTEXT } = {}): DiffResult {\n const a = splitLines(oldStr);\n const b = splitLines(newStr);\n if (a.length > MAX_LINES || b.length > MAX_LINES) return coarseDiff(a, b);\n const ops = lcsOps(a, b);\n return buildHunks(ops, context);\n}\n\nfunction splitLines(s: string): string[] {\n s = String(s);\n if (!s) return [];\n return s.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n}\n\ntype Op = { t: \" \" | \"+\" | \"-\"; s: string };\n\nfunction lcsOps(a: string[], b: string[]): Op[] {\n const n = a.length;\n const m = b.length;\n const dp = new Uint32Array((n + 1) * (m + 1));\n for (let i = n - 1; i >= 0; i--)\n for (let j = m - 1; j >= 0; j--)\n dp[i * (m + 1) + j] = a[i] === b[j]\n ? dp[(i + 1) * (m + 1) + j + 1] + 1\n : Math.max(dp[(i + 1) * (m + 1) + j], dp[i * (m + 1) + j + 1]);\n const ops: Op[] = [];\n let i = 0;\n let j = 0;\n while (i < n && j < m) {\n if (a[i] === b[j]) {\n ops.push({ t: \" \", s: a[i] });\n i++; j++;\n } else if (dp[(i + 1) * (m + 1) + j] >= dp[i * (m + 1) + j + 1]) {\n ops.push({ t: \"-\", s: a[i] });\n i++;\n } else {\n ops.push({ t: \"+\", s: b[j] });\n j++;\n }\n }\n while (i < n) ops.push({ t: \"-\", s: a[i++] });\n while (j < m) ops.push({ t: \"+\", s: b[j++] });\n return ops;\n}\n\nfunction buildHunks(ops: Op[], context: number): DiffResult {\n const changed: number[] = [];\n ops.forEach((op, i) => {\n if (op.t !== \" \") changed.push(i);\n });\n if (!changed.length) return { hunks: [], added: 0, removed: 0 };\n\n let added = 0;\n let removed = 0;\n for (const op of ops) {\n if (op.t === \"+\") added++;\n else if (op.t === \"-\") removed++;\n }\n\n const ranges: { s: number; e: number }[] = [];\n for (const idx of changed) {\n const s = Math.max(0, idx - context);\n const e = Math.min(ops.length, idx + context + 1);\n const last = ranges[ranges.length - 1];\n if (last && s <= last.e + MERGE_GAP) last.e = Math.max(last.e, e);\n else ranges.push({ s, e });\n }\n\n const hunks: Hunk[] = [];\n let oldLn = 1;\n let newLn = 1;\n const oldNos: number[] = [];\n const newNos: number[] = [];\n for (const op of ops) {\n oldNos.push(oldLn);\n newNos.push(newLn);\n if (op.t !== \"+\") oldLn++;\n if (op.t !== \"-\") newLn++;\n }\n for (const r of ranges) {\n hunks.push({\n oldStart: oldNos[r.s],\n newStart: newNos[r.s],\n lines: ops.slice(r.s, r.e).map((op) => ({ t: op.t, s: op.s })),\n });\n }\n return { hunks, added, removed };\n}\n\nfunction coarseDiff(a: string[], b: string[]): DiffResult {\n return {\n hunks: [\n {\n oldStart: 1,\n newStart: 1,\n lines: [...a.map((s) => ({ t: \"-\" as const, s })), ...b.map((s) => ({ t: \"+\" as const, s }))].slice(0, 2000),\n },\n ],\n added: b.length,\n removed: a.length,\n truncated: true,\n };\n}\n\n// Build the detail for Edit/MultiEdit: compute hunks from old/new or edits[]\nexport function editDetail(detail: Record = {}) {\n if (Array.isArray(detail.edits) && detail.edits.length) {\n const hunks: Hunk[] = [];\n let added = 0;\n let removed = 0;\n for (const e of detail.edits as { old_string?: string; old?: string; new_string?: string; new?: string }[]) {\n const d = diffLines(e.old_string ?? e.old ?? \"\", e.new_string ?? e.new ?? \"\");\n for (const h of d.hunks) hunks.push(h);\n added += d.added;\n removed += d.removed;\n }\n return { path: detail.path, hunks, added, removed };\n }\n if (detail.old !== undefined || detail.new !== undefined) {\n const d = diffLines(String(detail.old ?? \"\"), String(detail.new ?? \"\"));\n return { path: detail.path, hunks: d.hunks, added: d.added, removed: d.removed };\n }\n return detail;\n}\n","export type SlashCommandKind = \"command\" | \"skill\";\n\nexport type SlashCommand = {\n name: string;\n description: string;\n argumentHint?: string;\n kind: SlashCommandKind;\n};\n\nconst CLAUDE_BUILTIN_COMMANDS = new Set([\"clear\", \"compact\", \"context\", \"usage\", \"init\", \"debug\"]);\n\nexport function classifyClaudeSlashCommand(name: string): SlashCommandKind {\n return CLAUDE_BUILTIN_COMMANDS.has(name) ? \"command\" : \"skill\";\n}\n\n/** Parse \"/name args\" (must start with /) */\nexport function parseSlashInput(text: string): { name: string; args: string; raw: string } | null {\n const t = text.trim();\n if (!t.startsWith(\"/\")) return null;\n const body = t.slice(1).trim();\n if (!body) return null;\n const sp = body.indexOf(\" \");\n if (sp === -1) return { name: body, args: \"\", raw: t };\n return { name: body.slice(0, sp), args: body.slice(sp + 1).trim(), raw: t };\n}\n\nexport function mapAcpAvailableCommands(\n cmds: { name: string; description: string; input?: { hint?: string } | null }[],\n): SlashCommand[] {\n return cmds.map((c) => ({\n name: c.name,\n description: c.description,\n argumentHint: c.input?.hint,\n kind: \"skill\" as const,\n }));\n}\n\nexport function sortSlashCommands(list: SlashCommand[]): SlashCommand[] {\n return [...list].sort((a, b) => {\n if (a.kind !== b.kind) return a.kind === \"command\" ? -1 : 1;\n return a.name.localeCompare(b.name);\n });\n}\n","// MockProvider: registered only with AGENT_CONSOLE_MOCK=1. Persistent session, interruptible, with permissions and steering.\n// Scripts by message prefix: default /perm /plan /ask /slow /error /long /exit; supports steer/interrupt/resume.\nimport type { Provider, ProviderSession, DetectResult, ModelInfo,\n Mode, SendInput, RespondAnswer, SlashCommand,\n} from \"./types.js\";\nimport type { ProviderEvent } from \"../timeline/types.js\";\nimport { AsyncQueue } from \"../util/queue.js\";\nimport { editDetail } from \"../agents/diff.js\";\nimport { sortSlashCommands } from \"./slash.js\";\n\nexport const mockCapabilities = {\n resume: true,\n interrupt: true,\n steer: true,\n images: true,\n plan: true,\n questions: true,\n cost: true,\n effort: [\"low\", \"medium\", \"high\"],\n modes: [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"] as Mode[],\n};\n\nconst rid = () => Math.random().toString(36).slice(2, 10);\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\nclass MockCrash extends Error {\n constructor() {\n super(\"__mock_crash__\");\n }\n}\n\ntype Ctx = {\n emit: (ev: ProviderEvent) => void;\n waitFor: (requestId: string) => Promise;\n interrupted: () => boolean;\n steerText: () => string;\n replyPrefix: () => string;\n input: SendInput;\n};\n\nexport class MockSession implements ProviderSession {\n events: AsyncIterable;\n private q = new AsyncQueue();\n private sessionId: string;\n private turnId: string | null = null;\n private turnNum = 0;\n private interruptFlag = false;\n private closed = false;\n private pendingReq = new Map void>();\n private steered: string[] = [];\n private resumed = false;\n\n constructor(private o: {\n threadId: string;\n cwd: string;\n model: string | null;\n mode: Mode;\n effort: string | null;\n resumeId: string | null;\n }) {\n this.sessionId = o.resumeId ?? \"mock-\" + rid();\n this.resumed = o.resumeId !== null;\n this.events = this.q;\n this.q.push({ type: \"provider_session_id\", id: this.sessionId });\n }\n\n private emit(ev: ProviderEvent): void {\n (ev as { turnId?: string }).turnId ??= this.turnId ?? undefined;\n this.q.push(ev);\n }\n\n async send(input: SendInput): Promise {\n if (this.closed) throw new Error(\"mock session closed\");\n this.turnNum++;\n this.turnId = \"u_\" + Date.now().toString(36) + \"_\" + this.turnNum;\n this.interruptFlag = false;\n this.steered = [];\n this.emit({\n type: \"turn_started\",\n turnId: this.turnId,\n model: this.o.model ?? \"demo-fast\",\n mode: this.o.mode,\n effort: this.o.effort,\n });\n void this.runTurn(input);\n }\n\n private async runTurn(input: SendInput): Promise {\n // itemIds are globally unique (a real provider creates new itemIds each time); sessionId can't be used:\n // a resumed session keeps the same providerSessionId and would collide with the old session's itemIds.\n // Same ids would merge the new turn's events into old entries, and the new reply would \"fly\" into an old bubble mid-timeline.\n const p = \"i\" + rid() + \"_\";\n const ctx: Ctx = {\n emit: (ev) => {\n const e = ev as { itemId?: string };\n if (e.itemId) e.itemId = p + e.itemId;\n this.emit(ev);\n },\n waitFor: (id) => this.waitFor(id),\n interrupted: () => this.interruptFlag || this.closed,\n steerText: () => this.steered.join(\";\"),\n replyPrefix: () => this.resumePrefix(),\n input,\n };\n try {\n const script = pickScript(input.text);\n await script(ctx);\n if (this.interruptFlag || this.closed) {\n this.emit({ type: \"turn_ended\", turnId: this.turnId!, status: \"interrupted\", durationMs: 0 });\n } else {\n this.emit({ type: \"usage\", inputTokens: 1200, outputTokens: 340, cacheReadTokens: 800, costUsd: 0.0123 });\n this.emit({ type: \"turn_ended\", turnId: this.turnId!, status: \"completed\", durationMs: 6000, filesChanged: 1 });\n }\n } catch (e) {\n if (e instanceof MockCrash) {\n this.q.close(); // Crash: no turn_ended; the manager appends failed\n return;\n }\n if (this.interruptFlag || this.closed) {\n this.emit({ type: \"turn_ended\", turnId: this.turnId!, status: \"interrupted\", durationMs: 0 });\n } else {\n this.emit({ type: \"turn_ended\", turnId: this.turnId!, status: \"failed\", error: String((e as Error)?.message ?? e) });\n }\n }\n }\n\n respond(requestId: string, answer: RespondAnswer): void {\n const resolve = this.pendingReq.get(requestId);\n if (resolve) {\n this.pendingReq.delete(requestId);\n resolve(answer);\n }\n }\n\n private waitFor(requestId: string): Promise {\n return new Promise((resolve) => {\n this.pendingReq.set(requestId, resolve);\n });\n }\n\n async interrupt(): Promise {\n this.interruptFlag = true;\n // Pending permissions/questions are cancelled directly\n for (const [id, resolve] of this.pendingReq) {\n resolve({ cancelled: true });\n this.pendingReq.delete(id);\n }\n await sleep(120); // Guarantees interrupted within <1s\n }\n\n async steer(input: SendInput): Promise {\n this.steered.push(input.text);\n }\n\n async setModel(model: string | null): Promise {\n this.o.model = model;\n }\n async setMode(mode: Mode): Promise {\n this.o.mode = mode;\n }\n async setEffort(effort: string | null): Promise {\n this.o.effort = effort;\n }\n\n async listCommands(): Promise {\n return sortSlashCommands([\n { name: \"perm\", description: \"演示授权弹窗\", kind: \"command\" },\n { name: \"plan\", description: \"演示计划审批\", kind: \"command\" },\n { name: \"ask\", description: \"演示提问\", kind: \"command\" },\n { name: \"slow\", description: \"慢速多步工具\", kind: \"skill\" },\n { name: \"error\", description: \"模拟失败\", kind: \"skill\" },\n ]);\n }\n\n async close(): Promise {\n this.closed = true;\n for (const [, resolve] of this.pendingReq) resolve({ cancelled: true });\n this.pendingReq.clear();\n this.q.close();\n }\n\n // With resumeId the first reply gets a \"(已续接)\" prefix\n private resumePrefix(): string {\n if (this.resumed && this.turnNum === 1) return \"(已续接)\";\n return \"\";\n }\n}\n\n// ---------- scripts ----------\n\nasync function scriptDefault(ctx: Ctx): Promise {\n const { emit } = ctx;\n const sleepA = (ms: number) => sleepAbortable(ms, ctx);\n\n emit({ type: \"reasoning_delta\", itemId: \"r1\", text: \"让我看看项目结构…\" });\n await sleepA(500);\n emit({ type: \"reasoning\", itemId: \"r1\", text: \"让我看看项目结构…需要先读几个文件。\" });\n\n const tools = [\n { id: \"t1\", name: \"Read\", input: { file_path: ctx.input.text.includes(\"abs\") ? \"D:/x/a.ts\" : \"src/index.ts\" } },\n { id: \"t2\", name: \"Read\", input: { file_path: \"package.json\" } },\n ];\n for (const t of tools) {\n emit({ type: \"tool_call\", itemId: t.id, kind: \"read\", title: `读取 ${t.input.file_path}`, status: \"running\", input: t.input });\n if (await sleepA(300)) return;\n emit({ type: \"tool_call\", itemId: t.id, kind: \"read\", title: `读取 ${t.input.file_path}`, status: \"ok\", output: \"…文件内容…\", durationMs: 300 });\n }\n\n emit({ type: \"tool_call\", itemId: \"t3\", kind: \"search\", title: '搜索 \"TODO\"', status: \"running\", input: { pattern: \"TODO\" } });\n await sleepA(200);\n emit({ type: \"tool_call\", itemId: \"t3\", kind: \"search\", title: '搜索 \"TODO\"', status: \"ok\", output: \"3 处匹配\" });\n\n const oldStr = \"const x = 1;\\nconst y = 2;\\nreturn x + y;\";\n const newStr = \"const x = 1;\\nconst y = 2;\\nconst z = 3;\\nreturn x + y + z;\";\n emit({\n type: \"tool_call\", itemId: \"t4\", kind: \"edit\", title: \"编辑 src/index.ts\", status: \"running\",\n input: { file_path: \"src/index.ts\", old_string: oldStr, new_string: newStr },\n detail: editDetail({ path: \"src/index.ts\", old: oldStr, new: newStr }),\n });\n if (await sleepA(400)) return;\n emit({\n type: \"tool_call\", itemId: \"t4\", kind: \"edit\", title: \"编辑 src/index.ts\", status: \"ok\",\n detail: editDetail({ path: \"src/index.ts\", old: oldStr, new: newStr }), durationMs: 400,\n });\n\n emit({ type: \"tool_call\", itemId: \"t5\", kind: \"bash\", title: \"运行 npm test\", status: \"running\", input: { command: \"npm test\" } });\n for (let i = 1; i <= 20; i++) {\n if (await sleepA(60)) return;\n emit({ type: \"tool_output_delta\", itemId: \"t5\", text: `第 ${i} 行输出\\n` });\n }\n emit({ type: \"tool_call\", itemId: \"t5\", kind: \"bash\", title: \"运行 npm test\", status: \"ok\", output: \"✓ 全部通过\", exitCode: 0, durationMs: 1200 });\n\n emit({\n type: \"todo\",\n items: [\n { text: \"分析需求\", status: \"done\" },\n { text: \"修改代码\", status: \"done\" },\n { text: \"跑测试\", status: \"done\" },\n { text: \"写文档\", status: \"in_progress\" },\n ],\n });\n await sleepA(300);\n\n const reply = ctx.replyPrefix() + mockReply(ctx);\n for (const ch of chunkText(reply, 40)) {\n if (await sleepA(30)) return;\n emit({ type: \"assistant_delta\", itemId: \"m1\", text: ch });\n }\n emit({ type: \"assistant_message\", itemId: \"m1\", text: reply });\n}\n\nasync function scriptPerm(ctx: Ctx): Promise {\n const { emit } = ctx;\n const requestId = \"perm-\" + rid();\n emit({\n type: \"permission_request\",\n requestId,\n title: \"运行 npm test\",\n kind: \"tool\",\n detail: { command: \"npm test\", cwd: \".\", toolName: \"Bash\" },\n options: [\n { id: \"accept\", label: \"允许\", kind: \"allow_once\" },\n { id: \"acceptForSession\", label: \"本对话都允许\", kind: \"allow_always\" },\n { id: \"decline\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const ans = await ctx.waitFor(requestId);\n if (ans.cancelled || ans.optionId === \"decline\") {\n emit({ type: \"tool_call\", itemId: \"p1\", kind: \"bash\", title: \"运行 npm test\", status: \"failed\", output: \"已被用户拒绝\", exitCode: 1 });\n emit({ type: \"assistant_message\", itemId: \"pm\", text: \"已按你的要求跳过该操作。\" });\n return;\n }\n emit({ type: \"tool_call\", itemId: \"p1\", kind: \"bash\", title: \"运行 npm test\", status: \"running\", input: { command: \"npm test\" } });\n if (await sleepAbortable(500, ctx)) return;\n emit({ type: \"tool_call\", itemId: \"p1\", kind: \"bash\", title: \"运行 npm test\", status: \"ok\", output: \"✓ 12 个测试通过\", exitCode: 0, durationMs: 500 });\n emit({ type: \"assistant_message\", itemId: \"pm\", text: \"测试通过了。\" });\n}\n\nasync function scriptPlan(ctx: Ctx): Promise {\n const { emit } = ctx;\n emit({\n type: \"plan\",\n itemId: \"plan1\",\n markdown: \"## 实施计划\\n\\n1. 改 `src/index.ts` 入口\\n2. 更新测试\\n3. 跑一遍 e2e\",\n entries: [\n { step: \"改 src/index.ts 入口\", status: \"pending\" },\n { step: \"更新测试\", status: \"pending\" },\n { step: \"跑一遍 e2e\", status: \"pending\" },\n ],\n });\n const requestId = \"perm-\" + rid();\n emit({\n type: \"permission_request\",\n requestId,\n title: \"实施这个计划?\",\n kind: \"plan\",\n detail: { markdown: \"见上方计划\" },\n options: [\n { id: \"implement\", label: \"开始实施\", kind: \"allow_once\" },\n { id: \"reject\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const ans = await ctx.waitFor(requestId);\n if (ans.cancelled || ans.optionId === \"reject\") {\n emit({ type: \"assistant_message\", itemId: \"pm\", text: \"好的,请告诉我怎么改。\" });\n return;\n }\n emit({ type: \"tool_call\", itemId: \"pl1\", kind: \"edit\", title: \"编辑 src/index.ts\", status: \"ok\" });\n await sleepAbortable(400, ctx);\n emit({ type: \"assistant_message\", itemId: \"pm\", text: \"已按计划改完入口和测试。\" });\n}\n\nasync function scriptAsk(ctx: Ctx): Promise {\n const { emit } = ctx;\n const requestId = \"q-\" + rid();\n emit({\n type: \"question\",\n requestId,\n questions: [\n {\n id: \"q1\",\n header: \"部署目标\",\n question: \"要部署到哪个环境?\",\n options: [\n { id: \"staging\", label: \"Staging\" },\n { id: \"prod\", label: \"生产环境\" },\n ],\n },\n {\n id: \"q2\",\n header: \"备注\",\n question: \"还有什么要补充的?\",\n freeText: true,\n },\n ],\n });\n const ans = await ctx.waitFor(requestId);\n const env = ans.answers?.q1 ?? \"(未选)\";\n const note = ans.answers?.q2 ?? \"\";\n emit({ type: \"assistant_message\", itemId: \"am\", text: `收到:部署到 ${env}${note ? `,备注「${note}」` : \"\"}。` });\n}\n\nasync function scriptSlow(ctx: Ctx): Promise {\n const { emit } = ctx;\n for (let i = 1; i <= 6; i++) {\n const id = \"s\" + i;\n emit({ type: \"tool_call\", itemId: id, kind: \"bash\", title: `步骤 ${i}/6`, status: \"running\", input: { command: `step ${i}` } });\n if (await sleepAbortable(2000, ctx)) return;\n emit({ type: \"tool_call\", itemId: id, kind: \"bash\", title: `步骤 ${i}/6`, status: \"ok\", output: `步骤 ${i} 完成`, durationMs: 2000 });\n }\n emit({ type: \"assistant_message\", itemId: \"sm\", text: \"6 个步骤全部跑完。\" });\n}\n\nasync function scriptError(ctx: Ctx): Promise {\n const { emit } = ctx;\n emit({ type: \"tool_call\", itemId: \"e1\", kind: \"bash\", title: \"运行 ./build.sh\", status: \"running\", input: { command: \"./build.sh\" } });\n await sleepAbortable(800, ctx);\n throw new Error(\"构建失败:找不到模块 foo(模拟错误)\");\n}\n\nasync function scriptLong(ctx: Ctx): Promise {\n const { emit } = ctx;\n emit({ type: \"assistant_delta\", itemId: \"L1\", text: \"\" });\n for (let i = 0; i < 5000; i++) {\n if (i % 100 === 0 && ctx.interrupted()) return;\n emit({ type: \"assistant_delta\", itemId: \"L1\", text: `块${i} ` });\n if (i % 200 === 0) await sleep(0);\n }\n emit({ type: \"assistant_message\", itemId: \"L1\", text: \"(5000 个增量合并后的长文)\" });\n}\n\nasync function scriptExit(_ctx: Ctx): Promise {\n await sleep(300);\n throw new MockCrash();\n}\n\nfunction pickScript(text: string): (ctx: Ctx) => Promise {\n const t = text.trim();\n if (t.startsWith(\"/perm\")) return scriptPerm;\n if (t.startsWith(\"/plan\")) return scriptPlan;\n if (t.startsWith(\"/ask\")) return scriptAsk;\n if (t.startsWith(\"/slow\")) return scriptSlow;\n if (t.startsWith(\"/error\")) return scriptError;\n if (t.startsWith(\"/long\")) return scriptLong;\n if (t.startsWith(\"/exit\")) return scriptExit;\n return scriptDefault;\n}\n\nasync function sleepAbortable(ms: number, ctx: Ctx): Promise {\n const start = Date.now();\n while (Date.now() - start < ms) {\n if (ctx.interrupted()) return true;\n await sleep(Math.min(20, Math.max(1, ms - (Date.now() - start))));\n }\n return ctx.interrupted();\n}\n\nfunction chunkText(s: string, n: number): string[] {\n const out: string[] = [];\n for (let i = 0; i < s.length; i += n) out.push(s.slice(i, i + n));\n return out;\n}\n\nfunction mockReply(ctx: Ctx): string {\n const base = [\n \"好的,我已经按你的要求改好了。\",\n \"\",\n \"## 改动\",\n \"- `src/index.ts`:把返回逻辑补成三变量求和\",\n \"- 新增一行常量 `z`\",\n \"\",\n \"```ts\",\n \"const z = 3;\",\n \"return x + y + z;\",\n \"```\",\n \"\",\n \"| 文件 | 变更 |\",\n \"|---|---|\",\n \"| src/index.ts | +2/-1 |\",\n \"\",\n \"测试全部通过。\",\n ].join(\"\\n\");\n return ctx.steerText() ? base + `\\n\\n> 插话:${ctx.steerText()}` : base;\n}\n\n// ---------- Provider ----------\n\n/** For tests: counts createSession calls */\nexport const mockProviderMetrics = { createSessionCalls: 0 };\n\nexport function createMockProvider(): Provider {\n return {\n id: \"mock\",\n name: \"Mock(演示)\",\n capabilities: mockCapabilities,\n async detect(): Promise {\n return { available: true, bin: \"mock\", version: \"demo\" };\n },\n async listModels(): Promise {\n return [\n { id: \"demo-fast\", name: \"演示·快\", isDefault: true, efforts: [\"low\", \"medium\", \"high\"] },\n { id: \"demo-pro\", name: \"演示·强\", efforts: [\"low\", \"medium\", \"high\"] },\n ];\n },\n async createSession(o) {\n mockProviderMetrics.createSessionCalls++;\n return new MockSession(o);\n },\n };\n}\n","// Cross-platform executable lookup in PATH (shared by the plugin host index.mjs and the daemon; esbuild bundles it into dist for the daemon).\n//\n// which / where.exe are no longer used:\n// - some minimal Linux systems have no which;\n// - where.exe writes in the console code page (CP936 on Chinese Windows), so a Chinese user name or path is garbled when decoded as UTF-8,\n// existsSync fails and the CLI \"can't be found\".\n// Windows searches in PATHEXT order (.COM;.EXE;.BAT;.CMD), with .ps1 last; extensionless npm sh shims are never returned.\n// Besides PATH, add a set of common install directories: GUI apps launched from the macOS Dock/Finder only get launchd's minimal PATH\n// (Magpie adds the login shell's PATH itself; this is the fallback): Homebrew, npm global, Volta, Claude's native install, etc.\nimport { accessSync, constants, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport path from \"node:path\";\n\nfunction defaultExists(file, platform) {\n try {\n if (!statSync(file).isFile()) return false;\n if (platform !== \"win32\") accessSync(file, constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction envGet(env, name) {\n if (env[name] !== undefined) return env[name];\n // Windows environment variable names are case-insensitive (Path / PATH)\n const lower = name.toLowerCase();\n for (const key of Object.keys(env)) if (key.toLowerCase() === lower) return env[key];\n return undefined;\n}\n\n/** Common install directories that aren't necessarily in a GUI process's PATH (candidates only; callers check whether they exist). */\nexport function extraBinDirs({ platform = process.platform, env = process.env, home = homedir() } = {}) {\n const p = platform === \"win32\" ? path.win32 : path.posix;\n const dirs = [];\n if (platform === \"win32\") {\n const appData = envGet(env, \"APPDATA\");\n const localAppData = envGet(env, \"LOCALAPPDATA\");\n const programFiles = envGet(env, \"ProgramFiles\") || \"C:\\\\Program Files\";\n const nvmSymlink = envGet(env, \"NVM_SYMLINK\");\n if (home) dirs.push(p.join(home, \".local\", \"bin\"));\n if (appData) dirs.push(p.join(appData, \"npm\"));\n if (nvmSymlink) dirs.push(nvmSymlink);\n dirs.push(p.join(programFiles, \"nodejs\"));\n if (localAppData) dirs.push(p.join(localAppData, \"Programs\", \"nodejs\"));\n if (home) {\n dirs.push(p.join(home, \".volta\", \"bin\"));\n dirs.push(p.join(home, \".bun\", \"bin\"));\n }\n return dirs;\n }\n if (home) {\n dirs.push(p.join(home, \".local\", \"bin\"));\n dirs.push(p.join(home, \".npm-global\", \"bin\"));\n dirs.push(p.join(home, \".volta\", \"bin\"));\n dirs.push(p.join(home, \".bun\", \"bin\"));\n dirs.push(p.join(home, \".claude\", \"local\"));\n }\n if (platform === \"darwin\") dirs.push(\"/opt/homebrew/bin\");\n dirs.push(\"/usr/local/bin\", \"/usr/bin\", \"/bin\");\n return dirs;\n}\n\n/** PATH's directories followed by extraBinDirs; deduped (case-insensitive on Windows). */\nexport function searchDirs({ platform = process.platform, env = process.env, home = homedir(), extra = true } = {}) {\n const sep = platform === \"win32\" ? \";\" : \":\";\n const raw = String(envGet(env, \"PATH\") ?? \"\")\n .split(sep)\n .map((d) => d.trim().replace(/^\"(.*)\"$/, \"$1\"))\n .filter(Boolean);\n const all = extra ? [...raw, ...extraBinDirs({ platform, env, home })] : raw;\n const seen = new Set();\n const out = [];\n for (const d of all) {\n const key = platform === \"win32\" ? d.toLowerCase().replace(/[\\\\/]+$/, \"\") : d.replace(/\\/+$/, \"\");\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(d);\n }\n return out;\n}\n\nfunction windowsExts(env) {\n const list = String(envGet(env, \"PATHEXT\") || \".COM;.EXE;.BAT;.CMD\")\n .split(\";\")\n .map((e) => e.trim().toLowerCase())\n .filter((e) => e.startsWith(\".\"));\n if (!list.includes(\".ps1\")) list.push(\".ps1\");\n return list;\n}\n\n/**\n * Return the full paths of every hit, in order (PATH first, then extraBinDirs).\n * @param {string} name command name (may include an extension on Windows)\n */\nexport function findOnPath(\n name,\n { platform = process.platform, env = process.env, home = homedir(), extra = true, exists } = {},\n) {\n if (!name) return [];\n const p = platform === \"win32\" ? path.win32 : path.posix;\n const check = exists ?? ((file) => defaultExists(file, platform));\n // Already a path: check it directly\n if (name.includes(\"/\") || (platform === \"win32\" && name.includes(\"\\\\\"))) {\n return check(name) ? [name] : [];\n }\n let names = [name];\n if (platform === \"win32\") {\n const exts = windowsExts(env);\n const ext = p.extname(name).toLowerCase();\n names = ext && exts.includes(ext) ? [name] : exts.map((e) => name + e);\n }\n const hits = [];\n for (const dir of searchDirs({ platform, env, home, extra })) {\n for (const n of names) {\n const file = p.join(dir, n);\n if (check(file) && !hits.includes(file)) hits.push(file);\n }\n }\n return hits;\n}\n\n/** The resolved node executable, or null if not found (the plugin host is Bun, so process.execPath can't serve as node). */\nexport function findNode(opts = {}) {\n return findOnPath(\"node\", opts).find((file) => isNodeExecutable(file)) ?? null;\n}\n\n/** Whether process.execPath itself is node (rather than Bun / Electron etc.). */\nexport function isNodeExecutable(file) {\n return /^node(\\.exe)?$/i.test(String(file || \"\").split(/[\\\\/]/).pop() ?? \"\");\n}\n\nexport const NODE_MISSING_MESSAGE =\n \"找不到 Node.js(需要 20 或更高版本)。请安装 Node.js(https://nodejs.org)后完全退出并重新打开 Magpie。\" +\n \" / Node.js 20+ was not found on PATH. Install it, then quit and reopen Magpie.\";\nexport const NODE_TOO_OLD_MESSAGE =\n \"Node.js 版本太旧,需要 20 或更高版本。升级后完全退出并重新打开 Magpie。\" +\n \" / Node.js 20+ is required. Upgrade it, then quit and reopen Magpie.\";\n\n/**\n * For the plugin host: return the real node path (the host itself is Bun). If none is found, throw an error with a userMessage,\n * which the gate's 503 page displays; the daemon is no longer silently run on Bun.\n */\nexport function requireNode({ execPath = process.execPath, find = findNode } = {}) {\n if (isNodeExecutable(execPath)) return execPath;\n const hit = find();\n if (hit) return hit;\n const error = new Error(\"node not found on PATH\");\n error.code = \"ENODE\";\n error.userMessage = NODE_MISSING_MESSAGE;\n throw error;\n}\n","// Cross-platform executable resolution + process tree termination (ported from lib/bin.mjs).\n// Goal: start .cmd-shim CLIs like claude/codex without shell:true.\n\nimport { execFileSync, spawn, type ChildProcess, type SpawnOptions } from \"node:child_process\";\nimport { findOnPath, findNode as findNodeOnPath, isNodeExecutable } from \"../../../lib/pathscan.mjs\";\nimport { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\n\nconst IS_WIN = process.platform === \"win32\";\nconst cache = new Map();\nconst CACHE_MS = 60 * 1000;\n\nexport type ResolveResult =\n | {\n ok: true;\n cmd: string;\n prefixArgs: string[];\n display: string;\n via: string;\n /** via === \"cmd-shim\": a .cmd/.bat that must be started through cmd.exe (spawnBin / argvFor handle escaping) */\n shim?: string;\n }\n | { ok: false; reason: string };\n\n// PATH lookup lives in lib/pathscan.mjs: which / where.exe are no longer used (where's output uses the console code page, which garbles Chinese paths).\nfunction which(name: string): string[] {\n return findOnPath(name);\n}\n\n// ---- Windows: escaping when starting a .cmd/.bat through cmd.exe (same rules as cross-spawn) ----\nconst CMD_META = /([()\\][%!^\"`<>&|;, *?])/g;\n\nexport function escapeCmdCommand(cmd: string): string {\n return cmd.replace(CMD_META, \"^$1\");\n}\n\nexport function escapeCmdArgument(arg: string, doubleEscape: boolean): string {\n let a = String(arg);\n // First quote per CommandLineToArgvW rules (backslashes + quotes)\n a = a.replace(/(\\\\*)\"/g, '$1$1\\\\\"');\n a = a.replace(/(\\\\*)$/, \"$1$1\");\n a = `\"${a}\"`;\n // Then escape cmd metacharacters; batch files parse arguments again, so escape twice\n a = a.replace(CMD_META, \"^$1\");\n if (doubleEscape) a = a.replace(CMD_META, \"^$1\");\n return a;\n}\n\nexport type Argv = { cmd: string; args: string[]; windowsVerbatimArguments?: boolean };\n\n/** Resolution result + arguments → the command line actually given to spawn. */\nexport function argvFor(r: ResolveResult & { ok: true }, args: string[]): Argv {\n if (r.via === \"cmd-shim\" && r.shim) {\n const line = [escapeCmdCommand(r.shim), ...args.map((a) => escapeCmdArgument(a, true))].join(\" \");\n return { cmd: r.cmd, args: [\"/d\", \"/s\", \"/c\", `\"${line}\"`], windowsVerbatimArguments: true };\n }\n return { cmd: r.cmd, args: [...r.prefixArgs, ...args] };\n}\n\n// ---- Safety net: end Agent process trees still alive when the daemon exits ----\nconst liveTrees = new Set();\nlet exitHookInstalled = false;\n\nfunction reapOnExit(): void {\n for (const pid of liveTrees) {\n try {\n if (IS_WIN) execFileSync(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { stdio: \"ignore\", windowsHide: true, timeout: 3000 });\n else process.kill(-pid, \"SIGKILL\");\n } catch {}\n }\n liveTrees.clear();\n}\n\n/** Register a process to clean up when the daemon exits (on non-Windows it must lead a detached process group). */\nexport function trackTree(child: ChildProcess): void {\n if (child.pid == null) return;\n const pid = child.pid;\n liveTrees.add(pid);\n child.once(\"exit\", () => liveTrees.delete(pid));\n if (!exitHookInstalled) {\n exitHookInstalled = true;\n process.once(\"exit\", reapOnExit);\n }\n}\n\n/** Start a CLI from a resolution result. On non-Windows it gets its own process group (detached) so killTree can also end grandchildren. */\nexport function spawnBin(\n r: ResolveResult & { ok: true },\n args: string[],\n opts: SpawnOptions & { group?: boolean } = {},\n): ChildProcess {\n const a = argvFor(r, args);\n const { group, ...rest } = opts;\n const child = spawn(a.cmd, a.args, {\n windowsHide: true,\n ...rest,\n ...(a.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),\n ...(group && !IS_WIN ? { detached: true } : {}),\n });\n if (group) trackTree(child);\n return child;\n}\n\n// Find the script/program path a .cmd calls from its text.\nexport function shimTarget(\n cmdText: string,\n cmdDir: string | null,\n deps: { exists?: (p: string) => boolean; win?: boolean } = {},\n): string | null {\n const exists = deps.exists ?? existsSync;\n const P = (deps.win ?? IS_WIN) ? path.win32 : path;\n const cands: string[] = [];\n const re = /\"([^\"]+?\\.(?:js|mjs|cjs|exe|cmd|bat))\"/gi;\n let m: RegExpExecArray | null;\n while ((m = re.exec(cmdText))) cands.push(m[1]);\n const re2 = /(?:%~?dp0%?)?[A-Za-z]:?[\\\\/\\w.@-]*?\\.(?:js|mjs|cjs|exe)\\b/gi;\n while ((m = re2.exec(cmdText))) cands.push(m[0]);\n const found: string[] = [];\n for (const raw of cands) {\n let p = raw.replace(/%~?dp0%?/gi, \"\");\n p = p.replace(/%[A-Z_]+%/gi, \"\");\n p = p.trim().replace(/^[\\\\/]+/, \"\");\n if (!p || /%\\w+%/.test(p) || !/\\.(js|mjs|cjs|exe)$/i.test(p)) continue;\n if (!P.isAbsolute(p)) {\n if (!cmdDir) continue;\n p = P.join(cmdDir, p);\n }\n if (exists(p)) found.push(P.resolve(p));\n }\n // npm shims first write IF EXIST \"%dp0%\\node.exe\" (it really exists when the global dir is the node install dir, e.g. nvm-windows),\n // and only then the real cli.js: the script wins, node.exe itself is not the target\n return (\n found.find((f) => /\\.(js|mjs|cjs)$/i.test(f)) ??\n found.find((f) => /\\.exe$/i.test(f) && !isNodeExecutable(P.basename(f))) ??\n null\n );\n}\n\n// Which node to use when the shim points at a .js: the daemon itself runs on node, so prefer that one; otherwise node from PATH\nfunction findNode(): string {\n if (isNodeExecutable(process.execPath)) return process.execPath;\n return findNodeOnPath() ?? \"node\";\n}\n\nexport async function resolveBin(name: string): Promise {\n const hit = cache.get(name);\n if (hit && Date.now() - hit.at < CACHE_MS) return hit.out;\n const out = await resolveBinUncached(name);\n cache.set(name, { out, at: Date.now() });\n return out;\n}\n\nexport type ResolvedBin = ResolveResult & { ok: true; binName: string };\n\n/** Try several executable names from the catalog in order and return the first hit. */\nexport async function resolveFirstBin(names: string[]): Promise {\n const uniq = [...new Set(names.filter(Boolean))];\n for (const binName of uniq) {\n const r = await resolveBin(binName);\n if (r.ok) return { ...r, binName };\n }\n const label = uniq[0] ?? \"cli\";\n return { ok: false, reason: `PATH 里找不到 ${uniq.length > 1 ? uniq.join(\" 或 \") : label}` };\n}\n\nexport function clearBinCache(name?: string): void {\n if (name) cache.delete(name);\n else cache.clear();\n}\n\nexport type ResolveDeps = {\n platform?: NodeJS.Platform;\n find?: (name: string) => string[];\n readText?: (file: string) => Promise;\n exists?: (file: string) => boolean;\n node?: () => string;\n comspec?: string;\n};\n\nfunction resolveBinUncached(name: string): Promise {\n return resolveBinWith(name);\n}\n\n/** Implementation of resolveBin with injectable dependencies (tests simulate a Windows PATH / shims). */\nexport async function resolveBinWith(name: string, deps: ResolveDeps = {}): Promise {\n const platform = deps.platform ?? process.platform;\n const win = platform === \"win32\";\n const P = win ? path.win32 : path.posix;\n const exists = deps.exists ?? existsSync;\n const hits = (deps.find ?? which)(name);\n if (!hits.length) return { ok: false, reason: `PATH 里找不到 ${name}` };\n // Take the first hit in PATH order (same as typing the command in a terminal; within one directory on Windows, PATHEXT order)\n const first = hits[0];\n if (!win) return { ok: true, cmd: first, prefixArgs: [], display: first, via: \"path\" };\n\n const ext = P.extname(first).toLowerCase();\n if (ext === \".exe\" || ext === \".com\") return { ok: true, cmd: first, prefixArgs: [], display: first, via: \"exe\" };\n if (ext === \".ps1\") {\n return {\n ok: true,\n cmd: \"powershell.exe\",\n prefixArgs: [\"-NoProfile\", \"-NonInteractive\", \"-ExecutionPolicy\", \"Bypass\", \"-File\", first],\n display: first,\n via: \"ps1\",\n };\n }\n // .cmd / .bat\n const dir = P.dirname(first);\n if (name.toLowerCase() === \"npx\") {\n const node = P.join(dir, \"node.exe\");\n const cli = P.join(dir, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\");\n if (exists(node) && exists(cli))\n return { ok: true, cmd: node, prefixArgs: [cli], display: `${node} ${cli}`, via: \"npx-shim\" };\n }\n const text = await (deps.readText ?? ((f: string) => readFile(f, \"utf8\")))(first).catch(() => \"\");\n const target = shimTarget(text, dir, { exists, win: true });\n if (target && /\\.(js|mjs|cjs)$/i.test(target)) {\n const node = (deps.node ?? findNode)();\n return { ok: true, cmd: node, prefixArgs: [target], display: `${node} ${target}`, via: \"shim-js\" };\n }\n if (target && /\\.exe$/i.test(target)) {\n return { ok: true, cmd: target, prefixArgs: [], display: target, via: \"shim-exe\" };\n }\n // PowerShell / other .cmd wrappers: start through cmd.exe (e.g. cursor-agent); escaping happens in argvFor\n const comspec = deps.comspec ?? (process.env.ComSpec || \"cmd.exe\");\n return { ok: true, cmd: comspec, prefixArgs: [], display: first, via: \"cmd-shim\", shim: first };\n}\n\n// Windows: taskkill /T kills the whole tree; elsewhere: detached process group, kill(-pid).\nexport function killTree(child: ChildProcess | null | undefined, { graceMs = 3000 } = {}): Promise {\n if (!child || child.pid == null) return Promise.resolve();\n const pid = child.pid;\n if (IS_WIN) {\n return new Promise((resolvePromise) => {\n const tk = spawn(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { windowsHide: true });\n tk.on(\"error\", () => {\n try {\n child.kill();\n } catch {}\n resolvePromise();\n });\n tk.on(\"close\", () => resolvePromise());\n setTimeout(resolvePromise, graceMs + 2000);\n });\n }\n return new Promise((resolvePromise) => {\n let done = false;\n const finish = () => {\n if (!done) {\n done = true;\n resolvePromise();\n }\n };\n try {\n process.kill(-pid, \"SIGTERM\");\n } catch {\n try {\n child.kill(\"SIGTERM\");\n } catch {}\n }\n // A child that has already exited won't emit close again: no need to wait out graceMs\n if (child.exitCode !== null || child.signalCode !== null) {\n finish();\n return;\n }\n const t = setTimeout(() => {\n try {\n process.kill(-pid, \"SIGKILL\");\n } catch {\n try {\n child.kill(\"SIGKILL\");\n } catch {}\n }\n finish();\n }, graceMs);\n t.unref?.();\n child.on(\"close\", () => {\n clearTimeout(t);\n finish();\n });\n });\n}\n","// Tool name → kind / title / detail mapping (ported from lib/agents/kinds.mjs).\n// Adapters call callInfo(name, input, cwd) to get {kind, title, detail}; the frontend renders by kind.\nimport type { ToolKind } from \"../timeline/types.js\";\n\nconst MAX_CMD = 80;\nconst trunc = (s: string, n = MAX_CMD): string => (s.length > n ? s.slice(0, n - 1) + \"…\" : s);\n\nexport function relPath(p: string, cwd: string): string {\n if (!p) return \"\";\n const s = String(p);\n if (!cwd) return s;\n const norm = (x: string) => x.replace(/\\\\/g, \"/\").replace(/\\/+$/, \"\");\n const a = norm(s);\n const b = norm(cwd);\n if (a.toLowerCase().startsWith(b.toLowerCase() + \"/\")) return a.slice(b.length + 1);\n if (a.toLowerCase() === b.toLowerCase()) return \".\";\n return s;\n}\n\nexport type CallInfo = { kind: ToolKind; title: string; detail: Record };\n\ntype ToolInput = Record & { _exists?: boolean };\n\nexport function callInfo(name: string, input: ToolInput = {}, cwd = \"\"): CallInfo {\n const t = String(name || \"\");\n const f = (kind: ToolKind, title: string, detail: Record): CallInfo => ({ kind, title, detail });\n const file = String(input.file_path ?? input.path ?? input.notebook_path ?? \"\");\n\n switch (t) {\n case \"Read\":\n return f(\"read\", `读取 ${relPath(file, cwd)}`, { path: file, offset: input.offset, limit: input.limit });\n case \"Edit\":\n return f(\"edit\", `编辑 ${relPath(file, cwd)}`, { path: file, old: input.old_string, new: input.new_string });\n case \"MultiEdit\":\n return f(\"edit\", `编辑 ${relPath(file, cwd)}(${((input.edits as unknown[]) || []).length} 处)`, { path: file, edits: input.edits ?? [] });\n case \"Write\":\n return f(\"write\", `${input._exists ? \"覆盖\" : \"新建\"} ${relPath(file, cwd)}`, { path: file, content: input.content });\n case \"NotebookEdit\":\n return f(\"edit\", `编辑笔记本 ${relPath(String(input.notebook_path || file), cwd)}`, { path: input.notebook_path ?? file });\n case \"Bash\":\n return f(\"bash\", `运行 ${trunc(String(input.command ?? \"\"))}`, { command: input.command, description: input.description });\n case \"BashOutput\":\n return f(\"bash\", `查看输出 ${String(input.bash_id ?? \"\")}`, { bashId: input.bash_id });\n case \"KillShell\":\n return f(\"bash\", `结束进程 ${String(input.shell_id ?? \"\")}`, { shellId: input.shell_id });\n case \"Grep\":\n return f(\"search\", `搜索 \"${trunc(String(input.pattern ?? \"\"), 40)}\"`, { pattern: input.pattern, path: input.path, glob: input.glob });\n case \"Glob\":\n case \"LS\":\n return f(\"glob\", `查找 ${trunc(String(input.pattern ?? input.path ?? \"\"), 40)}`, { pattern: input.pattern, path: input.path });\n case \"WebFetch\":\n return f(\"web\", `打开 ${hostOf(String(input.url ?? \"\"))}`, { url: input.url });\n case \"WebSearch\":\n return f(\"web\", `搜索网页 \"${trunc(String(input.query ?? \"\"), 40)}\"`, { query: input.query });\n case \"TodoWrite\": {\n const items = todoItems((input.todos as { content?: string; text?: string; status?: string }[]) ?? []);\n const done = items.filter((x) => x.status === \"done\").length;\n return f(\"todo\", `更新待办(${done}/${items.length})`, { items });\n }\n case \"Task\":\n case \"Agent\":\n return f(\"task\", `子任务:${trunc(String(input.description ?? input.prompt ?? \"\"), 40)}`, { description: input.description });\n default:\n break;\n }\n const m = /^mcp__([^_]+)__(.+)$/.exec(t);\n if (m) return f(\"mcp\", `${m[1]} · ${m[2]}`, { server: m[1], tool: m[2] });\n return f(\"other\", t || \"工具\", {});\n}\n\nfunction hostOf(url: string): string {\n try {\n return new URL(url).host;\n } catch {\n return String(url || \"\").slice(0, 40);\n }\n}\n\nexport function todoItems(todos: { content?: string; text?: string; status?: string }[] = []): { text: string; status: string }[] {\n return todos.map((x) => ({\n text: x.content || x.text || \"\",\n status: x.status === \"completed\" ? \"done\" : x.status === \"in_progress\" ? \"in_progress\" : \"pending\",\n }));\n}\n","// SDK messages → ProviderEvent (§7.2.3). Ported from normalize in lib/agents/claude.mjs.\nimport type { SDKMessage, SDKPartialAssistantMessage } from \"@anthropic-ai/claude-agent-sdk\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { callInfo, todoItems } from \"../../agents/kinds.js\";\nimport { editDetail } from \"../../agents/diff.js\";\n\nconst MAX_OUTPUT = 20 * 1024;\nconst ANSI_RE = /\\x1b\\[[0-9;]*[a-zA-Z]|\\x1b\\][^\\x07]*\\x07/g;\nconst clean = (s: string): string => String(s ?? \"\").replace(ANSI_RE, \"\");\n\nexport type MapState = {\n blocks: Map; // content_block_start index → block type\n msgId: string; // Current streaming message.id\n // Streaming block index → unified itemId (msgId:type:ordinal within type).\n // The SDK's content_block index interleaves thinking/text, while the content array of the assistant event\n // may be renumbered (when thinking is split into another message, text goes back to 0) — a bare index would make\n // the delta and complete event itemIds diverge and the frontend render two duplicate replies. Align both sides by type + ordinal within type.\n itemIdByBlock: Map;\n typeSeq: Map; // Count of each block type in the current message (delta side)\n sawInterrupt: boolean; // After interrupt() the next result counts as interrupted\n};\n\nexport function newMapState(): MapState {\n return { blocks: new Map(), msgId: \"\", itemIdByBlock: new Map(), typeSeq: new Map(), sawInterrupt: false };\n}\n\n// Block type → short code and channel for itemIds; thinking/reasoning are one kind, text another; tool_use uses the tool id and doesn't go through here\nconst blockKind = (t: string): string =>\n t === \"thinking\" || t === \"redacted_thinking\" ? \"k\" : t === \"text\" ? \"t\" : \"x\";\n\nfunction itemIdFor(st: MapState, blockIdx: number, blkType: string): string {\n const k = blockKind(blkType);\n const n = st.typeSeq.get(k) ?? 0;\n st.typeSeq.set(k, n + 1);\n const id = `${st.msgId}:${k}${n}`;\n st.itemIdByBlock.set(blockIdx, id);\n return id;\n}\n\ntype Rec = Record;\nconst rec = (v: unknown): Rec => (v && typeof v === \"object\" ? (v as Rec) : {});\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" ? v : undefined);\n\n// One SDK message → 0..n unified events\nexport function mapSdkMessage(m: SDKMessage, st: MapState, cwd: string): ProviderEvent[] {\n const out: ProviderEvent[] = [];\n if (!m || typeof m !== \"object\") return out;\n const r = m as unknown as Rec;\n\n if (r.type === \"system\") {\n if (r.subtype === \"init\") {\n const sid = str(r.session_id) ?? \"\";\n if (sid) out.push({ type: \"provider_session_id\", id: sid });\n }\n // compact_boundary / status / api_retry etc. → lightweight notices\n if (r.subtype === \"api_retry\") {\n out.push({ type: \"notice\", level: \"warn\", message: `API 重试中(第 ${r.attempt ?? \"?\"} 次)` });\n }\n if (r.subtype === \"compact_boundary\") {\n out.push({ type: \"notice\", level: \"info\", message: \"上下文已压缩\" });\n }\n return out;\n }\n\n if (r.type === \"stream_event\") {\n const e = rec((m as SDKPartialAssistantMessage).event);\n if (e.type === \"message_start\") {\n const mid = str(rec(e.message).id);\n if (mid) st.msgId = mid;\n st.blocks.clear();\n st.itemIdByBlock.clear();\n st.typeSeq.clear();\n return out;\n }\n if (e.type === \"content_block_start\") {\n const idx = typeof e.index === \"number\" ? e.index : 0;\n const bt = str(rec(e.content_block).type) ?? \"\";\n st.blocks.set(idx, bt);\n itemIdFor(st, idx, bt); // Register index → unified itemId\n return out;\n }\n if (e.type === \"content_block_delta\") {\n const idx = typeof e.index === \"number\" ? e.index : 0;\n const d = rec(e.delta);\n // Fallback when a delta arrives before its start: infer and register from the delta type\n const itemId = st.itemIdByBlock.get(idx) ?? itemIdFor(st, idx, d.type === \"thinking_delta\" ? \"thinking\" : \"text\");\n if (d.type === \"text_delta\" && typeof d.text === \"string\" && d.text) {\n out.push({ type: \"assistant_delta\", itemId, text: d.text });\n } else if (d.type === \"thinking_delta\") {\n const t = str(d.thinking) ?? str(d.text);\n if (t) out.push({ type: \"reasoning_delta\", itemId, text: t });\n }\n // Ignore input_json_delta: wait for the complete tool_use in the assistant message\n }\n return out;\n }\n\n if (r.type === \"assistant\") {\n const mid = str(rec(r.message).id) ?? st.msgId ?? \"\";\n const content = rec(r.message).content;\n const parts = Array.isArray(content) ? content : [];\n // Same rule as the streaming side: build itemIds from type + ordinal within type so they overwrite the matching deltas\n const seqs = new Map();\n parts.forEach((p) => {\n const blk = rec(p);\n const t = str(blk.type) ?? \"\";\n const k = blockKind(t);\n const n = seqs.get(k) ?? 0;\n seqs.set(k, n + 1);\n const itemId = `${mid}:${k}${n}`;\n if (blk.type === \"text\" && typeof blk.text === \"string\" && blk.text) {\n out.push({ type: \"assistant_message\", itemId, text: blk.text });\n } else if (blk.type === \"thinking\" || blk.type === \"redacted_thinking\") {\n const t2 = str(blk.thinking) ?? str(blk.text);\n if (t2) out.push({ type: \"reasoning\", itemId, text: t2 });\n } else if (blk.type === \"tool_use\") {\n out.push(...mapToolUse(blk, cwd));\n }\n });\n return out;\n }\n\n if (r.type === \"user\") {\n const content = rec(r.message).content;\n const parts = Array.isArray(content) ? content : [];\n for (const p of parts) {\n const blk = rec(p);\n if (blk.type === \"tool_result\") {\n const output = clean(contentText(blk.content));\n out.push({\n type: \"tool_call\",\n itemId: str(blk.tool_use_id) ?? \"\",\n kind: \"other\",\n title: \"\",\n status: blk.is_error ? \"failed\" : \"ok\",\n output: output.length > MAX_OUTPUT ? output.slice(0, MAX_OUTPUT) : output,\n });\n }\n }\n return out;\n }\n\n if (r.type === \"result\") {\n const usage = rec(r.usage);\n const num = (k: string): number => (typeof usage[k] === \"number\" ? (usage[k] as number) : 0);\n out.push({\n type: \"usage\",\n inputTokens: num(\"input_tokens\"),\n outputTokens: num(\"output_tokens\"),\n cacheReadTokens: num(\"cache_read_input_tokens\"),\n costUsd: typeof r.total_cost_usd === \"number\" ? r.total_cost_usd : 0,\n });\n const subtype = str(r.subtype) ?? \"\";\n const interrupted = st.sawInterrupt || /interrupt/i.test(subtype);\n const isError = r.is_error === true;\n out.push({\n type: \"turn_ended\",\n turnId: \"\", // The session fills in the current turnId\n status: interrupted ? \"interrupted\" : isError ? \"failed\" : \"completed\",\n durationMs: typeof r.duration_ms === \"number\" ? r.duration_ms : undefined,\n error: isError && !interrupted ? str(r.result) ?? subtype : undefined,\n filesChanged: typeof r.num_turns === \"number\" ? undefined : undefined,\n });\n st.sawInterrupt = false;\n return out;\n }\n return out;\n}\n\nfunction mapToolUse(blk: Rec, cwd: string): ProviderEvent[] {\n const input = rec(blk.input);\n const name = str(blk.name) ?? \"tool\";\n const id = str(blk.id) ?? \"\";\n const info = callInfo(name, { ...input, _exists: true }, cwd);\n let detail: unknown = info.detail;\n if (info.kind === \"edit\" && (input.old_string !== undefined || Array.isArray(input.edits))) {\n detail = editDetail({ path: (info.detail as Rec).path as string, old: input.old_string, new: input.new_string, edits: input.edits });\n }\n if (info.kind === \"write\" && typeof input.content === \"string\") {\n const lines = input.content.split(\"\\n\");\n detail = { path: (info.detail as Rec).path, lines: lines.slice(0, 200), totalLines: lines.length };\n }\n const out: ProviderEvent[] = [\n {\n type: \"tool_call\",\n itemId: id,\n kind: info.kind,\n title: info.title,\n status: \"running\",\n input,\n detail,\n },\n ];\n if (name === \"TodoWrite\") {\n out.push({ type: \"todo\", items: todoItems((input.todos as { content?: string; status?: string }[]) ?? []) });\n }\n return out;\n}\n\nfunction contentText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) return content.map((p) => str(rec(p).text) ?? \"\").join(\"\");\n return \"\";\n}\n","/** Same as the frontend's normalizeLang */\nexport type AppLocale = \"zh\" | \"en\";\n\nlet locale: AppLocale = \"zh\";\n\nexport function normalizeLang(raw: string | null | undefined): AppLocale {\n const s = String(raw ?? \"\").trim().toLowerCase();\n if (!s) return \"zh\";\n if (s === \"zh\" || s.startsWith(\"zh-\")) return \"zh\";\n return \"en\";\n}\n\nexport function setAppLocale(lang: string | undefined): void {\n locale = normalizeLang(lang);\n}\n\nexport function getAppLocale(): AppLocale {\n return locale;\n}\n\nexport function intlLocale(): string {\n return locale === \"zh\" ? \"zh-CN\" : \"en-US\";\n}\n\nconst dict: Record> = {\n zh: {\n \"thread.defaultTitle\": \"新对话\",\n \"notice.daemonRestart\": \"后台已重启,本轮已中断\",\n \"notice.sessionStartFailed\": \"会话启动失败:{detail}\",\n \"notice.sendFailed\": \"发送失败:{detail}\",\n \"notice.effortNextTurn\": \"思考强度已保存,将在下一轮生效\",\n \"notice.effortResetOnModel\": \"当前模型不支持该思考强度,已恢复为 Default\",\n \"turn.agentExit\": \"Agent 进程意外退出\",\n \"attachment.missing\": \"附件不存在或已失效({ref}),请重新上传\",\n \"export.defaultModel\": \"默认\",\n },\n en: {\n \"thread.defaultTitle\": \"New chat\",\n \"notice.daemonRestart\": \"Daemon restarted — this turn was interrupted\",\n \"notice.sessionStartFailed\": \"Session start failed: {detail}\",\n \"notice.sendFailed\": \"Send failed: {detail}\",\n \"notice.effortNextTurn\": \"Reasoning effort saved — takes effect on the next turn\",\n \"notice.effortResetOnModel\": \"This model does not support that effort level; reset to Default\",\n \"turn.agentExit\": \"Agent process exited unexpectedly\",\n \"attachment.missing\": \"Attachment missing or expired ({ref}) — upload again\",\n \"export.defaultModel\": \"Default\",\n },\n};\n\nexport function tr(key: string, params?: Record): string {\n let s = dict[locale][key] ?? dict.en[key] ?? key;\n if (params) {\n for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v));\n }\n return s;\n}\n","import { existsSync } from \"node:fs\";\nimport type { AttachmentStore } from \"../store/attachments.js\";\nimport { validId } from \"./paths.js\";\nimport { tr } from \"./i18n.js\";\n\nlet globalAttachmentStore: AttachmentStore | null = null;\n\n/** Registered at main startup so Providers can resolve upload ids when reading images */\nexport function bindAttachmentStore(store: AttachmentStore): void {\n globalAttachmentStore = store;\n}\n\nasync function lookupAttachmentPath(ref: string): Promise {\n const store = globalAttachmentStore;\n if (!store || !validId(ref)) return null;\n const att = await store.get(ref);\n return att?.path ?? null;\n}\n\nexport async function resolveAttachmentRefs(\n store: AttachmentStore,\n refs: { path: string; mime: string }[],\n): Promise<{ path: string; mime: string }[]> {\n const out: { path: string; mime: string }[] = [];\n for (const a of refs) {\n const att = validId(a.path) ? await store.get(a.path) : null;\n if (att) {\n out.push({ path: att.path, mime: att.mime || a.mime });\n continue;\n }\n const viaGlobal = await lookupAttachmentPath(a.path);\n if (viaGlobal) {\n out.push({ path: viaGlobal, mime: a.mime });\n continue;\n }\n if (existsSync(a.path)) {\n out.push(a);\n continue;\n }\n throw new Error(tr(\"attachment.missing\", { ref: a.path }));\n }\n return out;\n}\n\n/** One path: absolute path / upload id → the file under the attachments directory; throws on failure */\nexport async function resolveImagePath(ref: string): Promise {\n if (existsSync(ref)) return ref;\n const p = await lookupAttachmentPath(ref);\n if (p) return p;\n throw new Error(tr(\"attachment.missing\", { ref: ref }));\n}\n","// Claude ProviderSession (§7.2.2/7.2.4/7.2.5/7.2.6): persistent query, streaming, permissions, interrupt, hot switching.\nimport { query, type Query, type Options, type SDKUserMessage, type PermissionResult, type EffortLevel, type PermissionMode } from \"@anthropic-ai/claude-agent-sdk\";\nimport { readFile } from \"node:fs/promises\";\nimport type { ProviderSession, SendInput, RespondAnswer, Mode, SlashCommand } from \"../types.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { AsyncQueue } from \"../../util/queue.js\";\nimport { callInfo } from \"../../agents/kinds.js\";\nimport { editDetail } from \"../../agents/diff.js\";\nimport { mapSdkMessage, newMapState, type MapState } from \"./map.js\";\nimport { log } from \"../../log.js\";\nimport { resolveImagePath } from \"../../util/attachmentRefs.js\";\nimport { classifyClaudeSlashCommand, sortSlashCommands } from \"../slash.js\";\n\nconst EFFORTS: EffortLevel[] = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"];\nconst rid = () => Math.random().toString(36).slice(2, 10);\n\ntype PendingReq = {\n kind: \"permission\" | \"question\";\n resolve: (r: RespondAnswer) => void;\n};\n\nexport type ClaudeSessionOpts = {\n threadId: string;\n cwd: string;\n model: string | null;\n mode: Mode;\n effort: string | null;\n resumeId: string | null;\n binCmd: string; // The claude executable (resolveBin's cmd)\n binPrefixArgs: string[]; // = [cli.js] in the shim case of node cli.js\n binVia?: string; // resolveBin's via: for cmd-shim / ps1 the SDK can't launch this file directly\n onModeChange?: (mode: Mode) => void; // Callback when a plan is approved → default (the manager updates meta)\n};\n\n/** resolveBin result → pathToClaudeCodeExecutable for the SDK.\n * - shim-js (node + cli.js): give cli.js to the SDK, which launches it with node;\n * - executable (native claude / claude.exe): pass it directly;\n * - cmd-shim / ps1 (.cmd / .ps1 wrappers on Windows): Node can't spawn them directly, so let the SDK find claude itself. */\nexport function claudeExecutable(o: Pick): { path: string; node: boolean } | null {\n if (!o.binCmd) return null;\n if (o.binVia === \"cmd-shim\" || o.binVia === \"ps1\") return null;\n const last = o.binPrefixArgs[o.binPrefixArgs.length - 1];\n if (last && /\\.(c|m)?js$/i.test(last)) return { path: last, node: true };\n if (o.binPrefixArgs.length) return null;\n return { path: o.binCmd, node: false };\n}\n\nexport class ClaudeSession implements ProviderSession {\n events: AsyncIterable;\n private q = new AsyncQueue();\n private input = new AsyncQueue();\n private query: Query | null = null;\n private abort = new AbortController();\n private sessionId: string | null;\n private turnId: string | null = null;\n private turnNum = 0;\n private st: MapState = newMapState();\n private pending = new Map();\n private needsRestart = false;\n private restarting: Promise | null = null;\n private closed = false;\n private stderrTail = \"\";\n private pumpDone: Promise | null = null;\n\n constructor(private o: ClaudeSessionOpts) {\n this.sessionId = o.resumeId;\n this.events = this.q;\n }\n\n // The query is only started before the first send (a failed detection should be reported quickly)\n private ensureQuery(): Query {\n if (this.query && !this.needsRestart) return this.query;\n this.abort = new AbortController();\n this.st = newMapState();\n const options: Options = {\n cwd: this.o.cwd,\n model: this.o.model ?? undefined,\n permissionMode: this.o.mode as PermissionMode,\n canUseTool: (toolName, input, opts) => this.canUseTool(toolName, input, opts),\n includePartialMessages: true,\n settingSources: [\"user\", \"project\", \"local\"],\n systemPrompt: { type: \"preset\", preset: \"claude_code\" },\n abortController: this.abort,\n ...(this.sessionId ? { resume: this.sessionId } : {}),\n stderr: (d: string) => {\n this.stderrTail = (this.stderrTail + d).slice(-4096);\n },\n };\n const exe = claudeExecutable(this.o);\n if (exe) {\n options.pathToClaudeCodeExecutable = exe.path;\n if (exe.node) options.executable = \"node\";\n }\n if (this.o.effort && EFFORTS.includes(this.o.effort as EffortLevel)) {\n options.effort = this.o.effort as EffortLevel;\n }\n const q = query({ prompt: this.input as AsyncIterable, options });\n this.query = q;\n this.needsRestart = false;\n this.pumpDone = this.pump(q);\n return q;\n }\n\n private async pump(q: Query): Promise {\n try {\n for await (const m of q) {\n for (const ev of mapSdkMessage(m, this.st, this.o.cwd)) this.emit(ev);\n }\n } catch (e) {\n // An end caused by abort is not a crash\n if (!this.abort.signal.aborted && !this.closed) {\n this.emit({ type: \"notice\", level: \"error\", message: \"Claude 会话异常:\" + this.errText(e) });\n }\n }\n // The event stream ended. An old query that restart() deliberately replaced (this.query is no longer it) must not close the event queue,\n // otherwise every event of the new query is dropped and the next turn stays running forever.\n if (!this.closed && this.query === q) {\n this.needsRestart = true; // Rebuild with resume on the next send\n this.q.close();\n }\n }\n\n private emit(ev: ProviderEvent): void {\n if (ev.type === \"turn_ended\" && !ev.turnId) ev.turnId = this.turnId ?? \"unknown\";\n if (ev.type === \"provider_session_id\" && ev.id) this.sessionId = ev.id;\n this.q.push(ev);\n }\n\n private errText(e: unknown): string {\n const base = String((e as Error)?.message ?? e);\n return this.stderrTail ? `${base};stderr: ${this.stderrTail.slice(-400)}` : base;\n }\n\n async send(input: SendInput): Promise {\n if (this.closed) throw new Error(\"会话已关闭\");\n if (this.needsRestart) await this.restart();\n const q = this.ensureQuery();\n this.turnNum++;\n this.turnId = \"u_\" + Date.now().toString(36) + \"_\" + this.turnNum;\n this.st.sawInterrupt = false; // A new turn clears the old interrupt flag\n this.emit({\n type: \"turn_started\",\n turnId: this.turnId,\n model: this.o.model,\n mode: this.o.mode,\n effort: this.o.effort,\n });\n const content = [] as unknown as SDKUserMessage[\"message\"][\"content\"];\n const blocks = content as unknown as Record[];\n for (const img of input.images ?? []) {\n try {\n const disk = await resolveImagePath(img.path);\n const data = await readFile(disk);\n blocks.push({\n type: \"image\",\n source: { type: \"base64\", media_type: img.mime, data: data.toString(\"base64\") },\n });\n } catch (e) {\n this.emit({ type: \"notice\", level: \"warn\", message: `图片读取失败 ${img.path}:${String(e)}` });\n }\n }\n blocks.push({ type: \"text\", text: input.text });\n this.input.push({\n type: \"user\",\n message: { role: \"user\", content },\n parent_tool_use_id: null,\n session_id: this.sessionId ?? \"\",\n });\n void q; // Messages flow into the running query through input\n }\n\n private async restart(): Promise {\n if (this.restarting) return this.restarting;\n this.restarting = (async () => {\n // Close the old query (drop the reference first so the old pump knows on exit that this was a deliberate restart)\n this.query = null;\n try {\n this.input.close();\n this.abort.abort();\n } catch {}\n if (this.pumpDone) await this.pumpDone.catch(() => {});\n // New input queue (the old one may already be closed)\n this.input = new AsyncQueue();\n try {\n this.ensureQuery();\n } finally {\n this.restarting = null;\n }\n })();\n return this.restarting;\n }\n\n // ---- permissions (§7.2.4) ----\n private canUseTool(\n toolName: string,\n input: Record,\n opts: { signal: AbortSignal; toolUseID?: string },\n ): Promise {\n const requestId = \"perm_\" + rid();\n const { signal } = opts;\n\n if (toolName === \"ExitPlanMode\") {\n return this.handlePlanMode(requestId, input, signal, opts.toolUseID);\n }\n if (toolName === \"AskUserQuestion\" && Array.isArray(input.questions)) {\n return this.handleQuestion(requestId, input, signal);\n }\n\n const info = callInfo(toolName, { ...input, _exists: true }, this.o.cwd);\n let detail: Record = { ...(info.detail as Record), toolName };\n if (info.kind === \"edit\" && (input.old_string !== undefined || Array.isArray(input.edits))) {\n detail = { ...editDetail({ path: info.detail.path as string, old: input.old_string, new: input.new_string, edits: input.edits }), toolName };\n }\n this.emit({\n type: \"permission_request\",\n requestId,\n title: info.title,\n kind: \"tool\",\n detail,\n options: [\n { id: \"allow\", label: \"允许\", kind: \"allow_once\" },\n { id: \"allow_always\", label: \"本对话都允许\", kind: \"allow_always\" },\n { id: \"deny\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n return new Promise((resolve) => {\n this.pending.set(requestId, { kind: \"permission\", resolve: (a) => resolve(this.toPermissionResult(a, input)) });\n signal.addEventListener(\"abort\", () => {\n if (this.pending.delete(requestId)) {\n this.emit({ type: \"permission_resolved\", requestId, cancelled: true });\n resolve({ behavior: \"deny\", message: \"已取消\", interrupt: true });\n }\n }, { once: true });\n });\n }\n\n private toPermissionResult(a: RespondAnswer, input: Record): PermissionResult {\n if (a.cancelled || a.optionId === \"deny\") {\n return { behavior: \"deny\", message: \"用户拒绝了此操作\", ...(a.cancelled ? { interrupt: true } : {}) };\n }\n return { behavior: \"allow\", updatedInput: input };\n }\n\n private async handlePlanMode(requestId: string, input: Record, signal: AbortSignal, toolUseID?: string): Promise {\n const planText = typeof input.plan === \"string\" ? input.plan : \"\";\n if (planText) {\n this.emit({ type: \"plan\", itemId: toolUseID ?? requestId, markdown: planText });\n }\n this.emit({\n type: \"permission_request\",\n requestId,\n title: \"Agent 提交了计划\",\n kind: \"plan\",\n detail: { plan: planText },\n options: [\n { id: \"implement\", label: \"实施\", kind: \"allow_once\" },\n { id: \"reject\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const answer = await new Promise((resolve) => {\n this.pending.set(requestId, { kind: \"permission\", resolve: (a) => resolve(a as RespondAnswer) });\n signal.addEventListener(\"abort\", () => {\n if (this.pending.delete(requestId)) {\n this.emit({ type: \"permission_resolved\", requestId, cancelled: true });\n resolve({ cancelled: true });\n }\n }, { once: true });\n });\n if (answer.cancelled || answer.optionId === \"reject\") {\n return { behavior: \"deny\", message: \"用户拒绝了计划,请根据反馈修改\", ...(answer.cancelled ? { interrupt: true } : {}) };\n }\n // implement: switch back to default mode\n try {\n await this.query?.setPermissionMode(\"default\");\n this.o.mode = \"default\";\n this.o.onModeChange?.(\"default\");\n } catch (e) {\n log(\"warn\", \"setPermissionMode(default) 失败\", { err: String(e) });\n }\n return { behavior: \"allow\", updatedInput: input };\n }\n\n private async handleQuestion(requestId: string, input: Record, signal: AbortSignal): Promise {\n this.emit({ type: \"question\", requestId, questions: input.questions as unknown[] });\n const answer = await new Promise((resolve) => {\n this.pending.set(requestId, { kind: \"question\", resolve: (a) => resolve(a as RespondAnswer) });\n signal.addEventListener(\"abort\", () => {\n if (this.pending.delete(requestId)) {\n this.emit({ type: \"permission_resolved\", requestId, cancelled: true });\n resolve({ cancelled: true });\n }\n }, { once: true });\n });\n if (answer.cancelled) {\n return { behavior: \"deny\", message: \"用户取消了提问\", interrupt: true };\n }\n // answers are keyed by the question text (Claude's AskUserQuestion schema keys answers by question text)\n const questions = (input.questions as { question?: string; header?: string }[]) ?? [];\n const normalized: Record = {};\n for (const ques of questions) {\n const text = ques.question;\n if (!text) continue;\n const v = answer.answers?.[text] ?? (ques.header ? answer.answers?.[ques.header] : undefined);\n if (v) normalized[text] = v;\n }\n return { behavior: \"allow\", updatedInput: { ...input, answers: normalized } };\n }\n\n respond(requestId: string, answer: RespondAnswer): void {\n const p = this.pending.get(requestId);\n if (p) {\n this.pending.delete(requestId);\n p.resolve(answer);\n }\n }\n\n async interrupt(): Promise {\n this.st.sawInterrupt = true;\n // Cancel every pending permission/question\n for (const [id, p] of this.pending) {\n this.emit({ type: \"permission_resolved\", requestId: id, cancelled: true });\n p.resolve({ cancelled: true });\n }\n this.pending.clear();\n try {\n const q = this.query;\n if (q) {\n await Promise.race([\n q.interrupt(),\n new Promise((_, rej) => setTimeout(() => rej(new Error(\"interrupt 超时\")), 3000)),\n ]);\n }\n } catch (e) {\n log(\"warn\", \"interrupt 失败\", { err: String(e) });\n this.needsRestart = true;\n }\n }\n\n async setModel(model: string | null): Promise {\n this.o.model = model;\n try {\n await this.query?.setModel(model ?? undefined);\n } catch (e) {\n log(\"warn\", \"setModel 失败\", { err: String(e) });\n }\n }\n\n async setMode(mode: Mode): Promise {\n this.o.mode = mode;\n try {\n await this.query?.setPermissionMode(mode as PermissionMode);\n } catch (e) {\n log(\"warn\", \"setPermissionMode 失败\", { err: String(e) });\n }\n }\n\n async setEffort(effort: string | null): Promise {\n this.o.effort = effort;\n // SDK Options.effort only applies when the query is created → rebuild (continued via resume)\n this.needsRestart = true;\n }\n\n async listCommands(): Promise {\n try {\n const q = this.ensureQuery();\n const commands = await q.supportedCommands();\n const map = new Map();\n for (const cmd of commands) {\n if (!map.has(cmd.name)) {\n map.set(cmd.name, {\n name: cmd.name,\n description: cmd.description,\n argumentHint: cmd.argumentHint,\n kind: classifyClaudeSlashCommand(cmd.name),\n });\n }\n }\n return sortSlashCommands([...map.values()]);\n } catch {\n return [];\n }\n }\n\n async close(): Promise {\n this.closed = true;\n for (const [, p] of this.pending) p.resolve({ cancelled: true });\n this.pending.clear();\n this.input.close();\n try {\n this.abort.abort();\n } catch {}\n if (this.pumpDone) {\n await Promise.race([this.pumpDone.catch(() => {}), new Promise((r) => setTimeout(r, 3000))]);\n }\n this.q.close();\n }\n}\n","// Model list (§7.2.7): Claude Code has no model query API.\n// Behind the magpie gateway, gateway models, the default model and thinking levels are filled in by providers/magpieGateway.ts;\n// without magpie only \"Default\" is offered (the frontend always shows it; no empty-id placeholder is added here anymore).\nimport type { ModelInfo } from \"../types.js\";\n\nexport async function listClaudeModels(): Promise {\n return [];\n}\n","// History import (§7.6): Claude sessions live in ~/.claude/projects//.jsonl.\n// Don't guess the encoding rule: list every directory, read the first record with a cwd from each jsonl, match the project with samePath.\n// Read-only throughout.\nimport { readdir, readFile, stat } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { samePath } from \"../util/paths.js\";\n\nconst CLAUDE_ROOT = join(homedir(), \".claude\", \"projects\");\n\ntype Rec = Record;\nconst rec = (v: unknown): Rec => (v && typeof v === \"object\" ? (v as Rec) : {});\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" ? v : undefined);\n\n// One jsonl line → {cwd?, title?, isUserText}\nfunction parseLine(line: string): { cwd?: string; userText?: string } | null {\n let r: Rec;\n try {\n r = rec(JSON.parse(line));\n } catch {\n return null;\n }\n const cwd = str(r.cwd);\n let userText: string | undefined;\n if (r.type === \"user\") {\n const content = rec(r.message).content;\n if (typeof content === \"string\") userText = content;\n else if (Array.isArray(content)) {\n const t = content.map((p) => (rec(p).type === \"text\" ? str(rec(p).text) : undefined)).find(Boolean);\n userText = t;\n }\n }\n return { cwd, userText };\n}\n\nasync function* linesOf(path: string): AsyncGenerator {\n const text = await readFile(path, \"utf8\").catch(() => \"\");\n for (const line of text.split(\"\\n\")) {\n if (line.trim()) yield line;\n }\n}\n\n// Project cwd → that project's past sessions\nexport async function listClaudeHistory(cwd: string): Promise<{ sessionId: string; title: string; updatedAt: number; messageCount: number }[]> {\n const out: { sessionId: string; title: string; updatedAt: number; messageCount: number }[] = [];\n const dirs = await readdir(CLAUDE_ROOT).catch(() => [] as string[]);\n for (const d of dirs) {\n const dirPath = join(CLAUDE_ROOT, d);\n const files = (await readdir(dirPath).catch(() => [] as string[])).filter((f) => f.endsWith(\".jsonl\"));\n for (const f of files) {\n const path = join(dirPath, f);\n let match = false;\n let title = \"\";\n let count = 0;\n for await (const line of linesOf(path)) {\n const p = parseLine(line);\n if (!p) continue;\n if (!match) {\n if (!p.cwd) continue;\n match = samePath(p.cwd, cwd);\n if (!match) break;\n }\n if (p.userText && !title) title = p.userText.slice(0, 40);\n if (p.userText) count++;\n }\n if (!match) continue;\n const s = await stat(path).catch(() => null);\n out.push({\n sessionId: f.replace(/\\.jsonl$/, \"\"),\n title: title || \"(无标题)\",\n updatedAt: s ? Math.round(s.mtimeMs) : 0,\n messageCount: count,\n });\n }\n }\n out.sort((a, b) => b.updatedAt - a.updatedAt);\n return out;\n}\n\n// Read one session's jsonl → SDK message shape (so mapSdkMessage can be reused)\nexport async function readClaudeHistory(sessionId: string, cwd: string): Promise {\n const dirs = await readdir(CLAUDE_ROOT).catch(() => [] as string[]);\n for (const d of dirs) {\n const path = join(CLAUDE_ROOT, d, `${sessionId}.jsonl`);\n if (!existsSync(path)) continue;\n // Confirm it belongs to this project (the first cwd record matches)\n const msgs: Rec[] = [];\n let matched = false;\n for await (const line of linesOf(path)) {\n const p = parseLine(line);\n if (!matched) {\n if (p?.cwd && samePath(p.cwd, cwd)) matched = true;\n else continue;\n }\n let r: Rec;\n try {\n r = rec(JSON.parse(line));\n } catch {\n continue;\n }\n // Keep only user/assistant/result — same shape as the SDK's streamed messages, so mapSdkMessage can digest them\n if (r.type === \"user\" || r.type === \"assistant\") msgs.push(r);\n }\n return msgs;\n }\n return [];\n}\n","// Claude Provider (§7.2): detection + createSession(ClaudeSession).\nimport type { Provider, ProviderSession, DetectResult, ModelInfo, HistoryItem } from \"../types.js\";\nimport { resolveBin, killTree, spawnBin, type ResolveResult } from \"../../util/bin.js\";\nimport { ClaudeSession } from \"./session.js\";\nimport { listClaudeModels } from \"./models.js\";\nimport { listClaudeHistory, readClaudeHistory } from \"../../history/import.js\";\nimport { mapSdkMessage, newMapState } from \"./map.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\n\nconst INSTALL_HINT = \"npm i -g @anthropic-ai/claude-code\";\n\nexport const claudeCapabilities = {\n resume: true,\n interrupt: true,\n steer: false, // Claude doesn't support real steering — the manager queues instead (§7.2.2)\n images: true,\n plan: true,\n questions: true,\n cost: true,\n effort: [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"],\n modes: [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"] as (\"plan\" | \"default\" | \"acceptEdits\" | \"bypassPermissions\")[],\n};\n\nfunction runVersion(r: ResolveResult & { ok: true }, args: string[], timeoutMs = 5000): Promise {\n return new Promise((resolvePromise, reject) => {\n let out = \"\";\n const p = spawnBin(r, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"], env: { ...process.env, NO_COLOR: \"1\", FORCE_COLOR: \"0\" } });\n const t = setTimeout(() => {\n try {\n void killTree(p, { graceMs: 500 }); // On Windows a .cmd wrapper must be killed together with its child processes\n } catch {}\n reject(new Error(\"timeout\"));\n }, timeoutMs);\n p.stdout?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.stderr?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.on(\"error\", reject);\n p.on(\"close\", (code) => {\n clearTimeout(t);\n if (code === 0) resolvePromise(out);\n else reject(new Error(\"exit \" + code));\n });\n });\n}\n\nexport function createClaudeProvider(): Provider {\n return {\n id: \"claude\",\n name: \"Claude Code\",\n capabilities: claudeCapabilities,\n async detect(): Promise {\n const bin = await resolveBin(\"claude\");\n if (!bin.ok) return { available: false, reason: bin.reason, installHint: INSTALL_HINT };\n try {\n const version = await runVersion(bin, [\"--version\"]);\n return { available: true, bin: bin.display, version: version.trim().split(\"\\n\")[0] || \"\" };\n } catch {\n return { available: true, bin: bin.display, version: \"\" };\n }\n },\n async listModels(): Promise {\n return listClaudeModels();\n },\n async createSession(o): Promise {\n const bin = await resolveBin(\"claude\");\n if (!bin.ok) throw new Error(`找不到 claude:${bin.reason}`);\n return new ClaudeSession({\n threadId: o.threadId,\n cwd: o.cwd,\n model: o.model,\n mode: o.mode,\n effort: o.effort,\n resumeId: o.resumeId,\n binCmd: bin.cmd,\n binPrefixArgs: bin.prefixArgs,\n binVia: bin.via,\n });\n },\n // §7.6: jsonl sessions under ~/.claude/projects matched by cwd\n async listHistory(cwd: string): Promise {\n const items = await listClaudeHistory(cwd);\n return items.map((i) => ({ provider: \"claude\", ...i, imported: false }));\n },\n async readHistory(sessionId: string, cwd: string): Promise {\n const msgs = await readClaudeHistory(sessionId, cwd);\n const st = newMapState();\n const out: ProviderEvent[] = [];\n for (const m of msgs) {\n const r = m as { type?: string; message?: { content?: unknown } };\n // User text messages don't appear as events in the SDK stream (the manager writes user_message itself); emit them here\n if (r.type === \"user\") {\n const content = r.message?.content;\n const text = typeof content === \"string\"\n ? content\n : Array.isArray(content)\n ? (content as { type?: string; text?: string }[]).filter((b) => b.type === \"text\").map((b) => b.text ?? \"\").join(\"\")\n : \"\";\n if (text.trim()) out.push({ type: \"user_message\", text });\n else out.push(...mapSdkMessage(m as never, st, cwd)); // tool_result and the like still go through the mapping\n continue;\n }\n out.push(...mapSdkMessage(m as never, st, cwd));\n }\n // Finish: an imported session is closed\n out.push({ type: \"turn_ended\", turnId: \"\", status: \"completed\" });\n return out;\n },\n };\n}\n","// Codex history import (§7.6): ~/.codex/sessions/**/rollout-*.jsonl\n// Layout: session_meta (with cwd) → event_msg → response_item (message/function_call/...)\n// Import only user messages (minus environment_context and similar wrappers) + the assistant's final reply text; tool steps become notices. Read-only throughout.\nimport { readdir, readFile, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { samePath } from \"../util/paths.js\";\n\nconst CODEX_ROOT = join(homedir(), \".codex\", \"sessions\");\n\ntype Rec = Record;\nconst rec = (v: unknown): Rec => (v && typeof v === \"object\" ? (v as Rec) : {});\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" ? v : undefined);\n\n// Environment wrappers in user messages: anything whose first line starts with or isn't user input\nconst WRAPPERS = /^\\s*<(environment_context|user_instructions|skills_instructions|permissions instructions)>/i;\n\nfunction textOf(content: unknown, kind: string): string {\n if (!Array.isArray(content)) return \"\";\n return (content as Rec[])\n .filter((p) => p.type === kind || p.type === \"input_text\" || p.type === \"output_text\")\n .map((p) => str(p.text) ?? \"\")\n .join(\"\\n\")\n .trim();\n}\n\n// Find rollout-*.jsonl recursively\nasync function* walk(dir: string, depth = 0): AsyncGenerator {\n if (depth > 6) return;\n for (const name of await readdir(dir).catch(() => [] as string[])) {\n const p = join(dir, name);\n const s = await stat(p).catch(() => null);\n if (!s) continue;\n if (s.isDirectory()) yield* walk(p, depth + 1);\n else if (/^rollout-.*\\.jsonl$/.test(name)) yield p;\n }\n}\n\nexport type CodexHistItem = { sessionId: string; title: string; updatedAt: number; messageCount: number };\n\nexport async function listCodexHistory(cwd: string): Promise {\n const out: CodexHistItem[] = [];\n for await (const path of walk(CODEX_ROOT)) {\n const m = /rollout-.*-([0-9a-f-]{36})\\.jsonl$/i.exec(path);\n const sessionId = m?.[1];\n if (!sessionId) continue;\n const text = await readFile(path, \"utf8\").catch(() => \"\");\n let match = false;\n let title = \"\";\n let count = 0;\n for (const line of text.split(\"\\n\")) {\n if (!line.trim()) continue;\n let r: Rec;\n try {\n r = rec(JSON.parse(line));\n } catch {\n continue;\n }\n // session_meta records payload.cwd\n if (r.type === \"session_meta\") {\n const cwdInFile = str(rec(r.payload).cwd);\n if (cwdInFile && samePath(cwdInFile, cwd)) match = true;\n else break; // Not this project: stop reading\n continue;\n }\n if (!match) continue;\n if (r.type === \"response_item\") {\n const p = rec(r.payload);\n if (p.type === \"message\" && p.role === \"user\") {\n const t = textOf(p.content, \"input_text\");\n if (t && !WRAPPERS.test(t)) {\n count++;\n if (!title) title = t.slice(0, 40);\n }\n }\n }\n }\n if (!match) continue;\n const s = await stat(path).catch(() => null);\n out.push({ sessionId, title: title || \"(无标题)\", updatedAt: s ? Math.round(s.mtimeMs) : 0, messageCount: count });\n }\n out.sort((a, b) => b.updatedAt - a.updatedAt);\n return out;\n}\n\n// Read one rollout → simplified events (user_message / assistant_message / notice for tool steps / turn_ended)\ntype CodexEv = {\n type: string; text?: string; message?: string; itemId?: string; level?: string;\n turnId?: string; status?: string;\n};\nexport async function readCodexHistory(sessionId: string, cwd: string): Promise {\n const out: CodexEv[] = [];\n let file: string | null = null;\n for await (const p of walk(CODEX_ROOT)) {\n if (p.endsWith(`${sessionId}.jsonl`)) { file = p; break; }\n }\n if (!file) return out;\n const text = await readFile(file, \"utf8\").catch(() => \"\");\n let matched = false;\n let aid = 0;\n // response_item.message and event_msg.item_completed duplicate each other → dedupe by text\n const seenText = new Set();\n const pushText = (type: \"user_message\" | \"assistant_message\", t: string) => {\n const key = type + \":\" + t;\n if (!t || seenText.has(key)) return;\n seenText.add(key);\n out.push(type === \"user_message\" ? { type, text: t } : { type, itemId: `imp_a${aid++}`, text: t });\n };\n for (const line of text.split(\"\\n\")) {\n if (!line.trim()) continue;\n let r: Rec;\n try {\n r = rec(JSON.parse(line));\n } catch {\n continue;\n }\n if (r.type === \"session_meta\") {\n const c = str(rec(r.payload).cwd);\n if (c && samePath(c, cwd)) matched = true;\n continue;\n }\n if (!matched) continue;\n // response_item message\n if (r.type === \"response_item\") {\n const p = rec(r.payload);\n if (p.type === \"message\") {\n const role = str(p.role);\n const phase = str(p.phase);\n if (role === \"user\") {\n const t = textOf(p.content, \"input_text\");\n if (t && !WRAPPERS.test(t)) pushText(\"user_message\", t);\n } else if (role === \"assistant\" && phase !== \"commentary\") {\n const t = textOf(p.content, \"output_text\");\n if (t) pushText(\"assistant_message\", t);\n }\n } else if (p.type === \"function_call\" || p.type === \"custom_tool_call\") {\n const name = str(p.name) ?? \"tool\";\n out.push({ type: \"notice\", level: \"info\", message: `(历史)调用 ${name}` });\n }\n continue;\n }\n // event_msg.item_completed: AgentMessage (Text content blocks) and task_complete.last_agent_message\n if (r.type === \"event_msg\") {\n const p = rec(r.payload);\n if (p.type === \"item_completed\") {\n const it = rec(p.item);\n if (it.type === \"AgentMessage\" && it.phase !== \"commentary\") {\n const t = textOf(it.content, \"Text\");\n if (t) pushText(\"assistant_message\", t);\n } else if (it.type === \"UserMessage\") {\n const t = textOf(it.content, \"text\");\n if (t && !WRAPPERS.test(t)) pushText(\"user_message\", t);\n }\n } else if (p.type === \"task_complete\") {\n const last = str(p.last_agent_message);\n if (last) pushText(\"assistant_message\", last);\n }\n continue;\n }\n const p2 = rec(r.payload);\n if (p2.type === \"function_call\" || p2.type === \"custom_tool_call\") {\n // Tool steps collapse into a one-line notice (§7.6, Codex part)\n const name = str(p2.name) ?? \"tool\";\n out.push({ type: \"notice\", level: \"info\", message: `(历史)调用 ${name}` });\n }\n }\n out.push({ type: \"turn_ended\", turnId: \"import\", status: \"completed\" });\n return out;\n}\n","// JSON-RPC over stdio client for `codex app-server` (§7.3.2).\n// stdout is parsed line by line: {id,result|error} → response; {id,method} → server request; {method} → notification.\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { log } from \"../../log.js\";\nimport { killTree, trackTree, type Argv } from \"../../util/bin.js\";\n\nexport class CodexRpcError extends Error {\n code?: number;\n constructor(message: string, code?: number) {\n super(message);\n this.code = code;\n }\n}\n\nexport class CodexProcessExitedError extends CodexRpcError {\n constructor() {\n super(\"Codex 进程已退出\");\n }\n}\n\ntype ServerRequestHandler = (method: string, params: Record) => Promise;\ntype NotificationHandler = (method: string, params: Record) => void;\n\nexport class CodexRpc {\n private proc: ChildProcess;\n private nextId = 1;\n private pending = new Map void; reject: (e: Error) => void }>();\n private buf = \"\";\n private exited = false;\n onServerRequest?: ServerRequestHandler;\n onNotification?: NotificationHandler;\n onExit?: (code: number | null) => void;\n\n constructor(\n argv: Argv,\n opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {},\n ) {\n this.proc = spawn(argv.cmd, argv.args, {\n cwd: opts.cwd,\n env: opts.env ?? process.env,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n windowsHide: true,\n ...(argv.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),\n // Non-Windows: its own process group, so after a close() timeout killTree also ends the commands codex started\n ...(process.platform !== \"win32\" ? { detached: true } : {}),\n });\n trackTree(this.proc);\n this.proc.stdout!.setEncoding(\"utf8\");\n this.proc.stdout!.on(\"data\", (chunk: string) => this.onData(chunk));\n this.proc.stderr!.setEncoding(\"utf8\");\n this.proc.stderr!.on(\"data\", (chunk: string) => {\n this.stderrTail = (this.stderrTail + chunk).slice(-4096);\n });\n this.proc.on(\"error\", () => this.failAll());\n this.proc.on(\"close\", (code) => {\n this.exited = true;\n this.failAll();\n this.onExit?.(code);\n });\n }\n\n private stderrTail = \"\";\n\n get stderr(): string {\n return this.stderrTail;\n }\n\n get isExited(): boolean {\n return this.exited;\n }\n\n private onData(chunk: string): void {\n this.buf += chunk;\n let i: number;\n while ((i = this.buf.indexOf(\"\\n\")) >= 0) {\n const line = this.buf.slice(0, i).trim();\n this.buf = this.buf.slice(i + 1);\n if (!line) continue;\n let msg: Record;\n try {\n msg = JSON.parse(line);\n } catch {\n log(\"warn\", \"codex rpc: 非 JSON 行\", { line: line.slice(0, 200) });\n continue;\n }\n void this.dispatch(msg);\n }\n }\n\n private async dispatch(msg: Record): Promise {\n const hasId = msg.id !== undefined && msg.id !== null;\n if (hasId && (\"result\" in msg || \"error\" in msg)) {\n const id = Number(msg.id);\n const p = this.pending.get(id);\n if (p) {\n this.pending.delete(id);\n if (msg.error) {\n const e = msg.error as { code?: number; message?: string };\n p.reject(new CodexRpcError(e.message ?? \"RPC error\", e.code));\n } else {\n p.resolve(msg.result);\n }\n }\n return;\n }\n if (hasId && msg.method) {\n // Server request → needs a response\n const id = Number(msg.id);\n try {\n const result = this.onServerRequest\n ? await this.onServerRequest(String(msg.method), (msg.params as Record) ?? {})\n : undefined;\n this.send({ id, result: result ?? null });\n } catch (e) {\n this.send({ id, error: { code: -32603, message: String((e as Error)?.message ?? e) } });\n }\n return;\n }\n if (!hasId && msg.method) {\n try {\n this.onNotification?.(String(msg.method), (msg.params as Record) ?? {});\n } catch (e) {\n log(\"warn\", \"codex rpc: 通知处理失败\", { method: msg.method, err: String(e) });\n }\n }\n }\n\n private send(obj: Record): void {\n if (this.exited) return;\n try {\n this.proc.stdin!.write(JSON.stringify(obj) + \"\\n\");\n } catch {}\n }\n\n request(method: string, params?: Record, timeoutMs = 60000): Promise {\n if (this.exited) return Promise.reject(new CodexProcessExitedError());\n const id = this.nextId++;\n return new Promise((resolvePromise, reject) => {\n const timer = setTimeout(() => {\n this.pending.delete(id);\n reject(new CodexRpcError(`请求超时:${method}`));\n }, timeoutMs);\n this.pending.set(id, {\n resolve: (v) => {\n clearTimeout(timer);\n resolvePromise(v as T);\n },\n reject: (e) => {\n clearTimeout(timer);\n reject(e);\n },\n });\n this.send({ jsonrpc: \"2.0\", id, method, params: params ?? {} });\n });\n }\n\n notify(method: string, params?: Record): void {\n this.send({ jsonrpc: \"2.0\", method, params: params ?? {} });\n }\n\n private failAll(): void {\n const err = new CodexProcessExitedError();\n for (const [, p] of this.pending) p.reject(err);\n this.pending.clear();\n }\n\n async close(): Promise {\n try {\n this.proc.stdin?.end();\n } catch {}\n await new Promise((resolvePromise) => {\n const t = setTimeout(() => {\n void killTree(this.proc, { graceMs: 1500 });\n resolvePromise();\n }, 3000);\n this.proc.on(\"close\", () => {\n clearTimeout(t);\n resolvePromise();\n });\n if (this.exited) {\n clearTimeout(t);\n resolvePromise();\n }\n });\n }\n}\n","// Codex app-server notifications → ProviderEvent (§7.3.4). Method and field names follow the generated schema.\nimport type { ProviderEvent } from \"../../timeline/types.js\";\n\nconst MAX_OUTPUT = 20 * 1024;\nconst trunc = (s: string, n = 80): string => (s.length > n ? s.slice(0, n - 1) + \"…\" : s);\n\ntype Rec = Record;\nconst rec = (v: unknown): Rec => (v && typeof v === \"object\" ? (v as Rec) : {});\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" ? v : undefined);\n\nexport type CodexMapState = {\n currentTurnId: string | null;\n filesChanged: number;\n diffText: string;\n};\n\nexport function newCodexMapState(): CodexMapState {\n return { currentTurnId: null, filesChanged: 0, diffText: \"\" };\n}\n\n// One server notification → 0..n unified events. Server requests (approvals) don't go through here.\nexport function mapNotification(method: string, params: Rec, st: CodexMapState): ProviderEvent[] {\n const out: ProviderEvent[] = [];\n const turnId = str(params.turnId);\n\n switch (method) {\n case \"turn/started\": {\n const turn = rec(params.turn);\n const tid = str(turn.id);\n if (tid) st.currentTurnId = tid;\n st.filesChanged = 0;\n st.diffText = \"\";\n break;\n }\n case \"item/agentMessage/delta\": {\n const itemId = str(params.itemId) ?? \"\";\n const delta = str(params.delta) ?? \"\";\n if (itemId && delta) out.push({ type: \"assistant_delta\", itemId, text: delta });\n break;\n }\n case \"item/plan/delta\": {\n // Incremental plan stream: wait for the plan item in item/completed to send the full plan\n break;\n }\n case \"item/reasoning/summaryTextDelta\":\n case \"item/reasoning/textDelta\": {\n const itemId = str(params.itemId) ?? \"\";\n const delta = str(params.delta) ?? \"\";\n if (itemId && delta) out.push({ type: \"reasoning_delta\", itemId, text: delta });\n break;\n }\n case \"item/commandExecution/outputDelta\":\n case \"item/fileChange/outputDelta\":\n case \"command/exec/outputDelta\": {\n const itemId = str(params.itemId) ?? \"\";\n const delta = str(params.delta) ?? \"\";\n if (itemId && delta) out.push({ type: \"tool_output_delta\", itemId, text: delta });\n break;\n }\n case \"item/started\": {\n const item = rec(params.item);\n const type = str(item.type) ?? \"\";\n const itemId = str(item.id) ?? \"\";\n if (type === \"commandExecution\") {\n const cmd = str(item.command) ?? \"\";\n out.push({\n type: \"tool_call\", itemId, kind: \"bash\",\n title: `运行 ${trunc(cmd)}`, status: \"running\",\n detail: { command: cmd, cwd: str(item.cwd) },\n });\n } else if (type === \"fileChange\") {\n const changes = Array.isArray(item.changes) ? item.changes : [];\n out.push({\n type: \"tool_call\", itemId, kind: \"edit\",\n title: `修改 ${changes.length} 个文件`, status: \"running\",\n detail: { changes },\n });\n } else if (type === \"mcpToolCall\" || type === \"dynamicToolCall\") {\n const tool = str(item.tool) ?? str(item.name) ?? \"\";\n const server = str(item.server) ?? \"\";\n out.push({\n type: \"tool_call\", itemId, kind: \"mcp\",\n title: `${server || \"mcp\"} · ${tool}`, status: \"running\",\n });\n } else if (type === \"webSearch\") {\n out.push({\n type: \"tool_call\", itemId, kind: \"web\",\n title: `搜索网页 \"${trunc(str(item.query) ?? \"\", 40)}\"`, status: \"running\",\n });\n }\n // agentMessage/reasoning/userMessage/plan etc.: wait for a delta or completed\n break;\n }\n case \"item/completed\": {\n const item = rec(params.item);\n const type = str(item.type) ?? \"\";\n const itemId = str(item.id) ?? \"\";\n if (type === \"agentMessage\") {\n const text = str(item.text) ?? \"\";\n if (text) out.push({ type: \"assistant_message\", itemId, text });\n } else if (type === \"reasoning\") {\n const summary = Array.isArray(item.summary) ? item.summary : [];\n const text = summary.filter((s): s is string => typeof s === \"string\").join(\"\\n\");\n if (text) out.push({ type: \"reasoning\", itemId, text });\n } else if (type === \"commandExecution\") {\n const cmd = str(item.command) ?? \"\";\n const exitCode = typeof item.exitCode === \"number\" ? item.exitCode : undefined;\n const output = str(item.aggregatedOutput) ?? \"\";\n out.push({\n type: \"tool_call\", itemId, kind: \"bash\",\n title: `运行 ${trunc(cmd)}`,\n status: exitCode === 0 ? \"ok\" : \"failed\",\n output: output.length > MAX_OUTPUT ? output.slice(0, MAX_OUTPUT) : output,\n exitCode,\n durationMs: typeof item.durationMs === \"number\" ? item.durationMs : undefined,\n });\n } else if (type === \"fileChange\") {\n const changes = Array.isArray(item.changes) ? item.changes : [];\n const istatus = str(item.status);\n st.filesChanged += changes.length;\n out.push({\n type: \"tool_call\", itemId, kind: \"edit\",\n title: `修改 ${changes.length} 个文件`,\n status: istatus === \"failed\" ? \"failed\" : \"ok\",\n detail: { changes },\n });\n } else if (type === \"mcpToolCall\" || type === \"dynamicToolCall\") {\n const tool = str(item.tool) ?? str(item.name) ?? \"\";\n const server = str(item.server) ?? \"\";\n const istatus = str(item.status);\n const err = str(item.error);\n out.push({\n type: \"tool_call\", itemId, kind: \"mcp\",\n title: `${server || \"mcp\"} · ${tool}`,\n status: istatus === \"failed\" || err ? \"failed\" : \"ok\",\n output: err ?? undefined,\n });\n } else if (type === \"webSearch\") {\n out.push({\n type: \"tool_call\", itemId, kind: \"web\",\n title: `搜索网页 \"${trunc(str(item.query) ?? \"\", 40)}\"`, status: \"ok\",\n });\n } else if (type === \"plan\") {\n const text = str(item.text) ?? \"\";\n if (text) out.push({ type: \"plan\", itemId, markdown: text });\n }\n break;\n }\n case \"turn/plan/updated\": {\n const steps = Array.isArray(params.plan) ? params.plan : [];\n const items = steps.map((s) => {\n const step = rec(s);\n const status = str(step.status) ?? \"pending\";\n return {\n text: str(step.step) ?? \"\",\n status: status === \"completed\" ? \"done\" : status === \"inProgress\" || status === \"in_progress\" ? \"in_progress\" : \"pending\",\n };\n });\n if (items.length) out.push({ type: \"todo\", items });\n break;\n }\n case \"turn/diff/updated\": {\n const diff = str(params.diff) ?? \"\";\n if (diff) st.diffText = diff;\n break;\n }\n case \"thread/tokenUsage/updated\": {\n const usage = rec(params.tokenUsage);\n const last = rec(usage.last) || usage;\n const input = typeof last.inputTokens === \"number\" ? last.inputTokens : 0;\n const output = typeof last.outputTokens === \"number\" ? last.outputTokens : 0;\n const cached = typeof last.cachedInputTokens === \"number\" ? last.cachedInputTokens : 0;\n out.push({\n type: \"usage\",\n inputTokens: input,\n outputTokens: output,\n cacheReadTokens: cached,\n costUsd: 0,\n });\n break;\n }\n case \"turn/completed\": {\n const turn = rec(params.turn);\n const status = str(turn.status) ?? \"completed\";\n const error = rec(turn.error);\n out.push({\n type: \"turn_ended\",\n turnId: str(turn.id) ?? turnId ?? st.currentTurnId ?? \"\",\n status: status === \"completed\" ? \"completed\" : status === \"interrupted\" ? \"interrupted\" : \"failed\",\n durationMs: typeof turn.durationMs === \"number\" ? turn.durationMs : undefined,\n error: str(error.message),\n filesChanged: st.filesChanged || undefined,\n });\n break;\n }\n case \"error\": {\n const msg = str(params.message) ?? \"Codex 错误\";\n out.push({ type: \"notice\", level: \"error\", message: msg });\n break;\n }\n default:\n break;\n }\n return out;\n}\n","import { readdir, readFile, stat } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport type { SlashCommand } from \"../slash.js\";\nimport { parseSlashInput, sortSlashCommands } from \"../slash.js\";\n\nexport type CodexSlashEntry = SlashCommand & { body: string };\n\nfunction parseFrontMatter(raw: string): { meta: Record; body: string } {\n if (!raw.startsWith(\"---\\n\")) return { meta: {}, body: raw };\n const end = raw.indexOf(\"\\n---\\n\", 4);\n if (end === -1) return { meta: {}, body: raw };\n const fm = raw.slice(4, end);\n const meta: Record = {};\n for (const line of fm.split(/\\r?\\n/)) {\n const m = line.match(/^([a-zA-Z0-9_-]+):\\s*(.+)$/);\n if (m) meta[m[1]!] = m[2]!.trim().replace(/^[\"']|[\"']$/g, \"\");\n }\n return { meta, body: raw.slice(end + 5) };\n}\n\nfunction firstLine(body: string): string {\n for (const line of body.split(/\\r?\\n/)) {\n const t = line.trim();\n if (t && !t.startsWith(\"#\")) return t.replace(/^#+\\s*/, \"\").slice(0, 120);\n }\n return \"\";\n}\n\nasync function readPromptFiles(dir: string): Promise {\n const out: CodexSlashEntry[] = [];\n let names: string[] = [];\n try {\n names = await readdir(dir);\n } catch {\n return out;\n }\n for (const f of names) {\n if (!f.endsWith(\".md\")) continue;\n const path = join(dir, f);\n try {\n const st = await stat(path);\n if (!st.isFile()) continue;\n const raw = await readFile(path, \"utf8\");\n const { meta, body } = parseFrontMatter(raw);\n const name = basename(f, \".md\");\n out.push({\n name,\n description: meta.description || firstLine(body) || name,\n argumentHint: meta.argumentHint || meta[\"argument-hint\"],\n kind: \"command\",\n body,\n });\n } catch {\n /* skip */\n }\n }\n return out;\n}\n\nasync function readSkillDirs(root: string): Promise {\n const out: CodexSlashEntry[] = [];\n let dirs: string[] = [];\n try {\n dirs = await readdir(root);\n } catch {\n return out;\n }\n for (const d of dirs) {\n const skillPath = join(root, d, \"SKILL.md\");\n try {\n const st = await stat(skillPath);\n if (!st.isFile()) continue;\n const raw = await readFile(skillPath, \"utf8\");\n const { meta, body } = parseFrontMatter(raw);\n const name = meta.name || d;\n out.push({\n name,\n description: meta.description || firstLine(body) || name,\n argumentHint: meta.argumentHint,\n kind: \"skill\",\n body,\n });\n } catch {\n /* skip */\n }\n }\n return out;\n}\n\nexport async function loadCodexSlashEntries(cwd: string, opts?: { skipHome?: boolean }): Promise {\n const home = homedir();\n const parts = await Promise.all([\n opts?.skipHome ? Promise.resolve([]) : readPromptFiles(join(home, \".codex\", \"prompts\")),\n readPromptFiles(join(cwd, \".codex\", \"prompts\")),\n opts?.skipHome ? Promise.resolve([]) : readSkillDirs(join(home, \".codex\", \"skills\")),\n readSkillDirs(join(cwd, \".codex\", \"skills\")),\n ]);\n const byName = new Map();\n for (const list of parts) {\n for (const e of list) {\n if (!byName.has(e.name)) byName.set(e.name, e);\n }\n }\n return sortSlashCommands([...byName.values()]) as CodexSlashEntry[];\n}\n\nexport function expandCodexSlash(text: string, entries: CodexSlashEntry[]): string {\n const parsed = parseSlashInput(text);\n if (!parsed) return text;\n const hit = entries.find((e) => e.name === parsed.name);\n if (!hit) return text;\n return hit.body.replace(/\\$ARGUMENTS/g, parsed.args);\n}\n\nexport function codexSlashCommandsFromEntries(entries: CodexSlashEntry[]): SlashCommand[] {\n return entries.map(({ body: _b, ...rest }) => rest);\n}\n","// Codex ProviderSession (§7.3.3/7.3.5): codex app-server JSON-RPC session.\nimport type { ProviderSession, SendInput, RespondAnswer, Mode, SlashCommand } from \"../types.js\";\nimport { resolveImagePath } from \"../../util/attachmentRefs.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { AsyncQueue } from \"../../util/queue.js\";\nimport { CodexRpc } from \"./rpc.js\";\nimport { argvFor } from \"../../util/bin.js\";\nimport { mapNotification, newCodexMapState, type CodexMapState } from \"./map.js\";\nimport {\n codexSlashCommandsFromEntries,\n expandCodexSlash,\n loadCodexSlashEntries,\n type CodexSlashEntry,\n} from \"./commands.js\";\nimport { log } from \"../../log.js\";\n\nconst rid = () => Math.random().toString(36).slice(2, 10);\n\n// §7.3.3 permission presets (per the four modes). Note the two shapes differ:\n// `sandbox` in thread/start|resume is a kebab-case enum (\"read-only\"…);\n// `sandboxPolicy` in turn/start is an object ({type:\"readOnly\"}…).\nconst MODE_PRESETS: Record }> = {\n plan: { approvalPolicy: \"on-request\", sandbox: \"read-only\", sandboxPolicy: { type: \"readOnly\" } },\n default: { approvalPolicy: \"on-request\", sandbox: \"read-only\", sandboxPolicy: { type: \"readOnly\" } },\n acceptEdits: { approvalPolicy: \"on-request\", sandbox: \"workspace-write\", sandboxPolicy: { type: \"workspaceWrite\" } },\n bypassPermissions: { approvalPolicy: \"never\", sandbox: \"danger-full-access\", sandboxPolicy: { type: \"dangerFullAccess\" } },\n ask: { approvalPolicy: \"on-request\", sandbox: \"read-only\", sandboxPolicy: { type: \"readOnly\" } },\n};\n\ntype PendingReq = {\n kind: \"permission\" | \"question\";\n resolve: (a: RespondAnswer) => void;\n};\n\nexport class CodexSession implements ProviderSession {\n events: AsyncIterable;\n private q = new AsyncQueue();\n private rpc: CodexRpc | null = null;\n private st: CodexMapState = newCodexMapState();\n private sessionThreadId: string | null = null;\n private pending = new Map();\n private closed = false;\n private started: Promise | null = null;\n private interruptWanted = false;\n private ourTurnId: string | null = null; // The current turn's turnId in the timeline (generated on send)\n private staleCodexTurns = new Set();\n private codexSlash: CodexSlashEntry[] | null = null;\n\n constructor(\n private o: {\n threadId: string;\n cwd: string;\n model: string | null;\n mode: Mode;\n effort: string | null;\n resumeId: string | null;\n binCmd: string;\n binPrefixArgs: string[];\n binVia?: string;\n binShim?: string;\n version: string;\n },\n ) {\n this.events = this.q;\n }\n\n private emit(ev: ProviderEvent): void {\n if (ev.type === \"turn_ended\") {\n // A late completion for the previous turn (already ended by the interrupt fallback): drop it so it doesn't end the new turn\n if (ev.turnId && this.staleCodexTurns.delete(ev.turnId)) return;\n // turn_started uses our own turnId, so the end must use the same one,\n // otherwise the manager's per-turn dedupe breaks (interrupt fallback + late completion would end the next turn)\n if (this.ourTurnId) ev.turnId = this.ourTurnId;\n this.ourTurnId = null;\n this.st.currentTurnId = null;\n this.interruptWanted = false;\n }\n this.q.push(ev);\n }\n\n private async start(): Promise {\n if (this.started) return this.started;\n const attempt = (async () => {\n const rpc = new CodexRpc(\n argvFor(\n { ok: true, cmd: this.o.binCmd, prefixArgs: this.o.binPrefixArgs, display: this.o.binCmd, via: this.o.binVia ?? \"path\", shim: this.o.binShim },\n [\"app-server\"],\n ),\n { cwd: this.o.cwd },\n );\n this.rpc = rpc;\n rpc.onNotification = (m, p) => {\n for (const ev of mapNotification(m, p, this.st)) this.emit(ev);\n if (this.interruptWanted && this.st.currentTurnId) void this.interrupt();\n };\n rpc.onServerRequest = (m, p) => this.onServerRequest(m, p);\n rpc.onExit = () => {\n for (const [, p] of this.pending) p.resolve({ cancelled: true });\n this.pending.clear();\n if (!this.closed) this.q.close();\n };\n await rpc.request(\"initialize\", {\n clientInfo: { name: \"agent-console\", title: \"Agent Console\", version: this.o.version },\n capabilities: { experimentalApi: true },\n });\n rpc.notify(\"initialized\", {});\n const preset = MODE_PRESETS[this.o.mode];\n let threadId: string;\n if (this.o.resumeId) {\n const res = (await rpc.request(\"thread/resume\", {\n threadId: this.o.resumeId,\n cwd: this.o.cwd,\n model: this.o.model,\n approvalPolicy: preset.approvalPolicy,\n sandbox: preset.sandbox,\n })) as { thread?: { id?: string } };\n threadId = res.thread?.id ?? this.o.resumeId;\n } else {\n const res = (await rpc.request(\"thread/start\", {\n cwd: this.o.cwd,\n model: this.o.model,\n approvalPolicy: preset.approvalPolicy,\n sandbox: preset.sandbox,\n })) as { thread?: { id?: string } };\n threadId = res.thread?.id ?? \"\";\n }\n if (threadId) {\n this.sessionThreadId = threadId;\n this.emit({ type: \"provider_session_id\", id: threadId });\n }\n })();\n // Don't cache a failed start: clear it so the next send starts app-server again\n this.started = attempt.catch((e) => {\n if (this.started === guarded) {\n this.started = null;\n const rpc = this.rpc;\n this.rpc = null;\n if (rpc) {\n rpc.onExit = () => {};\n void Promise.resolve(rpc.close()).catch(() => {});\n }\n }\n throw e;\n });\n const guarded = this.started;\n return this.started;\n }\n\n async send(input: SendInput): Promise {\n if (this.closed) throw new Error(\"会话已关闭\");\n await this.start();\n const rpc = this.rpc!;\n const preset = MODE_PRESETS[this.o.mode];\n const entries = this.codexSlash ?? (await loadCodexSlashEntries(this.o.cwd));\n this.codexSlash = entries;\n const wireText = expandCodexSlash(input.text, entries);\n const items: Record[] = [{ type: \"text\", text: wireText }];\n for (const img of input.images ?? []) {\n items.push({ type: \"localImage\", path: await resolveImagePath(img.path) });\n }\n this.interruptWanted = false;\n const turnId = \"u_\" + Date.now().toString(36) + \"_\" + rid();\n // A new turn started before the previous one completed (interrupt fallback) → remember its Codex turnId\n if (this.ourTurnId && this.st.currentTurnId) this.staleCodexTurns.add(this.st.currentTurnId);\n this.ourTurnId = turnId;\n this.st.currentTurnId = null; // Filled in by this turn's turn/started, so we never interrupt/steer with the previous turn's id\n this.emit({\n type: \"turn_started\",\n turnId,\n model: this.o.model,\n mode: this.o.mode,\n effort: this.o.effort,\n });\n try {\n await rpc.request(\"turn/start\", {\n threadId: this.sessionThreadId ?? undefined,\n input: items,\n model: this.o.model,\n effort: this.o.effort,\n approvalPolicy: preset.approvalPolicy,\n sandboxPolicy: preset.sandboxPolicy,\n });\n } catch (e) {\n this.emit({\n type: \"turn_ended\",\n turnId,\n status: \"failed\",\n error: String((e as Error)?.message ?? e),\n });\n }\n }\n\n async steer(input: SendInput): Promise {\n // No Codex turnId yet (turn/started hasn't arrived) → throw; the manager queues instead so the message isn't lost\n if (!this.rpc || !this.st.currentTurnId) throw new Error(\"当前轮次还没开始,无法插话\");\n const items: Record[] = [{ type: \"text\", text: input.text }];\n for (const img of input.images ?? []) {\n items.push({ type: \"localImage\", path: await resolveImagePath(img.path) });\n }\n try {\n await this.rpc.request(\"turn/steer\", {\n threadId: this.threadId(),\n expectedTurnId: this.st.currentTurnId,\n input: items,\n });\n } catch (e) {\n log(\"warn\", \"codex steer 失败\", { err: String(e) });\n }\n }\n\n async interrupt(): Promise {\n for (const [, p] of this.pending) p.resolve({ cancelled: true });\n this.pending.clear();\n if (!this.rpc) return;\n if (!this.st.currentTurnId) {\n // turn/started hasn't arrived yet: remember it and send the interrupt as soon as the turnId is known\n this.interruptWanted = true;\n return;\n }\n this.interruptWanted = false;\n try {\n await this.rpc.request(\n \"turn/interrupt\",\n { threadId: this.threadId(), turnId: this.st.currentTurnId },\n 5000,\n );\n } catch (e) {\n // \"Already idle\"-type errors count as success\n const msg = String((e as Error)?.message ?? e);\n if (!/idle|not running|no.*turn/i.test(msg)) {\n log(\"warn\", \"codex interrupt 失败\", { err: msg });\n }\n }\n }\n\n private threadId(): string | undefined {\n return this.sessionThreadId ?? undefined;\n }\n\n // ---- server requests (§7.3.5 approvals) ----\n private async onServerRequest(method: string, params: Record): Promise {\n switch (method) {\n case \"item/commandExecution/requestApproval\": {\n const command = String(params.command ?? \"\");\n const requestId = \"perm_\" + rid();\n this.emit({\n type: \"permission_request\",\n requestId,\n title: `运行 ${command.slice(0, 80)}`,\n kind: \"tool\",\n detail: { command, cwd: params.cwd, reason: params.reason, approvalId: params.approvalId, itemId: params.itemId },\n options: [\n { id: \"accept\", label: \"允许\", kind: \"allow_once\" },\n { id: \"acceptForSession\", label: \"本对话都允许\", kind: \"allow_always\" },\n { id: \"decline\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const a = await this.waitFor(requestId);\n if (a.cancelled) return { decision: \"cancel\" };\n const opt = a.optionId === \"acceptForSession\" ? \"acceptForSession\" : a.optionId === \"decline\" ? \"decline\" : \"accept\";\n return { decision: opt };\n }\n case \"item/fileChange/requestApproval\": {\n const changes = Array.isArray(params.changes) ? params.changes : [];\n const requestId = \"perm_\" + rid();\n this.emit({\n type: \"permission_request\",\n requestId,\n title: \"修改文件\",\n kind: \"tool\",\n detail: { changes, approvalId: params.approvalId, itemId: params.itemId, reason: params.reason },\n options: [\n { id: \"accept\", label: \"允许\", kind: \"allow_once\" },\n { id: \"acceptForSession\", label: \"本对话都允许\", kind: \"allow_always\" },\n { id: \"decline\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const a = await this.waitFor(requestId);\n if (a.cancelled) return { decision: \"cancel\" };\n const opt = a.optionId === \"acceptForSession\" ? \"acceptForSession\" : a.optionId === \"decline\" ? \"decline\" : \"accept\";\n return { decision: opt };\n }\n case \"item/tool/requestUserInput\": {\n const requestId = \"q_\" + rid();\n this.emit({\n type: \"question\",\n requestId,\n questions: (params.questions as unknown[]) ?? [],\n });\n const a = await this.waitFor(requestId);\n if (a.cancelled) return { answers: {} };\n // schema: { answers: { : { answers: [..] } } }\n const raw = a.answers ?? {};\n const shaped: Record = {};\n for (const [k, v] of Object.entries(raw)) shaped[k] = { answers: [v] };\n return { answers: shaped };\n }\n default:\n throw new Error(`未识别的服务端请求 ${method}`);\n }\n }\n\n private waitFor(requestId: string): Promise {\n return new Promise((resolve) => {\n this.pending.set(requestId, { kind: \"permission\", resolve });\n });\n }\n\n respond(requestId: string, answer: RespondAnswer): void {\n const p = this.pending.get(requestId);\n if (p) {\n this.pending.delete(requestId);\n p.resolve(answer);\n // Writing back resolved events is the manager's job (permission_resolved/question_answered)\n }\n }\n\n async setModel(model: string | null): Promise {\n this.o.model = model; // Takes effect on the next turn/start\n }\n async setMode(mode: Mode): Promise {\n this.o.mode = mode; // Takes effect on the next turn/start\n }\n async setEffort(effort: string | null): Promise {\n this.o.effort = effort; // Takes effect on the next turn/start\n }\n\n async listCommands(): Promise {\n try {\n const entries = this.codexSlash ?? (await loadCodexSlashEntries(this.o.cwd));\n this.codexSlash = entries;\n return codexSlashCommandsFromEntries(entries);\n } catch {\n return [];\n }\n }\n\n async close(): Promise {\n this.closed = true;\n for (const [, p] of this.pending) p.resolve({ cancelled: true });\n this.pending.clear();\n if (this.rpc) await this.rpc.close();\n this.q.close();\n }\n}\n","// Codex Provider (§7.3): detect + listModels (temporary app-server → model/list, cached 10 minutes) + createSession.\nimport type { Provider, ProviderSession, DetectResult, ModelInfo, Capabilities, HistoryItem } from \"../types.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { listCodexHistory, readCodexHistory } from \"../../history/codex.js\";\nimport { argvFor, resolveBin, killTree, spawnBin, type ResolveResult } from \"../../util/bin.js\";\nimport { CodexRpc, CodexProcessExitedError } from \"./rpc.js\";\nimport { CodexSession } from \"./session.js\";\nimport { log } from \"../../log.js\";\n\nconst INSTALL_HINT = \"npm i -g @openai/codex\";\n\nexport const codexCapabilities: Capabilities = {\n resume: true,\n interrupt: true,\n steer: true,\n images: true,\n plan: true,\n questions: true,\n cost: false, // tokenUsage yes, cost no\n effort: [], // effort values depend on the model — filled from model/list after detect\n modes: [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"],\n};\n\nlet modelCache: { at: number; models: ModelInfo[] } | null = null;\nconst MODEL_CACHE_MS = 10 * 60 * 1000;\n\nfunction runVersion(r: ResolveResult & { ok: true }, args: string[], timeoutMs = 5000): Promise {\n return new Promise((resolvePromise, reject) => {\n let out = \"\";\n const p = spawnBin(r, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n const t = setTimeout(() => {\n try {\n void killTree(p, { graceMs: 500 }); // On Windows a .cmd wrapper must be killed together with its child processes\n } catch {}\n reject(new Error(\"timeout\"));\n }, timeoutMs);\n p.stdout?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.stderr?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.on(\"error\", reject);\n p.on(\"close\", (code) => {\n clearTimeout(t);\n if (code === 0) resolvePromise(out);\n else reject(new Error(\"exit \" + code));\n });\n });\n}\n\n/** Normalize the level list to strings: accepts string[] and { reasoningEffort, description }[]; other shapes are dropped */\nexport function normalizeEfforts(raw: unknown[] | undefined): string[] {\n const out: string[] = [];\n for (const e of raw ?? []) {\n const v = typeof e === \"string\" ? e : (e as { reasoningEffort?: unknown } | null)?.reasoningEffort;\n if (typeof v === \"string\" && v && !out.includes(v)) out.push(v);\n }\n return out;\n}\n\nasync function fetchModels(r: ResolveResult & { ok: true }, cwd: string): Promise {\n const rpc = new CodexRpc(argvFor(r, [\"app-server\"]), { cwd });\n try {\n await rpc.request(\"initialize\", {\n clientInfo: { name: \"agent-console\", title: \"Agent Console\", version: \"0\" },\n capabilities: { experimentalApi: true },\n }, 15000);\n rpc.notify(\"initialized\", {});\n const res = (await rpc.request(\"model/list\", {}, 15000)) as {\n data?: {\n id?: string;\n displayName?: string;\n isDefault?: boolean;\n // Newer app-server returns { reasoningEffort, description }[], older ones string[]\n supportedReasoningEfforts?: unknown[];\n defaultReasoningEffort?: string;\n }[];\n };\n const rows = res.data ?? [];\n return rows\n .filter((m) => typeof m.id === \"string\" && m.id)\n .map((m) => {\n const efforts = normalizeEfforts(m.supportedReasoningEfforts);\n const thinkingOptions = efforts.map((e) => ({ id: e, label: e }));\n const defaultThinking = (typeof m.defaultReasoningEffort === \"string\" ? m.defaultReasoningEffort : null) ?? thinkingOptions[0]?.id ?? null;\n return {\n id: m.id!,\n name: m.displayName ?? m.id!,\n isDefault: m.isDefault,\n efforts,\n thinkingOptions,\n defaultThinking,\n };\n });\n } finally {\n await rpc.close();\n }\n}\n\nexport function createCodexProvider(version: string): Provider {\n return {\n id: \"codex\",\n name: \"Codex\",\n capabilities: codexCapabilities,\n async detect(): Promise {\n const bin = await resolveBin(\"codex\");\n if (!bin.ok) {\n return { available: false, reason: bin.reason, installHint: INSTALL_HINT };\n }\n try {\n const v = await runVersion(bin, [\"--version\"]);\n return { available: true, bin: bin.display, version: v.trim().split(\"\\n\")[0] || \"\" };\n } catch (e) {\n return { available: true, bin: bin.display, version: \"\", reason: String(e) };\n }\n },\n async listModels(): Promise {\n if (modelCache && Date.now() - modelCache.at < MODEL_CACHE_MS) return modelCache.models;\n const bin = await resolveBin(\"codex\");\n if (!bin.ok) return [{ id: \"\", name: \"默认\" }];\n try {\n const models = await fetchModels(bin, process.cwd());\n modelCache = { at: Date.now(), models };\n return models;\n } catch (e) {\n // Gateway region errors etc. — the frontend shows a single \"Default\" entry (§7.3.6)\n log(\"warn\", \"codex model/list 失败\", { err: String(e) });\n return [{ id: \"\", name: \"默认\" }];\n }\n },\n async createSession(o): Promise {\n const bin = await resolveBin(\"codex\");\n if (!bin.ok) throw new Error(`找不到 codex:${bin.reason}`);\n return new CodexSession({\n threadId: o.threadId,\n cwd: o.cwd,\n model: o.model,\n mode: o.mode,\n effort: o.effort,\n resumeId: o.resumeId,\n binCmd: bin.cmd,\n binPrefixArgs: bin.prefixArgs,\n binVia: bin.via,\n binShim: bin.shim,\n version,\n });\n },\n // §7.6: ~/.codex/sessions/**/rollout-*.jsonl matched by cwd\n async listHistory(cwd: string): Promise {\n const items = await listCodexHistory(cwd);\n return items.map((i) => ({ provider: \"codex\", ...i, imported: false }));\n },\n async readHistory(sessionId: string, cwd: string): Promise {\n return (await readCodexHistory(sessionId, cwd)) as unknown as ProviderEvent[];\n },\n };\n}\n","/** Provider catalog (fixed, 8 entries).\n * status: verified=accepted on a real machine | experimental=usable but not guaranteed | planned=integration not confirmed\n * All 8 are currently verified; default toggle: verified ones are on by default (see enabled.ts). */\nexport type ProviderStatus = \"verified\" | \"experimental\" | \"planned\";\n\nexport type ProviderCatalogEntry = {\n id: string;\n name: string;\n bins: string[];\n installHint: string;\n status: ProviderStatus;\n};\n\nexport const PROVIDER_CATALOG: ProviderCatalogEntry[] = [\n { id: \"claude\", name: \"Claude Code\", bins: [\"claude\"], installHint: \"npm i -g @anthropic-ai/claude-code\", status: \"verified\" },\n { id: \"codex\", name: \"Codex\", bins: [\"codex\"], installHint: \"见 OpenAI Codex CLI 安装说明\", status: \"verified\" },\n { id: \"cursor\", name: \"Cursor\", bins: [\"cursor-agent\", \"cursor\"], installHint: \"安装 Cursor 并确保 CLI 在 PATH\", status: \"verified\" },\n { id: \"copilot\", name: \"Copilot\", bins: [\"copilot\"], installHint: \"GitHub Copilot CLI 安装说明\", status: \"verified\" },\n { id: \"opencode\", name: \"OpenCode\", bins: [\"opencode\"], installHint: \"npm i -g opencode / 见 OpenCode 文档\", status: \"verified\" },\n { id: \"pi\", name: \"Pi\", bins: [\"pi-acp\", \"pi\", \"pi-coding-agent\"], installHint: \"npm i -g pi-acp(或 Pi coding agent + ACP 适配器)\", status: \"verified\" },\n { id: \"devin\", name: \"Devin CLI\", bins: [\"devin\"], installHint: \"Devin CLI 官方安装\", status: \"verified\" },\n { id: \"dsh\", name: \"dsh\", bins: [\"dsh\"], installHint: \"npm i -g @deepseek-ai/dsh\", status: \"verified\" },\n];\n\nexport function catalogById(id: string): ProviderCatalogEntry | undefined {\n return PROVIDER_CATALOG.find((p) => p.id === id);\n}\n\nexport function providerCreatable(status: ProviderStatus): boolean {\n return status !== \"planned\";\n}\n","import type { ChildProcess } from \"node:child_process\";\nimport { Readable, Writable } from \"node:stream\";\nimport { resolve as resolvePath } from \"node:path\";\nimport * as acp from \"@agentclientprotocol/sdk\";\nimport type { ResolveResult } from \"../../util/bin.js\";\nimport { killTree, spawnBin } from \"../../util/bin.js\";\n\nexport function spawnResolved(r: ResolveResult & { ok: true }, args: string[], o: { cwd?: string } = {}): ChildProcess {\n // group: on non-Windows it gets its own process group so killTree also ends the tool processes it started\n return spawnBin(r, args, {\n cwd: o.cwd,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n env: { ...process.env, NO_COLOR: \"1\", FORCE_COLOR: \"0\" },\n group: true,\n });\n}\n\nexport function acpStreamFromChild(child: ChildProcess): acp.Stream {\n if (!child.stdin || !child.stdout) throw new Error(\"ACP 子进程缺少 stdio 管道\");\n const output = Writable.toWeb(child.stdin) as WritableStream;\n const input = Readable.toWeb(child.stdout) as ReadableStream;\n return acp.ndJsonStream(output, input);\n}\n\nexport function absCwd(cwd: string): string {\n return resolvePath(cwd);\n}\n\nexport async function killAcpChild(child: ChildProcess | null): Promise {\n await killTree(child);\n}\n","import { readFile, writeFile } from \"node:fs/promises\";\nimport type { ClientApp } from \"@agentclientprotocol/sdk\";\nimport { methods } from \"@agentclientprotocol/sdk\";\nimport type { RespondAnswer } from \"../types.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\n\nexport type PermBridge = {\n push(ev: ProviderEvent): void;\n waitFor(requestId: string): Promise;\n /** Cursor Auto-review: no permission card, allow is chosen automatically */\n autoAcceptPermissions?: () => boolean;\n};\n\nexport function registerAcpClientHandlers(app: ClientApp, bridge: PermBridge): void {\n app.onRequest(methods.client.fs.readTextFile, async (ctx) => {\n const text = await readFile(ctx.params.path, \"utf8\");\n return { content: text };\n });\n app.onRequest(methods.client.fs.writeTextFile, async (ctx) => {\n await writeFile(ctx.params.path, ctx.params.content, \"utf8\");\n return {};\n });\n app.onRequest(methods.client.session.requestPermission, async (ctx) => {\n const tc = ctx.params.toolCall;\n const options = ctx.params.options.map((o) => ({\n id: o.optionId,\n label: o.name,\n kind: o.kind,\n }));\n if (bridge.autoAcceptPermissions?.()) {\n const allow =\n options.find((o) => o.kind === \"allow_once\" || o.kind === \"allow_always\") ?? options[0];\n if (allow) return { outcome: { outcome: \"selected\", optionId: allow.id } };\n }\n const requestId = \"p\" + Math.random().toString(36).slice(2, 10);\n bridge.push({\n type: \"permission_request\",\n requestId,\n title: tc.title ?? tc.name ?? \"需要授权\",\n kind: tc.kind ?? \"tool\",\n detail: tc.rawInput ?? undefined,\n options,\n });\n const answer = await bridge.waitFor(requestId);\n if (answer.cancelled) return { outcome: { outcome: \"cancelled\" } };\n const optionId = answer.optionId ?? options[0]?.id;\n if (!optionId) return { outcome: { outcome: \"cancelled\" } };\n return { outcome: { outcome: \"selected\", optionId } };\n });\n}\n","import type * as schema from \"@agentclientprotocol/sdk\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport type { ToolKind } from \"../../timeline/types.js\";\n\nconst rid = () => Math.random().toString(36).slice(2, 8);\n\nfunction mapToolKind(k: schema.ToolKind | null | undefined): ToolKind {\n switch (k) {\n case \"read\":\n case \"edit\":\n case \"search\":\n return k;\n case \"execute\":\n return \"bash\";\n case \"fetch\":\n return \"web\";\n default:\n return \"other\";\n }\n}\n\nfunction mapToolStatus(s: schema.ToolCallStatus | null | undefined): \"running\" | \"ok\" | \"failed\" {\n if (s === \"completed\") return \"ok\";\n if (s === \"failed\") return \"failed\";\n return \"running\";\n}\n\nfunction textFromContent(content: schema.ContentBlock): string {\n if (content.type === \"text\") return content.text;\n return \"\";\n}\n\nexport type MapCtx = { itemPrefix: string; messageIds: Map };\n\nexport function newMapCtx(): MapCtx {\n return { itemPrefix: \"a\" + rid() + \"_\", messageIds: new Map() };\n}\n\nexport function mapSessionUpdate(update: schema.SessionUpdate, ctx: MapCtx): ProviderEvent[] {\n const out: ProviderEvent[] = [];\n const mid = \"messageId\" in update && update.messageId ? String(update.messageId) : null;\n\n switch (update.sessionUpdate) {\n case \"agent_message_chunk\": {\n const text = textFromContent(update.content);\n if (!text) break;\n const itemId = mid ? ctx.messageIds.get(mid) ?? ctx.itemPrefix + mid : ctx.itemPrefix + \"msg\";\n if (mid) ctx.messageIds.set(mid, itemId);\n out.push({ type: \"assistant_delta\", itemId, text });\n break;\n }\n case \"agent_thought_chunk\": {\n const text = textFromContent(update.content);\n if (!text) break;\n const itemId = mid ? ctx.itemPrefix + \"t_\" + mid : ctx.itemPrefix + \"think\";\n out.push({ type: \"reasoning_delta\", itemId, text });\n break;\n }\n case \"tool_call\":\n out.push({\n type: \"tool_call\",\n itemId: ctx.itemPrefix + update.toolCallId,\n kind: mapToolKind(update.kind),\n title: update.title,\n status: mapToolStatus(update.status),\n input: update.rawInput,\n });\n break;\n case \"tool_call_update\":\n out.push({\n type: \"tool_call\",\n itemId: ctx.itemPrefix + update.toolCallId,\n kind: mapToolKind(update.kind),\n title: update.title ?? update.name ?? \"tool\",\n status: mapToolStatus(update.status),\n input: update.rawInput,\n output: typeof update.rawOutput === \"string\" ? update.rawOutput : undefined,\n });\n break;\n case \"plan\":\n out.push({\n type: \"plan\",\n itemId: ctx.itemPrefix + \"plan\",\n markdown: update.entries.map((e) => `- ${e.content}`).join(\"\\n\"),\n entries: update.entries.map((e) => ({ step: e.content, status: e.status ?? \"\" })),\n });\n break;\n case \"notice\":\n out.push({\n type: \"notice\",\n level: update.severity === \"error\" ? \"error\" : update.severity === \"warning\" ? \"warn\" : \"info\",\n message: update.description ? `${update.title}: ${update.description}` : update.title,\n });\n break;\n case \"usage_update\":\n out.push({\n type: \"usage\",\n inputTokens: update.used,\n outputTokens: 0,\n cacheReadTokens: 0,\n costUsd: typeof update.cost?.amount === \"number\" ? update.cost.amount : 0,\n contextWindow: update.size,\n });\n break;\n default:\n break;\n }\n return out;\n}\n\nexport function stopToTurnEnd(stopReason: schema.StopReason, turnId: string, err?: string): ProviderEvent {\n if (stopReason === \"cancelled\") {\n return { type: \"turn_ended\", turnId, status: \"interrupted\" };\n }\n if (stopReason === \"refusal\" || stopReason === \"max_tokens\") {\n return { type: \"turn_ended\", turnId, status: \"failed\", error: err ?? stopReason };\n }\n return { type: \"turn_ended\", turnId, status: \"completed\" };\n}\n","// Persistent ACP session: cursor-agent acp / devin acp and others.\nimport type { ActiveSession, ClientConnection, ClientContext } from \"@agentclientprotocol/sdk\";\nimport { client, methods, PROTOCOL_VERSION } from \"@agentclientprotocol/sdk\";\nimport type { ChildProcess } from \"node:child_process\";\nimport type { ProviderSession, SendInput, RespondAnswer, Mode, SlashCommand } from \"../types.js\";\nimport { mapAcpAvailableCommands, sortSlashCommands } from \"../slash.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { AsyncQueue } from \"../../util/queue.js\";\nimport { resolveFirstBin, type ResolveResult } from \"../../util/bin.js\";\nimport { absCwd, acpStreamFromChild, killAcpChild, spawnResolved } from \"./spawn.js\";\nimport { registerAcpClientHandlers } from \"./clientHandlers.js\";\nimport { mapSessionUpdate, newMapCtx, stopToTurnEnd, type MapCtx } from \"./mapUpdate.js\";\n\ntype AcpSelectValue = { value?: string; name?: string; options?: AcpSelectValue[] };\ntype AcpConfigOption = { id: string; category?: string | null; type?: string; currentValue?: unknown; options?: AcpSelectValue[] };\nimport { log } from \"../../log.js\";\n\nconst rid = () => Math.random().toString(36).slice(2, 10);\n\ntype AttachableCtx = {\n attachSession(response: { sessionId: string; modes?: unknown }): ActiveSession;\n};\n\nexport type AcpSessionOpts = {\n threadId: string;\n cwd: string;\n model: string | null;\n mode: Mode;\n effort: string | null;\n resumeId: string | null;\n binName: string;\n binCandidates: string[];\n acpArgs: string[];\n appVersion: string;\n flavor: \"cursor\" | \"generic\";\n modeMap?: Partial>;\n cursorFast?: boolean;\n cursorAutoReview?: boolean;\n launch?: { cmd: string; prefixArgs: string[] };\n};\n\nexport class AcpSession implements ProviderSession {\n events: AsyncIterable;\n private q = new AsyncQueue();\n private child: ChildProcess | null = null;\n private conn: ClientConnection | null = null;\n private ctx: ClientContext | null = null;\n private active: ActiveSession | null = null;\n private initErr: Error | null = null;\n private initPromise: Promise;\n private closed = false;\n private turnId: string | null = null;\n private turnNum = 0;\n private running = false;\n private permPending = new Map void>();\n private mapCtx: MapCtx = newMapCtx();\n private modelConfigId: string | null = null;\n private thoughtConfigId: string | null = null;\n private cursorFast = false;\n private cursorAutoReview = false;\n private slashCommands: SlashCommand[] = [];\n private activeTurnId: string | null = null;\n private configOptions: AcpConfigOption[] | null = null;\n private wakePump: (() => void) | null = null;\n // listCommands waits for the first batch of commands (or confirmation there are none), following Paseo's waitForInitialCommands\n private commandsReady!: Promise;\n private settleCommands!: () => void;\n\n constructor(private o: AcpSessionOpts) {\n this.events = this.q;\n this.cursorFast = o.cursorFast ?? false;\n // The shield (auto-accept permission prompts) UI was removed: never auto-approve, so conversations that once turned it on don't keep approving with the toggle hidden\n this.cursorAutoReview = false;\n this.commandsReady = new Promise((r) => (this.settleCommands = r));\n this.initPromise = this.boot();\n }\n\n private async boot(): Promise {\n const bin = this.o.launch\n ? ({\n ok: true as const,\n cmd: this.o.launch.cmd,\n prefixArgs: this.o.launch.prefixArgs,\n display: this.o.launch.cmd,\n via: \"custom\",\n binName: \"custom\",\n } satisfies ResolveResult & { ok: true; binName: string })\n : await resolveFirstBin(this.o.binCandidates?.length ? this.o.binCandidates : [this.o.binName]);\n if (!bin.ok) throw new Error(bin.reason);\n try {\n this.child = spawnResolved(bin, this.o.acpArgs, { cwd: this.o.cwd });\n this.child.stderr?.on(\"data\", (d: Buffer) => {\n const t = d.toString().trim();\n if (t) log(\"info\", `[${this.o.binName} acp] ${t.slice(0, 500)}`);\n });\n this.child.on(\"error\", (e) => this.failInit(e));\n this.child.on(\"close\", (code) => {\n if (!this.closed && code !== 0) {\n this.q.push({ type: \"notice\", level: \"error\", message: `${this.o.binName} 进程退出 (${code})` });\n }\n });\n\n const app = client({ name: \"magpie-agent-console\" });\n registerAcpClientHandlers(app, {\n push: (ev) => this.q.push(ev),\n waitFor: (id) => this.waitPerm(id),\n autoAcceptPermissions: () => this.cursorAutoReview,\n });\n const stream = acpStreamFromChild(this.child);\n this.conn = app.connect(stream);\n this.ctx = this.conn.agent;\n await this.ctx.request(methods.agent.initialize, {\n protocolVersion: PROTOCOL_VERSION,\n // Cursor: declare support for the parameterized model picker (as Paseo does); only then does it expose levels / Fast as separate config options\n clientCapabilities: this.o.flavor === \"cursor\" ? { _meta: { parameterizedModelPicker: true } } : {},\n clientInfo: { name: \"Agent Console\", version: this.o.appVersion },\n });\n\n const cwd = absCwd(this.o.cwd);\n const ctx = this.ctx;\n if (!ctx) throw new Error(\"ACP 未连接\");\n if (this.o.resumeId) {\n const loaded = await ctx.request(methods.agent.session.load, {\n sessionId: this.o.resumeId,\n cwd,\n mcpServers: [],\n });\n this.active = (ctx as unknown as AttachableCtx).attachSession({\n sessionId: this.o.resumeId,\n modes: loaded.modes,\n });\n } else {\n this.active = await ctx.buildSession(cwd).start();\n }\n this.syncModelConfigId();\n this.syncThoughtConfigId();\n await this.applySessionMode(this.o.mode);\n await this.applySessionModel(this.o.model); // Also applies the thinking level\n await this.applyCursorAutoReviewConfig(this.cursorAutoReview);\n this.q.push({ type: \"provider_session_id\", id: this.active.sessionId });\n const init = (this.active.newSessionResponse as { availableCommands?: { name: string; description: string; input?: { hint?: string } | null }[] })\n ?.availableCommands;\n if (init?.length) this.applyAvailableCommands(init);\n void this.pumpUpdates();\n } catch (e) {\n this.failInit(e);\n throw e;\n }\n }\n\n private failInit(e: unknown): void {\n this.initErr = e instanceof Error ? e : new Error(String(e));\n }\n\n private applyAvailableCommands(\n raw: { name: string; description: string; input?: { hint?: string } | null }[],\n ): void {\n this.slashCommands = sortSlashCommands(mapAcpAvailableCommands(raw));\n this.q.push({ type: \"slash_commands\", commands: this.slashCommands });\n this.settleCommands();\n }\n\n private async ready(): Promise {\n await this.initPromise.catch(() => {});\n if (this.initErr) throw this.initErr;\n if (!this.active) throw new Error(\"ACP 会话未就绪\");\n }\n\n /** The config options currently in effect: each set_config_option response returns the latest set (levels change after switching models); otherwise use session/new's */\n private currentConfig(): AcpConfigOption[] {\n return (this.configOptions ?? this.active?.newSessionResponse.configOptions ?? []) as AcpConfigOption[];\n }\n\n private syncModelConfigId(): void {\n for (const o of this.currentConfig()) {\n if (o.type === \"select\" && (o.category === \"model\" || o.id === \"model\")) {\n this.modelConfigId = o.id;\n return;\n }\n }\n if (this.o.flavor === \"cursor\") this.modelConfigId = \"model\";\n }\n\n // Level options belong to the current model: recompute on every refresh (cleared when switching to a model without levels)\n private syncThoughtConfigId(): void {\n this.thoughtConfigId = null;\n const opts = this.currentConfig().filter((o) => o.type === \"select\");\n const hit =\n opts.find((o) => o.category === \"thought_level\") ??\n opts.find((o) => /thought|thinking|effort|reasoning/i.test(o.id ?? \"\"));\n if (hit) this.thoughtConfigId = hit.id;\n }\n\n /** set_config_option, then refresh local state from the latest config options it returns */\n private async setOption(configId: string, value: string): Promise {\n if (!this.ctx || !this.active) return false;\n try {\n const res = (await this.ctx.request(methods.agent.session.setConfigOption, {\n sessionId: this.active.sessionId,\n configId,\n value,\n })) as { configOptions?: AcpConfigOption[] } | null;\n if (Array.isArray(res?.configOptions)) {\n this.configOptions = res.configOptions;\n this.syncThoughtConfigId();\n }\n return true;\n } catch (e) {\n log(\"info\", `ACP set_config_option(${configId}=${value}): ${String(e)}`);\n return false;\n }\n }\n\n private async applySessionThought(effort: string | null): Promise {\n if (!effort || !this.thoughtConfigId) return;\n // Don't send a level the current model doesn't support (the level set changes after switching models)\n const opt = this.currentConfig().find((o) => o.id === this.thoughtConfigId);\n const values = (opt?.options ?? []).flatMap((x) => (x.options ? x.options : [x])).map((x) => x.value);\n if (values.length && !values.includes(effort)) return;\n await this.setOption(this.thoughtConfigId, effort);\n }\n\n /** Mode choices the Agent reports: prefer a category=mode select in configOptions, otherwise ACP modes.availableModes */\n private modeChoices(): { id: string; name: string; configId: string | null }[] {\n const opt = this.currentConfig().find(\n (o) => o.type === \"select\" && (o.category === \"mode\" || o.id === \"mode\"),\n ) as { id: string; options?: { value?: string; name?: string }[] } | undefined;\n if (opt?.options?.length) {\n return opt.options\n .filter((x) => typeof x.value === \"string\")\n .map((x) => ({ id: x.value!, name: x.name ?? x.value!, configId: opt.id }));\n }\n return (this.active?.modes?.availableModes ?? []).map((m) => ({ id: m.id, name: m.name, configId: null }));\n }\n\n private resolveAcpModeId(mode: Mode): string | null {\n if (this.o.modeMap) {\n // Find the Agent's real mode via the table; Copilot's mode ids are URLs (…/session-modes#agent), so match on the # suffix\n const want = this.o.modeMap[mode];\n if (!want) return null;\n const hit = this.modeChoices().find(\n (m) => m.id === want || m.id.endsWith(`#${want}`) || m.name.toLowerCase() === want,\n );\n return hit?.id ?? want;\n }\n if (this.o.flavor === \"cursor\") {\n const map: Partial> = { default: \"agent\", plan: \"plan\", ask: \"ask\" };\n const want = map[mode] ?? \"agent\";\n const avail = this.active?.modes?.availableModes ?? [];\n const hit = avail.find((m) => m.id === want || m.name.toLowerCase() === want);\n return hit?.id ?? want;\n }\n return mode === \"plan\" ? \"plan\" : \"default\";\n }\n\n private async applySessionMode(mode: Mode): Promise {\n if (!this.ctx || !this.active) return;\n // Copilot \"Allow All\" (as Paseo does): use the agent mode, then turn the allow_all config on; turn it off for other modes\n const hasAllowAll = this.currentConfig().some((o) => o.id === \"allow_all\");\n if (this.o.modeMap?.[mode] === \"allow-all\") {\n const agentId = this.modeChoices().find((m) => m.id === \"agent\" || m.id.endsWith(\"#agent\"));\n if (agentId?.configId) await this.setOption(agentId.configId, agentId.id);\n if (hasAllowAll) await this.setOption(\"allow_all\", \"on\");\n return;\n }\n if (hasAllowAll && this.o.modeMap) await this.setOption(\"allow_all\", \"off\");\n const modeId = this.resolveAcpModeId(mode);\n if (!modeId) return; // This Agent has no such mode: don't send anything\n try {\n // Modes offered as a config option (Devin / OpenCode / Copilot) use set_config_option, otherwise set_mode\n const viaConfig = this.modeChoices().find((m) => m.id === modeId)?.configId;\n if (viaConfig) {\n await this.setOption(viaConfig, modeId);\n return;\n }\n await this.ctx.request(methods.agent.session.setMode, {\n sessionId: this.active.sessionId,\n modeId,\n });\n } catch (e) {\n log(\"info\", `ACP setMode(${mode}): ${String(e)}`);\n }\n }\n\n /**\n * Switch model → refresh level options from the new config options it returns → then apply the level (as Paseo does).\n * Cursor connects with parameterizedModelPicker: only the base model name is sent (grok-4.6); level / Fast are separate config options.\n */\n private async applySessionModel(model: string | null): Promise {\n if (!this.ctx || !this.active) return;\n const value = model && this.o.flavor === \"cursor\" ? model.replace(/\\[.*\\]$/u, \"\") : model;\n if (value) {\n const modelOption = this.currentConfig().find((o) => o.id === (this.modelConfigId ?? \"model\"));\n const values = (modelOption?.options ?? []).flatMap((x) => x.options ? x.options : [x])\n .map((x) => x.value).filter((x): x is string => typeof x === \"string\");\n // Old sessions may have saved another Agent's model ID. Skip invalid values and let ACP keep its current model.\n if (!values.length || values.includes(value)) {\n await this.setOption(this.modelConfigId ?? \"model\", value);\n } else {\n log(\"info\", `忽略 ${this.o.binName} 的无效模型:${value}`);\n }\n }\n if (this.o.flavor === \"cursor\" && this.cursorFast && this.currentConfig().some((o) => o.id === \"fast\")) {\n await this.setOption(\"fast\", \"true\");\n }\n await this.applySessionThought(this.o.effort);\n }\n\n private async applyCursorAutoReviewConfig(on: boolean): Promise {\n if (this.o.flavor !== \"cursor\" || !this.ctx || !this.active) return;\n for (const configId of [\"auto_review\", \"autoReview\", \"smart_auto\"]) {\n try {\n await this.ctx.request(methods.agent.session.setConfigOption, {\n sessionId: this.active.sessionId,\n configId,\n type: \"boolean\",\n value: on,\n });\n return;\n } catch {\n /* try the next configId */\n }\n }\n }\n\n /** Cursor only: Fast / Auto-review (can be changed while the session is open) */\n async updateCursorOpts(fast: boolean, autoReview: boolean): Promise {\n this.cursorFast = fast;\n void autoReview; // See the constructor: auto-approval is no longer supported\n await this.ready();\n await this.applySessionModel(this.o.model);\n await this.applyCursorAutoReviewConfig(false);\n }\n\n private waitPerm(requestId: string): Promise {\n return new Promise((resolve) => {\n this.permPending.set(requestId, resolve);\n });\n }\n\n respond(requestId: string, answer: RespondAnswer): void {\n const r = this.permPending.get(requestId);\n if (r) {\n this.permPending.delete(requestId);\n r(answer);\n this.q.push({\n type: \"permission_resolved\",\n requestId,\n optionId: answer.optionId,\n cancelled: answer.cancelled,\n });\n }\n }\n\n async send(input: SendInput): Promise {\n await this.ready();\n if (this.running) throw new Error(\"当前轮次尚未结束\");\n this.turnNum++;\n this.turnId = \"u_\" + Date.now().toString(36) + \"_\" + this.turnNum;\n this.mapCtx = newMapCtx();\n this.running = true;\n this.q.push({\n type: \"turn_started\",\n turnId: this.turnId,\n model: this.o.model,\n mode: this.o.mode,\n effort: this.o.effort,\n });\n const text = input.text.trim();\n if (!text) {\n this.running = false;\n this.q.push({ type: \"turn_ended\", turnId: this.turnId, status: \"completed\" });\n return;\n }\n void this.runTurn(text);\n }\n\n // Send this turn's prompt; reading updates is the job of the persistent pumpUpdates() (following Paseo: session/update is handled at session level)\n private async runTurn(text: string): Promise {\n this.activeTurnId = this.turnId;\n this.wakePump?.();\n this.wakePump = null;\n this.active!.prompt(text).catch(() => {\n /* failures surface through nextUpdate() and pumpUpdates ends the turn */\n });\n }\n\n private endTurn(ev: ProviderEvent): void {\n this.q.push(ev);\n this.activeTurnId = null;\n this.running = false;\n }\n\n /**\n * Read session/update for the whole lifetime of the session.\n * It used to read only during a turn: the available_commands_update an Agent pushes after session/new and before the first message went unread,\n * so the slash menu stayed empty. Now command updates are cached at any time; conversation content becomes timeline events only while a turn is in progress.\n */\n private async pumpUpdates(): Promise {\n const session = this.active;\n if (!session) return;\n while (!this.closed) {\n let msg: Awaited>;\n try {\n msg = await session.nextUpdate();\n } catch (e) {\n if (this.closed) return;\n const turnId = this.activeTurnId;\n if (turnId) {\n this.endTurn({ type: \"turn_ended\", turnId, status: \"failed\", error: String((e as Error)?.message ?? e) });\n }\n // After an error the queue keeps throwing the same error until the next prompt clears it: wait for the next turn before reading again\n await new Promise((r) => (this.wakePump = r));\n continue;\n }\n if (msg.kind === \"session_update\") {\n const u = msg.update;\n if (u.sessionUpdate === \"available_commands_update\") {\n this.applyAvailableCommands(u.availableCommands ?? []);\n continue;\n }\n if (!this.activeTurnId) continue; // Other updates outside a turn (e.g. mode change echoes) don't go to the timeline\n for (const ev of mapSessionUpdate(u, this.mapCtx)) this.q.push(ev);\n } else if (this.activeTurnId) {\n this.endTurn(stopToTurnEnd(msg.stopReason, this.activeTurnId));\n }\n }\n }\n\n async interrupt(): Promise {\n await this.ready();\n if (!this.ctx || !this.active) return;\n try {\n await this.ctx.request(methods.agent.session.cancel, { sessionId: this.active.sessionId });\n } catch (e) {\n log(\"info\", `ACP cancel: ${String(e)}`);\n }\n }\n\n async setModel(model: string | null): Promise {\n this.o.model = model;\n await this.ready();\n await this.applySessionModel(model);\n }\n\n async setMode(mode: Mode): Promise {\n this.o.mode = mode;\n await this.ready();\n await this.applySessionMode(mode);\n }\n\n async setEffort(effort: string | null): Promise {\n this.o.effort = effort;\n await this.ready();\n await this.applySessionThought(effort);\n }\n\n async listCommands(): Promise {\n try {\n await this.ready();\n } catch {\n return [];\n }\n // Commands are pushed asynchronously after session/new: wait at most 10 s for the first batch (Agents that report none return empty after the timeout)\n if (!this.slashCommands.length) {\n await Promise.race([this.commandsReady, new Promise((r) => setTimeout(r, 10000))]);\n }\n return this.slashCommands;\n }\n\n async close(): Promise {\n this.closed = true;\n this.wakePump?.();\n this.settleCommands();\n for (const r of this.permPending.values()) r({ cancelled: true });\n this.permPending.clear();\n try {\n this.active?.dispose();\n } catch {}\n try {\n this.conn?.close();\n } catch {}\n await killAcpChild(this.child);\n this.child = null;\n this.q.close();\n }\n}\n","import type { Provider, DetectResult, ModelInfo, Capabilities, Mode } from \"../types.js\";\nimport { killTree, spawnBin, type ResolveResult } from \"../../util/bin.js\";\nimport { resolveFirstBin } from \"../../util/bin.js\";\nimport { AcpSession } from \"./session.js\";\n\nexport type AcpProviderConfig = {\n id: string;\n name: string;\n binName: string;\n /** Takes precedence over binName: detect / listModels / session resolve these in order */\n binCandidates?: string[];\n acpArgs: string[];\n installHint: string;\n appVersion?: string;\n flavor?: \"cursor\" | \"generic\";\n capabilities?: Capabilities;\n fetchModels?: (r: ResolveResult & { ok: true }) => Promise;\n /** Our permission modes → this Agent's mode names over ACP; when given, only modes in the table are shown */\n modeMap?: Partial>;\n /** Mode names shown in the UI (the Agent's own naming) */\n modeLabels?: Partial>;\n};\n\nexport const acpCapabilities: Capabilities = {\n resume: true,\n interrupt: true,\n steer: false,\n images: false,\n plan: true,\n questions: false,\n cost: false,\n effort: [],\n modes: [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"],\n};\n\nexport const cursorAcpCapabilities: Capabilities = {\n ...acpCapabilities,\n questions: true,\n modes: [\"default\", \"plan\", \"ask\"],\n};\n\nfunction runVersion(r: ResolveResult & { ok: true }, args: string[], timeoutMs = 8000): Promise {\n return new Promise((resolvePromise, reject) => {\n let out = \"\";\n const p = spawnBin(r, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"], env: { ...process.env, NO_COLOR: \"1\" } });\n const t = setTimeout(() => {\n try {\n void killTree(p, { graceMs: 500 }); // On Windows a .cmd wrapper must be killed together with its child processes\n } catch {}\n reject(new Error(\"timeout\"));\n }, timeoutMs);\n p.stdout?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.stderr?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.on(\"error\", reject);\n p.on(\"close\", (code) => {\n clearTimeout(t);\n if (code === 0 || out.trim()) resolvePromise(out.trim());\n else reject(new Error(\"exit \" + code));\n });\n });\n}\n\nasync function resolveProviderBin(cfg: AcpProviderConfig) {\n const names = cfg.binCandidates?.length ? cfg.binCandidates : [cfg.binName];\n return resolveFirstBin(names);\n}\n\nexport function createAcpProvider(cfg: AcpProviderConfig): Provider {\n const base = cfg.capabilities ?? acpCapabilities;\n // With a mode table, expose only the modes the Agent really has (in our fixed order)\n const ORDER: Mode[] = [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"];\n const caps: Capabilities = cfg.modeMap\n ? { ...base, modes: ORDER.filter((m) => cfg.modeMap![m] !== undefined), modeLabels: cfg.modeLabels }\n : base;\n return {\n id: cfg.id,\n name: cfg.name,\n capabilities: caps,\n async detect(): Promise {\n const r = await resolveProviderBin(cfg);\n if (!r.ok) {\n return { available: false, reason: r.reason, installHint: cfg.installHint };\n }\n let version: string | undefined;\n try {\n if (cfg.id === \"cursor\") {\n version = (await runVersion(r, [\"--version\"])).split(/\\r?\\n/)[0]?.trim();\n } else if (cfg.id === \"devin\") {\n version = (await runVersion(r, [\"version\"])).split(/\\r?\\n/)[0]?.trim();\n } else if (cfg.id === \"opencode\") {\n version = (await runVersion(r, [\"--version\"])).split(/\\r?\\n/)[0]?.trim();\n }\n } catch {\n /* Having the binary is enough */\n }\n return { available: true, bin: r.display, version };\n },\n async listModels(): Promise {\n const r = await resolveProviderBin(cfg);\n if (!r.ok) return [{ id: \"default\", name: \"默认\", isDefault: true }];\n if (cfg.fetchModels) return cfg.fetchModels(r);\n return [{ id: \"default\", name: \"默认\", isDefault: true }];\n },\n async createSession(o) {\n return new AcpSession({\n threadId: o.threadId,\n cwd: o.cwd,\n model: o.model,\n mode: o.mode,\n effort: o.effort,\n resumeId: o.resumeId,\n binName: cfg.binName,\n binCandidates: cfg.binCandidates ?? [cfg.binName],\n acpArgs: cfg.acpArgs,\n appVersion: cfg.appVersion ?? \"0.0.1\",\n flavor: cfg.flavor ?? \"generic\",\n modeMap: cfg.modeMap,\n cursorFast: o.cursorFast,\n cursorAutoReview: o.cursorAutoReview,\n launch: o.launch,\n });\n },\n };\n}\n","// ACP probe: start the Agent's ACP process once → initialize → session/new, read the models and thinking levels it reports, then close it.\n// Sends no prompt and costs nothing. This is the Agent's own model list (as Paseo does), more reliable than parsing CLI text.\nimport { tmpdir } from \"node:os\";\nimport type { ModelInfo } from \"../types.js\";\nimport type { ResolveResult } from \"../../util/bin.js\";\nimport { spawnResolved, killAcpChild } from \"./spawn.js\";\nimport { log } from \"../../log.js\";\n\ntype SelectOption = { value?: unknown; name?: unknown; options?: SelectOption[] };\ntype ConfigOption = { id?: string; category?: string; type?: string; currentValue?: unknown; options?: SelectOption[] };\n\nexport type AcpProbeResult = {\n models: { id: string; name: string }[];\n currentModel: string | null;\n thinking: { id: string; label: string }[];\n currentThinking: string | null;\n};\n\nfunction flatten(opts: SelectOption[] | undefined): { id: string; name: string }[] {\n const out: { id: string; name: string }[] = [];\n for (const o of opts ?? []) {\n if (o.options) out.push(...flatten(o.options));\n else if (typeof o.value === \"string\" && o.value) {\n out.push({ id: o.value, name: typeof o.name === \"string\" && o.name ? o.name : o.value });\n }\n }\n // Dedupe (Copilot reports auto twice)\n return out.filter((m, i) => out.findIndex((x) => x.id === m.id) === i);\n}\n\n/** Extract models and thinking levels from a session/new response (pure function, easy to test) */\nexport function parseAcpSession(res: { configOptions?: ConfigOption[]; models?: { currentModelId?: string; availableModels?: { modelId?: string; name?: string }[] } }): AcpProbeResult {\n const opts = res.configOptions ?? [];\n const modelOpt = opts.find((o) => o.type === \"select\" && (o.category === \"model\" || o.id === \"model\"));\n const thoughtOpt = opts.find((o) => o.type === \"select\" && (o.category === \"thought_level\" || o.id === \"thought_level\"));\n let models = flatten(modelOpt?.options);\n let currentModel = typeof modelOpt?.currentValue === \"string\" ? modelOpt.currentValue : null;\n if (!models.length && res.models?.availableModels?.length) {\n models = res.models.availableModels\n .filter((m) => typeof m.modelId === \"string\" && m.modelId)\n .map((m) => ({ id: m.modelId!, name: m.name || m.modelId! }));\n models = models.filter((m, i) => models.findIndex((x) => x.id === m.id) === i);\n currentModel = res.models.currentModelId ?? currentModel;\n }\n const thinking = flatten(thoughtOpt?.options).map((t) => ({ id: t.id, label: t.name }));\n const currentThinking = typeof thoughtOpt?.currentValue === \"string\" ? thoughtOpt.currentValue : null;\n return { models, currentModel, thinking, currentThinking };\n}\n\nexport function probeAcp(r: ResolveResult & { ok: true }, acpArgs: string[], timeoutMs = 30000): Promise {\n return probeAcpRaw(r, acpArgs, timeoutMs).then((x) => x.parsed);\n}\n\n/** Like probeAcp, but also calls an Agent-specific method after session/new (e.g. Cursor's cursor/list_available_models) */\nexport function probeAcpRaw(\n r: ResolveResult & { ok: true },\n acpArgs: string[],\n timeoutMs = 30000,\n extMethod?: string,\n): Promise<{ parsed: AcpProbeResult; ext: unknown }> {\n return new Promise((resolvePromise, reject) => {\n const child = spawnResolved(r, acpArgs, { cwd: tmpdir() });\n let buf = \"\";\n let nextId = 0;\n const pending = new Map void; rej: (e: Error) => void }>();\n let done = false;\n const finish = (err: Error | null, v?: { parsed: AcpProbeResult; ext: unknown }) => {\n if (done) return;\n done = true;\n clearTimeout(timer);\n void killAcpChild(child).catch(() => {});\n if (err) reject(err);\n else resolvePromise(v!);\n };\n const timer = setTimeout(() => finish(new Error(\"ACP 探测超时\")), timeoutMs);\n const send = (method: string, params: unknown) =>\n new Promise((res, rej) => {\n const id = ++nextId;\n pending.set(id, { res, rej });\n child.stdin?.write(JSON.stringify({ jsonrpc: \"2.0\", id, method, params }) + \"\\n\");\n });\n child.on(\"error\", (e) => finish(e));\n child.on(\"close\", () => finish(new Error(\"ACP 进程提前退出\")));\n child.stderr?.on(\"data\", () => {});\n child.stdout?.on(\"data\", (d: Buffer) => {\n buf += d.toString();\n let n: number;\n while ((n = buf.indexOf(\"\\n\")) >= 0) {\n const line = buf.slice(0, n).trim();\n buf = buf.slice(n + 1);\n if (!line) continue;\n let m: { id?: number; method?: string; result?: unknown; error?: unknown };\n try {\n m = JSON.parse(line);\n } catch {\n continue;\n }\n if (m.id !== undefined && !m.method && pending.has(m.id)) {\n const p = pending.get(m.id)!;\n pending.delete(m.id);\n if (m.error) p.rej(new Error(JSON.stringify(m.error)));\n else p.res(m.result);\n } else if (m.id !== undefined && m.method) {\n // Requests from the Agent (shouldn't happen during a probe): always answer empty so it doesn't hang\n child.stdin?.write(JSON.stringify({ jsonrpc: \"2.0\", id: m.id, result: null }) + \"\\n\");\n }\n }\n });\n void (async () => {\n try {\n await send(\"initialize\", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: \"Agent Console\", version: \"probe\" } });\n const res = (await send(\"session/new\", { cwd: tmpdir(), mcpServers: [] })) as Parameters[0];\n let ext: unknown = null;\n if (extMethod) ext = await send(extMethod, {}).catch(() => null);\n finish(null, { parsed: parseAcpSession(res ?? {}), ext });\n } catch (e) {\n finish(e instanceof Error ? e : new Error(String(e)));\n }\n })();\n });\n}\n\n/** Probe result → model list: the current model is the default; thinking levels hang on the current model (other models' levels are only known after switching, so reuse the same set for now) */\nexport function probeToModels(p: AcpProbeResult): ModelInfo[] {\n return p.models.map((m) => ({\n id: m.id,\n name: m.name,\n isDefault: m.id === p.currentModel,\n ...(p.thinking.length\n ? { thinkingOptions: p.thinking, defaultThinking: p.currentThinking ?? undefined }\n : {}),\n }));\n}\n\nconst EFFORT_LABEL: Record = {\n none: \"None\", minimal: \"Minimal\", low: \"Low\", medium: \"Medium\", high: \"High\", xhigh: \"Extra High\", max: \"Max\",\n};\n\ntype CursorCatalog = {\n models?: { value?: string; name?: string; configOptions?: ConfigOption[] }[];\n};\n\n/**\n * Cursor: a model's thinking levels are not in the session-level thought_level but in each model's own parameter (effort / reasoning_effort / reasoning).\n * Following Paseo's resolveCursorCatalogModels: call Cursor's extension method cursor/list_available_models to get each model's parameter definitions.\n * The model id is the base name without parameters (grok-4.6); thinkingParam records the level parameter name, sent as grok-4.6[effort=high].\n */\nexport function cursorCatalogToModels(catalog: CursorCatalog, currentModel: string | null): ModelInfo[] {\n const currentBase = currentModel?.replace(/\\[.*\\]$/u, \"\") ?? null;\n const out: ModelInfo[] = [];\n for (const m of catalog.models ?? []) {\n if (typeof m.value !== \"string\" || !m.value) continue;\n const id = m.value.replace(/\\[.*\\]$/u, \"\");\n const thought = (m.configOptions ?? []).find((o) => o.category === \"thought_level\");\n const levels = flatten(thought?.options);\n out.push({\n id,\n name: typeof m.name === \"string\" && m.name ? m.name : id,\n isDefault: id === currentBase,\n ...(thought && levels.length\n ? {\n thinkingOptions: levels.map((l) => ({ id: l.id, label: EFFORT_LABEL[l.id] ?? l.name })),\n defaultThinking: typeof thought.currentValue === \"string\" ? thought.currentValue : undefined,\n thinkingParam: thought.id,\n }\n : {}),\n });\n }\n return out;\n}\n\nexport function cursorProbeModels(acpArgs: string[]) {\n return async (r: ResolveResult & { ok: true }): Promise => {\n try {\n const { parsed, ext } = await probeAcpRaw(r, acpArgs, 40000, \"cursor/list_available_models\");\n const fromCatalog = ext ? cursorCatalogToModels(ext as CursorCatalog, parsed.currentModel) : [];\n // If the extension method is unavailable (older Cursor CLI), fall back to the model list reported by the session (no levels)\n return fromCatalog.length ? fromCatalog : probeToModels(parsed);\n } catch (e) {\n log(\"warn\", \"Cursor 模型探测失败\", { err: String(e) });\n return [];\n }\n };\n}\n\nexport function acpProbeModels(acpArgs: string[]) {\n return async (r: ResolveResult & { ok: true }): Promise => {\n try {\n return probeToModels(await probeAcp(r, acpArgs));\n } catch (e) {\n log(\"warn\", \"ACP 探测模型失败\", { err: String(e) });\n return [];\n }\n };\n}\n","import type { ProviderCatalogEntry } from \"../catalog.js\";\nimport type { AcpProviderConfig } from \"./provider.js\";\nimport { cursorAcpCapabilities } from \"./provider.js\";\nimport { acpProbeModels, cursorProbeModels } from \"./probe.js\";\n\nconst ACP_IDS = new Set([\"cursor\", \"devin\", \"opencode\", \"dsh\", \"pi\", \"copilot\"]);\n\nexport function isAcpCatalogId(id: string): boolean {\n return ACP_IDS.has(id);\n}\n\n// Model lists and thinking levels always come from what the Agent reports over ACP (acpProbeModels), no longer from parsing each CLI's text output.\n// modeMap: our permission modes → the Agent's real mode names (from what session/new actually reports); \"\" means keep the entry but send nothing.\nexport function acpConfigForEntry(entry: ProviderCatalogEntry, appVersion: string): AcpProviderConfig | null {\n if (!ACP_IDS.has(entry.id)) return null;\n const base: Omit = {\n id: entry.id,\n name: entry.name,\n binName: entry.bins[0] ?? entry.id,\n binCandidates: entry.bins,\n installHint: entry.installHint,\n appVersion,\n };\n\n switch (entry.id) {\n case \"cursor\":\n // Modes: agent / plan / ask (see cursorAcpCapabilities)\n return {\n ...base,\n acpArgs: [\"acp\"],\n flavor: \"cursor\",\n capabilities: cursorAcpCapabilities,\n // Each model's own thinking levels come from cursor/list_available_models (as Paseo does)\n fetchModels: cursorProbeModels([\"acp\"]),\n };\n case \"devin\":\n // Modes: Code(accept-edits) / Smart / Ask / Plan / Bypass Permissions\n return {\n ...base,\n acpArgs: [\"acp\"],\n fetchModels: acpProbeModels([\"acp\"]),\n modeMap: { plan: \"plan\", default: \"ask\", acceptEdits: \"accept-edits\", bypassPermissions: \"bypass\" },\n modeLabels: { plan: \"Plan\", default: \"Ask\", acceptEdits: \"Code\", bypassPermissions: \"Bypass Permissions\" },\n };\n case \"opencode\":\n // Modes: build / plan; thinking level effort (low…ultra, including fast variants)\n return {\n ...base,\n acpArgs: [\"acp\"],\n fetchModels: acpProbeModels([\"acp\"]),\n modeMap: { plan: \"plan\", default: \"build\" },\n modeLabels: { plan: \"Plan\", default: \"Build\" },\n };\n case \"copilot\":\n // As Paseo: Agent / Plan / Allow All. Allow All = agent mode + allow_all config on (autopilot is not used)\n return {\n ...base,\n acpArgs: [\"--acp\"],\n fetchModels: acpProbeModels([\"--acp\"]),\n modeMap: { plan: \"plan\", default: \"agent\", bypassPermissions: \"allow-all\" },\n modeLabels: { plan: \"Plan\", default: \"Agent\", bypassPermissions: \"Allow All\" },\n };\n case \"pi\":\n // pi-acp has no mode options; thinking level thought_level (low…max)\n return { ...base, acpArgs: [], fetchModels: acpProbeModels([]), modeMap: { default: \"\" } };\n case \"dsh\":\n return { ...base, acpArgs: [\"--profile\", \"acp\"], fetchModels: acpProbeModels([\"--profile\", \"acp\"]) };\n default:\n return null;\n }\n}\n","// Provider registry: 8 in the catalog; claude/codex native, the rest over ACP (including copilot --acp).\nimport type { Provider } from \"./types.js\";\nimport { createMockProvider } from \"./mock.js\";\nimport { createClaudeProvider } from \"./claude/provider.js\";\nimport { createCodexProvider } from \"./codex/provider.js\";\nimport { PROVIDER_CATALOG } from \"./catalog.js\";\nimport { createAcpProvider } from \"./acp/provider.js\";\nimport { acpConfigForEntry } from \"./acp/specs.js\";\n\nconst providers = new Map();\n\nexport function registerProvider(p: Provider): void {\n providers.set(p.id, p);\n}\n\nexport function getProvider(id: string): Provider | undefined {\n return providers.get(id);\n}\n\nexport function listProviders(): Provider[] {\n return [...providers.values()];\n}\n\nexport function initProviders(version: string = \"\"): void {\n providers.clear();\n for (const entry of PROVIDER_CATALOG) {\n if (entry.id === \"claude\") registerProvider(createClaudeProvider());\n else if (entry.id === \"codex\") registerProvider(createCodexProvider(version));\n else {\n const acp = acpConfigForEntry(entry, version);\n if (acp) registerProvider(createAcpProvider(acp));\n }\n }\n if (process.env.AGENT_CONSOLE_MOCK === \"1\") {\n registerProvider(createMockProvider());\n }\n}\n","// Whether the user enabled this Provider in settings.\n// Never toggled by hand: catalog status=verified is on by default, others off; entries outside the catalog (mock etc.) are on.\nimport type { Settings } from \"../store/settings.js\";\nimport { catalogById } from \"./catalog.js\";\n\nexport function isProviderEnabled(settings: Settings, id: string): boolean {\n const m = settings.providerEnabled;\n if (m && typeof m === \"object\" && typeof m[id] === \"boolean\") return m[id];\n const entry = catalogById(id);\n if (!entry) return true;\n return entry.status === \"verified\";\n}\n","import { execFile } from \"node:child_process\";\n\n/** Parse PowerShell stdout into a folder path (handles Base64 and CP936 consoles) */\nexport function decodePickFolderStdout(stdout: string | Buffer): string | null {\n const buf = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout, \"utf8\");\n const line = buf.toString(\"utf8\").trim().split(/\\r?\\n/).pop()?.trim() ?? \"\";\n if (!line) return null;\n\n // New format: Base64 of the UTF-8 path, so a GBK-encoded Chinese Windows console isn't garbled when Node decodes it as UTF-8\n if (/^[A-Za-z0-9+/]+=*$/.test(line)) {\n const decoded = Buffer.from(line, \"base64\").toString(\"utf8\");\n if (decoded && !decoded.includes(\"\\uFFFD\")) return decoded;\n }\n\n if (!line.includes(\"\\uFFFD\")) return line;\n\n const gbkLine = new TextDecoder(\"gbk\").decode(buf).trim().split(/\\r?\\n/).pop()?.trim() ?? \"\";\n return gbkLine || null;\n}\n\nexport function pickFolderWindows(initial?: string): Promise<{ available: boolean; path: string | null }> {\n return new Promise((resolvePromise) => {\n if (process.platform !== \"win32\") return resolvePromise({ available: false, path: null });\n const init = (initial ?? \"\").replace(/'/g, \"''\");\n const script =\n \"Add-Type -AssemblyName System.Windows.Forms;\" +\n \"$d = New-Object System.Windows.Forms.FolderBrowserDialog;\" +\n \"$d.Description = '选择项目文件夹';\" +\n \"$d.ShowNewFolderButton = $true;\" +\n (init ? `$d.SelectedPath = '${init}';` : \"\") +\n \"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { \" +\n \"[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($d.SelectedPath)) \" +\n \"}\";\n execFile(\n \"powershell.exe\",\n [\"-NoProfile\", \"-STA\", \"-NonInteractive\", \"-Command\", script],\n { timeout: 120000, windowsHide: false, encoding: \"buffer\" },\n (e, stdout) => {\n if (e) return resolvePromise({ available: true, path: null });\n resolvePromise({ available: true, path: decodePickFolderStdout(stdout ?? Buffer.alloc(0)) });\n },\n );\n });\n}\n","// Native folder picker: Windows uses PowerShell FolderBrowserDialog (pickFolderWin.ts),\n// macOS osascript's choose folder, Linux zenity / kdialog when present; with none, report unavailable and the frontend falls back to typing.\n// Everything starts processes directly via execFile, no shell; the initial directory is passed only as an argument or an escaped AppleScript string.\nimport { execFile } from \"node:child_process\";\nimport { existsSync, statSync } from \"node:fs\";\nimport { findOnPath } from \"../../../lib/pathscan.mjs\";\nimport { pickFolderWindows } from \"./pickFolderWin.js\";\n\nexport type PickResult = { available: boolean; path: string | null };\nconst TIMEOUT = 10 * 60 * 1000; // The user may keep the dialog open for a while\n\n/** AppleScript string literal: only backslashes and double quotes need escaping. */\nexport function appleScriptString(s: string): string {\n return '\"' + s.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"') + '\"';\n}\n\nfunction isDir(p: string | undefined): p is string {\n try {\n return !!p && existsSync(p) && statSync(p).isDirectory();\n } catch {\n return false;\n }\n}\n\n/** Command-line arguments (for testability). Returns null when the platform has no picker. */\nexport function pickFolderCommand(\n platform: NodeJS.Platform,\n initial: string | undefined,\n find: (name: string) => string | null = (n) => findOnPath(n)[0] ?? null,\n env: NodeJS.ProcessEnv = process.env,\n): { cmd: string; args: string[] } | null {\n const init = isDir(initial) ? initial : undefined;\n if (platform === \"darwin\") {\n const prompt = appleScriptString(\"选择项目文件夹 / Choose a project folder\");\n const loc = init ? ` default location (POSIX file ${appleScriptString(init)})` : \"\";\n return { cmd: \"/usr/bin/osascript\", args: [\"-e\", `POSIX path of (choose folder with prompt ${prompt}${loc})`] };\n }\n if (platform === \"linux\" || platform === \"freebsd\" || platform === \"openbsd\") {\n // No graphical session (magpie web on a server): no window can open, so the frontend asks for a typed path\n if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return null;\n const zenity = find(\"zenity\");\n if (zenity) {\n const args = [\"--file-selection\", \"--directory\", \"--title=选择项目文件夹 / Choose a project folder\"];\n if (init) args.push(`--filename=${init.endsWith(\"/\") ? init : init + \"/\"}`);\n return { cmd: zenity, args };\n }\n const kdialog = find(\"kdialog\");\n if (kdialog) return { cmd: kdialog, args: [\"--getexistingdirectory\", init ?? env.HOME ?? \"/\"] };\n }\n return null;\n}\n\nexport function pickFolder(initial?: string): Promise {\n if (process.platform === \"win32\") return pickFolderWindows(initial);\n const c = pickFolderCommand(process.platform, initial);\n if (!c) return Promise.resolve({ available: false, path: null });\n return new Promise((resolvePromise) => {\n execFile(c.cmd, c.args, { timeout: TIMEOUT, encoding: \"utf8\" }, (e, stdout) => {\n // Cancel: osascript / zenity / kdialog all exit non-zero\n if (e) return resolvePromise({ available: true, path: null });\n let p = String(stdout ?? \"\").replace(/\\r?\\n$/, \"\").trim();\n // osascript's POSIX path ends with / (except for the root)\n if (p.length > 1) p = p.replace(/\\/+$/, \"\");\n resolvePromise({ available: true, path: p || null });\n }).on(\"error\", () => resolvePromise({ available: false, path: null }));\n });\n}\n","// magpie gateway: magpie points each Agent's models at the local gateway (applied.json records the model currently applied to each).\n// Here we fill in, for those Agents, the model list, the default model and each model's thinking levels. Read-only; no configuration is changed.\nimport { readFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { ModelInfo } from \"./types.js\";\nimport { log } from \"../log.js\";\n\n/** Agent Console provider id → the key in magpie's applied.json + the model id prefix used inside that Agent */\nconst MAGPIE_TOOLS: Record = {\n claude: { key: \"claude\", prefix: \"\" },\n codex: { key: \"codex\", prefix: \"\" },\n opencode: { key: \"opencode\", prefix: \"magpie/\" },\n pi: { key: \"pi\", prefix: \"magpie/\" },\n dsh: { key: \"dsh\", prefix: \"magpie/\" },\n};\n\nconst EFFORT_LABEL: Record = {\n low: \"Low\", medium: \"Medium\", high: \"High\", xhigh: \"Extra High\", max: \"Max\",\n};\n\ntype Applied = Record;\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readApplied(): Promise {\n return (await readJson(join(homedir(), \".config\", \"magpie\", \"applied.json\"))) ?? {};\n}\n\nasync function readClaudeEnv(): Promise> {\n const s = await readJson<{ env?: Record }>(join(homedir(), \".claude\", \"settings.json\"));\n return s?.env ?? {};\n}\n\n/** Gateway address: ANTHROPIC_BASE_URL from the Claude config (written by magpie), otherwise the default 3425 */\nasync function gatewayBase(): Promise {\n const env = await readClaudeEnv();\n const raw = env.ANTHROPIC_BASE_URL?.trim();\n return (raw && /^https?:\\/\\/(127\\.0\\.0\\.1|localhost)(:\\d+)?/i.test(raw) ? raw : \"http://127.0.0.1:3425\").replace(/\\/+$/, \"\");\n}\n\nasync function fetchGatewayModelIds(): Promise {\n const base = await gatewayBase();\n try {\n const res = await fetch(`${base}/v1/models`, { signal: AbortSignal.timeout(5000) });\n if (!res.ok) return [];\n const j = (await res.json()) as { data?: { id?: unknown }[] };\n return (j.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === \"string\" && !!id);\n } catch (e) {\n log(\"warn\", \"读取 magpie 网关模型列表失败\", { err: String(e) });\n return [];\n }\n}\n\n/** CLAUDE_CODE_MODEL_CAPABILITIES: `id=effort,xhigh_effort,max_effort;id2=effort` → each model's levels */\nexport function parseCapabilities(raw: string | undefined): Map {\n const out = new Map();\n for (const part of (raw ?? \"\").split(\";\")) {\n const i = part.indexOf(\"=\");\n if (i <= 0) continue;\n const flags = new Set(part.slice(i + 1).split(\",\").map((s) => s.trim()));\n if (!flags.has(\"effort\")) continue;\n const levels = [\"low\", \"medium\", \"high\"];\n if (flags.has(\"xhigh_effort\")) levels.push(\"xhigh\");\n if (flags.has(\"max_effort\")) levels.push(\"max\");\n out.set(part.slice(0, i).trim(), levels);\n }\n return out;\n}\n\nfunction isPlaceholder(m: ModelInfo): boolean {\n return !m.id || /^(default|auto)$/i.test(m.id);\n}\n\n/**\n * Fill in model info for Agents managed by magpie; if magpie doesn't manage this Agent (not in applied.json) return it unchanged.\n * - The Agent's own list knows magpie models (e.g. OpenCode, Codex): keep its list with magpie's first (only what it knows, so picks never fail)\n * - The Agent can't report models (Claude, Pi, dsh): use the gateway's /v1/models with the prefix that Agent uses\n * - Default model = the model magpie currently applies to it; thinking levels from magpie's capability table\n */\nexport async function withMagpie(providerId: string, own: ModelInfo[]): Promise {\n const tool = MAGPIE_TOOLS[providerId];\n if (!tool) return own;\n const applied = (await readApplied())[tool.key]?.fields;\n if (!applied?.model) return own;\n\n const caps = parseCapabilities((await readClaudeEnv()).CLAUDE_CODE_MODEL_CAPABILITIES);\n const real = own.filter((m) => !isPlaceholder(m));\n\n // Over ACP, DSH returns its own real model IDs (JSON-array strings).\n // We must not push an old / another Agent's model ID from applied.json into DSH,\n // otherwise DSH treats it as a routed model and sends it to the wrong provider.\n if (providerId === \"dsh\") {\n const appliedId = applied.model.replace(/^magpie\\//, \"\");\n const selected = real.find((m) =>\n m.id === applied.model ||\n m.id === appliedId ||\n (appliedId.includes(\"/\") && m.id.includes(appliedId)),\n );\n const fallback = selected ?? real.find((m) => m.isDefault) ?? real[0];\n return real.map((m) => ({ ...m, isDefault: m.id === fallback?.id }));\n }\n\n const knowsMagpie = tool.prefix ? real.some((m) => m.id.startsWith(tool.prefix)) : real.length > 0;\n\n let list: ModelInfo[];\n if (knowsMagpie) {\n const mine = real.filter((m) => !tool.prefix || m.id.startsWith(tool.prefix));\n const others = tool.prefix ? real.filter((m) => !m.id.startsWith(tool.prefix)) : [];\n list = [...mine, ...others];\n } else {\n list = (await fetchGatewayModelIds()).map((gid) => ({ id: tool.prefix + gid, name: gid }));\n }\n\n // Default model = the one magpie currently applies; if it's not in the list, add it at the top\n const defId = applied.model;\n if (!list.some((m) => m.id === defId)) list.unshift({ id: defId, name: defId.slice(tool.prefix.length) || defId });\n list = list.map((m) => ({ ...m, isDefault: m.id === defId }));\n list.sort((a, b) => Number(!!b.isDefault) - Number(!!a.isDefault));\n\n // Thinking levels: use the Agent's own if it reports them, otherwise magpie's capability table\n return list.map((m) => {\n const name = tool.prefix && m.name.startsWith(tool.prefix) ? m.name.slice(tool.prefix.length) : m.name;\n if (m.thinkingOptions?.length && providerId !== \"claude\") return { ...m, name };\n const levels = caps.get(m.id.slice(tool.prefix.length) || m.id);\n if (!levels) return { ...m, name, thinkingOptions: providerId === \"claude\" ? [] : m.thinkingOptions };\n const def = applied.effort && levels.includes(applied.effort) ? applied.effort : levels.includes(\"high\") ? \"high\" : levels[0];\n return {\n ...m,\n name,\n thinkingOptions: levels.map((id) => ({ id, label: EFFORT_LABEL[id] ?? id })),\n defaultThinking: m.isDefault ? def : m.defaultThinking ?? def,\n };\n });\n}\n","// In-memory cache of Provider detection results and model lists.\n// detect / listModels often have to start a CLI (seconds); without a cache every page open and model pick would wait.\nimport type { Provider, DetectResult, ModelInfo } from \"./types.js\";\nimport { withMagpie } from \"./magpieGateway.js\";\n\nconst DETECT_MS = 60 * 1000;\nconst MODELS_MS = 10 * 60 * 1000;\nconst STEP_TIMEOUT_MS = 15000;\n// Fetching models may start an ACP session (Cursor launches slowly through PowerShell), so it gets a longer timeout\nconst MODELS_TIMEOUT_MS = 35000;\n\ntype Entry = { at: number; value: T } | undefined;\nconst detects = new Map>();\nconst models = new Map>();\n// Concurrent requests for the same Provider share one in-flight detection\nconst inflightDetect = new Map>();\nconst inflightModels = new Map>();\n\nfunction withTimeout(p: Promise, ms: number, fb: T): Promise {\n return Promise.race([p, new Promise((r) => setTimeout(() => r(fb), ms))]);\n}\n\nfunction fresh(e: Entry, ttl: number): e is { at: number; value: T } {\n return !!e && Date.now() - e.at < ttl;\n}\n\nexport async function cachedDetect(p: Provider): Promise {\n const hit = detects.get(p.id);\n if (fresh(hit, DETECT_MS)) return hit.value;\n let job = inflightDetect.get(p.id);\n if (!job) {\n job = withTimeout(\n p.detect().catch((e): DetectResult => ({ available: false, reason: String((e as Error)?.message ?? e) })),\n STEP_TIMEOUT_MS,\n { available: false, reason: \"检测超时\" },\n ).then((v) => {\n // Don't cache a timeout; try again next time\n if (v.reason !== \"检测超时\") detects.set(p.id, { at: Date.now(), value: v });\n return v;\n }).finally(() => inflightDetect.delete(p.id));\n inflightDetect.set(p.id, job);\n }\n return job;\n}\n\nexport async function cachedModels(p: Provider): Promise {\n const hit = models.get(p.id);\n if (fresh(hit, MODELS_MS)) return hit.value;\n let job = inflightModels.get(p.id);\n if (!job) {\n job = withTimeout(p.listModels().catch(() => []), MODELS_TIMEOUT_MS, null)\n .then(async (v) => {\n // Timeout: don't cache, fall back to the old value for now\n if (v === null) return models.get(p.id)?.value ?? [];\n // Agents behind the magpie gateway: fill in gateway models, the default model and thinking levels\n const list = await withMagpie(p.id, dropPlaceholder(v)).catch(() => dropPlaceholder(v));\n models.set(p.id, { at: Date.now(), value: list });\n return list;\n })\n .finally(() => inflightModels.delete(p.id));\n inflightModels.set(p.id, job);\n }\n return job;\n}\n\n/** A list holding only our own \"Default\" placeholder (id=default, name \"默认\") counts as having no real models.\n * An auto / Auto the Agent itself reports (e.g. Copilot) is a real option and is kept. */\nexport function dropPlaceholder(list: ModelInfo[]): ModelInfo[] {\n if (list.length === 1 && list[0].id === \"default\" && list[0].name === \"默认\") return [];\n return list;\n}\n\n/** Return the cached value immediately if there is one (may be stale), else undefined */\nexport function peekModels(id: string): ModelInfo[] | undefined {\n return models.get(id)?.value;\n}\n\nexport function clearProviderCache(id?: string): void {\n if (id) {\n detects.delete(id);\n models.delete(id);\n } else {\n detects.clear();\n models.clear();\n }\n}\n","// Method registry: zod-validated params → call the service → return. P0-5 skeleton; filled in from P1 on.\nimport { z } from \"zod\";\nimport type { Gateway, ConnCtx } from \"./gateway.js\";\nimport type { Config } from \"../config.js\";\nimport type { SettingsStore } from \"../store/settings.js\";\nimport type { ProjectsStore } from \"../store/projects.js\";\nimport type { ThreadsStore } from \"../store/threads.js\";\nimport { listProviders, getProvider } from \"../providers/registry.js\";\nimport { PROVIDER_CATALOG, catalogById, providerCreatable } from \"../providers/catalog.js\";\nimport { isProviderEnabled } from \"../providers/enabled.js\";\nimport type { SlashCommand } from \"../providers/types.js\";\nimport { GitService } from \"../git/service.js\";\nimport { FileService } from \"../files/service.js\";\nimport type { TerminalService } from \"../terminal/service.js\";\nimport { existsSync, statSync } from \"node:fs\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, win32 as win32Path } from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { pickFolder } from \"../util/pickFolder.js\";\nimport { clearBinCache } from \"../util/bin.js\";\nimport { cachedDetect, cachedModels, clearProviderCache } from \"../providers/infoCache.js\";\n\nconst threadModeSchema = z.enum([\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\", \"ask\"]);\n\nexport type Services = {\n cfg: Config;\n settings: SettingsStore;\n gateway: Gateway;\n projects: ProjectsStore;\n threads: ThreadsStore;\n git: GitService;\n files: FileService;\n terminals: TerminalService;\n // AgentManager provides runtime info once wired in at P1-4; may be empty (no session started)\n runtime?: (threadId: string) => { status: string; queue: unknown[]; pending: unknown[] } | null;\n manager?: {\n send(threadId: string, input: { text: string; attachments?: { path: string; mime: string; name?: string }[] }, behavior?: \"queue\" | \"steer\"): Promise;\n interrupt(threadId: string): Promise;\n respond(threadId: string, requestId: string, answer: { optionId?: string; answers?: Record; cancelled?: boolean }): Promise;\n setModel(threadId: string, model: string | null): Promise;\n setMode(threadId: string, mode: \"plan\" | \"default\" | \"acceptEdits\" | \"bypassPermissions\" | \"ask\"): Promise;\n setEffort(threadId: string, effort: string | null): Promise;\n setCursorOpts(threadId: string, patch: { cursorFast?: boolean; cursorAutoReview?: boolean }): Promise;\n setProvider(threadId: string, providerId: string): Promise;\n close(threadId: string): Promise;\n deleteThread(threadId: string): Promise;\n listCommands(threadId: string, opts?: { load?: boolean }): Promise;\n };\n};\n\nexport class ApiError extends Error {\n code: string;\n constructor(code: string, message: string) {\n super(message);\n this.code = code;\n }\n}\n\nfunction wrap(schema: T, fn: (p: z.infer, ctx: ConnCtx) => unknown) {\n return (params: unknown, ctx: ConnCtx) => {\n const r = schema.safeParse(params ?? {});\n if (!r.success) {\n throw new ApiError(\"bad_params\", \"参数不对:\" + r.error.issues.map((i) => i.message).join(\";\"));\n }\n return fn(r.data, ctx);\n };\n}\n\nexport function registerMethods(gw: Gateway, svc: Services): void {\n const zEmpty = z.object({}).passthrough().optional();\n\n gw.method(\"app.info\", wrap(zEmpty, () => ({\n version: svc.cfg.version,\n dataDir: svc.cfg.dataDir,\n pid: process.pid,\n port: svc.cfg.port,\n })));\n\n gw.method(\"app.shutdown\", wrap(zEmpty, () => {\n setTimeout(() => process.exit(0), 100);\n return { ok: true };\n }));\n\n gw.method(\"settings.get\", wrap(zEmpty, () => svc.settings.get()));\n\n gw.method(\"settings.set\", wrap(z.object({ patch: z.record(z.string(), z.unknown()) }), (p) =>\n svc.settings.set(p.patch)\n ));\n\n gw.method(\"sub.add\", wrap(\n z.object({ ch: z.string().min(1).max(120), afterSeq: z.number().int().nonnegative().optional() }),\n async (p, ctx) => {\n svc.gateway.subscribe(ctx.conn, p.ch);\n // The thread: channel replays persisted events after afterSeq (late subscribers / reconnects lose nothing)\n const m = /^thread:(.+)$/.exec(p.ch);\n if (m && p.afterSeq != null) {\n const events = await svc.threads.readEvents(m[1], p.afterSeq, 5000).catch(() => []);\n for (const ev of events) ctx.conn.send({ t: \"evt\", ch: p.ch, d: { type: \"event\", event: ev } });\n }\n return { ok: true };\n }\n ));\n\n gw.method(\"sub.remove\", wrap(z.object({ ch: z.string().min(1).max(120) }), (p, ctx) => {\n svc.gateway.unsubscribe(ctx.conn, p.ch);\n return { ok: true };\n }));\n\n // ---- providers.list (§6): detection + model list + capabilities ----\n // detect/listModels in parallel; 8s total per provider (listModels may start a temporary process).\n gw.method(\"providers.list\", wrap(\n z.object({ refresh: z.boolean().optional() }).passthrough().optional(),\n async (p) => {\n if (p?.refresh) {\n clearBinCache();\n clearProviderCache();\n }\n const settings = await svc.settings.get();\n const rows = await Promise.all(\n listProviders().map(async (p) => {\n const entry = catalogById(p.id);\n const enabled = isProviderEnabled(settings, p.id);\n // Disabled / not-integrated ones are neither detected nor asked for models (saves seconds of CLI startup)\n const skip = !enabled || entry?.status === \"planned\";\n const det: import(\"../providers/types.js\").DetectResult = skip\n ? { available: false, reason: !enabled ? \"已在设置中禁用\" : \"尚未接入\" }\n : await cachedDetect(p);\n const models = !skip && det.available ? await cachedModels(p) : [];\n return {\n id: p.id,\n name: p.name,\n available: det.available,\n enabled,\n status: entry?.status ?? \"verified\",\n ready: entry?.status === \"verified\",\n bin: det.bin,\n version: det.version,\n reason: !enabled ? \"已在设置中禁用\" : det.reason,\n installHint: det.installHint,\n capabilities: p.capabilities,\n models,\n };\n }),\n );\n const byId = new Map(rows.map((r) => [r.id, r]));\n const ordered = PROVIDER_CATALOG.map((e) => byId.get(e.id)).filter(Boolean) as typeof rows;\n for (const r of rows) {\n if (!PROVIDER_CATALOG.some((e) => e.id === r.id)) ordered.push(r);\n }\n return ordered;\n }\n ));\n\n // ---- projects.* ----\n gw.method(\"projects.list\", wrap(zEmpty, () => svc.projects.list()));\n\n gw.method(\"projects.create\", wrap(\n z.object({ path: z.string().min(1), name: z.string().optional() }),\n async (p) => {\n const proj = await svc.projects.create(p.path, p.name);\n // Record recent directories (deduped, case-insensitive, at most 12)\n const s = await svc.settings.get();\n const dirs = (s.recentDirs ?? []).filter((d) => d.toLowerCase() !== p.path.toLowerCase());\n await svc.settings.set({ recentDirs: [p.path, ...dirs].slice(0, 12) });\n gw.publish(\"sidebar\", { type: \"project.upsert\", project: proj });\n return proj;\n }\n ));\n\n gw.method(\"projects.rename\", wrap(z.object({ id: z.string(), name: z.string() }), async (p) => {\n const proj = await svc.projects.rename(p.id, p.name);\n gw.publish(\"sidebar\", { type: \"project.upsert\", project: proj });\n return proj;\n }));\n\n gw.method(\"projects.remove\", wrap(z.object({ id: z.string() }), async (p) => {\n const proj = await svc.projects.get(p.id);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n // Move all of the project's conversations to the trash\n for (const t of await svc.threads.list()) {\n if (t.projectId === p.id) {\n await svc.threads.delete(t.id);\n gw.publish(\"sidebar\", { type: \"thread.remove\", id: t.id });\n }\n }\n await svc.projects.remove(p.id);\n gw.publish(\"sidebar\", { type: \"project.remove\", id: p.id });\n return { ok: true };\n }));\n\n gw.method(\"projects.open\", wrap(z.object({ id: z.string() }), async (p) => {\n const proj = await svc.projects.get(p.id);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n if (!existsSync(proj.path)) throw new ApiError(\"bad_path\", \"目录不存在或不是文件夹\");\n const cmd = process.platform === \"win32\" ? \"explorer.exe\" : process.platform === \"darwin\" ? \"open\" : \"xdg-open\";\n // explorer.exe doesn't understand forward-slash paths (it opens \"Documents\"); a missing xdg-open must not make the daemon throw either\n const target = process.platform === \"win32\" ? win32Path.normalize(proj.path) : proj.path;\n execFile(cmd, [target], { windowsHide: true }, () => {}).on(\"error\", () => {});\n return { ok: true };\n }));\n\n // ---- fs.* ----\n gw.method(\"fs.check\", wrap(z.object({ path: z.string().min(1) }), (p) => {\n let exists = false;\n let isDir = false;\n try {\n exists = existsSync(p.path);\n isDir = exists && statSync(p.path).isDirectory();\n } catch {}\n return { exists, isDir };\n }));\n\n gw.method(\"fs.recentDirs\", wrap(zEmpty, async () => {\n const s = await svc.settings.get();\n return (s.recentDirs ?? []).slice(0, 12);\n }));\n\n // fs.pickFolder: show the native folder picker (the daemon runs locally): Windows PowerShell, macOS osascript,\n // Linux zenity/kdialog; with none, report unavailable and the frontend falls back to typing.\n gw.method(\"fs.pickFolder\", wrap(z.object({ initial: z.string().optional() }).passthrough().optional(), (p) =>\n pickFolder(p?.initial),\n ));\n\n // ---- threads.* ----\n gw.method(\"threads.list\", wrap(zEmpty, () => svc.threads.list()));\n\n gw.method(\"threads.create\", wrap(\n z.object({\n projectId: z.string().min(1),\n provider: z.string().min(1),\n model: z.string().nullable().optional(),\n mode: threadModeSchema.optional(),\n effort: z.string().nullable().optional(),\n title: z.string().optional(),\n providerSessionId: z.string().nullable().optional(),\n importedFrom: z.object({ provider: z.string(), sessionId: z.string() }).optional(),\n }),\n async (p) => {\n const proj = await svc.projects.get(p.projectId);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n if (!existsSync(proj.path)) throw new ApiError(\"bad_path\", \"项目目录已不存在\");\n // New conversations must use an enabled Agent (except imported sessions, which already ran)\n if (!p.providerSessionId && !isProviderEnabled(await svc.settings.get(), p.provider)) {\n throw new ApiError(\"bad_params\", \"该 Agent 已在设置中禁用\");\n }\n const cat = catalogById(p.provider);\n if (!p.providerSessionId && cat && !providerCreatable(cat.status)) {\n throw new ApiError(\"bad_params\", `${cat.name} 尚未接入,暂不能新建对话`);\n }\n const meta = await svc.threads.create(p);\n gw.publish(\"sidebar\", { type: \"thread.upsert\", thread: meta });\n return meta;\n }\n ));\n\n gw.method(\"threads.get\", wrap(\n z.object({ id: z.string(), afterSeq: z.number().int().nonnegative().optional() }),\n async (p) => {\n const meta = await svc.threads.get(p.id);\n if (!meta) throw new ApiError(\"not_found\", \"对话不存在\");\n const events = await svc.threads.readEvents(p.id, p.afterSeq ?? 0, 5000);\n const rt = svc.runtime?.(p.id) ?? null;\n return {\n meta,\n events,\n queue: rt?.queue ?? [],\n pending: rt?.pending ?? [],\n runtimeStatus: rt?.status ?? meta.status,\n };\n }\n ));\n\n gw.method(\"threads.update\", wrap(\n z.object({\n id: z.string(),\n title: z.string().optional(),\n pinned: z.boolean().optional(),\n archived: z.boolean().optional(),\n }),\n async (p) => {\n const meta = await svc.threads.get(p.id);\n if (!meta) throw new ApiError(\"not_found\", \"对话不存在\");\n const patch: Record = {};\n if (p.title !== undefined) {\n patch.title = p.title.trim() || meta.title;\n patch.titleAuto = false; // Once renamed by hand, never auto-overwrite\n }\n if (p.pinned !== undefined) patch.pinned = p.pinned;\n if (p.archived !== undefined) patch.archived = p.archived;\n const next = await svc.threads.patch(p.id, patch);\n gw.publish(\"sidebar\", { type: \"thread.upsert\", thread: next });\n return next;\n }\n ));\n\n gw.method(\"threads.delete\", wrap(z.object({ id: z.string() }), async (p) => {\n const meta = await svc.threads.get(p.id);\n if (!meta) throw new ApiError(\"not_found\", \"对话不存在\");\n if (svc.manager) await svc.manager.deleteThread(p.id);\n else await svc.threads.delete(p.id);\n gw.publish(\"sidebar\", { type: \"thread.remove\", id: p.id });\n return { ok: true };\n }));\n\n // ---- git.* (§5.8) ----\n const projectPath = async (projectId: string): Promise => {\n const proj = await svc.projects.get(projectId);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n if (!existsSync(proj.path)) throw new ApiError(\"bad_path\", \"项目目录已不存在\");\n return proj.path;\n };\n\n gw.method(\"git.status\", wrap(z.object({ projectId: z.string() }), async (p) =>\n svc.git.status(await projectPath(p.projectId))\n ));\n\n gw.method(\"git.diff\", wrap(z.object({ projectId: z.string(), path: z.string() }), async (p) =>\n svc.git.diff(await projectPath(p.projectId), p.path)\n ));\n\n gw.method(\"git.discard\", wrap(z.object({ projectId: z.string(), path: z.string() }), async (p) =>\n svc.git.discard(await projectPath(p.projectId), p.path, svc.cfg.dataDir)\n ));\n\n // ---- files.* (§5.9) ----\n gw.method(\"files.tree\", wrap(z.object({ projectId: z.string(), dir: z.string().default(\".\") }), async (p) =>\n svc.files.tree(await projectPath(p.projectId), p.dir)\n ));\n\n gw.method(\"files.read\", wrap(z.object({ projectId: z.string(), path: z.string() }), async (p) =>\n svc.files.read(await projectPath(p.projectId), p.path)\n ));\n\n // ---- history.* (§7.6) ----\n gw.method(\"history.list\", wrap(z.object({ projectId: z.string() }), async (p) => {\n const proj = await svc.projects.get(p.projectId);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n const imported = new Set(\n (await svc.threads.list())\n .filter((t) => t.projectId === p.projectId && t.providerSessionId)\n .map((t) => t.providerSessionId),\n );\n const out: unknown[] = [];\n for (const provider of listProviders()) {\n if (!provider.listHistory) continue;\n const items = await provider.listHistory(proj.path).catch(() => []);\n for (const i of items) out.push({ ...i, imported: imported.has(i.sessionId) });\n }\n return out;\n }));\n\n gw.method(\"history.import\", wrap(\n z.object({\n projectId: z.string(),\n items: z.array(z.object({ provider: z.string(), sessionId: z.string(), title: z.string().optional() })).min(1).max(20),\n }),\n async (p) => {\n const proj = await svc.projects.get(p.projectId);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n const metas = [];\n for (const item of p.items) {\n const provider = getProvider(item.provider);\n if (!provider?.readHistory) continue;\n // Idempotent: reuse if the same providerSessionId was already imported\n const existing = (await svc.threads.list()).find(\n (t) => t.projectId === p.projectId && t.provider === item.provider && t.providerSessionId === item.sessionId,\n );\n if (existing) { metas.push(existing); continue; }\n const meta = await svc.threads.create({\n projectId: p.projectId,\n provider: item.provider,\n title: item.title,\n providerSessionId: item.sessionId,\n importedFrom: { provider: item.provider, sessionId: item.sessionId },\n });\n const events = await provider.readHistory(item.sessionId, proj.path).catch(() => []);\n for (const ev of events) {\n await svc.threads.appendEvent(meta.id, ev as Parameters[1]);\n }\n await svc.threads.patch(meta.id, { status: \"closed\" });\n gw.publish(\"sidebar\", { type: \"thread.upsert\", thread: await svc.threads.get(meta.id) });\n metas.push(await svc.threads.get(meta.id));\n }\n return metas;\n }\n ));\n\n // ---- terminal.* (§7.5) ----\n gw.method(\"terminal.list\", wrap(z.object({ projectId: z.string().optional() }).passthrough().optional(), (p) =>\n svc.terminals.list(p?.projectId)\n ));\n\n gw.method(\"terminal.create\", wrap(\n z.object({ projectId: z.string(), cols: z.number().int().min(20).max(500).default(80), rows: z.number().int().min(5).max(200).default(24) }),\n async (p) => {\n const path = await projectPath(p.projectId);\n const r = await svc.terminals.create(p.projectId, path, p.cols, p.rows);\n if (\"error\" in r) throw new ApiError(\"bad_params\", r.error);\n return r;\n }\n ));\n\n gw.method(\"terminal.attach\", wrap(z.object({ id: z.string() }), async (p, ctx) => {\n const r = svc.terminals.attach(p.id);\n if (r.error || r.index == null) throw new ApiError(\"not_found\", r.error ?? \"终端不存在\");\n // attach also subscribes to the terminal: channel (input frames are gated on that subscription too)\n svc.gateway.subscribe(ctx.conn, `terminal:${r.index}`);\n return r;\n }));\n\n gw.method(\"terminal.resize\", wrap(z.object({ id: z.string(), cols: z.number().int(), rows: z.number().int() }), (p) =>\n svc.terminals.resize(p.id, p.cols, p.rows)\n ));\n\n gw.method(\"terminal.kill\", wrap(z.object({ id: z.string() }), (p) => svc.terminals.kill(p.id)));\n\n // ---- thread.* (runtime operations, need the manager) ----\n const needManager = () => {\n if (!svc.manager) throw new ApiError(\"unavailable\", \"会话管理器未启动\");\n return svc.manager;\n };\n\n gw.method(\"thread.send\", wrap(\n z.object({\n id: z.string(),\n text: z.string(),\n attachments: z.array(z.object({ path: z.string(), mime: z.string(), name: z.string().optional() })).optional(),\n behavior: z.enum([\"queue\", \"steer\"]).optional(),\n }),\n async (p) => {\n await needManager().send(p.id, { text: p.text, attachments: p.attachments }, p.behavior ?? \"queue\");\n return { ok: true };\n }\n ));\n\n gw.method(\"thread.interrupt\", wrap(z.object({ id: z.string() }), async (p) => {\n await needManager().interrupt(p.id);\n return { ok: true };\n }));\n\n gw.method(\"thread.respond\", wrap(\n z.object({\n id: z.string(),\n requestId: z.string(),\n optionId: z.string().optional(),\n answers: z.record(z.string(), z.string()).optional(),\n cancelled: z.boolean().optional(),\n }),\n async (p) => {\n await needManager().respond(p.id, p.requestId, { optionId: p.optionId, answers: p.answers, cancelled: p.cancelled });\n return { ok: true };\n }\n ));\n\n gw.method(\"thread.setModel\", wrap(z.object({ id: z.string(), model: z.string().nullable() }), async (p) => {\n await needManager().setModel(p.id, p.model);\n return { ok: true };\n }));\n\n gw.method(\"thread.setMode\", wrap(z.object({ id: z.string(), mode: threadModeSchema }), async (p) => {\n await needManager().setMode(p.id, p.mode);\n return { ok: true };\n }));\n\n gw.method(\"thread.setEffort\", wrap(z.object({ id: z.string(), effort: z.string().nullable() }), async (p) => {\n await needManager().setEffort(p.id, p.effort);\n return { ok: true };\n }));\n\n gw.method(\"thread.commands\", wrap(z.object({ id: z.string(), load: z.boolean().optional() }), async (p) => {\n const commands = await needManager().listCommands(p.id, { load: p.load });\n return { commands };\n }));\n\n gw.method(\"thread.setCursorOpts\", wrap(\n z.object({\n id: z.string(),\n cursorFast: z.boolean().optional(),\n cursorAutoReview: z.boolean().optional(),\n }),\n async (p) => {\n await needManager().setCursorOpts(p.id, {\n cursorFast: p.cursorFast,\n cursorAutoReview: p.cursorAutoReview,\n });\n return { ok: true };\n },\n ));\n\n gw.method(\"thread.setProvider\", wrap(z.object({ id: z.string(), provider: z.string().min(1) }), async (p) => {\n await needManager().setProvider(p.id, p.provider);\n return { ok: true };\n }));\n\n gw.method(\"thread.close\", wrap(z.object({ id: z.string() }), async (p) => {\n await needManager().close(p.id);\n return { ok: true };\n }));\n\n // ---- thread.exportMarkdown (P2-7): persisted events → Markdown text ----\n gw.method(\"thread.exportMarkdown\", wrap(z.object({ id: z.string() }), async (p) => {\n const meta = await svc.threads.get(p.id);\n if (!meta) throw new ApiError(\"not_found\", \"对话不存在\");\n const events = await svc.threads.readEvents(p.id, 0, 20000);\n const L: string[] = [];\n L.push(`# ${meta.title}`, \"\");\n L.push(`> ${meta.provider} · ${meta.model ?? \"默认\"} · ${meta.mode} · ${new Date(meta.createdAt).toLocaleString(\"zh-CN\")}`, \"\");\n for (const e of events) {\n switch (e.type) {\n case \"user_message\": L.push(`**我**${e.steered ? \"(插话)\" : \"\"}:\\n\\n${e.text}`, \"\"); break;\n case \"assistant_message\": L.push(e.text, \"\"); break;\n case \"reasoning\": L.push(`
思考\\n\\n${e.text}\\n
`, \"\"); break;\n case \"tool_call\": {\n const icon = e.status === \"ok\" ? \"✓\" : e.status === \"failed\" ? \"✗\" : \"…\";\n L.push(`- \\`${e.kind}\\` ${icon} ${e.title}${e.output ? `\\n \\`\\`\\`\\n ${e.output.slice(0, 2000)}\\n \\`\\`\\`` : \"\"}`);\n break;\n }\n case \"plan\": L.push(\"### 计划\\n\\n\" + e.markdown, \"\"); break;\n case \"todo\": L.push(\"### 待办\\n\\n\" + e.items.map((i) => `- [${i.status === \"done\" || i.status === \"completed\" ? \"x\" : \" \"}] ${i.text}`).join(\"\\n\"), \"\"); break;\n case \"notice\": L.push(`_${e.message}_`, \"\"); break;\n case \"turn_ended\": L.push(`— ${e.status}${e.durationMs ? ` · ${Math.round(e.durationMs / 1000)}s` : \"\"}${e.error ? ` · ${e.error}` : \"\"} —`, \"\"); break;\n default: break;\n }\n }\n const markdown = L.join(\"\\n\");\n const safe = meta.title.replace(/[\\\\/:*?\"<>|\\s]+/g, \"_\").slice(0, 60) || \"thread\";\n const filename = `${safe}.md`;\n const dir = join(homedir(), \"Downloads\");\n await mkdir(dir, { recursive: true });\n let dest = join(dir, filename);\n if (existsSync(dest)) {\n const stamp = new Date().toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n dest = join(dir, `${safe}-${stamp}.md`);\n }\n await writeFile(dest, markdown, \"utf8\");\n revealInFileManager(dest);\n return { markdown, filename, path: dest };\n }));\n}\n\nfunction revealInFileManager(filePath: string): void {\n if (process.platform === \"win32\") {\n execFile(\"explorer.exe\", [\"/select,\" + win32Path.normalize(filePath)], { windowsHide: true }, () => {}).on(\"error\", () => {});\n return;\n }\n if (process.platform === \"darwin\") {\n execFile(\"open\", [\"-R\", filePath], () => {}).on(\"error\", () => {});\n return;\n }\n execFile(\"xdg-open\", [dirname(filePath)], () => {}).on(\"error\", () => {});\n}\n","// Atomic JSON write: temp file + rename. On fs failure throws an ApiError with a Chinese message (P5-2: disk write failure notice).\nimport { writeFile, rename, mkdir } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { ApiError } from \"../ws/methods.js\";\n\nexport async function writeJsonAtomic(file: string, obj: unknown): Promise {\n try {\n await mkdir(dirname(file), { recursive: true });\n const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;\n await writeFile(tmp, JSON.stringify(obj, null, 2), \"utf8\");\n await rename(tmp, file);\n } catch (e) {\n if (e instanceof ApiError) throw e;\n throw new ApiError(\"io_error\", `写入数据失败(${file}):${(e as Error)?.message ?? e}`);\n }\n}\n","// JSONL read/write: serialized appends, corrupt lines skipped. Write failures throw an ApiError with a Chinese message (P5-2: disk write failure notice).\nimport { appendFile, readFile, mkdir, rename, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { ApiError } from \"../ws/methods.js\";\n\nexport async function readJsonl(file: string): Promise {\n let text = \"\";\n try {\n text = await readFile(file, \"utf8\");\n } catch {\n return [];\n }\n const out: T[] = [];\n for (const line of text.split(\"\\n\")) {\n const t = line.trim();\n if (!t) continue;\n try {\n out.push(JSON.parse(t) as T);\n } catch {\n // Skip corrupt lines\n }\n }\n return out;\n}\n\n// One serialized write queue per file to keep append order.\nconst writers = new Map>();\n\nexport function appendJsonl(file: string, obj: unknown): Promise {\n const prev = writers.get(file) ?? Promise.resolve();\n const next = prev.then(async () => {\n try {\n await mkdir(dirname(file), { recursive: true });\n await appendFile(file, JSON.stringify(obj) + \"\\n\", \"utf8\");\n } catch (e) {\n if (e instanceof ApiError) throw e;\n throw new ApiError(\"io_error\", `写入数据失败(${file}):${(e as Error)?.message ?? e}`);\n }\n });\n writers.set(\n file,\n next.catch(() => {})\n );\n return next;\n}\n\n// Full rewrite (rare path, e.g. cleanup)\nexport async function writeJsonl(file: string, items: unknown[]): Promise {\n try {\n await mkdir(dirname(file), { recursive: true });\n const tmp = file + \".tmp-\" + process.pid;\n await writeFile(tmp, items.map((o) => JSON.stringify(o)).join(\"\\n\") + (items.length ? \"\\n\" : \"\"), \"utf8\");\n await rename(tmp, file);\n } catch (e) {\n if (e instanceof ApiError) throw e;\n throw new ApiError(\"io_error\", `写入数据失败(${file}):${(e as Error)?.message ?? e}`);\n }\n}\n","// Legacy data migration (§10.1): runs once when the daemon starts.\n// Old: sessions//{meta.json,events.jsonl} + projects in settings.json →\n// New: projects.json + threads//{meta.json,timeline.jsonl}. Old files are read-only and copied to trash/legacy-/ as a backup.\nimport { cp, mkdir, readdir, readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { SettingsStore } from \"../store/settings.js\";\nimport type { ProjectsStore } from \"../store/projects.js\";\nimport type { ThreadsStore, ThreadMeta } from \"../store/threads.js\";\nimport type { Mode, TimelineEvent } from \"../timeline/types.js\";\nimport { appendJsonl } from \"../util/jsonl.js\";\nimport { writeJsonAtomic } from \"../util/atomic.js\";\nimport { validId } from \"../util/paths.js\";\nimport { log } from \"../log.js\";\n\ntype OldMeta = {\n id: string; agent: string; cwd: string; model: string; permission: string;\n title: string; titleAuto: boolean; pinned: boolean; cliSessionId: string | null;\n created: number; updated: number; lastStatus?: string; totalCost?: number; totalUsage?: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number } | null;\n};\ntype OldEvt = Record & { type: string; seq?: number; ts?: number };\n\nconst SKIP_TITLE = /^\\/(slow|deny|error|long|codex)/;\nconst MODE_MAP: Record = {\n plan: \"plan\", default: \"default\", acceptEdits: \"acceptEdits\", bypassPermissions: \"bypassPermissions\",\n auto: \"acceptEdits\", full: \"bypassPermissions\",\n};\nconst belongsTo = (cwd: string, parent: string) => {\n const c = cwd.replace(/\\\\/g, \"/\").toLowerCase().replace(/\\/+$/, \"\");\n const p = parent.replace(/\\\\/g, \"/\").toLowerCase().replace(/\\/+$/, \"\");\n return c === p || c.startsWith(p + \"/\");\n};\n\nfunction stamp(): string {\n const d = new Date();\n const p = (n: number) => String(n).padStart(2, \"0\");\n return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;\n}\n\nexport async function migrateLegacy(dataDir: string, stores: {\n settings: SettingsStore; projects: ProjectsStore; threads: ThreadsStore;\n}): Promise<{ migrated: number; skipped: boolean }> {\n const settings = await stores.settings.get();\n if (settings.v1Migrated) return { migrated: 0, skipped: true };\n const oldDir = join(dataDir, \"sessions\");\n const oldSettingsPath = join(dataDir, \"settings.json\");\n if (!existsSync(oldDir) && !existsSync(oldSettingsPath)) {\n await stores.settings.set({ v1Migrated: true });\n return { migrated: 0, skipped: true };\n }\n\n // 1. Backup\n const backup = join(dataDir, \"trash\", `legacy-${stamp()}`);\n await mkdir(backup, { recursive: true });\n if (existsSync(oldDir)) await cp(oldDir, join(backup, \"sessions\"), { recursive: true }).catch(() => {});\n if (existsSync(oldSettingsPath)) await cp(oldSettingsPath, join(backup, \"settings.json\")).catch(() => {});\n\n // Read the old settings (read the file directly; SettingsStore already loaded the same path, the fields coexist without conflict)\n const oldSettings = existsSync(oldSettingsPath)\n ? (JSON.parse(await readFile(oldSettingsPath, \"utf8\").catch(() => \"{}\")) as Record)\n : {};\n\n // 2. Projects: old settings.projects → projects.json; orphan cwds are added per the adoptOrphans rule\n const projectIdByPath = new Map();\n const lc = (p: string) => p.replace(/\\\\/g, \"/\").toLowerCase().replace(/\\/+$/, \"\");\n for (const p of (oldSettings.projects as { name?: string; path?: string }[] | undefined) ?? []) {\n if (!p?.path) continue;\n try {\n const proj = await stores.projects.create(p.path, p.name);\n projectIdByPath.set(lc(proj.path), proj.id);\n } catch (e) {\n log(\"warn\", \"迁移:建项目失败\", { path: p.path, err: String(e) });\n }\n }\n\n // Old session list\n let sessionIds: string[] = [];\n try {\n sessionIds = (await readdir(oldDir)).filter((d) => validId(d) && existsSync(join(oldDir, d, \"meta.json\")));\n } catch {}\n const metas: OldMeta[] = [];\n for (const id of sessionIds) {\n try {\n const m = JSON.parse(await readFile(join(oldDir, id, \"meta.json\"), \"utf8\")) as OldMeta;\n if (m?.cwd) metas.push(m);\n } catch {}\n }\n // Orphan cwd → add a project (subdirectories fold into their parent)\n const extraDirs: string[] = [];\n const projPaths = [...projectIdByPath.keys()];\n for (const m of metas) {\n const covered = projPaths.some((pp) => belongsTo(m.cwd, pp)) || extraDirs.some((d) => belongsTo(m.cwd, d));\n if (covered) continue;\n for (let i = extraDirs.length - 1; i >= 0; i--) if (belongsTo(extraDirs[i], m.cwd)) extraDirs.splice(i, 1);\n extraDirs.push(m.cwd);\n }\n for (const d of extraDirs) {\n if (!existsSync(d)) continue; // Skip directories that no longer exist (same as the old adoptOrphans)\n try {\n const name = d.replace(/\\\\/g, \"/\").split(\"/\").filter(Boolean).pop() ?? d;\n const proj = await stores.projects.create(d, name);\n projectIdByPath.set(lc(proj.path), proj.id);\n projPaths.push(lc(proj.path));\n } catch (e) {\n log(\"warn\", \"迁移:补建孤儿项目失败\", { path: d, err: String(e) });\n }\n }\n\n const projectOf = (cwd: string): string | null => {\n const l = lc(cwd);\n // Take the longest match (a subdirectory joins the most specific project)\n let best: string | null = null;\n for (const [pp, id] of projectIdByPath) {\n if (belongsTo(l, pp) && (best == null || pp.length > best.length)) best = pp;\n }\n return best ? projectIdByPath.get(best)! : null;\n };\n\n // 3. Sessions\n let migrated = 0;\n const threadsDir = join(dataDir, \"threads\");\n for (const m of metas) {\n try {\n const projectId = projectOf(m.cwd);\n if (!projectId) { log(\"warn\", \"迁移:无项目可归,跳过\", { id: m.id, cwd: m.cwd }); continue; }\n const testTitle = SKIP_TITLE.test(m.title ?? \"\") || m.title === \"默认剧本演示\";\n const provider = m.agent === \"claude\" || m.agent === \"codex\" ? m.agent : \"mock\";\n const archived = testTitle || (m.agent !== \"claude\" && m.agent !== \"codex\");\n const now = Date.now();\n const meta: ThreadMeta = {\n id: `t_${m.id.replace(/^s?_?/, \"\")}`.slice(0, 80),\n projectId,\n title: m.title || \"旧对话\",\n titleAuto: false,\n provider,\n model: m.model || null,\n mode: MODE_MAP[m.permission] ?? \"default\",\n effort: null,\n providerSessionId: m.cliSessionId || null,\n status: \"closed\",\n pinned: !!m.pinned,\n archived,\n createdAt: m.created || now,\n updatedAt: m.updated || now,\n lastTurnStatus: m.lastStatus === \"ok\" ? \"completed\" : m.lastStatus === \"error\" ? \"failed\" : null,\n usage: {\n inputTokens: m.totalUsage?.inputTokens ?? 0,\n outputTokens: m.totalUsage?.outputTokens ?? 0,\n cacheReadTokens: m.totalUsage?.cacheReadTokens ?? 0,\n costUsd: m.totalCost ?? 0,\n },\n importedFrom: m.cliSessionId ? { provider, sessionId: m.cliSessionId } : undefined,\n seq: 0,\n };\n const tdir = join(threadsDir, meta.id);\n if (existsSync(tdir)) meta.id = `t_${m.id}_v1`.slice(0, 80);\n await mkdir(join(threadsDir, meta.id), { recursive: true });\n\n // Event mapping\n const oldEvts = (await readFile(join(oldDir, m.id, \"events.jsonl\"), \"utf8\").catch(() => \"\"))\n .split(\"\\n\").filter((l) => l.trim())\n .map((l) => { try { return JSON.parse(l) as OldEvt; } catch { return null; } })\n .filter((x): x is OldEvt => !!x);\n const lines: TimelineEvent[] = [];\n const openTools = new Map();\n let seq = 0;\n const push = (ev: Record & { type: TimelineEvent[\"type\"] }) => {\n seq++;\n lines.push({ ...ev, seq, ts: Date.now(), threadId: meta.id } as TimelineEvent);\n };\n for (const e of oldEvts) {\n switch (e.type) {\n case \"user\": push({ type: \"user_message\", text: String(e.text ?? \"\") }); break;\n case \"text\": push({ type: \"assistant_message\", itemId: `lg_${seq}`, text: String(e.text ?? \"\") }); break;\n case \"thinking\": push({ type: \"reasoning\", itemId: `lg_${seq}`, text: String(e.text ?? \"\") }); break;\n case \"tool.call\": {\n const key = String(e.itemId ?? e.callId ?? `c${seq}`);\n openTools.set(key, { kind: String(e.kind ?? \"other\"), title: String(e.title ?? e.kind ?? \"工具\"), input: e.input, detail: e.detail });\n push({ type: \"tool_call\", itemId: key, kind: \"other\", title: String(e.title ?? \"工具\"), status: \"running\", input: e.input, detail: e.detail });\n break;\n }\n case \"tool.result\": {\n const key = String(e.itemId ?? e.callId ?? `c${seq}`);\n const open = openTools.get(key);\n openTools.delete(key);\n push({\n type: \"tool_call\", itemId: key,\n kind: (open?.kind ?? \"other\") as import(\"../timeline/types.js\").ToolKind,\n title: open?.title ?? \"工具\",\n status: e.ok === false || e.error ? \"failed\" : \"ok\",\n output: typeof e.output === \"string\" ? e.output.slice(0, 20000) : undefined,\n detail: open?.detail,\n } as Record & { type: \"tool_call\" });\n break;\n }\n case \"todo\": {\n const items = (e.items as { text?: string; status?: string }[] | undefined) ?? [];\n push({ type: \"todo\", items: items.map((i) => ({ text: String(i.text ?? \"\"), status: i.status === \"done\" ? \"done\" : i.status === \"in_progress\" ? \"in_progress\" : \"todo\" })) });\n break;\n }\n case \"permission.denied\": push({ type: \"notice\", level: \"warn\", message: `已拒绝:${String(e.title ?? e.kind ?? \"\")}` }); break;\n case \"permission.request\": case \"permission.resolved\": break; // Old permission cards are stale; don't recreate them\n case \"notice\": push({ type: \"notice\", level: (e.level as \"info\" | \"warn\" | \"error\") ?? \"info\", message: String(e.message ?? \"\") }); break;\n case \"run.end\": {\n const u = e.usage as { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number } | undefined;\n if (u) push({ type: \"usage\", turnId: `lg_t${seq}`, inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0, cacheReadTokens: u.cacheReadTokens ?? 0, costUsd: (e.costUsd as number) ?? 0 });\n push({ type: \"turn_ended\", turnId: `lg_t${seq}`, status: e.status === \"error\" ? \"failed\" : e.status === \"interrupted\" ? \"interrupted\" : \"completed\", durationMs: (e.ms as number) ?? undefined, error: e.error as string | undefined });\n break;\n }\n case \"run.start\": break;\n case \"init\": break;\n default: break;\n }\n }\n // Unclosed tool.call → mark failed\n for (const [key, o] of openTools) {\n seq++;\n lines.push({ type: \"tool_call\", itemId: key, kind: \"other\", title: o.title, status: \"failed\", seq, ts: Date.now(), threadId: meta.id } as TimelineEvent);\n }\n for (const ev of lines) await appendJsonl(join(threadsDir, meta.id, \"timeline.jsonl\"), ev);\n meta.seq = seq;\n await writeJsonAtomic(join(threadsDir, meta.id, \"meta.json\"), meta);\n migrated++;\n } catch (e) {\n log(\"warn\", \"迁移:会话失败\", { id: m.id, err: String(e) });\n }\n }\n\n // 4. Merge settings (UI preference fields only)\n const patch: Record = { v1Migrated: true };\n if (typeof oldSettings.theme === \"string\") patch.theme = oldSettings.theme;\n if (typeof oldSettings.sendKey === \"string\") patch.sendKey = oldSettings.sendKey;\n if (typeof oldSettings.showThinking === \"boolean\") patch.showThinking = oldSettings.showThinking;\n if (typeof oldSettings.defaultAgent === \"string\") patch.defaultProvider = oldSettings.defaultAgent === \"claude\" || oldSettings.defaultAgent === \"codex\" ? oldSettings.defaultAgent : \"claude\";\n if (typeof oldSettings.defaultPermission === \"string\") patch.defaultMode = MODE_MAP[oldSettings.defaultPermission] ?? \"default\";\n await stores.settings.set(patch);\n\n log(\"info\", `旧数据迁移完成:${migrated} 个会话、${projectIdByPath.size} 个项目,备份在 ${backup}`);\n return { migrated, skipped: false };\n}\n","// Startup warm-up: for enabled, non-planned Providers, detect then fetch models; results go into infoCache.\nimport type { Settings } from \"../store/settings.js\";\nimport { listProviders } from \"./registry.js\";\nimport { catalogById } from \"./catalog.js\";\nimport { isProviderEnabled } from \"./enabled.js\";\nimport { cachedDetect, cachedModels } from \"./infoCache.js\";\n\nexport async function warmProviderCache(settings: Settings): Promise {\n await Promise.all(\n listProviders().map(async (p) => {\n if (!isProviderEnabled(settings, p.id) || catalogById(p.id)?.status === \"planned\") return;\n const det = await cachedDetect(p);\n if (det.available) await cachedModels(p);\n }),\n );\n}\n","// Daemon entry: parse arguments, initialize services, start HTTP+WS, write daemon.json, handle exit.\nimport { mkdirSync, existsSync, rmSync, chmodSync } from \"node:fs\";\nimport { join, dirname } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { parseConfig } from \"./config.js\";\nimport { initLog, log } from \"./log.js\";\nimport { createHttpServer } from \"./http.js\";\nimport { AttachmentStore } from \"./store/attachments.js\";\nimport { SettingsStore } from \"./store/settings.js\";\nimport { ProjectsStore } from \"./store/projects.js\";\nimport { ThreadsStore } from \"./store/threads.js\";\nimport { Gateway } from \"./ws/gateway.js\";\nimport { registerMethods } from \"./ws/methods.js\";\nimport { writeJsonAtomic } from \"./util/atomic.js\";\nimport { initProviders } from \"./providers/registry.js\";\nimport { AgentManager } from \"./agents/manager.js\";\nimport { GitService } from \"./git/service.js\";\nimport { FileService } from \"./files/service.js\";\nimport { TerminalService } from \"./terminal/service.js\";\nimport { augmentPath, pidAlive } from \"./util/proc.js\";\n\n// The bundle targets node20: an older Node (or being started by Bun by mistake) gets a clear message instead of crashing on some API\nconst nodeMajor = Number(/^v?(\\d+)/.exec(process.versions.node ?? \"\")?.[1] ?? 0);\nif (nodeMajor < 20) {\n console.error(`[agent-console] 需要 Node.js 20 或更高版本,当前 ${process.version}(${process.execPath})`);\n process.exit(3);\n}\n\n// Agent CLI .js entries and the Claude SDK look up \"node\" by name; GUI apps launched from the macOS Dock get a minimal PATH,\n// so append the current node's directory and common install directories to PATH (existing order unchanged)\nprocess.env.PATH = augmentPath(process.env, dirname(process.execPath));\n\nconst cfg = parseConfig(process.argv.slice(2));\n\n// Top-level safety net: log, don't exit\nprocess.on(\"uncaughtException\", (e) => {\n log(\"error\", \"uncaughtException\", { err: String(e?.stack ?? e) });\n});\nprocess.on(\"unhandledRejection\", (e) => {\n log(\"error\", \"unhandledRejection\", { err: String(e) });\n});\n\nasync function main(): Promise {\n initLog(cfg.dataDir);\n for (const d of [\"threads\", \"attachments\", \"trash\", \"logs\"]) {\n mkdirSync(join(cfg.dataDir, d), { recursive: true });\n }\n\n const appDir = join(dirname(fileURLToPath(import.meta.url)), \"..\", \"..\", \"app\");\n const attachments = new AttachmentStore(join(cfg.dataDir, \"attachments\"));\n const { bindAttachmentStore } = await import(\"./util/attachmentRefs.js\");\n bindAttachmentStore(attachments);\n const settings = new SettingsStore(cfg.dataDir);\n const projects = new ProjectsStore(cfg.dataDir);\n const threads = new ThreadsStore(cfg.dataDir);\n const gateway = new Gateway(cfg);\n\n initProviders(cfg.version);\n // Legacy data migration (§10.1): runs once, skipped after v1Migrated; old files are read-only\n const { migrateLegacy } = await import(\"./migrate/legacy.js\");\n try {\n await migrateLegacy(cfg.dataDir, { settings, projects, threads });\n } catch (e) {\n log(\"error\", \"旧数据迁移失败\", { err: String(e) });\n }\n const manager = new AgentManager(gateway, threads, projects, settings, attachments);\n await manager.recoverOnStart();\n const terminals = new TerminalService(gateway);\n registerMethods(gateway, {\n cfg,\n settings,\n gateway,\n projects,\n threads,\n manager,\n git: new GitService(),\n files: new FileService(),\n terminals,\n runtime: (id) => manager.runtimeInfo(id),\n });\n\n const server = createHttpServer(cfg, appDir, attachments, {\n onUpgrade: (req, socket, head) => gateway.handleUpgrade(req, socket, head),\n });\n\n // Retry 5 times if the port is in use\n let attempts = 0;\n await new Promise((resolveListen, rejectListen) => {\n const tryListen = () => {\n attempts++;\n server.once(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"EADDRINUSE\" && attempts < 6) {\n log(\"warn\", `端口 ${cfg.port} 被占用,500ms 后重试(第 ${attempts} 次)`);\n setTimeout(tryListen, 500);\n } else {\n rejectListen(e);\n }\n });\n server.listen(cfg.port, \"127.0.0.1\");\n };\n server.once(\"listening\", () => resolveListen());\n tryListen();\n }).catch((e) => {\n log(\"error\", `端口 ${cfg.port} 无法监听`, { err: String(e) });\n process.exit(2);\n });\n\n // --port 0: after listening on a random port, write the actual port back into cfg.port. Host/Origin checks read cfg.port,\n // and no request can arrive before this (listen has only just completed), so there is no window checking against the old port.\n const bound = server.address();\n if (bound && typeof bound === \"object\") cfg.port = bound.port;\n // Ready line: the plugin host reads this line from stdout to learn the actual port (port and pid only, no token)\n process.stdout.write(JSON.stringify({ agentConsoleReady: true, port: cfg.port, pid: process.pid }) + \"\\n\");\n // Background warm-up: after start, pull detection/models for enabled providers into the cache so the first page open doesn't wait\n void (async () => {\n const { warmProviderCache } = await import(\"./providers/warm.js\");\n await warmProviderCache(await settings.get());\n })().catch((e) => log(\"warn\", \"Provider 预热失败\", { err: String(e) }));\n\n const daemonJsonPath = join(cfg.dataDir, \"daemon.json\");\n await writeJsonAtomic(daemonJsonPath, {\n pid: process.pid,\n port: cfg.port,\n token: cfg.token,\n version: cfg.version,\n buildStamp: cfg.buildStamp,\n startedAt: Date.now(),\n });\n chmodSync(daemonJsonPath, 0o600);\n log(\"info\", `daemon 已启动 端口 ${cfg.port} 数据目录 ${cfg.dataDir} 版本 ${cfg.version} pid ${process.pid}`);\n // Standalone dev mode: print the open URL with the token to the console (console only, never the log file)\n if (!cfg.parentPid) console.log(`[agent-console] 打开 http://127.0.0.1:${cfg.port}/#t=${cfg.token}`);\n\n // Idle exit: check every minute; no WS connection + no running conversation + no open terminal → exit after idleExitMinutes\n let idleSince = Date.now();\n setInterval(() => {\n const busy = gateway.connectionCount > 0 || manager.anyBusy() || terminals.count > 0;\n if (busy) idleSince = Date.now();\n if (Date.now() - idleSince > cfg.idleExitMinutes * 60 * 1000) {\n log(\"info\", \"空闲超时,daemon 退出\");\n shutdown(0);\n }\n }, 60 * 1000).unref();\n\n let shuttingDown = false;\n function shutdown(code: number): void {\n if (shuttingDown) return;\n shuttingDown = true;\n log(\"info\", \"daemon 关闭中\");\n terminals.killAll();\n gateway.closeAll();\n try {\n rmSync(daemonJsonPath, { force: true });\n } catch {}\n // Wait for Agent sessions to close cleanly (at most 2.5 s) before exiting; process trees still alive are cleaned up by util/bin's exit hook\n const sessionsClosed = Promise.race([manager.shutdown(), new Promise((r) => setTimeout(r, 2500))]);\n void sessionsClosed.then(() => server.close(() => process.exit(code)));\n setTimeout(() => process.exit(code), 4000).unref();\n }\n process.on(\"SIGINT\", () => shutdown(0));\n process.on(\"SIGTERM\", () => shutdown(0));\n // Lifeline: when spawned by the plugin host (Magpie's Bun plugin process) we get --parent-pid, and stdin is a pipe the host holds.\n // Host exits (normally or killed) → pipe closes → stdin end/close → exit;\n // also check every 2 seconds whether the parent is still alive, in case stdin was inherited by another process and never closed.\n // Not enabled in standalone dev mode (npm run dev, no --parent-pid); there we rely on idle exit only.\n if (cfg.parentPid) {\n const parentPid = cfg.parentPid;\n const parentWatch = setInterval(() => {\n if (!pidAlive(parentPid)) {\n clearInterval(parentWatch);\n log(\"info\", `父进程 ${parentPid} 已退出,daemon 跟随退出`);\n shutdown(0);\n }\n }, 2000);\n parentWatch.unref();\n process.stdin.on(\"end\", () => shutdown(0));\n process.stdin.on(\"close\", () => shutdown(0));\n process.stdin.on(\"error\", () => shutdown(0));\n process.stdin.resume();\n }\n}\n\nmain().catch((e) => {\n console.error(\"[agent-console] 启动失败\", e);\n process.exit(1);\n});\n","// Arguments and environment: port, dataDir, token, version, idleExitMinutes\nimport { randomBytes } from \"node:crypto\";\nimport { readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join, resolve, dirname } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { daemonBuildStamp } from \"./util/buildStamp.js\";\n\nexport type Config = {\n port: number;\n dataDir: string;\n token: string;\n version: string;\n idleExitMinutes: number;\n buildStamp: string;\n parentPid?: number;\n};\n\nfunction pkgVersion(): string {\n try {\n const here = dirname(fileURLToPath(import.meta.url));\n // dist/main.js → ../../package.json; src/main.ts also ends up in dist via esbuild\n const pkg = JSON.parse(readFileSync(join(here, \"..\", \"..\", \"package.json\"), \"utf8\"));\n return String(pkg.version || \"0.0.0\");\n } catch {\n return \"0.0.0\";\n }\n}\n\n// --data sets the data root (equivalent to setting XDG_CONFIG_HOME and then joining magpie/agent-console?\n// EXEC §5.2: --data (data root, equivalent to setting XDG_CONFIG_HOME; default $XDG_CONFIG_HOME\n// or ~/.config, then joined with magpie/agent-console). i.e. the --data value has XDG_CONFIG_HOME semantics.\nexport function parseConfig(argv: string[]): Config {\n let port: number | null = null;\n let data: string | null = null;\n let token: string | null = null;\n let parentPid: number | undefined;\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i];\n if (a === \"--port\" && argv[i + 1]) port = Number(argv[++i]);\n else if (a.startsWith(\"--port=\")) port = Number(a.slice(7));\n else if (a === \"--data\" && argv[i + 1]) data = argv[++i];\n else if (a.startsWith(\"--data=\")) data = a.slice(7);\n else if (a === \"--token\" && argv[i + 1]) token = argv[++i];\n else if (a.startsWith(\"--token=\")) token = a.slice(8);\n else if (a === \"--parent-pid\" && argv[i + 1]) parentPid = Number(argv[++i]);\n else if (a.startsWith(\"--parent-pid=\")) parentPid = Number(a.slice(13));\n }\n if (port == null || !Number.isInteger(port) || port < 0 || port > 65535) {\n const envPort = Number(process.env.AGENT_CONSOLE_PORT || \"\");\n port = Number.isInteger(envPort) && envPort >= 0 && envPort <= 65535 ? envPort : 3488;\n }\n const xdg = data ?? process.env.XDG_CONFIG_HOME ?? join(homedir(), \".config\");\n const dataDir = resolve(join(xdg, \"magpie\", \"agent-console\"));\n const idleEnv = Number(process.env.AGENT_CONSOLE_IDLE_EXIT_MIN || \"\");\n return {\n port,\n dataDir,\n token: token ?? process.env.AGENT_CONSOLE_TOKEN ?? randomBytes(16).toString(\"hex\"),\n version: pkgVersion(),\n idleExitMinutes: Number.isFinite(idleEnv) && idleEnv > 0 ? idleEnv : 60,\n buildStamp: daemonBuildStamp(),\n parentPid: Number.isInteger(parentPid) ? parentPid : undefined,\n };\n}\n","import { existsSync, statSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nfunction resolveDaemonMainJs(): string | null {\n const self = fileURLToPath(import.meta.url);\n const norm = self.replace(/\\\\/g, \"/\");\n // esbuild single-file bundle: the whole package is dist/main.js\n if (norm.endsWith(\"/daemon/dist/main.js\") || norm.endsWith(\"/dist/main.js\")) return self;\n\n const here = dirname(self);\n const candidates = [\n join(here, \"..\", \"..\", \"dist\", \"main.js\"), // src/util → daemon/dist/main.js (vitest/tsc)\n join(here, \"main.js\"), // Already in the dist directory\n join(here, \"..\", \"main.js\"),\n ];\n for (const p of candidates) {\n if (existsSync(p)) return p;\n }\n return null;\n}\n\n/** mtime of daemon/dist/main.js, used by the plugin to detect code updates and restart the process */\nexport function daemonBuildStamp(): string {\n try {\n const mainJs = resolveDaemonMainJs();\n if (!mainJs) return \"0\";\n return String(statSync(mainJs).mtimeMs);\n } catch {\n return \"0\";\n }\n}\n","// HTTP routes: / → app/index.html (no token), static assets, /api/ping, /api/upload, /attachments/*\nimport { createServer, type IncomingMessage, type ServerResponse, type Server } from \"node:http\";\nimport { readFile, stat } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { join, normalize, extname } from \"node:path\";\nimport { checkHost, classifyOrigin, tokenEqual } from \"./security.js\";\nimport type { Config } from \"./config.js\";\nimport type { AttachmentStore } from \"./store/attachments.js\";\nimport { log } from \"./log.js\";\n\nconst MIME: Record = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".gif\": \"image/gif\",\n \".webp\": \"image/webp\",\n \".woff2\": \"font/woff2\",\n \".map\": \"application/json\",\n \".json\": \"application/json; charset=utf-8\",\n};\n\nconst MAX_UPLOAD = 50 * 1024 * 1024; // 50MB\nconst MAX_MSG = 200 * 1024; // Ordinary messages: 200KB\n\nexport type HttpHooks = {\n onUpgrade?: (req: IncomingMessage, socket: import(\"node:net\").Socket, head: Buffer) => void;\n};\n\n// A malformed %xx must not become a 500\nfunction safeDecode(s: string): string | null {\n try {\n return decodeURIComponent(s);\n } catch {\n return null;\n }\n}\n\nfunction send(res: ServerResponse, code: number, body: string | Buffer, headers: Record = {}): void {\n res.writeHead(code, headers);\n res.end(body);\n}\n\nfunction sendJson(res: ServerResponse, code: number, obj: unknown, headers: Record = {}): void {\n send(res, code, JSON.stringify(obj), { \"content-type\": \"application/json; charset=utf-8\", \"cache-control\": \"no-store\", ...headers });\n}\n\n// Common headers for static responses: no caching (the page has no token, but don't let old versions linger), no sniffing, no Referer\nconst STATIC_HEADERS: Record = {\n \"cache-control\": \"no-store\",\n \"x-content-type-options\": \"nosniff\",\n \"referrer-policy\": \"no-referrer\",\n};\n\nexport function createHttpServer(cfg: Config, appDir: string, attachments: AttachmentStore, hooks: HttpHooks = {}): Server {\n const server = createServer(async (req, res) => {\n try {\n await route(req, res);\n } catch (e) {\n log(\"error\", \"HTTP 处理异常\", { url: req.url, err: String(e) });\n if (!res.headersSent) sendJson(res, 500, { ok: false, e: { code: \"internal\", message: \"服务内部错误\" } });\n else res.end();\n }\n });\n\n // Origin policy per request (see classifyOrigin in security.ts):\n // foreign (another site) → always 403, even with a token\n // opaque (Origin: null) → may only \"present\" the token on token routes (/api/*, /attachments/*);\n // it confers no trust by itself. Static routes are always refused.\n // none / self → static routes allowed (static files contain no token); token routes still need the token.\n // The CORS header is only given (as null) on responses that carry a valid token + Origin: null; preflight only for /api/upload.\n async function route(req: IncomingMessage, res: ServerResponse): Promise {\n // Host must be loopback + this process's actual port: blocks DNS rebinding (a rebound page's GET carries no Origin)\n const hostCheck = checkHost(req.headers.host, cfg.port);\n if (!hostCheck.ok) {\n sendJson(res, hostCheck.code, { ok: false, e: { code: \"bad_host\", message: hostCheck.message } });\n return;\n }\n const origin = classifyOrigin(req.headers.origin, cfg.port);\n if (origin === \"foreign\") {\n sendJson(res, 403, { ok: false, e: { code: \"bad_origin\", message: \"来源不允许\" } });\n return;\n }\n\n const url = new URL(req.url ?? \"/\", `http://127.0.0.1:${cfg.port}`);\n const pathname = url.pathname;\n const isApi = pathname.startsWith(\"/api/\");\n const isAttachment = pathname.startsWith(\"/attachments/\");\n\n // OPTIONS preflight: browsers don't send custom headers on a preflight, so there is no token here.\n // Only allow /api/upload, the one cross-origin POST the frontend really makes, and only for null / our own origin;\n // the preflight itself returns no data, and the real request still has to pass the token check.\n if (req.method === \"OPTIONS\") {\n if (pathname !== \"/api/upload\" || origin === \"none\") {\n sendJson(res, 403, { ok: false, e: { code: \"bad_preflight\", message: \"预检不允许\" } });\n return;\n }\n send(res, 204, \"\", {\n \"access-control-allow-origin\": origin === \"opaque\" ? \"null\" : `http://127.0.0.1:${cfg.port}`,\n \"access-control-allow-headers\": \"x-agent-console, content-type\",\n \"access-control-allow-methods\": \"GET, POST, OPTIONS\",\n \"access-control-max-age\": \"600\",\n vary: \"origin\",\n });\n return;\n }\n\n // Token routes: the token is the only authorization\n if (isApi || isAttachment) {\n const headerToken = req.headers[\"x-agent-console\"];\n // Attachments accept ?t= ( can't send custom headers); the API only accepts the header\n const queryToken = isAttachment ? url.searchParams.get(\"t\") : null;\n const validToken =\n (typeof headerToken === \"string\" && tokenEqual(headerToken, cfg.token)) ||\n (typeof queryToken === \"string\" && tokenEqual(queryToken, cfg.token));\n if (!validToken) {\n sendJson(res, 403, { ok: false, e: { code: \"bad_token\", message: \"令牌无效\" } });\n return;\n }\n // In a sandbox iframe (no allow-same-origin) the page's origin is null, so even a same-address fetch is cross-origin:\n // the response needs allow-origin: null to be readable. It's only sent after the token is verified,\n // so any Origin: null page without the token can read nothing.\n if (origin === \"opaque\") {\n res.setHeader(\"access-control-allow-origin\", \"null\");\n res.setHeader(\"vary\", \"origin\");\n }\n if (isApi) {\n await routeApi(req, res, pathname, url);\n return;\n }\n const id = safeDecode(pathname.slice(\"/attachments/\".length));\n const a = id === null ? null : await attachments.get(id);\n if (!a) {\n sendJson(res, 404, { ok: false, e: { code: \"not_found\", message: \"附件不存在\" } });\n return;\n }\n const data = await attachments.read(a.id);\n send(res, 200, data ?? Buffer.alloc(0), { ...STATIC_HEADERS, \"content-type\": a.mime });\n return;\n }\n\n // Static assets: refuse Origin: null. Ordinary navigations and + + + + + diff --git a/packages/agent-console/scripts/browser-path.mjs b/packages/agent-console/scripts/browser-path.mjs new file mode 100644 index 00000000..26e82efd --- /dev/null +++ b/packages/agent-console/scripts/browser-path.mjs @@ -0,0 +1,33 @@ +// Chromium-family browser for the e2e / screenshot scripts: MSEDGE_PATH / CHROME_PATH first, +// then the usual Edge, Chrome and Chromium install locations per platform. +import { existsSync } from "node:fs"; + +export function findBrowser(env = process.env, platform = process.platform, exists = existsSync) { + const explicit = env.MSEDGE_PATH || env.CHROME_PATH; + if (explicit) return explicit; + const local = env.LOCALAPPDATA || ""; + const candidates = + platform === "win32" + ? [ + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + local && `${local.replace(/\\/g, "/")}/Google/Chrome/Application/chrome.exe`, + ] + : platform === "darwin" + ? [ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ] + : [ + "/usr/bin/microsoft-edge", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + ]; + return candidates.filter(Boolean).find((p) => exists(p)); +} diff --git a/packages/agent-console/scripts/build-v1.mjs b/packages/agent-console/scripts/build-v1.mjs new file mode 100644 index 00000000..a2cf6e0f --- /dev/null +++ b/packages/agent-console/scripts/build-v1.mjs @@ -0,0 +1,100 @@ +// Deterministic v1 build: daemon ESM bundle plus browser bundle and HTML. +import * as esbuild from "esbuild"; +import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +export function options(outRoot = root) { + return { + daemon: { + entryPoints: ["daemon/src/main.ts"], + absWorkingDir: root, + platform: "node", + format: "esm", + target: "node20", + bundle: true, + packages: "external", + outfile: path.join(outRoot, "daemon/dist/main.js"), + sourcemap: "linked", + sourceRoot: "", + banner: { + js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", + }, + logLevel: "silent", + }, + web: { + entryPoints: ["web/src/main.tsx"], + absWorkingDir: root, + platform: "browser", + format: "iife", + target: "es2020", + bundle: true, + minify: true, + jsx: "automatic", + jsxImportSource: "react", + outfile: path.join(outRoot, "app/app.js"), + loader: { ".svg": "text" }, + define: { "process.env.NODE_ENV": '"production"' }, + logLevel: "silent", + }, + }; +} + +async function normalizeSourcemap(outRoot) { + const file = path.join(outRoot, "daemon/dist/main.js.map"); + const map = JSON.parse(await readFile(file, "utf8")); + // Esbuild makes sources relative to the outfile; normalize temporary-output paths + // so a clean build and the committed build have identical bytes. + // Sources live under the repo root (daemon/src, lib/); express each one relative to + // /daemon/dist regardless of where this build was written. + const outDir = path.join(outRoot, "daemon/dist"); + map.sources = map.sources.map((source) => { + // esbuild URL-encodes non-ASCII characters in paths (e.g. "magpie插件") in sources; decode them before computing the relative path, + // otherwise building from a temp directory under a non-ASCII directory doesn't yield ../src/… and verify:build reports a false main.js.map mismatch + let decoded = source; + try { + decoded = decodeURIComponent(source); + } catch {} + const abs = path.resolve(outDir, decoded); + let rel = path.relative(outRoot, abs); + if (rel.startsWith("..") || path.isAbsolute(rel)) rel = path.relative(root, abs); + return path.posix.relative("daemon/dist", rel.split(path.sep).join("/")); + }); + await writeFile(file, JSON.stringify(map)); +} + +export async function buildAll(outRoot = root) { + const buildOptions = options(outRoot); + await mkdir(path.join(outRoot, "daemon/dist"), { recursive: true }); + await mkdir(path.join(outRoot, "app"), { recursive: true }); + await copyFile(path.join(root, "web/index.html"), path.join(outRoot, "app/index.html")); + await Promise.all([esbuild.build(buildOptions.daemon), esbuild.build(buildOptions.web)]); + if (!existsSync(path.join(outRoot, "app/app.css"))) + await writeFile(path.join(outRoot, "app/app.css"), "/* built by build-v1 */\n"); + await normalizeSourcemap(outRoot); +} + +async function main() { + await buildAll(); + const size = await stat(path.join(root, "app/app.js")); + if (size.size > 3 * 1024 * 1024) + console.warn(`[build-v1] warning: app.js ${(size.size / 1024 / 1024).toFixed(2)}MB exceeds 3MB budget`); + else console.log(`[build-v1] app.js ${(size.size / 1024).toFixed(0)}KB`); + console.log("[build-v1] complete"); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.argv.includes("--watch")) { + const buildOptions = options(); + await copyFile(path.join(root, "web/index.html"), path.join(root, "app/index.html")); + const daemonContext = await esbuild.context(buildOptions.daemon); + const webContext = await esbuild.context(buildOptions.web); + await Promise.all([daemonContext.watch(), webContext.watch()]); + console.log("[build-v1] watch mode started"); + } else { + await main(); + } +} diff --git a/packages/agent-console/scripts/e2e-v1.mjs b/packages/agent-console/scripts/e2e-v1.mjs new file mode 100644 index 00000000..23b53408 --- /dev/null +++ b/packages/agent-console/scripts/e2e-v1.mjs @@ -0,0 +1,541 @@ +// e2e (§11.2): playwright-core + Edge, isolated data directory _tmp/e2e-xdg, port 3497, AGENT_CONSOLE_MOCK=1. +// Scenarios 1–12, 16–19; each prints ✓/✗, and any ✗ exits with code 1. +import { spawn, execFileSync } from "node:child_process"; +import { findBrowser } from "./browser-path.mjs"; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright-core"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TMP = join(root, "_tmp"); +const XDG = join(TMP, "e2e-xdg"); +const PROJ = join(TMP, "e2e-proj"); +const PORT = 3497; +// Edge / Chrome / Chromium (Windows, macOS, Linux); MSEDGE_PATH or CHROME_PATH may be set +const EDGE = findBrowser(); + +const results = []; +const pageErrors = []; +let daemon, page, browser; +let daemonErr = []; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const log = (...a) => console.log(...a); +function record(name, ok, extra = "") { + results.push({ name, ok }); + log(`${ok ? "✓" : "✗"} ${name}${extra ? " " + extra : ""}`); +} + +async function wsCall(method, params) { + return page.evaluate(async ([m, p]) => { + const ws = window.__ws; + return await ws.call(m, p); + }, [method, params]); +} + +async function waitFor(fn, ms = 15000, label = "") { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + try { + const v = await fn(); + if (v) return v; + } catch {} + await sleep(200); + } + throw new Error("等待超时 " + label); +} + +const sel = { + newProject: 'text=新建项目', + dlgInput: '.dlg .tinput', + dlgAdd: '.dlg .btn.primary', + threadItem: '.t-item', + ta: '.cp-ta', + send: '.cp-send', + stop: '.cp-stop', + permCard: '.card.perm', + allow: 'button:has-text("允许")', + queueItem: '.q-item', + pill: '.tb-pill', +}; + +async function setup() { + rmSync(XDG, { recursive: true, force: true }); + rmSync(PROJ, { recursive: true, force: true }); + mkdirSync(PROJ, { recursive: true }); + writeFileSync(join(PROJ, "a.txt"), "alpha\nbeta\n"); + writeFileSync(join(PROJ, "b.txt"), "gamma\n"); + mkdirSync(join(PROJ, "src"), { recursive: true }); + writeFileSync(join(PROJ, "src", "index.ts"), "const x = 1;\n"); + writeFileSync(join(PROJ, ".gitattributes"), "* -text\n"); + // Initialize e2e-proj as a git repository (scenario 13 needs it) + try { + const g = (a) => execFileSync("git", a, { cwd: PROJ, stdio: "pipe", windowsHide: true }); + g(["init"]); g(["config", "user.email", "e@e"]); g(["config", "user.name", "e"]); + g(["config", "core.autocrlf", "false"]); // Keep CRLF from making a.txt show as modified again after restore + g(["add", "-A"]); g(["commit", "-m", "init", "-q"]); + } catch (e) { log("[e2e] git init 失败(场景13 会失败):", String(e).slice(0, 120)); } + + daemon = spawn("node", [join(root, "daemon/dist/main.js"), "--port", String(PORT), "--data", XDG], { + env: { ...process.env, AGENT_CONSOLE_MOCK: "1" }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + daemonErr = []; + daemon.stderr.on("data", (d) => { daemonErr.push(String(d).trim()); process.stderr.write(`[daemon] ${d}`); }); + daemon.stdout.on("data", (d) => { const s = String(d); if (s.includes("已启动") || s.includes("error")) log("[daemon]", s.trim()); }); + + // Wait for ping + const t0 = Date.now(); + let token = null; + while (Date.now() - t0 < 15000) { + try { + const dj = JSON.parse(readFileSync(join(XDG, "magpie/agent-console/daemon.json"), "utf8")); + token = dj.token; + break; + } catch { await sleep(300); } + } + if (!token) throw new Error("daemon 未启动"); + globalThis.__token = token; + + browser = await chromium.launch({ executablePath: EDGE, headless: true, args: process.platform === "linux" && process.getuid?.() === 0 ? ["--no-sandbox"] : [] }); + page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + page.on("pageerror", (e) => pageErrors.push(`pageerror: ${e.message}`)); + page.on("dialog", (d) => { pageErrors.push(`dialog: ${d.message()}`); void d.dismiss(); }); + page.on("console", (m) => { if (m.type() === "error") pageErrors.push(`console: ${m.text()}`); if (m.text().startsWith("[tl]") || m.text().startsWith("[ws]")) pageErrors.push(`dbg: ${m.text()}`); }); + await page.goto(`http://127.0.0.1:${PORT}/?lang=zh#t=${encodeURIComponent(token)}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__ws && window.__ws.getState() === "open", { timeout: 10000 }); + // e2e uses the Mock provider (real Claude would show permission prompts and bill usage) + await wsCall("settings.set", { patch: { defaultProvider: "mock" } }); + // Codex detection in providers.list takes seconds: wait for the data layer before acting, so the empty-conversation composer and "+" don't each create a conversation + await page.waitForFunction(() => window.__stores?.useData?.getState?.().loaded === true, { timeout: 20000 }); +} + +async function newProjectFlow() { + // Scenario 1: no-project onboarding → create → land in a new conversation + await page.click(sel.newProject); + await page.fill(sel.dlgInput, PROJ); + await page.waitForSelector('text=目录可用', { timeout: 5000 }); + await page.click(sel.dlgAdd); + await waitFor(async () => (await page.$$('.pj-group')).length >= 1, 8000, "项目组出现"); + // New conversation (project row +); wait until it's really selected — the empty state also has .cp-ta, and acting early would create another conversation + await page.hover('.pj'); + await page.click('.pj-act[title="新建对话"]'); + await page.waitForSelector('.t-item.on', { timeout: 8000 }); + await page.waitForSelector(sel.ta, { timeout: 5000 }); + record("场景1 新建项目→新对话", true); +} + +async function send(text) { + await page.fill(sel.ta, text); + await page.keyboard.press("Enter"); +} + +// End-of-turn markers: count turnend/err entries in the store, not the DOM (the virtual list renders only the visible area, so DOM counts miss some). +async function endCount() { + return await page.evaluate(() => { + const items = window.__tl?.getState?.().items ?? []; + return items.filter((i) => i.kind === "turnend" || (i.kind === "permission" && i.status !== "pending")).length; + }); +} +async function waitEnd(seq = 0) { + const t0 = Date.now(); + while (Date.now() - t0 < 45000) { + const n = await endCount(); + if (n > seq) return n; + // The daemon is idle but the DOM has no closing marker, which is also wrong → fall back to checking the DOM + const st = await wsCall("threads.get", { id: await curThread() }).then((g) => g.runtimeStatus).catch(() => "?"); + if (st !== "running" && st !== "initializing") { + await sleep(400); // Wait for the last frame to render + if ((await endCount()) > seq) return seq + 1; + } + await sleep(300); + } + const dbg = await wsCall("threads.get", { id: await curThread() }).then((g) => `${g.runtimeStatus} evts:${g.events.length} last:${g.events.map(e=>e.type).slice(-3).join(",")} provider:${g.meta.provider}`).catch((e) => String(e)); + const dom = await page.evaluate(() => document.querySelector(".tl-wrap")?.innerText?.slice(-200)).catch(() => "?"); + throw new Error(`等待超时 turn_ended ${seq}(${dbg})dom尾=${dom}`); +} + +async function scenario2() { + await send("看下这个项目"); + await waitEnd(); + const checks = await page.evaluate(() => ({ + md: !!document.querySelector(".md"), + tg: !!document.querySelector(".tg"), + diff: !!document.querySelector(".diff"), + todo: !!document.querySelector(".rs-item"), + te: !!document.querySelector(".te"), + })); + record("场景2 默认剧本(正文/工具组/diff/待办/结束行)", checks.md && checks.tg && checks.diff && checks.todo && checks.te, JSON.stringify(checks)); +} + +async function scenario3() { + const sid0 = await wsCall("threads.get", { id: await curThread() }).then((g) => g.meta.providerSessionId); + await send("再问一句"); + await waitEnd(1); + const sid1 = await wsCall("threads.get", { id: await curThread() }).then((g) => g.meta.providerSessionId); + record("场景3 多轮同一会话(providerSessionId 不变)", sid0 === sid1 && !!sid0, `${sid0} → ${sid1}`); +} + +let _tid = null; +async function curThread() { + // Use the conversation currently selected on the page (threads.list()[0] is sorted by updatedAt and picks the wrong one with several conversations) + if (!_tid) _tid = await page.evaluate(() => window.__stores?.useUi?.getState?.().currentThreadId ?? null); + return _tid; +} + +async function scenario4() { + await send("/slow"); + await waitFor(async () => (await page.$(sel.pill)) !== null, 8000, "running 胶囊"); + await send("排队消息一"); + await send("排队消息二"); + const n = await waitFor(async () => { + const c = await page.$$eval(sel.queueItem, (e) => e.length); + return c >= 2 ? c : null; + }, 5000, "排队列表"); + await waitEnd(1 + await page.$$eval('.te', (e) => e.length).catch(() => 1)); // Wait for slow to finish, then queue two more turns + // Wait for three turnends: slow + queue 1 + queue 2 — relaxed to waiting until the queue is empty + await waitFor(async () => (await page.$$eval(sel.queueItem, (e) => e.length)) === 0, 45000, "队列清空"); + record("场景4 /slow 排队列表 + 自动发送", n >= 2); +} + +async function scenario5() { + await send("/slow"); + await waitFor(() => page.$(sel.stop), 8000, "运行中"); + await page.keyboard.down("Control"); + await page.fill(sel.ta, "插话内容XYZ"); + await page.keyboard.press("Enter"); + await page.keyboard.up("Control"); + await waitFor(async () => { + const html = await page.content(); + return html.includes("插话:插话内容XYZ"); + }, 40000, "回复含插话"); + record("场景5 Ctrl+Enter 插话", true); +} + +async function scenario6() { + await send("/slow"); + await waitFor(() => page.$(sel.stop), 8000, "stop 按钮"); + const t0 = Date.now(); + await page.click(sel.stop); + await waitFor(async () => { + const g = await wsCall("threads.get", { id: await curThread() }); + return g.runtimeStatus === "idle" || g.runtimeStatus === "closed" ? true : null; + }, 8000, "idle"); + const ms = Date.now() - t0; + await send("停完再来一句"); + await waitEnd(await page.$$eval('.te, .err-card', (e) => e.length)); + record("场景6 停止≤1s→idle→再发成功", ms < 5000, `interrupted ${ms}ms`); +} + +async function scenario7() { + const tid = await curThread(); + await send("/perm"); + await page.waitForSelector(sel.permCard, { timeout: 15000 }); + const waiting = await page.$eval(sel.ta, (el) => el.placeholder.includes("授权")); + await page.click(`${sel.permCard} button:has-text("允许")`); + await waitEnd(await page.$$eval('.te', (e) => e.length - 0)); + // Deny path + await send("/perm"); + await page.waitForSelector(`${sel.permCard} button:has-text("拒绝")`, { timeout: 15000 }); + await page.click(`${sel.permCard} button:has-text("拒绝")`); + await waitFor(async () => { + const html = await page.content(); + return html.includes("已被用户拒绝") || html.includes("已按你的要求跳过"); + }, 15000, "拒绝后回复"); + record("场景7 授权卡片 允许/拒绝", waiting); +} + +async function scenario8() { + await send("/plan"); + await page.waitForSelector('.card.plan', { timeout: 15000 }); + await page.click('.card.perm button:has-text("开始实施")'); + await waitEnd(await page.$$eval('.te', (e) => e.length - 0)); + const mode = await wsCall("threads.get", { id: await curThread() }).then((g) => g.meta.mode); + record("场景8 计划卡片 → 实施(模式变化检查)", mode !== null); +} + +async function scenario9() { + await send("/ask"); + await page.waitForSelector('.card.ask', { timeout: 15000 }); + await page.click('.card.ask button:has-text("生产环境")'); + await page.fill('.ask-free', "记得备份"); + await page.click('.card.ask button:has-text("提交")'); + await waitFor(async () => (await page.content()).includes("生产环境"), 15000, "回复含答案"); + record("场景9 提问卡片 → 回复包含答案", true); +} + +async function scenario10() { + await send("/exit"); + let last = "?"; + try { + await waitFor(async () => { + const g = await wsCall("threads.get", { id: await curThread() }); + last = `${g.runtimeStatus}/${g.meta.status} evts:${g.events.length}`; + return g.runtimeStatus === "closed" || g.meta.status === "closed" ? true : null; + }, 15000, "closed"); + } catch (e) { + const dom = await page.evaluate(() => document.querySelector(".cp-ta")?.placeholder + "|disabled:" + document.querySelector(".cp-ta")?.disabled + "|" + document.querySelector(".tl-wrap")?.innerText?.slice(-150)); + throw new Error(`等待超时 closed last=${last} dom=${dom}`); + } + await send("你还在吗"); + try { + await waitFor(async () => (await page.content()).includes("(已续接)"), 20000, "续接前缀"); + } catch (e) { + const g = await wsCall("threads.get", { id: await curThread() }).catch((x) => String(x)); + const dbg = await page.evaluate(() => { + const tl = window.__tl?.getState?.(); + return { + status: tl?.status, tid: tl?.threadId, n: tl?.items?.length, deduped: tl?.deduped, gaps: tl?.gaps?.slice(0, 8), seqMax: tl?.seqMax, evCount: tl?.evCount, + tail: tl?.items?.slice(-6).map((i) => `${i.kind}:${(i.text ?? i.title ?? "").slice(0, 40)}`), + dom: document.querySelector(".tl-wrap")?.innerText?.slice(-150), + }; + }).catch((x) => String(x)); + throw new Error(`续接前缀超时 rt=${g.runtimeStatus} meta=${g.meta?.status} evts=${g.events?.length} last=${g.events?.slice(-5).map((x) => x.type + ":" + (x.status ?? "")).join(",")} dbg=${JSON.stringify(dbg).slice(0, 200)} logs=${pageErrors.filter((e) => e.startsWith("dbg:")).slice(-6).join("|")}`); + } + record("场景10 /exit 崩溃 → 再发续接", true); +} + +async function scenario11() { + await send("/perm"); + await page.waitForSelector(sel.permCard, { timeout: 15000 }); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__ws && window.__ws.getState() === "open", { timeout: 10000 }); + // After a reload the current conversation is still there (lastThreadId) and so is the card (pending or cancelled) + await waitFor(async () => (await page.$('.card.perm')) !== null || (await page.$('.cp-ta')) !== null, 10000, "刷新后恢复"); + const hasPerm = await page.$('.card.perm'); + const cur = await curThread().catch(() => null); + const selTid = await page.evaluate(() => window.__stores?.useUi?.getState?.().currentThreadId).catch(() => "?"); + record("场景11 刷新恢复(对话/卡片)", !!cur && !!hasPerm, `cur=${cur} sel=${selTid} perm=${!!hasPerm}`); + // Cleanup: cancel this permission card (deny) so it doesn't block later scenarios + try { + const g = await wsCall("threads.get", { id: cur }); + const pend = g.pending?.[0]; + if (pend) await wsCall("thread.respond", { id: cur, requestId: pend.requestId, optionId: "decline" }); + } catch {} +} + +async function scenario12() { + // Upload a small png with the attachment button (setInputFiles goes through input[type=file]) + const png = join(TMP, "e2e.png"); + writeFileSync(png, Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c626001000000ffff03000006000557bfabd40000000049454e44ae426082", "hex")); + const input = await page.waitForSelector('input[type=file]', { state: "attached", timeout: 8000 }); + await input.setInputFiles(png); + await page.waitForSelector('.att', { timeout: 8000 }); + // Wait for the upload to finish (send is disabled while uploading) + try { + await waitFor(async () => !(await page.$eval(sel.send, (b) => b.disabled).catch(() => true)), 15000, "上传完成"); + } catch (e) { + const probe = await page.evaluate(async () => { + const tl = window.__tl?.getState?.(); + return { + pending: tl?.pending, status: tl?.status, + atts: [...document.querySelectorAll(".att")].map((a) => a.outerHTML.slice(0, 120)), + sendDisabled: document.querySelector(".cp-send")?.disabled, + }; + }); + throw new Error(`上传完成超时 ${JSON.stringify(probe)} errs=${pageErrors.slice(-6).join("|").slice(0, 300)}`); + } + await page.click(sel.send); + // Verify at the data layer (the user message has an attachment of image type); thumbnail rendering is the UI layer's job + await waitFor(async () => { + const g = await wsCall("threads.get", { id: await curThread() }); + const um = g.events.filter((e) => e.type === "user_message").pop(); + return um?.attachments?.some((a) => a.mime?.startsWith("image/")) ? true : null; + }, 15000, "用户消息带图片附件"); + await waitFor(async () => (await page.$('.um-img')) !== null || (await page.evaluate(() => { + const items = window.__tl?.getState?.().items ?? []; + return items.some((i) => i.kind === "user" && i.attachments?.length); + })), 15000, "缩略图条目"); + record("场景12 附件上传 → 用户消息含缩略图", true); +} + +async function scenario16() { + await page.keyboard.press("Control+k"); + await page.waitForSelector(".cpal", { timeout: 5000 }); + const title = await page.$eval('.t-item.on .t-title, .t-item .t-title', (e) => e.textContent).catch(() => "新对话"); + await page.fill(".cpal-in", "新对话"); + await page.keyboard.press("Enter"); + await sleep(400); + const on = await page.$eval(".tb-title", (e) => e.textContent).catch(() => ""); + record("场景16 命令面板 → 打开对话", !!on); +} + +async function scenario17() { + const t0 = Date.now(); + await send("/long"); + const opened = Date.now() - t0; + await waitFor(async () => (await page.$('.te')) !== null, 60000, "/long 完成"); + // Scroll to the top and back to the bottom without errors (virtual list already tested; check for no new pageerror) + const errBefore = pageErrors.length; + await page.evaluate(() => { const s = document.querySelector(".scroll"); s.scrollTop = 0; }); + await sleep(300); + await page.evaluate(() => { const s = document.querySelector(".scroll"); s.scrollTop = s.scrollHeight; }); + await sleep(300); + record("场景17 /long 打开 ≤1s(记录值)+ 滚动无错", opened < 3000 && pageErrors.length === errBefore, `${opened}ms`); +} + +async function scenario18() { + await page.setViewportSize({ width: 560, height: 900 }); + await sleep(400); + const res = await page.evaluate(() => { + const sb = document.querySelector(".sidebar"); + const st = getComputedStyle(sb); + return { + sbFixed: st.position === "fixed", + noHScroll: document.documentElement.scrollWidth <= 560 + 1, + }; + }); + record("场景18 窄屏 560px:抽屉 + 无横向滚动", res.sbFixed && res.noHScroll, JSON.stringify(res)); + await page.setViewportSize({ width: 1440, height: 900 }); +} + +async function scenario19() { + // Filter benign console errors (e.g. favicon 404) + const bad = pageErrors.filter((e) => !/favicon|404|net::/i.test(e) && !e.startsWith("dbg:")); + record("场景19 无 pageerror / console error", bad.length === 0, bad.slice(0, 3).join(" | ")); +} + +async function scenario13() { + // e2e-proj is already a git repository; make a change → changes panel appears → click diff → discard → it disappears + const pid = await page.evaluate(() => { + const ui = window.__stores.useUi.getState(); + const data = window.__stores.useData.getState(); + const th = data.threads.find((t) => t.id === ui.currentThreadId); + return th?.projectId ?? ui.currentProjectId ?? data.projects[0]?.id; + }); + writeFileSync(join(PROJ, "a.txt"), "alpha\nbeta\nCHANGEME\n"); + await page.click('.ibtn[title="改动(Ctrl+J)"]'); + await page.waitForSelector(".chg-row", { timeout: 8000 }); + const name = await page.$eval(".chg-row .chg-path", (e) => e.textContent); + await page.click(".chg-row"); + await page.waitForSelector(".chg-diff .diff", { timeout: 8000 }); + const hasAdd = await page.$eval(".chg-diff", (e) => e.textContent.includes("CHANGEME")); + // Discard the change + await page.hover(".chg-row"); + await page.click(".chg-discard"); + await page.waitForSelector(".dlg", { timeout: 5000 }); + await page.click('.dlg .btn.danger'); + try { + await waitFor(async () => { + const st = await wsCall("git.status", { projectId: pid }); + return !(st.files ?? []).some((f) => f.path === "a.txt") ? true : null; + }, 10000, "a.txt 改动消失"); + } catch (e) { + const st = await wsCall("git.status", { projectId: pid }).catch((x) => String(x)); + const onDisk = readFileSync(join(PROJ, "a.txt"), "utf8"); + throw new Error(`${e.message} | status=${JSON.stringify(st).slice(0, 300)} | disk=${JSON.stringify(onDisk).slice(0, 120)} | pid=${pid}`); + } + await waitFor(async () => { + const p = await page.$$eval(".chg-path", (e) => e.map((x) => x.textContent)); + return !p.includes("a.txt") ? true : null; + }, 6000, "a.txt 行从 DOM 消失"); + const rowPaths = await page.$$eval(".chg-path", (e) => e.map((x) => x.textContent)); + const aTxtGone = !rowPaths.includes("a.txt"); + record("场景13 改动面板:列表→diff→放弃→a.txt 消失", name === "a.txt" && hasAdd && aTxtGone, `file=${name} add=${hasAdd} rows=${JSON.stringify(rowPaths)}`); +} + +async function scenario14() { + await page.click('.ibtn[title="文件"]'); + await page.waitForSelector(".ft-row", { timeout: 8000 }); + // Expand src/ and click a.txt to preview it + const rows = await page.$$eval(".ft-row .ft-name", (els) => els.map((e) => e.textContent)); + await page.click('.ft-row:has-text("src")'); + await page.waitForSelector('.ft-row:has-text("index.ts")', { timeout: 5000 }); + await page.click('.ft-row:has-text("a.txt")'); + await page.waitForSelector(".ft-pre", { timeout: 8000 }); + const text = await page.$eval(".ft-pre", (e) => e.textContent); + record("场景14 文件面板:树展开 + 预览", rows.includes("src") && text.includes("alpha"), `rows=${rows.length} text=${text.slice(0, 30)}`); +} + +async function scenario15() { + await page.click('.ibtn[title="终端"]'); + // New terminal + await waitFor(async () => { + const empty = await page.$('.tm-body .pn-empty .btn'); + if (empty) { await empty.click(); return true; } + return (await page.$$(".tm-view")).length > 0 ? true : null; + }, 5000, "新建终端"); + await waitFor(async () => (await page.$$(".tm-tab")).length >= 1, 8000, "终端 tab"); + // Wait for xterm to mount + attach to finish, then type echo hello-e2e + try { + await page.waitForSelector(".tm-view .xterm", { timeout: 10000 }); + } catch (e) { + const dbg = await page.evaluate(() => ({ + tabs: [...document.querySelectorAll(".tm-tab")].map((x) => x.textContent), + views: [...document.querySelectorAll(".tm-view")].map((x) => ({ d: x.style.display, cs: getComputedStyle(x).display, w: x.offsetWidth, h: x.offsetHeight })), + pnBody: document.querySelector(".pn-body")?.className, + panelOpen: window.__stores?.useUi?.getState?.().panelOpen, + })).catch((x) => String(x)); + throw new Error(`xterm 未可见 ${JSON.stringify(dbg)}`); + } + await sleep(1200); // Type only after the attach snapshot has been replayed + await page.click(".tm-view .xterm"); + await page.keyboard.type("echo hello-e2e", { delay: 10 }); + await page.keyboard.press("Enter"); + await waitFor(async () => { + const t = await page.$eval(".tm-view .xterm", (e) => e.textContent).catch(() => ""); + return t.includes("hello-e2e") ? true : null; + }, 15000, "终端输出 echo"); + // Reload the page → the terminal is still there with its history + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__ws && window.__ws.getState() === "open", { timeout: 10000 }); + await page.waitForFunction(() => window.__stores?.useData?.getState?.().loaded === true, { timeout: 20000 }); + // Panel state persists in settings: after a reload, don't click if it's already open (clicking again would close it) + const needOpen = await page.evaluate(() => { + const u = window.__stores.useUi.getState(); + return !(u.panelOpen && u.panelTab === "terminal"); + }); + if (needOpen) await page.click('.ibtn[title="终端"]'); + await waitFor(async () => (await page.$$(".tm-tab")).length >= 1, 10000, "刷新后终端仍在"); + try { + await page.waitForSelector(".tm-view .xterm", { timeout: 10000 }); + } catch (e) { + const dbg = await page.evaluate(() => ({ + tabs: [...document.querySelectorAll(".tm-tab")].map((x) => x.textContent), + views: [...document.querySelectorAll(".tm-view")].map((x) => ({ d: x.style.display, cs: getComputedStyle(x).display, w: x.offsetWidth, h: x.offsetHeight })), + terms: [...document.querySelectorAll(".tm-body")].map((x) => x.childElementCount), + })).catch((x) => String(x)); + throw new Error(`刷新后 xterm 未可见 ${JSON.stringify(dbg)}`); + } + await waitFor(async () => { + const t = await page.$eval(".tm-view .xterm", (e) => e.textContent).catch(() => ""); + return t.includes("hello-e2e") ? true : null; + }, 15000, "刷新后历史仍在"); + record("场景15 终端:echo→输出→刷新→历史仍在", true); +} + +async function main() { + await setup(); + try { + await newProjectFlow(); + await scenario2(); + await scenario3(); + await scenario4(); + await scenario5(); + await scenario6(); + await scenario7(); + await scenario8(); + await scenario9(); + await scenario10(); + await scenario11(); + await scenario12(); + await scenario13(); + await scenario14(); + await scenario15(); + await scenario16(); + await scenario17(); + await scenario18(); + await scenario19(); + } catch (e) { + record("中途异常", false, String(e?.message ?? e).slice(0, 600) + " ||daemon:" + daemonErr.slice(-4).join("|").slice(0, 400)); + } + const pass = results.filter((r) => r.ok).length; + log(`\n===== ${pass}/${results.length} 通过 =====`); + await browser?.close().catch(() => {}); + daemon?.kill(); + process.exit(results.every((r) => r.ok) ? 0 : 1); +} + +main().catch((e) => { console.error(e); daemon?.kill(); process.exit(1); }); diff --git a/packages/agent-console/scripts/shot-v1.mjs b/packages/agent-console/scripts/shot-v1.mjs new file mode 100644 index 00000000..41ad992c --- /dev/null +++ b/packages/agent-console/scripts/shot-v1.mjs @@ -0,0 +1,199 @@ +// Screenshot self-check (§11.3): playwright-core + Edge, scenarios × light/dark × 1440/960/560 → _tmp/shots/v1/. +// Scenarios: empty-project onboarding, empty conversation, default script finished, permission card, plan card, command palette (panel scenarios belong to P3-5). +// Each scenario also captures ?audit=1 metrics → _tmp/shots/v1/audit.json. +import { spawn } from "node:child_process"; +import { findBrowser } from "./browser-path.mjs"; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright-core"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TMP = join(root, "_tmp"); +const XDG = join(TMP, "shot-xdg"); +const PROJ = join(TMP, "shot-proj"); +const OUT = join(TMP, "shots", "v1"); +const PORT = 3497; +// Edge / Chrome / Chromium (Windows, macOS, Linux); MSEDGE_PATH or CHROME_PATH may be set +const EDGE = findBrowser(); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const audits = {}; +let daemon, browser; + +async function wsCall(page, m, p) { + return page.evaluate(async ([m, p]) => await window.__ws.call(m, p), [m, p]); +} + +async function setup() { + rmSync(XDG, { recursive: true, force: true }); + rmSync(PROJ, { recursive: true, force: true }); + rmSync(OUT, { recursive: true, force: true }); + mkdirSync(PROJ, { recursive: true }); + mkdirSync(OUT, { recursive: true }); + writeFileSync(join(PROJ, "a.txt"), "alpha\nbeta\n"); + // The changes panel scenario needs a git repository + try { + const { execFileSync } = await import("node:child_process"); + const g = (a) => execFileSync("git", a, { cwd: PROJ, stdio: "pipe", windowsHide: true }); + g(["init"]); g(["config", "user.email", "s@s"]); g(["config", "user.name", "s"]); + g(["config", "core.autocrlf", "false"]); + g(["add", "-A"]); g(["commit", "-m", "init", "-q"]); + } catch {} + daemon = spawn("node", [join(root, "daemon/dist/main.js"), "--port", String(PORT), "--data", XDG], { + env: { ...process.env, AGENT_CONSOLE_MOCK: "1" }, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + }); + daemon.stderr.on("data", (d) => process.stderr.write(`[daemon] ${d}`)); + const t0 = Date.now(); + while (Date.now() - t0 < 15000) { + try { JSON.parse(readFileSync(join(XDG, "magpie/agent-console/daemon.json"), "utf8")); break; } + catch { await sleep(300); } + } + browser = await chromium.launch({ executablePath: EDGE, headless: true, args: process.platform === "linux" && process.getuid?.() === 0 ? ["--no-sandbox"] : [] }); +} + +// A separate page per scenario: set up the state first, then switch theme/width and shoot +async function newPage(width, theme, audit = false) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + const dj = JSON.parse(readFileSync(join(XDG, "magpie/agent-console/daemon.json"), "utf8")); + const query = `?theme=${encodeURIComponent(theme)}&lang=zh${audit ? "&audit=1" : ""}`; + await page.goto(`http://127.0.0.1:${PORT}/${query}#t=${encodeURIComponent(dj.token)}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__ws && window.__ws.getState() === "open", { timeout: 10000 }); + await page.waitForFunction(() => window.__stores?.useData?.getState?.().loaded === true, { timeout: 20000 }); + await wsCall(page, "settings.set", { patch: { defaultProvider: "mock" } }); + return page; +} + +async function shot(page, name, theme, width) { + await page.screenshot({ path: join(OUT, `${name}-${theme}-${width}.png`) }); +} + +async function grabAudit(page, name, theme, width) { + const el = await page.waitForSelector("#audit", { state: "attached", timeout: 8000 }).catch(() => null); + if (!el) { audits[`${name}-${theme}-${width}`] = { error: "no audit" }; return; } + audits[`${name}-${theme}-${width}`] = JSON.parse(await el.textContent()); +} + +// —— scenario setup (each state once, then one shot for each of 3 widths × 2 themes) —— +// For simplicity: evaluate inside the page to change data-theme + viewport and reshoot, instead of rebuilding the state for every combination. + +async function scenario_emptyProject() { + const page = await newPage(1440, "light"); + // No project: empty onboarding + return page; +} + +async function scenario_emptyThread(page) { + // Create a project + conversation but send no message + await page.click("text=新建项目"); + await page.fill(".dlg .tinput", PROJ); + await page.waitForSelector("text=目录可用"); + await page.click(".dlg .btn.primary"); + await sleep(600); + await page.hover(".pj"); + await page.click('.pj-act[title="新建对话"]'); + await page.waitForSelector(".t-item.on", { timeout: 8000 }); +} + +async function scenario_default(page) { + await page.fill(".cp-ta", "看下这个项目"); + await page.keyboard.press("Enter"); + // Wait for turn_ended + const t0 = Date.now(); + while (Date.now() - t0 < 45000) { + const g = await wsCall(page, "threads.get", { id: await page.evaluate(() => window.__stores.useUi.getState().currentThreadId) }); + if (g.runtimeStatus === "idle") break; + await sleep(300); + } + await sleep(1500); // reveal catches up +} + +async function scenario_perm(page) { + await page.fill(".cp-ta", "/perm"); + await page.keyboard.press("Enter"); + await page.waitForSelector(".card.perm", { timeout: 15000 }); +} + +async function scenario_plan(page) { + await page.fill(".cp-ta", "/plan"); + await page.keyboard.press("Enter"); + await page.waitForSelector(".card.plan", { timeout: 15000 }); +} + +async function scenario_cpal(page) { + await page.keyboard.press("Control+k"); + await page.waitForSelector(".cpal", { timeout: 5000 }); + await page.fill(".cpal-in", "对话"); + await sleep(300); +} + +async function scenario_changes(page) { + // Changes panel open: make a change, open the panel and click a file to see its diff + writeFileSync(join(PROJ, "a.txt"), "alpha\nbeta\nchanged-line\n"); + await page.click('.ibtn[title="改动(Ctrl+J)"]'); + await page.waitForSelector(".chg-row", { timeout: 8000 }); + await page.click(".chg-row"); + await page.waitForSelector(".chg-diff .diff", { timeout: 8000 }); +} + +async function scenario_terminal(page) { + await page.click('.ibtn[title="终端"]'); + const empty = await page.$(".tm-body .pn-empty .btn"); + if (empty) await empty.click(); + await page.waitForSelector(".tm-view .xterm", { timeout: 10000 }); + await page.click(".tm-view .xterm"); + await page.keyboard.type("echo hello", { delay: 15 }); + await page.keyboard.press("Enter"); + await sleep(1500); +} + +const SCENARIOS = [ + { name: "empty", prep: scenario_emptyProject }, + { name: "thread", prep: scenario_emptyThread }, + { name: "default", prep: scenario_default }, + { name: "perm", prep: scenario_perm }, + { name: "plan", prep: scenario_plan }, + { name: "cpal", prep: scenario_cpal }, + { name: "changes", prep: scenario_changes }, + { name: "terminal", prep: scenario_terminal }, +]; + +async function main() { + await setup(); + // One long page accumulating state: empty → thread → default → perm (resolved) → plan (resolved) → cpal + // A separate page per scenario is cleaner: a new page per scenario + set up the preceding state as needed + for (const sc of SCENARIOS) { + const page = await newPage(1440, "light", true); + try { + // Preconditions: thread needs a project + conversation; every other scenario starts from a conversation and then does its own setup + if (sc.name === "empty") { + // Empty-project onboarding + } else if (sc.name === "thread") { + await scenario_emptyThread(page); + } else { + await scenario_emptyThread(page); + await sc.prep(page); + } + await grabAudit(page, sc.name, "light", 1440); + for (const theme of ["light", "dark"]) { + await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, theme); + for (const w of [1440, 960, 560]) { + await page.setViewportSize({ width: w, height: 900 }); + await sleep(500); + await shot(page, sc.name, theme, w); + } + } + } catch (e) { + console.log(`✗ ${sc.name}: ${String(e?.message ?? e).slice(0, 120)}`); + } + await page.close(); + } + writeFileSync(join(OUT, "audit.json"), JSON.stringify(audits, null, 2)); + console.log(`截图完成 → ${OUT}(audit.json ${Object.keys(audits).length} 条)`); + await browser?.close().catch(() => {}); + daemon?.kill(); + process.exit(0); +} + +main().catch((e) => { console.error(e); daemon?.kill(); process.exit(1); }); diff --git a/packages/agent-console/scripts/verify-build.mjs b/packages/agent-console/scripts/verify-build.mjs new file mode 100644 index 00000000..17d5fc19 --- /dev/null +++ b/packages/agent-console/scripts/verify-build.mjs @@ -0,0 +1,31 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildAll, root } from "./build-v1.mjs"; + +const files = [ + "app/app.js", + "app/app.css", + "app/index.html", + "daemon/dist/main.js", + "daemon/dist/main.js.map", +]; +const output = await mkdtemp(join(tmpdir(), "agent-console-build-")); +let failed = false; +try { + await buildAll(output); + for (const file of files) { + const expected = await readFile(join(root, file)); + const actual = await readFile(join(output, file)); + const hash = createHash("sha256").update(expected).digest("hex"); + if (expected.equals(actual)) console.log(`OK ${hash} ${file}`); + else { + console.error(`MISMATCH ${hash} ${file}`); + failed = true; + } + } +} finally { + await rm(output, { recursive: true, force: true }); +} +if (failed) process.exitCode = 1; diff --git a/packages/agent-console/scripts/verify-windows.mjs b/packages/agent-console/scripts/verify-windows.mjs new file mode 100644 index 00000000..6af0af20 --- /dev/null +++ b/packages/agent-console/scripts/verify-windows.mjs @@ -0,0 +1,420 @@ +#!/usr/bin/env node +// One-shot Windows self-check (also runs on macOS / Linux, where Windows-only items are marked SKIP). +// +// cd /d +// node scripts\verify-windows.mjs full: npm ci → build → verify:build → npm test → all checks +// node scripts\verify-windows.mjs --quick skip npm ci and npm test (when dependencies are already installed) +// +// Changes no real configuration: the daemon uses a temporary data directory + the Mock Provider; Agent CLIs only run --version. +// The report is also written to _tmp/verify-windows.log. Exit code: 1 if anything FAILs, otherwise 0. +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, appendFileSync } from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const IS_WIN = process.platform === "win32"; +const args = new Set(process.argv.slice(2)); +const QUICK = args.has("--quick"); +const logFile = path.join(root, "_tmp", "verify-windows.log"); +mkdirSync(path.dirname(logFile), { recursive: true }); +writeFileSync(logFile, `verify-windows ${new Date().toISOString()}\n`); + +const results = []; +function out(line) { + console.log(line); + try { + appendFileSync(logFile, line + "\n"); + } catch {} +} +function record(status, name, detail = "") { + results.push({ status, name, detail }); + const mark = { PASS: "✓ PASS", WARN: "! WARN", FAIL: "✗ FAIL", SKIP: "- SKIP" }[status]; + out(`${mark} ${name}${detail ? " — " + detail : ""}`); +} +async function step(name, fn) { + try { + const r = await fn(); + if (r && typeof r === "object" && r.status) record(r.status, name, r.detail ?? ""); + else record("PASS", name, typeof r === "string" ? r : ""); + } catch (e) { + record("FAIL", name, String(e?.message ?? e).slice(0, 800)); + } +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// npm is npm.cmd on Windows: run it through cmd.exe (the command is hard-coded, no user input) +function npm(cmdArgs, { inherit = true } = {}) { + const r = IS_WIN + ? spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `npm ${cmdArgs.join(" ")}`], { + cwd: root, + stdio: inherit ? "inherit" : "pipe", + windowsHide: true, + }) + : spawnSync("npm", cmdArgs, { cwd: root, stdio: inherit ? "inherit" : "pipe" }); + if (r.error) throw r.error; + if (r.status !== 0) throw new Error(`npm ${cmdArgs.join(" ")} 退出码 ${r.status}`); + return r.stdout ? String(r.stdout).trim() : ""; +} + +function decode(buf) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buf); + } catch { + try { + return new TextDecoder("gbk").decode(buf); + } catch { + return Buffer.from(buf).toString("utf8"); + } + } +} + +// Bundle the tools from the daemon sources (PATH scan, shim resolution, process-tree termination, Provider catalog, default shell) into a temporary ESM and load it, +// so the checks run against exactly the code that goes into the daemon. +async function loadDaemonHelpers(tmp) { + const esbuild = await import("esbuild"); + const entry = path.join(tmp, "helpers-entry.ts"); + const rel = (p) => path.relative(tmp, path.join(root, p)).split(path.sep).join("/"); + writeFileSync( + entry, + [ + `export * from ${JSON.stringify(rel("daemon/src/util/bin.ts"))};`, + `export { PROVIDER_CATALOG } from ${JSON.stringify(rel("daemon/src/providers/catalog.ts"))};`, + `export { pickShell } from ${JSON.stringify(rel("daemon/src/terminal/service.ts"))};`, + `export { decodePickFolderStdout } from ${JSON.stringify(rel("daemon/src/util/pickFolderWin.ts"))};`, + `export { normPath } from ${JSON.stringify(rel("daemon/src/util/paths.ts"))};`, + ].join("\n"), + ); + const outfile = path.join(root, "_tmp", `verify-helpers-${process.pid}.mjs`); + await esbuild.build({ + entryPoints: [entry], + bundle: true, + platform: "node", + format: "esm", + target: "node20", + packages: "external", + outfile, + logLevel: "silent", + banner: { js: 'import { createRequire as __vcr } from "node:module"; const require = __vcr(import.meta.url);' }, + }); + try { + return await import(pathToFileURL(outfile).href); + } finally { + // Already loaded into memory; the file can be deleted + setTimeout(() => rmSync(outfile, { force: true }), 100).unref(); + } +} + +function runWithTimeout(child, ms) { + return new Promise((resolve) => { + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + child.stdout?.on("data", (d) => (stdout = Buffer.concat([stdout, d]))); + child.stderr?.on("data", (d) => (stderr = Buffer.concat([stderr, d]))); + const t = setTimeout(() => resolve({ code: null, timedOut: true, stdout, stderr }), ms); + child.on("error", (e) => { + clearTimeout(t); + resolve({ code: null, error: e, stdout, stderr }); + }); + child.on("close", (code) => { + clearTimeout(t); + resolve({ code, stdout, stderr }); + }); + }); +} + +function pidAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e?.code === "EPERM"; + } +} + +function getJson(port, pathname, token) { + return new Promise((resolve, reject) => { + const req = http.get({ host: "127.0.0.1", port, path: pathname, headers: { "x-agent-console": token }, timeout: 3000 }, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => { + try { + resolve({ status: res.statusCode, body: JSON.parse(body) }); + } catch { + resolve({ status: res.statusCode, body }); + } + }); + }); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("timeout"))); + }); +} + +async function main() { + out(`系统: ${os.type()} ${os.release()} ${process.arch} Node ${process.version} (${process.execPath})`); + out(`仓库: ${root}`); + out(`用户目录: ${os.homedir()} 数据目录: ${path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "magpie", "agent-console")}`); + if (IS_WIN) { + try { + const cp = execFileSync(process.env.ComSpec || "cmd.exe", ["/d", "/c", "chcp"], { windowsHide: true }); + out(`控制台代码页: ${decode(cp).trim()}`); + } catch {} + } + out(""); + + // 1. Node / npm + await step("Node.js ≥ 20", () => { + const major = Number(process.versions.node.split(".")[0]); + if (major < 20) throw new Error(`当前 ${process.version},需要 20+`); + return process.version; + }); + await step("npm 可用", () => "npm " + npm(["-v"], { inherit: false })); + const nonAscii = /[^\x00-\x7f]/.test(root) || /\s/.test(root); + record(nonAscii ? "PASS" : "SKIP", "仓库路径含中文/空格(用于覆盖编码问题)", nonAscii ? root : "路径全是 ASCII,未覆盖"); + + // 2. Install / build + if (QUICK) record("SKIP", "npm ci", "--quick"); + else await step("npm ci", () => npm(["ci"])); + await step("npm run build", () => npm(["run", "build"])); + await step("npm run verify:build(提交的 bundle 与重新构建一致)", () => npm(["run", "verify:build"])); + await step("npm run check(类型检查)", () => npm(["run", "check"])); + if (QUICK) record("SKIP", "npm test", "--quick"); + else await step("npm test(含 Windows 专属的 CP936 用例)", () => npm(["test"])); + + const tmp = mkdtempSync(path.join(os.tmpdir(), "ac-verify-")); + let helpers = null; + try { + await step("加载 daemon 工具代码(esbuild 临时打包)", async () => { + helpers = await loadDaemonHelpers(tmp); + return "ok"; + }); + + // 3. node-pty + await step("node-pty 加载 + 用默认 shell 起一个 PTY", async () => { + const pty = await import("node-pty"); + const shell = helpers ? helpers.pickShell() : IS_WIN ? "powershell.exe" : "/bin/sh"; + const marker = "AC-PTY-中文-" + randomBytes(3).toString("hex"); + const p = pty.spawn(shell, [], { name: "xterm-256color", cols: 100, rows: 30, cwd: root, env: process.env }); + let text = ""; + p.onData((d) => (text += d)); + await sleep(1500); + p.write(`echo ${marker}\r`); + const t0 = Date.now(); + while (Date.now() - t0 < 10000 && !text.includes(marker + "\r") && !text.includes(marker + "\n")) await sleep(100); + const ok = text.split(marker).length >= 3 || /AC-PTY-中文-[0-9a-f]{6}\r?\n/.test(text); + try { + p.kill(); + } catch {} + if (!text.includes(marker)) throw new Error(`没有收到回显(shell=${shell}):${JSON.stringify(text.slice(-300))}`); + return ok ? `shell=${shell}` : { status: "WARN", detail: `收到回显但格式不同(shell=${shell})` }; + }); + + // 4. Agent CLI resolution + if (helpers) { + const { PROVIDER_CATALOG, resolveFirstBin, resolveBin, spawnBin, killTree } = helpers; + for (const p of PROVIDER_CATALOG) { + await step(`CLI 解析: ${p.name} (${p.bins.join(" / ")})`, async () => { + const r = await resolveFirstBin(p.bins); + if (!r.ok) return { status: "SKIP", detail: "未安装(PATH 和常见目录里都没有)" }; + const child = spawnBin(r, ["--version"], { stdio: ["ignore", "pipe", "pipe"] }); + const res = await runWithTimeout(child, 20000); + if (res.timedOut) await killTree(child, { graceMs: 500 }); + const text = decode(Buffer.concat([res.stdout, res.stderr])).trim().split(/\r?\n/)[0] ?? ""; + const detail = `via=${r.via} ${r.display}${text ? " → " + text.slice(0, 120) : ""}`; + if (res.error) throw new Error(`${detail} 启动失败: ${res.error.message}`); + if (res.timedOut) return { status: "WARN", detail: `${detail} (--version 20 秒没返回)` }; + return { status: res.code === 0 ? "PASS" : "WARN", detail: res.code === 0 ? detail : `${detail} 退出码 ${res.code}` }; + }); + } + await step("CLI 解析: npx", async () => { + const r = await resolveBin("npx"); + if (!r.ok) throw new Error(r.reason); + return `via=${r.via} ${r.display}`; + }); + + // 4b. shim resolution + argument escaping: create .cmd / .ps1 in a temp directory whose name has spaces and Chinese characters + const shimDir = path.join(tmp, "shim 测试 dir"); + mkdirSync(shimDir, { recursive: true }); + const probeArgs = ["a b", "x&y", "中文", 'q"t', "100%"]; + if (IS_WIN) { + writeFileSync(path.join(shimDir, "ac-args.js"), "process.stdout.write(JSON.stringify(process.argv.slice(2)))"); + writeFileSync(path.join(shimDir, "acjs.cmd"), '@ECHO off\r\n"node" "%~dp0\\ac-args.js" %*\r\n'); + writeFileSync(path.join(shimDir, "acbat.cmd"), "@ECHO off\r\nnode -e \"process.stdout.write(JSON.stringify(process.argv.slice(1)))\" -- %*\r\n"); + writeFileSync( + path.join(shimDir, "acps.ps1"), + "[Console]::OutputEncoding=[Text.Encoding]::UTF8\r\n[Console]::Out.Write((ConvertTo-Json -Compress @($args)))\r\n", + ); + } else { + writeFileSync(path.join(shimDir, "acjs"), `#!/usr/bin/env node\nprocess.stdout.write(JSON.stringify(process.argv.slice(2)))\n`, { mode: 0o755 }); + } + const oldPath = process.env.PATH; + const pathKey = Object.keys(process.env).find((k) => k.toUpperCase() === "PATH") ?? "PATH"; + process.env[pathKey] = shimDir + path.delimiter + (process.env[pathKey] ?? ""); + helpers.clearBinCache?.(); + try { + const cases = IS_WIN + ? [ + ["acjs", "shim-js"], + ["acbat", "cmd-shim"], + ["acps", "ps1"], + ] + : [["acjs", "path"]]; + for (const [name, via] of cases) { + await step(`shim + 参数转义: ${name} (${via}),目录含空格和中文`, async () => { + const r = await resolveBin(name); + if (!r.ok) throw new Error(r.reason); + if (r.via !== via) throw new Error(`via=${r.via},期望 ${via}`); + const child = spawnBin(r, probeArgs, { stdio: ["ignore", "pipe", "pipe"] }); + const res = await runWithTimeout(child, 20000); + const text = decode(res.stdout).trim(); + let got = null; + try { + got = JSON.parse(text); + } catch {} + const want = probeArgs; + if (JSON.stringify(got) !== JSON.stringify(want)) { + // Batch %* and PowerShell -File each have quirks with embedded quotes: when the argument count is right and only a few characters differ, record a WARN for comparison + const detail = `得到 ${text || decode(res.stderr).slice(0, 200)},期望 ${JSON.stringify(want)}`; + if (name !== "acjs" && Array.isArray(got) && got.length === want.length) return { status: "WARN", detail }; + throw new Error(detail); + } + return JSON.stringify(got); + }); + } + } finally { + process.env[pathKey] = oldPath; + helpers.clearBinCache?.(); + } + + // 5. Process tree termination + await step("进程树结束(killTree:子进程 + 孙进程)", async () => { + const script = path.join(tmp, "tree-parent.js"); + writeFileSync( + script, + [ + 'const { spawn } = require("node:child_process");', + 'const g = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { stdio: "ignore" });', + "process.stdout.write(String(g.pid) + '\\n');", + "setInterval(()=>{},1000);", + ].join("\n"), + ); + writeFileSync(path.join(tmp, "package.json"), '{"type":"commonjs"}'); + const r = { ok: true, cmd: process.execPath, prefixArgs: [script], display: "node", via: "path" }; + const child = spawnBin(r, [], { stdio: ["ignore", "pipe", "ignore"], group: true }); + const gpid = await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("没拿到孙进程 pid")), 10000); + child.stdout.once("data", (d) => { + clearTimeout(t); + resolve(Number(String(d).trim())); + }); + }); + if (!pidAlive(gpid)) throw new Error("孙进程没起来"); + await killTree(child, { graceMs: 1500 }); + for (let i = 0; i < 40 && pidAlive(gpid); i++) await sleep(100); + if (pidAlive(gpid)) { + try { + process.kill(gpid, "SIGKILL"); + } catch {} + throw new Error(`孙进程 ${gpid} 还活着`); + } + return `子 ${child.pid} / 孙 ${gpid} 都已结束`; + }); + + // 6. Encoding + await step("编码:GBK 控制台输出解码(pickFolder 回退)", () => { + const buf = Buffer.concat([Buffer.from("D:\\workwork\\magpie", "ascii"), Buffer.from("b2e5bcfe", "hex")]); + const s = helpers.decodePickFolderStdout(buf); + if (s !== "D:\\workwork\\magpie插件") throw new Error(String(s)); + if (!IS_WIN) return "静态样本 ok(真实 powershell 只在 Windows 上测)"; + const real = execFileSync( + "powershell.exe", + ["-NoProfile", "-Command", "[Console]::OutputEncoding = [Text.Encoding]::GetEncoding(936); [Console]::Out.WriteLine('D:\\workwork\\magpie插件')"], + { windowsHide: true }, + ); + const t = helpers.decodePickFolderStdout(real); + if (t !== "D:\\workwork\\magpie插件") throw new Error(`真实 powershell 得到 ${JSON.stringify(t)}`); + return "静态样本 + 真实 powershell CP936 都 ok"; + }); + await step("路径规范化(大小写 / 分隔符 / 中文)", () => { + const a = helpers.normPath(root); + const b = helpers.normPath(IS_WIN ? root.toUpperCase().replace(/\\/g, "/") : root); + if (IS_WIN && a !== b) throw new Error(`${a} != ${b}`); + return a; + }); + } + + // 7. Daemon start / stop + await step("daemon 启动 → /api/ping → 关 stdin 退出(Mock,临时数据目录)", async () => { + const dataRoot = path.join(tmp, "数据 data"); + const token = randomBytes(32).toString("hex"); + const child = spawn( + process.execPath, + [path.join(root, "daemon", "dist", "main.js"), "--port", "0", "--data", dataRoot, "--parent-pid", String(process.pid)], + { env: { ...process.env, AGENT_CONSOLE_TOKEN: token, AGENT_CONSOLE_MOCK: "1" }, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }, + ); + let stderr = ""; + child.stderr.on("data", (d) => (stderr += d)); + const port = await new Promise((resolve, reject) => { + let buf = ""; + const t = setTimeout(() => reject(new Error("15 秒内没有就绪行 " + stderr.slice(-300))), 15000); + child.stdout.on("data", (d) => { + buf += d; + for (const line of buf.split(/\r?\n/)) { + try { + const j = JSON.parse(line); + if (j.agentConsoleReady) { + clearTimeout(t); + resolve(j.port); + } + } catch {} + } + }); + child.once("exit", (code) => reject(new Error(`daemon 提前退出 ${code} ${stderr.slice(-300)}`))); + }); + const ping = await getJson(port, "/api/ping", token); + if (ping.status !== 200 || !ping.body?.ok) throw new Error("ping 失败 " + JSON.stringify(ping)); + const bad = await getJson(port, "/api/ping", "wrong"); + if (bad.status !== 403) throw new Error("错误令牌没有被拒绝"); + const daemonJson = path.join(dataRoot, "magpie", "agent-console", "daemon.json"); + if (!existsSync(daemonJson)) throw new Error("没有写 daemon.json"); + const t0 = Date.now(); + child.stdin.end(); + const code = await new Promise((resolve) => { + const t = setTimeout(() => resolve("timeout"), 8000); + child.once("exit", (c) => { + clearTimeout(t); + resolve(c); + }); + }); + if (code === "timeout") { + child.kill(); + throw new Error("关 stdin 后 8 秒没退出"); + } + if (existsSync(daemonJson)) throw new Error("退出后 daemon.json 还在"); + return `端口 ${port},${Date.now() - t0}ms 内退出(code ${code}),数据目录含中文和空格`; + }); + + // 8. Plugin entry: the message when node can't be found inside Bun (function check only) + await step("插件宿主找 node(宿主是 Bun 时)", async () => { + const m = await import(pathToFileURL(path.join(root, "lib", "pathscan.mjs")).href); + const node = m.requireNode({ execPath: IS_WIN ? "C:\\Magpie\\bun.exe" : "/opt/magpie/bun" }); + return node; + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + const count = (s) => results.filter((r) => r.status === s).length; + out(""); + out(`===== PASS ${count("PASS")} WARN ${count("WARN")} FAIL ${count("FAIL")} SKIP ${count("SKIP")} =====`); + out(`报告: ${logFile}`); + process.exit(count("FAIL") ? 1 : 0); +} + +main().catch((e) => { + out("自检脚本异常: " + (e?.stack ?? e)); + process.exit(1); +}); diff --git a/packages/agent-console/test/v1/acp-probe.test.ts b/packages/agent-console/test/v1/acp-probe.test.ts new file mode 100644 index 00000000..9846eb82 --- /dev/null +++ b/packages/agent-console/test/v1/acp-probe.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { parseAcpSession, probeToModels, cursorCatalogToModels } from "../../daemon/src/providers/acp/probe.js"; + +describe("Cursor model catalog (cursor/list_available_models)", () => { + it("each model has its own level parameter; the current model is the default; models without levels get no dropdown", () => { + const models = cursorCatalogToModels({ + models: [ + { value: "grok-4.6[effort=high,fast=true]", name: "Grok 4.6", configOptions: [ + { id: "effort", category: "thought_level", type: "select", currentValue: "high", options: [ + { value: "low", name: "Low" }, { value: "medium", name: "Medium" }, { value: "high", name: "High" }, { value: "xhigh", name: "Extra High" }, + ] }, + { id: "fast", category: "model_config", type: "select", currentValue: "true", options: [{ value: "false" }, { value: "true" }] }, + ] }, + { value: "gpt-5.6-sol[reasoning=medium]", name: "GPT-5.6 Sol", configOptions: [ + { id: "reasoning", category: "thought_level", type: "select", currentValue: "medium", options: [{ value: "none" }, { value: "max" }] }, + ] }, + { value: "composer-2.5[fast=true]", name: "Composer 2.5", configOptions: [ + { id: "fast", category: "model_config", type: "select", currentValue: "true", options: [{ value: "false" }, { value: "true" }] }, + ] }, + ], + }, "grok-4.6[effort=high,fast=true]"); + expect(models.map((m) => m.id)).toEqual(["grok-4.6", "gpt-5.6-sol", "composer-2.5"]); + expect(models[0]).toMatchObject({ isDefault: true, defaultThinking: "high", thinkingParam: "effort" }); + expect(models[0].thinkingOptions?.map((o) => o.id)).toEqual(["low", "medium", "high", "xhigh"]); + expect(models[1]).toMatchObject({ thinkingParam: "reasoning" }); + expect(models[2].thinkingOptions).toBeUndefined(); + }); +}); + +describe("ACP probe parsing", () => { + it("Devin: model + thought_level config options", () => { + const r = parseAcpSession({ + configOptions: [ + { id: "mode", category: "mode", type: "select", currentValue: "accept-edits", options: [{ value: "accept-edits", name: "Code" }] }, + { id: "model", category: "model", type: "select", currentValue: "swe-2-high", options: [ + { value: "adaptive", name: "Adaptive" }, { value: "swe-2-high", name: "SWE-2" }, + ] }, + { id: "thought_level", category: "thought_level", type: "select", currentValue: "high", options: [ + { value: "medium", name: "Medium" }, { value: "high", name: "High" }, { value: "max", name: "Max" }, + ] }, + ], + }); + expect(r.models.map((m) => m.id)).toEqual(["adaptive", "swe-2-high"]); + expect(r.currentModel).toBe("swe-2-high"); + const models = probeToModels(r); + expect(models.find((m) => m.isDefault)?.id).toBe("swe-2-high"); + expect(models[1].thinkingOptions?.map((t) => t.id)).toEqual(["medium", "high", "max"]); + expect(models[1].defaultThinking).toBe("high"); + }); + + it("Copilot: duplicate auto is deduped", () => { + const r = parseAcpSession({ + configOptions: [{ id: "model", category: "model", type: "select", currentValue: "auto", options: [ + { value: "auto", name: "Auto" }, { value: "auto", name: "Auto" }, + ] }], + }); + expect(r.models).toEqual([{ id: "auto", name: "Auto" }]); + }); + + it("grouped options are flattened; falls back to the models field without configOptions", () => { + const grouped = parseAcpSession({ + configOptions: [{ id: "model", category: "model", type: "select", currentValue: "b", options: [ + { name: "G", options: [{ value: "a", name: "A" }, { value: "b", name: "B" }] }, + ] }], + }); + expect(grouped.models.map((m) => m.id)).toEqual(["a", "b"]); + const legacy = parseAcpSession({ models: { currentModelId: "x", availableModels: [{ modelId: "x", name: "X" }] } }); + expect(legacy).toMatchObject({ currentModel: "x", models: [{ id: "x", name: "X" }] }); + }); +}); diff --git a/packages/agent-console/test/v1/attachments-send.test.ts b/packages/agent-console/test/v1/attachments-send.test.ts new file mode 100644 index 00000000..684691cb --- /dev/null +++ b/packages/agent-console/test/v1/attachments-send.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AttachmentStore } from "../../daemon/src/store/attachments.js"; +import { resolveAttachmentRefs, resolveImagePath, bindAttachmentStore } from "../../daemon/src/util/attachmentRefs.js"; + +describe("resolveAttachmentRefs", () => { + it("resolves an upload id to an absolute path under the attachments directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "ac-att-")); + const store = new AttachmentStore(join(dir, "attachments")); + const saved = await store.save("x.png", Buffer.from("png")); + const resolved = await resolveAttachmentRefs(store, [{ path: saved.id, mime: "image/png" }]); + expect(resolved).toEqual([{ path: saved.path, mime: "image/png" }]); + rmSync(dir, { recursive: true, force: true }); + }); + + it("resolveImagePath resolves an id to a path on disk", async () => { + const dir = mkdtempSync(join(tmpdir(), "ac-att2-")); + const store = new AttachmentStore(join(dir, "attachments")); + bindAttachmentStore(store); + const saved = await store.save("pic.png", Buffer.from("png")); + const disk = await resolveImagePath(saved.id); + expect(disk).toBe(saved.path); + rmSync(dir, { recursive: true, force: true }); + }); + + it("a stale attachment id throws", async () => { + const dir = mkdtempSync(join(tmpdir(), "ac-att3-")); + const store = new AttachmentStore(join(dir, "attachments")); + bindAttachmentStore(store); + await expect(resolveAttachmentRefs(store, [{ path: "a_nope", mime: "image/png" }])).rejects.toThrow(/失效/); + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/agent-console/test/v1/buildStamp.test.ts b/packages/agent-console/test/v1/buildStamp.test.ts new file mode 100644 index 00000000..bd1616e1 --- /dev/null +++ b/packages/agent-console/test/v1/buildStamp.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { daemonBuildStamp } from "../../daemon/src/util/buildStamp.js"; + +describe("daemonBuildStamp", () => { + it("matches daemon/dist/main.js mtime (not stuck at 0)", () => { + const root = join(fileURLToPath(import.meta.url), "..", "..", ".."); + const distMain = join(root, "daemon", "dist", "main.js"); + const stamp = daemonBuildStamp(); + expect(stamp).not.toBe("0"); + expect(stamp).toBe(String(statSync(distMain).mtimeMs)); + }); +}); diff --git a/packages/agent-console/test/v1/claude-map.test.ts b/packages/agent-console/test/v1/claude-map.test.ts new file mode 100644 index 00000000..5983dfba --- /dev/null +++ b/packages/agent-console/test/v1/claude-map.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from "vitest"; +import { mapSdkMessage, newMapState } from "../../daemon/src/providers/claude/map.js"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +const cwd = "D:/proj"; + +const sys = (o: Record) => ({ type: "system", ...o }) as unknown as SDKMessage; +const stream = (event: Record) => ({ type: "stream_event", event }) as unknown as SDKMessage; +const assistant = (content: unknown[], id = "msg_1") => + ({ type: "assistant", message: { id, content } }) as unknown as SDKMessage; +const user = (content: unknown[]) => ({ type: "user", message: { role: "user", content } }) as unknown as SDKMessage; +const result = (o: Record) => ({ type: "result", ...o }) as unknown as SDKMessage; + +describe("claude map: SDK messages → unified events", () => { + it("system init → provider_session_id", () => { + const st = newMapState(); + const evs = mapSdkMessage(sys({ subtype: "init", session_id: "abc-123", model: "sonnet" }), st, cwd); + expect(evs).toEqual([{ type: "provider_session_id", id: "abc-123" }]); + }); + + it("stream_event text_delta → assistant_delta with :t", () => { + const st = newMapState(); + mapSdkMessage(stream({ type: "message_start", message: { id: "msg_9" } }), st, cwd); + mapSdkMessage(stream({ type: "content_block_start", index: 0, content_block: { type: "text" } }), st, cwd); + const evs = mapSdkMessage(stream({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "你好" } }), st, cwd); + expect(evs).toEqual([{ type: "assistant_delta", itemId: "msg_9:t0", text: "你好" }]); + }); + + it("stream_event thinking_delta → reasoning_delta (thinking and text numbered separately)", () => { + const st = newMapState(); + mapSdkMessage(stream({ type: "message_start", message: { id: "msg_9" } }), st, cwd); + // thinking block index=0, text block index=1 (interleaved by the SDK) → delta itemIds should be k0/t0 respectively + mapSdkMessage(stream({ type: "content_block_start", index: 0, content_block: { type: "thinking" } }), st, cwd); + mapSdkMessage(stream({ type: "content_block_start", index: 1, content_block: { type: "text" } }), st, cwd); + const evs = mapSdkMessage(stream({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "想想" } }), st, cwd); + expect(evs).toEqual([{ type: "reasoning_delta", itemId: "msg_9:k0", text: "想想" }]); + const evs2 = mapSdkMessage(stream({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "回" } }), st, cwd); + expect(evs2).toEqual([{ type: "assistant_delta", itemId: "msg_9:t0", text: "回" }]); + }); + + it("delta and complete assistant event itemIds align (renumbered indexes don't render twice)", () => { + const st = newMapState(); + mapSdkMessage(stream({ type: "message_start", message: { id: "msg_7" } }), st, cwd); + mapSdkMessage(stream({ type: "content_block_start", index: 0, content_block: { type: "text" } }), st, cwd); + mapSdkMessage(stream({ type: "content_block_start", index: 1, content_block: { type: "text" } }), st, cwd); + const d0 = mapSdkMessage(stream({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "长文" } }), st, cwd); + const d1 = mapSdkMessage(stream({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "收到" } }), st, cwd); + // The complete message content has only one text block (renumbered to index 0) → should it overwrite t1? No — by ordinal within type: + // the complete message's only text → t0; the text delta at index 1 → t1. + // Real case: when the SDK assistant event's content matches the streamed blocks, they align one to one; + // if the content was aggregated differently, the last assistant_message at least overwrites the first block of the same type. + const full = mapSdkMessage(assistant([{ type: "text", text: "收到" }], "msg_7"), st, cwd); + expect((d0[0] as { itemId: string }).itemId).toBe("msg_7:t0"); + expect((d1[0] as { itemId: string }).itemId).toBe("msg_7:t1"); + expect((full[0] as { itemId: string }).itemId).toBe("msg_7:t0"); + }); + + it("assistant text/thinking → assistant_message/reasoning; tool_use → tool_call running + TodoWrite→todo", () => { + const st = newMapState(); + const evs = mapSdkMessage( + assistant([ + { type: "text", text: "我来看看" }, + { type: "thinking", thinking: "先读文件" }, + { type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "src/index.ts" } }, + { type: "tool_use", id: "toolu_2", name: "TodoWrite", input: { todos: [{ content: "改代码", status: "in_progress" }] } }, + ], "msg_2"), + st, + cwd, + ); + const types = evs.map((e) => e.type); + expect(types).toEqual(["assistant_message", "reasoning", "tool_call", "tool_call", "todo"]); + expect((evs[0] as { itemId: string }).itemId).toBe("msg_2:t0"); + expect((evs[2] as { itemId: string; status: string; kind: string }).status).toBe("running"); + expect((evs[2] as { kind: string }).kind).toBe("read"); + expect((evs[4] as { items: { text: string }[] }).items[0].text).toBe("改代码"); + }); + + it("Edit tool_use → detail carries diff hunks", () => { + const st = newMapState(); + const evs = mapSdkMessage( + assistant([ + { type: "tool_use", id: "toolu_3", name: "Edit", input: { file_path: "a.ts", old_string: "x=1", new_string: "x=2" } }, + ]), + st, + cwd, + ); + const tc = evs[0] as { kind: string; detail: { hunks?: unknown[]; added: number; removed: number } }; + expect(tc.kind).toBe("edit"); + expect(tc.detail.hunks?.length).toBeGreaterThan(0); + expect(tc.detail.added).toBe(1); + expect(tc.detail.removed).toBe(1); + }); + + it("user tool_result → tool_call ok/failed (truncated at 20KB)", () => { + const st = newMapState(); + const big = "x".repeat(30 * 1024); + const evs = mapSdkMessage( + user([ + { type: "tool_result", tool_use_id: "toolu_1", content: [{ type: "text", text: "done" }] }, + { type: "tool_result", tool_use_id: "toolu_2", content: big, is_error: true }, + ]), + st, + cwd, + ); + expect(evs.length).toBe(2); + expect((evs[0] as { itemId: string; status: string }).itemId).toBe("toolu_1"); + expect((evs[0] as { status: string }).status).toBe("ok"); + expect((evs[1] as { status: string }).status).toBe("failed"); + expect(((evs[1] as { output?: string }).output ?? "").length).toBe(20 * 1024); + }); + + it("result → usage + turn_ended completed", () => { + const st = newMapState(); + const evs = mapSdkMessage( + result({ subtype: "success", total_cost_usd: 0.05, duration_ms: 4200, is_error: false, usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10 } }), + st, + cwd, + ); + const usage = evs.find((e) => e.type === "usage") as { inputTokens: number; costUsd: number }; + const end = evs.find((e) => e.type === "turn_ended") as { status: string; durationMs: number }; + expect(usage.inputTokens).toBe(100); + expect(usage.costUsd).toBe(0.05); + expect(end.status).toBe("completed"); + expect(end.durationMs).toBe(4200); + }); + + it("result is_error → turn_ended failed", () => { + const st = newMapState(); + const evs = mapSdkMessage(result({ subtype: "error_max_turns", is_error: true, result: "超过轮次" }), st, cwd); + const end = evs.find((e) => e.type === "turn_ended") as { status: string; error?: string }; + expect(end.status).toBe("failed"); + expect(end.error).toBe("超过轮次"); + }); + + it("result after interrupt → interrupted (sawInterrupt or a subtype containing interrupt)", () => { + const st = newMapState(); + st.sawInterrupt = true; + const evs = mapSdkMessage(result({ subtype: "success", is_error: false }), st, cwd); + const end = evs.find((e) => e.type === "turn_ended") as { status: string }; + expect(end.status).toBe("interrupted"); + expect(st.sawInterrupt).toBe(false); + + const st2 = newMapState(); + const evs2 = mapSdkMessage(result({ subtype: "error_interrupted", is_error: true }), st2, cwd); + const end2 = evs2.find((e) => e.type === "turn_ended") as { status: string }; + expect(end2.status).toBe("interrupted"); + }); +}); diff --git a/packages/agent-console/test/v1/claude-thinking.test.ts b/packages/agent-console/test/v1/claude-thinking.test.ts new file mode 100644 index 00000000..0d313c06 --- /dev/null +++ b/packages/agent-console/test/v1/claude-thinking.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { claudeThinkingForModel } from "../../daemon/src/providers/claude/thinking.js"; + +describe("claudeThinkingForModel", () => { + it("base levels with high as default", () => { + const t = claudeThinkingForModel("claude-sonnet-4-20250514"); + expect(t.defaultThinking).toBe("high"); + expect(t.thinkingOptions?.map((o) => o.id)).toEqual(["low", "medium", "high", "max", "xhigh"]); + }); + + it("ordinary models have no xhigh", () => { + const t = claudeThinkingForModel("claude-3-haiku"); + expect(t.thinkingOptions?.map((o) => o.id)).toEqual(["low", "medium", "high", "max"]); + }); +}); diff --git a/packages/agent-console/test/v1/cli-models.test.ts b/packages/agent-console/test/v1/cli-models.test.ts new file mode 100644 index 00000000..38859ee5 --- /dev/null +++ b/packages/agent-console/test/v1/cli-models.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { parseDevinModelsList, parseOpencodeModels } from "../../daemon/src/providers/acp/cliModels.js"; + +describe("CLI model parsers", () => { + it("parseDevinModelsList", () => { + const sample = ` +Available models (2 families) + swe-2-high SWE-2 High [262K context, Free] + adaptive Adaptive [$0.5 / 1M Input] +`; + const m = parseDevinModelsList(sample); + expect(m.some((x) => x.id === "swe-2-high")).toBe(true); + expect(m.find((x) => x.id === "adaptive")?.isDefault).toBe(true); + }); + + it("parseOpencodeModels", () => { + const m = parseOpencodeModels("opencode/beam-free\nopencode/big-pickle\n"); + expect(m.map((x) => x.id)).toEqual(["opencode/beam-free", "opencode/big-pickle"]); + }); +}); diff --git a/packages/agent-console/test/v1/coalescer.test.ts b/packages/agent-console/test/v1/coalescer.test.ts new file mode 100644 index 00000000..221fe3f2 --- /dev/null +++ b/packages/agent-console/test/v1/coalescer.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { Coalescer } from "../../daemon/src/timeline/coalescer.js"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe("coalescer", () => { + it("first delta is sent immediately (leading)", async () => { + const out: [string, string][] = []; + const c = new Coalescer(60, (id, t) => out.push([id, t])); + c.push("a", "he"); + expect(out).toEqual([["a", "he"]]); + c.dispose(); + }); + + it("deltas within 60ms are merged and sent once (trailing)", async () => { + const out: [string, string][] = []; + const c = new Coalescer(60, (id, t) => out.push([id, t])); + c.push("a", "he"); // leading: sent immediately + c.push("a", "llo"); + c.push("a", "!"); + expect(out).toEqual([["a", "he"]]); + await sleep(90); + expect(out).toEqual([["a", "he"], ["a", "llo!"]]); + c.dispose(); + }); + + it("leading resumes after the window ends", async () => { + const out: [string, string][] = []; + const c = new Coalescer(50, (id, t) => out.push([id, t])); + c.push("a", "x"); + await sleep(80); + c.push("a", "y"); // Window has passed → sent immediately + expect(out).toEqual([["a", "x"], ["a", "y"]]); + c.dispose(); + }); + + it("pending deltas are flushed before the complete event", async () => { + const out: [string, string][] = []; + const c = new Coalescer(60, (id, t) => out.push([id, t])); + c.push("a", "he"); + c.push("a", "llo"); + c.flushItem("a"); // Complete event arrives + expect(out).toEqual([["a", "he"], ["a", "llo"]]); + await sleep(80); + expect(out.length).toBe(2); // Nothing more is sent after flush + }); + + it("different itemIds don't affect each other", async () => { + const out: [string, string][] = []; + const c = new Coalescer(60, (id, t) => out.push([id, t])); + c.push("a", "1"); + c.push("b", "2"); + c.push("a", "3"); + expect(out).toEqual([["a", "1"], ["b", "2"]]); + c.dispose(); // flush a:3 + expect(out).toEqual([["a", "1"], ["b", "2"], ["a", "3"]]); + }); +}); diff --git a/packages/agent-console/test/v1/codex-rpc.test.ts b/packages/agent-console/test/v1/codex-rpc.test.ts new file mode 100644 index 00000000..b9e9d06a --- /dev/null +++ b/packages/agent-console/test/v1/codex-rpc.test.ts @@ -0,0 +1,144 @@ +import { afterAll, describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import { CodexRpc, CodexProcessExitedError } from "../../daemon/src/providers/codex/rpc.js"; +import { mapNotification, newCodexMapState } from "../../daemon/src/providers/codex/map.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Simulated app-server: echoes each JSON-RPC line as {id,result:{echo:params}}; +// on method:"emit" → sends the notifications/server requests in params.lines; +// the echo server request is answered with {id, result:{ok:true}}. +const fakeServer = ` +let buf = ""; +process.stdin.on("data", (c) => { + buf += c; + let i; + while ((i = buf.indexOf("\\n")) >= 0) { + const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); + if (!line) continue; + const m = JSON.parse(line); + if (m.method === "emit") { + for (const l of m.params.lines) process.stdout.write(JSON.stringify(l) + "\\n"); + process.stdout.write(JSON.stringify({ id: m.id, result: { ok: true } }) + "\\n"); + } else if (m.method === "serverRequest") { + // reply to the client + send a server request; once the client answers {id:999,result}, send a notification + process.stdout.write(JSON.stringify({ id: m.id, result: { ok: true } }) + "\\n"); + process.stdout.write(JSON.stringify({ id: 999, method: "item/commandExecution/requestApproval", params: { command: "npm test", itemId: "i1" } }) + "\\n"); + } else if (m.id === 999 && m.result !== undefined) { + process.stdout.write(JSON.stringify({ method: "approval/replied", params: m.result }) + "\\n"); + } else if (m.id !== undefined && m.method) { + process.stdout.write(JSON.stringify({ id: m.id, result: { echo: m.method } }) + "\\n"); + } + } +}); +process.stdin.on("end", () => process.exit(0)); +`; + +// All cases share one temporary script, deleted afterwards +const dir = mkdtempSync(join(tmpdir(), "rpc-")); +const script = join(dir, "server.mjs"); +writeFileSync(script, fakeServer); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +function makeRpc(): { rpc: CodexRpc; notifications: { m: string; p: Record }[] } { + const rpc = new CodexRpc({ cmd: process.execPath, args: [script] }); + const notifications: { m: string; p: Record }[] = []; + rpc.onNotification = (m, p) => notifications.push({ m, p }); + rpc.onServerRequest = async () => ({ decision: "accept" }); + return { rpc, notifications }; +} + +describe("CodexRpc", () => { + it("request → result", async () => { + const { rpc } = makeRpc(); + const res = await rpc.request<{ echo: string }>("turn/start", { x: 1 }); + expect(res.echo).toBe("turn/start"); + await rpc.close(); + }); + + it("notification → onNotification", async () => { + const { rpc, notifications } = makeRpc(); + await rpc.request("emit", { + lines: [ + { method: "turn/started", params: { turn: { id: "t1" } } }, + { method: "item/agentMessage/delta", params: { itemId: "i1", delta: "hi" } }, + ], + }); + await new Promise((r) => setTimeout(r, 200)); + expect(notifications.map((n) => n.m)).toEqual(["turn/started", "item/agentMessage/delta"]); + await rpc.close(); + }); + + it("server request → onServerRequest answer", async () => { + const { rpc, notifications } = makeRpc(); + await rpc.request("serverRequest", {}); + await new Promise((r) => setTimeout(r, 200)); + expect(notifications.some((n) => n.m === "approval/replied" && (n.p as { decision?: string }).decision === "accept")).toBe(true); + await rpc.close(); + }); + + it("process exit → pending requests reject", async () => { + const { rpc } = makeRpc(); + const p = rpc.request("turn/start", {}); + await rpc.close(); + await expect(rpc.request("x")).rejects.toBeInstanceOf(CodexProcessExitedError); + await p.catch(() => {}); + }); +}); + +describe("codex map", () => { + it("turn/started + deltas + completed", () => { + const st = newCodexMapState(); + let evs = mapNotification("turn/started", { turn: { id: "t1" } }, st); + expect(st.currentTurnId).toBe("t1"); + evs = mapNotification("item/agentMessage/delta", { itemId: "m1", delta: "你好" }, st); + expect(evs).toEqual([{ type: "assistant_delta", itemId: "m1", text: "你好" }]); + evs = mapNotification("item/reasoning/summaryTextDelta", { itemId: "r1", delta: "想" }, st); + expect(evs).toEqual([{ type: "reasoning_delta", itemId: "r1", text: "想" }]); + }); + + it("item/started commandExecution → tool_call running; completed → ok", () => { + const st = newCodexMapState(); + let evs = mapNotification("item/started", { item: { type: "commandExecution", id: "c1", command: "npm test" } }, st); + expect(evs[0]).toMatchObject({ type: "tool_call", itemId: "c1", kind: "bash", status: "running" }); + evs = mapNotification("item/completed", { item: { type: "commandExecution", id: "c1", command: "npm test", exitCode: 0, aggregatedOutput: "ok", durationMs: 500 } }, st); + expect(evs[0]).toMatchObject({ status: "ok", output: "ok", exitCode: 0 }); + }); + + it("fileChange → edit tool_call + filesChanged count", () => { + const st = newCodexMapState(); + mapNotification("item/started", { item: { type: "fileChange", id: "f1", changes: [{ path: "a.ts" }, { path: "b.ts" }] } }, st); + mapNotification("item/completed", { item: { type: "fileChange", id: "f1", changes: [{ path: "a.ts" }, { path: "b.ts" }], status: "completed" } }, st); + expect(st.filesChanged).toBe(2); + const evs = mapNotification("turn/completed", { turn: { id: "t1", status: "completed", durationMs: 100 } }, st); + const end = evs.find((e) => e.type === "turn_ended") as { filesChanged?: number; status: string }; + expect(end.status).toBe("completed"); + expect(end.filesChanged).toBe(2); + }); + + it("turn/plan/updated → todo", () => { + const st = newCodexMapState(); + const evs = mapNotification("turn/plan/updated", { plan: [{ step: "改代码", status: "inProgress" }, { step: "测试", status: "pending" }] }, st); + expect(evs[0]).toMatchObject({ type: "todo" }); + const items = (evs[0] as { items: { text: string; status: string }[] }).items; + expect(items[0]).toEqual({ text: "改代码", status: "in_progress" }); + expect(items[1].status).toBe("pending"); + }); + + it("turn/completed interrupted/failed", () => { + const st = newCodexMapState(); + let evs = mapNotification("turn/completed", { turn: { id: "t1", status: "interrupted" } }, st); + expect((evs[0] as { status: string }).status).toBe("interrupted"); + evs = mapNotification("turn/completed", { turn: { id: "t2", status: "failed", error: { message: "网关地区不支持" } } }, st); + const end = evs[0] as { status: string; error?: string }; + expect(end.status).toBe("failed"); + expect(end.error).toBe("网关地区不支持"); + }); + + it("thread/tokenUsage/updated → usage", () => { + const st = newCodexMapState(); + const evs = mapNotification("thread/tokenUsage/updated", { tokenUsage: { last: { inputTokens: 10, outputTokens: 5, cachedInputTokens: 2 } } }, st); + expect(evs[0]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5, cacheReadTokens: 2 }); + }); +}); diff --git a/packages/agent-console/test/v1/crossplatform.test.ts b/packages/agent-console/test/v1/crossplatform.test.ts new file mode 100644 index 00000000..61f09c0e --- /dev/null +++ b/packages/agent-console/test/v1/crossplatform.test.ts @@ -0,0 +1,351 @@ +// Cross-platform logic (independent of the real OS): PATH scanning, .cmd/.ps1 shim resolution, cmd.exe escaping, +// default shell, path normalization, pid liveness, PATH completion, cross-drive moves, the gate's 503 text. +import { describe, it, expect } from "vitest"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extraBinDirs, findNode, findOnPath, isNodeExecutable, requireNode, searchDirs } from "../../lib/pathscan.mjs"; +import { failureHtml } from "../../lib/gate.mjs"; +import { + argvFor, + escapeCmdArgument, + escapeCmdCommand, + killTree, + resolveBinWith, + shimTarget, + spawnBin, +} from "../../daemon/src/util/bin.js"; +import { pickShell, simpleShellArgs } from "../../daemon/src/terminal/service.js"; +import { normPath } from "../../daemon/src/util/paths.js"; +import { augmentPath, pidAlive } from "../../daemon/src/util/proc.js"; +import { moveAcrossDevices } from "../../daemon/src/git/service.js"; +import { claudeExecutable } from "../../daemon/src/providers/claude/session.js"; + +const lower = (s: string) => s.toLowerCase(); + +describe("pathscan (simulated Windows)", () => { + const env = { Path: "C:\\Users\\张三\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs", PATHEXT: ".COM;.EXE;.BAT;.CMD" }; + const files = new Set( + [ + "C:\\Users\\张三\\AppData\\Roaming\\npm\\claude.cmd", + "C:\\Users\\张三\\AppData\\Roaming\\npm\\claude.ps1", + "C:\\Users\\张三\\AppData\\Roaming\\npm\\claude", + "C:\\Users\\张三\\AppData\\Roaming\\npm\\onlyps.ps1", + "C:\\Program Files\\nodejs\\node.exe", + "C:\\Program Files\\nodejs\\claude.exe", + ].map(lower), + ); + const exists = (f: string) => files.has(lower(f)); + const opts = { platform: "win32" as const, env, home: "C:\\Users\\张三", exists }; + + it("env var names are case-insensitive (Path), PATH order + PATHEXT order, extensionless shims are not returned", () => { + const hits = findOnPath("claude", opts); + expect(hits[0]).toBe("C:\\Users\\张三\\AppData\\Roaming\\npm\\claude.cmd"); + expect(hits).toContain("C:\\Users\\张三\\AppData\\Roaming\\npm\\claude.ps1"); + expect(hits).toContain("C:\\Program Files\\nodejs\\claude.exe"); + expect(hits).not.toContain("C:\\Users\\张三\\AppData\\Roaming\\npm\\claude"); + }); + + it("a lone .ps1 is found too; names with an extension are looked up as-is", () => { + expect(findOnPath("onlyps", opts)).toEqual(["C:\\Users\\张三\\AppData\\Roaming\\npm\\onlyps.ps1"]); + expect(findOnPath("node.exe", opts)).toEqual(["C:\\Program Files\\nodejs\\node.exe"]); + }); + + it("findNode only accepts node.exe; requireNode searches PATH when the host is Bun and gives a userMessage when not found", () => { + expect(findNode(opts)).toBe("C:\\Program Files\\nodejs\\node.exe"); + expect(requireNode({ execPath: "C:\\Magpie\\bun.exe", find: () => "C:\\n\\node.exe" })).toBe("C:\\n\\node.exe"); + expect(requireNode({ execPath: "/usr/bin/node", find: () => null })).toBe("/usr/bin/node"); + let err: (Error & { code?: string; userMessage?: string }) | null = null; + try { + requireNode({ execPath: "/opt/magpie/bun", find: () => null }); + } catch (e) { + err = e as Error & { code?: string; userMessage?: string }; + } + expect(err?.code).toBe("ENODE"); + expect(err?.userMessage).toMatch(/Node\.js/); + }); + + it("directory dedupe is case-insensitive and quotes in PATH are stripped", () => { + const dirs = searchDirs({ ...opts, env: { PATH: '"C:\\A b";c:\\a B\\;C:\\X', APPDATA: "C:\\X" }, extra: true }); + expect(dirs.filter((d) => lower(d).replace(/\\$/, "") === "c:\\a b")).toHaveLength(1); + expect(dirs.filter((d) => lower(d) === "c:\\x")).toHaveLength(1); + }); + + it("isNodeExecutable", () => { + expect(isNodeExecutable("C:\\Program Files\\nodejs\\node.exe".replace(/\\/g, "/"))).toBe(true); + expect(isNodeExecutable("/usr/local/bin/node")).toBe(true); + expect(isNodeExecutable("/Applications/Magpie.app/Contents/MacOS/bun")).toBe(false); + }); +}); + +describe("pathscan (simulated macOS / Linux)", () => { + it("adds Homebrew and ~/.local/bin when the macOS GUI PATH is minimal", () => { + const dirs = extraBinDirs({ platform: "darwin", env: {}, home: "/Users/me" }); + expect(dirs).toContain("/opt/homebrew/bin"); + expect(dirs).toContain("/usr/local/bin"); + expect(dirs).toContain("/Users/me/.local/bin"); + const hit = findOnPath("codex", { + platform: "darwin", + env: { PATH: "/usr/bin:/bin" }, + home: "/Users/me", + exists: (f) => f === "/opt/homebrew/bin/codex", + }); + expect(hit).toEqual(["/opt/homebrew/bin/codex"]); + }); + + it("Linux does not add Homebrew; PATH comes first", () => { + expect(extraBinDirs({ platform: "linux", env: {}, home: "/home/me" })).not.toContain("/opt/homebrew/bin"); + const hit = findOnPath("x", { + platform: "linux", + env: { PATH: "/a:/b" }, + home: "/home/me", + exists: (f) => f === "/b/x" || f === "/usr/local/bin/x", + }); + expect(hit).toEqual(["/b/x", "/usr/local/bin/x"]); + }); +}); + +describe("resolveBin (simulated Windows)", () => { + const npmShim = [ + "@ECHO off", + "SETLOCAL", + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ") ELSE (", + ' SET "_prog=node"', + ")", + '"%_prog%" "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*', + ].join("\r\n"); + const base = "C:\\Program Files\\nodejs"; + + it("npm .cmd shim → node + cli.js; node.exe in the same directory is not picked by mistake", async () => { + const files = new Set([`${base}\\node.exe`, `${base}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`].map(lower)); + const r = await resolveBinWith("claude", { + platform: "win32", + find: () => [`${base}\\claude.cmd`], + readText: async () => npmShim, + exists: (f) => files.has(lower(f)), + node: () => "C:\\node\\node.exe", + }); + expect(r).toMatchObject({ ok: true, via: "shim-js", cmd: "C:\\node\\node.exe" }); + if (r.ok) expect(r.prefixArgs[0]).toBe(`${base}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`); + }); + + it("uses the exe when shimTarget only points at an .exe", () => { + const t = shimTarget('"%~dp0\\bin\\tool.exe" %*', "C:\\t", { win: true, exists: (f) => lower(f) === "c:\\t\\bin\\tool.exe" }); + expect(t).toBe("C:\\t\\bin\\tool.exe"); + }); + + it(".exe used directly; .ps1 via powershell -File; other .cmd via cmd-shim", async () => { + const exe = await resolveBinWith("codex", { platform: "win32", find: () => ["C:\\x\\codex.exe"] }); + expect(exe).toMatchObject({ ok: true, via: "exe", cmd: "C:\\x\\codex.exe", prefixArgs: [] }); + const ps = await resolveBinWith("agent", { platform: "win32", find: () => ["C:\\x\\agent.ps1"] }); + expect(ps).toMatchObject({ ok: true, via: "ps1", cmd: "powershell.exe" }); + if (ps.ok) expect(ps.prefixArgs.slice(-2)).toEqual(["-File", "C:\\x\\agent.ps1"]); + const cmd = await resolveBinWith("cursor-agent", { + platform: "win32", + find: () => ["C:\\Users\\A B\\AppData\\Local\\cursor-agent\\cursor-agent.cmd"], + readText: async () => '@powershell -NoProfile -File "%~dp0cursor-agent.ps1" %*', + exists: () => false, + comspec: "C:\\Windows\\System32\\cmd.exe", + }); + expect(cmd).toMatchObject({ ok: true, via: "cmd-shim", cmd: "C:\\Windows\\System32\\cmd.exe" }); + }); + + it("npx special case: node.exe + npx-cli.js", async () => { + const r = await resolveBinWith("npx", { + platform: "win32", + find: () => [`${base}\\npx.cmd`], + exists: () => true, + }); + expect(r).toMatchObject({ ok: true, via: "npx-shim", cmd: `${base}\\node.exe` }); + }); + + it("gives a reason when not found; POSIX uses the PATH hit directly", async () => { + expect(await resolveBinWith("nope", { platform: "win32", find: () => [] })).toMatchObject({ ok: false }); + expect(await resolveBinWith("claude", { platform: "darwin", find: () => ["/opt/homebrew/bin/claude"] })).toMatchObject({ + ok: true, + cmd: "/opt/homebrew/bin/claude", + via: "path", + }); + }); +}); + +describe("cmd.exe escaping (cmd-shim)", () => { + it("spaces and metacharacters in paths are ^-escaped; arguments are quoted and escaped twice", () => { + expect(escapeCmdCommand("C:\\Users\\A B\\x.cmd")).toBe("C:\\Users\\A^ B\\x.cmd"); + expect(escapeCmdArgument("a b", false)).toBe('^"a^ b^"'); + expect(escapeCmdArgument("a&b", true)).toBe('^^^"a^^^&b^^^"'); + expect(escapeCmdArgument('say "hi"\\', false)).toBe('^"say^ \\^"hi\\^"\\\\^"'); + }); + + it("argvFor: cmd-shim uses /d /s /c + windowsVerbatimArguments; others just prepend prefixArgs", () => { + const a = argvFor( + { ok: true, cmd: "cmd.exe", prefixArgs: [], display: "", via: "cmd-shim", shim: "C:\\A B\\agent.cmd" }, + ["--print", "x y"], + ); + expect(a.cmd).toBe("cmd.exe"); + expect(a.windowsVerbatimArguments).toBe(true); + expect(a.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(a.args[3]).toBe('"C:\\A^ B\\agent.cmd ^^^"--print^^^" ^^^"x^^^ y^^^""'); + const b = argvFor({ ok: true, cmd: "node", prefixArgs: ["cli.js"], display: "", via: "shim-js" }, ["-v"]); + expect(b).toEqual({ cmd: "node", args: ["cli.js", "-v"] }); + }); +}); + +describe("claudeExecutable", () => { + it("shim-js → cli.js + node; native exe passed directly; cmd-shim/ps1 left to the SDK to find", () => { + expect(claudeExecutable({ binCmd: "C:\\n\\node.exe", binPrefixArgs: ["C:\\x\\cli.js"], binVia: "shim-js" })).toEqual({ + path: "C:\\x\\cli.js", + node: true, + }); + expect(claudeExecutable({ binCmd: "/usr/local/bin/claude", binPrefixArgs: [] })).toEqual({ path: "/usr/local/bin/claude", node: false }); + expect(claudeExecutable({ binCmd: "cmd.exe", binPrefixArgs: [], binVia: "cmd-shim" })).toBeNull(); + expect(claudeExecutable({ binCmd: "powershell.exe", binPrefixArgs: ["-File", "c.ps1"], binVia: "ps1" })).toBeNull(); + }); +}); + +describe("default shell", () => { + it("Windows: pwsh → powershell → system powershell → cmd", () => { + expect(pickShell({ platform: "win32", env: {}, find: (n) => (n === "pwsh" ? "C:\\P\\pwsh.exe" : null) })).toBe("C:\\P\\pwsh.exe"); + expect( + pickShell({ platform: "win32", env: { SystemRoot: "C:\\Windows" }, find: () => null, isExec: () => false, }), + ).toBe("cmd.exe"); + expect( + pickShell({ platform: "win32", env: { SystemRoot: "C:\\Windows", ComSpec: "C:\\Windows\\system32\\cmd.exe" }, find: () => null, isExec: () => false }), + ).toBe("C:\\Windows\\system32\\cmd.exe"); + }); + it("macOS defaults to zsh; $SHELL is skipped when it does not exist; Linux bash → sh", () => { + expect(pickShell({ platform: "darwin", env: {}, isExec: (f) => f === "/bin/zsh" || f === "/bin/bash" })).toBe("/bin/zsh"); + expect(pickShell({ platform: "darwin", env: { SHELL: "/opt/gone/fish" }, isExec: (f) => f === "/bin/zsh" })).toBe("/bin/zsh"); + expect(pickShell({ platform: "linux", env: { SHELL: "/usr/bin/fish" }, isExec: () => true })).toBe("/usr/bin/fish"); + expect(pickShell({ platform: "linux", env: {}, isExec: (f) => f === "/bin/sh" })).toBe("/bin/sh"); + }); + it("no-PTY arguments", () => { + expect(simpleShellArgs("C:\\Windows\\system32\\cmd.exe", "win32")).toEqual(["/Q"]); + expect(simpleShellArgs("pwsh.exe", "win32")).toEqual(["-NoLogo"]); + expect(simpleShellArgs("/bin/zsh", "darwin")).toEqual(["-i"]); + }); +}); + +describe("path normalization", () => { + it("case-insensitive on macOS / Windows, case-sensitive on Linux", () => { + expect(normPath("/Users/Me/Proj/", "darwin")).toBe("/users/me/proj"); + expect(normPath("/home/Me/Proj", "linux")).toBe("/home/Me/Proj"); + }); + it("macOS normalizes NFD file names (Chinese / accented) to NFC", () => { + expect(normPath("/tmp/cafe\u0301", "darwin")).toBe(normPath("/tmp/caf\u00e9", "darwin")); + }); +}); + +describe("process utilities", () => { + it("pidAlive: EPERM counts as alive, ESRCH as dead", () => { + const err = (code: string) => () => { + throw Object.assign(new Error(code), { code }); + }; + expect(pidAlive(123, err("EPERM"))).toBe(true); + expect(pidAlive(123, err("ESRCH"))).toBe(false); + expect(pidAlive(123, () => true)).toBe(true); + expect(pidAlive(0, () => true)).toBe(false); + expect(pidAlive(process.pid)).toBe(true); + }); + + it("augmentPath: appends at the end, dedupes (case-insensitive on Windows), keeps the existing order", () => { + const win = augmentPath({ Path: "C:\\B;C:\\Program Files\\NodeJS" }, "C:\\Program Files\\nodejs", "win32", "C:\\Users\\me"); + const parts = win.split(";"); + expect(parts.slice(0, 2)).toEqual(["C:\\B", "C:\\Program Files\\NodeJS"]); + expect(parts.filter((p) => lower(p) === "c:\\program files\\nodejs")).toHaveLength(1); + const mac = augmentPath({ PATH: "/usr/bin:/bin" }, "/opt/homebrew/bin", "darwin", "/Users/me").split(":"); + expect(mac.slice(0, 3)).toEqual(["/usr/bin", "/bin", "/opt/homebrew/bin"]); + expect(mac).toContain("/Users/me/.local/bin"); + }); + + it.skipIf(process.platform === "win32")("killTree (POSIX): detached process group, grandchildren exit too", async () => { + const r = { ok: true as const, cmd: "/bin/sh", prefixArgs: [], display: "sh", via: "path" }; + const child = spawnBin(r, ["-c", "sleep 30 & echo $!; wait"], { stdio: ["ignore", "pipe", "ignore"], group: true }); + const grandchild = await new Promise((res) => child.stdout!.once("data", (d) => res(Number(String(d).trim())))); + expect(pidAlive(grandchild)).toBe(true); + await killTree(child, { graceMs: 1000 }); + for (let i = 0; i < 20 && pidAlive(grandchild); i++) await new Promise((r) => setTimeout(r, 50)); + expect(pidAlive(grandchild)).toBe(false); + }); + + it("killTree returns immediately for a child that already exited", async () => { + const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + await new Promise((r) => child.once("exit", r)); + const t0 = Date.now(); + await killTree(child, { graceMs: 3000 }); + expect(Date.now() - t0).toBeLessThan(1000); + }); + + it("spawnBin really starts a process from the argvFor result", async () => { + const r = { ok: true as const, cmd: process.execPath, prefixArgs: ["-e"], display: "node", via: "path" }; + const out = spawnSync(r.cmd, [...r.prefixArgs, "process.stdout.write('ok')"]).stdout.toString(); + expect(out).toBe("ok"); + const child = spawnBin(r, ["process.stdout.write('中文 ok')"], { stdio: ["ignore", "pipe", "ignore"] }); + let text = ""; + child.stdout!.on("data", (d) => (text += d)); + await new Promise((res) => child.once("close", res)); + expect(text).toBe("中文 ok"); + }); +}); + +describe("cross-drive move (git discarding untracked files)", () => { + it("copy then delete when rename fails with EXDEV; other errors are thrown as usual", async () => { + const dir = mkdtempSync(join(tmpdir(), "ac-move-")); + try { + const from = join(dir, "a"); + mkdirSync(join(from, "sub"), { recursive: true }); + writeFileSync(join(from, "sub", "f.txt"), "内容"); + const to = join(dir, "trash", "a"); + mkdirSync(join(dir, "trash")); + const exdev = async () => { + throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); + }; + await moveAcrossDevices(from, to, { rename: exdev as never }); + expect(existsSync(from)).toBe(false); + expect(readFileSync(join(to, "sub", "f.txt"), "utf8")).toBe("内容"); + const eacces = async () => { + throw Object.assign(new Error("denied"), { code: "EACCES" }); + }; + await expect(moveAcrossDevices(to, join(dir, "b"), { rename: eacces as never })).rejects.toThrow("denied"); + expect(existsSync(to)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("gate 503 page", () => { + it("shows only the userMessage, HTML-escaped; other error details are not exposed", () => { + const html = failureHtml(Object.assign(new Error("secret /home/x"), { userMessage: "装 Node" })); + expect(html).toContain("<b>装 Node</b>"); + expect(html).not.toContain(""); + expect(failureHtml(new Error("secret /home/x"))).not.toContain("secret"); + }); +}); + +describe("folder picker (macOS / Linux)", async () => { + const { appleScriptString, pickFolderCommand } = await import("../../daemon/src/util/pickFolder.js"); + it("AppleScript strings escape backslashes and quotes; no default location when the initial directory does not exist", () => { + expect(appleScriptString('a"b\\c')).toBe('"a\\"b\\\\c"'); + const c = pickFolderCommand("darwin", "/definitely/not/here"); + expect(c?.cmd).toBe("/usr/bin/osascript"); + expect(c?.args[1]).not.toContain("default location"); + const d = pickFolderCommand("darwin", tmpdir()); + expect(d?.args[1]).toContain("default location (POSIX file "); + }); + it("Linux: zenity / kdialog only with a graphical session", () => { + const find = (n: string) => (n === "kdialog" ? "/usr/bin/kdialog" : null); + expect(pickFolderCommand("linux", undefined, find, {})).toBeNull(); + expect(pickFolderCommand("linux", undefined, find, { DISPLAY: ":0", HOME: "/home/me" })).toEqual({ + cmd: "/usr/bin/kdialog", + args: ["--getexistingdirectory", "/home/me"], + }); + const z = pickFolderCommand("linux", tmpdir(), (n) => (n === "zenity" ? "/usr/bin/zenity" : null), { WAYLAND_DISPLAY: "wayland-0" }); + expect(z?.args).toContain("--directory"); + expect(z?.args.at(-1)).toMatch(/^--filename=.*\/$/); + expect(pickFolderCommand("aix" as NodeJS.Platform, undefined, find, { DISPLAY: ":0" })).toBeNull(); + }); +}); diff --git a/packages/agent-console/test/v1/cursor-models.test.ts b/packages/agent-console/test/v1/cursor-models.test.ts new file mode 100644 index 00000000..d75e475b --- /dev/null +++ b/packages/agent-console/test/v1/cursor-models.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { parseCursorListModels, cursorWireModel } from "../../daemon/src/providers/acp/cursorModels.js"; + +describe("cursor list-models parsing", () => { + it("parses id and display name", () => { + const text = `Available models + +auto - Auto (default) +composer-2.5 - Composer 2.5 (current) +grok-4.7-high - Grok 4.7 High +Tip: use --model`; + const m = parseCursorListModels(text); + expect(m.find((x) => x.id === "composer-2.5")?.name).toBe("Composer 2.5"); + expect(m.find((x) => x.id === "auto")?.isDefault).toBe(true); + }); + + it("Fast toggle appends the model parameter", () => { + expect(cursorWireModel("composer-2.5", false)).toBe("composer-2.5"); + expect(cursorWireModel("composer-2.5", true)).toBe("composer-2.5[fast=true]"); + // Parameterized values reported over ACP: sent unchanged without Fast; with Fast only fast changes + const v = "claude-opus-5-5[context=300k,effort=medium,fast=false]"; + expect(cursorWireModel(v, false)).toBe(v); + expect(cursorWireModel(v, true)).toBe("claude-opus-5-5[context=300k,effort=medium,fast=true]"); + // A thinking level is chosen: written as that model's own level parameter + expect(cursorWireModel("grok-4.6", false, { param: "effort", value: "xhigh" })).toBe("grok-4.6[effort=xhigh]"); + expect(cursorWireModel("gpt-5.6-sol", true, { param: "reasoning", value: "max" })).toBe("gpt-5.6-sol[reasoning=max,fast=true]"); + expect(cursorWireModel(v, false, { param: "effort", value: "high" })).toBe("claude-opus-5-5[context=300k,fast=false,effort=high]"); + }); +}); diff --git a/packages/agent-console/test/v1/files.test.ts b/packages/agent-console/test/v1/files.test.ts new file mode 100644 index 00000000..05815e78 --- /dev/null +++ b/packages/agent-console/test/v1/files.test.ts @@ -0,0 +1,88 @@ +// FileService tests (§5.9 + §11.1): tree ignore rules, directories first, read refuses binary/oversized/out-of-bounds +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FileService } from "../../daemon/src/files/service.js"; + +let dir = ""; +const svc = new FileService(); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "ac-files-")); + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(join(dir, "node_modules", "junk"), { recursive: true }); + mkdirSync(join(dir, ".git"), { recursive: true }); + mkdirSync(join(dir, "ignored-dir"), { recursive: true }); + writeFileSync(join(dir, "a.ts"), "const a = 1;\n"); + writeFileSync(join(dir, "z.md"), "# hi\n"); + writeFileSync(join(dir, "src", "b.py"), "print(1)\n"); + writeFileSync(join(dir, "skipped.log"), "noise\n"); + writeFileSync(join(dir, "ignored-dir", "x.txt"), "gone\n"); + writeFileSync(join(dir, ".gitignore"), "skipped.log\nignored-dir/\n"); + // Binary (contains \0) and oversized (>1MB) + writeFileSync(join(dir, "bin.dat"), Buffer.from([0x89, 0x50, 0, 0x47, 1, 2])); + writeFileSync(join(dir, "big.txt"), Buffer.alloc(1024 * 1024 + 1, 65)); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("files.tree", () => { + it("one level: .git/node_modules absent, .gitignore rules apply, directories first", async () => { + const r = await svc.tree(dir, "."); + const names = (r.entries ?? []).map((e) => e.name); + expect(names).not.toContain(".git"); + expect(names).not.toContain("node_modules"); + expect(names).not.toContain("skipped.log"); + expect(names).not.toContain("ignored-dir"); + expect(names).toContain("src"); + expect(names).toContain("a.ts"); + // Directories first + const idxDir = names.indexOf("src"); + const idxFile = names.indexOf("a.ts"); + expect(idxDir).toBeGreaterThanOrEqual(0); + expect(idxFile).toBeGreaterThan(idxDir); + }); + + it("subdirectories load lazily: dir=src lists only one level of src", async () => { + const r = await svc.tree(dir, "src"); + const names = (r.entries ?? []).map((e) => e.name); + expect(names).toEqual(["b.py"]); + }); + + it("out-of-bounds path → error", async () => { + const r = await svc.tree(dir, "../../"); + expect(r.error).toBeTruthy(); + }); +}); + +describe("files.read", () => { + it("text → text + language by extension", async () => { + const r = await svc.read(dir, "a.ts"); + expect(r.text).toBe("const a = 1;\n"); + expect(r.language).toBe("typescript"); + }); + + it("binary (\\\\0 in the first 8KB) → binary:true", async () => { + const r = await svc.read(dir, "bin.dat"); + expect(r.binary).toBe(true); + expect(r.text).toBeUndefined(); + }); + + it(">1MB → tooLarge:true", async () => { + const r = await svc.read(dir, "big.txt"); + expect(r.tooLarge).toBe(true); + }); + + it("out-of-bounds path → error", async () => { + const r = await svc.read(dir, "../outside.txt"); + expect(r.error).toBeTruthy(); + }); + + it("directory → error", async () => { + const r = await svc.read(dir, "src"); + expect(r.error).toBeTruthy(); + }); +}); diff --git a/packages/agent-console/test/v1/fixes.test.ts b/packages/agent-console/test/v1/fixes.test.ts new file mode 100644 index 00000000..c0842a16 --- /dev/null +++ b/packages/agent-console/test/v1/fixes.test.ts @@ -0,0 +1,138 @@ +// Regression tests for code-review fixes: delta types, permission event dedupe, restart recovery, upload CORS, WS buffer check +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AddressInfo } from "node:net"; +import { AgentManager } from "../../daemon/src/agents/manager.js"; +import { ThreadsStore } from "../../daemon/src/store/threads.js"; +import { ProjectsStore } from "../../daemon/src/store/projects.js"; +import { SettingsStore } from "../../daemon/src/store/settings.js"; +import { AttachmentStore } from "../../daemon/src/store/attachments.js"; +import { Gateway } from "../../daemon/src/ws/gateway.js"; +import { createHttpServer } from "../../daemon/src/http.js"; +import { initProviders } from "../../daemon/src/providers/registry.js"; +import type { Config } from "../../daemon/src/config.js"; + +process.env.AGENT_CONSOLE_MOCK = "1"; + +const cfg: Config = { port: 0, dataDir: "", token: "t", version: "0.0.0", idleExitMinutes: 60, buildStamp: "0" }; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +let dir: string; +let threads: ThreadsStore; +let projects: ProjectsStore; +let settings: SettingsStore; +let gw: Gateway; +let manager: AgentManager; +let threadId: string; +let published: { ch: string; data: { type?: string; event?: { type: string; itemId?: string; text?: string } } }[]; + +type Internals = { onProviderEvent(id: string, ev: Record): Promise }; +const feed = (ev: Record) => (manager as unknown as Internals).onProviderEvent(threadId, ev); + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "ac-fix-")); + threads = new ThreadsStore(dir); + projects = new ProjectsStore(dir); + settings = new SettingsStore(dir); + gw = new Gateway(cfg); + published = []; + const orig = gw.publish.bind(gw); + gw.publish = (ch: string, data: unknown) => { + published.push({ ch, data: data as (typeof published)[number]["data"] }); + orig(ch, data); + }; + initProviders(); + manager = new AgentManager(gw, threads, projects, settings, new AttachmentStore(join(dir, "attachments"))); + const proj = await projects.create(dir); + threadId = (await threads.create({ projectId: proj.id, provider: "mock" })).id; +}); + +afterEach(() => { + manager.shutdown(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe("review fixes", () => { + it("thinking/tool-output deltas keep their type when pushed instead of becoming assistant_delta", async () => { + await feed({ type: "reasoning_delta", itemId: "r1", text: "想" }); + await feed({ type: "tool_output_delta", itemId: "c1", text: "out" }); + await feed({ type: "assistant_delta", itemId: "a1", text: "答" }); + await sleep(100); + const deltas = published.map((p) => p.data.event).filter((e) => e && e.type.endsWith("_delta")); + expect(deltas).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "reasoning_delta", itemId: "r1", text: "想" }), + expect.objectContaining({ type: "tool_output_delta", itemId: "c1", text: "out" }), + expect.objectContaining({ type: "assistant_delta", itemId: "a1", text: "答" }), + ])); + }); + + it("permission results already persisted by the manager are not written again as permission_resolved from the provider echo", async () => { + await feed({ type: "permission_request", requestId: "p1", title: "x", kind: "tool", options: [{ id: "allow", label: "允许", kind: "allow_once" }] }); + await manager.respond(threadId, "p1", { optionId: "allow" }); + await feed({ type: "permission_resolved", requestId: "p1", optionId: "allow" }); + const evs = await threads.readEvents(threadId, 0, 1000); + expect(evs.filter((e) => e.type === "permission_resolved")).toHaveLength(1); + }); + + it("turn_ended is recorded once per turn (a late end from the provider after the interrupt fallback is dropped)", async () => { + await feed({ type: "turn_started", turnId: "u1", model: null, mode: "default", effort: null }); + await feed({ type: "turn_ended", turnId: "u1", status: "interrupted" }); + await feed({ type: "turn_ended", turnId: "u1", status: "completed" }); + const evs = await threads.readEvents(threadId, 0, 1000); + expect(evs.filter((e) => e.type === "turn_ended")).toHaveLength(1); + }); + + it("restart recovery: previous turn has a status, the running turn never ended → turn_ended is still appended", async () => { + await threads.patch(threadId, { status: "running", lastTurnStatus: "completed" }); + const base = { ts: Date.now(), threadId }; + let seq = (await threads.get(threadId))!.seq; + await threads.appendEvent(threadId, { ...base, seq: ++seq, type: "turn_started", turnId: "u1", model: null, mode: "default", effort: null }); + await threads.appendEvent(threadId, { ...base, seq: ++seq, type: "turn_ended", turnId: "u1", status: "completed" }); + await threads.appendEvent(threadId, { ...base, seq: ++seq, type: "user_message", text: "第二轮" }); + await threads.appendEvent(threadId, { ...base, seq: ++seq, type: "turn_started", turnId: "u2", model: null, mode: "default", effort: null }); + const m2 = new AgentManager(gw, threads, projects, settings, new AttachmentStore(join(dir, "attachments"))); + await m2.recoverOnStart(); + const ends = (await threads.readEvents(threadId, 0, 1000)).filter((e) => e.type === "turn_ended"); + expect(ends).toHaveLength(2); + expect(ends[1]).toMatchObject({ turnId: "u2", status: "interrupted" }); + m2.shutdown(); + }); +}); + +describe("Provider disabled", () => { + it("after disabling: new conversations cannot send; conversations that already ran keep working", async () => { + await settings.set({ providerEnabled: { mock: false } }); + await expect(manager.send(threadId, { text: "你好" })).rejects.toThrow(/禁用/); + + const proj = (await projects.list())[0]; + const old = await threads.create({ projectId: proj.id, provider: "mock", providerSessionId: "mock-old" }); + await manager.send(old.id, { text: "继续" }); + const t0 = Date.now(); + while ((await threads.get(old.id))?.status !== "idle" && Date.now() - t0 < 10000) await sleep(50); + const evs = await threads.readEvents(old.id, 0, 1000); + expect(evs.some((e) => e.type === "turn_ended" && (e as { status?: string }).status === "completed")).toBe(true); + }, 15000); +}); + +describe("upload CORS (sandbox iframe origin is null)", () => { + it("POST responses for Origin: null also carry access-control-allow-origin", async () => { + const appDir = join(dir, "app"); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(appDir, "index.html"), "x"); + const c: Config = { ...cfg, dataDir: dir, token: "tok", buildStamp: "0" }; + const server = createHttpServer(c, appDir, new AttachmentStore(join(dir, "attachments"))); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + c.port = (server.address() as AddressInfo).port; + try { + const res = await fetch(`http://127.0.0.1:${c.port}/api/upload?name=a.txt`, { + method: "POST", headers: { origin: "null", "x-agent-console": "tok" }, body: "hello", + }); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe("null"); + } finally { + server.close(); + } + }); +}); diff --git a/packages/agent-console/test/v1/gate.test.ts b/packages/agent-console/test/v1/gate.test.ts new file mode 100644 index 00000000..5692fd95 --- /dev/null +++ b/packages/agent-console/test/v1/gate.test.ts @@ -0,0 +1,97 @@ +// Plugin gate (lib/gate.mjs): driven by a fake ensureDaemon, no real daemon is started. +import { describe, expect, it } from "vitest"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { freePort, request } from "./helpers-net.js"; + +const gateUrl = pathToFileURL(join(resolve(__dirname, "..", ".."), "lib", "gate.mjs")).href; + +describe("gate (lib/gate.mjs)", () => { + it("checks Host, refuses any Origin, requires the gate key, and serializes opens", async () => { + const { createGate } = await import(/* @vite-ignore */ gateUrl); + const port = await freePort(); + const daemonPort = await freePort(); + const key = "c".repeat(64); + const token = "a".repeat(64); + let calls = 0; + const gate = createGate({ + port, + key, + token, + ensureDaemon: async () => { + calls++; + await new Promise((r) => setTimeout(r, 50)); + return { port: daemonPort }; + }, + }); + await gate.listen(); + try { + expect((await request(port, `/open?k=${key}`, { host: `evil.example:${port}` })).status).toBe(421); + expect((await request(port, `/open?k=${key}`, { host: `127.0.0.1:${port + 1}` })).status).toBe(421); + expect((await request(port, `/open?k=${key}`, { origin: "null" })).status).toBe(403); + expect((await request(port, `/open?k=${key}`, { origin: "https://evil.example" })).status).toBe(403); + expect((await request(port, "/")).status).toBe(404); + expect((await request(port, "/open")).status).toBe(403); + expect((await request(port, "/open?k=wrong")).status).toBe(403); + // the daemon token is not a gate key, in either parameter + expect((await request(port, `/open?k=${token}`)).status).toBe(403); + expect((await request(port, `/open?t=${token}`)).status).toBe(403); + expect((await request(port, `/open?t=${key}`)).status).toBe(403); + expect(calls).toBe(0); + + const results = await Promise.all([ + request(port, `/open?k=${key}&theme=dark`), + request(port, `/open?k=${key}&theme=dark`), + ]); + expect(calls).toBe(1); + for (const result of results) { + expect(result.status).toBe(302); + expect(result.headers.location).toBe(`http://127.0.0.1:${daemonPort}/?theme=dark#t=${token}`); + expect(result.headers["cache-control"]).toBe("no-store"); + expect(result.headers["x-content-type-options"]).toBe("nosniff"); + expect(result.headers["referrer-policy"]).toBe("no-referrer"); + expect(result.headers["access-control-allow-origin"]).toBeUndefined(); + expect(result.headers.location).not.toContain(key); + } + } finally { + await gate.close(); + } + }); + + it("answers 503 when the daemon cannot start, and recovers on the next open", async () => { + const { createGate } = await import(/* @vite-ignore */ gateUrl); + const port = await freePort(); + const key = "d".repeat(64); + const token = "b".repeat(64); + let fail = true; + const gate = createGate({ + port, + key, + token, + ensureDaemon: async () => { + if (fail) throw new Error("spawn ENOENT"); + return { port: 45678 }; + }, + }); + await gate.listen(); + try { + const first = await request(port, `/open?k=${key}`); + expect(first.status).toBe(503); + expect(first.body).not.toContain(token); + fail = false; + const second = await request(port, `/open?k=${key}`); + expect(second.status).toBe(302); + } finally { + await gate.close(); + } + }); + + it("refuses to start with a missing gate key or one equal to the daemon token", async () => { + const { createGate } = await import(/* @vite-ignore */ gateUrl); + const ensureDaemon = async () => ({ port: 1 }); + const token = "e".repeat(64); + expect(() => createGate({ port: 1, token, ensureDaemon })).toThrow(); + expect(() => createGate({ port: 1, key: token, token, ensureDaemon })).toThrow(); + expect(() => createGate({ port: 1, key: "f".repeat(64), token, ensureDaemon })).not.toThrow(); + }); +}); diff --git a/packages/agent-console/test/v1/git.test.ts b/packages/agent-console/test/v1/git.test.ts new file mode 100644 index 00000000..38896116 --- /dev/null +++ b/packages/agent-console/test/v1/git.test.ts @@ -0,0 +1,98 @@ +// GitService tests (§5.8 + §11.1): git init in a temp directory → status/diff/discard, untracked → trash/discarded +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { GitService } from "../../daemon/src/git/service.js"; + +let dir = ""; +let dataDir = ""; +const git = new GitService(); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "ac-git-")); + dataDir = join(dir, "data"); + const g = (args: string[]) => execFileSync("git", args, { cwd: dir, encoding: "utf8", windowsHide: true }); + g(["init"]); + g(["config", "user.email", "t@t"]); + g(["config", "user.name", "t"]); + writeFileSync(join(dir, "a.txt"), "line1\nline2\n"); + g(["add", "a.txt"]); + g(["commit", "-m", "init"]); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("git.status", () => { + it("not a repository → isRepo:false", async () => { + const d = mkdtempSync(join(tmpdir(), "ac-nogit-")); + try { + const r = await git.status(d); + expect(r.isRepo).toBe(false); + } finally { + rmSync(d, { recursive: true, force: true }); + } + }); + + it("inside a repo: clean → empty files; changes + untracked → classified correctly", async () => { + const clean = await git.status(dir); + expect(clean.isRepo).toBe(true); + expect(clean.branch).toBeTruthy(); + expect(clean.files ?? []).toHaveLength(0); + + writeFileSync(join(dir, "a.txt"), "line1\nline2\nline3\n"); + writeFileSync(join(dir, "new.txt"), "brand new\n"); + const r = await git.status(dir); + expect(r.files?.some((f) => f.path === "a.txt" && f.kind === "modified")).toBe(true); + expect(r.files?.some((f) => f.path === "new.txt" && f.kind === "untracked")).toBe(true); + }); +}); + +describe("git.diff", () => { + it("tracked change → unified diff text with +/-", async () => { + const r = await git.diff(dir, "a.txt"); + expect(r.diff).toBeTruthy(); + expect(r.diff).toContain("+line3"); + }); + + it("untracked file → untracked:true + all-added diff", async () => { + const r = await git.diff(dir, "new.txt"); + expect(r.untracked).toBe(true); + expect(r.diff).toContain("+brand new"); + }); + + it("path outside the project → error", async () => { + const r = await git.diff(dir, "../outside.txt"); + expect(r.error).toBeTruthy(); + expect(r.diff).toBeUndefined(); + }); +}); + +describe("git.discard", () => { + it("tracked → git restore brings back the original content", async () => { + const r = await git.discard(dir, "a.txt", dataDir); + expect(r.ok).toBe(true); + expect(readFileSync(join(dir, "a.txt"), "utf8").replace(/\r\n/g, "\n")).toBe("line1\nline2\n"); + const st = await git.status(dir); + expect(st.files?.some((f) => f.path === "a.txt")).toBe(false); + }); + + it("untracked → moved to trash/discarded// and gone from the project", async () => { + const r = await git.discard(dir, "new.txt", dataDir); + expect(r.ok).toBe(true); + expect(r.trashedTo).toContain("trash"); + expect(r.trashedTo).toContain("discarded"); + expect(existsSync(join(dir, "new.txt"))).toBe(false); + expect(existsSync(r.trashedTo!)).toBe(true); + expect(readFileSync(r.trashedTo!, "utf8")).toBe("brand new\n"); + }); + + it("nonexistent file → error", async () => { + const r = await git.discard(dir, "ghost.txt", dataDir); + expect(r.ok).toBe(false); + expect(r.error).toBeTruthy(); + }); +}); diff --git a/packages/agent-console/test/v1/helpers-net.ts b/packages/agent-console/test/v1/helpers-net.ts new file mode 100644 index 00000000..6cd3b962 --- /dev/null +++ b/packages/agent-console/test/v1/helpers-net.ts @@ -0,0 +1,72 @@ +// Small network helpers for tests: get a free port, send requests with explicit Host/Origin headers. +import http from "node:http"; +import net from "node:net"; +import type { AddressInfo } from "node:net"; + +export async function freePort(): Promise { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const port = (server.address() as AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +// Whether the port can be bound again (used to confirm the gate has closed) +export async function canBind(port: number): Promise { + const server = net.createServer(); + const ok = await new Promise((resolve) => { + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => resolve(true)); + }); + if (ok) await new Promise((resolve) => server.close(() => resolve())); + return ok; +} + +export type Reply = { status: number; headers: http.IncomingHttpHeaders; body: string }; + +export function request( + port: number, + path: string, + headers: Record = {}, + method = "GET", +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port, + path, + method, + agent: false, + headers: { host: `127.0.0.1:${port}`, ...headers }, + }, + (res) => { + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body })); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export async function waitFor(check: () => boolean, timeoutMs: number, stepMs = 100): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (check()) return true; + await new Promise((resolve) => setTimeout(resolve, stepMs)); + } + return check(); +} diff --git a/packages/agent-console/test/v1/i18n.test.ts b/packages/agent-console/test/v1/i18n.test.ts new file mode 100644 index 00000000..04bdf2bd --- /dev/null +++ b/packages/agent-console/test/v1/i18n.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { initLocale, normalizeLang, t } from "../../web/src/i18n/index.js"; + +describe("i18n", () => { + it("normalizeLang: zh variants → zh, anything else → en", () => { + expect(normalizeLang("zh")).toBe("zh"); + expect(normalizeLang("zh-CN")).toBe("zh"); + expect(normalizeLang("en")).toBe("en"); + expect(normalizeLang("ja")).toBe("en"); + }); + + it("UI text is English with ?lang=en", () => { + initLocale("?lang=en"); + expect(t("sidebar.newProject")).toBe("New project"); + expect(t("app.title")).toBe("Agent Console"); + initLocale("?lang=zh"); + expect(t("sidebar.newProject")).toBe("新建项目"); + expect(t("app.title")).toBe("Agent 控制台"); + }); +}); diff --git a/packages/agent-console/test/v1/layout-bounds.test.ts b/packages/agent-console/test/v1/layout-bounds.test.ts new file mode 100644 index 00000000..2c9341e3 --- /dev/null +++ b/packages/agent-console/test/v1/layout-bounds.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { maxPanelWidth, clampLayoutWidths, MAIN_MIN } from "../../web/src/layout/bounds.js"; + +describe("layout bounds", () => { + const base = { sidebarWidth: 248, panelWidth: 440, panelOpen: true, sbHidden: false }; + + it("the right panel cannot exceed the window minus the sidebar and MAIN_MIN", () => { + const vw = 1200; + expect(maxPanelWidth(base, vw)).toBe(1200 - 248 - 10 - MAIN_MIN); + }); + + it("clamp shrinks the panel when the window is narrow", () => { + const sized = clampLayoutWidths({ ...base, panelWidth: 900 }, 1000); + expect(sized.panelWidth).toBeLessThan(900); + expect(1000 - sized.sidebarWidth - sized.panelWidth - 10).toBeGreaterThanOrEqual(MAIN_MIN - 1); + }); + + it("the panel's upper bound shrinks when the sidebar widens", () => { + const vw = 1100; + const a = maxPanelWidth({ ...base, sidebarWidth: 248 }, vw); + const b = maxPanelWidth({ ...base, sidebarWidth: 360 }, vw); + expect(b).toBeLessThan(a); + }); +}); diff --git a/packages/agent-console/test/v1/lifeline.test.ts b/packages/agent-console/test/v1/lifeline.test.ts new file mode 100644 index 00000000..dcee9b00 --- /dev/null +++ b/packages/agent-console/test/v1/lifeline.test.ts @@ -0,0 +1,103 @@ +// Daemon lifeline: --port 0 ready line, Host checked against the actual port, exits when stdin closes, exits when the parent disappears. +import { afterEach, describe, expect, it } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pidAlive, request, waitFor } from "./helpers-net.js"; + +const repo = resolve(__dirname, "..", ".."); +const mainJs = join(repo, "daemon", "dist", "main.js"); +const hasDist = existsSync(mainJs); +const TOKEN = "lifeline-token"; + +let children: ChildProcess[] = []; +let dirs: string[] = []; + +afterEach(() => { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + children = []; + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +function startDaemon(parentPid: number): ChildProcess { + const data = mkdtempSync(join(tmpdir(), "ac-life-")); + dirs.push(data); + const child = spawn(process.execPath, [mainJs, "--port", "0", "--data", data, "--parent-pid", String(parentPid)], { + env: { ...process.env, AGENT_CONSOLE_TOKEN: TOKEN, AGENT_CONSOLE_MOCK: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + children.push(child); + child.stderr!.resume(); + return child; +} + +function readyLine(child: ChildProcess): Promise<{ agentConsoleReady: boolean; port: number; pid: number }> { + return new Promise((resolvePromise, reject) => { + let buffer = ""; + const onData = (chunk: Buffer) => { + buffer += String(chunk); + for (const line of buffer.split(/\r?\n/)) { + try { + const parsed = JSON.parse(line); + if (parsed.agentConsoleReady) { + child.stdout!.off("data", onData); + child.stdout!.resume(); + resolvePromise(parsed); + return; + } + } catch {} + } + }; + child.stdout!.on("data", onData); + child.once("exit", (code) => reject(new Error(`daemon exited early (${code})`))); + }); +} + +function exited(child: ChildProcess): Promise { + if (child.exitCode !== null) return Promise.resolve(child.exitCode); + return new Promise((resolvePromise) => child.once("exit", (code) => resolvePromise(code))); +} + +describe.skipIf(!hasDist)("daemon lifeline (daemon/dist/main.js)", () => { + it("prints a ready line with the real port, checks Host against it, exits when stdin closes", async () => { + const child = startDaemon(process.pid); + const ready = await readyLine(child); + expect(ready.agentConsoleReady).toBe(true); + expect(ready.port).toBeGreaterThan(0); + expect(ready.pid).toBe(child.pid); + + const good = await request(ready.port, "/", { host: `127.0.0.1:${ready.port}` }); + expect(good.status).toBe(200); + const bad = await request(ready.port, "/", { host: `127.0.0.1:${ready.port + 1}` }); + expect(bad.status).toBe(421); + + const started = Date.now(); + child.stdin!.end(); + const code = await Promise.race([exited(child), new Promise((r) => setTimeout(() => r("timeout"), 5000))]); + expect(code).toBe(0); + expect(Date.now() - started).toBeLessThan(5000); + }, 15000); + + it("exits within 7 s when the parent pid dies (stdin kept open)", async () => { + const dummy = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + children.push(dummy); + expect(await waitFor(() => typeof dummy.pid === "number", 2000)).toBe(true); + + const child = startDaemon(dummy.pid!); + const ready = await readyLine(child); + expect(ready.port).toBeGreaterThan(0); + + dummy.kill("SIGKILL"); + await exited(dummy); + const started = Date.now(); + const gone = await waitFor(() => child.exitCode !== null || !pidAlive(child.pid!), 7000); + expect(gone).toBe(true); + expect(Date.now() - started).toBeLessThan(7000); + // stdin never closed: the exit really comes from parent detection + expect(child.stdin!.writableEnded).toBe(false); + }, 15000); +}); diff --git a/packages/agent-console/test/v1/magpie-gateway.test.ts b/packages/agent-console/test/v1/magpie-gateway.test.ts new file mode 100644 index 00000000..f4111622 --- /dev/null +++ b/packages/agent-console/test/v1/magpie-gateway.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { parseCapabilities } from "../../daemon/src/providers/magpieGateway.js"; + +describe("magpie gateway capability table", () => { + it("effort / xhigh_effort / max_effort become levels", () => { + const m = parseCapabilities("devin/swe-2=effort,xhigh_effort,max_effort;cursor/auto=effort;cursor/grok-4.7@256k=effort,xhigh_effort;x/no=foo"); + expect(m.get("devin/swe-2")).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(m.get("cursor/auto")).toEqual(["low", "medium", "high"]); + expect(m.get("cursor/grok-4.7@256k")).toEqual(["low", "medium", "high", "xhigh"]); + expect(m.has("x/no")).toBe(false); + }); + + it("empty or missing returns an empty table", () => { + expect(parseCapabilities(undefined).size).toBe(0); + expect(parseCapabilities("").size).toBe(0); + }); +}); diff --git a/packages/agent-console/test/v1/manager.test.ts b/packages/agent-console/test/v1/manager.test.ts new file mode 100644 index 00000000..e8b41720 --- /dev/null +++ b/packages/agent-console/test/v1/manager.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentManager } from "../../daemon/src/agents/manager.js"; +import { ThreadsStore } from "../../daemon/src/store/threads.js"; +import { ProjectsStore } from "../../daemon/src/store/projects.js"; +import { SettingsStore } from "../../daemon/src/store/settings.js"; +import { AttachmentStore } from "../../daemon/src/store/attachments.js"; +import { Gateway } from "../../daemon/src/ws/gateway.js"; +import { initProviders } from "../../daemon/src/providers/registry.js"; +import { mockProviderMetrics } from "../../daemon/src/providers/mock.js"; +import type { Config } from "../../daemon/src/config.js"; + +process.env.AGENT_CONSOLE_MOCK = "1"; + +const cfg: Config = { port: 0, dataDir: "", token: "t", version: "0.0.0", idleExitMinutes: 60, buildStamp: "0" }; + +let dir: string; +let threads: ThreadsStore; +let projects: ProjectsStore; +let settings: SettingsStore; +let gw: Gateway; +let manager: AgentManager; +let threadId: string; + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "ac-test-")); + mockProviderMetrics.createSessionCalls = 0; + threads = new ThreadsStore(dir); + projects = new ProjectsStore(dir); + settings = new SettingsStore(dir); + gw = new Gateway(cfg); + initProviders(); + manager = new AgentManager(gw, threads, projects, settings, new AttachmentStore(join(dir, "attachments"))); + const proj = await projects.create(dir); + const meta = await threads.create({ projectId: proj.id, provider: "mock" }); + threadId = meta.id; +}); + +afterEach(() => { + manager.shutdown(); + rmSync(dir, { recursive: true, force: true }); +}); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const waitFor = async (fn: () => boolean | Promise, ms = 10000) => { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + if (await fn()) return; + await sleep(50); + } + throw new Error("waitFor 超时"); +}; +const events = () => threads.readEvents(threadId, 0, 50000); + +describe("AgentManager", () => { + it("listCommands creates no session by default", async () => { + await manager.listCommands(threadId); + expect(mockProviderMetrics.createSessionCalls).toBe(0); + }); + + it("concurrent listCommands(load) and send create only one session", async () => { + await Promise.all([ + manager.listCommands(threadId, { load: true }), + manager.send(threadId, { text: "你好" }), + ]); + await waitFor(async () => (await threads.get(threadId))?.status === "idle", 20000); + expect(mockProviderMetrics.createSessionCalls).toBe(1); + }, 25000); + + it("send → turn_ended completed", async () => { + await manager.send(threadId, { text: "你好" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + const evs = await events(); + expect(evs.some((e) => e.type === "user_message")).toBe(true); + expect(evs.some((e) => e.type === "turn_started")).toBe(true); + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("completed"); + // providerSessionId was written back + const meta = await threads.get(threadId); + expect(meta?.providerSessionId).toMatch(/^mock-/); + // Auto title + expect(meta?.title).toBe("你好"); + }, 15000); + + it("send while running → queued, sent automatically when done", async () => { + await manager.send(threadId, { text: "/slow" }); + await waitFor(async () => (await threads.get(threadId))?.status === "running"); + await manager.send(threadId, { text: "第二条" }); + expect(manager.runtimeInfo(threadId)?.queue.length).toBe(1); + await waitFor(async () => (await threads.get(threadId))?.status === "idle", 20000); + const evs = await events(); + const users = evs.filter((e) => e.type === "user_message").map((e) => (e as { text: string }).text); + expect(users).toEqual(["/slow", "第二条"]); + }, 25000); + + it("steer: steers directly while running", async () => { + await manager.send(threadId, { text: "/slow" }); + await waitFor(async () => (await threads.get(threadId))?.status === "running"); + await manager.send(threadId, { text: "顺便改个名" }, "steer"); + const evs = await events(); + const steered = evs.find((e) => e.type === "user_message" && (e as { steered?: boolean }).steered); + expect(steered).toBeTruthy(); + expect(manager.runtimeInfo(threadId)?.queue.length).toBe(0); + await waitFor(async () => (await threads.get(threadId))?.status === "idle", 20000); + }, 25000); + + it("interrupt: back to idle within 1 second", async () => { + await manager.send(threadId, { text: "/slow" }); + await waitFor(async () => (await threads.get(threadId))?.status === "running"); + const t0 = Date.now(); + await manager.interrupt(threadId); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + expect(Date.now() - t0).toBeLessThan(1000); + const evs = await events(); + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("interrupted"); + }, 15000); + + it("pending permission → respond allow", async () => { + await manager.send(threadId, { text: "/perm" }); + await waitFor(async () => (manager.runtimeInfo(threadId)?.pending.length ?? 0) > 0); + const pending = manager.runtimeInfo(threadId)!.pending[0] as { requestId: string }; + await manager.respond(threadId, pending.requestId, { optionId: "accept" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + const evs = await events(); + const resolved = evs.find((e) => e.type === "permission_resolved"); + expect(resolved && (resolved as { optionId?: string }).optionId).toBe("accept"); + const tool = evs.find((e) => e.type === "tool_call" && (e as { status?: string }).status === "ok"); + expect(tool).toBeTruthy(); + }, 15000); + + it("invalid optionId → 400 (bad_params)", async () => { + await manager.send(threadId, { text: "/perm" }); + await waitFor(async () => (manager.runtimeInfo(threadId)?.pending.length ?? 0) > 0); + const pending = manager.runtimeInfo(threadId)!.pending[0] as { requestId: string }; + await expect(manager.respond(threadId, pending.requestId, { optionId: "bogus" })).rejects.toThrow(/optionId 非法/); + // Still replies normally + await manager.respond(threadId, pending.requestId, { optionId: "decline" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + }, 15000); + + it("interrupt cancels pending permissions automatically", async () => { + await manager.send(threadId, { text: "/perm" }); + await waitFor(async () => (manager.runtimeInfo(threadId)?.pending.length ?? 0) > 0); + // While /perm is pending the status is still running (waiting for the user) — manager.interrupt needs running, and a waiting mock counts as running + const meta = await threads.get(threadId); + expect(meta?.status).toBe("running"); + await manager.interrupt(threadId); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + const evs = await events(); + expect(evs.some((e) => e.type === "permission_resolved" && (e as { cancelled?: boolean }).cancelled)).toBe(true); + expect(manager.runtimeInfo(threadId)?.pending.length).toBe(0); + }, 15000); + + it("/exit crash → closed; the next send resumes", async () => { + await manager.send(threadId, { text: "/exit" }); + await waitFor(async () => (await threads.get(threadId))?.status === "closed"); + const evs = await events(); + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("failed"); + expect(end && (end as { error?: string }).error).toContain("意外退出"); + const oldSid = (await threads.get(threadId))?.providerSessionId; + + // Send again → resume + await manager.send(threadId, { text: "再来" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + const meta = await threads.get(threadId); + expect(meta?.providerSessionId).toBe(oldSid); // resumeId is passed to the new session; the mock keeps the same id + const msgs = (await events()).filter((e) => e.type === "assistant_message"); + const last = msgs[msgs.length - 1] as { text?: string } | undefined; + expect(last?.text?.startsWith("(已续接)")).toBe(true); + }, 20000); + + it("restart recovery: running → closed + notice + turn_ended(interrupted) + permissions cancelled", async () => { + // Build a running state + an unresolved permission request + const proj = await projects.create(dir); + const t = await threads.create({ projectId: proj.id, provider: "mock" }); + await threads.patch(t.id, { status: "running" }); + const seq0 = (await threads.get(t.id))!.seq; + await threads.appendEvent(t.id, { + seq: seq0 + 1, ts: Date.now(), threadId: t.id, type: "turn_started", turnId: "u1", model: null, mode: "default", effort: null, + }); + await threads.appendEvent(t.id, { + seq: seq0 + 2, ts: Date.now(), threadId: t.id, type: "permission_request", requestId: "r1", + title: "运行 x", kind: "tool", options: [{ id: "accept", label: "允许", kind: "allow_once" }], + }); + + const m2 = new AgentManager(gw, threads, projects, settings, new AttachmentStore(join(dir, "attachments"))); + await m2.recoverOnStart(); + const meta = await threads.get(t.id); + expect(meta?.status).toBe("closed"); + const evs = await threads.readEvents(t.id, 0, 50000); + expect(evs.some((e) => e.type === "notice" && (e as { message?: string }).message?.includes("已重启"))).toBe(true); + expect(evs.some((e) => e.type === "turn_ended" && (e as { status?: string }).status === "interrupted")).toBe(true); + expect(evs.some((e) => e.type === "permission_resolved" && (e as { cancelled?: boolean }).cancelled)).toBe(true); + m2.shutdown(); + }, 15000); + + it("idle recycling: idle timeout → closed", async () => { + await settings.set({ idleCloseMinutes: 0.0001 }); // ~6ms + await manager.send(threadId, { text: "你好" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + // lastActivity is in milliseconds; wait more than 6ms, then trigger recycling by hand + await sleep(50); + await (manager as unknown as { recycleIdle(): Promise }).recycleIdle(); + expect((await threads.get(threadId))?.status).toBe("closed"); + }, 15000); +}); diff --git a/packages/agent-console/test/v1/migrate.test.ts b/packages/agent-console/test/v1/migrate.test.ts new file mode 100644 index 00000000..b9cfa3c6 --- /dev/null +++ b/packages/agent-console/test/v1/migrate.test.ts @@ -0,0 +1,141 @@ +// Legacy migration unit test (§10.1): build an old layout → migrateLegacy → check the new threads/projects/settings. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { migrateLegacy } from "../../daemon/src/migrate/legacy.js"; +import { SettingsStore } from "../../daemon/src/store/settings.js"; +import { ProjectsStore } from "../../daemon/src/store/projects.js"; +import { ThreadsStore } from "../../daemon/src/store/threads.js"; + +let dir: string; +let realCwd: string; + +async function seed() { + dir = await mkdtemp(join(tmpdir(), "ac-migrate-")); + realCwd = join(dir, "proj-a"); + await mkdir(realCwd, { recursive: true }); + // Old settings.json (with a projects list and UI preferences) + await writeFile(join(dir, "settings.json"), JSON.stringify({ + defaultAgent: "codex", defaultPermission: "auto", sendKey: "ctrlEnter", showThinking: false, theme: "dark", + projects: [{ name: "A 项目", path: realCwd }], + })); + const sdir = join(dir, "sessions"); + // Session 1: normal (tool.call+result, text, run.end) + await mkdir(join(sdir, "s_aaa"), { recursive: true }); + await writeFile(join(sdir, "s_aaa", "meta.json"), JSON.stringify({ + id: "s_aaa", agent: "claude", cwd: realCwd, model: "sonnet", permission: "default", + title: "正常会话", titleAuto: false, pinned: true, cliSessionId: "cli-123", + created: 1700000000000, updated: 1700000100000, lastStatus: "ok", + totalCost: 0.12, totalUsage: { inputTokens: 100, outputTokens: 50 }, + })); + await writeFile(join(sdir, "s_aaa", "events.jsonl"), [ + JSON.stringify({ type: "user", text: "看下代码" }), + JSON.stringify({ type: "text", text: "好的" }), + JSON.stringify({ type: "thinking", text: "先读文件" }), + JSON.stringify({ type: "tool.call", itemId: "c1", kind: "read", title: "读 a.ts" }), + JSON.stringify({ type: "tool.result", itemId: "c1", ok: true, output: "content" }), + JSON.stringify({ type: "todo", items: [{ text: "改", status: "done" }, { text: "测", status: "pending" }] }), + JSON.stringify({ type: "run.end", status: "ok", ms: 5000, usage: { inputTokens: 10, outputTokens: 5 }, costUsd: 0.01 }), + "坏行{", + ].join("\n")); + // Session 2: test title → archived; orphan cwd (nonexistent directory → no project added → skipped) + await mkdir(join(sdir, "s_bbb"), { recursive: true }); + await writeFile(join(sdir, "s_bbb", "meta.json"), JSON.stringify({ + id: "s_bbb", agent: "claude", cwd: join(dir, "gone-dir"), permission: "default", + title: "/slow 测试", titleAuto: true, pinned: false, cliSessionId: null, + created: 1, updated: 2, + })); + await writeFile(join(sdir, "s_bbb", "events.jsonl"), JSON.stringify({ type: "user", text: "x" })); + // Session 3: orphan cwd = a project subdirectory → folded into the parent project + await mkdir(join(realCwd, "sub"), { recursive: true }); + await mkdir(join(sdir, "s_ccc"), { recursive: true }); + await writeFile(join(sdir, "s_ccc", "meta.json"), JSON.stringify({ + id: "s_ccc", agent: "unknown-agent", cwd: join(realCwd, "sub"), permission: "full", + title: "子目录会话", titleAuto: true, pinned: false, cliSessionId: "zzz", + created: 3, updated: 4, + })); + await writeFile(join(sdir, "s_ccc", "events.jsonl"), JSON.stringify({ type: "text", text: "hi" })); + return dir; +} + +beforeEach(async () => { await seed(); }); +afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + +describe("migrateLegacy", () => { + it("migrates sessions/projects/settings, backs up the old directory, sets v1Migrated", async () => { + const stores = { settings: new SettingsStore(dir), projects: new ProjectsStore(dir), threads: new ThreadsStore(dir) }; + const r = await migrateLegacy(dir, stores); + expect(r.migrated).toBe(2); // s_aaa + s_ccc (s_bbb's cwd doesn't exist and is skipped) + + const projs = await stores.projects.list(); + expect(projs).toHaveLength(1); + expect(projs[0].name).toBe("A 项目"); + + const threads = await stores.threads.list(); + expect(threads).toHaveLength(2); + const t1 = threads.find((t) => t.title === "正常会话")!; + expect(t1.provider).toBe("claude"); + expect(t1.providerSessionId).toBe("cli-123"); + expect(t1.status).toBe("closed"); + expect(t1.pinned).toBe(true); + expect(t1.archived).toBe(false); + expect(t1.mode).toBe("default"); + expect(t1.usage.costUsd).toBeCloseTo(0.12); + expect(t1.importedFrom).toEqual({ provider: "claude", sessionId: "cli-123" }); + + const evs = await stores.threads.readEvents(t1.id); + const types = evs.map((e) => e.type); + expect(types).toEqual(["user_message", "assistant_message", "reasoning", "tool_call", "tool_call", "todo", "usage", "turn_ended"]); + const calls = evs.filter((e) => e.type === "tool_call"); + expect((calls[0] as { status: string }).status).toBe("running"); + expect((calls[1] as { status: string }).status).toBe("ok"); + expect((calls[1] as { output?: string }).output).toBe("content"); + const end = evs.find((e) => e.type === "turn_ended") as { status: string; durationMs?: number }; + expect(end.status).toBe("completed"); + expect(end.durationMs).toBe(5000); + const seqs = evs.map((e) => e.seq); + expect([...seqs].sort((a, b) => a - b)).toEqual(seqs); // Strictly increasing + + const t3 = threads.find((t) => t.title === "子目录会话")!; + expect(t3.provider).toBe("mock"); // Unknown agent + expect(t3.archived).toBe(true); + expect(t3.mode).toBe("bypassPermissions"); // full mapping + expect(t3.projectId).toBe(projs[0].id); // Subdirectory folded into its parent + + const s = await stores.settings.get(); + expect(s.v1Migrated).toBe(true); + expect(s.theme).toBe("dark"); + expect(s.sendKey).toBe("ctrlEnter"); + expect(s.showThinking).toBe(false); + expect(s.defaultProvider).toBe("codex"); + expect(s.defaultMode).toBe("acceptEdits"); // auto mapping + + // Backup exists, old files untouched + const trash = join(dir, "trash"); + expect(existsSync(trash)).toBe(true); + expect(existsSync(join(dir, "sessions", "s_aaa", "meta.json"))).toBe(true); + }); + + it("runs again: skipped after v1Migrated", async () => { + const stores = { settings: new SettingsStore(dir), projects: new ProjectsStore(dir), threads: new ThreadsStore(dir) }; + await migrateLegacy(dir, stores); + const r2 = await migrateLegacy(dir, stores); + expect(r2.skipped).toBe(true); + expect(r2.migrated).toBe(0); + expect(await stores.threads.list()).toHaveLength(2); + }); + + it("empty data directory: sets v1Migrated and returns skipped", async () => { + const empty = await mkdtemp(join(tmpdir(), "ac-mig-empty-")); + try { + const stores = { settings: new SettingsStore(empty), projects: new ProjectsStore(empty), threads: new ThreadsStore(empty) }; + const r = await migrateLegacy(empty, stores); + expect(r).toEqual({ migrated: 0, skipped: true }); + expect((await stores.settings.get()).v1Migrated).toBe(true); + } finally { + await rm(empty, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/agent-console/test/v1/mock.test.ts b/packages/agent-console/test/v1/mock.test.ts new file mode 100644 index 00000000..2d7d04cb --- /dev/null +++ b/packages/agent-console/test/v1/mock.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMockProvider } from "../../daemon/src/providers/mock.js"; +import type { MockSession } from "../../daemon/src/providers/mock.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; +import { initProviders, getProvider } from "../../daemon/src/providers/registry.js"; + +const opts = { + threadId: "th1", + cwd: "D:/proj", + model: null, + mode: "default" as const, + effort: null, + resumeId: null as string | null, +}; + +async function collect(session: MockSession, until: (ev: ProviderEvent) => boolean, timeoutMs = 15000): Promise { + const out: ProviderEvent[] = []; + const it = session.events[Symbol.asyncIterator](); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const r = await Promise.race([ + it.next(), + new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), Math.max(1, deadline - Date.now()))), + ]); + if (r.done) break; + out.push(r.value); + if (until(r.value)) break; + } + return out; +} + +const isEnd = (ev: ProviderEvent) => ev.type === "turn_ended"; +const types = (evs: ProviderEvent[]) => evs.map((e) => e.type); + +describe("mock provider", () => { + beforeEach(() => { + process.env.AGENT_CONSOLE_MOCK = "1"; + }); + + it("registry registers mock only with MOCK=1", async () => { + delete process.env.AGENT_CONSOLE_MOCK; + initProviders(); + expect(getProvider("mock")).toBeUndefined(); + process.env.AGENT_CONSOLE_MOCK = "1"; + initProviders(); + expect(getProvider("mock")?.id).toBe("mock"); + }); + + it("detect/listModels/createSession", async () => { + const p = createMockProvider(); + expect((await p.detect()).available).toBe(true); + const models = await p.listModels(); + expect(models.length).toBe(2); + expect(models[0].isDefault).toBe(true); + const s = (await p.createSession(opts)) as MockSession; + const it = s.events[Symbol.asyncIterator](); + const first = await it.next(); + expect(first.value.type).toBe("provider_session_id"); + await s.close(); + }); + + it("default script: a complete event sequence for one turn", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const collected: ProviderEvent[] = []; + const pump = (async () => { + for await (const ev of s.events) collected.push(ev); + })(); + await s.send({ text: "帮我改一下", images: [] }); + await new Promise((r) => setTimeout(r, 9000)); + await s.close(); + await pump; + const t = types(collected); + expect(t[0]).toBe("provider_session_id"); + expect(t).toContain("turn_started"); + expect(t).toContain("reasoning"); + expect(t).toContain("tool_call"); + expect(t).toContain("todo"); + expect(t).toContain("assistant_delta"); + expect(t).toContain("assistant_message"); + expect(t).toContain("usage"); + const end = collected.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("completed"); + }, 20000); + + it("/perm allow → tool succeeds; deny → failed", async () => { + const p = createMockProvider(); + // Allow + let s = (await p.createSession(opts)) as MockSession; + let pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "permission_request") { + setTimeout(() => s.respond(ev.requestId, { optionId: "accept" }), 10); + } + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/perm", images: [] }); + let evs = await pump; + let tool = evs.find((e) => e.type === "tool_call" && e.status === "ok"); + expect(tool).toBeTruthy(); + await s.close(); + + // Deny + s = (await p.createSession(opts)) as MockSession; + pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "permission_request") { + setTimeout(() => s.respond(ev.requestId, { optionId: "decline" }), 10); + } + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/perm", images: [] }); + evs = await pump; + tool = evs.find((e) => e.type === "tool_call" && e.status === "failed"); + expect(tool).toBeTruthy(); + const msg = evs.find((e) => e.type === "assistant_message"); + expect(msg && (msg as { text?: string }).text).toContain("跳过"); + await s.close(); + }, 20000); + + it("/plan deny → reply", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "permission_request") { + setTimeout(() => s.respond(ev.requestId, { optionId: "reject" }), 10); + } + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/plan", images: [] }); + const evs = await pump; + expect(evs.some((e) => e.type === "plan")).toBe(true); + const msg = evs.find((e) => e.type === "assistant_message"); + expect(msg && (msg as { text?: string }).text).toContain("怎么改"); + await s.close(); + }, 20000); + + it("/ask answers are woven into the reply", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "question") { + setTimeout(() => s.respond(ev.requestId, { answers: { q1: "prod", q2: "周五前" } }), 10); + } + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/ask", images: [] }); + const evs = await pump; + const msg = evs.find((e) => e.type === "assistant_message"); + expect(msg && (msg as { text?: string }).text).toContain("prod"); + await s.close(); + }, 20000); + + it("interrupt → interrupted within 1 second", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/slow", images: [] }); + await new Promise((r) => setTimeout(r, 600)); + const t0 = Date.now(); + await s.interrupt(); + const evs = await pump; + expect(Date.now() - t0).toBeLessThan(1000); + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("interrupted"); + await s.close(); + }, 20000); + + it("/error fails midway", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/error", images: [] }); + const evs = await pump; + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("failed"); + expect(end && (end as { error?: string }).error).toContain("构建失败"); + await s.close(); + }, 20000); + + it("/exit crash: the stream ends with no turn_ended", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) evs.push(ev); + return evs; + })(); + await s.send({ text: "/exit", images: [] }); + const evs = await pump; + expect(evs.some((e) => e.type === "turn_ended")).toBe(false); + }, 20000); + + it("resumeId → the first reply carries the resumed prefix", async () => { + const s = (await createMockProvider().createSession({ ...opts, resumeId: "abc123" })) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "继续", images: [] }); + const evs = await pump; + const msg = evs.find((e) => e.type === "assistant_message"); + expect(msg && (msg as { text?: string }).text?.startsWith("(已续接)")).toBe(true); + await s.close(); + }, 20000); +}); diff --git a/packages/agent-console/test/v1/pickFolder.test.ts b/packages/agent-console/test/v1/pickFolder.test.ts new file mode 100644 index 00000000..62c0d9aa --- /dev/null +++ b/packages/agent-console/test/v1/pickFolder.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { decodePickFolderStdout } from "../../daemon/src/util/pickFolderWin.js"; + +describe("pickFolder stdout decoding", () => { + it("Base64 UTF-8 path", () => { + const path = "D:\\workwork\\magpie插件"; + const b64 = Buffer.from(path, "utf8").toString("base64"); + expect(decodePickFolderStdout(`${b64}\r\n`)).toBe(path); + }); + + it("plain ASCII path (old Write-Output)", () => { + expect(decodePickFolderStdout("D:\\workwork\\demo\r\n")).toBe("D:\\workwork\\demo"); + }); + + it("CP936 bytes (static sample) fall back to GBK", () => { + // GBK encoding of "D:\\workwork\\magpie插件": 插=b2e5 件=bcfe + const buf = Buffer.concat([Buffer.from("D:\\workwork\\magpie", "ascii"), Buffer.from("b2e5bcfe", "hex"), Buffer.from("\r\n")]); + expect(decodePickFolderStdout(buf)).toBe("D:\\workwork\\magpie插件"); + }); + + it.skipIf(process.platform !== "win32")("CP936 console output falls back to GBK (real powershell.exe)", () => { + const buf = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-Command", + "[Console]::OutputEncoding = [Text.Encoding]::GetEncoding(936); " + + "[Console]::Out.WriteLine('D:\\workwork\\magpie插件')", + ], + { encoding: "buffer" }, + ); + expect(decodePickFolderStdout(buf)).toBe("D:\\workwork\\magpie插件"); + }); +}); diff --git a/packages/agent-console/test/v1/plugin-entry.test.ts b/packages/agent-console/test/v1/plugin-entry.test.ts new file mode 100644 index 00000000..8d59e183 --- /dev/null +++ b/packages/agent-console/test/v1/plugin-entry.test.ts @@ -0,0 +1,170 @@ +// Plugin entry (index.mjs): import has no side effects; server() → session.js → /open → daemon → stop() end to end. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { canBind, freePort, pidAlive, request, waitFor } from "./helpers-net.js"; + +const repo = resolve(__dirname, "..", ".."); +const entryUrl = pathToFileURL(join(repo, "index.mjs")).href; +const hasDist = existsSync(join(repo, "daemon", "dist", "main.js")); +const ENV_KEYS = ["AGENT_CONSOLE_PAGE_DIR", "XDG_CONFIG_HOME", "AGENT_CONSOLE_PORT", "AGENT_CONSOLE_MOCK"]; + +let savedEnv: Record = {}; +let tempDirs: string[] = []; +let pageDir = ""; +let leftoverPids: number[] = []; +let stopHost: (() => Promise) | null = null; + +function parseSession(text: string): { v: number; port: number; key: string; token?: string } { + const match = /^globalThis\.AgentConsoleSession = (\{.*\});\n$/.exec(text); + if (!match) throw new Error(`unexpected session.js: ${text}`); + return JSON.parse(match[1]); +} + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + pageDir = mkdtempSync(join(tmpdir(), "ac-entry-page-")); + const xdg = mkdtempSync(join(tmpdir(), "ac-entry-xdg-")); + tempDirs = [pageDir, xdg]; + process.env.AGENT_CONSOLE_PAGE_DIR = pageDir; + process.env.XDG_CONFIG_HOME = xdg; + leftoverPids = []; + stopHost = null; +}); + +afterEach(async () => { + if (stopHost) await stopHost().catch(() => {}); + for (const pid of leftoverPids) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); +}); + +describe("plugin entry (index.mjs)", () => { + it("import has no side effects: no listen, no session.js, no spawn", async () => { + const port = await freePort(); + process.env.AGENT_CONSOLE_PORT = String(port); + vi.resetModules(); + const mod = await import(/* @vite-ignore */ `${entryUrl}?noside=${Date.now()}`); + expect(typeof mod.default.server).toBe("function"); + expect(mod.default.id).toBe("agent-console"); + // Give possible async side effects a moment + await new Promise((r) => setTimeout(r, 300)); + expect(existsSync(join(pageDir, "session.js"))).toBe(false); + expect(await canBind(port)).toBe(true); + expect(existsSync(join(process.env.XDG_CONFIG_HOME!, "magpie", "agent-console"))).toBe(false); + }); + + it.skipIf(!hasDist)( + "end to end: server() → session.js → /open → daemon → stop()", + async () => { + const port = await freePort(); + process.env.AGENT_CONSOLE_MOCK = "1"; + delete process.env.AGENT_CONSOLE_PORT; + vi.resetModules(); + const mod = await import(/* @vite-ignore */ `${entryUrl}?e2e=${Date.now()}`); + const host = await mod.default.server({}, { port }); + stopHost = () => host.stop(); + + const sessionPath = join(pageDir, "session.js"); + expect(await waitFor(() => existsSync(sessionPath), 5000)).toBe(true); + const session = parseSession(readFileSync(sessionPath, "utf8")); + expect(session.v).toBe(2); + expect(session.port).toBe(port); + expect(session.key).toMatch(/^[0-9a-f]{64}$/); + // Any page that knows Magpie's page path can read session.js via + + diff --git a/packages/agent-console/web/src/App.tsx b/packages/agent-console/web/src/App.tsx new file mode 100644 index 00000000..ada2890d --- /dev/null +++ b/packages/agent-console/web/src/App.tsx @@ -0,0 +1,72 @@ +// App shell: connection state → three-column layout. Each area is filled in by P2-2..P2-6. +import { useConnection } from "./stores/connection"; +import { Layout } from "./components/Layout"; +import { Sidebar } from "./components/Sidebar"; +import { Topbar } from "./components/Topbar"; +import { Chat } from "./components/Chat"; +import { useTimeline } from "./stores/timeline"; +import { useData } from "./stores/data"; +import { useEffect, useState } from "react"; +import { CommandPalette } from "./components/CommandPalette"; +import { Panel } from "./components/Panel"; +import { SettingsDialog } from "./components/Settings"; +import { Toasts } from "./components/Toast"; +import { t } from "./i18n/index.js"; + +export function App() { + const { state, version } = useConnection(); + const pending = useTimeline((s) => s.pending); + const providers = useData((s) => s.providers); + const [palette, setPalette] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + // Ctrl+K command palette; open-settings custom event; Esc closes panels + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPalette((p) => !p); + } + }; + const onSettings = () => setSettingsOpen(true); + window.addEventListener("keydown", onKey); + window.addEventListener("open-settings", onSettings); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("open-settings", onSettings); + }; + }, []); + + if (state === "down") { + return ( +
+
+ {t("boot.serviceDown")} + +
+
+ ); + } + if (state === "connecting" && !version) { + return ( +
+
+ {t("boot.connecting")} +
+ ); + } + + return ( + useTimeline.getState().threadId!).filter(Boolean))} />} + topbar={} + panel={} + > + {state === "reconnecting" &&
{t("boot.reconnecting")}
} + + {palette && setPalette(false)} />} + {settingsOpen && setSettingsOpen(false)} providers={providers} />} + +
+ ); +} diff --git a/packages/agent-console/web/src/Debug.tsx b/packages/agent-console/web/src/Debug.tsx new file mode 100644 index 00000000..aba4e5db --- /dev/null +++ b/packages/agent-console/web/src/Debug.tsx @@ -0,0 +1,192 @@ +// P1-8 debug page (?debug=1): project picker, conversation list, composer, raw timeline, permission buttons. +// For integration only; the real UI is rewritten in P2. +import { useEffect, useRef, useState } from "react"; +import { useConnection } from "./stores/connection"; +import { ws } from "./main"; + +type Project = { id: string; name: string; path: string }; +type ThreadMeta = { + id: string; projectId: string; title: string; provider: string; + status: string; archived: boolean; +}; +type Ev = Record & { seq?: number; type?: string }; +type Pending = { requestId: string; kind: string; options?: { id: string; label: string }[] }; + +const call = (m: string, p?: unknown) => ws.call(m, p); + +export function DebugApp() { + const { state, version } = useConnection(); + const [projects, setProjects] = useState([]); + const [threads, setThreads] = useState([]); + const [projectId, setProjectId] = useState(""); + const [threadId, setThreadId] = useState(""); + const [events, setEvents] = useState([]); + const [pending, setPending] = useState([]); + const [queue, setQueue] = useState([]); + const [input, setInput] = useState(""); + const [err, setErr] = useState(""); + const subRef = useRef<(() => void) | null>(null); + + useEffect(() => { + if (state !== "open") return; + void call("projects.list").then((r) => setProjects((r as Project[]) ?? [])); + void call("threads.list").then((r) => setThreads((r as ThreadMeta[]) ?? [])); + }, [state]); + + useEffect(() => { + subRef.current?.(); + subRef.current = null; + setEvents([]); + setPending([]); + setQueue([]); + if (!threadId || state !== "open") return; + void call("threads.get", { id: threadId }).then((r) => { + const res = r as { events?: Ev[]; pending?: Pending[]; queue?: unknown[] }; + setEvents(res.events ?? []); + setPending(res.pending ?? []); + setQueue(res.queue ?? []); + }); + subRef.current = ws.subscribe(`thread:${threadId}`, (_ch, data) => { + const d = data as { type?: string; event?: Ev; queue?: unknown[] }; + if (d.type === "event" && d.event) { + setEvents((evs) => [...evs, d.event!]); + if (d.event.type === "permission_resolved" || d.event.type === "question_answered") { + const rid = (d.event as { requestId?: string }).requestId; + setPending((ps) => ps.filter((p) => p.requestId !== rid)); + } + } + if (d.type === "queue" && d.queue) setQueue(d.queue); + }); + return () => subRef.current?.(); + }, [threadId, state]); + + // Pending changes are also filled in from timeline events (when a permission_request event arrives) + useEffect(() => { + const reqs = events.filter((e) => e.type === "permission_request" || e.type === "question"); + const resolved = new Set( + events + .filter((e) => e.type === "permission_resolved" || e.type === "question_answered") + .map((e) => (e as { requestId?: string }).requestId), + ); + const next = reqs + .filter((e) => !resolved.has((e as { requestId?: string }).requestId)) + .map((e) => ({ + requestId: (e as { requestId?: string }).requestId ?? "", + kind: e.type === "question" ? "question" : "permission", + options: (e as { options?: { id: string; label: string }[] }).options, + })); + setPending((cur) => { + const merged = new Map(cur.map((p) => [p.requestId, p])); + for (const p of next) merged.set(p.requestId, p); + return [...merged.values()]; + }); + }, [events]); + + const refresh = async () => { + if (!threadId) return; + const r = (await call("threads.get", { id: threadId }).catch(() => null)) as { + events?: Ev[]; pending?: Pending[]; queue?: unknown[]; + } | null; + if (r) { + setEvents(r.events ?? []); + setPending((cur) => { + const merged = new Map(cur.map((p) => [p.requestId, p])); + for (const p of r.pending ?? []) merged.set(p.requestId, p); + return [...merged.values()]; + }); + setQueue(r.queue ?? []); + } + }; + + const send = async (behavior?: "queue" | "steer") => { + setErr(""); + if (!threadId || !input.trim()) return; + const text = input; + setInput(""); + try { + await call("thread.send", { id: threadId, text, behavior }); + } catch (e) { + setErr(String((e as Error)?.message ?? e)); + } + // sub.add may not have completed yet → fetch the current events once to catch up + await refresh(); + }; + + const respond = async (requestId: string, optionId?: string, answers?: Record) => { + await call("thread.respond", { id: threadId, requestId, optionId, answers }).catch((e) => setErr(String((e as Error)?.message ?? e))); + setTimeout(() => void refresh(), 1500); // Let the approved tool_call ok land in the list + }; + + const createThread = async () => { + if (!projectId) return; + const meta = (await call("threads.create", { projectId, provider: "mock" }).catch((e) => { setErr(String((e as Error)?.message ?? e)); return null; })) as ThreadMeta | null; + if (meta) { + setThreads((ts) => [meta, ...ts]); + setThreadId(meta.id); + } + }; + + return ( +
+
+
{state === "open" ? `已连接 · ${version ?? ""}` : "连接中…"}
+ + +
+ {threads.filter((t) => !projectId || t.projectId === projectId).map((t) => ( +
setThreadId(t.id)} + style={{ + padding: "6px 8px", cursor: "pointer", borderRadius: 4, + background: t.id === threadId ? "var(--bg-active, #333)" : "transparent", + }} + > + {t.title} ({t.status}) +
+ ))} +
+
+
+
+ {events.map((e, i) => ( +
+              {(e.seq ?? "") + " " + (e.type ?? "?") + " " + JSON.stringify(e).slice(0, 400)}
+            
+ ))} + {pending.map((p) => ( +
+
授权/提问 {p.requestId}
+ {p.options?.map((o) => ( + + ))} + {p.kind === "question" && ( + + )} + +
+ ))} +
+ {queue.length > 0 &&
排队中:{queue.length} 条
} + {err &&
{err}
} +
+ setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void send(); }} + placeholder="/perm /plan /slow /exit …" + style={{ flex: 1 }} + /> + + + +
+
+
+ ); +} diff --git a/packages/agent-console/web/src/api/token.ts b/packages/agent-console/web/src/api/token.ts new file mode 100644 index 00000000..19208a5e --- /dev/null +++ b/packages/agent-console/web/src/api/token.ts @@ -0,0 +1,5 @@ +// The daemon token lives in the fragment so it is not sent in the HTTP Referer. +export function getToken(): string { + const fragment = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash; + return new URLSearchParams(fragment).get("t") ?? ""; +} diff --git a/packages/agent-console/web/src/api/ws.ts b/packages/agent-console/web/src/api/ws.ts new file mode 100644 index 00000000..53a11463 --- /dev/null +++ b/packages/agent-console/web/src/api/ws.ts @@ -0,0 +1,174 @@ +// WS client: hello, request/response, subscriptions, auto-reconnect, binary frames. +import { t } from "../i18n/index.js"; +export type ConnState = "connecting" | "open" | "reconnecting" | "down"; +export type EvtHandler = (ch: string, data: unknown) => void; +export type BinHandler = (op: number, index: number, payload: Uint8Array) => void; + +type Pending = { res: (v: unknown) => void; rej: (e: Error) => void; timer: ReturnType }; + +export class WsClient { + private ws: WebSocket | null = null; + private seq = 0; + private pending = new Map(); + private subs = new Map number | undefined }>(); + private binHandlers: BinHandler[] = []; + private stateListeners = new Set<(s: ConnState) => void>(); + private state: ConnState = "connecting"; + private retry = 0; + private closed = false; + private helloOk: Record | null = null; + private helloWaiters: ((v: Record) => void)[] = []; + + constructor(private token: string, private lang = "zh") {} + + onState(fn: (s: ConnState) => void): () => void { + this.stateListeners.add(fn); + fn(this.state); + return () => this.stateListeners.delete(fn); + } + getState(): ConnState { + return this.state; + } + private setState(s: ConnState): void { + this.state = s; + for (const f of this.stateListeners) f(s); + } + + connect(): void { + if (this.closed) return; + this.setState(this.retry === 0 ? "connecting" : "reconnecting"); + const ws = new WebSocket(`ws://${location.host}/ws`); + ws.binaryType = "arraybuffer"; + this.ws = ws; + ws.onopen = () => { + ws.send(JSON.stringify({ t: "hello", token: this.token, v: 1, lang: this.lang })); + }; + ws.onmessage = (ev) => { + if (typeof ev.data !== "string") { + this.onBinary(new Uint8Array(ev.data as ArrayBuffer)); + return; + } + let msg: Record; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + if (msg.t === "hello.ok") { + this.retry = 0; + this.helloOk = msg; + this.setState("open"); + const w = this.helloWaiters.splice(0); + for (const fn of w) fn(msg); + // Restore subscriptions after reconnecting + for (const [ch, s] of this.subs) { + void this.call("sub.add", { ch, afterSeq: s.afterSeq?.() }).catch(() => {}); + } + return; + } + if (msg.t === "res") { + const id = msg.id as number; + const p = this.pending.get(id); + if (p) { + this.pending.delete(id); + clearTimeout(p.timer); + if (msg.ok) p.res(msg.r); + else { + const e = msg.e as { code?: string; message?: string } | undefined; + const err = new Error(e?.message ?? t("api.requestFailed")); + (err as { code?: string }).code = e?.code; + p.rej(err); + } + } + return; + } + if (msg.t === "evt") { + const sub = this.subs.get(msg.ch as string); + sub?.handler(msg.ch as string, msg.d); + return; + } + }; + ws.onclose = () => { + this.ws = null; + // Reject all pending requests + for (const [id, p] of this.pending) { + clearTimeout(p.timer); + p.rej(new Error("连接中断")); + this.pending.delete(id); + } + if (this.closed) return; + this.retry++; + const delay = Math.min(1000 * Math.pow(2, this.retry - 1), 10000); + this.setState(this.retry > 20 ? "down" : "reconnecting"); + setTimeout(() => this.connect(), delay); + }; + ws.onerror = () => {}; + } + + waitHello(): Promise> { + if (this.helloOk) return Promise.resolve(this.helloOk); + return new Promise((res) => this.helloWaiters.push(res)); + } + + call(method: string, params?: unknown, timeoutMs = 60000): Promise { + return new Promise((res, rej) => { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) { + rej(new Error("连接未建立")); + return; + } + const id = ++this.seq; + const timer = setTimeout(() => { + this.pending.delete(id); + rej(new Error("请求超时")); + }, timeoutMs); + this.pending.set(id, { res, rej, timer }); + ws.send(JSON.stringify({ t: "req", id, m: method, p: params ?? {} })); + }); + } + + subscribe(ch: string, handler: EvtHandler, afterSeq?: () => number | undefined): () => void { + this.subs.set(ch, { handler, afterSeq }); + if (this.state === "open") { + void this.call("sub.add", { ch, afterSeq: afterSeq?.() }).catch(() => {}); + } + return () => { + this.subs.delete(ch); + if (this.state === "open") void this.call("sub.remove", { ch }).catch(() => {}); + }; + } + + onBinaryFrame(fn: BinHandler): () => void { + this.binHandlers.push(fn); + return () => { + this.binHandlers = this.binHandlers.filter((f) => f !== fn); + }; + } + private onBinary(data: Uint8Array): void { + if (data.length < 3) return; + const op = data[0]; + const index = (data[1] << 8) | data[2]; + for (const f of this.binHandlers) f(op, index, data.subarray(3)); + } + sendBinary(op: number, index: number, payload: Uint8Array): void { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const frame = new Uint8Array(3 + payload.length); + frame[0] = op; + frame[1] = (index >> 8) & 0xff; + frame[2] = index & 0xff; + frame.set(payload, 3); + ws.send(frame); + } + + ping(): void { + try { + this.ws?.send(JSON.stringify({ t: "ping" })); + } catch {} + } + + close(): void { + this.closed = true; + this.ws?.close(); + } +} diff --git a/packages/agent-console/web/src/components/Chat.tsx b/packages/agent-console/web/src/components/Chat.tsx new file mode 100644 index 00000000..b45278a5 --- /dev/null +++ b/packages/agent-console/web/src/components/Chat.tsx @@ -0,0 +1,225 @@ +// Conversation area (§8.6): virtual list, stick-to-bottom, empty state, queue list, Composer. +import { useCallback, useEffect, useRef, useState } from "react"; +import { isImeComposing } from "../composer/ime"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useUi } from "../stores/ui"; +import { useData, pickDefaultProvider } from "../stores/data"; +import { useTimeline } from "../stores/timeline"; +import { TimelineRow } from "./Timeline"; +import { Composer } from "./Composer"; +import { Icon } from "./Icons"; +import { ws } from "../main"; +import { toast } from "./Toast"; +import { useLiveSettings } from "./Settings"; +import { errMsg, localizeDaemonMessage, t as tr, tf } from "../i18n/index.js"; + +export function Chat() { + const ui = useUi(); + const { projects, threads } = useData(); + const tl = useTimeline(); + const prefs = useLiveSettings(); + const items = prefs.showThinking ? tl.items : tl.items.filter((it) => it.kind !== "reasoning"); + const scrollRef = useRef(null); + const [stick, setStick] = useState(true); + const [hasNew, setHasNew] = useState(false); + + const project = projects.find((p) => p.id === ui.currentProjectId) ?? null; + const threadId = ui.currentThreadId; + const thread = threadId ? threads.find((th) => th.id === threadId) ?? null : null; + const activeThreadId = + project && thread && thread.projectId === project.id ? thread.id : null; + + useEffect(() => { + if (activeThreadId) tl.open(activeThreadId); + else tl.close(); + setStick(true); + setHasNew(false); + }, [activeThreadId]); // eslint-disable-line react-hooks/exhaustive-deps + + const est = useCallback((i: number) => { + const it = items[i]; + if (!it) return 80; + switch (it.kind) { + case "user": return 60; + case "assistant": case "reasoning": return 120; + case "toolgroup": return 30 + it.steps.length * 26; + case "plan": return 200; + case "permission": case "question": return 120; + case "todo": return 30; + case "turnend": case "notice": return 30; + default: return 60; + } + }, [items]); + + const virt = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: est, + getItemKey: (i) => items[i]?.key ?? i, + overscan: 8, + measureElement: (el) => el.getBoundingClientRect().height, + }); + + const count = items.length; + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + if (stick) el.scrollTop = el.scrollHeight; + else setHasNew(true); + }, [count, stick]); + + const onScroll = () => { + const el = scrollRef.current; + if (!el) return; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60; + setStick(nearBottom); + if (nearBottom) setHasNew(false); + }; + + const jump = () => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); + setStick(true); + setHasNew(false); + }; + + const resend = (text: string) => { + if (!activeThreadId) return; + void ws.call("thread.send", { id: activeThreadId, text }); + }; + const retryLast = () => { + if (tl.lastUserText) resend(tl.lastUserText); + }; + + if (projects.length === 0) { + return ( +
+ +
+ ); + } + + if (!activeThreadId) { + return ( +
+
+ {project ? ( + <> +
{tf("chat.newInProject", { name: project.name })}
+ + + ) : ( + + )} +
+
+ ); + } + + return ( + <> +
+
+
+ {virt.getVirtualItems().map((v) => { + const it = items[v.index]; + if (!it) return null; + return ( +
+ +
+ ); + })} +
+ {tl.items.length === 0 && !tl.error && ( +
{tr("chat.emptyHint")}
+ )} + {tl.error &&
{localizeDaemonMessage(tl.error)}
} +
+
+ {!stick && ( + + )} + + + ); +} + +/** No project / nothing selected: centered onboarding matching the sidebar's empty state, no Composer at the bottom */ +function WorkspaceEmpty({ kind }: { kind: "no-projects" }) { + void kind; + return ( +
+
{tr("sidebar.noProjectsTitle")}
+
{tr("sidebar.noProjectsSub")}
+
+ +
+
+ ); +} + +function EmptyComposer({ projectId }: { projectId: string }) { + const ui = useUi(); + const prefs = useLiveSettings(); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + + const send = async () => { + if (!text.trim() || busy) return; + setBusy(true); + try { + const provider = await pickDefaultProvider(); + const s = (await ws.call("settings.get")) as { defaultMode?: string; defaultEffort?: string | null }; + const created = (await ws.call("threads.create", { + projectId, provider, model: null, + mode: s.defaultMode ?? "default", effort: s.defaultEffort ?? null, + })) as { id: string }; + ui.select(projectId, created.id); + await ws.call("thread.send", { id: created.id, text: text.trim() }); + } catch (e) { + toast(tr("chat.failSend") + errMsg(e), true); + setBusy(false); + } + }; + + return ( +
+